jedmund-svelte/src/lib/server/cloudinary.ts
Justin Edmund 4f46b0e666 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>
2025-08-23 03:13:36 -04:00

349 lines
10 KiB
TypeScript

import { v2 as cloudinary } from 'cloudinary'
import type { UploadApiResponse, UploadApiErrorResponse } from 'cloudinary'
import { logger } from './logger'
import { uploadFileLocally } from './local-storage'
import { dev } from '$app/environment'
import { selectBestDominantColor } from './color-utils'
// Configure Cloudinary
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
secure: true
})
// Check if Cloudinary is configured
export function isCloudinaryConfigured(): boolean {
return !!(
process.env.CLOUDINARY_CLOUD_NAME &&
process.env.CLOUDINARY_API_KEY &&
process.env.CLOUDINARY_API_SECRET
)
}
// Upload options for different asset types
const uploadPresets = {
// For general media uploads (blog posts, project images)
media: {
folder: 'jedmund/media',
resource_type: 'auto' as const,
// Remove allowed_formats to avoid SVG validation issues
transformation: [{ quality: 'auto:good' }, { fetch_format: 'auto' }]
},
// For photo albums
photos: {
folder: 'jedmund/photos',
resource_type: 'image' as const,
allowed_formats: ['jpg', 'jpeg', 'png', 'webp'],
transformation: [{ quality: 'auto:best' }, { fetch_format: 'auto' }]
},
// For project galleries
projects: {
folder: 'jedmund/projects',
resource_type: 'image' as const,
// Remove allowed_formats to avoid SVG validation issues
transformation: [{ quality: 'auto:good' }, { fetch_format: 'auto' }]
}
}
// Image size variants (2025-appropriate sizes)
export const imageSizes = {
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 },
xlarge: { width: 2560, quality: 'auto:good' as const }
}
export interface UploadResult {
success: boolean
publicId?: string
url?: string
secureUrl?: string
thumbnailUrl?: string
width?: number
height?: number
format?: string
size?: number
dominantColor?: string
colors?: any
aspectRatio?: number
duration?: number
videoCodec?: string
audioCodec?: string
bitrate?: number
error?: string
}
// Upload a single file
export async function uploadFile(
file: File,
type: 'media' | 'photos' | 'projects' = 'media',
customOptions?: any
): Promise<UploadResult> {
try {
// 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()) {
logger.info('Using local storage for file upload')
const localResult = await uploadFileLocally(file, type)
if (!localResult.success) {
return {
success: false,
error: localResult.error || 'Local upload failed'
}
}
const aspectRatio =
localResult.width && localResult.height ? localResult.width / localResult.height : undefined
return {
success: true,
publicId: `local/${localResult.filename}`,
url: localResult.url,
secureUrl: localResult.url,
thumbnailUrl: localResult.thumbnailUrl,
width: localResult.width,
height: localResult.height,
format: file.type.split('/')[1],
size: localResult.size,
aspectRatio,
duration: localResult.duration,
videoCodec: localResult.videoCodec,
audioCodec: localResult.audioCodec,
bitrate: localResult.bitrate
}
}
// Convert File to buffer
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Check if file is SVG for logging purposes
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')
// Extract filename without extension
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '')
const fileExtension = file.name.split('.').pop()?.toLowerCase()
// Prepare upload options
const uploadOptions = {
...uploadPresets[type],
...customOptions,
public_id: `${Date.now()}-${fileNameWithoutExt}`,
// For SVG files, explicitly set format to preserve extension
...(isSvg && { format: 'svg' }),
// Request color analysis for images
colors: true
}
// Log upload attempt for debugging
logger.info('Attempting file upload:', {
filename: file.name,
mimeType: file.type,
size: file.size,
isSvg,
uploadOptions
})
// Upload to Cloudinary
const result = await new Promise<UploadApiResponse>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(uploadOptions, (error, result) => {
if (error) reject(error)
else if (result) resolve(result)
else reject(new Error('Upload failed'))
})
uploadStream.end(buffer)
})
// 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
if (result.colors && Array.isArray(result.colors) && result.colors.length > 0) {
dominantColor = selectBestDominantColor(result.colors, {
minPercentage: 2,
preferVibrant: true,
excludeGreys: false,
preferBrighter: true
})
}
// 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)
return {
success: true,
publicId: result.public_id,
url: result.url,
secureUrl: result.secure_url,
thumbnailUrl,
width: result.width,
height: result.height,
format: result.format,
size: result.bytes,
dominantColor,
colors: result.colors,
aspectRatio,
duration,
videoCodec,
audioCodec,
bitrate
}
} catch (error) {
logger.error('Cloudinary upload failed', error as Error)
logger.mediaUpload(file.name, file.size, file.type, false)
// Enhanced error logging
if (error instanceof Error) {
logger.error('Upload error details:', {
filename: file.name,
mimeType: file.type,
size: file.size,
errorMessage: error.message,
errorStack: error.stack
})
}
return {
success: false,
error: error instanceof Error ? error.message : 'Upload failed'
}
}
}
// Upload multiple files
export async function uploadFiles(
files: File[],
type: 'media' | 'photos' | 'projects' = 'media'
): Promise<UploadResult[]> {
const uploadPromises = files.map((file) => uploadFile(file, type))
return Promise.all(uploadPromises)
}
// Delete a file from Cloudinary
export async function deleteFile(publicId: string): Promise<boolean> {
try {
if (!isCloudinaryConfigured()) {
throw new Error('Cloudinary is not configured')
}
// Try to delete with auto resource type first
const result = await cloudinary.uploader.destroy(publicId, {
resource_type: 'auto'
})
return result.result === 'ok'
} catch (error) {
logger.error('Cloudinary delete failed', error as Error)
return false
}
}
// Generate optimized URL for an image
export function getOptimizedUrl(
publicId: string,
options?: {
width?: number
height?: number
quality?: string
format?: string
crop?: string
}
): string {
return cloudinary.url(publicId, {
secure: true,
transformation: [
{
quality: options?.quality || 'auto:good',
fetch_format: options?.format || 'auto',
...(options?.width && { width: options.width }),
...(options?.height && { height: options.height }),
...(options?.crop && { crop: options.crop })
}
]
})
}
// Get responsive image URLs for different screen sizes
export function getResponsiveUrls(publicId: string): Record<string, string> {
return {
thumbnail: getOptimizedUrl(publicId, imageSizes.thumbnail),
small: getOptimizedUrl(publicId, { width: imageSizes.small.width }),
medium: getOptimizedUrl(publicId, { width: imageSizes.medium.width }),
large: getOptimizedUrl(publicId, { width: imageSizes.large.width }),
xlarge: getOptimizedUrl(publicId, { width: imageSizes.xlarge.width }),
original: cloudinary.url(publicId, { secure: true })
}
}
// Smart image size selection based on container width
export function getSmartImageUrl(publicId: string, containerWidth: number, retina = true): string {
// Account for retina displays
const targetWidth = retina ? containerWidth * 2 : containerWidth
// Select appropriate size
if (targetWidth <= 600) {
return getOptimizedUrl(publicId, { width: imageSizes.small.width })
} else if (targetWidth <= 1200) {
return getOptimizedUrl(publicId, { width: imageSizes.medium.width })
} else if (targetWidth <= 1920) {
return getOptimizedUrl(publicId, { width: imageSizes.large.width })
} else {
return getOptimizedUrl(publicId, { width: imageSizes.xlarge.width })
}
}
// Extract public ID from Cloudinary URL
export function extractPublicId(url: string): string | null {
try {
// Cloudinary URLs typically follow this pattern:
// https://res.cloudinary.com/{cloud_name}/image/upload/{version}/{public_id}.{format}
// First decode the URL to handle encoded characters
const decodedUrl = decodeURIComponent(url)
const match = decodedUrl.match(/\/v\d+\/(.+)\.[a-zA-Z]+$/)
if (match) {
// Re-encode the public ID for Cloudinary API
return match[1]
}
return null
} catch {
return null
}
}