diff --git a/package-lock.json b/package-lock.json index da2f312..73a15b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@tiptap/pm": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", "@tiptap/suggestion": "^2.12.0", + "@types/fluent-ffmpeg": "^2.1.27", "@types/jsonwebtoken": "^9.0.9", "@types/leaflet": "^1.9.18", "@types/multer": "^1.4.12", @@ -45,6 +46,7 @@ "cloudinary": "^2.6.1", "dotenv": "^16.5.0", "exifr": "^7.1.3", + "fluent-ffmpeg": "^2.1.3", "giantbombing-api": "^1.0.4", "gray-matter": "^4.0.3", "ioredis": "^5.4.1", @@ -2872,6 +2874,14 @@ "@types/send": "*" } }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.27.tgz", + "integrity": "sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -3513,6 +3523,11 @@ "node": ">=4" } }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5027,6 +5042,30 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fluent-ffmpeg/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/foreground-child": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", diff --git a/package.json b/package.json index 23290bc..78b6d8c 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@tiptap/pm": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", "@tiptap/suggestion": "^2.12.0", + "@types/fluent-ffmpeg": "^2.1.27", "@types/jsonwebtoken": "^9.0.9", "@types/leaflet": "^1.9.18", "@types/multer": "^1.4.12", @@ -95,6 +96,7 @@ "cloudinary": "^2.6.1", "dotenv": "^16.5.0", "exifr": "^7.1.3", + "fluent-ffmpeg": "^2.1.3", "giantbombing-api": "^1.0.4", "gray-matter": "^4.0.3", "ioredis": "^5.4.1", diff --git a/prisma/migrations/20250823_add_video_metadata/migration.sql b/prisma/migrations/20250823_add_video_metadata/migration.sql new file mode 100644 index 0000000..2189c14 --- /dev/null +++ b/prisma/migrations/20250823_add_video_metadata/migration.sql @@ -0,0 +1,5 @@ +-- Add video metadata fields to Media table +ALTER TABLE "Media" ADD COLUMN IF NOT EXISTS "duration" DOUBLE PRECISION; +ALTER TABLE "Media" ADD COLUMN IF NOT EXISTS "videoCodec" VARCHAR(50); +ALTER TABLE "Media" ADD COLUMN IF NOT EXISTS "audioCodec" VARCHAR(50); +ALTER TABLE "Media" ADD COLUMN IF NOT EXISTS "bitrate" INTEGER; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1358443..1e52383 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,6 +127,10 @@ model Media { dominantColor String? @db.VarChar(7) colors Json? aspectRatio Float? + duration Float? // Video duration in seconds + videoCodec String? @db.VarChar(50) + audioCodec String? @db.VarChar(50) + bitrate Int? // Bitrate in bits per second albums AlbumMedia[] usage MediaUsage[] photos Photo[] diff --git a/src/lib/server/cloudinary.ts b/src/lib/server/cloudinary.ts index 4115c6d..f7ed2ae 100644 --- a/src/lib/server/cloudinary.ts +++ b/src/lib/server/cloudinary.ts @@ -51,7 +51,7 @@ const uploadPresets = { // Image size variants (2025-appropriate sizes) export const imageSizes = { - thumbnail: { width: 800, height: 600, crop: 'fill' as const }, // Much larger thumbnails for modern displays + thumbnail: { width: 1920, crop: 'scale' as const, quality: 'auto:good' as const }, // High-res thumbnails that maintain aspect ratio small: { width: 600, quality: 'auto:good' as const }, medium: { width: 1200, quality: 'auto:good' as const }, large: { width: 1920, quality: 'auto:good' as const }, @@ -71,6 +71,10 @@ export interface UploadResult { dominantColor?: string colors?: any aspectRatio?: number + duration?: number + videoCodec?: string + audioCodec?: string + bitrate?: number error?: string } @@ -81,8 +85,8 @@ export async function uploadFile( customOptions?: any ): Promise { try { - // TEMPORARY: Force Cloudinary usage for testing - const FORCE_CLOUDINARY_IN_DEV = true // Toggle this to test + // Toggle this to use Cloudinary in development (requires API keys) + const FORCE_CLOUDINARY_IN_DEV = false // Set to true to use Cloudinary in dev // Use local storage in development or when Cloudinary is not configured if ((dev && !FORCE_CLOUDINARY_IN_DEV) || !isCloudinaryConfigured()) { @@ -109,7 +113,11 @@ export async function uploadFile( height: localResult.height, format: file.type.split('/')[1], size: localResult.size, - aspectRatio + aspectRatio, + duration: localResult.duration, + videoCodec: localResult.videoCodec, + audioCodec: localResult.audioCodec, + bitrate: localResult.bitrate } } @@ -155,11 +163,21 @@ export async function uploadFile( uploadStream.end(buffer) }) - // Generate thumbnail URL - const thumbnailUrl = cloudinary.url(result.public_id, { - ...imageSizes.thumbnail, - secure: true - }) + // Generate thumbnail URL - different approach for videos vs images + const isVideo = file.type.startsWith('video/') + const thumbnailUrl = isVideo + ? cloudinary.url(result.public_id + '.jpg', { + resource_type: 'video', + transformation: [ + { width: 1920, crop: 'scale', quality: 'auto:good' }, // 'scale' maintains aspect ratio + { start_offset: 'auto' } // Let Cloudinary pick the most interesting frame + ], + secure: true + }) + : cloudinary.url(result.public_id, { + ...imageSizes.thumbnail, + secure: true + }) // Extract dominant color using smart selection let dominantColor: string | undefined @@ -174,6 +192,19 @@ export async function uploadFile( // Calculate aspect ratio const aspectRatio = result.width && result.height ? result.width / result.height : undefined + + // Extract video metadata if present + let duration: number | undefined + let videoCodec: string | undefined + let audioCodec: string | undefined + let bitrate: number | undefined + + if (isVideo && result.duration) { + duration = result.duration + videoCodec = result.video?.codec + audioCodec = result.audio?.codec + bitrate = result.bit_rate + } logger.mediaUpload(file.name, file.size, file.type, true) @@ -189,7 +220,11 @@ export async function uploadFile( size: result.bytes, dominantColor, colors: result.colors, - aspectRatio + aspectRatio, + duration, + videoCodec, + audioCodec, + bitrate } } catch (error) { logger.error('Cloudinary upload failed', error as Error) diff --git a/src/lib/server/local-storage.ts b/src/lib/server/local-storage.ts index 9a856ab..499b80f 100644 --- a/src/lib/server/local-storage.ts +++ b/src/lib/server/local-storage.ts @@ -2,6 +2,7 @@ import { writeFile, mkdir } from 'fs/promises' import { existsSync } from 'fs' import path from 'path' import sharp from 'sharp' +import ffmpeg from 'fluent-ffmpeg' import { logger } from './logger' // Base directory for local uploads @@ -44,6 +45,10 @@ export interface LocalUploadResult { width?: number height?: number size?: number + duration?: number + videoCodec?: string + audioCodec?: string + bitrate?: number error?: string } @@ -58,48 +63,119 @@ export async function uploadFileLocally( // Generate unique filename const filename = generateFilename(file.name) const filepath = path.join(UPLOAD_DIR, type, filename) - const thumbnailPath = path.join(UPLOAD_DIR, 'thumbnails', `thumb-${filename}`) + const thumbnailFilename = `thumb-${filename.replace(path.extname(filename), '')}.jpg` + const thumbnailPath = path.join(UPLOAD_DIR, 'thumbnails', thumbnailFilename) // Convert File to buffer const arrayBuffer = await file.arrayBuffer() const buffer = Buffer.from(arrayBuffer) - // Process image with sharp to get dimensions + // Check if file is a video + const isVideo = file.type.startsWith('video/') + + // Process dimensions and create thumbnail let width = 0 let height = 0 + let duration: number | undefined + let videoCodec: string | undefined + let audioCodec: string | undefined + let bitrate: number | undefined - try { - const image = sharp(buffer) - const metadata = await image.metadata() - width = metadata.width || 0 - height = metadata.height || 0 - - // Save original + if (isVideo) { + // Save video file first await writeFile(filepath, buffer) - // Create thumbnail (800x600 for modern displays) - await image - .resize(800, 600, { - fit: 'cover', - position: 'center' + // Get video metadata and generate thumbnail + await new Promise((resolve, reject) => { + ffmpeg.ffprobe(filepath, (err, metadata) => { + if (err) { + logger.error('Failed to probe video file', err) + reject(err) + return + } + + // Extract video metadata + const videoStream = metadata.streams.find(s => s.codec_type === 'video') + const audioStream = metadata.streams.find(s => s.codec_type === 'audio') + + if (videoStream) { + width = videoStream.width || 0 + height = videoStream.height || 0 + videoCodec = videoStream.codec_name + } + + if (audioStream) { + audioCodec = audioStream.codec_name + } + + duration = metadata.format.duration + bitrate = metadata.format.bit_rate ? parseInt(metadata.format.bit_rate) : undefined + + // Generate thumbnail + ffmpeg(filepath) + .on('end', () => { + logger.info('Video thumbnail generated', { + filename: thumbnailFilename, + duration, + videoCodec, + audioCodec, + bitrate + }) + resolve() + }) + .on('error', (err) => { + logger.error('Failed to generate video thumbnail', err) + resolve() // Continue without thumbnail + }) + .screenshots({ + timestamps: ['50%'], // Get frame at 50% of video duration + filename: thumbnailFilename, + folder: path.join(UPLOAD_DIR, 'thumbnails'), + size: '1920x?' // Maintain aspect ratio with max 1920px width + }) }) - .jpeg({ quality: 85 }) // Good quality for larger thumbnails - .toFile(thumbnailPath) - } catch (imageError) { - // If sharp fails (e.g., for SVG), just save the original - logger.warn('Sharp processing failed, saving original only', imageError as Error) - await writeFile(filepath, buffer) + }).catch((err) => { + // If ffmpeg fails, continue without metadata + logger.warn('Video processing failed, continuing without metadata', err) + }) + } else { + // Process image with sharp + try { + const image = sharp(buffer) + const metadata = await image.metadata() + width = metadata.width || 0 + height = metadata.height || 0 + + // Save original + await writeFile(filepath, buffer) + + // Create thumbnail (max 1920px wide for high-res displays) + await image + .resize(1920, null, { + fit: 'inside', + withoutEnlargement: true + }) + .jpeg({ quality: 85 }) // Good quality for larger thumbnails + .toFile(thumbnailPath) + } catch (imageError) { + // If sharp fails (e.g., for SVG), just save the original + logger.warn('Sharp processing failed, saving original only', imageError as Error) + await writeFile(filepath, buffer) + } } // Construct URLs const url = `${PUBLIC_PATH}/${type}/${filename}` - const thumbnailUrl = `${PUBLIC_PATH}/thumbnails/thumb-${filename}` + const thumbnailUrl = existsSync(thumbnailPath) + ? `${PUBLIC_PATH}/thumbnails/${thumbnailFilename}` + : null logger.info('File uploaded locally', { filename, type, size: file.size, - dimensions: `${width}x${height}` + dimensions: `${width}x${height}`, + isVideo }) return { @@ -109,7 +185,11 @@ export async function uploadFileLocally( thumbnailUrl, width, height, - size: file.size + size: file.size, + duration, + videoCodec, + audioCodec, + bitrate } } catch (error) { logger.error('Local upload failed', error as Error) diff --git a/src/lib/utils/mediaHelpers.ts b/src/lib/utils/mediaHelpers.ts index 0724e5a..254585a 100644 --- a/src/lib/utils/mediaHelpers.ts +++ b/src/lib/utils/mediaHelpers.ts @@ -92,3 +92,26 @@ export function getMimeTypeDisplayName(mimeType: string): string { return typeMap[mimeType] || getFileType(mimeType) } + +/** + * Format duration from seconds to readable format + */ +export function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60) + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + return `${minutes}:${secs.toString().padStart(2, '0')}` +} + +/** + * Format bitrate to readable format + */ +export function formatBitrate(bitrate: number): string { + if (bitrate < 1000) return `${bitrate} bps` + if (bitrate < 1000000) return `${(bitrate / 1000).toFixed(0)} kbps` + return `${(bitrate / 1000000).toFixed(1)} Mbps` +} diff --git a/src/routes/api/media/upload/+server.ts b/src/routes/api/media/upload/+server.ts index 412ac11..24f078c 100644 --- a/src/routes/api/media/upload/+server.ts +++ b/src/routes/api/media/upload/+server.ts @@ -119,7 +119,7 @@ export const POST: RequestHandler = async (event) => { } // Validate file type - const allowedTypes = [ + const allowedImageTypes = [ 'image/jpeg', 'image/jpg', 'image/png', @@ -127,14 +127,28 @@ export const POST: RequestHandler = async (event) => { 'image/gif', 'image/svg+xml' ] + + const allowedVideoTypes = [ + 'video/webm', + 'video/mp4', + 'video/ogg', + 'video/quicktime', + 'video/x-msvideo' + ] + + const allowedTypes = [...allowedImageTypes, ...allowedVideoTypes] + if (!allowedTypes.includes(file.type)) { - return errorResponse('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF, SVG', 400) + return errorResponse('Invalid file type. Allowed types: Images (JPEG, PNG, WebP, GIF, SVG) and Videos (WebM, MP4, OGG, MOV, AVI)', 400) } - // Validate file size (max 10MB) - const maxSize = 10 * 1024 * 1024 // 10MB + // Validate file size - different limits for images and videos + const isVideo = allowedVideoTypes.includes(file.type) + const maxSize = isVideo ? 100 * 1024 * 1024 : 10 * 1024 * 1024 // 100MB for videos, 10MB for images + const maxSizeText = isVideo ? '100MB' : '10MB' + if (file.size > maxSize) { - return errorResponse('File too large. Maximum size is 10MB', 400) + return errorResponse(`File too large. Maximum size is ${maxSizeText}`, 400) } // Extract EXIF data for image files (but don't block upload if it fails) @@ -164,6 +178,10 @@ export const POST: RequestHandler = async (event) => { dominantColor: uploadResult.dominantColor, colors: uploadResult.colors, aspectRatio: uploadResult.aspectRatio, + duration: uploadResult.duration, + videoCodec: uploadResult.videoCodec, + audioCodec: uploadResult.audioCodec, + bitrate: uploadResult.bitrate, exifData: exifData, description: description?.trim() || null, isPhotography: isPhotography