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:
@@ -1,11 +1,9 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
|
||||
const { processUploadedVideo } = require('./videoProcessor');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
const mime = require('mime-types');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -73,15 +71,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream';
|
||||
const isVideo = isVideoMimeType(resolvedMime, file?.originalname);
|
||||
|
||||
// Count existing photos to generate sequence number
|
||||
let counter = 1;
|
||||
let photoType = isVideo ? 'video' : 'individual'; // default type
|
||||
let photoType = 'individual'; // default type
|
||||
|
||||
// If categoryId is provided and matches photo types, use it as type
|
||||
if (!isVideo && categoryId === 'collage') {
|
||||
if (categoryId === 'collage') {
|
||||
photoType = 'collage';
|
||||
}
|
||||
|
||||
@@ -96,7 +91,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual');
|
||||
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
categoryName,
|
||||
@@ -156,31 +151,34 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thumbnail and metadata
|
||||
let thumbnailPath = null;
|
||||
// Determine if this is a video or image
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`);
|
||||
try {
|
||||
const result = await processUploadedVideo(newPath, videoThumbnailPath);
|
||||
videoMetadata = result?.metadata || null;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} catch (videoErr) {
|
||||
console.error('Failed to process uploaded video, falling back to placeholder:', videoErr.message);
|
||||
thumbnailPath = await generateVideoPlaceholder(newFilename);
|
||||
}
|
||||
|
||||
const result = await processUploadedVideo(newPath, videoThumbnailPath);
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
}
|
||||
|
||||
|
||||
// Calculate relative paths
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
|
||||
// Add to database with uploaded_by field
|
||||
|
||||
// Add to database with uploaded_by field and media metadata
|
||||
let insertResult;
|
||||
const clientName = trx?.client?.config?.client;
|
||||
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
|
||||
@@ -194,10 +192,19 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy,
|
||||
source_origin: 'managed',
|
||||
mime_type: resolvedMime,
|
||||
media_type: isVideo ? 'video' : 'photo'
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
// Add video-specific metadata if applicable
|
||||
if (isVideo && videoMetadata) {
|
||||
photoData.duration = videoMetadata.duration;
|
||||
photoData.video_codec = videoMetadata.videoCodec;
|
||||
photoData.audio_codec = videoMetadata.audioCodec;
|
||||
photoData.width = videoMetadata.width;
|
||||
photoData.height = videoMetadata.height;
|
||||
}
|
||||
|
||||
if (supportsReturning) {
|
||||
insertResult = await trx('photos')
|
||||
.insert(photoData)
|
||||
@@ -223,8 +230,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
id: photoId,
|
||||
filename: newFilename,
|
||||
size: file.size,
|
||||
type: photoType,
|
||||
media_type: isVideo ? 'video' : 'photo'
|
||||
type: photoType
|
||||
});
|
||||
|
||||
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
|
||||
|
||||
Reference in New Issue
Block a user