mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +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:
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
|
||||
export const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
||||
|
||||
export type ActiveTusUpload = { abort: (shouldTerminate?: boolean) => Promise<unknown> | void };
|
||||
|
||||
export type PendingProjectUploadCleanup =
|
||||
| { type: 'bunny'; videoId: string; uploadToken: string }
|
||||
| {
|
||||
type: 'r2';
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string;
|
||||
};
|
||||
|
||||
export type ProjectVideoUploadProgress = {
|
||||
onProgress?: (progress: number) => void;
|
||||
onStatus?: (status: string) => void;
|
||||
onTusUploadReady?: (upload: ActiveTusUpload) => void;
|
||||
onPendingUpload?: (pending: PendingProjectUploadCleanup) => void;
|
||||
isCancelled?: () => boolean;
|
||||
};
|
||||
|
||||
export function isVideoFile(file: File): boolean {
|
||||
if (file.type.startsWith('video/')) return true;
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
return !!ext && VIDEO_FILE_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
export function extractVideoFiles(dataTransfer: DataTransfer | null): File[] {
|
||||
if (!dataTransfer?.files?.length) return [];
|
||||
return Array.from(dataTransfer.files).filter(isVideoFile);
|
||||
}
|
||||
|
||||
export function getDefaultTitleFromFile(file: File): string {
|
||||
const withoutExt = file.name.replace(/\.[^/.]+$/, '').trim();
|
||||
return withoutExt || file.name;
|
||||
}
|
||||
|
||||
export async function cleanupPendingProjectUpload(
|
||||
projectId: string,
|
||||
pending: PendingProjectUploadCleanup,
|
||||
keepalive = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (pending.type === 'bunny') {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
|
||||
keepalive,
|
||||
});
|
||||
} else {
|
||||
await cleanupPendingR2VideoUpload(
|
||||
projectId,
|
||||
{
|
||||
objectKey: pending.objectKey,
|
||||
uploadToken: pending.uploadToken,
|
||||
reservationId: pending.reservationId,
|
||||
thumbnailObjectKey: pending.thumbnailObjectKey,
|
||||
},
|
||||
keepalive
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending project upload:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadProjectVideo(
|
||||
projectId: string,
|
||||
file: File,
|
||||
options: {
|
||||
provider: DirectUploadProvider;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
bunnyCdnHostname?: string | null;
|
||||
} & ProjectVideoUploadProgress
|
||||
): Promise<void> {
|
||||
const {
|
||||
provider,
|
||||
title: titleOverride,
|
||||
description = null,
|
||||
bunnyCdnHostname = resolvePublicBunnyCdnHostname(),
|
||||
onProgress,
|
||||
onStatus,
|
||||
onTusUploadReady,
|
||||
onPendingUpload,
|
||||
isCancelled,
|
||||
} = options;
|
||||
|
||||
const title = titleOverride?.trim() || getDefaultTitleFromFile(file);
|
||||
let pendingCleanup: PendingProjectUploadCleanup | null = null;
|
||||
|
||||
try {
|
||||
if (provider === 'r2') {
|
||||
onStatus?.('Initializing upload...');
|
||||
const uploaded = await uploadVideoToR2(projectId, file, {
|
||||
onProgress: (progress) => {
|
||||
onProgress?.(progress);
|
||||
onStatus?.(`Uploading... ${progress}%`);
|
||||
},
|
||||
});
|
||||
|
||||
pendingCleanup = {
|
||||
type: 'r2',
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
};
|
||||
onPendingUpload?.(pendingCleanup);
|
||||
|
||||
if (isCancelled?.()) {
|
||||
throw new Error('Upload cancelled');
|
||||
}
|
||||
|
||||
onStatus?.('Saving video...');
|
||||
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
videoUrl: uploaded.proxyUrl,
|
||||
providerId: 'r2',
|
||||
videoId: uploaded.objectKey,
|
||||
thumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
|
||||
duration: uploaded.duration,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
objectKey: uploaded.objectKey,
|
||||
reservationId: uploaded.reservationId,
|
||||
}),
|
||||
});
|
||||
|
||||
const createPayload = (await createResponse.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(createPayload?.error || 'Failed to create video');
|
||||
}
|
||||
|
||||
pendingCleanup = null;
|
||||
return;
|
||||
}
|
||||
|
||||
onStatus?.('Initializing upload...');
|
||||
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
const initPayload = (await initResponse.json().catch(() => null)) as {
|
||||
data?: {
|
||||
videoId: string;
|
||||
libraryId: string;
|
||||
signature: string;
|
||||
expirationTime: number;
|
||||
uploadToken: string;
|
||||
};
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
if (!initResponse.ok || !initPayload?.data) {
|
||||
throw new Error(initPayload?.error || 'Failed to initialize upload');
|
||||
}
|
||||
|
||||
const { videoId, libraryId, signature, expirationTime, uploadToken } = initPayload.data;
|
||||
pendingCleanup = { type: 'bunny', videoId, uploadToken };
|
||||
onPendingUpload?.(pendingCleanup);
|
||||
|
||||
if (isCancelled?.()) {
|
||||
throw new Error('Upload cancelled');
|
||||
}
|
||||
|
||||
const { Upload } = await import('tus-js-client');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const upload = new Upload(file, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: signature,
|
||||
AuthorizationExpire: expirationTime.toString(),
|
||||
VideoId: videoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: file.type,
|
||||
title,
|
||||
},
|
||||
onError: (error) => {
|
||||
onTusUploadReady?.({ abort: () => undefined });
|
||||
reject(new Error(error.message));
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = Number(((bytesUploaded / bytesTotal) * 100).toFixed(1));
|
||||
onProgress?.(percentage);
|
||||
onStatus?.(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
onTusUploadReady?.({ abort: () => undefined });
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
onTusUploadReady?.(upload);
|
||||
upload.start();
|
||||
});
|
||||
|
||||
if (isCancelled?.()) {
|
||||
throw new Error('Upload cancelled');
|
||||
}
|
||||
|
||||
onStatus?.('Saving video...');
|
||||
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
videoUrl: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`,
|
||||
providerId: 'bunny',
|
||||
videoId,
|
||||
thumbnailUrl: bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${videoId}/thumbnail.jpg`
|
||||
: null,
|
||||
duration: null,
|
||||
uploadToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const createPayload = (await createResponse.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(createPayload?.error || 'Failed to create video');
|
||||
}
|
||||
|
||||
pendingCleanup = null;
|
||||
} catch (error) {
|
||||
if (pendingCleanup) {
|
||||
await cleanupPendingProjectUpload(projectId, pendingCleanup);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user