Add comprehensive video support to galleries

This commit implements full video upload, storage, streaming, and playback functionality
for the PicPeak photo sharing platform, allowing users to upload and view videos alongside
photos in galleries.

Backend Changes:
- Added video processing dependencies (fluent-ffmpeg, @ffmpeg-installer/ffmpeg)
- Created videoProcessor.js service for video metadata extraction and thumbnail generation
- Updated photoProcessor.js to handle both images and videos
- Modified adminPhotos.js to accept video files with 500MB size limit
- Enhanced gallery.js with HTTP range request support for video streaming
- Expanded fileSecurityUtils.js with video MIME types and magic number validation
- Added database migration for video support columns (media_type, duration, codecs, dimensions)

Frontend Changes:
- Updated TypeScript types to include video metadata fields
- Created VideoPlayer.tsx component with custom controls
- Modified PhotoUpload.tsx to accept video files (.mp4, .webm, .mov, .avi)
- Updated UserPhotoUpload.tsx for guest video uploads
- Enhanced PhotoGrid.tsx with video badges and duration display
- Modified PhotoLightbox.tsx to conditionally render VideoPlayer for videos

Database Schema:
- Added media_type column ('image' | 'video')
- Added mime_type, duration, video_codec, audio_codec columns
- Added width and height columns for media dimensions
- Migrated existing photos to media_type 'image'

