Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s

This commit is contained in:
2025-11-28 13:29:44 +01:00
parent bce5f749b1
commit 9a75f1c929
28 changed files with 1635 additions and 294 deletions
+18 -7
View File
@@ -3,8 +3,10 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const logger = require('../utils/logger');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
@@ -47,9 +49,11 @@ async function processNewPhoto(filePath) {
const eventSlug = pathParts[0];
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
// Check if this is an image file
// Check if this is an image or video file
const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
const detectedMime = mime.lookup(filePath) || '';
const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext);
if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Skip temporary upload files
const filename = path.basename(filePath);
@@ -65,11 +69,17 @@ async function processNewPhoto(filePath) {
// Get file stats
const stats = await fs.stat(filePath);
// Generate thumbnail
const thumbnailPath = await generateThumbnail(filePath);
// Generate thumbnail or placeholder
let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(filename);
} else {
thumbnailPath = await generateThumbnail(filePath);
}
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Check if photo already exists
const existingPhoto = await db('photos')
@@ -83,8 +93,9 @@ async function processNewPhoto(filePath) {
filename: path.basename(filePath),
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: stats.size
type: isVideo ? 'video' : photoType,
size_bytes: stats.size,
mime_type: mimeType
});
logger.info(`Added new photo: ${relativePath}`);
+51 -1
View File
@@ -237,4 +237,54 @@ async function ensureThumbnail(photo) {
return null;
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
async function generateVideoPlaceholder(originalFilename, options = {}) {
const parsed = path.parse(originalFilename || '');
const baseName = parsed.name || 'video';
const thumbnailDir = getThumbnailPath();
const thumbnailFilename = `thumb_${baseName}.jpg`;
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
const settings = await getThumbnailSettings();
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
if (options.regenerate) {
try {
await fs.unlink(thumbnailPath);
} catch (_) {
// ignore if missing
}
}
try {
await fs.mkdir(thumbnailDir, { recursive: true });
const svg = `
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#1e293b" stop-opacity="0.9"/>
</linearGradient>
</defs>
<rect width="${width}" height="${height}" rx="18" fill="url(#grad)"/>
<circle cx="${width / 2}" cy="${height / 2}" r="${Math.min(width, height) / 6}" fill="rgba(255,255,255,0.85)"/>
<polygon points="${width / 2 - 10},${height / 2 - 14} ${width / 2 - 10},${height / 2 + 14} ${width / 2 + 16},${height / 2}" fill="#0f172a"/>
<text x="50%" y="${height - 18}" font-family="Arial, sans-serif" font-size="16" fill="rgba(255,255,255,0.9)" text-anchor="middle">
VIDEO
</text>
</svg>
`;
await sharp(Buffer.from(svg))
.resize(width, height, { fit: 'cover' })
.jpeg({ quality: settings.quality || DEFAULT_THUMBNAIL_QUALITY })
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
logger.error('Failed to generate video placeholder thumbnail:', error.message);
return null;
}
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
+28 -15
View File
@@ -1,8 +1,10 @@
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { generateThumbnail } = require('./imageProcessor');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -70,12 +72,15 @@ 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 = 'individual'; // default type
let photoType = isVideo ? 'video' : 'individual'; // default type
// If categoryId is provided and matches photo types, use it as type
if (categoryId === 'collage') {
if (!isVideo && categoryId === 'collage') {
photoType = 'collage';
}
@@ -90,7 +95,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
// Generate new filename
const extension = path.extname(file.originalname);
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual');
const newFilename = generatePhotoFilename(
event.event_name,
categoryName,
@@ -150,8 +155,13 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
}
}
// Generate thumbnail
const thumbnailPath = await generateThumbnail(newPath);
// Generate thumbnail or placeholder
let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(newFilename);
} else {
thumbnailPath = await generateThumbnail(newPath);
}
// Calculate relative paths
const storagePath = getStoragePath();
@@ -173,20 +183,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
source_origin: 'managed',
mime_type: resolvedMime
})
.returning('id');
} else {
insertResult = await trx('photos').insert({
event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
});
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed',
mime_type: resolvedMime
});
}
const insertedId = Array.isArray(insertResult)
@@ -206,7 +218,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
id: photoId,
filename: newFilename,
size: file.size,
type: photoType
type: photoType,
media_type: isVideo ? 'video' : 'photo'
});
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);