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:
yusufipk
2026-05-27 17:04:39 +02:00
parent b6de3a29aa
commit 4bf6e821af
57 changed files with 2707 additions and 436 deletions
+123 -21
View File
@@ -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(
+23 -5
View File
@@ -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}
+6 -2
View File
@@ -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,
+129 -2
View File
@@ -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);
+4 -2
View File
@@ -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>
+4 -1
View File
@@ -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>
+3 -3
View File
@@ -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}