Merge pull request #13 from jedmund/feature/video-upload-support

Add video upload support
This commit is contained in:
Justin Edmund 2025-08-23 00:19:48 -07:00 committed by GitHub
commit c89b2b0db5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 465 additions and 60 deletions

39
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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[]

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { formatFileSize, isImageFile } from '$lib/utils/mediaHelpers' import { formatFileSize, isImageFile, isVideoFile } from '$lib/utils/mediaHelpers'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
interface FilePreview { interface FilePreview {
@ -85,6 +85,8 @@
<div class="file-preview"> <div class="file-preview">
{#if isImageFile(preview.type)} {#if isImageFile(preview.type)}
<img src={preview.url} alt={preview.name} /> <img src={preview.url} alt={preview.name} />
{:else if isVideoFile(preview.type)}
<div class="file-icon">🎬</div>
{:else} {:else}
<div class="file-icon">📄</div> <div class="file-icon">📄</div>
{/if} {/if}

View file

@ -13,7 +13,7 @@
import MediaUsageList from './MediaUsageList.svelte' import MediaUsageList from './MediaUsageList.svelte'
import { authenticatedFetch } from '$lib/admin-auth' import { authenticatedFetch } from '$lib/admin-auth'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import { formatFileSize, getFileType } from '$lib/utils/mediaHelpers' import { formatFileSize, getFileType, isVideoFile } from '$lib/utils/mediaHelpers'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
interface Props { interface Props {
@ -214,12 +214,19 @@
showCloseButton={false} showCloseButton={false}
> >
<div class="media-details-modal"> <div class="media-details-modal">
<!-- Left Pane - Image Preview --> <!-- Left Pane - Media Preview -->
<div class="image-pane"> <div class="image-pane">
{#if media.mimeType.startsWith('image/')} {#if media.mimeType.startsWith('image/')}
<div class="image-container"> <div class="image-container">
<SmartImage {media} alt={media.description || media.filename} class="preview-image" /> <SmartImage {media} alt={media.description || media.filename} class="preview-image" />
</div> </div>
{:else if isVideoFile(media.mimeType)}
<div class="video-container">
<video controls poster={media.thumbnailUrl || undefined} class="preview-video">
<source src={media.url} type={media.mimeType} />
Your browser does not support the video tag.
</video>
</div>
{:else} {:else}
<div class="file-placeholder"> <div class="file-placeholder">
<FileIcon size={64} /> <FileIcon size={64} />
@ -386,6 +393,23 @@
} }
} }
.video-container {
max-width: 90%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
.preview-video {
width: 100%;
height: auto;
max-width: 100%;
object-fit: contain;
background: #000;
border-radius: $corner-radius-md;
}
}
.file-placeholder { .file-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import SmartImage from '../SmartImage.svelte' import SmartImage from '../SmartImage.svelte'
import FileIcon from '../icons/FileIcon.svelte' import FileIcon from '../icons/FileIcon.svelte'
import { isImageFile } from '$lib/utils/mediaHelpers' import PlayIcon from '$icons/play.svg?component'
import { isImageFile, isVideoFile } from '$lib/utils/mediaHelpers'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
interface Props { interface Props {
@ -86,6 +87,25 @@
class="media-image {item.mimeType === 'image/svg+xml' ? 'svg-image' : ''}" class="media-image {item.mimeType === 'image/svg+xml' ? 'svg-image' : ''}"
containerWidth={150} containerWidth={150}
/> />
{:else if isVideoFile(item.mimeType)}
{#if item.thumbnailUrl}
<div class="video-thumbnail-wrapper">
<img
src={item.thumbnailUrl}
alt={item.filename}
loading={i < 8 ? 'eager' : 'lazy'}
class="media-image video-thumbnail"
/>
<div class="video-overlay">
<PlayIcon class="play-icon" />
</div>
</div>
{:else}
<div class="media-placeholder video-placeholder">
<PlayIcon class="video-icon" />
<span class="video-label">Video</span>
</div>
{/if}
{:else} {:else}
<div class="media-placeholder"> <div class="media-placeholder">
<FileIcon size={32} /> <FileIcon size={32} />
@ -204,6 +224,40 @@
} }
} }
.video-thumbnail-wrapper {
width: 100%;
height: 100%;
position: relative;
.video-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
:global(.play-icon) {
width: 20px;
height: 20px;
color: white;
margin-left: -2px;
}
}
}
.media-placeholder { .media-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -211,6 +265,24 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: $gray-60; color: $gray-60;
&.video-placeholder {
flex-direction: column;
gap: $unit;
:global(.video-icon) {
width: 32px;
height: 32px;
color: $gray-60;
}
.video-label {
font-size: 12px;
color: $gray-50;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
} }
.hover-overlay { .hover-overlay {

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from './Button.svelte' import Button from './Button.svelte'
import { formatFileSize, getFileType } from '$lib/utils/mediaHelpers' import { formatFileSize, getFileType, isVideoFile, formatDuration, formatBitrate } from '$lib/utils/mediaHelpers'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
interface Props { interface Props {
@ -36,7 +36,32 @@
<span class="value">{media.width} × {media.height}px</span> <span class="value">{media.width} × {media.height}px</span>
</div> </div>
{/if} {/if}
{#if media.dominantColor} {#if isVideoFile(media.mimeType)}
{#if media.duration}
<div class="info-item">
<span class="label">Duration</span>
<span class="value">{formatDuration(media.duration)}</span>
</div>
{/if}
{#if media.videoCodec}
<div class="info-item">
<span class="label">Video Codec</span>
<span class="value">{media.videoCodec.toUpperCase()}</span>
</div>
{/if}
{#if media.audioCodec}
<div class="info-item">
<span class="label">Audio Codec</span>
<span class="value">{media.audioCodec.toUpperCase()}</span>
</div>
{/if}
{#if media.bitrate}
<div class="info-item">
<span class="label">Bitrate</span>
<span class="value">{formatBitrate(media.bitrate)}</span>
</div>
{/if}
{:else if media.dominantColor}
<div class="info-item"> <div class="info-item">
<span class="label">Dominant Color</span> <span class="label">Dominant Color</span>
<span class="value color-value"> <span class="value color-value">

View file

@ -37,17 +37,19 @@
} }
function addFiles(newFiles: File[]) { function addFiles(newFiles: File[]) {
// Filter for image files // Filter for supported file types (images and videos)
const imageFiles = newFiles.filter((file) => file.type.startsWith('image/')) const supportedFiles = newFiles.filter(
(file) => file.type.startsWith('image/') || file.type.startsWith('video/')
)
if (imageFiles.length !== newFiles.length) { if (supportedFiles.length !== newFiles.length) {
uploadErrors = [ uploadErrors = [
...uploadErrors, ...uploadErrors,
`${newFiles.length - imageFiles.length} non-image files were skipped` `${newFiles.length - supportedFiles.length} unsupported files were skipped`
] ]
} }
files = [...files, ...imageFiles] files = [...files, ...supportedFiles]
} }
function removeFile(id: string | number) { function removeFile(id: string | number) {
@ -149,7 +151,7 @@
<!-- Drop Zone (compact when files are selected) --> <!-- Drop Zone (compact when files are selected) -->
<FileUploadZone <FileUploadZone
onFilesAdded={handleFilesAdded} onFilesAdded={handleFilesAdded}
accept={['image/*']} accept={['image/*', 'video/*']}
multiple={true} multiple={true}
compact={files.length > 0} compact={files.length > 0}
disabled={isUploading} disabled={isUploading}
@ -221,6 +223,9 @@
.modal-inner-content { .modal-inner-content {
padding: $unit $unit-3x $unit-3x; padding: $unit $unit-3x $unit-3x;
display: flex;
flex-direction: column;
gap: $unit-2x;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
} }

View file

@ -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)

View file

@ -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)

View file

@ -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`
}

View file

@ -12,7 +12,8 @@
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte' import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
import MediaUploadModal from '$lib/components/admin/MediaUploadModal.svelte' import MediaUploadModal from '$lib/components/admin/MediaUploadModal.svelte'
import AlbumSelectorModal from '$lib/components/admin/AlbumSelectorModal.svelte' import AlbumSelectorModal from '$lib/components/admin/AlbumSelectorModal.svelte'
import ChevronDown from '$icons/chevron-down.svg' import ChevronDown from '$icons/chevron-down.svg?component'
import PlayIcon from '$icons/play.svg?component'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
let media = $state<Media[]>([]) let media = $state<Media[]>([])
@ -157,6 +158,10 @@
return 'File' return 'File'
} }
function isVideoFile(mimeType: string): boolean {
return mimeType.startsWith('video/')
}
function handleMediaClick(item: Media) { function handleMediaClick(item: Media) {
selectedMedia = item selectedMedia = item
isDetailsModalOpen = true isDetailsModalOpen = true
@ -378,7 +383,9 @@
<div class="actions-dropdown"> <div class="actions-dropdown">
<Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload</Button> <Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload</Button>
<Button variant="ghost" iconOnly buttonSize="large" onclick={handleDropdownToggle}> <Button variant="ghost" iconOnly buttonSize="large" onclick={handleDropdownToggle}>
<ChevronDown slot="icon" /> {#snippet icon()}
<ChevronDown />
{/snippet}
</Button> </Button>
{#if isDropdownOpen} {#if isDropdownOpen}
@ -543,6 +550,20 @@
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url} src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
alt={item.description || item.filename} alt={item.description || item.filename}
/> />
{:else if isVideoFile(item.mimeType)}
{#if item.thumbnailUrl}
<div class="video-thumbnail-wrapper">
<img src={item.thumbnailUrl} alt={item.description || item.filename} />
<div class="video-overlay">
<PlayIcon class="play-icon" />
</div>
</div>
{:else}
<div class="file-placeholder video-placeholder">
<PlayIcon class="video-icon" />
<span class="file-type">Video</span>
</div>
{/if}
{:else} {:else}
<div class="file-placeholder"> <div class="file-placeholder">
<span class="file-type">{getFileType(item.mimeType)}</span> <span class="file-type">{getFileType(item.mimeType)}</span>
@ -746,6 +767,41 @@
object-fit: cover; object-fit: cover;
} }
.video-thumbnail-wrapper {
width: 100%;
height: 150px;
position: relative;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
:global(.play-icon) {
width: 20px;
height: 20px;
color: white;
margin-left: -2px;
}
}
}
.file-placeholder { .file-placeholder {
width: 100%; width: 100%;
height: 150px; height: 150px;
@ -754,6 +810,17 @@
justify-content: center; justify-content: center;
background: $gray-90; background: $gray-90;
&.video-placeholder {
flex-direction: column;
gap: $unit;
:global(.video-icon) {
width: 24px;
height: 24px;
color: $gray-60;
}
}
.file-type { .file-type {
font-size: 0.875rem; font-size: 0.875rem;
color: $gray-40; color: $gray-40;

View file

@ -45,17 +45,19 @@
} }
function addFiles(newFiles: File[]) { function addFiles(newFiles: File[]) {
// Filter for image files // Filter for supported file types (images and videos)
const imageFiles = newFiles.filter((file) => file.type.startsWith('image/')) const supportedFiles = newFiles.filter((file) =>
file.type.startsWith('image/') || file.type.startsWith('video/')
)
if (imageFiles.length !== newFiles.length) { if (supportedFiles.length !== newFiles.length) {
uploadErrors = [ uploadErrors = [
...uploadErrors, ...uploadErrors,
`${newFiles.length - imageFiles.length} non-image files were skipped` `${newFiles.length - supportedFiles.length} unsupported files were skipped`
] ]
} }
files = [...files, ...imageFiles] files = [...files, ...supportedFiles]
} }
function removeFile(index: number) { function removeFile(index: number) {
@ -197,6 +199,8 @@
<div class="file-preview"> <div class="file-preview">
{#if file.type.startsWith('image/')} {#if file.type.startsWith('image/')}
<img src={URL.createObjectURL(file)} alt={file.name} /> <img src={URL.createObjectURL(file)} alt={file.name} />
{:else if file.type.startsWith('video/')}
<div class="file-icon">🎬</div>
{:else} {:else}
<div class="file-icon">📄</div> <div class="file-icon">📄</div>
{/if} {/if}
@ -317,9 +321,9 @@
/> />
</svg> </svg>
</div> </div>
<h3>Drop images here</h3> <h3>Drop media files here</h3>
<p>or click to browse and select files</p> <p>or click to browse and select files</p>
<p class="upload-hint">Supports JPG, PNG, GIF, WebP, and SVG files</p> <p class="upload-hint">Images: JPG, PNG, GIF, WebP, SVG | Videos: WebM, MP4, OGG, MOV, AVI</p>
{:else} {:else}
<div class="compact-content"> <div class="compact-content">
<svg <svg
@ -358,7 +362,7 @@
bind:this={fileInput} bind:this={fileInput}
type="file" type="file"
multiple multiple
accept="image/*" accept="image/*,video/*"
onchange={handleFileSelect} onchange={handleFileSelect}
class="hidden-input" class="hidden-input"
/> />

View file

@ -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