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-28 17:53:56 +01:00
committed by paul
parent 8c87f1537b
commit 68a9dc5749
14 changed files with 1039 additions and 509 deletions
+33 -27
View File
@@ -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})`);
+182
View File
@@ -0,0 +1,182 @@
const ffmpeg = require('fluent-ffmpeg');
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
// Set FFmpeg path
ffmpeg.setFfmpegPath(ffmpegPath);
/**
* Extract video metadata using FFmpeg
* @param {string} videoPath - Path to the video file
* @returns {Promise<Object>} - Video metadata
*/
async function extractVideoMetadata(videoPath) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(videoPath, (err, metadata) => {
if (err) {
logger.error('Error extracting video metadata', { error: err.message, videoPath });
return reject(err);
}
try {
const videoStream = metadata.streams.find(s => s.codec_type === 'video');
const audioStream = metadata.streams.find(s => s.codec_type === 'audio');
const result = {
duration: Math.floor(metadata.format.duration || 0),
width: videoStream?.width || null,
height: videoStream?.height || null,
videoCodec: videoStream?.codec_name || null,
audioCodec: audioStream?.codec_name || null,
size: metadata.format.size || 0,
bitrate: metadata.format.bit_rate || null,
format: metadata.format.format_name || null
};
resolve(result);
} catch (parseErr) {
logger.error('Error parsing video metadata', { error: parseErr.message });
reject(parseErr);
}
});
});
}
/**
* Generate thumbnail from video
* @param {string} videoPath - Path to the video file
* @param {string} outputPath - Path for the output thumbnail
* @param {Object} options - Thumbnail options
* @returns {Promise<string>} - Path to generated thumbnail
*/
async function generateVideoThumbnail(videoPath, outputPath, options = {}) {
const {
timeOffset = '00:00:01', // Take screenshot at 1 second
size = '300x300',
quality = 2 // 1-31, lower is better quality
} = options;
return new Promise((resolve, reject) => {
ffmpeg(videoPath)
.screenshots({
timestamps: [timeOffset],
filename: path.basename(outputPath),
folder: path.dirname(outputPath),
size: size
})
.on('end', () => {
logger.info('Video thumbnail generated', { videoPath, outputPath });
resolve(outputPath);
})
.on('error', (err) => {
logger.error('Error generating video thumbnail', { error: err.message, videoPath });
reject(err);
});
});
}
/**
* Validate that a file is a valid video
* @param {string} videoPath - Path to the video file
* @returns {Promise<boolean>} - True if valid video
*/
async function isValidVideo(videoPath) {
try {
const metadata = await extractVideoMetadata(videoPath);
return metadata.duration > 0 && metadata.width > 0 && metadata.height > 0;
} catch (error) {
logger.error('Video validation failed', { error: error.message, videoPath });
return false;
}
}
/**
* Get video duration in seconds
* @param {string} videoPath - Path to the video file
* @returns {Promise<number>} - Duration in seconds
*/
async function getVideoDuration(videoPath) {
try {
const metadata = await extractVideoMetadata(videoPath);
return metadata.duration;
} catch (error) {
logger.error('Error getting video duration', { error: error.message });
return 0;
}
}
/**
* Process uploaded video - extract metadata and generate thumbnail
* @param {string} videoPath - Path to the video file
* @param {string} thumbnailPath - Path for the thumbnail
* @param {Object} options - Processing options
* @returns {Promise<Object>} - Video metadata and processing result
*/
async function processUploadedVideo(videoPath, thumbnailPath, options = {}) {
try {
// Validate video
const isValid = await isValidVideo(videoPath);
if (!isValid) {
throw new Error('Invalid video file');
}
// Extract metadata
const metadata = await extractVideoMetadata(videoPath);
// Generate thumbnail
await generateVideoThumbnail(videoPath, thumbnailPath, options);
// Verify thumbnail was created
try {
await fs.access(thumbnailPath);
} catch (err) {
throw new Error('Thumbnail generation failed');
}
return {
success: true,
metadata,
thumbnailPath
};
} catch (error) {
logger.error('Error processing video', { error: error.message, videoPath });
throw error;
}
}
/**
* Get video thumbnail at specific time
* @param {string} videoPath - Path to video file
* @param {string} outputPath - Output path for thumbnail
* @param {number} timeInSeconds - Time in seconds to capture thumbnail
* @returns {Promise<string>} - Path to thumbnail
*/
async function getThumbnailAtTime(videoPath, outputPath, timeInSeconds = 1) {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const timeOffset = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
return generateVideoThumbnail(videoPath, outputPath, { timeOffset });
}
/**
* Check if file is a video based on MIME type
* @param {string} mimeType - MIME type of the file
* @returns {boolean} - True if video MIME type
*/
function isVideoMimeType(mimeType) {
return mimeType && mimeType.startsWith('video/');
}
module.exports = {
extractVideoMetadata,
generateVideoThumbnail,
isValidVideo,
getVideoDuration,
processUploadedVideo,
getThumbnailAtTime,
isVideoMimeType
};