mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: bulk video uploads and S3 asset video support (#18)
Add multi-file drag-and-drop queues for project videos and the assets pane, and route asset video uploads through S3/R2 when direct Bunny uploads are disabled.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -26,17 +27,15 @@ import {
|
|||||||
type VideoSource,
|
type VideoSource,
|
||||||
} from '@/lib/video-providers';
|
} from '@/lib/video-providers';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
import {
|
||||||
|
cleanupPendingProjectUpload,
|
||||||
|
getDefaultTitleFromFile,
|
||||||
|
isVideoFile,
|
||||||
|
uploadProjectVideo,
|
||||||
|
type ActiveTusUpload,
|
||||||
|
type PendingProjectUploadCleanup,
|
||||||
|
} from '@/lib/client/project-video-upload';
|
||||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||||
import * as tus from 'tus-js-client';
|
|
||||||
|
|
||||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
|
||||||
|
|
||||||
function isVideoFile(file: File): boolean {
|
|
||||||
if (file.type.startsWith('video/')) return true;
|
|
||||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
|
||||||
return !!extension && VIDEO_FILE_EXTENSIONS.includes(extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewVideoPageClient({
|
export default function NewVideoPageClient({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -53,26 +52,21 @@ export default function NewVideoPageClient({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
|
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
|
||||||
|
|
||||||
// URL Mode State
|
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
|
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
|
||||||
const [urlError, setUrlError] = useState('');
|
const [urlError, setUrlError] = useState('');
|
||||||
|
|
||||||
// Upload Mode State
|
|
||||||
const [uploadMode, setUploadMode] = useState<'url' | 'file'>('url');
|
const [uploadMode, setUploadMode] = useState<'url' | 'file'>('url');
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadStatus, setUploadStatus] = useState('');
|
const [uploadStatus, setUploadStatus] = useState('');
|
||||||
|
const [currentUploadIndex, setCurrentUploadIndex] = useState(0);
|
||||||
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
const [isFileDragOver, setIsFileDragOver] = useState(false);
|
||||||
const [pendingBunnyVideoId, setPendingBunnyVideoId] = useState<string | null>(null);
|
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
|
||||||
const [pendingBunnyUploadToken, setPendingBunnyUploadToken] = useState<string | null>(null);
|
const pendingUploadRef = useRef<PendingProjectUploadCleanup | null>(null);
|
||||||
const pendingBunnyVideoIdRef = useRef<string | null>(null);
|
const cancelRequestedRef = useRef(false);
|
||||||
const pendingBunnyUploadTokenRef = useRef<string | null>(null);
|
|
||||||
const pendingR2ObjectKeyRef = useRef<string | null>(null);
|
|
||||||
const pendingR2UploadTokenRef = useRef<string | null>(null);
|
|
||||||
const pendingR2ReservationIdRef = useRef<string | null>(null);
|
|
||||||
const activeTusUploadRef = useRef<tus.Upload | null>(null);
|
|
||||||
const fileDragDepthRef = useRef(0);
|
const fileDragDepthRef = useRef(0);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [submitError, setSubmitError] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -80,61 +74,31 @@ export default function NewVideoPageClient({
|
|||||||
description: '',
|
description: '',
|
||||||
});
|
});
|
||||||
const isUploadingFile = isLoading && uploadMode === 'file';
|
const isUploadingFile = isLoading && uploadMode === 'file';
|
||||||
|
const isMultiFileUpload = selectedFiles.length > 1;
|
||||||
const leaveWarningMessage =
|
const leaveWarningMessage =
|
||||||
'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
|
|
||||||
}, [pendingBunnyVideoId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
|
|
||||||
}, [pendingBunnyUploadToken]);
|
|
||||||
|
|
||||||
const cleanupPendingBunnyVideo = useCallback(
|
|
||||||
async (videoId: string, uploadToken: string, keepalive = false) => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ videoId, uploadToken }),
|
|
||||||
keepalive,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to cleanup pending Bunny upload:', error);
|
|
||||||
} finally {
|
|
||||||
if (pendingBunnyVideoIdRef.current === videoId) {
|
|
||||||
pendingBunnyVideoIdRef.current = null;
|
|
||||||
setPendingBunnyVideoId(null);
|
|
||||||
}
|
|
||||||
if (pendingBunnyUploadTokenRef.current === uploadToken) {
|
|
||||||
pendingBunnyUploadTokenRef.current = null;
|
|
||||||
setPendingBunnyUploadToken(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const abortAndCleanupPendingUpload = useCallback(
|
const abortAndCleanupPendingUpload = useCallback(
|
||||||
(keepalive = false) => {
|
(keepalive = false) => {
|
||||||
const pendingVideoId = pendingBunnyVideoIdRef.current;
|
cancelRequestedRef.current = true;
|
||||||
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
|
|
||||||
if (!pendingVideoId || !pendingUploadToken) return;
|
|
||||||
|
|
||||||
if (activeTusUploadRef.current) {
|
if (activeTusUploadRef.current) {
|
||||||
try {
|
try {
|
||||||
activeTusUploadRef.current.abort(true);
|
void Promise.resolve(activeTusUploadRef.current.abort(false));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore abort failures; we'll still attempt cleanup.
|
// Ignore abort failures.
|
||||||
} finally {
|
} finally {
|
||||||
activeTusUploadRef.current = null;
|
activeTusUploadRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
|
const pending = pendingUploadRef.current;
|
||||||
|
if (pending) {
|
||||||
|
void cleanupPendingProjectUpload(projectId, pending, keepalive);
|
||||||
|
pendingUploadRef.current = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[cleanupPendingBunnyVideo]
|
[projectId]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -168,9 +132,8 @@ export default function NewVideoPageClient({
|
|||||||
window.removeEventListener('pagehide', handlePageHide);
|
window.removeEventListener('pagehide', handlePageHide);
|
||||||
window.removeEventListener('popstate', handlePopState);
|
window.removeEventListener('popstate', handlePopState);
|
||||||
};
|
};
|
||||||
}, [abortAndCleanupPendingUpload, isUploadingFile]);
|
}, [abortAndCleanupPendingUpload, isUploadingFile, leaveWarningMessage]);
|
||||||
|
|
||||||
// Auto-fetch metadata when a valid video source is detected
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!videoSource) return;
|
if (!videoSource) return;
|
||||||
|
|
||||||
@@ -216,41 +179,70 @@ export default function NewVideoPageClient({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const addSelectedFiles = useCallback(
|
||||||
const file = e.target.files?.[0];
|
(incoming: File[]) => {
|
||||||
if (file) {
|
const validFiles: File[] = [];
|
||||||
if (!isVideoFile(file)) {
|
let invalidCount = 0;
|
||||||
setSubmitError('Please select a valid video file.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedFile(file);
|
|
||||||
setSubmitError('');
|
|
||||||
if (!formData.title) {
|
|
||||||
// Strip extension from filename for default title
|
|
||||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
|
||||||
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSelectedVideoFile = useCallback(
|
for (const file of incoming) {
|
||||||
(file: File) => {
|
if (!isVideoFile(file)) {
|
||||||
if (!isVideoFile(file)) {
|
invalidCount += 1;
|
||||||
setSubmitError('Please select a valid video file.');
|
continue;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
setSubmitError('Please select valid video files.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedFile(file);
|
if (invalidCount > 0) {
|
||||||
setSubmitError('');
|
setSubmitError(
|
||||||
|
`${invalidCount} file${invalidCount === 1 ? '' : 's'} skipped (not a video).`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSubmitError('');
|
||||||
|
}
|
||||||
|
|
||||||
if (!formData.title) {
|
setSelectedFiles((prev) => {
|
||||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
const next = [...prev];
|
||||||
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
for (const file of validFiles) {
|
||||||
|
const duplicate = next.some(
|
||||||
|
(existing) =>
|
||||||
|
existing.name === file.name &&
|
||||||
|
existing.size === file.size &&
|
||||||
|
existing.lastModified === file.lastModified
|
||||||
|
);
|
||||||
|
if (!duplicate) next.push(file);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length === 1 && !formData.title) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
title: getDefaultTitleFromFile(validFiles[0]),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[formData.title]
|
[formData.title]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files ?? []);
|
||||||
|
if (files.length > 0) {
|
||||||
|
addSelectedFiles(files);
|
||||||
|
}
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSelectedFile = (index: number) => {
|
||||||
|
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileDragEnter = useCallback(
|
const handleFileDragEnter = useCallback(
|
||||||
(event: React.DragEvent<HTMLLabelElement>) => {
|
(event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -290,83 +282,105 @@ export default function NewVideoPageClient({
|
|||||||
setIsFileDragOver(false);
|
setIsFileDragOver(false);
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
|
|
||||||
const file = Array.from(event.dataTransfer.files)[0];
|
const files = Array.from(event.dataTransfer.files);
|
||||||
if (!file) return;
|
if (files.length === 0) return;
|
||||||
setSelectedVideoFile(file);
|
addSelectedFiles(files);
|
||||||
},
|
},
|
||||||
[isLoading, setSelectedVideoFile]
|
[addSelectedFiles, isLoading]
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadToBunny = async (
|
const uploadSingleFileWithForm = async (file: File) => {
|
||||||
file: File
|
cancelRequestedRef.current = false;
|
||||||
): Promise<{
|
pendingUploadRef.current = null;
|
||||||
videoId: string;
|
|
||||||
libraryId: string;
|
const title = formData.title.trim() || getDefaultTitleFromFile(file);
|
||||||
providerId: string;
|
const description = formData.description.trim() || null;
|
||||||
url: string;
|
|
||||||
uploadToken: string;
|
await uploadProjectVideo(projectId, file, {
|
||||||
}> => {
|
provider: directUploadProvider,
|
||||||
// 1. Initialize Bunny Stream upload (creates video & gets signature)
|
title,
|
||||||
setUploadStatus('Initializing upload...');
|
description,
|
||||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
bunnyCdnHostname,
|
||||||
method: 'POST',
|
onProgress: (progress) => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
setUploadProgress(progress);
|
||||||
body: JSON.stringify({ title: formData.title || file.name }),
|
setUploadStatus(`Uploading... ${progress}%`);
|
||||||
|
},
|
||||||
|
onStatus: setUploadStatus,
|
||||||
|
onTusUploadReady: (upload) => {
|
||||||
|
activeTusUploadRef.current = upload;
|
||||||
|
},
|
||||||
|
onPendingUpload: (pending) => {
|
||||||
|
pendingUploadRef.current = pending;
|
||||||
|
},
|
||||||
|
isCancelled: () => cancelRequestedRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!initRes.ok) {
|
pendingUploadRef.current = null;
|
||||||
const data = await initRes.json();
|
activeTusUploadRef.current = null;
|
||||||
throw new Error(data.error || 'Failed to initialize upload');
|
};
|
||||||
|
|
||||||
|
const uploadMultipleFiles = async (files: File[]) => {
|
||||||
|
cancelRequestedRef.current = false;
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < files.length; index++) {
|
||||||
|
if (cancelRequestedRef.current) break;
|
||||||
|
|
||||||
|
const file = files[index];
|
||||||
|
setCurrentUploadIndex(index + 1);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadStatus(`Uploading ${index + 1} of ${files.length}: ${file.name}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadProjectVideo(projectId, file, {
|
||||||
|
provider: directUploadProvider,
|
||||||
|
bunnyCdnHostname,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
setUploadProgress(progress);
|
||||||
|
setUploadStatus(
|
||||||
|
`Uploading ${index + 1} of ${files.length}: ${file.name} (${progress}%)`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onStatus: (status) => {
|
||||||
|
setUploadStatus(`Uploading ${index + 1} of ${files.length}: ${status}`);
|
||||||
|
},
|
||||||
|
onTusUploadReady: (upload) => {
|
||||||
|
activeTusUploadRef.current = upload;
|
||||||
|
},
|
||||||
|
onPendingUpload: (pending) => {
|
||||||
|
pendingUploadRef.current = pending;
|
||||||
|
},
|
||||||
|
isCancelled: () => cancelRequestedRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingUploadRef.current = null;
|
||||||
|
activeTusUploadRef.current = null;
|
||||||
|
successCount += 1;
|
||||||
|
} catch (error) {
|
||||||
|
pendingUploadRef.current = null;
|
||||||
|
activeTusUploadRef.current = null;
|
||||||
|
failCount += 1;
|
||||||
|
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||||
|
setSubmitError(`${file.name}: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
if (successCount > 0 && failCount === 0) {
|
||||||
data: { videoId, libraryId, signature, expirationTime, uploadToken },
|
router.push(`/projects/${projectId}`);
|
||||||
} = await initRes.json();
|
return;
|
||||||
setPendingBunnyVideoId(videoId);
|
}
|
||||||
setPendingBunnyUploadToken(uploadToken);
|
|
||||||
pendingBunnyVideoIdRef.current = videoId;
|
|
||||||
pendingBunnyUploadTokenRef.current = uploadToken;
|
|
||||||
|
|
||||||
// 2. Upload via TUS
|
if (successCount > 0 && failCount > 0) {
|
||||||
return new Promise((resolve, reject) => {
|
setSubmitError(
|
||||||
setUploadStatus('Uploading video...');
|
`${successCount} uploaded, ${failCount} failed. Remove failed files and retry.`
|
||||||
const upload = new tus.Upload(file, {
|
);
|
||||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
return;
|
||||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
}
|
||||||
headers: {
|
|
||||||
AuthorizationSignature: signature,
|
if (failCount > 0 && successCount === 0) {
|
||||||
AuthorizationExpire: expirationTime.toString(),
|
throw new Error('All uploads failed');
|
||||||
VideoId: videoId,
|
}
|
||||||
LibraryId: libraryId,
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
filetype: file.type,
|
|
||||||
title: formData.title || file.name,
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
activeTusUploadRef.current = null;
|
|
||||||
reject(new Error('Upload failed: ' + error.message));
|
|
||||||
},
|
|
||||||
onProgress: (bytesUploaded, bytesTotal) => {
|
|
||||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
|
||||||
setUploadProgress(Number(percentage));
|
|
||||||
setUploadStatus(`Uploading... ${percentage}%`);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
activeTusUploadRef.current = null;
|
|
||||||
setUploadStatus('Processing video...');
|
|
||||||
resolve({
|
|
||||||
videoId,
|
|
||||||
libraryId,
|
|
||||||
providerId: 'bunny',
|
|
||||||
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`,
|
|
||||||
uploadToken,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
activeTusUploadRef.current = upload;
|
|
||||||
upload.start();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -376,130 +390,66 @@ export default function NewVideoPageClient({
|
|||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
setUploadStatus('');
|
setUploadStatus('');
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
setCurrentUploadIndex(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let uploadedBunnyVideoId: string | null = null;
|
|
||||||
let uploadedBunnyUploadToken: string | null = null;
|
|
||||||
let finalTitle = formData.title.trim();
|
|
||||||
const finalDescription = formData.description.trim() || null;
|
|
||||||
let finalVideoUrl = '';
|
|
||||||
let finalProviderId = '';
|
|
||||||
let finalVideoId = '';
|
|
||||||
let finalThumbnailUrl: string | null = null;
|
|
||||||
let finalDuration: number | null = null;
|
|
||||||
|
|
||||||
if (uploadMode === 'url') {
|
if (uploadMode === 'url') {
|
||||||
if (!videoSource) {
|
if (!videoSource) {
|
||||||
setUrlError('Please enter a valid video URL');
|
setUrlError('Please enter a valid video URL');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finalTitle = finalTitle || videoSource.metadata?.title || 'Untitled Video';
|
|
||||||
finalVideoUrl = videoSource.originalUrl;
|
|
||||||
finalProviderId = videoSource.providerId;
|
|
||||||
finalVideoId = videoSource.videoId;
|
|
||||||
finalThumbnailUrl = getThumbnailUrl(videoSource, 'large');
|
|
||||||
finalDuration = videoSource.metadata?.duration || null;
|
|
||||||
} else {
|
|
||||||
if (!directUploadsEnabled) {
|
|
||||||
throw new Error('Direct uploads are disabled by this host');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedFile) {
|
const finalTitle = formData.title.trim() || videoSource.metadata?.title || 'Untitled Video';
|
||||||
setSubmitError('Please select a video file to upload');
|
const finalDescription = formData.description.trim() || null;
|
||||||
setIsLoading(false);
|
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/videos`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: finalTitle,
|
||||||
|
description: finalDescription,
|
||||||
|
videoUrl: videoSource.originalUrl,
|
||||||
|
providerId: videoSource.providerId,
|
||||||
|
videoId: videoSource.videoId,
|
||||||
|
thumbnailUrl: getThumbnailUrl(videoSource, 'large'),
|
||||||
|
duration: videoSource.metadata?.duration || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSubmitError(data.error || 'Failed to add video');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finalTitle = finalTitle || selectedFile.name;
|
|
||||||
|
|
||||||
if (directUploadProvider === 'r2') {
|
router.push(`/projects/${projectId}`);
|
||||||
const r2Data = await uploadVideoToR2(projectId, selectedFile, {
|
|
||||||
onProgress: (progress) => {
|
|
||||||
setUploadProgress(progress);
|
|
||||||
setUploadStatus(`Uploading... ${progress}%`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
pendingR2ObjectKeyRef.current = r2Data.objectKey;
|
|
||||||
pendingR2UploadTokenRef.current = r2Data.uploadToken;
|
|
||||||
pendingR2ReservationIdRef.current = r2Data.reservationId;
|
|
||||||
|
|
||||||
finalVideoUrl = r2Data.proxyUrl;
|
|
||||||
finalProviderId = 'r2';
|
|
||||||
finalVideoId = r2Data.objectKey;
|
|
||||||
finalThumbnailUrl = r2Data.thumbnailUrl || '/placeholder-video-thumbnail.png';
|
|
||||||
finalDuration = r2Data.duration;
|
|
||||||
uploadedBunnyUploadToken = r2Data.uploadToken;
|
|
||||||
} else {
|
|
||||||
const bunnyData = await uploadToBunny(selectedFile);
|
|
||||||
uploadedBunnyVideoId = bunnyData.videoId;
|
|
||||||
uploadedBunnyUploadToken = bunnyData.uploadToken;
|
|
||||||
|
|
||||||
finalVideoUrl = bunnyData.url;
|
|
||||||
finalProviderId = bunnyData.providerId;
|
|
||||||
finalVideoId = bunnyData.videoId;
|
|
||||||
finalThumbnailUrl = bunnyCdnHostname
|
|
||||||
? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg`
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/videos`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: finalTitle,
|
|
||||||
description: finalDescription,
|
|
||||||
videoUrl: finalVideoUrl,
|
|
||||||
providerId: finalProviderId,
|
|
||||||
videoId: finalVideoId,
|
|
||||||
thumbnailUrl: finalThumbnailUrl,
|
|
||||||
duration: finalDuration,
|
|
||||||
uploadToken: uploadedBunnyUploadToken,
|
|
||||||
objectKey: pendingR2ObjectKeyRef.current,
|
|
||||||
reservationId: pendingR2ReservationIdRef.current,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setSubmitError(data.error || 'Failed to add video');
|
|
||||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
|
||||||
await cleanupPendingBunnyVideo(uploadedBunnyVideoId, uploadedBunnyUploadToken);
|
|
||||||
} else if (pendingR2ObjectKeyRef.current && pendingR2UploadTokenRef.current) {
|
|
||||||
await cleanupPendingR2VideoUpload(projectId, {
|
|
||||||
objectKey: pendingR2ObjectKeyRef.current,
|
|
||||||
uploadToken: pendingR2UploadTokenRef.current,
|
|
||||||
reservationId: pendingR2ReservationIdRef.current,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingBunnyVideoIdRef.current = null;
|
if (!directUploadsEnabled) {
|
||||||
pendingBunnyUploadTokenRef.current = null;
|
throw new Error('Direct uploads are disabled by this host');
|
||||||
pendingR2ObjectKeyRef.current = null;
|
}
|
||||||
pendingR2UploadTokenRef.current = null;
|
|
||||||
pendingR2ReservationIdRef.current = null;
|
if (selectedFiles.length === 0) {
|
||||||
setPendingBunnyVideoId(null);
|
setSubmitError('Please select at least one video file to upload');
|
||||||
setPendingBunnyUploadToken(null);
|
setIsLoading(false);
|
||||||
router.push(`/projects/${projectId}`);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFiles.length === 1) {
|
||||||
|
await uploadSingleFileWithForm(selectedFiles[0]);
|
||||||
|
router.push(`/projects/${projectId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadMultipleFiles(selectedFiles);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to add video:', error);
|
console.error('Failed to add video:', error);
|
||||||
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||||
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
|
|
||||||
await cleanupPendingBunnyVideo(
|
|
||||||
pendingBunnyVideoIdRef.current,
|
|
||||||
pendingBunnyUploadTokenRef.current
|
|
||||||
);
|
|
||||||
} else if (pendingR2ObjectKeyRef.current && pendingR2UploadTokenRef.current) {
|
|
||||||
await cleanupPendingR2VideoUpload(projectId, {
|
|
||||||
objectKey: pendingR2ObjectKeyRef.current,
|
|
||||||
uploadToken: pendingR2UploadTokenRef.current,
|
|
||||||
reservationId: pendingR2ReservationIdRef.current,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
activeTusUploadRef.current = null;
|
activeTusUploadRef.current = null;
|
||||||
|
pendingUploadRef.current = null;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -532,7 +482,7 @@ export default function NewVideoPageClient({
|
|||||||
<CardTitle>Add Video</CardTitle>
|
<CardTitle>Add Video</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{directUploadsEnabled
|
{directUploadsEnabled
|
||||||
? 'Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.'
|
? 'Paste a video link or upload one or more files directly to add them to your project.'
|
||||||
: 'Paste a video link to add it to your project. Direct uploads are disabled on this host.'}
|
: 'Paste a video link to add it to your project. Direct uploads are disabled on this host.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -592,7 +542,7 @@ export default function NewVideoPageClient({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="file">Video File</Label>
|
<Label htmlFor="file">Video Files</Label>
|
||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center justify-center w-full">
|
||||||
<label
|
<label
|
||||||
htmlFor="file"
|
htmlFor="file"
|
||||||
@@ -600,49 +550,92 @@ export default function NewVideoPageClient({
|
|||||||
onDragOver={handleFileDragOver}
|
onDragOver={handleFileDragOver}
|
||||||
onDragLeave={handleFileDragLeave}
|
onDragLeave={handleFileDragLeave}
|
||||||
onDrop={handleFileDrop}
|
onDrop={handleFileDrop}
|
||||||
className={`flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
className={`flex flex-col items-center justify-center w-full min-h-40 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
|
||||||
isFileDragOver
|
isFileDragOver
|
||||||
? 'border-primary bg-primary/10'
|
? 'border-primary bg-primary/10'
|
||||||
: selectedFile
|
: selectedFiles.length > 0
|
||||||
? 'border-primary bg-muted/30 hover:bg-muted/50'
|
? 'border-primary bg-muted/30 hover:bg-muted/50'
|
||||||
: 'border-border bg-muted/30 hover:bg-muted/50'
|
: 'border-border bg-muted/30 hover:bg-muted/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
<div className="flex flex-col items-center justify-center pt-5 pb-6 px-4 w-full">
|
||||||
{selectedFile ? (
|
{selectedFiles.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<UploadCloud className="w-10 h-10 mb-3 text-muted-foreground" />
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground text-center">
|
||||||
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Multiple videos supported · MP4, WebM, MOV, and more
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : selectedFiles.length === 1 ? (
|
||||||
<>
|
<>
|
||||||
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
||||||
<p className="mb-2 text-sm text-foreground font-medium">
|
<p className="mb-2 text-sm text-foreground font-medium">
|
||||||
{selectedFile.name}
|
{selectedFiles[0].name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
|
{(selectedFiles[0].size / (1024 * 1024)).toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<UploadCloud className="w-10 h-10 mb-3 text-muted-foreground" />
|
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
||||||
<p className="mb-2 text-sm text-muted-foreground">
|
<p className="mb-2 text-sm text-foreground font-medium">
|
||||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
{selectedFiles.length} videos selected
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Click or drop to add more files
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
id="file"
|
id="file"
|
||||||
type="file"
|
type="file"
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedFiles.length > 1 && (
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded-md border border-border">
|
||||||
|
{selectedFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
|
||||||
|
className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0"
|
||||||
|
>
|
||||||
|
<FileVideo className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{file.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{getDefaultTitleFromFile(file)} ·{' '}
|
||||||
|
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => removeSelectedFile(index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Video Preview (Only for URL mode) */}
|
|
||||||
{uploadMode === 'url' && thumbnailUrl && videoSource && (
|
{uploadMode === 'url' && thumbnailUrl && videoSource && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Preview</Label>
|
<Label>Preview</Label>
|
||||||
@@ -658,37 +651,54 @@ export default function NewVideoPageClient({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Title */}
|
{uploadMode === 'url' || selectedFiles.length <= 1 ? (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<Label htmlFor="title">Title</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="title">Title</Label>
|
||||||
id="title"
|
<Input
|
||||||
placeholder={
|
id="title"
|
||||||
isFetchingMeta
|
placeholder={
|
||||||
? 'Fetching title...'
|
isFetchingMeta
|
||||||
: 'Video title (will auto-fill from video if empty)'
|
? 'Fetching title...'
|
||||||
}
|
: uploadMode === 'file' && isMultiFileUpload
|
||||||
value={formData.title}
|
? 'Not used for multi-file uploads'
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
: 'Video title (will auto-fill from video if empty)'
|
||||||
disabled={isLoading}
|
}
|
||||||
/>
|
value={formData.title}
|
||||||
<p className="text-xs text-muted-foreground">
|
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||||
Leave empty to use the original video title
|
disabled={isLoading || (uploadMode === 'file' && isMultiFileUpload)}
|
||||||
</p>
|
/>
|
||||||
</div>
|
{uploadMode === 'file' && isMultiFileUpload ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Each file will use its filename as the title.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty to use the original video title
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="description">Description (optional)</Label>
|
||||||
<Label htmlFor="description">Description (optional)</Label>
|
<Textarea
|
||||||
<Textarea
|
id="description"
|
||||||
id="description"
|
placeholder="Add context about this video..."
|
||||||
placeholder="Add context about this video..."
|
value={formData.description}
|
||||||
value={formData.description}
|
onChange={(e) =>
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||||
rows={3}
|
}
|
||||||
disabled={isLoading}
|
rows={3}
|
||||||
/>
|
disabled={isLoading || (uploadMode === 'file' && isMultiFileUpload)}
|
||||||
</div>
|
/>
|
||||||
|
{uploadMode === 'file' && isMultiFileUpload ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Descriptions are not applied in bulk upload mode.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<p className="text-sm text-destructive flex items-center gap-1">
|
<p className="text-sm text-destructive flex items-center gap-1">
|
||||||
@@ -710,7 +720,10 @@ export default function NewVideoPageClient({
|
|||||||
)}
|
)}
|
||||||
{isUploadingFile && (
|
{isUploadingFile && (
|
||||||
<p className="text-xs text-amber-500">
|
<p className="text-xs text-amber-500">
|
||||||
Do not close, refresh, or navigate away while the upload is in progress.
|
Do not close, refresh, or navigate away while uploads are in progress.
|
||||||
|
{isMultiFileUpload && currentUploadIndex > 0
|
||||||
|
? ` (${currentUploadIndex} of ${selectedFiles.length})`
|
||||||
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -722,11 +735,13 @@ export default function NewVideoPageClient({
|
|||||||
disabled={
|
disabled={
|
||||||
isLoading ||
|
isLoading ||
|
||||||
(uploadMode === 'url' && !videoSource) ||
|
(uploadMode === 'url' && !videoSource) ||
|
||||||
(uploadMode === 'file' && !selectedFile)
|
(uploadMode === 'file' && selectedFiles.length === 0)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
Add Video
|
{uploadMode === 'file' && selectedFiles.length > 1
|
||||||
|
? `Upload ${selectedFiles.length} Videos`
|
||||||
|
: 'Add Video'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function GET(
|
|||||||
project: { select: projectSelect },
|
project: { select: projectSelect },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const [versions, session] = await Promise.all([
|
const [versions, assets, session] = await Promise.all([
|
||||||
db.videoVersion.findMany({
|
db.videoVersion.findMany({
|
||||||
where: { originalUrl },
|
where: { originalUrl },
|
||||||
take: 2,
|
take: 2,
|
||||||
@@ -56,6 +56,14 @@ export async function GET(
|
|||||||
video: { select: videoSelect },
|
video: { select: videoSelect },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: { sourceUrl: originalUrl },
|
||||||
|
take: 2,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
video: { select: videoSelect },
|
||||||
|
},
|
||||||
|
}),
|
||||||
auth(),
|
auth(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -63,6 +71,9 @@ export async function GET(
|
|||||||
for (const version of versions) {
|
for (const version of versions) {
|
||||||
uniqueVideos.set(version.video.id, version.video);
|
uniqueVideos.set(version.video.id, version.video);
|
||||||
}
|
}
|
||||||
|
for (const asset of assets) {
|
||||||
|
uniqueVideos.set(asset.video.id, asset.video);
|
||||||
|
}
|
||||||
if (uniqueVideos.size > 1) {
|
if (uniqueVideos.size > 1) {
|
||||||
return apiErrors.forbidden('Access denied');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { db } from '@/lib/db';
|
|||||||
import {
|
import {
|
||||||
extractImageFileNameFromProxyUrl,
|
extractImageFileNameFromProxyUrl,
|
||||||
extractAudioFileNameFromProxyUrl,
|
extractAudioFileNameFromProxyUrl,
|
||||||
|
extractVideoFileNameFromProxyUrl,
|
||||||
getVideoAssetAccessContext,
|
getVideoAssetAccessContext,
|
||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
|
import { buildVideoObjectKey } from '@/lib/video-upload-validation';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
||||||
@@ -35,6 +37,16 @@ const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
|
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
|
||||||
|
|
||||||
|
const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
ogg: 'video/ogg',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
m4v: 'video/mp4',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
avi: 'video/x-msvideo',
|
||||||
|
};
|
||||||
|
|
||||||
function sanitizeFileName(value: string): string {
|
function sanitizeFileName(value: string): string {
|
||||||
const sanitized = value
|
const sanitized = value
|
||||||
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||||
@@ -137,6 +149,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||||
|
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
|
||||||
|
if (!fileName) return apiErrors.badRequest('Invalid video asset URL');
|
||||||
|
const key = buildVideoObjectKey(fileName);
|
||||||
|
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4';
|
||||||
|
const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`;
|
||||||
|
const contentDisposition = buildContentDisposition(downloadName);
|
||||||
|
const extKey = ext.replace('.', '');
|
||||||
|
const contentType = VIDEO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'video/mp4';
|
||||||
|
|
||||||
|
return proxyR2MediaObject({
|
||||||
|
request,
|
||||||
|
key,
|
||||||
|
fallbackContentType: contentType,
|
||||||
|
cacheControl: 'private, no-store',
|
||||||
|
extraHeaders: {
|
||||||
|
'Content-Disposition': contentDisposition,
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
internalErrorMessage: 'Failed to retrieve video',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sourceParam = request.nextUrl.searchParams.get('source');
|
const sourceParam = request.nextUrl.searchParams.get('source');
|
||||||
const rawQuality = request.nextUrl.searchParams.get('quality');
|
const rawQuality = request.nextUrl.searchParams.get('quality');
|
||||||
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
|
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
provider: true,
|
provider: true,
|
||||||
sourceUrl: true,
|
sourceUrl: true,
|
||||||
providerVideoId: true,
|
providerVideoId: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
uploadedByUserId: true,
|
uploadedByUserId: true,
|
||||||
uploadedByGuestIdentityId: true,
|
uploadedByGuestIdentityId: true,
|
||||||
},
|
},
|
||||||
@@ -41,6 +42,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
let shouldDeleteImageObject = false;
|
let shouldDeleteImageObject = false;
|
||||||
let shouldDeleteAudioObject = false;
|
let shouldDeleteAudioObject = false;
|
||||||
|
let shouldDeleteVideoObject = false;
|
||||||
|
let shouldDeleteVideoThumbnail = false;
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
await tx.videoAsset.delete({ where: { id: asset.id } });
|
await tx.videoAsset.delete({ where: { id: asset.id } });
|
||||||
|
|
||||||
@@ -59,6 +62,22 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
]);
|
]);
|
||||||
shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||||
|
const [assetReferenceCount, versionReferenceCount] = await Promise.all([
|
||||||
|
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
|
||||||
|
tx.videoVersion.count({ where: { originalUrl: asset.sourceUrl } }),
|
||||||
|
]);
|
||||||
|
shouldDeleteVideoObject = assetReferenceCount === 0 && versionReferenceCount === 0;
|
||||||
|
|
||||||
|
if (asset.thumbnailUrl) {
|
||||||
|
const [assetThumbnailCount, commentImageCount] = await Promise.all([
|
||||||
|
tx.videoAsset.count({ where: { thumbnailUrl: asset.thumbnailUrl } }),
|
||||||
|
tx.comment.count({ where: { imageUrl: asset.thumbnailUrl } }),
|
||||||
|
]);
|
||||||
|
shouldDeleteVideoThumbnail = assetThumbnailCount === 0 && commentImageCount === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
||||||
@@ -68,6 +87,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
|
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
|
||||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||||
}
|
}
|
||||||
|
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||||
|
const urlsToDelete: string[] = [];
|
||||||
|
if (shouldDeleteVideoObject && asset.sourceUrl) {
|
||||||
|
urlsToDelete.push(asset.sourceUrl);
|
||||||
|
}
|
||||||
|
if (shouldDeleteVideoThumbnail && asset.thumbnailUrl) {
|
||||||
|
urlsToDelete.push(asset.thumbnailUrl);
|
||||||
|
}
|
||||||
|
if (urlsToDelete.length > 0) {
|
||||||
|
r2CleanupResult = await deleteMediaFilesBestEffort(urlsToDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let bunnyCleanupResult:
|
let bunnyCleanupResult:
|
||||||
| Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>
|
| Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
enforceGuestUploadQuota,
|
enforceGuestUploadQuota,
|
||||||
verifyGuestUploadToken,
|
verifyGuestUploadToken,
|
||||||
} from '@/lib/guest-upload-token';
|
} from '@/lib/guest-upload-token';
|
||||||
import { isBunnyUploadsFeatureEnabled } from '@/lib/feature-flags';
|
import { isBunnyUploadsEnabled } from '@/lib/feature-flags';
|
||||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||||
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
|
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
@@ -33,7 +33,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
||||||
if (!title) return apiErrors.badRequest('Title is required');
|
if (!title) return apiErrors.badRequest('Title is required');
|
||||||
|
|
||||||
if (!isBunnyUploadsFeatureEnabled()) {
|
if (!isBunnyUploadsEnabled()) {
|
||||||
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
return apiErrors.badRequest('Direct uploads are disabled by this host');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
import {
|
||||||
|
createR2UploadToken,
|
||||||
|
parseR2UploadToken,
|
||||||
|
verifyR2UploadToken,
|
||||||
|
} from '@/lib/r2-upload-token';
|
||||||
|
import {
|
||||||
|
createPresignedImagePutUrl,
|
||||||
|
createPresignedVideoPutUrl,
|
||||||
|
deleteR2Object,
|
||||||
|
deleteVideoObject,
|
||||||
|
} from '@/lib/r2';
|
||||||
|
import { getMaxVideoUploadBytes, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||||
|
import {
|
||||||
|
buildVideoObjectKey,
|
||||||
|
getVideoExtensionFromMime,
|
||||||
|
resolveVideoContentType,
|
||||||
|
videoProxyPathFromFilename,
|
||||||
|
} from '@/lib/video-upload-validation';
|
||||||
|
import { logError } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
enforceStorageQuota,
|
||||||
|
releaseStorageReservation,
|
||||||
|
reserveStorageQuota,
|
||||||
|
} from '@/lib/storage-quota';
|
||||||
|
import { createR2UploadSession } from '@/lib/r2-upload-session';
|
||||||
|
import { getVideoAssetAccessContext } from '@/lib/video-assets';
|
||||||
|
|
||||||
|
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||||
|
|
||||||
|
const VIDEO_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
|
||||||
|
const THUMBNAIL_RESERVE_BYTES = BigInt(512 * 1024);
|
||||||
|
|
||||||
|
// POST /api/videos/[videoId]/assets/r2-init
|
||||||
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const limited = await rateLimit(request, 'asset-r2-init');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const { videoId } = await params;
|
||||||
|
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
|
||||||
|
if (!context) return apiErrors.notFound('Video');
|
||||||
|
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
|
||||||
|
|
||||||
|
if (!context.viewerUserId) {
|
||||||
|
return apiErrors.unauthorized('Sign in is required for direct video uploads');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isS3VideoUploadsEnabled()) {
|
||||||
|
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const fileName = typeof body?.fileName === 'string' ? body.fileName.trim() : '';
|
||||||
|
const contentTypeInput = typeof body?.contentType === 'string' ? body.contentType.trim() : '';
|
||||||
|
const sizeBytesRaw = body?.sizeBytes;
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
return apiErrors.badRequest('fileName is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizeBytes: bigint;
|
||||||
|
try {
|
||||||
|
sizeBytes = BigInt(sizeBytesRaw);
|
||||||
|
if (sizeBytes <= BigInt(0)) {
|
||||||
|
return apiErrors.badRequest('sizeBytes must be a positive integer');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return apiErrors.badRequest('sizeBytes must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxVideoUploadBytes();
|
||||||
|
if (sizeBytes > maxBytes) {
|
||||||
|
return apiErrors.badRequest('Video file exceeds the maximum allowed upload size');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = resolveVideoContentType(fileName, contentTypeInput);
|
||||||
|
if (!contentType) {
|
||||||
|
return apiErrors.badRequest('Unsupported video format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = getVideoExtensionFromMime(contentType);
|
||||||
|
if (!ext) {
|
||||||
|
return apiErrors.badRequest('Unsupported video format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const billedUserId = context.video.project.workspace.ownerId;
|
||||||
|
const projectId = context.video.projectId;
|
||||||
|
|
||||||
|
const quotaError = await enforceStorageQuota(billedUserId, sizeBytes + THUMBNAIL_RESERVE_BYTES);
|
||||||
|
if (quotaError) return quotaError;
|
||||||
|
|
||||||
|
const reserveResult = await reserveStorageQuota(
|
||||||
|
billedUserId,
|
||||||
|
sizeBytes + THUMBNAIL_RESERVE_BYTES,
|
||||||
|
VIDEO_RESERVATION_TTL_MS
|
||||||
|
);
|
||||||
|
if ('error' in reserveResult) return reserveResult.error;
|
||||||
|
|
||||||
|
const fileId = randomUUID();
|
||||||
|
const filename = `${fileId}.${ext}`;
|
||||||
|
const objectKey = buildVideoObjectKey(filename);
|
||||||
|
const proxyUrl = videoProxyPathFromFilename(filename);
|
||||||
|
const thumbnailFilename = `${fileId}.jpg`;
|
||||||
|
const thumbnailObjectKey = `images/${thumbnailFilename}`;
|
||||||
|
const thumbnailProxyUrl = `/api/upload/image/${thumbnailFilename}`;
|
||||||
|
|
||||||
|
let presignedPutUrl: string;
|
||||||
|
let thumbnailPresignedPutUrl: string;
|
||||||
|
try {
|
||||||
|
[presignedPutUrl, thumbnailPresignedPutUrl] = await Promise.all([
|
||||||
|
createPresignedVideoPutUrl(objectKey, contentType, sizeBytes),
|
||||||
|
createPresignedImagePutUrl(thumbnailObjectKey, 'image/jpeg'),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
await releaseStorageReservation(reserveResult.reservationId, billedUserId);
|
||||||
|
logError('Failed to create presigned asset video upload URL:', error);
|
||||||
|
return apiErrors.internalError('Failed to initialize video upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadJti = randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + VIDEO_RESERVATION_TTL_MS);
|
||||||
|
const uploadSession = await createR2UploadSession({
|
||||||
|
userId: context.viewerUserId,
|
||||||
|
projectId,
|
||||||
|
billedUserId,
|
||||||
|
objectKey,
|
||||||
|
thumbnailObjectKey,
|
||||||
|
declaredSizeBytes: sizeBytes,
|
||||||
|
contentType,
|
||||||
|
reservationId: reserveResult.reservationId,
|
||||||
|
uploadJti,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadToken = createR2UploadToken({
|
||||||
|
userId: context.viewerUserId,
|
||||||
|
projectId,
|
||||||
|
objectKey,
|
||||||
|
sessionId: uploadSession.id,
|
||||||
|
tokenId: uploadJti,
|
||||||
|
thumbnailObjectKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
presignedPutUrl,
|
||||||
|
objectKey,
|
||||||
|
proxyUrl,
|
||||||
|
uploadToken,
|
||||||
|
reservationId: reserveResult.reservationId,
|
||||||
|
contentType,
|
||||||
|
thumbnailPresignedPutUrl,
|
||||||
|
thumbnailObjectKey,
|
||||||
|
thumbnailProxyUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error initializing R2 asset video upload:', error);
|
||||||
|
return apiErrors.internalError('Failed to initialize upload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/videos/[videoId]/assets/r2-init
|
||||||
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const limited = await rateLimit(request, 'asset-r2-init');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const { videoId } = await params;
|
||||||
|
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
|
||||||
|
if (!context) return apiErrors.notFound('Video');
|
||||||
|
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
|
||||||
|
|
||||||
|
if (!context.viewerUserId) {
|
||||||
|
return apiErrors.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isS3VideoUploadsEnabled()) {
|
||||||
|
return apiErrors.badRequest('S3 video uploads are disabled by this host');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
|
||||||
|
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||||
|
const thumbnailObjectKey =
|
||||||
|
typeof body?.thumbnailObjectKey === 'string' ? body.thumbnailObjectKey.trim() : '';
|
||||||
|
|
||||||
|
if (!objectKey || !uploadToken) {
|
||||||
|
return apiErrors.badRequest('objectKey and uploadToken are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = context.video.projectId;
|
||||||
|
const tokenPayload = parseR2UploadToken(uploadToken);
|
||||||
|
if (!tokenPayload) {
|
||||||
|
return apiErrors.forbidden('Invalid upload token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidUploadToken = verifyR2UploadToken(uploadToken, {
|
||||||
|
userId: context.viewerUserId,
|
||||||
|
projectId,
|
||||||
|
objectKey,
|
||||||
|
sessionId: tokenPayload.sid,
|
||||||
|
tokenId: tokenPayload.jti,
|
||||||
|
});
|
||||||
|
if (!isValidUploadToken) {
|
||||||
|
return apiErrors.forbidden('Invalid upload token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadSession = await db.videoUploadSession.findFirst({
|
||||||
|
where: {
|
||||||
|
id: tokenPayload.sid,
|
||||||
|
status: 'INITIATED',
|
||||||
|
userId: context.viewerUserId,
|
||||||
|
projectId,
|
||||||
|
objectKey,
|
||||||
|
uploadJti: tokenPayload.jti,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reservationId: true,
|
||||||
|
billedUserId: true,
|
||||||
|
thumbnailObjectKey: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!uploadSession) {
|
||||||
|
return apiErrors.forbidden('Invalid upload token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thumbnailObjectKey && thumbnailObjectKey !== uploadSession.thumbnailObjectKey) {
|
||||||
|
return apiErrors.badRequest('Invalid thumbnail object key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelled = await db.videoUploadSession.updateMany({
|
||||||
|
where: {
|
||||||
|
id: uploadSession.id,
|
||||||
|
status: 'INITIATED',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
consumedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (cancelled.count !== 1) {
|
||||||
|
return apiErrors.forbidden('Invalid upload token');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
deleteVideoObject(objectKey),
|
||||||
|
uploadSession.thumbnailObjectKey.startsWith('images/')
|
||||||
|
? deleteR2Object(uploadSession.thumbnailObjectKey)
|
||||||
|
: Promise.resolve(),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Failed to delete pending R2 asset video object:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await releaseStorageReservation(uploadSession.reservationId, uploadSession.billedUserId);
|
||||||
|
|
||||||
|
const response = successResponse({ message: 'Pending upload cleaned up' });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error cleaning up pending R2 asset video upload:', error);
|
||||||
|
return apiErrors.internalError('Failed to cleanup pending upload');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,15 +16,18 @@ import {
|
|||||||
SAFE_BUNNY_VIDEO_ID,
|
SAFE_BUNNY_VIDEO_ID,
|
||||||
SAFE_IMAGE_PROXY_PATH,
|
SAFE_IMAGE_PROXY_PATH,
|
||||||
SAFE_AUDIO_PROXY_PATH,
|
SAFE_AUDIO_PROXY_PATH,
|
||||||
|
SAFE_VIDEO_PROXY_PATH,
|
||||||
canDeleteAssetForViewer,
|
canDeleteAssetForViewer,
|
||||||
extractImageFileNameFromProxyUrl,
|
extractImageFileNameFromProxyUrl,
|
||||||
extractImageKeyFromProxyUrl,
|
extractImageKeyFromProxyUrl,
|
||||||
extractAudioKeyFromProxyUrl,
|
extractAudioKeyFromProxyUrl,
|
||||||
extractAudioFileNameFromProxyUrl,
|
extractAudioFileNameFromProxyUrl,
|
||||||
|
extractVideoFileNameFromProxyUrl,
|
||||||
getVideoAssetAccessContext,
|
getVideoAssetAccessContext,
|
||||||
sanitizeAssetDisplayName,
|
sanitizeAssetDisplayName,
|
||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||||
import {
|
import {
|
||||||
enforceStorageQuota,
|
enforceStorageQuota,
|
||||||
reserveStorageQuota,
|
reserveStorageQuota,
|
||||||
@@ -269,7 +272,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
asset,
|
asset,
|
||||||
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
|
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
|
||||||
context.canDownloadAssets ||
|
context.canDownloadAssets ||
|
||||||
(asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess),
|
((asset.provider === VideoAssetProvider.R2_AUDIO ||
|
||||||
|
asset.provider === VideoAssetProvider.R2_VIDEO) &&
|
||||||
|
context.hasViewAccess),
|
||||||
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -293,6 +298,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
// POST /api/videos/[videoId]/assets
|
// POST /api/videos/[videoId]/assets
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
let reservationId: string | null = null;
|
let reservationId: string | null = null;
|
||||||
|
let finalizedR2AssetSession: {
|
||||||
|
sessionId: string;
|
||||||
|
reservationId: string | null;
|
||||||
|
billedUserId: string;
|
||||||
|
objectKey: string;
|
||||||
|
viewerUserId: string;
|
||||||
|
projectId: string;
|
||||||
|
} | null = null;
|
||||||
try {
|
try {
|
||||||
const limited = await rateLimit(request, 'asset-create');
|
const limited = await rateLimit(request, 'asset-create');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
@@ -309,7 +322,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
provider !== VideoAssetProvider.R2_IMAGE &&
|
provider !== VideoAssetProvider.R2_IMAGE &&
|
||||||
provider !== VideoAssetProvider.YOUTUBE &&
|
provider !== VideoAssetProvider.YOUTUBE &&
|
||||||
provider !== VideoAssetProvider.BUNNY &&
|
provider !== VideoAssetProvider.BUNNY &&
|
||||||
provider !== VideoAssetProvider.R2_AUDIO
|
provider !== VideoAssetProvider.R2_AUDIO &&
|
||||||
|
provider !== VideoAssetProvider.R2_VIDEO
|
||||||
) {
|
) {
|
||||||
return apiErrors.badRequest('Invalid provider');
|
return apiErrors.badRequest('Invalid provider');
|
||||||
}
|
}
|
||||||
@@ -400,6 +414,59 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
kind = 'VIDEO';
|
kind = 'VIDEO';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === VideoAssetProvider.R2_VIDEO) {
|
||||||
|
if (!context.viewerUserId) {
|
||||||
|
return apiErrors.forbidden('R2 video asset uploads require sign-in');
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||||
|
const objectKey = typeof body?.objectKey === 'string' ? body.objectKey.trim() : '';
|
||||||
|
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
|
||||||
|
thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null;
|
||||||
|
|
||||||
|
if (!SAFE_VIDEO_PROXY_PATH.test(sourceUrl)) {
|
||||||
|
return apiErrors.badRequest('Video URL must reference an uploaded video file');
|
||||||
|
}
|
||||||
|
if (!objectKey || !uploadToken) {
|
||||||
|
return apiErrors.badRequest('objectKey and uploadToken are required');
|
||||||
|
}
|
||||||
|
if (thumbnailUrl && !SAFE_IMAGE_PROXY_PATH.test(thumbnailUrl)) {
|
||||||
|
return apiErrors.badRequest('Thumbnail URL must reference an uploaded image file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalizeResult = await finalizeR2VideoUpload({
|
||||||
|
userId: context.viewerUserId,
|
||||||
|
projectId: context.video.projectId,
|
||||||
|
videoUrl: sourceUrl,
|
||||||
|
objectKey,
|
||||||
|
uploadToken,
|
||||||
|
});
|
||||||
|
if (!finalizeResult.ok) {
|
||||||
|
if (finalizeResult.status === 403) {
|
||||||
|
return apiErrors.forbidden(finalizeResult.error);
|
||||||
|
}
|
||||||
|
return apiErrors.badRequest(finalizeResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
assetSizeBytes = finalizeResult.sizeBytes;
|
||||||
|
reservationId = finalizeResult.reservationId;
|
||||||
|
if (!thumbnailUrl) {
|
||||||
|
thumbnailUrl = finalizeResult.thumbnailProxyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = extractVideoFileNameFromProxyUrl(sourceUrl);
|
||||||
|
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Video');
|
||||||
|
kind = 'VIDEO';
|
||||||
|
finalizedR2AssetSession = {
|
||||||
|
sessionId: finalizeResult.sessionId,
|
||||||
|
reservationId: finalizeResult.reservationId,
|
||||||
|
billedUserId: finalizeResult.billedUserId,
|
||||||
|
objectKey: finalizeResult.objectKey,
|
||||||
|
viewerUserId: context.viewerUserId,
|
||||||
|
projectId: context.video.projectId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === VideoAssetProvider.BUNNY) {
|
if (provider === VideoAssetProvider.BUNNY) {
|
||||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||||
providerVideoId =
|
providerVideoId =
|
||||||
@@ -472,7 +539,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
// Only needed for R2 providers where the invalid-reservation fallback quota
|
// Only needed for R2 providers where the invalid-reservation fallback quota
|
||||||
// check requires Bunny usage data.
|
// check requires Bunny usage data.
|
||||||
const preFetchedBunnyData =
|
const preFetchedBunnyData =
|
||||||
provider === VideoAssetProvider.R2_IMAGE || provider === VideoAssetProvider.R2_AUDIO
|
provider === VideoAssetProvider.R2_IMAGE ||
|
||||||
|
provider === VideoAssetProvider.R2_AUDIO ||
|
||||||
|
provider === VideoAssetProvider.R2_VIDEO
|
||||||
? await getCachedUserBunnyStorage()
|
? await getCachedUserBunnyStorage()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -503,7 +572,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||||
FROM video_assets
|
FROM video_assets
|
||||||
WHERE "billedUserId" = ${billedUserId}
|
WHERE "billedUserId" = ${billedUserId}
|
||||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
|
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
|
||||||
`;
|
`;
|
||||||
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||||
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
|
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
|
||||||
@@ -521,6 +590,26 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (finalizedR2AssetSession) {
|
||||||
|
const consumed = await tx.videoUploadSession.updateMany({
|
||||||
|
where: {
|
||||||
|
id: finalizedR2AssetSession.sessionId,
|
||||||
|
status: 'INITIATED',
|
||||||
|
userId: finalizedR2AssetSession.viewerUserId,
|
||||||
|
projectId: finalizedR2AssetSession.projectId,
|
||||||
|
objectKey: finalizedR2AssetSession.objectKey,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'FINALIZED',
|
||||||
|
consumedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (consumed.count !== 1) {
|
||||||
|
throw new Error('Upload session already consumed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return tx.videoAsset.create({
|
return tx.videoAsset.create({
|
||||||
data: {
|
data: {
|
||||||
videoId: context.video.id,
|
videoId: context.video.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { VideoPageContent } from '@/components/video-page-content';
|
import { VideoPageContent } from '@/components/video-page-content';
|
||||||
import { ShareLinkBootstrap } from '@/components/share-link-bootstrap';
|
import { ShareLinkBootstrap } from '@/components/share-link-bootstrap';
|
||||||
import { ShareLinkUnlock } from '@/components/share-link-unlock';
|
import { ShareLinkUnlock } from '@/components/share-link-unlock';
|
||||||
|
import { isS3VideoUploadsEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
interface WatchPageProps {
|
interface WatchPageProps {
|
||||||
params: Promise<{ videoId: string }>;
|
params: Promise<{ videoId: string }>;
|
||||||
@@ -19,5 +20,11 @@ export default async function WatchPage({ params, searchParams }: WatchPageProps
|
|||||||
return <ShareLinkUnlock videoId={videoId} />;
|
return <ShareLinkUnlock videoId={videoId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <VideoPageContent mode="watch" videoId={videoId} />;
|
return (
|
||||||
|
<VideoPageContent
|
||||||
|
mode="watch"
|
||||||
|
videoId={videoId}
|
||||||
|
directUploadProvider={isS3VideoUploadsEnabled() ? 'r2' : 'bunny'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Loader2, UploadCloud } from 'lucide-react';
|
import { CheckCircle2, Loader2, UploadCloud, XCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -22,7 +22,14 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
import {
|
||||||
|
cleanupPendingProjectUpload,
|
||||||
|
getDefaultTitleFromFile,
|
||||||
|
isVideoFile,
|
||||||
|
uploadProjectVideo,
|
||||||
|
type ActiveTusUpload,
|
||||||
|
type PendingProjectUploadCleanup,
|
||||||
|
} from '@/lib/client/project-video-upload';
|
||||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||||
|
|
||||||
type ProjectOption = {
|
type ProjectOption = {
|
||||||
@@ -31,6 +38,16 @@ type ProjectOption = {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type QueueItemStatus = 'pending' | 'uploading' | 'done' | 'error' | 'cancelled';
|
||||||
|
|
||||||
|
type QueueItem = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
status: QueueItemStatus;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface VideoDragDropUploaderProps {
|
interface VideoDragDropUploaderProps {
|
||||||
fixedProjectId?: string;
|
fixedProjectId?: string;
|
||||||
fixedProjectName?: string;
|
fixedProjectName?: string;
|
||||||
@@ -40,29 +57,18 @@ interface VideoDragDropUploaderProps {
|
|||||||
directUploadProvider?: DirectUploadProvider;
|
directUploadProvider?: DirectUploadProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
|
||||||
type ActiveTusUpload = { abort: (shouldTerminate?: boolean) => Promise<unknown> | void };
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractVideoFile(dataTransfer: DataTransfer | null): File | null {
|
|
||||||
if (!dataTransfer?.files?.length) return null;
|
|
||||||
const files = Array.from(dataTransfer.files);
|
|
||||||
return files.find(isVideoFile) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasFileData(dataTransfer: DataTransfer | null): boolean {
|
function hasFileData(dataTransfer: DataTransfer | null): boolean {
|
||||||
if (!dataTransfer) return false;
|
if (!dataTransfer) return false;
|
||||||
return Array.from(dataTransfer.types || []).includes('Files');
|
return Array.from(dataTransfer.types || []).includes('Files');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultTitleFromFile(file: File): string {
|
function createQueueItem(file: File): QueueItem {
|
||||||
const withoutExt = file.name.replace(/\.[^/.]+$/, '').trim();
|
return {
|
||||||
return withoutExt || file.name;
|
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
file,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoDragDropUploader({
|
export function VideoDragDropUploader({
|
||||||
@@ -79,7 +85,7 @@ export function VideoDragDropUploader({
|
|||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||||
const [isDragActive, setIsDragActive] = useState(false);
|
const [isDragActive, setIsDragActive] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [droppedFile, setDroppedFile] = useState<File | null>(null);
|
const [queue, setQueue] = useState<QueueItem[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadStatus, setUploadStatus] = useState('');
|
const [uploadStatus, setUploadStatus] = useState('');
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
@@ -90,23 +96,9 @@ export function VideoDragDropUploader({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
|
const activeTusUploadRef = useRef<ActiveTusUpload | null>(null);
|
||||||
const pendingUploadRef = useRef<
|
const pendingUploadRef = useRef<(PendingProjectUploadCleanup & { projectId: string }) | null>(
|
||||||
| {
|
null
|
||||||
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 cancelRequestedRef = useRef(false);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const hasLoadedProjectsRef = useRef(false);
|
const hasLoadedProjectsRef = useRef(false);
|
||||||
@@ -118,6 +110,12 @@ export function VideoDragDropUploader({
|
|||||||
return new Map(projects.map((project) => [project.id, project.name]));
|
return new Map(projects.map((project) => [project.id, project.name]));
|
||||||
}, [projects]);
|
}, [projects]);
|
||||||
|
|
||||||
|
const pendingCount = queue.filter((item) => item.status === 'pending').length;
|
||||||
|
const doneCount = queue.filter((item) => item.status === 'done').length;
|
||||||
|
const errorCount = queue.filter((item) => item.status === 'error').length;
|
||||||
|
const totalCount = queue.length;
|
||||||
|
const hasQueue = totalCount > 0;
|
||||||
|
|
||||||
const ensureProjectsLoaded = useCallback(async () => {
|
const ensureProjectsLoaded = useCallback(async () => {
|
||||||
if (!canUpload) return;
|
if (!canUpload) return;
|
||||||
if (!needsProjectSelection) return;
|
if (!needsProjectSelection) return;
|
||||||
@@ -183,7 +181,7 @@ export function VideoDragDropUploader({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canUpload) {
|
if (!canUpload) {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setDroppedFile(null);
|
setQueue([]);
|
||||||
}
|
}
|
||||||
}, [canUpload]);
|
}, [canUpload]);
|
||||||
|
|
||||||
@@ -196,7 +194,7 @@ export function VideoDragDropUploader({
|
|||||||
}
|
}
|
||||||
}, [needsProjectSelection, projectOptions, workspaceId]);
|
}, [needsProjectSelection, projectOptions, workspaceId]);
|
||||||
|
|
||||||
const cleanupUploadState = useCallback(() => {
|
const resetUploadState = useCallback(() => {
|
||||||
activeTusUploadRef.current = null;
|
activeTusUploadRef.current = null;
|
||||||
pendingUploadRef.current = null;
|
pendingUploadRef.current = null;
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
@@ -207,7 +205,6 @@ export function VideoDragDropUploader({
|
|||||||
const cancelPendingUpload = useCallback(async () => {
|
const cancelPendingUpload = useCallback(async () => {
|
||||||
if (!isUploading) return;
|
if (!isUploading) return;
|
||||||
cancelRequestedRef.current = true;
|
cancelRequestedRef.current = true;
|
||||||
const pending = pendingUploadRef.current;
|
|
||||||
|
|
||||||
if (activeTusUploadRef.current) {
|
if (activeTusUploadRef.current) {
|
||||||
try {
|
try {
|
||||||
@@ -219,274 +216,163 @@ export function VideoDragDropUploader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pending = pendingUploadRef.current;
|
||||||
if (pending) {
|
if (pending) {
|
||||||
try {
|
await cleanupPendingProjectUpload(pending.projectId, pending);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupUploadState();
|
setQueue((prev) =>
|
||||||
setDroppedFile(null);
|
prev.map((item) =>
|
||||||
setDialogOpen(false);
|
item.status === 'uploading' || item.status === 'pending'
|
||||||
|
? { ...item, status: 'cancelled' as const }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
resetUploadState();
|
||||||
setShowCancelUploadDialog(false);
|
setShowCancelUploadDialog(false);
|
||||||
toast.info('Upload cancelled');
|
toast.info('Upload cancelled');
|
||||||
}, [cleanupUploadState, isUploading]);
|
}, [isUploading, resetUploadState]);
|
||||||
|
|
||||||
|
const uploadQueueToProject = useCallback(
|
||||||
|
async (files: File[], projectId: string, projectName?: string) => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
const uploadFileToProject = useCallback(
|
|
||||||
async (file: File, projectId: string, projectName?: string) => {
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
cancelRequestedRef.current = false;
|
cancelRequestedRef.current = false;
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadStatus('Initializing upload...');
|
|
||||||
setUploadProgress(0);
|
|
||||||
setSelectedProjectId(projectId);
|
setSelectedProjectId(projectId);
|
||||||
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
|
setSelectedProjectName(projectName ?? projectsById.get(projectId) ?? null);
|
||||||
|
|
||||||
let pendingCleanup:
|
const initialQueue = files.map(createQueueItem);
|
||||||
| { type: 'bunny'; videoId: string; uploadToken: string }
|
setQueue(initialQueue);
|
||||||
| {
|
|
||||||
type: 'r2';
|
|
||||||
objectKey: string;
|
|
||||||
uploadToken: string;
|
|
||||||
reservationId: string | null;
|
|
||||||
thumbnailObjectKey?: string;
|
|
||||||
}
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
try {
|
let successCount = 0;
|
||||||
const title = getDefaultTitleFromFile(file);
|
let failCount = 0;
|
||||||
|
|
||||||
if (directUploadProvider === 'r2') {
|
for (let index = 0; index < initialQueue.length; index++) {
|
||||||
const uploaded = await uploadVideoToR2(projectId, file, {
|
if (cancelRequestedRef.current) break;
|
||||||
|
|
||||||
|
const item = initialQueue[index];
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadStatus(`Uploading ${index + 1} of ${initialQueue.length}: ${item.file.name}`);
|
||||||
|
|
||||||
|
setQueue((prev) =>
|
||||||
|
prev.map((entry) =>
|
||||||
|
entry.id === item.id
|
||||||
|
? { ...entry, status: 'uploading', progress: 0, error: undefined }
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadProjectVideo(projectId, item.file, {
|
||||||
|
provider: directUploadProvider,
|
||||||
|
bunnyCdnHostname,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
setUploadProgress(progress);
|
setUploadProgress(progress);
|
||||||
setUploadStatus(`Uploading... ${progress}%`);
|
setQueue((prev) =>
|
||||||
|
prev.map((entry) => (entry.id === item.id ? { ...entry, progress } : entry))
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
onStatus: (status) => {
|
||||||
pendingCleanup = {
|
setUploadStatus(`Uploading ${index + 1} of ${initialQueue.length}: ${status}`);
|
||||||
type: 'r2',
|
},
|
||||||
objectKey: uploaded.objectKey,
|
onTusUploadReady: (upload) => {
|
||||||
uploadToken: uploaded.uploadToken,
|
activeTusUploadRef.current = upload;
|
||||||
reservationId: uploaded.reservationId,
|
},
|
||||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
onPendingUpload: (pending) => {
|
||||||
};
|
pendingUploadRef.current = { ...pending, projectId };
|
||||||
pendingUploadRef.current = {
|
},
|
||||||
type: 'r2',
|
isCancelled: () => cancelRequestedRef.current,
|
||||||
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 {
|
if (cancelRequestedRef.current) break;
|
||||||
error?: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
pendingUploadRef.current = null;
|
||||||
throw new Error(createPayload?.error || 'Failed to create video');
|
activeTusUploadRef.current = null;
|
||||||
}
|
successCount += 1;
|
||||||
|
|
||||||
toast.success(
|
setQueue((prev) =>
|
||||||
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
prev.map((entry) =>
|
||||||
|
entry.id === item.id ? { ...entry, status: 'done', progress: 100 } : entry
|
||||||
|
)
|
||||||
);
|
);
|
||||||
setDialogOpen(false);
|
} catch (error) {
|
||||||
setDroppedFile(null);
|
if (cancelRequestedRef.current) break;
|
||||||
cleanupUploadState();
|
|
||||||
router.push(`/projects/${projectId}`);
|
pendingUploadRef.current = null;
|
||||||
router.refresh();
|
activeTusUploadRef.current = null;
|
||||||
return;
|
failCount += 1;
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to upload video';
|
||||||
|
setQueue((prev) =>
|
||||||
|
prev.map((entry) =>
|
||||||
|
entry.id === item.id ? { ...entry, status: 'error', error: message } : entry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast.error(`${item.file.name}: ${message}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
resetUploadState();
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const initPayload = (await initResponse.json().catch(() => null)) as {
|
if (cancelRequestedRef.current) {
|
||||||
data?: {
|
return;
|
||||||
videoId: string;
|
}
|
||||||
libraryId: string;
|
|
||||||
signature: string;
|
|
||||||
expirationTime: number;
|
|
||||||
uploadToken: string;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
if (!initResponse.ok || !initPayload?.data) {
|
if (successCount > 0) {
|
||||||
throw new Error(initPayload?.error || 'Failed to initialize upload');
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdVideoId = initPayload.data.videoId;
|
|
||||||
const uploadToken = initPayload.data.uploadToken;
|
|
||||||
pendingCleanup = { type: 'bunny', videoId: createdVideoId, uploadToken };
|
|
||||||
pendingUploadRef.current = {
|
|
||||||
type: 'bunny',
|
|
||||||
projectId,
|
|
||||||
videoId: createdVideoId,
|
|
||||||
uploadToken,
|
|
||||||
};
|
|
||||||
|
|
||||||
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: initPayload.data!.signature,
|
|
||||||
AuthorizationExpire: initPayload.data!.expirationTime.toString(),
|
|
||||||
VideoId: initPayload.data!.videoId,
|
|
||||||
LibraryId: initPayload.data!.libraryId,
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
filetype: file.type,
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
activeTusUploadRef.current = null;
|
|
||||||
reject(new Error(error.message));
|
|
||||||
},
|
|
||||||
onProgress: (bytesUploaded, bytesTotal) => {
|
|
||||||
const percentage = Number(((bytesUploaded / bytesTotal) * 100).toFixed(1));
|
|
||||||
setUploadProgress(percentage);
|
|
||||||
setUploadStatus(`Uploading... ${percentage}%`);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
activeTusUploadRef.current = null;
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
activeTusUploadRef.current = upload;
|
|
||||||
upload.start();
|
|
||||||
});
|
|
||||||
|
|
||||||
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: `https://iframe.mediadelivery.net/embed/${initPayload.data.libraryId}/${initPayload.data.videoId}`,
|
|
||||||
providerId: 'bunny',
|
|
||||||
videoId: initPayload.data.videoId,
|
|
||||||
thumbnailUrl: bunnyCdnHostname
|
|
||||||
? `https://${bunnyCdnHostname}/${initPayload.data.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');
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
|
||||||
);
|
|
||||||
setDialogOpen(false);
|
|
||||||
setDroppedFile(null);
|
|
||||||
cleanupUploadState();
|
|
||||||
router.push(`/projects/${projectId}`);
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Drag-drop upload failed:', error);
|
|
||||||
|
|
||||||
if (cancelRequestedRef.current) {
|
if (successCount > 0 && failCount === 0) {
|
||||||
setUploadStatus('');
|
toast.success(
|
||||||
setUploadProgress(0);
|
successCount === 1
|
||||||
setIsUploading(false);
|
? `Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
||||||
return;
|
: `${successCount} videos uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
|
||||||
|
);
|
||||||
|
if (fixedProjectId) {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setQueue([]);
|
||||||
}
|
}
|
||||||
|
} else if (successCount > 0 && failCount > 0) {
|
||||||
if (pendingCleanup) {
|
toast.warning(`${successCount} uploaded, ${failCount} failed`);
|
||||||
try {
|
} else if (failCount > 0) {
|
||||||
if (pendingCleanup.type === 'bunny') {
|
toast.error('All uploads failed');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadStatus('');
|
|
||||||
setUploadProgress(0);
|
|
||||||
setIsUploading(false);
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bunnyCdnHostname, cleanupUploadState, directUploadProvider, projectsById, router]
|
[bunnyCdnHostname, directUploadProvider, fixedProjectId, projectsById, resetUploadState, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDropFile = useCallback(
|
const handleDropFiles = useCallback(
|
||||||
(file: File) => {
|
(files: File[]) => {
|
||||||
if (!canUpload) {
|
if (!canUpload) {
|
||||||
toast.error('You do not have permission to upload videos here');
|
toast.error('You do not have permission to upload videos here');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDroppedFile(file);
|
const videoFiles = files.filter(isVideoFile);
|
||||||
|
const invalidCount = files.length - videoFiles.length;
|
||||||
|
|
||||||
if (fixedProjectId) {
|
if (videoFiles.length === 0) {
|
||||||
void uploadFileToProject(file, fixedProjectId, fixedProjectName);
|
toast.error('Please drop valid video files');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (invalidCount > 0) {
|
||||||
|
toast.error(`${invalidCount} file${invalidCount === 1 ? '' : 's'} skipped (not a video)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedProjectId) {
|
||||||
|
void uploadQueueToProject(videoFiles, fixedProjectId, fixedProjectName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQueue(videoFiles.map(createQueueItem));
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
void ensureProjectsLoaded();
|
void ensureProjectsLoaded();
|
||||||
},
|
},
|
||||||
[canUpload, ensureProjectsLoaded, fixedProjectId, fixedProjectName, uploadFileToProject]
|
[canUpload, ensureProjectsLoaded, fixedProjectId, fixedProjectName, uploadQueueToProject]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -520,13 +406,10 @@ export function VideoDragDropUploader({
|
|||||||
dragDepthRef.current = 0;
|
dragDepthRef.current = 0;
|
||||||
setIsDragActive(false);
|
setIsDragActive(false);
|
||||||
|
|
||||||
const videoFile = extractVideoFile(event.dataTransfer);
|
const allFiles = Array.from(event.dataTransfer?.files ?? []);
|
||||||
if (!videoFile) {
|
if (allFiles.length === 0) return;
|
||||||
toast.error('Please drop a valid video file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDropFile(videoFile);
|
handleDropFiles(allFiles);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('dragenter', handleDragEnter);
|
window.addEventListener('dragenter', handleDragEnter);
|
||||||
@@ -540,7 +423,15 @@ export function VideoDragDropUploader({
|
|||||||
window.removeEventListener('dragleave', handleDragLeave);
|
window.removeEventListener('dragleave', handleDragLeave);
|
||||||
window.removeEventListener('drop', handleDrop);
|
window.removeEventListener('drop', handleDrop);
|
||||||
};
|
};
|
||||||
}, [handleDropFile]);
|
}, [handleDropFiles]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setQueue([]);
|
||||||
|
setUploadStatus('');
|
||||||
|
setUploadProgress(0);
|
||||||
|
setSelectedProjectId(fixedProjectId ?? null);
|
||||||
|
setSelectedProjectName(fixedProjectName ?? null);
|
||||||
|
}, [fixedProjectId, fixedProjectName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -549,11 +440,11 @@ export function VideoDragDropUploader({
|
|||||||
<div className="flex h-full items-center justify-center px-4">
|
<div className="flex h-full items-center justify-center px-4">
|
||||||
<div className="w-full max-w-2xl rounded-2xl border-2 border-dashed border-primary bg-background p-10 text-center shadow-2xl">
|
<div className="w-full max-w-2xl rounded-2xl border-2 border-dashed border-primary bg-background p-10 text-center shadow-2xl">
|
||||||
<UploadCloud className="mx-auto mb-4 h-10 w-10 text-primary" />
|
<UploadCloud className="mx-auto mb-4 h-10 w-10 text-primary" />
|
||||||
<p className="text-lg font-semibold">Drop video to upload</p>
|
<p className="text-lg font-semibold">Drop videos to upload</p>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{fixedProjectId
|
{fixedProjectId
|
||||||
? `Upload to ${fixedProjectName ?? 'current project'}`
|
? `Upload multiple videos to ${fixedProjectName ?? 'current project'}`
|
||||||
: 'Drop now, then choose a project card.'}
|
: 'Drop multiple videos, then choose a project.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -568,26 +459,62 @@ export function VideoDragDropUploader({
|
|||||||
setShowCancelUploadDialog(true);
|
setShowCancelUploadDialog(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDroppedFile(null);
|
closeDialog();
|
||||||
setUploadStatus('');
|
|
||||||
setUploadProgress(0);
|
|
||||||
setSelectedProjectId(fixedProjectId ?? null);
|
|
||||||
setSelectedProjectName(fixedProjectName ?? null);
|
|
||||||
}
|
}
|
||||||
setDialogOpen(open);
|
setDialogOpen(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="border-2 border-border bg-background text-foreground sm:max-w-xl">
|
<DialogContent className="border-2 border-border bg-background text-foreground sm:max-w-xl">
|
||||||
<DialogHeader className="space-y-1">
|
<DialogHeader className="space-y-1">
|
||||||
<DialogTitle className="text-2xl font-bold">Choose a project</DialogTitle>
|
<DialogTitle className="text-2xl font-bold">
|
||||||
|
{needsProjectSelection ? 'Choose a project' : 'Uploading videos'}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{droppedFile
|
{hasQueue
|
||||||
? 'Upload 1 video to:'
|
? totalCount === 1
|
||||||
: 'Drop a video file anywhere on this page to start.'}
|
? `Upload 1 video${needsProjectSelection ? ' to:' : ''}`
|
||||||
|
: `Upload ${totalCount} videos${needsProjectSelection ? ' to:' : ''}`
|
||||||
|
: 'Drop video files anywhere on this page to start.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{hasQueue && (
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded-md border border-border">
|
||||||
|
{queue.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0"
|
||||||
|
>
|
||||||
|
{item.status === 'done' ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-600" />
|
||||||
|
) : item.status === 'error' ? (
|
||||||
|
<XCircle className="h-4 w-4 shrink-0 text-destructive" />
|
||||||
|
) : item.status === 'uploading' ? (
|
||||||
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||||
|
) : item.status === 'cancelled' ? (
|
||||||
|
<XCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<div className="h-4 w-4 shrink-0 rounded-full border border-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{item.file.name}</p>
|
||||||
|
{item.error ? (
|
||||||
|
<p className="truncate text-xs text-destructive">{item.error}</p>
|
||||||
|
) : (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{getDefaultTitleFromFile(item.file)}
|
||||||
|
{item.status === 'uploading' && item.progress > 0
|
||||||
|
? ` · ${item.progress}%`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!fixedProjectId && (
|
{!fixedProjectId && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{isLoadingProjects ? (
|
{isLoadingProjects ? (
|
||||||
@@ -597,23 +524,31 @@ export function VideoDragDropUploader({
|
|||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
tabIndex={droppedFile && !isUploading ? 0 : -1}
|
tabIndex={hasQueue && !isUploading ? 0 : -1}
|
||||||
disabled={!droppedFile || isUploading}
|
disabled={!hasQueue || isUploading || pendingCount === 0}
|
||||||
aria-disabled={!droppedFile || isUploading}
|
aria-disabled={!hasQueue || isUploading || pendingCount === 0}
|
||||||
className={`block w-full border-b border-border p-4 text-left transition-colors last:border-b-0 ${selectedProjectId === project.id ? 'bg-accent' : 'bg-background'} ${!droppedFile || isUploading ? 'opacity-60' : 'hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'}`}
|
className={`block w-full border-b border-border p-4 text-left transition-colors last:border-b-0 ${selectedProjectId === project.id ? 'bg-accent' : 'bg-background'} ${!hasQueue || isUploading ? 'opacity-60' : 'hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!droppedFile || isUploading) return;
|
if (!hasQueue || isUploading) return;
|
||||||
|
const pendingFiles = queue
|
||||||
|
.filter((item) => item.status === 'pending')
|
||||||
|
.map((item) => item.file);
|
||||||
|
if (pendingFiles.length === 0) return;
|
||||||
setSelectedProjectId(project.id);
|
setSelectedProjectId(project.id);
|
||||||
setSelectedProjectName(project.name);
|
setSelectedProjectName(project.name);
|
||||||
void uploadFileToProject(droppedFile, project.id, project.name);
|
void uploadQueueToProject(pendingFiles, project.id, project.name);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
if (!droppedFile || isUploading) return;
|
if (!hasQueue || isUploading) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const pendingFiles = queue
|
||||||
|
.filter((item) => item.status === 'pending')
|
||||||
|
.map((item) => item.file);
|
||||||
|
if (pendingFiles.length === 0) return;
|
||||||
setSelectedProjectId(project.id);
|
setSelectedProjectId(project.id);
|
||||||
setSelectedProjectName(project.name);
|
setSelectedProjectName(project.name);
|
||||||
void uploadFileToProject(droppedFile, project.id, project.name);
|
void uploadQueueToProject(pendingFiles, project.id, project.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-2xl font-semibold leading-tight text-foreground">
|
<p className="text-2xl font-semibold leading-tight text-foreground">
|
||||||
@@ -656,12 +591,27 @@ export function VideoDragDropUploader({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{totalCount > 1 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{doneCount} of {totalCount} complete
|
||||||
|
{errorCount > 0 ? ` · ${errorCount} failed` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!fixedProjectId && !isUploading && droppedFile && (
|
{!fixedProjectId && !isUploading && hasQueue && pendingCount > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Click a project card to start uploading.
|
Click a project card to start uploading {pendingCount} video
|
||||||
|
{pendingCount === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isUploading && hasQueue && (doneCount > 0 || errorCount > 0) && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{doneCount > 0 ? `${doneCount} uploaded` : ''}
|
||||||
|
{doneCount > 0 && errorCount > 0 ? ', ' : ''}
|
||||||
|
{errorCount > 0 ? `${errorCount} failed` : ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -673,8 +623,9 @@ export function VideoDragDropUploader({
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Cancel upload?</AlertDialogTitle>
|
<AlertDialogTitle>Cancel upload?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
A video upload is in progress. If you cancel now, the current upload will be
|
{totalCount > 1
|
||||||
discarded.
|
? 'Video uploads are in progress. If you cancel now, the current upload and any remaining queued files will be discarded.'
|
||||||
|
: 'A video upload is in progress. If you cancel now, the current upload will be discarded.'}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -925,6 +925,7 @@ export function VideoPageContent({
|
|||||||
loadMoreAssets={loadMoreAssets}
|
loadMoreAssets={loadMoreAssets}
|
||||||
highlightedAssetId={highlightedAssetId}
|
highlightedAssetId={highlightedAssetId}
|
||||||
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
|
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
|
||||||
|
directUploadProvider={directUploadProvider}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
composer={
|
composer={
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ import {
|
|||||||
type BunnyPreviewPlayerHandle,
|
type BunnyPreviewPlayerHandle,
|
||||||
} from '@/components/video-page/bunny-preview-player';
|
} from '@/components/video-page/bunny-preview-player';
|
||||||
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||||
import type { VideoAsset } from '@/components/video-page/types';
|
import type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types';
|
||||||
|
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
|
||||||
import {
|
import {
|
||||||
extractPastedImageFile,
|
extractPastedImageFile,
|
||||||
validateImageFile,
|
validateImageFile,
|
||||||
@@ -87,12 +88,13 @@ interface AssetsPaneProps {
|
|||||||
canDownloadAssets: boolean;
|
canDownloadAssets: boolean;
|
||||||
getGuestUploadToken: (intent: 'image' | 'audio') => Promise<string | null>;
|
getGuestUploadToken: (intent: 'image' | 'audio') => Promise<string | null>;
|
||||||
createAsset: (payload: {
|
createAsset: (payload: {
|
||||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
providerVideoId?: string;
|
providerVideoId?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
uploadToken?: string;
|
uploadToken?: string;
|
||||||
|
objectKey?: string;
|
||||||
reservationId?: string | null;
|
reservationId?: string | null;
|
||||||
}) => Promise<VideoAsset | null>;
|
}) => Promise<VideoAsset | null>;
|
||||||
deleteAsset: (assetId: string) => Promise<boolean>;
|
deleteAsset: (assetId: string) => Promise<boolean>;
|
||||||
@@ -102,6 +104,7 @@ interface AssetsPaneProps {
|
|||||||
loadMoreAssets: () => Promise<void>;
|
loadMoreAssets: () => Promise<void>;
|
||||||
highlightedAssetId: string | null;
|
highlightedAssetId: string | null;
|
||||||
onHighlightedAssetHandled: () => void;
|
onHighlightedAssetHandled: () => void;
|
||||||
|
directUploadProvider?: DirectUploadProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AssetsPane = memo(function AssetsPane({
|
export const AssetsPane = memo(function AssetsPane({
|
||||||
@@ -122,16 +125,18 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
loadMoreAssets,
|
loadMoreAssets,
|
||||||
highlightedAssetId,
|
highlightedAssetId,
|
||||||
onHighlightedAssetHandled,
|
onHighlightedAssetHandled,
|
||||||
|
directUploadProvider = 'bunny',
|
||||||
}: AssetsPaneProps) {
|
}: AssetsPaneProps) {
|
||||||
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image');
|
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image');
|
||||||
const [imageTitle, setImageTitle] = useState('');
|
const [imageTitle, setImageTitle] = useState('');
|
||||||
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
|
const [pendingImageFiles, setPendingImageFiles] = useState<File[]>([]);
|
||||||
const [youtubeUrl, setYoutubeUrl] = useState('');
|
const [youtubeUrl, setYoutubeUrl] = useState('');
|
||||||
const [youtubeTitle, setYoutubeTitle] = useState('');
|
const [youtubeTitle, setYoutubeTitle] = useState('');
|
||||||
const [bunnyTitle, setBunnyTitle] = useState('');
|
const [bunnyTitle, setBunnyTitle] = useState('');
|
||||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||||
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
|
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
|
||||||
const [bunnyProgress, setBunnyProgress] = useState(0);
|
const [bunnyProgress, setBunnyProgress] = useState(0);
|
||||||
|
const [bunnyUploadLabel, setBunnyUploadLabel] = useState('');
|
||||||
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>(
|
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
@@ -152,6 +157,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false });
|
const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false });
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const bunnyInputRef = useRef<HTMLInputElement>(null);
|
const bunnyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const voiceInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Voice recording state
|
// Voice recording state
|
||||||
const [voiceTitle, setVoiceTitle] = useState('');
|
const [voiceTitle, setVoiceTitle] = useState('');
|
||||||
@@ -159,7 +165,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const [recordingTime, setRecordingTime] = useState(0);
|
const [recordingTime, setRecordingTime] = useState(0);
|
||||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||||
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
|
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
|
||||||
const [pendingAudioFile, setPendingAudioFile] = useState<File | null>(null);
|
const [pendingAudioFiles, setPendingAudioFiles] = useState<File[]>([]);
|
||||||
const [isUploadingVoice, setIsUploadingVoice] = useState(false);
|
const [isUploadingVoice, setIsUploadingVoice] = useState(false);
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const audioChunksRef = useRef<BlobPart[]>([]);
|
const audioChunksRef = useRef<BlobPart[]>([]);
|
||||||
@@ -339,17 +345,14 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
);
|
);
|
||||||
}, [bunnyReadyByAssetId, selectedAsset]);
|
}, [bunnyReadyByAssetId, selectedAsset]);
|
||||||
|
|
||||||
const handleImageUpload = useCallback(
|
const uploadSingleImageAsset = useCallback(
|
||||||
async (file: File) => {
|
async (file: File, displayName?: string): Promise<boolean> => {
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const imageError = await validateImageFile(file);
|
const imageError = await validateImageFile(file);
|
||||||
if (imageError) {
|
if (imageError) {
|
||||||
toast.error(imageError);
|
toast.error(`${file.name}: ${imageError}`);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploadingImage(true);
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
@@ -367,39 +370,99 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
} | null;
|
} | null;
|
||||||
const uploadedImageUrl = uploadPayload?.data?.url;
|
const uploadedImageUrl = uploadPayload?.data?.url;
|
||||||
if (!uploadRes.ok || !uploadedImageUrl) {
|
if (!uploadRes.ok || !uploadedImageUrl) {
|
||||||
toast.error(uploadPayload?.error || 'Failed to upload image');
|
toast.error(`${file.name}: ${uploadPayload?.error || 'Failed to upload image'}`);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await createAsset({
|
const created = await createAsset({
|
||||||
provider: 'R2_IMAGE',
|
provider: 'R2_IMAGE',
|
||||||
sourceUrl: uploadedImageUrl,
|
sourceUrl: uploadedImageUrl,
|
||||||
displayName: imageTitle.trim() || file.name,
|
displayName: displayName?.trim() || file.name,
|
||||||
reservationId: uploadPayload?.data?.reservationId ?? null,
|
reservationId: uploadPayload?.data?.reservationId ?? null,
|
||||||
});
|
});
|
||||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
return !!created;
|
||||||
setImageTitle('');
|
|
||||||
setPendingImageFile(null);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload image asset:', error);
|
console.error('Failed to upload image asset:', error);
|
||||||
toast.error('Failed to upload image');
|
toast.error(`${file.name}: Failed to upload image`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[videoId, getGuestUploadToken, createAsset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImageUpload = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploadingImage(true);
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const displayName = imageTitle.trim() || file.name;
|
||||||
|
const ok = await uploadSingleImageAsset(file, displayName);
|
||||||
|
if (ok) successCount += 1;
|
||||||
|
else failCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||||
|
setImageTitle('');
|
||||||
|
setPendingImageFiles([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0 && failCount === 0) {
|
||||||
|
toast.success(successCount === 1 ? 'Image uploaded' : `${successCount} images uploaded`);
|
||||||
|
} else if (successCount > 0 && failCount > 0) {
|
||||||
|
toast.warning(`${successCount} uploaded, ${failCount} failed`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingImage(false);
|
setIsUploadingImage(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[videoId, getGuestUploadToken, createAsset, imageTitle]
|
[imageTitle, uploadSingleImageAsset]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const stageImageFiles = useCallback(async (files: File[]) => {
|
||||||
const file = event.target.files?.[0];
|
const validFiles: File[] = [];
|
||||||
if (!file) return;
|
for (const file of files) {
|
||||||
const imageError = await validateImageFile(file);
|
const imageError = await validateImageFile(file);
|
||||||
if (imageError) {
|
if (imageError) {
|
||||||
toast.error(imageError);
|
toast.error(`${file.name}: ${imageError}`);
|
||||||
return;
|
continue;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
}
|
}
|
||||||
setPendingImageFile(file);
|
if (validFiles.length === 0) return;
|
||||||
toast.success('Image attached. Click Upload Image to send.');
|
|
||||||
|
setPendingImageFiles((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
for (const file of validFiles) {
|
||||||
|
const duplicate = next.some(
|
||||||
|
(existing) =>
|
||||||
|
existing.name === file.name &&
|
||||||
|
existing.size === file.size &&
|
||||||
|
existing.lastModified === file.lastModified
|
||||||
|
);
|
||||||
|
if (!duplicate) next.push(file);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
validFiles.length === 1
|
||||||
|
? 'Image attached. Click Upload to send.'
|
||||||
|
: `${validFiles.length} images attached. Click Upload to send.`
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
setUploadTab('image');
|
||||||
|
await stageImageFiles(files);
|
||||||
|
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImagePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
|
const handleImagePaste = async (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
@@ -407,13 +470,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const pastedImage = extractPastedImageFile(event.clipboardData);
|
const pastedImage = extractPastedImageFile(event.clipboardData);
|
||||||
if (!pastedImage) return;
|
if (!pastedImage) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const imageError = await validateImageFile(pastedImage);
|
await stageImageFiles([pastedImage]);
|
||||||
if (imageError) {
|
|
||||||
toast.error(imageError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingImageFile(pastedImage);
|
|
||||||
toast.success('Image attached from clipboard. Click Upload Image to send.');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateYoutubeAsset = async () => {
|
const handleCreateYoutubeAsset = async () => {
|
||||||
@@ -430,10 +487,10 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBunnyFileUpload = useCallback(
|
const handleBunnyFileUpload = useCallback(
|
||||||
async (file: File) => {
|
async (file: File, options?: { index?: number; total?: number }) => {
|
||||||
if (!file.type.startsWith('video/')) {
|
if (!file.type.startsWith('video/')) {
|
||||||
toast.error('Please select a video file');
|
toast.error(`${file.name}: Please select a video file`);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let uploadedVideoId: string | null = null;
|
let uploadedVideoId: string | null = null;
|
||||||
@@ -441,6 +498,11 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
try {
|
try {
|
||||||
setIsUploadingBunny(true);
|
setIsUploadingBunny(true);
|
||||||
setBunnyProgress(0);
|
setBunnyProgress(0);
|
||||||
|
if (options?.total && options.total > 1) {
|
||||||
|
setBunnyUploadLabel(`Uploading ${options.index ?? 1} of ${options.total}: ${file.name}`);
|
||||||
|
} else {
|
||||||
|
setBunnyUploadLabel('');
|
||||||
|
}
|
||||||
|
|
||||||
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -459,8 +521,8 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
if (!initRes.ok || !initPayload?.data) {
|
if (!initRes.ok || !initPayload?.data) {
|
||||||
toast.error(initPayload?.error || 'Failed to initialize Bunny upload');
|
toast.error(`${file.name}: ${initPayload?.error || 'Failed to initialize Bunny upload'}`);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initData = initPayload.data;
|
const initData = initPayload.data;
|
||||||
@@ -508,11 +570,10 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
}
|
}
|
||||||
setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false }));
|
setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false }));
|
||||||
setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true }));
|
setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true }));
|
||||||
if (bunnyInputRef.current) bunnyInputRef.current.value = '';
|
return true;
|
||||||
setBunnyTitle('');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload Bunny asset:', error);
|
console.error('Failed to upload Bunny asset:', error);
|
||||||
toast.error('Failed to upload Bunny video');
|
toast.error(`${file.name}: Failed to upload Bunny video`);
|
||||||
if (uploadedVideoId && uploadToken) {
|
if (uploadedVideoId && uploadToken) {
|
||||||
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -520,18 +581,109 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }),
|
body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }),
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingBunny(false);
|
setIsUploadingBunny(false);
|
||||||
setBunnyProgress(0);
|
setBunnyProgress(0);
|
||||||
|
setBunnyUploadLabel('');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[videoId, bunnyTitle, bunnyCdnHostname, createAsset]
|
[videoId, bunnyTitle, bunnyCdnHostname, createAsset]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleR2FileUpload = useCallback(
|
||||||
|
async (file: File, options?: { index?: number; total?: number }) => {
|
||||||
|
if (!file.type.startsWith('video/')) {
|
||||||
|
toast.error(`${file.name}: Please select a video file`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUploadingBunny(true);
|
||||||
|
setBunnyProgress(0);
|
||||||
|
if (options?.total && options.total > 1) {
|
||||||
|
setBunnyUploadLabel(`Uploading ${options.index ?? 1} of ${options.total}: ${file.name}`);
|
||||||
|
} else {
|
||||||
|
setBunnyUploadLabel('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResult = await uploadAssetVideoToR2(videoId, file, {
|
||||||
|
onProgress: (progress) => setBunnyProgress(progress),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdAsset = await createAsset({
|
||||||
|
provider: 'R2_VIDEO',
|
||||||
|
sourceUrl: uploadResult.proxyUrl,
|
||||||
|
objectKey: uploadResult.objectKey,
|
||||||
|
uploadToken: uploadResult.uploadToken,
|
||||||
|
reservationId: uploadResult.reservationId,
|
||||||
|
thumbnailUrl: uploadResult.thumbnailUrl ?? undefined,
|
||||||
|
displayName: bunnyTitle.trim() || file.name,
|
||||||
|
});
|
||||||
|
if (!createdAsset) {
|
||||||
|
throw new Error('Failed to finalize video asset');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload R2 asset video:', error);
|
||||||
|
toast.error(`${file.name}: Failed to upload video`);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsUploadingBunny(false);
|
||||||
|
setBunnyProgress(0);
|
||||||
|
setBunnyUploadLabel('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[videoId, bunnyTitle, createAsset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVideoFileUpload = useCallback(
|
||||||
|
(file: File, options?: { index?: number; total?: number }) => {
|
||||||
|
if (directUploadProvider === 'r2') {
|
||||||
|
return handleR2FileUpload(file, options);
|
||||||
|
}
|
||||||
|
return handleBunnyFileUpload(file, options);
|
||||||
|
},
|
||||||
|
[directUploadProvider, handleBunnyFileUpload, handleR2FileUpload]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVideoBatchUpload = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < files.length; index++) {
|
||||||
|
const ok = await handleVideoFileUpload(files[index], {
|
||||||
|
index: index + 1,
|
||||||
|
total: files.length,
|
||||||
|
});
|
||||||
|
if (ok) successCount += 1;
|
||||||
|
else failCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bunnyInputRef.current) bunnyInputRef.current.value = '';
|
||||||
|
if (successCount > 0) setBunnyTitle('');
|
||||||
|
|
||||||
|
if (successCount > 0 && failCount === 0) {
|
||||||
|
toast.success(successCount === 1 ? 'Video uploaded' : `${successCount} videos uploaded`);
|
||||||
|
} else if (successCount > 0 && failCount > 0) {
|
||||||
|
toast.warning(`${successCount} uploaded, ${failCount} failed`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleVideoFileUpload]
|
||||||
|
);
|
||||||
|
|
||||||
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const files = Array.from(event.target.files ?? []).filter((file) =>
|
||||||
if (!file) return;
|
file.type.startsWith('video/')
|
||||||
await handleBunnyFileUpload(file);
|
);
|
||||||
|
if (files.length === 0) {
|
||||||
|
toast.error('Please select valid video files');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleVideoBatchUpload(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBunnyThumbnailError = (assetId: string) => {
|
const handleBunnyThumbnailError = (assetId: string) => {
|
||||||
@@ -605,73 +757,157 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
setPendingAudioFile(null);
|
setPendingAudioFiles([]);
|
||||||
if (recordingTimerRef.current) {
|
if (recordingTimerRef.current) {
|
||||||
clearInterval(recordingTimerRef.current);
|
clearInterval(recordingTimerRef.current);
|
||||||
recordingTimerRef.current = null;
|
recordingTimerRef.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleVoiceUpload = useCallback(async () => {
|
const uploadSingleAudioAsset = useCallback(
|
||||||
const uploadSource = pendingAudioFile ?? audioBlob;
|
async (file: File | Blob, fileName: string, displayName?: string): Promise<boolean> => {
|
||||||
if (!uploadSource) return;
|
const validationError = getAudioUploadValidationError(file);
|
||||||
|
if (validationError) {
|
||||||
|
toast.error(`${fileName}: ${validationError}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const validationError = getAudioUploadValidationError(uploadSource);
|
try {
|
||||||
if (validationError) {
|
const formData = new FormData();
|
||||||
toast.error(validationError);
|
formData.append('audio', file, file instanceof File ? file.name : 'recording.webm');
|
||||||
|
formData.append('videoId', videoId);
|
||||||
|
const guestUploadToken = await getGuestUploadToken('audio');
|
||||||
|
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
|
||||||
|
|
||||||
|
const uploadRes = await fetch('/api/upload/audio', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const uploadPayload = await readUploadAudioResponse(uploadRes);
|
||||||
|
const uploadedUrl = uploadPayload?.data?.url;
|
||||||
|
if (!uploadRes.ok || !uploadedUrl) {
|
||||||
|
toast.error(
|
||||||
|
`${fileName}: ${
|
||||||
|
uploadPayload?.error ||
|
||||||
|
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
|
||||||
|
'Failed to upload voice recording'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createAsset({
|
||||||
|
provider: 'R2_AUDIO',
|
||||||
|
sourceUrl: uploadedUrl,
|
||||||
|
displayName:
|
||||||
|
displayName?.trim() || fileName.replace(/\.[^/.]+$/, '') || 'Voice Recording',
|
||||||
|
reservationId: uploadPayload?.data?.reservationId ?? null,
|
||||||
|
});
|
||||||
|
return !!created;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload voice asset:', error);
|
||||||
|
toast.error(`${fileName}: Failed to upload voice recording`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[videoId, getGuestUploadToken, createAsset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stageAudioFiles = useCallback((files: File[]) => {
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const audioError = getAudioUploadValidationError(file);
|
||||||
|
if (audioError) {
|
||||||
|
toast.error(`${file.name}: ${audioError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
if (validFiles.length === 0) return;
|
||||||
|
|
||||||
|
setPendingAudioFiles((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
for (const file of validFiles) {
|
||||||
|
const duplicate = next.some(
|
||||||
|
(existing) =>
|
||||||
|
existing.name === file.name &&
|
||||||
|
existing.size === file.size &&
|
||||||
|
existing.lastModified === file.lastModified
|
||||||
|
);
|
||||||
|
if (!duplicate) next.push(file);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
validFiles.length === 1
|
||||||
|
? 'Audio file attached. Click Upload to send.'
|
||||||
|
: `${validFiles.length} audio files attached. Click Upload to send.`
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVoiceFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
setUploadTab('voice');
|
||||||
|
stageAudioFiles(files);
|
||||||
|
if (voiceInputRef.current) voiceInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVoiceUpload = useCallback(async () => {
|
||||||
|
if (audioBlob && pendingAudioFiles.length === 0) {
|
||||||
|
setIsUploadingVoice(true);
|
||||||
|
try {
|
||||||
|
const ok = await uploadSingleAudioAsset(
|
||||||
|
audioBlob,
|
||||||
|
'recording.webm',
|
||||||
|
voiceTitle.trim() || 'Voice Recording'
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
setVoiceTitle('');
|
||||||
|
setAudioBlob(null);
|
||||||
|
setAudioBlobUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsUploadingVoice(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingAudioFiles.length === 0) return;
|
||||||
|
|
||||||
setIsUploadingVoice(true);
|
setIsUploadingVoice(true);
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
for (const file of pendingAudioFiles) {
|
||||||
if (pendingAudioFile) {
|
const displayName = voiceTitle.trim() || file.name.replace(/\.[^/.]+$/, '');
|
||||||
formData.append('audio', pendingAudioFile);
|
const ok = await uploadSingleAudioAsset(file, file.name, displayName);
|
||||||
} else {
|
if (ok) successCount += 1;
|
||||||
formData.append('audio', audioBlob!, 'recording.webm');
|
else failCount += 1;
|
||||||
}
|
}
|
||||||
formData.append('videoId', videoId);
|
|
||||||
const guestUploadToken = await getGuestUploadToken('audio');
|
|
||||||
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
|
|
||||||
|
|
||||||
const uploadRes = await fetch('/api/upload/audio', {
|
if (successCount > 0) {
|
||||||
method: 'POST',
|
setVoiceTitle('');
|
||||||
body: formData,
|
setPendingAudioFiles([]);
|
||||||
});
|
if (voiceInputRef.current) voiceInputRef.current.value = '';
|
||||||
const uploadPayload = await readUploadAudioResponse(uploadRes);
|
}
|
||||||
const uploadedUrl = uploadPayload?.data?.url;
|
|
||||||
if (!uploadRes.ok || !uploadedUrl) {
|
if (successCount > 0 && failCount === 0) {
|
||||||
toast.error(
|
toast.success(
|
||||||
uploadPayload?.error ||
|
successCount === 1 ? 'Audio uploaded' : `${successCount} audio files uploaded`
|
||||||
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
|
|
||||||
'Failed to upload voice recording'
|
|
||||||
);
|
);
|
||||||
return;
|
} else if (successCount > 0 && failCount > 0) {
|
||||||
|
toast.warning(`${successCount} uploaded, ${failCount} failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackName = pendingAudioFile
|
|
||||||
? pendingAudioFile.name.replace(/\.[^/.]+$/, '')
|
|
||||||
: 'Voice Recording';
|
|
||||||
await createAsset({
|
|
||||||
provider: 'R2_AUDIO',
|
|
||||||
sourceUrl: uploadedUrl,
|
|
||||||
displayName: voiceTitle.trim() || fallbackName,
|
|
||||||
reservationId: uploadPayload?.data?.reservationId ?? null,
|
|
||||||
});
|
|
||||||
setVoiceTitle('');
|
|
||||||
setAudioBlob(null);
|
|
||||||
setAudioBlobUrl((prev) => {
|
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
setPendingAudioFile(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload voice asset:', error);
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to upload voice recording');
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingVoice(false);
|
setIsUploadingVoice(false);
|
||||||
}
|
}
|
||||||
}, [pendingAudioFile, audioBlob, videoId, getGuestUploadToken, createAsset, voiceTitle]);
|
}, [audioBlob, pendingAudioFiles, uploadSingleAudioAsset, voiceTitle]);
|
||||||
|
|
||||||
const handleDragEnter = useCallback(
|
const handleDragEnter = useCallback(
|
||||||
(e: React.DragEvent) => {
|
(e: React.DragEvent) => {
|
||||||
@@ -700,36 +936,52 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
if (!canUploadAssets) return;
|
if (!canUploadAssets) return;
|
||||||
|
|
||||||
const file = Array.from(e.dataTransfer.files)[0];
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (!file) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
if (file.type.startsWith('image/')) {
|
const imageFiles: File[] = [];
|
||||||
const imageError = await validateImageFile(file);
|
const videoFiles: File[] = [];
|
||||||
if (imageError) {
|
const audioFiles: File[] = [];
|
||||||
toast.error(imageError);
|
let unsupportedCount = 0;
|
||||||
return;
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
imageFiles.push(file);
|
||||||
|
} else if (file.type.startsWith('video/')) {
|
||||||
|
videoFiles.push(file);
|
||||||
|
} else if (file.type.startsWith('audio/')) {
|
||||||
|
audioFiles.push(file);
|
||||||
|
} else {
|
||||||
|
unsupportedCount += 1;
|
||||||
|
toast.error(`${file.name}: Unsupported file type`);
|
||||||
}
|
}
|
||||||
// Stage the file so the user can optionally set a name before uploading
|
}
|
||||||
|
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
setUploadTab('image');
|
setUploadTab('image');
|
||||||
setPendingImageFile(file);
|
await stageImageFiles(imageFiles);
|
||||||
} else if (file.type.startsWith('video/')) {
|
}
|
||||||
// Videos upload immediately (large files, no staging)
|
|
||||||
setUploadTab('bunny');
|
if (audioFiles.length > 0) {
|
||||||
await handleBunnyFileUpload(file);
|
|
||||||
} else if (file.type.startsWith('audio/')) {
|
|
||||||
const audioError = getAudioUploadValidationError(file);
|
|
||||||
if (audioError) {
|
|
||||||
toast.error(audioError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Stage the file so the user can optionally set a name before uploading
|
|
||||||
setUploadTab('voice');
|
setUploadTab('voice');
|
||||||
setPendingAudioFile(file);
|
stageAudioFiles(audioFiles);
|
||||||
} else {
|
}
|
||||||
toast.error('Unsupported file type. Drop an image, video, or audio file.');
|
|
||||||
|
if (videoFiles.length > 0) {
|
||||||
|
setUploadTab('bunny');
|
||||||
|
await handleVideoBatchUpload(videoFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
imageFiles.length === 0 &&
|
||||||
|
videoFiles.length === 0 &&
|
||||||
|
audioFiles.length === 0 &&
|
||||||
|
unsupportedCount === 0
|
||||||
|
) {
|
||||||
|
toast.error('Drop an image, video, or audio file.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[canUploadAssets, handleBunnyFileUpload]
|
[canUploadAssets, handleVideoBatchUpload, stageAudioFiles, stageImageFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAssetPreview = (asset: VideoAsset) => {
|
const renderAssetPreview = (asset: VideoAsset) => {
|
||||||
@@ -772,6 +1024,27 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset.provider === 'R2_VIDEO') {
|
||||||
|
const thumbnailSrc = asset.thumbnailUrl;
|
||||||
|
return (
|
||||||
|
<div className="h-24 w-36 rounded border overflow-hidden bg-black/70 relative flex items-center justify-center">
|
||||||
|
{thumbnailSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt={asset.displayName}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileVideo className="h-6 w-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||||
|
<Play className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
|
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
|
||||||
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
|
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
|
||||||
const isReadyToPlay = !!bunnyReadyByAssetId[asset.id];
|
const isReadyToPlay = !!bunnyReadyByAssetId[asset.id];
|
||||||
@@ -863,7 +1136,10 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-primary/10 border-2 border-dashed border-primary pointer-events-none">
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-primary/10 border-2 border-dashed border-primary pointer-events-none">
|
||||||
<UploadCloud className="h-8 w-8 text-primary" />
|
<UploadCloud className="h-8 w-8 text-primary" />
|
||||||
<span className="text-sm font-medium text-primary">Drop to upload</span>
|
<span className="text-sm font-medium text-primary">Drop files to upload</span>
|
||||||
|
<span className="text-xs text-primary/80">
|
||||||
|
Multiple images, videos, or audio supported
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -891,22 +1167,40 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
If set, this name will be used in @asset mentions.
|
If set, this name will be used in @asset mentions.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Tip: you can paste an image here with Ctrl/Cmd+V.
|
Tip: paste an image with Ctrl/Cmd+V, or drop multiple files onto this panel.
|
||||||
</p>
|
</p>
|
||||||
{pendingImageFile ? (
|
{pendingImageFiles.length > 0 ? (
|
||||||
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
|
<div className="space-y-1">
|
||||||
<span className="truncate">Attached: {pendingImageFile.name}</span>
|
{pendingImageFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
|
||||||
|
className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span className="truncate">Attached: {file.name}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingImageFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 px-2"
|
className="h-6 px-2 text-xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPendingImageFile(null);
|
setPendingImageFiles([]);
|
||||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Clear
|
Clear all
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -915,8 +1209,8 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isUploadingImage || isCreatingAsset}
|
disabled={isUploadingImage || isCreatingAsset}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (pendingImageFile) {
|
if (pendingImageFiles.length > 0) {
|
||||||
void handleImageUpload(pendingImageFile);
|
void handleImageUpload(pendingImageFiles);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
imageInputRef.current?.click();
|
imageInputRef.current?.click();
|
||||||
@@ -931,14 +1225,17 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
? 'Uploading...'
|
? 'Uploading...'
|
||||||
: isCreatingAsset
|
: isCreatingAsset
|
||||||
? 'Saving...'
|
? 'Saving...'
|
||||||
: pendingImageFile
|
: pendingImageFiles.length > 1
|
||||||
? 'Upload Image'
|
? `Upload ${pendingImageFiles.length} Images`
|
||||||
: 'Select Image'}
|
: pendingImageFiles.length === 1
|
||||||
|
? 'Upload Image'
|
||||||
|
: 'Select Images'}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={imageInputRef}
|
ref={imageInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleImageFileChange}
|
onChange={handleImageFileChange}
|
||||||
/>
|
/>
|
||||||
@@ -978,6 +1275,9 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
If set, this name will be used in @asset mentions.
|
If set, this name will be used in @asset mentions.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Drop multiple video files onto this panel to upload them in sequence.
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -989,21 +1289,27 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
) : (
|
) : (
|
||||||
<UploadCloud className="h-4 w-4 mr-2" />
|
<UploadCloud className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
{isUploadingBunny ? 'Uploading...' : 'Upload Video'}
|
{isUploadingBunny ? 'Uploading...' : 'Select Videos'}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={bunnyInputRef}
|
ref={bunnyInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="video/*"
|
accept="video/*"
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleBunnyUpload}
|
onChange={handleBunnyUpload}
|
||||||
/>
|
/>
|
||||||
{isUploadingBunny && (
|
{isUploadingBunny && (
|
||||||
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
|
<div className="space-y-1">
|
||||||
<div
|
{bunnyUploadLabel ? (
|
||||||
className="bg-primary h-2 rounded-full"
|
<p className="text-xs text-muted-foreground">{bunnyUploadLabel}</p>
|
||||||
style={{ width: `${bunnyProgress}%` }}
|
) : null}
|
||||||
/>
|
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full"
|
||||||
|
style={{ width: `${bunnyProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1017,31 +1323,54 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
onChange={(e) => setVoiceTitle(e.target.value)}
|
onChange={(e) => setVoiceTitle(e.target.value)}
|
||||||
disabled={isRecording || isUploadingVoice}
|
disabled={isRecording || isUploadingVoice}
|
||||||
/>
|
/>
|
||||||
{pendingAudioFile ? (
|
{pendingAudioFiles.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
|
{pendingAudioFiles.map((file, index) => (
|
||||||
<span className="truncate">Attached: {pendingAudioFile.name}</span>
|
<div
|
||||||
<Button
|
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
|
||||||
type="button"
|
className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2"
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 px-2"
|
|
||||||
onClick={() => setPendingAudioFile(null)}
|
|
||||||
>
|
>
|
||||||
Clear
|
<span className="truncate">Attached: {file.name}</span>
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingAudioFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingAudioFiles([]);
|
||||||
|
if (voiceInputRef.current) voiceInputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isUploadingVoice || isCreatingAsset}
|
disabled={isUploadingVoice || isCreatingAsset}
|
||||||
onClick={handleVoiceUpload}
|
onClick={() => void handleVoiceUpload()}
|
||||||
>
|
>
|
||||||
{isUploadingVoice ? (
|
{isUploadingVoice ? (
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<UploadCloud className="h-4 w-4 mr-2" />
|
<UploadCloud className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
{isUploadingVoice ? 'Uploading...' : 'Upload File'}
|
{isUploadingVoice
|
||||||
|
? 'Uploading...'
|
||||||
|
: pendingAudioFiles.length > 1
|
||||||
|
? `Upload ${pendingAudioFiles.length} Files`
|
||||||
|
: 'Upload File'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : isRecording ? (
|
) : isRecording ? (
|
||||||
@@ -1141,18 +1470,37 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full"
|
variant="outline"
|
||||||
onClick={startRecording}
|
className="w-full"
|
||||||
disabled={isUploadingVoice || isCreatingAsset}
|
onClick={startRecording}
|
||||||
>
|
disabled={isUploadingVoice || isCreatingAsset}
|
||||||
<Mic className="h-4 w-4 mr-2" />
|
>
|
||||||
Start Recording
|
<Mic className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Start Recording
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isUploadingVoice || isCreatingAsset}
|
||||||
|
onClick={() => voiceInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<UploadCloud className="h-4 w-4 mr-2" />
|
||||||
|
Select Audio Files
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={voiceInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleVoiceFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Or drag an audio file anywhere onto this panel.
|
Or drag multiple audio files anywhere onto this panel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1286,6 +1634,22 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
Open on YouTube
|
Open on YouTube
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : selectedAsset?.provider === 'R2_VIDEO' && canDownloadAssets ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
title="Download video"
|
||||||
|
aria-label="Download video"
|
||||||
|
disabled={activeDownloadAssetId === selectedAsset.id}
|
||||||
|
onClick={() => void downloadAsset(selectedAsset)}
|
||||||
|
>
|
||||||
|
{activeDownloadAssetId === selectedAsset.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? (
|
{selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -1346,6 +1710,14 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedAsset.provider === 'R2_VIDEO' && selectedAsset.sourceUrl ? (
|
||||||
|
<video
|
||||||
|
className="w-full h-full rounded-md border bg-black object-contain"
|
||||||
|
src={selectedAsset.sourceUrl}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BunnyPreviewPlayer
|
<BunnyPreviewPlayer
|
||||||
ref={bunnyPreviewPlayerRef}
|
ref={bunnyPreviewPlayerRef}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import type { VideoAsset } from '@/components/video-page/types';
|
|||||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||||
|
|
||||||
type CreateAssetPayload = {
|
type CreateAssetPayload = {
|
||||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
providerVideoId?: string;
|
providerVideoId?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
uploadToken?: string;
|
uploadToken?: string;
|
||||||
|
objectKey?: string;
|
||||||
reservationId?: string | null;
|
reservationId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface VideoAsset {
|
|||||||
id: string;
|
id: string;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
|
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
|
||||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
|
||||||
displayName: string;
|
displayName: string;
|
||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
providerVideoId: string | null;
|
providerVideoId: string | null;
|
||||||
|
|||||||
@@ -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-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||||
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||||
'asset-bunny-init': { 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 — debounced on client but protect against scripted callers
|
||||||
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
|
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
|
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||||
FROM video_assets
|
FROM video_assets
|
||||||
WHERE "billedUserId" = ${userId}
|
WHERE "billedUserId" = ${userId}
|
||||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO')
|
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
|
||||||
`,
|
`,
|
||||||
db.$queryRaw<[{ total: bigint }]>`
|
db.$queryRaw<[{ total: bigint }]>`
|
||||||
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
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
|
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||||
FROM video_assets
|
FROM video_assets
|
||||||
WHERE "billedUserId" = ${userId}
|
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 }]>`
|
const [r2VideoRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||||
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
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 IMAGE_PROXY_PREFIX = '/api/upload/image/';
|
||||||
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
||||||
|
const VIDEO_PROXY_PREFIX = '/api/upload/video/';
|
||||||
|
|
||||||
export const SAFE_IMAGE_PROXY_PATH =
|
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;
|
/^\/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 =
|
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;
|
/^\/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 const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
||||||
|
|
||||||
export type VideoAssetAccessContext = {
|
export type VideoAssetAccessContext = {
|
||||||
@@ -80,6 +83,19 @@ export function extractAudioFileNameFromProxyUrl(url: string): string | null {
|
|||||||
return filename || 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 {
|
export function mediaUrlToR2Key(url: string): string | null {
|
||||||
if (url.includes(IMAGE_PROXY_PREFIX)) {
|
if (url.includes(IMAGE_PROXY_PREFIX)) {
|
||||||
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
|
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "VideoAssetProvider" ADD VALUE 'R2_VIDEO';
|
||||||
@@ -100,6 +100,7 @@ enum VideoAssetProvider {
|
|||||||
YOUTUBE
|
YOUTUBE
|
||||||
BUNNY
|
BUNNY
|
||||||
R2_AUDIO
|
R2_AUDIO
|
||||||
|
R2_VIDEO
|
||||||
}
|
}
|
||||||
|
|
||||||
model DownloadEgressEvent {
|
model DownloadEgressEvent {
|
||||||
|
|||||||
Reference in New Issue
Block a user