Features:
- Video thumbnail generation from video frames
- Streaming support with range requests for efficient playback
- Video duration display on thumbnails
- Play button indicators on video items
- Full-featured video player with playback controls
- Support for MP4, WebM, MOV, and AVI formats
This commit is contained in:
Claude
2025-11-04 20:57:51 +00:00
committed by paul
parent 8c87f1537b
commit 68a9dc5749
14 changed files with 1039 additions and 509 deletions
+12 -24
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react';
import { Upload, X, Image, Loader2, Video } from 'lucide-react';
import { Upload, X, Image, Loader2 } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
import { api } from '../../config/api';
@@ -51,18 +51,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
const allowedFiles = files.filter(file => allowedTypes.includes(file.type));
const rejectedFiles = files.filter(file => !allowedTypes.includes(file.type));
if (rejectedFiles.length > 0) {
toast.error(
t('upload.unsupportedFiles', 'Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).')
);
}
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
);
// Check total file count with existing files
const totalFiles = selectedFiles.length + allowedFiles.length;
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
if (allowedNewFiles <= 0) {
@@ -76,11 +70,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
);
setSelectedFiles(prev => [...prev, ...allowedFiles.slice(0, allowedNewFiles)]);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...allowedFiles]);
setSelectedFiles(prev => [...prev, ...imageFiles]);
};
const removeFile = (index: number) => {
@@ -192,7 +186,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('upload.mediaCategory', 'Media category')}
{t('upload.photoCategory')}
</label>
<select
value={selectedCategoryId || ''}
@@ -222,7 +216,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')}
</p>
<p className="text-sm text-neutral-500">
{t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })}
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
</p>
<p
className={clsx(
@@ -242,7 +236,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
ref={fileInputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
onChange={handleFileSelect}
className="hidden"
/>
@@ -261,11 +255,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
>
<div className="flex items-center gap-3">
{file.type.startsWith('video/') ? (
<Video className="w-5 h-5 text-neutral-400" />
) : (
<Image className="w-5 h-5 text-neutral-400" />
)}
<Image className="w-5 h-5 text-neutral-400" />
<div>
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
{file.name}
@@ -298,9 +288,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
disabled={selectedFiles.length === 0 || isUploading}
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
>
{isUploading
? t('upload.uploading')
: t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`}
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
</Button>
</div>
+17 -6
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, Package, MessageSquare, Star } from 'lucide-react';
import { Download, Maximize2, Check, Package, MessageSquare, Star, Play } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -336,14 +336,25 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
</div>
)}
{/* Photo type badge */}
{photo.type === 'collage' && (
<div className="absolute bottom-2 left-2">
{/* Media type badges */}
<div className="absolute bottom-2 left-2 flex gap-2">
{photo.type === 'collage' && (
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage
</span>
</div>
)}
)}
{photo.media_type === 'video' && (
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
<Play className="w-3 h-3" fill="white" />
Video
{photo.duration && (
<span className="ml-1">
{Math.floor(photo.duration / 60)}:{String(photo.duration % 60).padStart(2, '0')}
</span>
)}
</span>
)}
</div>
</>
) : (
<div className="skeleton aspect-square w-full" />
@@ -3,10 +3,11 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage, AuthenticatedVideo } from '../common';
import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { VideoPlayer } from './VideoPlayer';
interface PhotoLightboxProps {
photos: Photo[];
@@ -64,11 +65,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === 'video' ||
(currentPhoto.mime_type && currentPhoto.mime_type.startsWith('video/')) ||
currentPhoto.type === 'video')
: false;
// DevTools protection for the lightbox when enhanced protection is enabled
useDevToolsProtection({
@@ -366,31 +362,27 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div>
<div className="flex items-center gap-2">
{!isVideo && (
<>
<button
onClick={handleZoomOut}
disabled={zoom <= 1}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Zoom out"
>
<ZoomOut className="w-5 h-5 text-white" />
</button>
<span className="text-white text-sm w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
disabled={zoom >= 3}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Zoom in"
>
<ZoomIn className="w-5 h-5 text-white" />
</button>
<div className="w-px h-6 bg-white/20 mx-2" />
</>
)}
<button
onClick={handleZoomOut}
disabled={zoom <= 1}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Zoom out"
>
<ZoomOut className="w-5 h-5 text-white" />
</button>
<span className="text-white text-sm w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
disabled={zoom >= 3}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Zoom in"
>
<ZoomIn className="w-5 h-5 text-white" />
</button>
<div className="w-px h-6 bg-white/20 mx-2" />
{allowDownloads && (
<button
@@ -457,29 +449,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div>
</div>
{/* Image container */}
{/* Image/Video container */}
<div
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
onClick={isVideo ? undefined : handleImageClick}
onMouseDown={isVideo ? undefined : handleMouseDown}
onMouseMove={isVideo ? undefined : handleMouseMove}
onMouseUp={isVideo ? undefined : handleMouseUp}
onMouseLeave={isVideo ? undefined : handleMouseUp}
onTouchStart={isVideo ? undefined : handleTouchStart}
onTouchMove={isVideo ? undefined : handleTouchMove}
onTouchEnd={isVideo ? undefined : handleTouchEnd}
onClick={currentPhoto.media_type === 'video' ? undefined : handleImageClick}
onMouseDown={currentPhoto.media_type === 'video' ? undefined : handleMouseDown}
onMouseMove={currentPhoto.media_type === 'video' ? undefined : handleMouseMove}
onMouseUp={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
onMouseLeave={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
onTouchStart={currentPhoto.media_type === 'video' ? undefined : handleTouchStart}
onTouchMove={currentPhoto.media_type === 'video' ? undefined : handleTouchMove}
onTouchEnd={currentPhoto.media_type === 'video' ? undefined : handleTouchEnd}
style={{
cursor: isVideo ? 'default' : zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
cursor: currentPhoto.media_type === 'video' ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'),
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
>
{isVideo ? (
<AuthenticatedVideo
{currentPhoto.media_type === 'video' ? (
<VideoPlayer
src={currentPhoto.url}
fallbackSrc={currentPhoto.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain bg-black"
slug={slug}
poster={currentPhoto.thumbnail_url || undefined}
poster={currentPhoto.thumbnail_url}
className="max-w-full max-h-full"
controls={true}
autoPlay={false}
/>
) : (
<AuthenticatedImage
@@ -509,24 +501,24 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
photoId: currentPhoto.id,
violationType,
protectionLevel,
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose();
}
}}
/>
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
photoId: currentPhoto.id,
violationType,
protectionLevel,
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose();
}
}}
/>
)}
</div>
@@ -143,7 +143,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
type="file"
className="hidden"
multiple
accept="image/jpeg,image/png,image/webp"
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
onChange={handleFileSelect}
disabled={uploading}
/>
@@ -0,0 +1,232 @@
import React, { useRef, useState, useEffect } from 'react';
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react';
interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
autoPlay?: boolean;
muted?: boolean;
loop?: boolean;
controls?: boolean;
width?: string | number;
height?: string | number;
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
src,
poster,
className = '',
autoPlay = false,
muted = false,
loop = false,
controls = true,
width = '100%',
height = 'auto'
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(muted);
const [isFullscreen, setIsFullscreen] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showControls, setShowControls] = useState(true);
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
setProgress((video.currentTime / video.duration) * 100 || 0);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleEnded = () => setIsPlaying(false);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
};
}, []);
const togglePlayPause = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
video.play();
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(!isMuted);
};
const toggleFullscreen = async () => {
const video = videoRef.current;
if (!video) return;
try {
if (!isFullscreen) {
if (video.requestFullscreen) {
await video.requestFullscreen();
}
setIsFullscreen(true);
} else {
if (document.exitFullscreen) {
await document.exitFullscreen();
}
setIsFullscreen(false);
}
} catch (error) {
console.error('Error toggling fullscreen:', error);
}
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current;
if (!video) return;
const rect = e.currentTarget.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
video.currentTime = pos * video.duration;
};
const formatTime = (seconds: number): string => {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleMouseMove = () => {
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) {
setShowControls(false);
}
}, 3000);
};
useEffect(() => {
return () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
}, []);
return (
<div
className={`relative bg-black rounded-lg overflow-hidden ${className}`}
style={{ width, height: height === 'auto' ? undefined : height }}
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
<video
ref={videoRef}
src={src}
poster={poster}
autoPlay={autoPlay}
muted={muted}
loop={loop}
className="w-full h-full object-contain"
playsInline
onClick={togglePlayPause}
/>
{controls && (
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
showControls ? 'opacity-100' : 'opacity-0'
}`}
>
{/* Progress bar */}
<div
className="w-full h-1 bg-gray-600 rounded-full cursor-pointer mb-3"
onClick={handleProgressClick}
>
<div
className="h-full bg-white rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
{/* Controls */}
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-3">
<button
onClick={togglePlayPause}
className="hover:bg-white/20 p-2 rounded-full transition-colors"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
</button>
<button
onClick={toggleMute}
className="hover:bg-white/20 p-2 rounded-full transition-colors"
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
<span className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<button
onClick={toggleFullscreen}
className="hover:bg-white/20 p-2 rounded-full transition-colors"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
</button>
</div>
</div>
)}
{/* Play button overlay when paused */}
{!isPlaying && showControls && (
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={togglePlayPause}
className="bg-black/50 hover:bg-black/70 text-white rounded-full p-6 transition-colors"
aria-label="Play"
>
<Play size={48} fill="white" />
</button>
</div>
)}
</div>
);
};
export default VideoPlayer;
+8
View File
@@ -63,6 +63,14 @@ export interface Photo {
category_slug?: string;
size: number;
uploaded_at: string;
// Media type fields
media_type?: 'image' | 'video';
mime_type?: string;
duration?: number; // Duration in seconds for videos
video_codec?: string;
audio_codec?: string;
width?: number;
height?: number;
// Feedback fields
has_feedback?: boolean;
average_rating?: number;