Merge pull request #21 from yusufipk/cursor/be172284

feat: bulk video uploads and S3 asset video support (fixes #18)
This commit is contained in:
Yusuf İpek
2026-06-14 00:27:09 +03:00
committed by GitHub
20 changed files with 1980 additions and 776 deletions
@@ -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)) {
setSubmitError('Please select a valid video file.'); invalidCount += 1;
continue;
}
validFiles.push(file);
}
if (validFiles.length === 0) {
setSubmitError('Please select valid video files.');
return; return;
} }
setSelectedFile(file); if (invalidCount > 0) {
setSubmitError(
`${invalidCount} file${invalidCount === 1 ? '' : 's'} skipped (not a video).`
);
} else {
setSubmitError(''); 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,72 +390,18 @@ 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);
return;
}
finalTitle = finalTitle || selectedFile.name;
if (directUploadProvider === 'r2') {
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`, { const response = await fetch(`/api/projects/${projectId}/videos`, {
method: 'POST', method: 'POST',
@@ -449,57 +409,47 @@ export default function NewVideoPageClient({
body: JSON.stringify({ body: JSON.stringify({
title: finalTitle, title: finalTitle,
description: finalDescription, description: finalDescription,
videoUrl: finalVideoUrl, videoUrl: videoSource.originalUrl,
providerId: finalProviderId, providerId: videoSource.providerId,
videoId: finalVideoId, videoId: videoSource.videoId,
thumbnailUrl: finalThumbnailUrl, thumbnailUrl: getThumbnailUrl(videoSource, 'large'),
duration: finalDuration, duration: videoSource.metadata?.duration || null,
uploadToken: uploadedBunnyUploadToken,
objectKey: pendingR2ObjectKeyRef.current,
reservationId: pendingR2ReservationIdRef.current,
}), }),
}); });
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
setSubmitError(data.error || 'Failed to add video'); 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;
pendingBunnyUploadTokenRef.current = null;
pendingR2ObjectKeyRef.current = null;
pendingR2UploadTokenRef.current = null;
pendingR2ReservationIdRef.current = null;
setPendingBunnyVideoId(null);
setPendingBunnyUploadToken(null);
router.push(`/projects/${projectId}`); router.push(`/projects/${projectId}`);
return;
}
if (!directUploadsEnabled) {
throw new Error('Direct uploads are disabled by this host');
}
if (selectedFiles.length === 0) {
setSubmitError('Please select at least one video file to upload');
setIsLoading(false);
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,7 +651,8 @@ export default function NewVideoPageClient({
</div> </div>
)} )}
{/* Title */} {uploadMode === 'url' || selectedFiles.length <= 1 ? (
<>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input <Input
@@ -666,29 +660,45 @@ export default function NewVideoPageClient({
placeholder={ placeholder={
isFetchingMeta isFetchingMeta
? 'Fetching title...' ? 'Fetching title...'
: uploadMode === 'file' && isMultiFileUpload
? 'Not used for multi-file uploads'
: 'Video title (will auto-fill from video if empty)' : 'Video title (will auto-fill from video if empty)'
} }
value={formData.title} value={formData.title}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
disabled={isLoading} disabled={isLoading || (uploadMode === 'file' && isMultiFileUpload)}
/> />
{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"> <p className="text-xs text-muted-foreground">
Leave empty to use the original video title Leave empty to use the original video title
</p> </p>
)}
</div> </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) => setFormData((prev) => ({ ...prev, description: e.target.value }))} onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
rows={3} rows={3}
disabled={isLoading} disabled={isLoading || (uploadMode === 'file' && isMultiFileUpload)}
/> />
{uploadMode === 'file' && isMultiFileUpload ? (
<p className="text-xs text-muted-foreground">
Descriptions are not applied in bulk upload mode.
</p>
) : null}
</div> </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"
+12 -1
View File
@@ -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');
}
}
+93 -4
View File
@@ -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,
+8 -1
View File
@@ -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'}
/>
);
} }
+241 -290
View File
@@ -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'; let successCount = 0;
objectKey: string; let failCount = 0;
uploadToken: string;
reservationId: string | null; for (let index = 0; index < initialQueue.length; index++) {
thumbnailObjectKey?: string; if (cancelRequestedRef.current) break;
}
| null = null; 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 { try {
const title = getDefaultTitleFromFile(file); await uploadProjectVideo(projectId, item.file, {
provider: directUploadProvider,
if (directUploadProvider === 'r2') { bunnyCdnHostname,
const uploaded = await uploadVideoToR2(projectId, file, {
onProgress: (progress) => { onProgress: (progress) => {
setUploadProgress(progress); setUploadProgress(progress);
setUploadStatus(`Uploading... ${progress}%`); setQueue((prev) =>
}, prev.map((entry) => (entry.id === item.id ? { ...entry, progress } : entry))
});
pendingCleanup = {
type: 'r2',
objectKey: uploaded.objectKey,
uploadToken: uploaded.uploadToken,
reservationId: uploaded.reservationId,
thumbnailObjectKey: uploaded.thumbnailObjectKey,
};
pendingUploadRef.current = {
type: 'r2',
projectId,
objectKey: uploaded.objectKey,
uploadToken: uploaded.uploadToken,
reservationId: uploaded.reservationId,
thumbnailObjectKey: uploaded.thumbnailObjectKey,
};
setUploadStatus('Saving video...');
const createResponse = await fetch(`/api/projects/${projectId}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: null,
videoUrl: uploaded.proxyUrl,
providerId: 'r2',
videoId: uploaded.objectKey,
thumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
duration: uploaded.duration,
uploadToken: uploaded.uploadToken,
objectKey: uploaded.objectKey,
reservationId: uploaded.reservationId,
}),
});
const createPayload = (await createResponse.json().catch(() => null)) as {
error?: string;
} | null;
if (!createResponse.ok) {
throw new Error(createPayload?.error || 'Failed to create video');
}
toast.success(
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
); );
setDialogOpen(false);
setDroppedFile(null);
cleanupUploadState();
router.push(`/projects/${projectId}`);
router.refresh();
return;
}
const initResponse = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 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: { onStatus: (status) => {
filetype: file.type, setUploadStatus(`Uploading ${index + 1} of ${initialQueue.length}: ${status}`);
title,
}, },
onError: (error) => { onTusUploadReady: (upload) => {
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; activeTusUploadRef.current = upload;
upload.start(); },
onPendingUpload: (pending) => {
pendingUploadRef.current = { ...pending, projectId };
},
isCancelled: () => cancelRequestedRef.current,
}); });
setUploadStatus('Saving video...'); if (cancelRequestedRef.current) break;
const createResponse = await fetch(`/api/projects/${projectId}/videos`, { pendingUploadRef.current = null;
method: 'POST', activeTusUploadRef.current = null;
headers: { 'Content-Type': 'application/json' }, successCount += 1;
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 { setQueue((prev) =>
error?: string; prev.map((entry) =>
} | null; entry.id === item.id ? { ...entry, status: 'done', progress: 100 } : entry
)
);
} catch (error) {
if (cancelRequestedRef.current) break;
if (!createResponse.ok) { pendingUploadRef.current = null;
throw new Error(createPayload?.error || 'Failed to create video'); activeTusUploadRef.current = null;
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}`);
}
} }
toast.success( resetUploadState();
`Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
);
setDialogOpen(false);
setDroppedFile(null);
cleanupUploadState();
router.push(`/projects/${projectId}`);
router.refresh();
} catch (error) {
console.error('Drag-drop upload failed:', error);
if (cancelRequestedRef.current) { if (cancelRequestedRef.current) {
setUploadStatus('');
setUploadProgress(0);
setIsUploading(false);
return; return;
} }
if (pendingCleanup) { if (successCount > 0) {
try { router.refresh();
if (pendingCleanup.type === 'bunny') {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: pendingCleanup.videoId,
uploadToken: pendingCleanup.uploadToken,
}),
});
} else {
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
}
} catch (cleanupError) {
console.error('Failed to cleanup pending upload:', cleanupError);
}
} }
setUploadStatus(''); if (successCount > 0 && failCount === 0) {
setUploadProgress(0); toast.success(
setIsUploading(false); successCount === 1
toast.error(error instanceof Error ? error.message : 'Failed to upload video'); ? `Video uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
: `${successCount} videos uploaded to ${projectName ?? projectsById.get(projectId) ?? 'project'}`
);
if (fixedProjectId) {
setDialogOpen(false);
setQueue([]);
}
} else if (successCount > 0 && failCount > 0) {
toast.warning(`${successCount} uploaded, ${failCount} failed`);
} else if (failCount > 0) {
toast.error('All uploads failed');
} }
}, },
[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>
+1
View File
@@ -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={
+485 -113
View File
@@ -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;
} }
setPendingImageFile(file); validFiles.push(file);
toast.success('Image attached. Click Upload Image to send.'); }
if (validFiles.length === 0) return;
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,31 +757,24 @@ 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);
const validationError = getAudioUploadValidationError(uploadSource);
if (validationError) { if (validationError) {
toast.error(validationError); toast.error(`${fileName}: ${validationError}`);
return; return false;
} }
setIsUploadingVoice(true);
try { try {
const formData = new FormData(); const formData = new FormData();
if (pendingAudioFile) { formData.append('audio', file, file instanceof File ? file.name : 'recording.webm');
formData.append('audio', pendingAudioFile);
} else {
formData.append('audio', audioBlob!, 'recording.webm');
}
formData.append('videoId', videoId); formData.append('videoId', videoId);
const guestUploadToken = await getGuestUploadToken('audio'); const guestUploadToken = await getGuestUploadToken('audio');
if (guestUploadToken) formData.append('uploadToken', guestUploadToken); if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
@@ -642,36 +787,127 @@ export const AssetsPane = memo(function AssetsPane({
const uploadedUrl = uploadPayload?.data?.url; const uploadedUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedUrl) { if (!uploadRes.ok || !uploadedUrl) {
toast.error( toast.error(
`${fileName}: ${
uploadPayload?.error || uploadPayload?.error ||
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) || (uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
'Failed to upload voice recording' 'Failed to upload voice recording'
}`
); );
return; return false;
} }
const fallbackName = pendingAudioFile const created = await createAsset({
? pendingAudioFile.name.replace(/\.[^/.]+$/, '')
: 'Voice Recording';
await createAsset({
provider: 'R2_AUDIO', provider: 'R2_AUDIO',
sourceUrl: uploadedUrl, sourceUrl: uploadedUrl,
displayName: voiceTitle.trim() || fallbackName, displayName:
displayName?.trim() || fileName.replace(/\.[^/.]+$/, '') || 'Voice Recording',
reservationId: uploadPayload?.data?.reservationId ?? null, 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(''); setVoiceTitle('');
setAudioBlob(null); setAudioBlob(null);
setAudioBlobUrl((prev) => { setAudioBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return null; 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]); return;
}
if (pendingAudioFiles.length === 0) return;
setIsUploadingVoice(true);
let successCount = 0;
let failCount = 0;
try {
for (const file of pendingAudioFiles) {
const displayName = voiceTitle.trim() || file.name.replace(/\.[^/.]+$/, '');
const ok = await uploadSingleAudioAsset(file, file.name, displayName);
if (ok) successCount += 1;
else failCount += 1;
}
if (successCount > 0) {
setVoiceTitle('');
setPendingAudioFiles([]);
if (voiceInputRef.current) voiceInputRef.current.value = '';
}
if (successCount > 0 && failCount === 0) {
toast.success(
successCount === 1 ? 'Audio uploaded' : `${successCount} audio files uploaded`
);
} else if (successCount > 0 && failCount > 0) {
toast.warning(`${successCount} uploaded, ${failCount} failed`);
}
} finally {
setIsUploadingVoice(false);
}
}, [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;
const imageFiles: File[] = [];
const videoFiles: File[] = [];
const audioFiles: File[] = [];
let unsupportedCount = 0;
for (const file of files) {
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
const imageError = await validateImageFile(file); imageFiles.push(file);
if (imageError) {
toast.error(imageError);
return;
}
// Stage the file so the user can optionally set a name before uploading
setUploadTab('image');
setPendingImageFile(file);
} else if (file.type.startsWith('video/')) { } else if (file.type.startsWith('video/')) {
// Videos upload immediately (large files, no staging) videoFiles.push(file);
setUploadTab('bunny');
await handleBunnyFileUpload(file);
} else if (file.type.startsWith('audio/')) { } else if (file.type.startsWith('audio/')) {
const audioError = getAudioUploadValidationError(file); audioFiles.push(file);
if (audioError) {
toast.error(audioError);
return;
}
// Stage the file so the user can optionally set a name before uploading
setUploadTab('voice');
setPendingAudioFile(file);
} else { } else {
toast.error('Unsupported file type. Drop an image, video, or audio file.'); unsupportedCount += 1;
toast.error(`${file.name}: Unsupported file type`);
}
}
if (imageFiles.length > 0) {
setUploadTab('image');
await stageImageFiles(imageFiles);
}
if (audioFiles.length > 0) {
setUploadTab('voice');
stageAudioFiles(audioFiles);
}
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 <Button
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-6 px-2" className="h-6 px-2"
onClick={() => { onClick={() => {
setPendingImageFile(null); setPendingImageFiles((prev) => prev.filter((_, i) => i !== index));
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={() => {
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 ${pendingImageFiles.length} Images`
: pendingImageFiles.length === 1
? 'Upload Image' ? 'Upload Image'
: 'Select 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,22 +1289,28 @@ 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="space-y-1">
{bunnyUploadLabel ? (
<p className="text-xs text-muted-foreground">{bunnyUploadLabel}</p>
) : null}
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden"> <div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
<div <div
className="bg-primary h-2 rounded-full" className="bg-primary h-2 rounded-full"
style={{ width: `${bunnyProgress}%` }} 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
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 <Button
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-6 px-2" className="h-6 px-2"
onClick={() => setPendingAudioFile(null)} onClick={() => {
setPendingAudioFiles((prev) => prev.filter((_, i) => i !== index));
}}
> >
Clear Remove
</Button> </Button>
</div> </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,6 +1470,7 @@ export const AssetsPane = memo(function AssetsPane({
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-2">
<Button <Button
variant="outline" variant="outline"
className="w-full" className="w-full"
@@ -1150,9 +1480,27 @@ export const AssetsPane = memo(function AssetsPane({
<Mic className="h-4 w-4 mr-2" /> <Mic className="h-4 w-4 mr-2" />
Start Recording Start Recording
</Button> </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;
}; };
+1 -1
View File
@@ -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;
+254
View File
@@ -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;
}
}
+145
View File
@@ -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 };
}
+1
View File
@@ -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
+2 -2
View File
@@ -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
+16
View File
@@ -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';
+1
View File
@@ -100,6 +100,7 @@ enum VideoAssetProvider {
YOUTUBE YOUTUBE
BUNNY BUNNY
R2_AUDIO R2_AUDIO
R2_VIDEO
} }
model DownloadEgressEvent { model DownloadEgressEvent {