mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: enable S3 video uploads and update related configurations
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES. - Updated .env.example and .env.docker.example to reflect new configuration options. - Enhanced Content Security Policy to include origins for S3-compatible storage. - Updated dependencies for AWS SDK to support new features. - Refactored upload logic to accommodate both Bunny and S3 upload providers. - Updated documentation to clarify the usage of direct uploads and S3 configurations. - Closes #11
This commit is contained in:
@@ -22,6 +22,8 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||
|
||||
type ProjectOption = {
|
||||
id: string;
|
||||
@@ -35,6 +37,7 @@ interface VideoDragDropUploaderProps {
|
||||
workspaceId?: string;
|
||||
projectOptions?: ProjectOption[];
|
||||
canUpload?: boolean;
|
||||
directUploadProvider?: DirectUploadProvider;
|
||||
}
|
||||
|
||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
||||
@@ -68,6 +71,7 @@ export function VideoDragDropUploader({
|
||||
workspaceId,
|
||||
projectOptions,
|
||||
canUpload = false,
|
||||
directUploadProvider = 'bunny',
|
||||
}: VideoDragDropUploaderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -86,11 +90,23 @@ export function VideoDragDropUploader({
|
||||
);
|
||||
|
||||
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
|
||||
const pendingUploadRef = useRef<{
|
||||
projectId: string;
|
||||
videoId: string;
|
||||
uploadToken: string;
|
||||
} | null>(null);
|
||||
const pendingUploadRef = useRef<
|
||||
| {
|
||||
type: 'bunny';
|
||||
projectId: string;
|
||||
videoId: string;
|
||||
uploadToken: string;
|
||||
}
|
||||
| {
|
||||
type: 'r2';
|
||||
projectId: string;
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
const cancelRequestedRef = useRef(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const hasLoadedProjectsRef = useRef(false);
|
||||
@@ -205,11 +221,20 @@ export function VideoDragDropUploader({
|
||||
|
||||
if (pending) {
|
||||
try {
|
||||
await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
|
||||
});
|
||||
if (pending.type === 'bunny') {
|
||||
await fetch(`/api/projects/${pending.projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: pending.videoId, uploadToken: pending.uploadToken }),
|
||||
});
|
||||
} else {
|
||||
await cleanupPendingR2VideoUpload(pending.projectId, {
|
||||
objectKey: pending.objectKey,
|
||||
uploadToken: pending.uploadToken,
|
||||
reservationId: pending.reservationId,
|
||||
thumbnailObjectKey: pending.thumbnailObjectKey,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup cancelled upload:', error);
|
||||
}
|
||||
@@ -232,12 +257,80 @@ export function VideoDragDropUploader({
|
||||
setSelectedProjectId(projectId);
|
||||
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
|
||||
|
||||
let createdVideoId: string | null = null;
|
||||
let uploadToken: string | null = null;
|
||||
let pendingCleanup:
|
||||
| { type: 'bunny'; videoId: string; uploadToken: string }
|
||||
| {
|
||||
type: 'r2';
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
const title = getDefaultTitleFromFile(file);
|
||||
|
||||
if (directUploadProvider === 'r2') {
|
||||
const uploaded = await uploadVideoToR2(projectId, file, {
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(progress);
|
||||
setUploadStatus(`Uploading... ${progress}%`);
|
||||
},
|
||||
});
|
||||
pendingCleanup = {
|
||||
type: 'r2',
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
};
|
||||
pendingUploadRef.current = {
|
||||
type: 'r2',
|
||||
projectId,
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
};
|
||||
|
||||
setUploadStatus('Saving video...');
|
||||
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description: null,
|
||||
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');
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setDroppedFile(null);
|
||||
cleanupUploadState();
|
||||
router.push(`/projects/${projectId}`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -259,9 +352,11 @@ export function VideoDragDropUploader({
|
||||
throw new Error(initPayload?.error || 'Failed to initialize upload');
|
||||
}
|
||||
|
||||
createdVideoId = initPayload.data.videoId;
|
||||
uploadToken = initPayload.data.uploadToken;
|
||||
const createdVideoId = initPayload.data.videoId;
|
||||
const uploadToken = initPayload.data.uploadToken;
|
||||
pendingCleanup = { type: 'bunny', videoId: createdVideoId, uploadToken };
|
||||
pendingUploadRef.current = {
|
||||
type: 'bunny',
|
||||
projectId,
|
||||
videoId: createdVideoId,
|
||||
uploadToken,
|
||||
@@ -346,13 +441,20 @@ export function VideoDragDropUploader({
|
||||
return;
|
||||
}
|
||||
|
||||
if (createdVideoId && uploadToken) {
|
||||
if (pendingCleanup) {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: createdVideoId, uploadToken }),
|
||||
});
|
||||
if (pendingCleanup.type === 'bunny') {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: pendingCleanup.videoId,
|
||||
uploadToken: pendingCleanup.uploadToken,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to cleanup pending upload:', cleanupError);
|
||||
}
|
||||
@@ -364,7 +466,7 @@ export function VideoDragDropUploader({
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
|
||||
}
|
||||
},
|
||||
[bunnyCdnHostname, cleanupUploadState, projectsById, router]
|
||||
[bunnyCdnHostname, cleanupUploadState, directUploadProvider, projectsById, router]
|
||||
);
|
||||
|
||||
const handleDropFile = useCallback(
|
||||
|
||||
@@ -71,14 +71,16 @@ interface VideoPageContentProps {
|
||||
mode: VideoPageMode;
|
||||
videoId: string;
|
||||
projectId?: string;
|
||||
bunnyUploadsEnabled?: boolean;
|
||||
directUploadsEnabled?: boolean;
|
||||
directUploadProvider?: import('@/components/video-page/types').DirectUploadProvider;
|
||||
}
|
||||
|
||||
export function VideoPageContent({
|
||||
mode,
|
||||
videoId,
|
||||
projectId: propProjectId,
|
||||
bunnyUploadsEnabled = true,
|
||||
directUploadsEnabled = false,
|
||||
directUploadProvider = 'bunny',
|
||||
}: VideoPageContentProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -200,7 +202,8 @@ export function VideoPageContent({
|
||||
} = useVersionActions({
|
||||
projectId: propProjectId,
|
||||
videoId,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
directUploadProvider,
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
@@ -282,6 +285,20 @@ export function VideoPageContent({
|
||||
if (!bunnyCdnHostname) return '';
|
||||
return `https://${bunnyCdnHostname}/${activeVersion.videoId}/playlist.m3u8`;
|
||||
}
|
||||
if (activeVersion.providerId === 'r2') {
|
||||
if (activeVersion.originalUrl.startsWith('/api/upload/video/')) {
|
||||
return activeVersion.originalUrl;
|
||||
}
|
||||
if (activeVersion.originalUrl.startsWith('videos/')) {
|
||||
const filename = activeVersion.originalUrl.slice('videos/'.length);
|
||||
return `/api/upload/video/${filename}`;
|
||||
}
|
||||
if (activeVersion.videoId.startsWith('videos/')) {
|
||||
const filename = activeVersion.videoId.slice('videos/'.length);
|
||||
return `/api/upload/video/${filename}`;
|
||||
}
|
||||
return activeVersion.originalUrl;
|
||||
}
|
||||
try {
|
||||
const url = new URL(activeVersion.originalUrl);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
@@ -551,7 +568,8 @@ export function VideoPageContent({
|
||||
const isBunnyVersion = activeVersion?.providerId === 'bunny';
|
||||
const showBunnyProcessingOverlay =
|
||||
isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
|
||||
const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
|
||||
const isR2Version = activeVersion?.providerId === 'r2';
|
||||
const showBunnyErrorOverlay = (isBunnyVersion || isR2Version) && bunnyPlaybackState === 'error';
|
||||
|
||||
const confirmGuestName = useCallback(() => {
|
||||
if (!guestName.trim()) return;
|
||||
@@ -732,7 +750,7 @@ export function VideoPageContent({
|
||||
onDownload={headerActions.onDownload}
|
||||
projectId={projectId}
|
||||
videoId={videoId}
|
||||
bunnyUploadsEnabled={bunnyUploadsEnabled}
|
||||
directUploadsEnabled={directUploadsEnabled}
|
||||
showVersionDialog={showVersionDialog}
|
||||
setShowVersionDialog={setShowVersionDialog}
|
||||
newVersionMode={newVersionMode}
|
||||
|
||||
@@ -45,7 +45,9 @@ export const DownloadControls = memo(function DownloadControls({
|
||||
|
||||
const isVideoDownloadAvailable =
|
||||
videoCanDownload &&
|
||||
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
|
||||
(activeVersion.providerId === 'bunny' ||
|
||||
activeVersion.providerId === 'direct' ||
|
||||
activeVersion.providerId === 'r2');
|
||||
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
return (
|
||||
@@ -180,7 +182,9 @@ export const DownloadMenuItems = memo(function DownloadMenuItems({
|
||||
|
||||
const isVideoDownloadAvailable =
|
||||
videoCanDownload &&
|
||||
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
|
||||
(activeVersion.providerId === 'bunny' ||
|
||||
activeVersion.providerId === 'direct' ||
|
||||
activeVersion.providerId === 'r2');
|
||||
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
return (
|
||||
|
||||
@@ -51,6 +51,15 @@ interface UseCommentActionsParams extends CommentActionsConfig {
|
||||
fetchAssets: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getAudioUploadFilename(blob: Blob): string {
|
||||
const mime = blob.type.split(';')[0].trim().toLowerCase();
|
||||
if (mime === 'audio/mp4') return 'recording.m4a';
|
||||
if (mime === 'audio/ogg' || mime === 'audio/opus') return 'recording.ogg';
|
||||
if (mime === 'audio/mpeg') return 'recording.mp3';
|
||||
if (mime === 'audio/wav') return 'recording.wav';
|
||||
return 'recording.webm';
|
||||
}
|
||||
|
||||
export function useCommentActions({
|
||||
videoId,
|
||||
setVideo,
|
||||
@@ -430,7 +439,8 @@ export function useCommentActions({
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(audioChunksRef.current, { type: recordedMime });
|
||||
setAudioBlob(blob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
if (recordingTimerRef.current) {
|
||||
@@ -472,7 +482,8 @@ export function useCommentActions({
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
const uploadFilename = getAudioUploadFilename(audioBlob);
|
||||
formData.append('audio', audioBlob, uploadFilename);
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -514,7 +525,7 @@ export function useCommentActions({
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
if (audioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
formData.append('audio', audioBlob, getAudioUploadFilename(audioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -829,7 +840,8 @@ export function useCommentActions({
|
||||
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
|
||||
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(replyAudioChunksRef.current, { type: recordedMime });
|
||||
setReplyAudioBlob(blob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
if (replyRecordingTimerRef.current) {
|
||||
@@ -870,7 +882,7 @@ export function useCommentActions({
|
||||
setIsUploadingReplyAudio(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -912,7 +924,7 @@ export function useCommentActions({
|
||||
|
||||
if (replyAudioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
|
||||
@@ -67,7 +67,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
toast.error('Download is disabled for this shared link');
|
||||
return;
|
||||
}
|
||||
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
|
||||
if (
|
||||
activeVersion.providerId !== 'bunny' &&
|
||||
activeVersion.providerId !== 'direct' &&
|
||||
activeVersion.providerId !== 'r2'
|
||||
) {
|
||||
toast.error('This video source does not support direct download');
|
||||
return;
|
||||
}
|
||||
@@ -97,6 +101,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
}
|
||||
|
||||
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
|
||||
} else if (activeVersion.providerId === 'r2') {
|
||||
if (!activeVersion.originalUrl.startsWith('/api/upload/video/')) {
|
||||
throw new Error('Direct download URL is not allowed');
|
||||
}
|
||||
downloadUrl = activeVersion.originalUrl;
|
||||
} else {
|
||||
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||
if (!downloadUrl) {
|
||||
@@ -113,8 +122,9 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
if (activeVersion.providerId === 'direct') {
|
||||
a.download = `${baseName}.mp4`;
|
||||
if (activeVersion.providerId === 'direct' || activeVersion.providerId === 'r2') {
|
||||
const ext = activeVersion.originalUrl.split('.').pop()?.toLowerCase() || 'mp4';
|
||||
a.download = `${baseName}.${ext}`;
|
||||
}
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/lib/video-providers';
|
||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
|
||||
interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
@@ -21,7 +22,8 @@ interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
export function useVersionActions({
|
||||
projectId,
|
||||
videoId,
|
||||
bunnyUploadsEnabled = true,
|
||||
directUploadsEnabled = false,
|
||||
directUploadProvider = 'bunny',
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
@@ -58,13 +60,111 @@ export function useVersionActions({
|
||||
}
|
||||
};
|
||||
|
||||
const uploadNewVersionFile = async (file: File, title: string) => {
|
||||
if (!projectId) throw new Error('Missing project');
|
||||
|
||||
if (directUploadProvider === 'r2') {
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const uploaded = await uploadVideoToR2(projectId, file, {
|
||||
onProgress: (progress) => {
|
||||
setNewVersionUploadProgress(progress);
|
||||
setNewVersionUploadStatus(`Uploading... ${progress}%`);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
finalVideoUrl: uploaded.proxyUrl,
|
||||
finalProviderId: 'r2',
|
||||
finalProviderVideoId: uploaded.objectKey,
|
||||
finalThumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
|
||||
finalDuration: uploaded.duration,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
objectKey: uploaded.objectKey,
|
||||
reservationId: uploaded.reservationId,
|
||||
pendingCleanup: {
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||
const {
|
||||
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
setNewVersionUploadStatus('Uploading video...');
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: signature,
|
||||
AuthorizationExpire: expirationTime.toString(),
|
||||
VideoId: bunnyVideoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: file.type,
|
||||
title,
|
||||
},
|
||||
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||
setNewVersionUploadProgress(Number(percentage));
|
||||
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNewVersionUploadStatus('Processing video...');
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
|
||||
return {
|
||||
finalVideoUrl: `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`,
|
||||
finalProviderId: 'bunny',
|
||||
finalProviderVideoId: bunnyVideoId,
|
||||
finalThumbnailUrl: bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
|
||||
: null,
|
||||
finalDuration: null as number | null,
|
||||
uploadToken,
|
||||
objectKey: null as string | null,
|
||||
reservationId: null as string | null,
|
||||
pendingCleanup: {
|
||||
bunnyVideoId,
|
||||
uploadToken,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
if (!projectId) return;
|
||||
setIsCreatingVersion(true);
|
||||
setNewVersionUploadStatus('');
|
||||
setNewVersionUploadProgress(0);
|
||||
let uploadedBunnyVideoId: string | null = null;
|
||||
let uploadedBunnyUploadToken: string | null = null;
|
||||
let pendingCleanup:
|
||||
| {
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
}
|
||||
| {
|
||||
bunnyVideoId: string;
|
||||
uploadToken: string;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
let finalVideoUrl = '';
|
||||
@@ -72,6 +172,9 @@ export function useVersionActions({
|
||||
let finalProviderVideoId = '';
|
||||
let finalThumbnailUrl: string | null = null;
|
||||
let finalDuration: number | null = null;
|
||||
let uploadToken: string | null = null;
|
||||
let objectKey: string | null = null;
|
||||
let reservationId: string | null = null;
|
||||
|
||||
if (newVersionMode === 'url') {
|
||||
if (!newVersionSource) throw new Error('Invalid URL');
|
||||
@@ -82,7 +185,7 @@ export function useVersionActions({
|
||||
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
||||
finalDuration = meta?.duration || null;
|
||||
} else {
|
||||
if (!bunnyUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
|
||||
if (!directUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
|
||||
if (!newVersionFile) throw new Error('No file selected');
|
||||
let title = newVersionFile.name;
|
||||
if (newVersionLabel.trim()) {
|
||||
@@ -91,55 +194,16 @@ export function useVersionActions({
|
||||
title = title.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||
const {
|
||||
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
uploadedBunnyVideoId = bunnyVideoId;
|
||||
uploadedBunnyUploadToken = uploadToken;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
setNewVersionUploadStatus('Uploading video...');
|
||||
const upload = new tus.Upload(newVersionFile, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: signature,
|
||||
AuthorizationExpire: expirationTime.toString(),
|
||||
VideoId: bunnyVideoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: newVersionFile.type,
|
||||
title,
|
||||
},
|
||||
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||
setNewVersionUploadProgress(Number(percentage));
|
||||
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setNewVersionUploadStatus('Processing video...');
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
|
||||
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
|
||||
finalProviderId = 'bunny';
|
||||
finalProviderVideoId = bunnyVideoId;
|
||||
finalThumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
|
||||
: null;
|
||||
const uploaded = await uploadNewVersionFile(newVersionFile, title);
|
||||
finalVideoUrl = uploaded.finalVideoUrl;
|
||||
finalProviderId = uploaded.finalProviderId;
|
||||
finalProviderVideoId = uploaded.finalProviderVideoId;
|
||||
finalThumbnailUrl = uploaded.finalThumbnailUrl;
|
||||
finalDuration = uploaded.finalDuration;
|
||||
uploadToken = uploaded.uploadToken;
|
||||
objectKey = uploaded.objectKey;
|
||||
reservationId = uploaded.reservationId;
|
||||
pendingCleanup = uploaded.pendingCleanup;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
|
||||
@@ -149,7 +213,9 @@ export function useVersionActions({
|
||||
videoUrl: finalVideoUrl,
|
||||
providerId: finalProviderId,
|
||||
providerVideoId: finalProviderVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
uploadToken,
|
||||
objectKey,
|
||||
reservationId,
|
||||
versionLabel: newVersionLabel.trim() || null,
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
@@ -180,24 +246,30 @@ export function useVersionActions({
|
||||
setNewVersionSource(null);
|
||||
setNewVersionFile(null);
|
||||
setNewVersionUploadStatus('');
|
||||
pendingCleanup = null;
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: uploadedBunnyVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
}),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
if (pendingCleanup && projectId) {
|
||||
if ('objectKey' in pendingCleanup) {
|
||||
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
|
||||
} else {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: pendingCleanup.bunnyVideoId,
|
||||
uploadToken: pendingCleanup.uploadToken,
|
||||
}),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
}
|
||||
}
|
||||
console.error('Failed to create version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to create version');
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
setNewVersionUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,34 +281,28 @@ export function useVersionActions({
|
||||
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
return { ...prev, versions: remaining };
|
||||
});
|
||||
|
||||
if (activeVersionId === versionToDelete) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
if (remaining.length > 0) {
|
||||
setActiveVersionId(remaining[0].id);
|
||||
} else {
|
||||
setActiveVersionId(null);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
setShowDeleteVersionDialog(false);
|
||||
setVersionToDelete(null);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || 'Failed to delete version');
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Failed to delete version');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete version');
|
||||
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
if (activeVersionId === versionToDelete && remaining.length > 0) {
|
||||
const nextActive = remaining.find((v) => v.isActive) ?? remaining[0];
|
||||
setActiveVersionId(nextActive.id);
|
||||
}
|
||||
return { ...prev, versions: remaining };
|
||||
});
|
||||
|
||||
setShowDeleteVersionDialog(false);
|
||||
setVersionToDelete(null);
|
||||
toast.success('Version deleted');
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
console.error('Failed to delete version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to delete version');
|
||||
} finally {
|
||||
setIsDeletingVersion(false);
|
||||
}
|
||||
@@ -259,10 +325,8 @@ export function useVersionActions({
|
||||
newVersionUploadStatus,
|
||||
handleNewVersionUrlChange,
|
||||
handleCreateVersion,
|
||||
|
||||
showDeleteVersionDialog,
|
||||
setShowDeleteVersionDialog,
|
||||
versionToDelete,
|
||||
setVersionToDelete,
|
||||
isDeletingVersion,
|
||||
handleDeleteVersion,
|
||||
|
||||
@@ -204,9 +204,10 @@ export function useVideoPlayer({
|
||||
if (!activeProviderId) return;
|
||||
const isYoutube = activeProviderId === 'youtube';
|
||||
const isBunny = activeProviderId === 'bunny';
|
||||
const isR2 = activeProviderId === 'r2';
|
||||
|
||||
if (isYoutube && !isApiLoaded) return;
|
||||
if (!isYoutube && !isBunny) return;
|
||||
if (!isYoutube && !isBunny && !isR2) return;
|
||||
|
||||
const currentVersionKey = `${activeProviderId ?? 'none'}:${activeVersionId ?? 'none'}`;
|
||||
const versionChanged = previousVersionKeyRef.current !== currentVersionKey;
|
||||
@@ -623,6 +624,132 @@ export function useVideoPlayer({
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
} else if (isR2) {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
let cachedDuration = 0;
|
||||
let destroyed = false;
|
||||
|
||||
const syncDuration = () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
setVideoDuration(videoEl.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProgress = () => {
|
||||
const current = videoEl.currentTime || 0;
|
||||
const duration =
|
||||
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
||||
? videoEl.duration
|
||||
: cachedDuration;
|
||||
scheduleWatchProgressSaveRef.current({
|
||||
progress: current,
|
||||
duration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (destroyed) return;
|
||||
setBunnyPlaybackState('none');
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
setIsReady(true);
|
||||
syncDuration();
|
||||
if (!videoEl.paused) {
|
||||
startBunnyFrameTracking();
|
||||
}
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
setIsPlaying(true);
|
||||
setBunnyPlaybackState('none');
|
||||
syncDuration();
|
||||
startBunnyFrameTracking();
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
setIsPlaying(false);
|
||||
stopBunnyFrameTracking();
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
stopBunnyFrameTracking();
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!isDraggingRef.current) {
|
||||
setCurrentTime(videoEl.currentTime || 0);
|
||||
}
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onVideoError = () => {
|
||||
if (destroyed) return;
|
||||
setBunnyPlaybackState('error');
|
||||
};
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('ended', onEnded);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.addEventListener('error', onVideoError);
|
||||
|
||||
const playbackSrc =
|
||||
embedUrl.startsWith('/') && typeof window !== 'undefined'
|
||||
? `${window.location.origin}${embedUrl}`
|
||||
: embedUrl;
|
||||
videoEl.src = playbackSrc;
|
||||
videoEl.load();
|
||||
|
||||
playerRef.current = {
|
||||
playVideo: () => {
|
||||
videoEl.play().catch((err) => console.error('Error playing video:', err));
|
||||
},
|
||||
pauseVideo: () => videoEl.pause(),
|
||||
seekTo: (time: number) => {
|
||||
videoEl.currentTime = time;
|
||||
},
|
||||
mute: () => {
|
||||
videoEl.muted = true;
|
||||
},
|
||||
unMute: () => {
|
||||
videoEl.muted = false;
|
||||
},
|
||||
isMuted: () => videoEl.muted,
|
||||
getCurrentTime: () => videoEl.currentTime || 0,
|
||||
getDuration: () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () =>
|
||||
videoEl.paused
|
||||
? (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
: (window.YT?.PlayerState?.PLAYING ?? 1),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
stopBunnyFrameTracking();
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('ended', onEnded);
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('error', onVideoError);
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -633,7 +760,7 @@ export function useVideoPlayer({
|
||||
} else {
|
||||
window.onYouTubeIframeAPIReady = initPlayer;
|
||||
}
|
||||
} else if (isBunny) {
|
||||
} else if (isBunny || isR2) {
|
||||
initPlayer();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
@@ -170,7 +170,7 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
>
|
||||
<div className={cn('relative w-full h-full', isFullscreenMode && 'absolute inset-0')}>
|
||||
{activeProviderId === 'bunny' ? (
|
||||
{activeProviderId === 'bunny' || activeProviderId === 'r2' ? (
|
||||
<div
|
||||
ref={bunnyViewportRef}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black"
|
||||
@@ -259,7 +259,9 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
Unable To Load Video
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Bunny stream is unavailable right now. Please refresh this page in a moment.
|
||||
{activeProviderId === 'r2'
|
||||
? 'This video file could not be loaded. Try refreshing the page or re-uploading the version.'
|
||||
: 'The Bunny stream is unavailable right now. Please refresh this page in a moment.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,10 +185,13 @@ export interface CommentActionsConfig {
|
||||
videoId: string;
|
||||
}
|
||||
|
||||
export type DirectUploadProvider = 'bunny' | 'r2';
|
||||
|
||||
export interface VersionActionsConfig {
|
||||
projectId?: string;
|
||||
videoId: string;
|
||||
bunnyUploadsEnabled?: boolean;
|
||||
directUploadsEnabled?: boolean;
|
||||
directUploadProvider?: DirectUploadProvider;
|
||||
}
|
||||
|
||||
export interface VideoPageHeaderActions {
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { VideoSource } from '@/lib/video-providers';
|
||||
interface VersionActionsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bunnyUploadsEnabled: boolean;
|
||||
directUploadsEnabled: boolean;
|
||||
newVersionMode: 'url' | 'file';
|
||||
onNewVersionModeChange: (mode: 'url' | 'file') => void;
|
||||
newVersionUrl: string;
|
||||
@@ -49,7 +49,7 @@ interface VersionActionsDialogProps {
|
||||
export const VersionActionsDialog = memo(function VersionActionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
newVersionMode,
|
||||
onNewVersionModeChange,
|
||||
newVersionUrl,
|
||||
@@ -88,10 +88,10 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
|
||||
className="mb-2"
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
className={`grid w-full ${directUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<TabsTrigger value="url">Link URL</TabsTrigger>
|
||||
{bunnyUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
|
||||
{directUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ interface VideoPageHeaderProps {
|
||||
onDownload: (preference?: BunnyDownloadPreference) => void;
|
||||
projectId?: string;
|
||||
videoId: string;
|
||||
bunnyUploadsEnabled: boolean;
|
||||
directUploadsEnabled: boolean;
|
||||
showVersionDialog: boolean;
|
||||
setShowVersionDialog: (open: boolean) => void;
|
||||
newVersionMode: 'url' | 'file';
|
||||
@@ -105,7 +105,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
onDownload,
|
||||
projectId,
|
||||
videoId,
|
||||
bunnyUploadsEnabled,
|
||||
directUploadsEnabled,
|
||||
showVersionDialog,
|
||||
setShowVersionDialog,
|
||||
newVersionMode,
|
||||
@@ -261,7 +261,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
<VersionActionsDialog
|
||||
open={showVersionDialog}
|
||||
onOpenChange={setShowVersionDialog}
|
||||
bunnyUploadsEnabled={bunnyUploadsEnabled}
|
||||
directUploadsEnabled={directUploadsEnabled}
|
||||
newVersionMode={newVersionMode}
|
||||
onNewVersionModeChange={setNewVersionMode}
|
||||
newVersionUrl={newVersionUrl}
|
||||
|
||||
Reference in New Issue
Block a user