feat: bulk video uploads and S3 asset video support (#18)

Add multi-file drag-and-drop queues for project videos and the assets pane, and route asset video uploads through S3/R2 when direct Bunny uploads are disabled.
This commit is contained in:
yusufipk
2026-06-13 23:24:21 +02:00
parent 52ace1a1a8
commit 00124bc7c2
20 changed files with 1980 additions and 776 deletions
@@ -8,8 +8,10 @@ import { db } from '@/lib/db';
import {
extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl,
extractVideoFileNameFromProxyUrl,
getVideoAssetAccessContext,
} from '@/lib/video-assets';
import { buildVideoObjectKey } from '@/lib/video-upload-validation';
import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
@@ -35,6 +37,16 @@ const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
};
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
mov: 'video/quicktime',
m4v: 'video/mp4',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
};
function sanitizeFileName(value: string): string {
const sanitized = value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
@@ -137,6 +149,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
});
}
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
if (!fileName) return apiErrors.badRequest('Invalid video asset URL');
const key = buildVideoObjectKey(fileName);
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4';
const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`;
const contentDisposition = buildContentDisposition(downloadName);
const extKey = ext.replace('.', '');
const contentType = VIDEO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'video/mp4';
return proxyR2MediaObject({
request,
key,
fallbackContentType: contentType,
cacheControl: 'private, no-store',
extraHeaders: {
'Content-Disposition': contentDisposition,
'X-Content-Type-Options': 'nosniff',
},
internalErrorMessage: 'Failed to retrieve video',
});
}
const sourceParam = request.nextUrl.searchParams.get('source');
const rawQuality = request.nextUrl.searchParams.get('quality');
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';