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
+59 -26

No files matched your search

+19 -3
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";
@@ -273,9 +274,24 @@ 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}
+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');
}
+15 -18
View File
@@ -32,6 +32,7 @@ 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";
@@ -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,12 +78,11 @@ 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
@@ -212,8 +211,8 @@ export default function NewPost() {
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(",") || [],
@@ -537,20 +536,18 @@ 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
+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">