mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-12 01:46:08 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
provider: true,
|
||||
sourceUrl: true,
|
||||
providerVideoId: true,
|
||||
thumbnailUrl: true,
|
||||
uploadedByUserId: true,
|
||||
uploadedByGuestIdentityId: true,
|
||||
},
|
||||
@@ -41,6 +42,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
let shouldDeleteImageObject = false;
|
||||
let shouldDeleteAudioObject = false;
|
||||
let shouldDeleteVideoObject = false;
|
||||
let shouldDeleteVideoThumbnail = false;
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.videoAsset.delete({ where: { id: asset.id } });
|
||||
|
||||
@@ -59,6 +62,22 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
]);
|
||||
shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||
}
|
||||
|
||||
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||
const [assetReferenceCount, versionReferenceCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
|
||||
tx.videoVersion.count({ where: { originalUrl: asset.sourceUrl } }),
|
||||
]);
|
||||
shouldDeleteVideoObject = assetReferenceCount === 0 && versionReferenceCount === 0;
|
||||
|
||||
if (asset.thumbnailUrl) {
|
||||
const [assetThumbnailCount, commentImageCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { thumbnailUrl: asset.thumbnailUrl } }),
|
||||
tx.comment.count({ where: { imageUrl: asset.thumbnailUrl } }),
|
||||
]);
|
||||
shouldDeleteVideoThumbnail = assetThumbnailCount === 0 && commentImageCount === 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
||||
@@ -68,6 +87,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||
}
|
||||
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||
const urlsToDelete: string[] = [];
|
||||
if (shouldDeleteVideoObject && asset.sourceUrl) {
|
||||
urlsToDelete.push(asset.sourceUrl);
|
||||
}
|
||||
if (shouldDeleteVideoThumbnail && asset.thumbnailUrl) {
|
||||
urlsToDelete.push(asset.thumbnailUrl);
|
||||
}
|
||||
if (urlsToDelete.length > 0) {
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort(urlsToDelete);
|
||||
}
|
||||
}
|
||||
|
||||
let bunnyCleanupResult:
|
||||
| Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>
|
||||
|
||||
Reference in New Issue
Block a user