feat: Implement direct video file uploads via Bunny.net and TUS protocol, adding a new API route and UI for file selection.

This commit is contained in:
Yusuf İpek
2026-02-22 09:55:08 +03:00
parent 5547346082
commit 0f24bcfe6c
12 changed files with 1054 additions and 185 deletions
@@ -43,6 +43,20 @@ interface Version {
_count: { comments: number };
}
interface PlayerAdapter {
playVideo: () => void;
pauseVideo: () => void;
seekTo: (time: number, allowSeekAhead?: boolean) => void;
mute: () => void;
unMute: () => void;
isMuted: () => boolean;
getCurrentTime: () => number;
getDuration: () => number;
getPlayerState: () => number;
setPlaybackRate?: (rate: number) => void;
destroy: () => void;
}
interface Comment {
id: string;
content: string | null;
@@ -96,8 +110,8 @@ export default function CompareVersionsPage() {
const [isDragging, setIsDragging] = useState(false);
const timelineRef = useRef<HTMLDivElement>(null);
// Map of versionId -> YT.Player
const playersRef = useRef<Map<string, YT.Player>>(new Map());
// Map of versionId -> YT.Player or Custom Adapter
const playersRef = useRef<Map<string, YT.Player | PlayerAdapter>>(new Map());
const rafRef = useRef<number | null>(null);
// Comments state per panel
@@ -219,7 +233,7 @@ export default function CompareVersionsPage() {
}, [isDragging]);
// Register/unregister players
const registerPlayer = useCallback((versionId: string, player: YT.Player) => {
const registerPlayer = useCallback((versionId: string, player: YT.Player | PlayerAdapter) => {
playersRef.current.set(versionId, player);
}, []);
@@ -319,7 +333,7 @@ export default function CompareVersionsPage() {
e.preventDefault();
players.forEach((p) => {
try {
if (p.isMuted()) { p.unMute(); } else { p.mute(); }
if (p.isMuted?.()) { p.unMute?.(); } else { p.mute?.(); }
} catch { /* */ }
});
break;
@@ -552,6 +566,13 @@ export default function CompareVersionsPage() {
onRegister={registerPlayer}
onUnregister={unregisterPlayer}
/>
) : version.providerId === 'bunny' ? (
<BunnyPanel
key={versionId}
version={version}
onRegister={registerPlayer}
onUnregister={unregisterPlayer}
/>
) : (
<iframe
src={version.originalUrl}
@@ -767,3 +788,97 @@ function YouTubePanel({
return <div ref={containerRef} className="w-full h-full pointer-events-none" />;
}
// Isolated Bunny Steam player component per panel filtering Player.js to YouTube wrapper interface
function BunnyPanel({
version,
onRegister,
onUnregister,
}: {
version: Version;
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
onUnregister: (versionId: string) => void;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
if (!iframeRef.current) return;
const playerjs = require('player.js');
const player = new playerjs.Player(iframeRef.current);
let cachedTime = 0;
let cachedDuration = 0;
let isPlaying = false;
let isMuted = false;
player.on('ready', () => {
player.getDuration((d: number) => { cachedDuration = d; });
const adapter = {
playVideo: () => player.play(),
pauseVideo: () => player.pause(),
seekTo: (time: number) => { cachedTime = time; player.setCurrentTime(time); },
mute: () => { isMuted = true; player.mute(); },
unMute: () => { isMuted = false; player.unmute(); },
isMuted: () => isMuted,
getCurrentTime: () => cachedTime,
getDuration: () => cachedDuration,
getPlayerState: () => isPlaying ? window.YT?.PlayerState?.PLAYING : window.YT?.PlayerState?.PAUSED,
setPlaybackRate: (rate: number) => {
try {
if (player && typeof player.setPlaybackRate === 'function') {
player.setPlaybackRate(rate);
}
} catch (e) {
console.error('Failed to set playback rate', e);
}
},
destroy: () => {
try {
player.off('ready');
player.off('timeupdate');
player.off('play');
player.off('pause');
player.off('ended');
} catch { }
}
};
onRegister(version.id, adapter);
});
player.on('timeupdate', (data: { seconds: number }) => { cachedTime = data.seconds; });
player.on('play', () => { isPlaying = true; });
player.on('pause', () => { isPlaying = false; });
player.on('ended', () => { isPlaying = false; });
return () => {
onUnregister(version.id);
try {
player.off('ready');
player.off('timeupdate');
player.off('play');
player.off('pause');
player.off('ended');
} catch { }
};
}, [version.id, onRegister, onUnregister]);
const src = version.originalUrl.replace('/play/', '/embed/');
const embedSrc = `${src}${src.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
return (
<div className="relative w-full h-full group">
<iframe
ref={iframeRef}
src={embedSrc}
width="100%"
height="100%"
className="w-full h-full pointer-events-none border-0"
style={{ pointerEvents: 'none' }}
allow="accelerometer; autoplay; encrypted-media; gyroscope;"
allowFullScreen
/>
</div>
)
}
@@ -1,16 +1,18 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2 } from 'lucide-react';
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2, UploadCloud, FileVideo } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
import * as tus from 'tus-js-client';
export default function NewVideoPage() {
const router = useRouter();
@@ -19,9 +21,18 @@ export default function NewVideoPage() {
const [isLoading, setIsLoading] = useState(false);
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
// URL Mode State
const [videoUrl, setVideoUrl] = useState('');
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
const [urlError, setUrlError] = useState('');
// Upload Mode State
const [uploadMode, setUploadMode] = useState<'url' | 'file'>('url');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState('');
const [submitError, setSubmitError] = useState('');
const [formData, setFormData] = useState({
title: '',
@@ -74,32 +85,137 @@ export default function NewVideoPage() {
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('video/')) {
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 uploadToBunny = async (file: File): Promise<{ videoId: string; libraryId: string; providerId: string; url: string }> => {
// 1. Initialize Bunny Stream upload (creates video & gets signature)
setUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: formData.title || file.name })
});
if (!initRes.ok) {
const data = await initRes.json();
throw new Error(data.error || 'Failed to initialize upload');
}
const { data: { videoId, libraryId, signature, expirationTime } } = await initRes.json();
// 2. Upload via TUS
return new Promise((resolve, reject) => {
setUploadStatus('Uploading video...');
const upload = new tus.Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: signature,
AuthorizationExpire: expirationTime.toString(),
VideoId: videoId,
LibraryId: libraryId,
},
metadata: {
filetype: file.type,
title: formData.title || file.name,
},
onError: (error) => {
reject(new Error('Upload failed: ' + error.message));
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
setUploadProgress(Number(percentage));
setUploadStatus(`Uploading... ${percentage}%`);
},
onSuccess: () => {
setUploadStatus('Processing video...');
resolve({
videoId,
libraryId,
providerId: 'bunny',
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`
});
},
});
upload.start();
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!videoSource) {
setUrlError('Please enter a valid video URL');
return;
}
setIsLoading(true);
setSubmitError('');
setUploadStatus('');
setUploadProgress(0);
try {
const thumbnailUrl = getThumbnailUrl(videoSource, 'large');
const title = formData.title.trim() || videoSource.metadata?.title || 'Untitled Video';
let finalTitle = formData.title.trim();
let 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 (!videoSource) {
setUrlError('Please enter a valid video URL');
setIsLoading(false);
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 (!selectedFile) {
setSubmitError('Please select a video file to upload');
setIsLoading(false);
return;
}
finalTitle = finalTitle || selectedFile.name;
// Handle TUS Upload
const bunnyData = await uploadToBunny(selectedFile);
finalVideoUrl = bunnyData.url;
finalProviderId = bunnyData.providerId;
finalVideoId = bunnyData.videoId;
// Bunny will generate thumbnails automatically after processing.
// We'll just provide the standard CDN thumbnail URL format as fallback.
finalThumbnailUrl = `https://vz-thumbnail.b-cdn.net/${bunnyData.videoId}/thumbnail.jpg`;
}
// Final POST to our database
const response = await fetch(`/api/projects/${projectId}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: formData.description.trim() || null,
videoUrl: videoSource.originalUrl,
providerId: videoSource.providerId,
videoId: videoSource.videoId,
thumbnailUrl,
duration: videoSource.metadata?.duration || null,
title: finalTitle,
description: finalDescription,
videoUrl: finalVideoUrl,
providerId: finalProviderId,
videoId: finalVideoId,
thumbnailUrl: finalThumbnailUrl,
duration: finalDuration,
}),
});
@@ -110,9 +226,9 @@ export default function NewVideoPage() {
}
router.push(`/projects/${projectId}`);
} catch (error) {
} catch (error: any) {
console.error('Failed to add video:', error);
setSubmitError('An unexpected error occurred');
setSubmitError(error.message || 'An unexpected error occurred');
} finally {
setIsLoading(false);
}
@@ -136,45 +252,82 @@ export default function NewVideoPage() {
<CardHeader>
<CardTitle>Add Video</CardTitle>
<CardDescription>
Paste a video link to add it to your project. Currently supports YouTube and Vimeo.
Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={uploadMode} onValueChange={(v) => setUploadMode(v as 'url' | 'file')} className="mb-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="url">Paste URL</TabsTrigger>
<TabsTrigger value="file">Direct Upload</TabsTrigger>
</TabsList>
</Tabs>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Video URL Input */}
<div className="space-y-2">
<Label htmlFor="url">Video URL</Label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="url"
placeholder="https://youtube.com/watch?v=..."
value={videoUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="pl-10"
required
disabled={isLoading}
/>
{uploadMode === 'url' ? (
<div className="space-y-2">
<Label htmlFor="url">Video URL</Label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="url"
placeholder="https://youtube.com/watch?v=..."
value={videoUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="pl-10"
required
disabled={isLoading}
/>
</div>
{urlError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{urlError}
</p>
)}
{videoSource && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected
{isFetchingMeta && ' — fetching metadata...'}
</p>
)}
</div>
) : (
<div className="space-y-2">
<Label htmlFor="file">Video File</Label>
<div className="flex items-center justify-center w-full">
<label htmlFor="file" className={`flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${selectedFile ? 'border-primary' : 'border-border'}`}>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
{selectedFile ? (
<>
<FileVideo className="w-10 h-10 mb-3 text-primary" />
<p className="mb-2 text-sm text-foreground font-medium">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</>
) : (
<>
<UploadCloud className="w-10 h-10 mb-3 text-muted-foreground" />
<p className="mb-2 text-sm text-muted-foreground">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
</>
)}
</div>
<input id="file" type="file" accept="video/*" className="hidden" onChange={handleFileChange} disabled={isLoading} />
</label>
</div>
</div>
)}
{urlError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{urlError}
</p>
)}
{videoSource && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
{videoSource.providerId.charAt(0).toUpperCase() + videoSource.providerId.slice(1)} video detected
{isFetchingMeta && ' — fetching metadata...'}
</p>
)}
</div>
{/* Video Preview */}
{thumbnailUrl && videoSource && (
{/* Video Preview (Only for URL mode) */}
{uploadMode === 'url' && thumbnailUrl && videoSource && (
<div className="space-y-2">
<Label>Preview</Label>
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted">
@@ -224,12 +377,23 @@ export default function NewVideoPage() {
</p>
)}
{uploadStatus && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{uploadStatus}</p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="w-full bg-secondary rounded-full h-2">
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={isLoading || !videoSource}>
<Button type="submit" disabled={isLoading || (uploadMode === 'url' && !videoSource) || (uploadMode === 'file' && !selectedFile)}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Add Video
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isLoading}>
Cancel
</Button>
</div>
@@ -0,0 +1,100 @@
import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import crypto from 'crypto';
type RouteParams = { params: Promise<{ projectId: string }> };
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// Check project access (must be owner, project admin, or workspace admin)
const project = await db.project.findUnique({
where: { id: projectId },
include: {
members: { where: { userId: session.user.id } },
workspace: {
include: {
members: { where: { userId: session.user.id } },
},
},
},
});
if (!project) {
return apiErrors.notFound('Project');
}
const isOwner = project.ownerId === session.user.id;
const membership = project.members[0];
const workspaceMembership = project.workspace.members[0];
const canEdit = isOwner ||
membership?.role === ProjectMemberRole.ADMIN ||
workspaceMembership?.role === WorkspaceMemberRole.ADMIN;
if (!canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { title } = body;
if (!title) {
return apiErrors.badRequest('Title is required');
}
const apiKey = process.env.BUNNY_STREAM_API_KEY;
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
if (!apiKey || !libraryId) {
return apiErrors.internalError('Bunny Stream is not configured correctly');
}
// 1. Create video object in Bunny Stream
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
method: 'POST',
headers: {
'AccessKey': apiKey,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ title })
});
if (!bunnyRes.ok) {
console.error('Failed to create Bunny Stream video', await bunnyRes.text());
return apiErrors.internalError('Failed to initialize video upload with provider');
}
const bunnyVideo = await bunnyRes.json();
const videoId = bunnyVideo.guid;
// 2. Generate TUS upload signature
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity
// SHA256(library_id + api_key + expiration_time + video_id)
const hash = crypto.createHash('sha256');
hash.update(libraryId + apiKey + expirationTime + videoId);
const signature = hash.digest('hex');
const response = successResponse({
videoId,
libraryId,
signature,
expirationTime
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error initializing Bunny upload:', error);
return apiErrors.internalError('Failed to initialize upload');
}
}