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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
||||
|
||||
export type R2AssetVideoInitResponse = {
|
||||
presignedPutUrl: string;
|
||||
objectKey: string;
|
||||
proxyUrl: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
contentType: string;
|
||||
thumbnailPresignedPutUrl: string;
|
||||
thumbnailObjectKey: string;
|
||||
thumbnailProxyUrl: string;
|
||||
};
|
||||
|
||||
export type R2AssetVideoUploadResult = R2AssetVideoInitResponse & {
|
||||
thumbnailUrl: string | null;
|
||||
};
|
||||
|
||||
type UploadProgressHandler = (progress: number) => void;
|
||||
|
||||
function uploadBytesWithProgress(
|
||||
url: string,
|
||||
body: Blob | File,
|
||||
contentType: string,
|
||||
onProgress?: UploadProgressHandler
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url);
|
||||
xhr.setRequestHeader('Content-Type', contentType);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!onProgress || !event.lengthComputable) return;
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(
|
||||
new Error(
|
||||
'Network error during upload. If you use direct S3/R2 uploads, configure bucket CORS to allow PUT from this site origin.'
|
||||
)
|
||||
);
|
||||
};
|
||||
xhr.onabort = () => reject(new Error('Upload aborted'));
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function initR2AssetVideoUpload(
|
||||
videoId: string,
|
||||
file: File
|
||||
): Promise<R2AssetVideoInitResponse> {
|
||||
const initRes = await fetch(`/api/videos/${videoId}/assets/r2-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
sizeBytes: file.size,
|
||||
}),
|
||||
});
|
||||
|
||||
const initPayload = (await initRes.json().catch(() => null)) as {
|
||||
data?: R2AssetVideoInitResponse;
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!initRes.ok || !initPayload?.data) {
|
||||
throw new Error(initPayload?.error || 'Failed to initialize video upload');
|
||||
}
|
||||
|
||||
return initPayload.data;
|
||||
}
|
||||
|
||||
export async function cleanupPendingR2AssetVideoUpload(
|
||||
videoId: string,
|
||||
input: {
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
thumbnailObjectKey?: string | null;
|
||||
},
|
||||
keepalive = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`/api/videos/${videoId}/assets/r2-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
objectKey: input.objectKey,
|
||||
uploadToken: input.uploadToken,
|
||||
thumbnailObjectKey: input.thumbnailObjectKey ?? undefined,
|
||||
}),
|
||||
keepalive,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending R2 asset video upload:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadAssetVideoToR2(
|
||||
videoId: string,
|
||||
file: File,
|
||||
options?: { onProgress?: UploadProgressHandler }
|
||||
): Promise<R2AssetVideoUploadResult> {
|
||||
const init = await initR2AssetVideoUpload(videoId, file);
|
||||
|
||||
const cleanupInput = {
|
||||
objectKey: init.objectKey,
|
||||
uploadToken: init.uploadToken,
|
||||
thumbnailObjectKey: init.thumbnailObjectKey,
|
||||
};
|
||||
|
||||
try {
|
||||
await uploadBytesWithProgress(
|
||||
init.presignedPutUrl,
|
||||
file,
|
||||
init.contentType,
|
||||
options?.onProgress
|
||||
);
|
||||
} catch (error) {
|
||||
await cleanupPendingR2AssetVideoUpload(videoId, cleanupInput);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const thumbnailBlob = await captureVideoThumbnail(file);
|
||||
let thumbnailUrl: string | null = null;
|
||||
if (thumbnailBlob) {
|
||||
try {
|
||||
await uploadBytesWithProgress(init.thumbnailPresignedPutUrl, thumbnailBlob, 'image/jpeg');
|
||||
thumbnailUrl = init.thumbnailProxyUrl;
|
||||
} catch (error) {
|
||||
console.warn('Failed to upload asset video thumbnail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...init, thumbnailUrl };
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
'asset-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
'asset-r2-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
|
||||
// Search — debounced on client but protect against scripted callers
|
||||
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_assets
|
||||
WHERE "billedUserId" = ${userId}
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
|
||||
`,
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
||||
@@ -143,7 +143,7 @@ export async function reserveStorageQuota(
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_assets
|
||||
WHERE "billedUserId" = ${userId}
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
|
||||
`;
|
||||
const [r2VideoRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
||||
|
||||
@@ -8,11 +8,14 @@ import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
|
||||
const IMAGE_PROXY_PREFIX = '/api/upload/image/';
|
||||
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
||||
const VIDEO_PROXY_PREFIX = '/api/upload/video/';
|
||||
|
||||
export const SAFE_IMAGE_PROXY_PATH =
|
||||
/^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
export const SAFE_AUDIO_PROXY_PATH =
|
||||
/^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
export const SAFE_VIDEO_PROXY_PATH =
|
||||
/^\/api\/upload\/video\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
||||
|
||||
export type VideoAssetAccessContext = {
|
||||
@@ -80,6 +83,19 @@ export function extractAudioFileNameFromProxyUrl(url: string): string | null {
|
||||
return filename || null;
|
||||
}
|
||||
|
||||
export function extractVideoKeyFromProxyUrl(url: string): string | null {
|
||||
if (!SAFE_VIDEO_PROXY_PATH.test(url)) return null;
|
||||
const filename = url.slice(VIDEO_PROXY_PREFIX.length);
|
||||
if (!filename) return null;
|
||||
return `videos/${filename}`;
|
||||
}
|
||||
|
||||
export function extractVideoFileNameFromProxyUrl(url: string): string | null {
|
||||
if (!SAFE_VIDEO_PROXY_PATH.test(url)) return null;
|
||||
const filename = url.slice(VIDEO_PROXY_PREFIX.length);
|
||||
return filename || null;
|
||||
}
|
||||
|
||||
export function mediaUrlToR2Key(url: string): string | null {
|
||||
if (url.includes(IMAGE_PROXY_PREFIX)) {
|
||||
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
|
||||
|
||||
Reference in New Issue
Block a user