Files
picpeak/backend/src/services/photoProcessor.js
T
Claude 68a9dc5749 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
2025-11-28 17:53:56 +01:00

266 lines
8.8 KiB
JavaScript

const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { generateThumbnail } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
function normalizeFiles(files) {
// Handle null, undefined, or falsy values
if (!files) {
console.log('[normalizeFiles] No files provided');
return [];
}
// Handle arrays
if (Array.isArray(files)) {
const validFiles = files.filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from array`);
return validFiles;
}
// Handle iterable objects (some multer configurations)
try {
if (typeof files === 'object' && typeof files[Symbol.iterator] === 'function') {
const validFiles = Array.from(files).filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from iterable`);
return validFiles;
}
} catch (err) {
console.warn('[normalizeFiles] Failed to iterate files object:', err.message);
}
// Handle plain objects (multer fieldname mapping)
if (typeof files === 'object') {
try {
const validFiles = Object.values(files)
.flatMap((value) => (Array.isArray(value) ? value : [value]))
.filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from object`);
return validFiles;
} catch (err) {
console.warn('[normalizeFiles] Failed to process files object:', err.message);
return [];
}
}
// Unexpected type
console.warn('[normalizeFiles] Unexpected files type:', typeof files);
return [];
}
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
const uploadedPhotos = [];
const fileList = normalizeFiles(files);
if (fileList.length === 0) {
return uploadedPhotos;
}
// Get event details
const event = await db('events').where({ id: eventId }).first();
if (!event) {
throw new Error('Event not found');
}
// Process each file
for (const file of fileList) {
const trx = await db.transaction();
try {
// Count existing photos to generate sequence number
let counter = 1;
let photoType = 'individual'; // default type
// If categoryId is provided and matches photo types, use it as type
if (categoryId === 'collage') {
photoType = 'collage';
}
// Count existing photos of the same type for numbering
const existingCount = await trx('photos')
.where({ event_id: eventId, type: photoType })
.count('id as count')
.first();
const existingCountValue = Number(existingCount?.count ?? 0);
counter = existingCountValue + 1;
// Generate new filename
const extension = path.extname(file.originalname);
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
const newFilename = generatePhotoFilename(
event.event_name,
categoryName,
counter,
extension
);
// Move file to event folder
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
await fs.mkdir(destPath, { recursive: true });
const newPath = path.join(destPath, newFilename);
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
if (!tempPath) {
const fileInfo = JSON.stringify({
originalname: file?.originalname,
mimetype: file?.mimetype,
size: file?.size,
availableKeys: Object.keys(file || {})
});
throw new Error(`Uploaded file is missing a temporary path. File info: ${fileInfo}`);
}
// Verify temp file exists before copying
try {
await fs.access(tempPath);
} catch (accessErr) {
console.error(`Temp file not accessible: ${tempPath}`, {
originalname: file?.originalname,
error: accessErr.message
});
throw new Error(`Uploaded file not found at temporary location: ${tempPath}`);
}
// Use copyFile and unlink instead of rename to avoid cross-device issues
try {
await fs.copyFile(tempPath, newPath);
console.log(`Successfully copied ${file.originalname} to ${newPath}`);
} catch (copyErr) {
console.error(`Failed to copy file from ${tempPath} to ${newPath}:`, copyErr);
throw new Error(`Failed to copy uploaded file: ${copyErr.message}`);
} finally {
// Clean up temp file with better error handling
try {
await fs.unlink(tempPath);
console.log(`Cleaned up temp file: ${tempPath}`);
} catch (unlinkErr) {
// Only warn if file exists but couldn't be deleted
// ENOENT means file was already deleted, which is fine
if (unlinkErr?.code !== 'ENOENT') {
console.warn(`Failed to clean up temp upload ${tempPath}:`, {
error: unlinkErr.message,
code: unlinkErr.code
});
}
}
}
// 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')}`);
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 and media metadata
let insertResult;
const clientName = trx?.client?.config?.client;
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
const photoData = {
event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed',
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)
.returning('id');
} else {
insertResult = await trx('photos').insert(photoData);
}
const insertedId = Array.isArray(insertResult)
? (insertResult[0]?.id ?? insertResult[0])
: insertResult;
const photoId = typeof insertedId === 'object' ? insertedId.id : insertedId;
if (photoId === undefined || photoId === null) {
throw new Error('Failed to determine inserted photo ID');
}
// Commit transaction
await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: newFilename,
size: file.size,
type: photoType
});
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, {
error: error.message,
stack: error.stack,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
tempPath: file?.path || file?.filepath || file?.tempFilePath
});
if (trx) {
try {
await trx.rollback();
} catch (rollbackErr) {
console.error('Failed to rollback transaction:', rollbackErr);
}
}
// Continue with other files
// Note: Individual file failures don't stop the entire upload batch
}
}
return uploadedPhotos;
}
module.exports = {
processUploadedPhotos
};