feat: add video upload support with ffmpeg processing
- Add video MIME types support (WebM, MP4, OGG, MOV, AVI) - Increase upload size limit to 100MB for videos - Add ffmpeg integration for local video processing - Generate video thumbnails at 50% duration - Extract video metadata (duration, codecs, bitrate) - Add database fields for video metadata - Support video uploads in both local and Cloudinary storage - Maintain aspect ratio for video thumbnails (1920px max width) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
bc2d1b4092
commit
4f46b0e666
8 changed files with 244 additions and 38 deletions
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -37,6 +37,7 @@
|
||||||
"@tiptap/pm": "^2.12.0",
|
"@tiptap/pm": "^2.12.0",
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/leaflet": "^1.9.18",
|
"@types/leaflet": "^1.9.18",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
"cloudinary": "^2.6.1",
|
"cloudinary": "^2.6.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"giantbombing-api": "^1.0.4",
|
"giantbombing-api": "^1.0.4",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
|
@ -2872,6 +2874,14 @@
|
||||||
"@types/send": "*"
|
"@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": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
|
@ -3513,6 +3523,11 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
|
@ -5027,6 +5042,30 @@
|
||||||
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
|
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@
|
||||||
"@tiptap/pm": "^2.12.0",
|
"@tiptap/pm": "^2.12.0",
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/leaflet": "^1.9.18",
|
"@types/leaflet": "^1.9.18",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
|
|
@ -95,6 +96,7 @@
|
||||||
"cloudinary": "^2.6.1",
|
"cloudinary": "^2.6.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"giantbombing-api": "^1.0.4",
|
"giantbombing-api": "^1.0.4",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -127,6 +127,10 @@ model Media {
|
||||||
dominantColor String? @db.VarChar(7)
|
dominantColor String? @db.VarChar(7)
|
||||||
colors Json?
|
colors Json?
|
||||||
aspectRatio Float?
|
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[]
|
albums AlbumMedia[]
|
||||||
usage MediaUsage[]
|
usage MediaUsage[]
|
||||||
photos Photo[]
|
photos Photo[]
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ const uploadPresets = {
|
||||||
|
|
||||||
// Image size variants (2025-appropriate sizes)
|
// Image size variants (2025-appropriate sizes)
|
||||||
export const imageSizes = {
|
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 },
|
small: { width: 600, quality: 'auto:good' as const },
|
||||||
medium: { width: 1200, quality: 'auto:good' as const },
|
medium: { width: 1200, quality: 'auto:good' as const },
|
||||||
large: { width: 1920, quality: 'auto:good' as const },
|
large: { width: 1920, quality: 'auto:good' as const },
|
||||||
|
|
@ -71,6 +71,10 @@ export interface UploadResult {
|
||||||
dominantColor?: string
|
dominantColor?: string
|
||||||
colors?: any
|
colors?: any
|
||||||
aspectRatio?: number
|
aspectRatio?: number
|
||||||
|
duration?: number
|
||||||
|
videoCodec?: string
|
||||||
|
audioCodec?: string
|
||||||
|
bitrate?: number
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,8 +85,8 @@ export async function uploadFile(
|
||||||
customOptions?: any
|
customOptions?: any
|
||||||
): Promise<UploadResult> {
|
): Promise<UploadResult> {
|
||||||
try {
|
try {
|
||||||
// TEMPORARY: Force Cloudinary usage for testing
|
// Toggle this to use Cloudinary in development (requires API keys)
|
||||||
const FORCE_CLOUDINARY_IN_DEV = true // Toggle this to test
|
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
|
// Use local storage in development or when Cloudinary is not configured
|
||||||
if ((dev && !FORCE_CLOUDINARY_IN_DEV) || !isCloudinaryConfigured()) {
|
if ((dev && !FORCE_CLOUDINARY_IN_DEV) || !isCloudinaryConfigured()) {
|
||||||
|
|
@ -109,7 +113,11 @@ export async function uploadFile(
|
||||||
height: localResult.height,
|
height: localResult.height,
|
||||||
format: file.type.split('/')[1],
|
format: file.type.split('/')[1],
|
||||||
size: localResult.size,
|
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)
|
uploadStream.end(buffer)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Generate thumbnail URL
|
// Generate thumbnail URL - different approach for videos vs images
|
||||||
const thumbnailUrl = cloudinary.url(result.public_id, {
|
const isVideo = file.type.startsWith('video/')
|
||||||
...imageSizes.thumbnail,
|
const thumbnailUrl = isVideo
|
||||||
secure: true
|
? 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
|
// Extract dominant color using smart selection
|
||||||
let dominantColor: string | undefined
|
let dominantColor: string | undefined
|
||||||
|
|
@ -174,6 +192,19 @@ export async function uploadFile(
|
||||||
|
|
||||||
// Calculate aspect ratio
|
// Calculate aspect ratio
|
||||||
const aspectRatio = result.width && result.height ? result.width / result.height : undefined
|
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)
|
logger.mediaUpload(file.name, file.size, file.type, true)
|
||||||
|
|
||||||
|
|
@ -189,7 +220,11 @@ export async function uploadFile(
|
||||||
size: result.bytes,
|
size: result.bytes,
|
||||||
dominantColor,
|
dominantColor,
|
||||||
colors: result.colors,
|
colors: result.colors,
|
||||||
aspectRatio
|
aspectRatio,
|
||||||
|
duration,
|
||||||
|
videoCodec,
|
||||||
|
audioCodec,
|
||||||
|
bitrate
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Cloudinary upload failed', error as Error)
|
logger.error('Cloudinary upload failed', error as Error)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { writeFile, mkdir } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
|
import ffmpeg from 'fluent-ffmpeg'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
|
|
||||||
// Base directory for local uploads
|
// Base directory for local uploads
|
||||||
|
|
@ -44,6 +45,10 @@ export interface LocalUploadResult {
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
size?: number
|
size?: number
|
||||||
|
duration?: number
|
||||||
|
videoCodec?: string
|
||||||
|
audioCodec?: string
|
||||||
|
bitrate?: number
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,48 +63,119 @@ export async function uploadFileLocally(
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
const filename = generateFilename(file.name)
|
const filename = generateFilename(file.name)
|
||||||
const filepath = path.join(UPLOAD_DIR, type, filename)
|
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
|
// Convert File to buffer
|
||||||
const arrayBuffer = await file.arrayBuffer()
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
const buffer = Buffer.from(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 width = 0
|
||||||
let height = 0
|
let height = 0
|
||||||
|
let duration: number | undefined
|
||||||
|
let videoCodec: string | undefined
|
||||||
|
let audioCodec: string | undefined
|
||||||
|
let bitrate: number | undefined
|
||||||
|
|
||||||
try {
|
if (isVideo) {
|
||||||
const image = sharp(buffer)
|
// Save video file first
|
||||||
const metadata = await image.metadata()
|
|
||||||
width = metadata.width || 0
|
|
||||||
height = metadata.height || 0
|
|
||||||
|
|
||||||
// Save original
|
|
||||||
await writeFile(filepath, buffer)
|
await writeFile(filepath, buffer)
|
||||||
|
|
||||||
// Create thumbnail (800x600 for modern displays)
|
// Get video metadata and generate thumbnail
|
||||||
await image
|
await new Promise<void>((resolve, reject) => {
|
||||||
.resize(800, 600, {
|
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
||||||
fit: 'cover',
|
if (err) {
|
||||||
position: 'center'
|
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
|
}).catch((err) => {
|
||||||
.toFile(thumbnailPath)
|
// If ffmpeg fails, continue without metadata
|
||||||
} catch (imageError) {
|
logger.warn('Video processing failed, continuing without metadata', err)
|
||||||
// If sharp fails (e.g., for SVG), just save the original
|
})
|
||||||
logger.warn('Sharp processing failed, saving original only', imageError as Error)
|
} else {
|
||||||
await writeFile(filepath, buffer)
|
// 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
|
// Construct URLs
|
||||||
const url = `${PUBLIC_PATH}/${type}/${filename}`
|
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', {
|
logger.info('File uploaded locally', {
|
||||||
filename,
|
filename,
|
||||||
type,
|
type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dimensions: `${width}x${height}`
|
dimensions: `${width}x${height}`,
|
||||||
|
isVideo
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -109,7 +185,11 @@ export async function uploadFileLocally(
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
size: file.size
|
size: file.size,
|
||||||
|
duration,
|
||||||
|
videoCodec,
|
||||||
|
audioCodec,
|
||||||
|
bitrate
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Local upload failed', error as Error)
|
logger.error('Local upload failed', error as Error)
|
||||||
|
|
|
||||||
|
|
@ -92,3 +92,26 @@ export function getMimeTypeDisplayName(mimeType: string): string {
|
||||||
|
|
||||||
return typeMap[mimeType] || getFileType(mimeType)
|
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`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export const POST: RequestHandler = async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
const allowedTypes = [
|
const allowedImageTypes = [
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/jpg',
|
'image/jpg',
|
||||||
'image/png',
|
'image/png',
|
||||||
|
|
@ -127,14 +127,28 @@ export const POST: RequestHandler = async (event) => {
|
||||||
'image/gif',
|
'image/gif',
|
||||||
'image/svg+xml'
|
'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)) {
|
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)
|
// Validate file size - different limits for images and videos
|
||||||
const maxSize = 10 * 1024 * 1024 // 10MB
|
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) {
|
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)
|
// 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,
|
dominantColor: uploadResult.dominantColor,
|
||||||
colors: uploadResult.colors,
|
colors: uploadResult.colors,
|
||||||
aspectRatio: uploadResult.aspectRatio,
|
aspectRatio: uploadResult.aspectRatio,
|
||||||
|
duration: uploadResult.duration,
|
||||||
|
videoCodec: uploadResult.videoCodec,
|
||||||
|
audioCodec: uploadResult.audioCodec,
|
||||||
|
bitrate: uploadResult.bitrate,
|
||||||
exifData: exifData,
|
exifData: exifData,
|
||||||
description: description?.trim() || null,
|
description: description?.trim() || null,
|
||||||
isPhotography: isPhotography
|
isPhotography: isPhotography
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue