feat: implement video thumbnail generation from Cloudinary URLs

This commit is contained in:
Michael committed 2026-02-10 10:11:33 +01:00
1 parent 58f513d476
commit a35b9e3d1c
4 files changed
+101 -68

No files matched your search

+27 -11
View File
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { getVideoThumbnailUrl } from "@/lib/media-utils";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react"; import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram } from "react-icons/si";
@@ -41,7 +42,7 @@ export default function MediaUpload() {
const onDrop = useCallback(async (acceptedFiles: File[]) => { const onDrop = useCallback(async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return; if (acceptedFiles.length === 0) return;
setUploadingCount(acceptedFiles.length); setUploadingCount(acceptedFiles.length);
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
@@ -67,7 +68,7 @@ export default function MediaUpload() {
try { try {
await Promise.all(uploadPromises); await Promise.all(uploadPromises);
queryClient.invalidateQueries({ queryKey: ["/api/media"] }); queryClient.invalidateQueries({ queryKey: ["/api/media"] });
toast({ toast({
title: "Succès", title: "Succès",
description: `${successCount} média(s) téléchargé(s) avec succès`, description: `${successCount} média(s) téléchargé(s) avec succès`,
@@ -186,7 +187,7 @@ export default function MediaUpload() {
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => cameraInputRef.current?.click()} onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
variant="outline" variant="outline"
@@ -195,7 +196,7 @@ export default function MediaUpload() {
> >
<Camera className="w-4 h-4" /> <Camera className="w-4 h-4" />
</Button> </Button>
<Button <Button
onClick={open} onClick={open}
className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl" className="bg-gradient-to-r from-primary to-secondary hover:opacity-90 rounded-xl"
data-testid="button-browse" data-testid="button-browse"
@@ -273,12 +274,27 @@ export default function MediaUpload() {
data-testid={`media-item-${media.id}`} data-testid={`media-item-${media.id}`}
> >
{media.type === "video" ? ( {media.type === "video" ? (
<div className="w-full h-full bg-muted/30 flex items-center justify-center"> <>
<Video className="w-12 h-12 text-secondary" /> <img
</div> src={getVideoThumbnailUrl(media.originalUrl)}
alt={media.fileName}
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
// Fallback: replace broken img with Video icon
const target = e.currentTarget;
target.style.display = 'none';
const fallback = target.parentElement?.querySelector('.video-fallback');
if (fallback) (fallback as HTMLElement).style.display = 'flex';
}}
/>
<div className="video-fallback w-full h-full bg-muted/30 flex items-center justify-center absolute inset-0" style={{ display: 'none' }}>
<Video className="w-12 h-12 text-secondary" />
</div>
</>
) : ( ) : (
<img <img
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="lazy" loading="lazy"
@@ -311,7 +327,7 @@ export default function MediaUpload() {
</div> </div>
))} ))}
</div> </div>
{/* Élément sentinelle pour le scroll infini */} {/* Élément sentinelle pour le scroll infini */}
{(mediaList && visibleCount < (mediaList as any[]).length) ? ( {(mediaList && visibleCount < (mediaList as any[]).length) ? (
<div ref={loadMoreRef} className="flex justify-center py-4 mt-3"> <div ref={loadMoreRef} className="flex justify-center py-4 mt-3">
@@ -324,7 +340,7 @@ export default function MediaUpload() {
<div className="space-y-6"> <div className="space-y-6">
<h4 className="text-sm font-semibold text-foreground">Aperçu et recadrage automatique</h4> <h4 className="text-sm font-semibold text-foreground">Aperçu et recadrage automatique</h4>
<div className="space-y-5"> <div className="space-y-5">
<div className="border border-border/50 rounded-xl p-5 bg-card shadow-sm"> <div className="border border-border/50 rounded-xl p-5 bg-card shadow-sm">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
+20
View File
@@ -0,0 +1,20 @@
/**
* Derives a Cloudinary video thumbnail URL from the original video URL.
*
* Cloudinary automatically generates image frames for uploaded videos.
* By inserting transformation parameters into the URL, we can extract
* a static JPEG thumbnail without any backend change or DB migration.
*
* @param originalUrl - The Cloudinary video URL (e.g. .../video/upload/v123/file.mp4)
* @returns A JPEG thumbnail URL with fill-crop at 400x400
*/
export function getVideoThumbnailUrl(originalUrl: string): string {
// Only transform Cloudinary video URLs
if (!originalUrl.includes('/video/upload/')) {
return originalUrl;
}
return originalUrl
.replace('/video/upload/', '/video/upload/so_0,w_400,h_400,c_fill/')
.replace(/\.[^.]+$/, '.jpg');
}
+49 -52
View File
@@ -32,19 +32,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { getVideoThumbnailUrl } from "@/lib/media-utils";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
function SortableMediaItem({ function SortableMediaItem({
media, media,
index, index,
isSelected, isSelected,
onToggle onToggle
}: { }: {
media: Media; media: Media;
index: number; index: number;
isSelected: boolean; isSelected: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const { const {
@@ -66,11 +67,10 @@ function SortableMediaItem({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${ className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${isSelected
isSelected
? 'border-primary ring-2 ring-primary' ? 'border-primary ring-2 ring-primary'
: 'border-transparent hover:border-muted-foreground' : 'border-transparent hover:border-muted-foreground'
}`} }`}
> >
<button <button
onClick={onToggle} onClick={onToggle}
@@ -78,16 +78,15 @@ function SortableMediaItem({
data-testid={`button-select-media-${media.id}`} data-testid={`button-select-media-${media.id}`}
> >
{isVideo ? ( {isVideo ? (
<video <img
src={media.originalUrl} src={getVideoThumbnailUrl(media.originalUrl)}
alt={media.fileName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
muted loading="lazy"
playsInline
preload="metadata"
/> />
) : ( ) : (
<img <img
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="lazy" loading="lazy"
@@ -99,7 +98,7 @@ function SortableMediaItem({
<div className="absolute top-1 right-1 w-6 h-6 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-xs font-bold"> <div className="absolute top-1 right-1 w-6 h-6 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-xs font-bold">
{index + 1} {index + 1}
</div> </div>
<div <div
{...attributes} {...attributes}
{...listeners} {...listeners}
className="absolute top-1 left-1 w-8 h-8 bg-black/50 rounded-full flex items-center justify-center cursor-move hover:bg-black/70 transition-colors" className="absolute top-1 left-1 w-8 h-8 bg-black/50 rounded-full flex items-center justify-center cursor-move hover:bg-black/70 transition-colors"
@@ -117,7 +116,7 @@ export default function NewPost() {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const { toast } = useToast(); const { toast } = useToast();
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const [productInfo, setProductInfo] = useState(''); const [productInfo, setProductInfo] = useState('');
const [selectedMedia, setSelectedMedia] = useState<string[]>([]); const [selectedMedia, setSelectedMedia] = useState<string[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]); const [selectedPages, setSelectedPages] = useState<string[]>([]);
@@ -211,9 +210,9 @@ export default function NewPost() {
const generateTextMutation = useMutation({ const generateTextMutation = useMutation({
mutationFn: async (productInfoText: string) => { mutationFn: async (productInfoText: string) => {
const productInfo = { const productInfo = {
name: productInfoText.match(/Produit:\s*(.+?)(?:\n|$)/i)?.[1] || name: productInfoText.match(/Produit:\s*(.+?)(?:\n|$)/i)?.[1] ||
productInfoText.match(/Nom:\s*(.+?)(?:\n|$)/i)?.[1] || productInfoText.match(/Nom:\s*(.+?)(?:\n|$)/i)?.[1] ||
productInfoText, productInfoText,
price: productInfoText.match(/Prix:\s*(.+?)(?:\n|$)/i)?.[1] || "", price: productInfoText.match(/Prix:\s*(.+?)(?:\n|$)/i)?.[1] || "",
description: productInfoText.match(/Description:\s*(.+?)(?:\n|$)/i)?.[1] || "", description: productInfoText.match(/Description:\s*(.+?)(?:\n|$)/i)?.[1] || "",
features: productInfoText.match(/Caractéristiques:\s*(.+?)(?:\n|$)/i)?.[1]?.split(",") || [], features: productInfoText.match(/Caractéristiques:\s*(.+?)(?:\n|$)/i)?.[1]?.split(",") || [],
@@ -240,7 +239,7 @@ export default function NewPost() {
}); });
const createPostMutation = useMutation({ const createPostMutation = useMutation({
mutationFn: (data: any) => mutationFn: (data: any) =>
apiRequest('POST', '/api/posts', data), apiRequest('POST', '/api/posts', data),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
@@ -375,12 +374,12 @@ export default function NewPost() {
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && ( {sidebarOpen && (
<div <div
className="fixed inset-0 bg-black/50 z-40 lg:hidden" className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
/> />
)} )}
<div className={` <div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out 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'} ${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
@@ -390,7 +389,7 @@ export default function NewPost() {
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} /> <TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto"> <div className="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Nouvelle publication</h1> <h1 className="text-3xl font-bold text-foreground">Nouvelle publication</h1>
@@ -409,7 +408,7 @@ export default function NewPost() {
<CardDescription>Sélectionnez ou uploadez une image/vidéo</CardDescription> <CardDescription>Sélectionnez ou uploadez une image/vidéo</CardDescription>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => cameraInputRef.current?.click()} onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
size="sm" size="sm"
@@ -419,7 +418,7 @@ export default function NewPost() {
> >
<Camera className="w-4 h-4" /> <Camera className="w-4 h-4" />
</Button> </Button>
<Button <Button
onClick={open} onClick={open}
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
size="sm" size="sm"
@@ -450,7 +449,7 @@ export default function NewPost() {
{isDragActive ? "Déposez votre photo ici" : "Aucun média disponible"} {isDragActive ? "Déposez votre photo ici" : "Aucun média disponible"}
</p> </p>
<div className="flex gap-2 justify-center"> <div className="flex gap-2 justify-center">
<Button <Button
onClick={() => cameraInputRef.current?.click()} onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
size="sm" size="sm"
@@ -461,7 +460,7 @@ export default function NewPost() {
<Camera className="w-4 h-4 mr-2" /> <Camera className="w-4 h-4 mr-2" />
Prendre une photo Prendre une photo
</Button> </Button>
<Button <Button
onClick={open} onClick={open}
disabled={uploadMutation.isPending} disabled={uploadMutation.isPending}
size="sm" size="sm"
@@ -479,12 +478,12 @@ export default function NewPost() {
<div className="text-sm font-medium text-muted-foreground mb-2"> <div className="text-sm font-medium text-muted-foreground mb-2">
Photos sélectionnées ({selectedMedia.length}/10) Photos sélectionnées ({selectedMedia.length}/10)
</div> </div>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext
items={selectedMedia} items={selectedMedia}
strategy={rectSortingStrategy} strategy={rectSortingStrategy}
> >
@@ -492,7 +491,7 @@ export default function NewPost() {
{selectedMedia.map((mediaId, index) => { {selectedMedia.map((mediaId, index) => {
const media = mediaList.find(m => m.id === mediaId); const media = mediaList.find(m => m.id === mediaId);
if (!media) return null; if (!media) return null;
return ( return (
<SortableMediaItem <SortableMediaItem
key={media.id} key={media.id}
@@ -518,7 +517,7 @@ export default function NewPost() {
{mediaList.map((media) => { {mediaList.map((media) => {
const isSelected = selectedMedia.includes(media.id); const isSelected = selectedMedia.includes(media.id);
const isVideo = media.type === 'video'; const isVideo = media.type === 'video';
return ( return (
<button <button
key={media.id} key={media.id}
@@ -537,24 +536,22 @@ export default function NewPost() {
}); });
} }
}} }}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${ className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${isSelected
isSelected
? 'border-primary ring-2 ring-primary opacity-50' ? 'border-primary ring-2 ring-primary opacity-50'
: 'border-transparent hover:border-muted-foreground' : 'border-transparent hover:border-muted-foreground'
}`} }`}
data-testid={`button-select-media-${media.id}`} data-testid={`button-select-media-${media.id}`}
> >
{isVideo ? ( {isVideo ? (
<video <img
src={media.originalUrl} src={getVideoThumbnailUrl(media.originalUrl)}
alt={media.fileName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
muted loading="lazy"
playsInline
preload="metadata"
/> />
) : ( ) : (
<img <img
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="lazy" loading="lazy"
@@ -593,7 +590,7 @@ export default function NewPost() {
data-testid="input-product-info" data-testid="input-product-info"
/> />
</div> </div>
<Button <Button
onClick={handleGenerateText} onClick={handleGenerateText}
disabled={generateTextMutation.isPending} disabled={generateTextMutation.isPending}
className="w-full" className="w-full"
@@ -613,7 +610,7 @@ export default function NewPost() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{generatedVariants.map((variant, index) => ( {generatedVariants.map((variant, index) => (
<div <div
key={index} key={index}
className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors" className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
> >
@@ -667,8 +664,8 @@ export default function NewPost() {
{pages.length === 0 ? ( {pages.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-muted-foreground mb-2">Aucune page connectée</p> <p className="text-muted-foreground mb-2">Aucune page connectée</p>
<Button <Button
variant="link" variant="link"
onClick={() => navigate('/pages')} onClick={() => navigate('/pages')}
data-testid="link-add-pages" data-testid="link-add-pages"
> >
@@ -754,7 +751,7 @@ export default function NewPost() {
</CardContent> </CardContent>
</Card> </Card>
<Button <Button
onClick={() => setPreviewModalOpen(true)} onClick={() => setPreviewModalOpen(true)}
disabled={createPostMutation.isPending} disabled={createPostMutation.isPending}
className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg" className="w-full bg-gradient-to-r from-primary to-secondary hover:opacity-90 shadow-lg"
@@ -768,7 +765,7 @@ export default function NewPost() {
</div> </div>
</div> </div>
</main> </main>
<Dialog open={uploadMutation.isPending}> <Dialog open={uploadMutation.isPending}>
<DialogContent className="sm:max-w-md [&>button]:hidden"> <DialogContent className="sm:max-w-md [&>button]:hidden">
<div className="flex flex-col items-center justify-center py-8"> <div className="flex flex-col items-center justify-center py-8">
+5 -5
View File
@@ -27,6 +27,7 @@ import {
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
import { getVideoThumbnailUrl } from "@/lib/media-utils";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
interface MusicTrack { interface MusicTrack {
@@ -485,12 +486,11 @@ export default function NewReel() {
: 'border-transparent hover:border-muted-foreground' : 'border-transparent hover:border-muted-foreground'
}`} }`}
> >
<video <img
src={media.originalUrl} src={getVideoThumbnailUrl(media.originalUrl)}
alt={media.fileName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
muted loading="lazy"
playsInline
preload="metadata"
/> />
{selectedVideoId === media.id && ( {selectedVideoId === media.id && (
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center"> <div className="absolute inset-0 bg-primary/20 flex items-center justify-center">