feat(video-assets): add full video asset system (uploads, downloads, @mentions, and cleanup/billing integration)

This commit is contained in:
Yusuf İpek
2026-02-25 18:34:03 +03:00
parent 6eea327083
commit 9ce033d306
32 changed files with 3524 additions and 231 deletions
+31
View File
@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { auth, checkProjectAccess } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -157,6 +158,36 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('Only the project owner can delete it');
}
const [projectVersionRefs, projectAssetRefs] = await Promise.all([
db.videoVersion.findMany({
where: {
video: { projectId },
},
select: {
providerId: true,
videoId: true,
},
}),
db.videoAsset.findMany({
where: {
video: { projectId },
provider: 'BUNNY',
providerVideoId: { not: null },
},
select: {
providerVideoId: true,
},
}),
]);
await cleanupBunnyStreamVideos([
...projectVersionRefs,
...projectAssetRefs.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
]);
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupProjectMediaFiles(projectId);
@@ -126,6 +126,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
canManageTags: access.canEdit,
canResolveComments: access.canEdit,
canRequestApproval: access.canEdit,
canShareVideo: access.canEdit,
canUploadAssets: access.hasAccess,
canDownloadAssets: !!session?.user?.id && access.hasAccess,
});
return withCacheControl(response, 'private, no-cache');
@@ -212,6 +215,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
videoId: true,
},
},
assets: {
select: {
provider: true,
providerVideoId: true,
},
},
project: true,
},
});
@@ -226,7 +235,15 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Delete Bunny provider videos first to avoid orphaned assets.
await cleanupBunnyStreamVideos(video.versions);
await cleanupBunnyStreamVideos([
...video.versions,
...video.assets
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
]);
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupVideoMediaFiles(videoId);
@@ -0,0 +1,176 @@
import { NextRequest } from 'next/server';
import { VideoAssetProvider } from '@prisma/client';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
import { fetchWithTimeout, resolveBunnyDownloadSource } from '@/lib/bunny-download';
import { db } from '@/lib/db';
import {
extractImageFileNameFromProxyUrl,
getVideoAssetAccessContext,
} from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
type BunnySourcePreference = 'auto' | 'original' | 'compressed';
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
};
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
function sanitizeFileName(value: string): string {
const sanitized = value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
.replace(/\s+/g, ' ')
.trim();
return sanitized.length > 0 ? sanitized : 'asset';
}
function toAsciiFileName(value: string): string {
const normalized = value
.normalize('NFKD')
.replace(/[^\x20-\x7E]/g, '')
.replace(/\s+/g, ' ')
.trim();
return normalized.length > 0 ? normalized : 'asset';
}
function buildContentDisposition(fileNameWithExt: string): string {
const asciiFallback = toAsciiFileName(fileNameWithExt).replace(/["\\]/g, '_');
const encoded = encodeURIComponent(fileNameWithExt);
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
function imageContentTypeFromFileName(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
}
// GET /api/videos/[videoId]/assets/[assetId]/download
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-download');
if (limited) return limited;
const { videoId, assetId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
if (!context.viewerUserId || !context.canDownloadAssets) {
return apiErrors.forbidden('Asset downloads require an authenticated account');
}
const asset = await db.videoAsset.findFirst({
where: { id: assetId, videoId },
select: {
id: true,
provider: true,
displayName: true,
sourceUrl: true,
providerVideoId: true,
},
});
if (!asset) return apiErrors.notFound('Asset');
if (asset.provider === VideoAssetProvider.YOUTUBE) {
return apiErrors.badRequest('YouTube assets cannot be downloaded');
}
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl);
if (!fileName) return apiErrors.badRequest('Invalid image asset URL');
const key = `images/${fileName}`;
const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png';
const downloadName = `${sanitizeFileName(asset.displayName)}${extension}`;
const contentDisposition = buildContentDisposition(downloadName);
return proxyR2MediaObject({
request,
key,
fallbackContentType: imageContentTypeFromFileName(fileName),
cacheControl: 'private, no-store',
extraHeaders: {
'Content-Disposition': contentDisposition,
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
},
internalErrorMessage: 'Failed to retrieve image',
});
}
const sourceParam = request.nextUrl.searchParams.get('source');
const rawQuality = request.nextUrl.searchParams.get('quality');
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
const requestedQuality = Number(rawQuality);
const sourcePreference: BunnySourcePreference =
sourceParam === null
? 'auto'
: sourceParam === 'original' || sourceParam === 'compressed'
? sourceParam
: 'auto';
if (sourceParam !== null && sourceParam !== 'original' && sourceParam !== 'compressed') {
return apiErrors.badRequest('Invalid source. Allowed values: original, compressed');
}
if (
rawQuality !== null
&& (!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality))
) {
return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240');
}
if (rawQuality !== null && sourcePreference === 'original') {
return apiErrors.badRequest('Quality cannot be used when source=original');
}
if (!asset.providerVideoId) {
return apiErrors.badRequest('Missing Bunny asset video id');
}
const source = await resolveBunnyDownloadSource(
asset.providerVideoId,
Number.isFinite(requestedQuality) ? requestedQuality : null,
sourcePreference
);
if (!source) {
if (sourcePreference === 'original') {
return apiErrors.notFound('Original file');
}
return apiErrors.notFound('Download file');
}
if (isPrepareOnly) {
const response = successResponse({
quality: source.quality,
sourceType: source.sourceType,
});
return withCacheControl(response, 'private, no-store');
}
const upstream = await fetchWithTimeout(source.url, { cache: 'no-store' });
if (!upstream.ok || !upstream.body) {
return apiErrors.notFound('Download file');
}
const extension = source.sourceType === 'compressed' ? '.mp4' : '';
const filename = `${sanitizeFileName(asset.displayName)}${extension}`;
const response = new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream',
'Content-Disposition': buildContentDisposition(filename),
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
const contentLength = upstream.headers.get('content-length');
if (contentLength) response.headers.set('Content-Length', contentLength);
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error downloading asset:', error);
return apiErrors.internalError('Failed to download asset');
}
}
@@ -0,0 +1,89 @@
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { VideoAssetProvider } from '@prisma/client';
import { NextRequest } from 'next/server';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { db } from '@/lib/db';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import {
canDeleteAssetForViewer,
extractImageKeyFromProxyUrl,
getVideoAssetAccessContext,
} from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
// DELETE /api/videos/[videoId]/assets/[assetId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-delete');
if (limited) return limited;
const { videoId, assetId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'COMMENT');
if (!context) return apiErrors.notFound('Video');
if (!context.canUploadAssets) return apiErrors.forbidden('Access denied');
const asset = await db.videoAsset.findFirst({
where: { id: assetId, videoId },
select: {
id: true,
provider: true,
sourceUrl: true,
providerVideoId: true,
uploadedByUserId: true,
uploadedByGuestIdentityId: true,
},
});
if (!asset) return apiErrors.notFound('Asset');
if (!canDeleteAssetForViewer(asset, context)) {
return apiErrors.forbidden('You can only delete assets you uploaded');
}
let shouldDeleteImageObject = false;
await db.$transaction(async (tx) => {
await tx.videoAsset.delete({ where: { id: asset.id } });
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
const [assetReferenceCount, commentReferenceCount] = await Promise.all([
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
tx.comment.count({ where: { imageUrl: asset.sourceUrl } }),
]);
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
}
});
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
const imageKey = extractImageKeyFromProxyUrl(asset.sourceUrl);
if (imageKey) {
try {
await r2Client.send(new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: imageKey,
}));
} catch (error) {
console.error(`Failed to delete R2 image asset ${imageKey}:`, error);
}
}
}
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
try {
await cleanupBunnyStreamVideos([{
providerId: 'bunny',
videoId: asset.providerVideoId,
}]);
} catch (error) {
console.error(`Failed to cleanup Bunny asset ${asset.providerVideoId}:`, error);
}
}
const response = successResponse({ message: 'Asset deleted' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error deleting video asset:', error);
return apiErrors.internalError('Failed to delete asset');
}
}
@@ -0,0 +1,158 @@
import crypto from 'crypto';
import { NextRequest } from 'next/server';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { createBunnyUploadToken, verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
import {
createGuestUploadToken,
deriveGuestUploadContext,
enforceGuestUploadQuota,
verifyGuestUploadToken,
} from '@/lib/guest-upload-token';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { getVideoAssetAccessContext, SAFE_BUNNY_VIDEO_ID } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string }> };
// POST /api/videos/[videoId]/assets/bunny-init
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-bunny-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');
const body = await request.json().catch(() => null);
const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) return apiErrors.badRequest('Title is required');
const shareSession = getShareSessionFromRequest(request, context.video.id);
if (!context.viewerUserId) {
const quotaError = await enforceGuestUploadQuota(request, context.video.id, 'bunny', shareSession?.token ?? null);
if (quotaError) return quotaError;
}
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');
}
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 asset', await bunnyRes.text());
return apiErrors.internalError('Failed to initialize Bunny upload');
}
const bunnyVideo = await bunnyRes.json();
const bunnyVideoId = typeof bunnyVideo?.guid === 'string' ? bunnyVideo.guid.trim() : '';
if (!bunnyVideoId || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
return apiErrors.internalError('Upload provider did not return a valid video identifier');
}
const expirationTime = Math.floor(Date.now() / 1000) + 3600;
const hash = crypto.createHash('sha256');
hash.update(libraryId + apiKey + expirationTime + bunnyVideoId);
const signature = hash.digest('hex');
let uploadToken = '';
if (context.viewerUserId) {
uploadToken = createBunnyUploadToken({
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: bunnyVideoId,
}, 3600);
} else {
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
if (!expectedContext) {
return apiErrors.forbidden('Missing trusted client IP header');
}
uploadToken = createGuestUploadToken({
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
}, 3600);
}
const response = successResponse({
videoId: bunnyVideoId,
libraryId,
signature,
expirationTime,
uploadToken,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error initializing Bunny asset upload:', error);
return apiErrors.internalError('Failed to initialize asset upload');
}
}
// DELETE /api/videos/[videoId]/assets/bunny-init
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-bunny-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');
const body = await request.json().catch(() => null);
const bunnyVideoId = typeof body?.videoId === 'string' ? body.videoId.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
if (!bunnyVideoId || !uploadToken || !SAFE_BUNNY_VIDEO_ID.test(bunnyVideoId)) {
return apiErrors.badRequest('videoId and uploadToken are required');
}
if (context.viewerUserId) {
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: bunnyVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
} else {
const shareSession = getShareSessionFromRequest(request, context.video.id);
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
if (!expectedContext) {
return apiErrors.forbidden('Missing trusted client IP header');
}
const isValidUploadToken = verifyGuestUploadToken(uploadToken, {
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
await cleanupBunnyStreamVideos([{ providerId: 'bunny', videoId: bunnyVideoId }]);
const response = successResponse({ message: 'Pending upload cleaned up' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error cleaning up Bunny asset upload:', error);
return apiErrors.internalError('Failed to cleanup pending upload');
}
}
+385
View File
@@ -0,0 +1,385 @@
import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { VideoAssetProvider } from '@prisma/client';
import { NextRequest } from 'next/server';
import { parseVideoUrl, getThumbnailUrl } from '@/lib/video-providers';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
import { db } from '@/lib/db';
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-upload-token';
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
import {
SAFE_BUNNY_VIDEO_ID,
SAFE_IMAGE_PROXY_PATH,
canDeleteAssetForViewer,
extractImageFileNameFromProxyUrl,
extractImageKeyFromProxyUrl,
getVideoAssetAccessContext,
sanitizeAssetDisplayName,
} from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string }> };
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
const ASSET_LIST_DEFAULT_LIMIT = 40;
const ASSET_LIST_MAX_LIMIT = 100;
const BUNNY_ALLOWED_THUMBNAIL_HOSTS = new Set([
'iframe.mediadelivery.net',
'video.bunnycdn.com',
'vz-965f4f4a-fc1.b-cdn.net',
]);
const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
type AssetWithViewerFields = {
id: string;
videoId: string;
kind: 'IMAGE' | 'VIDEO';
provider: VideoAssetProvider;
displayName: string;
sourceUrl: string;
providerVideoId: string | null;
thumbnailUrl: string | null;
uploadedByUserId?: string | null;
uploadedByGuestName: string | null;
uploadedByGuestIdentityId?: string | null;
createdAt: Date;
updatedAt: Date;
uploadedByUser: {
id: string;
name: string | null;
image: string | null;
} | null;
};
type YouTubeTitleCacheRecord = {
title: string | null;
expiresAt: number;
};
const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>();
function isAllowedBunnyMediaUrl(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return false;
return BUNNY_ALLOWED_THUMBNAIL_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}
function shapeAssetForViewer(asset: AssetWithViewerFields, canExposeSource: boolean, canDelete: boolean) {
return {
id: asset.id,
videoId: asset.videoId,
kind: asset.kind,
provider: asset.provider,
displayName: asset.displayName,
sourceUrl: canExposeSource ? asset.sourceUrl : null,
providerVideoId: canExposeSource ? asset.providerVideoId : null,
thumbnailUrl: canExposeSource ? asset.thumbnailUrl : null,
uploadedByUserId: asset.uploadedByUserId ?? null,
uploadedByGuestName: asset.uploadedByGuestName,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
uploadedByUser: asset.uploadedByUser,
canDelete,
};
}
function parsePaginationParam(value: string | null, fallback: number): number {
const parsed = Number.parseInt(value ?? '', 10);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
return parsed;
}
async function fetchYouTubeTitleFromProvider(videoId: string): Promise<string | null> {
const url = `https://www.youtube.com/oembed?url=${encodeURIComponent(`https://www.youtube.com/watch?v=${videoId}`)}&format=json`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(url, {
method: 'GET',
signal: controller.signal,
cache: 'no-store',
});
if (!response.ok) return null;
const payload = (await response.json().catch(() => null)) as { title?: string } | null;
if (!payload?.title || typeof payload.title !== 'string') return null;
return payload.title.trim() || null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
async function fetchYouTubeTitle(videoId: string): Promise<string | null> {
const now = Date.now();
const cached = youtubeTitleCache.get(videoId);
if (cached && cached.expiresAt > now) {
return cached.title;
}
const title = await fetchYouTubeTitleFromProvider(videoId);
youtubeTitleCache.set(videoId, {
title,
expiresAt: now + YOUTUBE_TITLE_CACHE_TTL_MS,
});
return title;
}
async function isFreshImageAttachment(url: string): Promise<boolean> {
const key = extractImageKeyFromProxyUrl(url);
if (!key) return false;
try {
const head = await r2Client.send(new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
}));
if (!head.LastModified) return false;
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
} catch {
return false;
}
}
// GET /api/videos/[videoId]/assets
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-list');
if (limited) return limited;
const { videoId } = await params;
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
const requestedLimit = parsePaginationParam(request.nextUrl.searchParams.get('limit'), ASSET_LIST_DEFAULT_LIMIT);
const requestedOffset = parsePaginationParam(request.nextUrl.searchParams.get('offset'), 0);
const limit = Math.min(ASSET_LIST_MAX_LIMIT, Math.max(1, requestedLimit));
const offset = requestedOffset;
const includeDeleteMetadata = context.canUploadAssets;
const assets = await db.videoAsset.findMany({
where: { videoId },
skip: offset,
take: limit + 1,
orderBy: { createdAt: 'desc' },
select: {
id: true,
videoId: true,
kind: true,
provider: true,
displayName: true,
sourceUrl: true,
providerVideoId: true,
thumbnailUrl: true,
uploadedByUserId: includeDeleteMetadata,
uploadedByGuestName: true,
uploadedByGuestIdentityId: includeDeleteMetadata,
createdAt: true,
updatedAt: true,
uploadedByUser: {
select: { id: true, name: true, image: true },
},
},
});
const hasMore = assets.length > limit;
const pagedAssets = hasMore ? assets.slice(0, limit) : assets;
const response = successResponse({
assets: pagedAssets.map((asset) => shapeAssetForViewer(
asset,
context.canDownloadAssets,
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
)),
pagination: {
limit,
offset,
hasMore,
nextOffset: hasMore ? offset + limit : null,
},
canUploadAssets: context.canUploadAssets,
canDownloadAssets: context.canDownloadAssets,
});
return withCacheControl(response, 'private, no-cache');
} catch (error) {
console.error('Error fetching video assets:', error);
return apiErrors.internalError('Failed to fetch assets');
}
}
// POST /api/videos/[videoId]/assets
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'asset-create');
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');
const body = await request.json().catch(() => null);
const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : '';
if (provider !== VideoAssetProvider.R2_IMAGE && provider !== VideoAssetProvider.YOUTUBE && provider !== VideoAssetProvider.BUNNY) {
return apiErrors.badRequest('Invalid provider');
}
const isGuest = !context.viewerUserId;
const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null;
const requestedDisplayName = typeof body?.displayName === 'string' ? body.displayName : null;
let displayName = '';
let sourceUrl = '';
let providerVideoId: string | null = null;
let thumbnailUrl: string | null = null;
let kind: 'IMAGE' | 'VIDEO' = 'IMAGE';
if (provider === VideoAssetProvider.R2_IMAGE) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
if (!SAFE_IMAGE_PROXY_PATH.test(sourceUrl)) {
return apiErrors.badRequest('Image URL must reference an uploaded image file');
}
if (!(await isFreshImageAttachment(sourceUrl))) {
return apiErrors.badRequest('Image upload expired. Please upload again.');
}
const fileName = extractImageFileNameFromProxyUrl(sourceUrl);
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Image');
thumbnailUrl = sourceUrl;
kind = 'IMAGE';
}
if (provider === VideoAssetProvider.YOUTUBE) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
const parsedSource = parseVideoUrl(sourceUrl);
if (!parsedSource || parsedSource.providerId !== 'youtube') {
return apiErrors.badRequest('Only YouTube URLs are allowed for this provider');
}
const sourceUrlError = validateUrl(parsedSource.originalUrl, 'YouTube URL');
if (sourceUrlError) return apiErrors.badRequest(sourceUrlError);
providerVideoId = parsedSource.videoId;
const youtubeTitle = await fetchYouTubeTitle(providerVideoId);
displayName = sanitizeAssetDisplayName(requestedDisplayName, youtubeTitle || `YouTube ${providerVideoId}`);
sourceUrl = parsedSource.originalUrl;
thumbnailUrl = getThumbnailUrl(parsedSource, 'large');
kind = 'VIDEO';
}
if (provider === VideoAssetProvider.BUNNY) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
providerVideoId = typeof body?.providerVideoId === 'string' ? body.providerVideoId.trim() : '';
const uploadToken = typeof body?.uploadToken === 'string' ? body.uploadToken.trim() : '';
thumbnailUrl = typeof body?.thumbnailUrl === 'string' ? body.thumbnailUrl.trim() : null;
if (!providerVideoId || !SAFE_BUNNY_VIDEO_ID.test(providerVideoId)) {
return apiErrors.badRequest('Invalid Bunny video id');
}
const sourceUrlError = validateUrl(sourceUrl, 'Bunny source URL');
if (sourceUrlError) return apiErrors.badRequest(sourceUrlError);
const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Bunny thumbnail URL');
if (thumbnailUrlError) return apiErrors.badRequest(thumbnailUrlError);
if (thumbnailUrl && !isAllowedBunnyMediaUrl(thumbnailUrl)) {
return apiErrors.badRequest('Bunny thumbnail URL must use an approved Bunny host');
}
if (!isAllowedBunnyMediaUrl(sourceUrl)) {
return apiErrors.badRequest('Bunny source URL must use an approved Bunny host');
}
if (!uploadToken) {
return apiErrors.badRequest('uploadToken is required');
}
if (context.viewerUserId) {
const isValidUploadToken = verifyBunnyUploadToken(uploadToken, {
userId: context.viewerUserId,
projectId: context.video.projectId,
videoId: providerVideoId,
});
if (!isValidUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
} else {
const shareSession = getShareSessionFromRequest(request, context.video.id);
const expectedContext = deriveGuestUploadContext(request, shareSession?.token ?? null);
if (!expectedContext) {
return apiErrors.forbidden('Missing trusted client IP header');
}
const isValidGuestUploadToken = verifyGuestUploadToken(uploadToken, {
projectId: context.video.projectId,
videoId: context.video.id,
intent: 'bunny',
context: expectedContext,
});
if (!isValidGuestUploadToken) {
return apiErrors.forbidden('Invalid Bunny upload token');
}
}
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
if (!thumbnailUrl) {
thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${providerVideoId}/thumbnail.jpg`;
}
kind = 'VIDEO';
}
const created = await db.videoAsset.create({
data: {
videoId: context.video.id,
kind,
provider,
displayName,
sourceUrl,
providerVideoId,
thumbnailUrl,
uploadedByUserId: context.viewerUserId,
uploadedByGuestIdentityId: context.viewerUserId ? null : guestIdentity?.identityId ?? null,
uploadedByGuestName: context.viewerUserId
? null
: sanitizeAssetDisplayName(typeof body?.guestName === 'string' ? body.guestName : null, 'Guest'),
billedUserId: context.video.project.workspace.ownerId,
},
select: {
id: true,
videoId: true,
kind: true,
provider: true,
displayName: true,
sourceUrl: true,
providerVideoId: true,
thumbnailUrl: true,
uploadedByGuestName: true,
createdAt: true,
updatedAt: true,
uploadedByUser: {
select: { id: true, name: true, image: true },
},
},
});
const response = successResponse(shapeAssetForViewer(
created,
context.canDownloadAssets,
true
), 201);
if (isGuest && guestIdentity?.shouldSetCookie) {
setGuestIdentityCookie(response, guestIdentity.identityId);
}
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error creating video asset:', error);
return apiErrors.internalError('Failed to create asset');
}
}
+5
View File
@@ -182,6 +182,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const canCommentWithShareLink = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canDownloadWithMembership = access.hasAccess;
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
const canUploadAssets = canCommentWithMembership || canCommentWithShareLink;
const canDownloadAssets = !!session?.user?.id && (access.hasAccess || shareAccess.hasAccess);
const response = successResponse({
...videoData,
versions,
@@ -198,6 +200,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
canDownload: canDownloadWithMembership || canDownloadWithShareLink,
canManageTags: access.canEdit,
canResolveComments: access.canEdit,
canShareVideo: access.canEdit,
canUploadAssets,
canDownloadAssets,
});
return withCacheControl(response, 'private, no-cache');
+34 -12
View File
@@ -166,20 +166,42 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
}
// Delete Bunny provider videos first to avoid orphaned external assets.
const workspaceVersionRefs = await db.videoVersion.findMany({
where: {
video: {
project: {
workspaceId,
const [workspaceVersionRefs, workspaceAssetRefs] = await Promise.all([
db.videoVersion.findMany({
where: {
video: {
project: {
workspaceId,
},
},
},
},
select: {
providerId: true,
videoId: true,
},
});
await cleanupBunnyStreamVideos(workspaceVersionRefs);
select: {
providerId: true,
videoId: true,
},
}),
db.videoAsset.findMany({
where: {
provider: 'BUNNY',
providerVideoId: { not: null },
video: {
project: {
workspaceId,
},
},
},
select: {
providerVideoId: true,
},
}),
]);
await cleanupBunnyStreamVideos([
...workspaceVersionRefs,
...workspaceAssetRefs.map((asset) => ({
providerId: 'bunny',
videoId: asset.providerVideoId as string,
})),
]);
// Clean up voice files from R2 before cascade delete removes comment rows
await cleanupWorkspaceMediaFiles(workspaceId);
+60
View File
@@ -23,6 +23,7 @@ import { useDownloadActions } from '@/components/video-page/hooks/use-download-a
import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync';
import { CommentComposer } from '@/components/video-page/comment-composer';
import { CommentsPane } from '@/components/video-page/comments-pane';
import { AssetsPane } from '@/components/video-page/assets-pane';
import { ApprovalRequestDialog } from '@/components/video-page/approval-request-dialog';
import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel';
import type {
@@ -34,6 +35,7 @@ import type {
VideoPageHeaderActions,
} from '@/components/video-page/types';
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
function formatTime(seconds: number): string {
const totalSeconds = Math.floor(seconds);
@@ -92,6 +94,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
toggleVoiceSpeed,
} = useCommentMedia();
const [showResolved, setShowResolved] = useState(false);
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
const [highlightedAssetId, setHighlightedAssetId] = useState<string | null>(null);
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
@@ -139,6 +143,29 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const isGuest = video ? !video.isAuthenticated : false;
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
const normalizedGuestName = guestName.trim();
const canUploadAssets = !!video?.canUploadAssets;
const canDownloadAssets = !!video?.canDownloadAssets;
const {
assets,
isLoadingAssets,
isCreatingAsset,
activeDeleteAssetId,
activeDownloadAssetId,
hasMoreAssets,
isLoadingMoreAssets,
loadMoreAssets,
createAsset,
deleteAsset,
downloadAsset,
getGuestUploadToken,
} = useVideoAssets({
videoId,
isAuthenticated: !!video?.isAuthenticated,
canUploadAssets,
canDownloadAssets,
guestName: normalizedGuestName,
});
const {
showVersionDialog,
@@ -181,6 +208,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setShowResolved(prev => !prev);
}, []);
const handleAssetMentionClick = useCallback((assetId: string) => {
setActiveSidePane('assets');
setHighlightedAssetId(assetId);
}, []);
const { isExportingCsv, isExportingPdf, exportComments } = useCommentExport({
activeVersionId,
showResolved,
@@ -191,6 +223,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const currentUserName = video?.currentUserName || null;
const canResolveComments = !!video?.canResolveComments;
const canRequestApproval = !!video?.canRequestApproval;
const canShareVideo = !!video?.canShareVideo;
const {
requests: approvalRequests,
@@ -655,6 +688,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
onCreateVersion={headerActions.onCreateVersion}
onOpenCompare={headerActions.onOpenCompare}
canRequestApproval={canRequestApproval}
canShareVideo={canShareVideo}
hasPendingApprovalRequest={!!activePendingRequest}
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
onOpenApprovalsPanel={handleOpenApprovalsPanel}
@@ -779,6 +813,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
isSubmittingReply={isSubmittingReply}
isUploadingReplyAudio={isUploadingReplyAudio}
isUploadingReplyImage={isUploadingReplyImage}
assets={assets}
onAssetMentionClick={handleAssetMentionClick}
activePane={activeSidePane}
setActivePane={setActiveSidePane}
assetsPane={(
<AssetsPane
videoId={videoId}
assets={assets}
isLoadingAssets={isLoadingAssets}
isCreatingAsset={isCreatingAsset}
activeDeleteAssetId={activeDeleteAssetId}
activeDownloadAssetId={activeDownloadAssetId}
canUploadAssets={canUploadAssets}
canDownloadAssets={canDownloadAssets}
getGuestUploadToken={getGuestUploadToken}
createAsset={createAsset}
deleteAsset={deleteAsset}
downloadAsset={downloadAsset}
hasMoreAssets={hasMoreAssets}
isLoadingMoreAssets={isLoadingMoreAssets}
loadMoreAssets={loadMoreAssets}
highlightedAssetId={highlightedAssetId}
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
/>
)}
composer={(
<CommentComposer
isRecording={isRecording}
@@ -816,6 +875,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
canManageTags={!!video.canManageTags}
projectId={projectId}
pauseVideoForAnnotation={composerActions.onPauseVideoForAnnotation}
assets={assets}
/>
)}
/>
@@ -0,0 +1,180 @@
'use client';
import { memo, type ReactNode } from 'react';
import { Download, ExternalLink, Image as ImageIcon, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { VideoAsset } from '@/components/video-page/types';
interface AssetListSectionProps {
assets: VideoAsset[];
isLoadingAssets: boolean;
focusedAssetId: string | null;
bunnyProcessingByAssetId: Record<string, boolean>;
activeDownloadAssetId: string | null;
activeDeleteAssetId: string | null;
canDownloadAssets: boolean;
hasMoreAssets: boolean;
isLoadingMoreAssets: boolean;
onViewAsset: (asset: VideoAsset) => void;
onDownloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => void;
onDeleteAsset: (assetId: string) => void;
onLoadMoreAssets: () => void;
renderAssetPreview: (asset: VideoAsset) => ReactNode;
}
export const AssetListSection = memo(function AssetListSection({
assets,
isLoadingAssets,
focusedAssetId,
bunnyProcessingByAssetId,
activeDownloadAssetId,
activeDeleteAssetId,
canDownloadAssets,
hasMoreAssets,
isLoadingMoreAssets,
onViewAsset,
onDownloadAsset,
onDeleteAsset,
onLoadMoreAssets,
renderAssetPreview,
}: AssetListSectionProps) {
if (isLoadingAssets) {
return (
<div className="text-sm text-muted-foreground flex items-center gap-2 py-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading assets...
</div>
);
}
if (assets.length === 0) {
return (
<div className="text-sm text-muted-foreground py-4 text-center border rounded-lg">
No assets uploaded yet.
</div>
);
}
return (
<div className="space-y-2">
{assets.map((asset) => (
<div
key={asset.id}
id={`asset-card-${asset.id}`}
className={cn(
'rounded-lg border p-2 flex gap-3 transition-colors',
focusedAssetId === asset.id && 'ring-2 ring-primary border-primary/60 bg-primary/5'
)}
>
<button className="shrink-0" onClick={() => onViewAsset(asset)}>
{renderAssetPreview(asset)}
</button>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium truncate">{asset.displayName}</p>
<div className="flex items-center gap-1 shrink-0">
{asset.provider === 'BUNNY' && bunnyProcessingByAssetId[asset.id] ? (
<Badge variant="secondary" className="text-[10px] gap-1">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
Processing
</Badge>
) : null}
</div>
</div>
<p className="text-xs text-muted-foreground">
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {new Date(asset.createdAt).toLocaleDateString()}
</p>
<div className="pt-1 flex items-center gap-1">
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="View asset"
aria-label="View asset"
disabled={asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id]}
onClick={() => onViewAsset(asset)}
>
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <ExternalLink className="h-3 w-3" />}
</Button>
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
asset.provider === 'BUNNY' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
onClick={() => onDownloadAsset(asset)}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
)
)}
{asset.canDelete && (
<Button
size="icon"
variant="destructive"
className="h-7 w-7"
title="Delete asset"
aria-label="Delete asset"
disabled={activeDeleteAssetId === asset.id}
onClick={() => onDeleteAsset(asset.id)}
>
{activeDeleteAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</Button>
)}
</div>
</div>
</div>
))}
{hasMoreAssets ? (
<Button
variant="outline"
className="w-full"
disabled={isLoadingMoreAssets}
onClick={onLoadMoreAssets}
>
{isLoadingMoreAssets ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
{isLoadingMoreAssets ? 'Loading more...' : 'Load more'}
</Button>
) : null}
</div>
);
});
+742
View File
@@ -0,0 +1,742 @@
'use client';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client';
import { toast } from 'sonner';
import { Download, FileVideo, Image as ImageIcon, Loader2, UploadCloud, X, Youtube } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ImagePreviewDialog } from '@/components/video-page/image-preview-dialog';
import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/video-page/bunny-preview-player';
import { AssetListSection } from '@/components/video-page/asset-list-section';
import type { VideoAsset } from '@/components/video-page/types';
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
interface AssetsPaneProps {
videoId: string;
assets: VideoAsset[];
isLoadingAssets: boolean;
isCreatingAsset: boolean;
activeDeleteAssetId: string | null;
activeDownloadAssetId: string | null;
canUploadAssets: boolean;
canDownloadAssets: boolean;
getGuestUploadToken: (intent: 'image') => Promise<string | null>;
createAsset: (payload: {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
displayName?: string;
sourceUrl: string;
providerVideoId?: string;
thumbnailUrl?: string;
uploadToken?: string;
}) => Promise<VideoAsset | null>;
deleteAsset: (assetId: string) => Promise<boolean>;
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
hasMoreAssets: boolean;
isLoadingMoreAssets: boolean;
loadMoreAssets: () => Promise<void>;
highlightedAssetId: string | null;
onHighlightedAssetHandled: () => void;
}
export const AssetsPane = memo(function AssetsPane({
videoId,
assets,
isLoadingAssets,
isCreatingAsset,
activeDeleteAssetId,
activeDownloadAssetId,
canUploadAssets,
canDownloadAssets,
getGuestUploadToken,
createAsset,
deleteAsset,
downloadAsset,
hasMoreAssets,
isLoadingMoreAssets,
loadMoreAssets,
highlightedAssetId,
onHighlightedAssetHandled,
}: AssetsPaneProps) {
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny'>('image');
const [imageTitle, setImageTitle] = useState('');
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
const [youtubeUrl, setYoutubeUrl] = useState('');
const [youtubeTitle, setYoutubeTitle] = useState('');
const [bunnyTitle, setBunnyTitle] = useState('');
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
const [bunnyProgress, setBunnyProgress] = useState(0);
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({});
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({});
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
const [focusedAssetId, setFocusedAssetId] = useState<string | null>(null);
const bunnyPreviewPlayerRef = useRef<BunnyPreviewPlayerHandle | null>(null);
const youtubeIframeRef = useRef<HTMLIFrameElement | null>(null);
const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false });
const imageInputRef = useRef<HTMLInputElement>(null);
const bunnyInputRef = useRef<HTMLInputElement>(null);
const sortedAssets = useMemo(() => {
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
}, [assets]);
useEffect(() => {
if (!highlightedAssetId) return;
const element = document.getElementById(`asset-card-${highlightedAssetId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
setFocusedAssetId(highlightedAssetId);
window.setTimeout(() => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)), 2500);
}
onHighlightedAssetHandled();
}, [highlightedAssetId, onHighlightedAssetHandled]);
useEffect(() => {
if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return;
const sendYouTubeCommand = (func: string, args: unknown[] = []) => {
const iframe = youtubeIframeRef.current;
if (!iframe?.contentWindow) return;
iframe.contentWindow.postMessage(JSON.stringify({
event: 'command',
func,
args,
}), '*');
};
const onMessage = (event: MessageEvent) => {
if (!selectedAsset || selectedAsset.provider !== 'YOUTUBE') return;
if (typeof event.data !== 'string') return;
let parsed: unknown;
try {
parsed = JSON.parse(event.data);
} catch {
return;
}
const info = (parsed as { info?: { currentTime?: number; playerState?: number; muted?: boolean } })?.info;
if (!info) return;
if (typeof info.currentTime === 'number') {
youtubePreviewStateRef.current.currentTime = info.currentTime;
}
if (typeof info.playerState === 'number') {
youtubePreviewStateRef.current.isPlaying = info.playerState === 1;
}
if (typeof info.muted === 'boolean') {
youtubePreviewStateRef.current.isMuted = info.muted;
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
const handledKeys = new Set(['Space', 'KeyK', 'ArrowLeft', 'ArrowRight', 'KeyJ', 'KeyL', 'KeyM', 'Escape']);
if (!handledKeys.has(event.code)) return;
event.preventDefault();
event.stopPropagation();
if (event.code === 'Escape') {
setSelectedAsset(null);
return;
}
if (selectedAsset.provider === 'BUNNY') {
switch (event.code) {
case 'Space':
case 'KeyK':
bunnyPreviewPlayerRef.current?.togglePlayPause();
break;
case 'ArrowLeft':
case 'KeyJ':
bunnyPreviewPlayerRef.current?.seekBy(-10);
break;
case 'ArrowRight':
case 'KeyL':
bunnyPreviewPlayerRef.current?.seekBy(10);
break;
case 'KeyM':
bunnyPreviewPlayerRef.current?.toggleMute();
break;
}
return;
}
if (selectedAsset.provider === 'YOUTUBE') {
switch (event.code) {
case 'Space':
case 'KeyK': {
const isPlaying = youtubePreviewStateRef.current.isPlaying;
sendYouTubeCommand(isPlaying ? 'pauseVideo' : 'playVideo');
youtubePreviewStateRef.current.isPlaying = !isPlaying;
break;
}
case 'ArrowLeft':
case 'KeyJ': {
const next = Math.max(0, youtubePreviewStateRef.current.currentTime - 10);
sendYouTubeCommand('seekTo', [next, true]);
youtubePreviewStateRef.current.currentTime = next;
break;
}
case 'ArrowRight':
case 'KeyL': {
const next = youtubePreviewStateRef.current.currentTime + 10;
sendYouTubeCommand('seekTo', [next, true]);
youtubePreviewStateRef.current.currentTime = next;
break;
}
case 'KeyM': {
const isMuted = youtubePreviewStateRef.current.isMuted;
sendYouTubeCommand(isMuted ? 'unMute' : 'mute');
youtubePreviewStateRef.current.isMuted = !isMuted;
break;
}
}
}
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('message', onMessage);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('message', onMessage);
};
}, [selectedAsset]);
const handleImageUpload = async (file: File) => {
if (!file) return;
const imageError = validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
try {
const formData = new FormData();
formData.append('image', file);
formData.append('videoId', videoId);
const guestUploadToken = await getGuestUploadToken('image');
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
const uploadRes = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null;
const uploadedImageUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedImageUrl) {
toast.error(uploadPayload?.error || 'Failed to upload image');
return;
}
await createAsset({
provider: 'R2_IMAGE',
sourceUrl: uploadedImageUrl,
displayName: imageTitle.trim() || file.name,
});
if (imageInputRef.current) imageInputRef.current.value = '';
setImageTitle('');
setPendingImageFile(null);
} catch (error) {
console.error('Failed to upload image asset:', error);
toast.error('Failed to upload image');
}
};
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const imageError = validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
setPendingImageFile(file);
toast.success('Image attached. Click Upload Image to send.');
};
const handleImagePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return;
const pastedImage = extractPastedImageFile(event.clipboardData);
if (!pastedImage) return;
const imageError = validateImageFile(pastedImage);
if (imageError) {
toast.error(imageError);
return;
}
event.preventDefault();
setPendingImageFile(pastedImage);
toast.success('Image attached from clipboard. Click Upload Image to send.');
};
const handleCreateYoutubeAsset = async () => {
if (!youtubeUrl.trim()) return;
const created = await createAsset({
provider: 'YOUTUBE',
sourceUrl: youtubeUrl.trim(),
displayName: youtubeTitle.trim() || undefined,
});
if (created) {
setYoutubeUrl('');
setYoutubeTitle('');
}
};
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('video/')) {
toast.error('Please select a video file');
return;
}
let uploadedVideoId: string | null = null;
let uploadToken: string | null = null;
try {
setIsUploadingBunny(true);
setBunnyProgress(0);
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, '') }),
});
const initPayload = (await initRes.json().catch(() => null)) as {
data?: {
videoId: string;
libraryId: string;
signature: string;
expirationTime: number;
uploadToken: string;
};
error?: string;
} | null;
if (!initRes.ok || !initPayload?.data) {
toast.error(initPayload?.error || 'Failed to initialize Bunny upload');
return;
}
const initData = initPayload.data;
uploadedVideoId = initData.videoId;
uploadToken = initData.uploadToken;
await new Promise<void>((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: initData.signature,
AuthorizationExpire: initData.expirationTime.toString(),
VideoId: initData.videoId,
LibraryId: initData.libraryId,
},
metadata: {
filetype: file.type,
title: file.name,
},
onError: (error) => reject(error),
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0;
setBunnyProgress(Math.min(100, Math.max(0, percentage)));
},
onSuccess: () => resolve(),
});
upload.start();
});
const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`;
const thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${initData.videoId}/thumbnail.jpg`;
const createdAsset = await createAsset({
provider: 'BUNNY',
sourceUrl,
providerVideoId: initData.videoId,
uploadToken: initData.uploadToken,
thumbnailUrl,
displayName: bunnyTitle.trim() || file.name,
});
if (!createdAsset) {
throw new Error('Failed to finalize Bunny asset');
}
if (bunnyInputRef.current) bunnyInputRef.current.value = '';
setBunnyTitle('');
} catch (error) {
console.error('Failed to upload Bunny asset:', error);
toast.error('Failed to upload Bunny video');
if (uploadedVideoId && uploadToken) {
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }),
}).catch(() => undefined);
}
} finally {
setIsUploadingBunny(false);
setBunnyProgress(0);
}
};
const handleBunnyThumbnailError = (assetId: string) => {
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: true }));
window.setTimeout(() => {
setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: false }));
}, 10000);
};
const renderAssetPreview = (asset: VideoAsset) => {
if (asset.kind === 'IMAGE') {
const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
return (
<div className="h-24 w-36 rounded border bg-black/20 flex items-center justify-center overflow-hidden">
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={imageSrc} alt={asset.displayName} className="h-full w-full object-contain" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}
</div>
);
}
if (asset.provider === 'YOUTUBE' && asset.providerVideoId) {
return (
<div className="h-24 w-36 rounded border overflow-hidden bg-black/70 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.thumbnailUrl || `https://img.youtube.com/vi/${asset.providerVideoId}/mqdefault.jpg`}
alt={asset.displayName}
className="h-full w-full object-contain"
/>
</div>
);
}
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null;
return (
<div className="h-24 w-36 rounded border overflow-hidden bg-muted 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"
onError={() => handleBunnyThumbnailError(asset.id)}
/>
) : (
<FileVideo className="h-6 w-6 text-muted-foreground" />
)}
{isProcessing && (
<div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center gap-1">
<Loader2 className="h-4 w-4 animate-spin text-white" />
<span className="text-[10px] text-white/90 font-medium">Processing...</span>
</div>
)}
</div>
);
};
const handleOpenAsset = (asset: VideoAsset) => {
const isBunnyProcessing = asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id];
if (isBunnyProcessing) {
toast.info('This Bunny asset is still processing.');
return;
}
if (asset.kind === 'IMAGE') {
if (!asset.sourceUrl) {
toast.error('Preview is unavailable for this asset');
return;
}
setPreviewImage(asset.sourceUrl);
setPreviewImageTitle(asset.displayName);
return;
}
setSelectedAsset(asset);
};
return (
<div className="space-y-4" onPaste={handleImagePaste}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">Assets</span>
<Badge variant="secondary">{assets.length}</Badge>
</div>
</div>
{canUploadAssets ? (
<div className="rounded-lg border p-3 space-y-3">
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny')}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="image">Image</TabsTrigger>
<TabsTrigger value="youtube">YouTube</TabsTrigger>
<TabsTrigger value="bunny">Video</TabsTrigger>
</TabsList>
</Tabs>
{uploadTab === 'image' && (
<div className="space-y-2">
<Input
placeholder="Optional name for mentions/tagging"
value={imageTitle}
onChange={(event) => setImageTitle(event.target.value)}
/>
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
<p className="text-xs text-muted-foreground">Tip: you can paste an image here with Ctrl/Cmd+V.</p>
{pendingImageFile ? (
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
<span className="truncate">Attached: {pendingImageFile.name}</span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2"
onClick={() => {
setPendingImageFile(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}
>
Clear
</Button>
</div>
) : null}
<Button
variant="outline"
className="w-full"
disabled={isCreatingAsset}
onClick={() => {
if (pendingImageFile) {
void handleImageUpload(pendingImageFile);
return;
}
imageInputRef.current?.click();
}}
>
<UploadCloud className="h-4 w-4 mr-2" />
{pendingImageFile ? 'Upload Image' : 'Select Image'}
</Button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFileChange}
/>
</div>
)}
{uploadTab === 'youtube' && (
<div className="space-y-2">
<Input
placeholder="https://youtube.com/watch?v=..."
value={youtubeUrl}
onChange={(event) => setYoutubeUrl(event.target.value)}
/>
<Input
placeholder="Optional display name"
value={youtubeTitle}
onChange={(event) => setYoutubeTitle(event.target.value)}
/>
<Button
className="w-full"
disabled={isCreatingAsset || !youtubeUrl.trim()}
onClick={handleCreateYoutubeAsset}
>
<Youtube className="h-4 w-4 mr-2" />
Add YouTube Asset
</Button>
</div>
)}
{uploadTab === 'bunny' && (
<div className="space-y-2">
<Input
placeholder="Optional name for mentions/tagging"
value={bunnyTitle}
onChange={(event) => setBunnyTitle(event.target.value)}
/>
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
<Button
variant="outline"
className="w-full"
disabled={isUploadingBunny || isCreatingAsset}
onClick={() => bunnyInputRef.current?.click()}
>
{isUploadingBunny ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingBunny ? 'Uploading...' : 'Upload Video'}
</Button>
<input
ref={bunnyInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleBunnyUpload}
/>
{isUploadingBunny && (
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
<div className="bg-primary h-2 rounded-full" style={{ width: `${bunnyProgress}%` }} />
</div>
)}
</div>
)}
</div>
) : (
<div className="rounded-lg border p-3 text-xs text-muted-foreground">
You do not have permission to upload assets.
</div>
)}
<AssetListSection
assets={sortedAssets}
isLoadingAssets={isLoadingAssets}
focusedAssetId={focusedAssetId}
bunnyProcessingByAssetId={bunnyProcessingByAssetId}
activeDownloadAssetId={activeDownloadAssetId}
activeDeleteAssetId={activeDeleteAssetId}
canDownloadAssets={canDownloadAssets}
hasMoreAssets={hasMoreAssets}
isLoadingMoreAssets={isLoadingMoreAssets}
onViewAsset={handleOpenAsset}
onDownloadAsset={(asset, preference) => void downloadAsset(asset, preference)}
onDeleteAsset={(assetId) => void deleteAsset(assetId)}
onLoadMoreAssets={() => void loadMoreAssets()}
renderAssetPreview={renderAssetPreview}
/>
<ImagePreviewDialog
previewImage={previewImage}
title={previewImageTitle}
downloadFileName={previewImageTitle}
canDownload={canDownloadAssets}
onClose={() => {
setPreviewImage(null);
setPreviewImageTitle(null);
}}
/>
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
<DialogContent
showCloseButton={false}
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none rounded-none flex items-center justify-center"
onClick={() => setSelectedAsset(null)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
event.preventDefault();
setSelectedAsset(null);
}
}}
>
<DialogTitle className="sr-only">{selectedAsset?.displayName || 'Video Preview'}</DialogTitle>
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={selectedAsset?.displayName || undefined}>
{selectedAsset?.displayName || 'Video Preview'}
</p>
{selectedAsset?.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 shrink-0"
>
<a
href={`https://www.youtube.com/watch?v=${selectedAsset.providerVideoId}`}
target="_blank"
rel="noopener noreferrer"
>
Open on YouTube
</a>
</Button>
) : null}
{selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
title="Download Bunny video"
aria-label="Download Bunny video"
disabled={
activeDownloadAssetId === selectedAsset.id
|| !!bunnyProcessingByAssetId[selectedAsset.id]
}
>
{activeDownloadAssetId === selectedAsset.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'original')}>
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'compressed')}>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedAsset(null)}
>
<span className="sr-only">Close</span>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 min-h-0 w-full p-2 sm:p-4">
{selectedAsset ? (
selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
<div className="w-full h-full rounded-md border overflow-hidden bg-black">
<iframe
ref={youtubeIframeRef}
className="w-full h-full"
src={`https://www.youtube.com/embed/${selectedAsset.providerVideoId}?enablejsapi=1&rel=0&modestbranding=1&playsinline=1${typeof window !== 'undefined' ? `&origin=${encodeURIComponent(window.location.origin)}` : ''}`}
title={selectedAsset.displayName}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
) : (
<BunnyPreviewPlayer
ref={bunnyPreviewPlayerRef}
providerVideoId={selectedAsset.providerVideoId}
isProcessing={!!bunnyProcessingByAssetId[selectedAsset.id]}
/>
)
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
});
@@ -0,0 +1,270 @@
'use client';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import Hls from 'hls.js';
import { Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface BunnyPreviewPlayerProps {
providerVideoId: string | null;
isProcessing: boolean;
}
export interface BunnyPreviewPlayerHandle {
togglePlayPause: () => void;
seekBy: (seconds: number) => void;
toggleMute: () => void;
}
const DEFAULT_BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
function resolveBunnyCdnHostname(): string {
const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
if (!configured) return DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
try {
const parsed = new URL(configured);
return parsed.hostname || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
} catch {
return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
}
}
function formatTime(value: number): string {
if (!Number.isFinite(value) || value < 0) return '0:00';
const total = Math.floor(value);
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPreviewPlayerProps>(function BunnyPreviewPlayer({ providerVideoId, isProcessing }, ref) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryAttemptRef = useRef(0);
const [isReady, setIsReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [loadError, setLoadError] = useState(false);
const playlistUrl = useMemo(() => {
if (!providerVideoId) return null;
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`;
}, [providerVideoId]);
useEffect(() => {
const video = videoRef.current;
if (!video || !playlistUrl) return;
let destroyed = false;
let usingHlsJs = false;
const clearRetry = () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
const scheduleRetry = (retryFn: () => void) => {
clearRetry();
retryTimerRef.current = setTimeout(() => {
if (!destroyed) retryFn();
}, 3000);
};
const getRetryUrl = () => {
retryAttemptRef.current += 1;
const separator = playlistUrl.includes('?') ? '&' : '?';
return `${playlistUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`;
};
const onLoadedMetadata = () => {
if (destroyed) return;
setIsReady(true);
setLoadError(false);
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
clearRetry();
};
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onEnded = () => setIsPlaying(false);
const onTimeUpdate = () => setCurrentTime(video.currentTime || 0);
const onError = () => {
if (destroyed) return;
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
setLoadError(true);
return;
}
setLoadError(false);
scheduleRetry(() => {
if (usingHlsJs && hlsRef.current) {
hlsRef.current.loadSource(getRetryUrl());
hlsRef.current.startLoad(-1);
} else {
video.src = getRetryUrl();
video.load();
}
});
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause);
video.addEventListener('ended', onEnded);
video.addEventListener('timeupdate', onTimeUpdate);
video.addEventListener('error', onError);
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl');
if (Hls.isSupported()) {
const hls = new Hls();
hlsRef.current = hls;
usingHlsJs = true;
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!destroyed) hls.loadSource(playlistUrl);
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (destroyed) return;
if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) {
scheduleRetry(() => hls.loadSource(getRetryUrl()));
}
});
} else if (canPlayNativeHls) {
video.src = playlistUrl;
video.load();
} else {
// Defer state update to avoid sync setState directly in effect body.
window.setTimeout(() => {
if (!destroyed) setLoadError(true);
}, 0);
}
return () => {
destroyed = true;
clearRetry();
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause);
video.removeEventListener('ended', onEnded);
video.removeEventListener('timeupdate', onTimeUpdate);
video.removeEventListener('error', onError);
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
video.removeAttribute('src');
video.load();
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsReady(false);
};
}, [playlistUrl]);
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current;
if (!video || !duration) return;
const rect = event.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
setCurrentTime(video.currentTime);
};
const togglePlayPause = useCallback(() => {
const video = videoRef.current;
if (!video || !isReady || isProcessing) return;
if (video.paused) void video.play();
else video.pause();
}, [isProcessing, isReady]);
const seekBy = useCallback((seconds: number) => {
const video = videoRef.current;
if (!video || !isReady || isProcessing || !duration) return;
video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds));
setCurrentTime(video.currentTime);
}, [duration, isProcessing, isReady]);
const toggleMute = useCallback(() => {
const video = videoRef.current;
if (!video) return;
const nextMuted = !video.muted;
video.muted = nextMuted;
setIsMuted(nextMuted);
}, []);
useImperativeHandle(ref, () => ({
togglePlayPause,
seekBy,
toggleMute,
}), [seekBy, toggleMute, togglePlayPause]);
return (
<div className="w-full h-full rounded-md border overflow-hidden bg-black flex flex-col">
<div className="relative flex-1 min-h-0 flex items-center justify-center bg-black" onClick={togglePlayPause}>
<video ref={videoRef} className="w-full h-full object-contain bg-black" playsInline preload="metadata" />
{(isProcessing || (!isReady && !loadError)) && (
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
<div className="flex items-center gap-2 text-white text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
</div>
</div>
)}
{loadError && !isProcessing && (
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
<p className="text-xs text-white/85">Unable to load Bunny preview right now.</p>
</div>
)}
</div>
<div className="shrink-0 border-t border-white/10 bg-black/70 px-2 py-1.5">
<div className="flex items-center gap-1.5 mb-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white hover:text-white"
disabled={!isReady || isProcessing}
onClick={togglePlayPause}
>
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-white hover:text-white"
disabled={!isReady}
onClick={toggleMute}
>
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
</Button>
<span className="text-[11px] text-white/80 tabular-nums ml-1">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div
className={cn(
'relative h-6 rounded bg-white/10 select-none',
isReady && !isProcessing ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
)}
onClick={seekTo}
>
<div
className="absolute left-0 top-0 h-full rounded bg-cyan-500/40 pointer-events-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
<div
className="absolute top-0 h-full w-1 rounded bg-cyan-400 pointer-events-none"
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
/>
</div>
</div>
</div>
);
});
+26 -20
View File
@@ -4,7 +4,6 @@ import { memo, type RefObject } from 'react';
import Link from 'next/link';
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,7 +12,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { AnnotationStroke } from '@/components/annotation-canvas';
import type { CommentTag } from '@/components/video-page/types';
import { MentionTextarea } from '@/components/video-page/mention-textarea';
import type { CommentTag, VideoAsset } from '@/components/video-page/types';
interface CommentComposerProps {
isRecording: boolean;
@@ -51,6 +51,7 @@ interface CommentComposerProps {
canManageTags: boolean;
projectId?: string;
pauseVideoForAnnotation: () => void;
assets: VideoAsset[];
}
export const CommentComposer = memo(function CommentComposer({
@@ -89,6 +90,7 @@ export const CommentComposer = memo(function CommentComposer({
canManageTags,
projectId,
pauseVideoForAnnotation,
assets,
}: CommentComposerProps) {
return (
<div className="shrink-0 p-4 border-t bg-background">
@@ -168,10 +170,11 @@ export const CommentComposer = memo(function CommentComposer({
</div>
)}
<Textarea
<MentionTextarea
placeholder="Add a note to your voice comment (optional)..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onChange={setCommentText}
assets={assets}
rows={1}
className="resize-none text-sm"
/>
@@ -222,21 +225,24 @@ export const CommentComposer = memo(function CommentComposer({
</div>
</div>
)}
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
onPaste={(e) => handlePaste(e, false)}
/>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-stretch">
<div className="flex-1 min-w-0">
<MentionTextarea
placeholder="Add a comment..."
value={commentText}
onChange={setCommentText}
assets={assets}
rows={6}
className="resize-none text-sm min-h-[180px] w-full"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
onPaste={(e) => handlePaste(e, false)}
/>
</div>
<div className="flex flex-col gap-1 self-end">
<Button
size="icon"
onClick={handleAddComment}
@@ -329,7 +335,7 @@ export const CommentComposer = memo(function CommentComposer({
)}
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
<p className="text-xs text-muted-foreground mt-2">Cmd+Enter to submit</p>
</>
)}
</div>
@@ -0,0 +1,90 @@
'use client';
import React from 'react';
import { Image as ImageIcon, Video } from 'lucide-react';
import type { VideoAsset } from '@/components/video-page/types';
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
const ASSET_MENTION_REGEX = /@\[(.+?)\]\(asset:([a-z0-9]+)\)/gi;
interface CommentRichTextProps {
text: string;
onAssetMentionClick?: (assetId: string) => void;
assets?: VideoAsset[];
}
function renderUrls(text: string): React.ReactNode[] {
const parts = text.split(URL_REGEX);
return parts.map((part, index) => {
if (/^https?:\/\/[^\s]+$/.test(part)) {
return (
<a
key={`url-${index}`}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
onClick={(event) => event.stopPropagation()}
>
{part}
</a>
);
}
return <React.Fragment key={`txt-${index}`}>{part}</React.Fragment>;
});
}
export function CommentRichText({ text, onAssetMentionClick, assets = [] }: CommentRichTextProps) {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(ASSET_MENTION_REGEX)) {
const mentionIndex = match.index ?? -1;
if (mentionIndex < 0) continue;
if (mentionIndex > lastIndex) {
nodes.push(...renderUrls(text.slice(lastIndex, mentionIndex)));
}
const fallbackLabel = match[1] || 'asset';
const assetId = match[2] || '';
const matchedAsset = assets.find((asset) => asset.id === assetId);
const label = matchedAsset?.displayName || fallbackLabel;
const isVideoAsset = matchedAsset?.kind === 'VIDEO';
nodes.push(
<button
key={`mention-${assetId}-${mentionIndex}`}
type="button"
className="inline-flex max-w-full items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-primary hover:bg-primary/20 transition-colors align-middle"
onClick={(event) => {
event.stopPropagation();
if (assetId && onAssetMentionClick) onAssetMentionClick(assetId);
}}
title={label}
>
<span
className={
isVideoAsset
? 'inline-flex h-4 shrink-0 items-center gap-0.5 rounded bg-violet-500/25 px-1 text-[9px] font-semibold tracking-wide text-violet-200'
: 'inline-flex h-4 shrink-0 items-center gap-0.5 rounded bg-emerald-500/25 px-1 text-[9px] font-semibold tracking-wide text-emerald-200'
}
>
{isVideoAsset ? <Video className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
{isVideoAsset ? 'VID' : 'SS'}
</span>
<span className="truncate max-w-[190px] sm:max-w-[240px]">
@{label}
</span>
</button>
);
lastIndex = mentionIndex + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(...renderUrls(text.slice(lastIndex)));
}
return <>{nodes}</>;
}
+119 -55
View File
@@ -1,11 +1,10 @@
'use client';
import { memo, type ReactNode, type RefObject } from 'react';
import { ArrowUpRight, CheckCircle2, Circle, Clock, Download, FileText, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
import { ArrowUpRight, CheckCircle2, ChevronDown, Circle, Clock, Download, FileText, FolderOpen, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,8 +13,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { Linkify } from '@/components/linkify';
import type { Comment, CommentTag, Version } from '@/components/video-page/types';
import { MentionTextarea } from '@/components/video-page/mention-textarea';
import { CommentRichText } from '@/components/video-page/comment-rich-text';
import type { Comment, CommentTag, Version, VideoAsset } from '@/components/video-page/types';
interface CommentsPaneProps {
isMobileCommentsOpen: boolean;
@@ -79,6 +79,11 @@ interface CommentsPaneProps {
isUploadingReplyAudio: boolean;
isUploadingReplyImage: boolean;
composer: ReactNode;
assets: VideoAsset[];
onAssetMentionClick: (assetId: string) => void;
activePane: 'comments' | 'assets';
setActivePane: (pane: 'comments' | 'assets') => void;
assetsPane: ReactNode;
}
export const CommentsPane = memo(function CommentsPane({
@@ -143,6 +148,11 @@ export const CommentsPane = memo(function CommentsPane({
isUploadingReplyAudio,
isUploadingReplyImage,
composer,
assets,
onAssetMentionClick,
activePane,
setActivePane,
assetsPane,
}: CommentsPaneProps) {
return (
<>
@@ -161,52 +171,94 @@ export const CommentsPane = memo(function CommentsPane({
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
isFullscreenMode && !showComments ? 'hidden' : ''
)}>
<div
className="shrink-0 flex items-center justify-between p-4 border-b lg:cursor-default"
>
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Comments</span>
<Badge variant="secondary">{comments.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
{showResolved ? 'Hide' : 'Show'} Resolved
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('csv');
}}
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
>
{isExportingCsv ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!activeVersion || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('pdf');
}}
title="Download comments as PDF"
>
{isExportingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 min-w-0 overflow-x-auto">
<Button
variant={activePane === 'comments' ? 'default' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => setActivePane('comments')}
>
<MessageSquare className="h-4 w-4 mr-1" />
Comments
<Badge variant="secondary" className="ml-2">{comments.length}</Badge>
</Button>
<Button
variant={activePane === 'assets' ? 'default' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => setActivePane('assets')}
>
<FolderOpen className="h-4 w-4 mr-1" />
Assets
<Badge variant="secondary" className="ml-2">{assets.length}</Badge>
</Button>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden shrink-0" onClick={() => setIsMobileCommentsOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
{activePane === 'comments' && (
<div className="flex w-full items-center justify-end gap-2 flex-wrap">
<Button
variant={showResolved ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}
>
Resolved
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2"
disabled={!activeVersion || isExportingCsv || isExportingPdf}
aria-label="Download comments"
title="Download comments"
>
{isExportingCsv || isExportingPdf ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
<ChevronDown className="h-4 w-4 ml-0.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('csv');
}}
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
>
<Download className="h-4 w-4 mr-2" />
Download CSV
</DropdownMenuItem>
<DropdownMenuItem
disabled={!activeVersion || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('pdf');
}}
title="Download comments as PDF"
>
<FileText className="h-4 w-4 mr-2" />
Download PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredComments.length === 0 ? (
{activePane === 'assets' ? assetsPane : filteredComments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No comments yet</p>
@@ -312,9 +364,10 @@ export const CommentsPane = memo(function CommentsPane({
{isEditing ? (
<div className="mb-2">
<Textarea
<MentionTextarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onChange={setEditText}
assets={assets}
rows={2}
className="resize-none text-sm mb-1"
autoFocus
@@ -402,7 +455,11 @@ export const CommentsPane = memo(function CommentsPane({
</div>
) : (
<div className="mb-2">
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
{comment.content && (
<p className="text-sm mb-2">
<CommentRichText text={comment.content} onAssetMentionClick={onAssetMentionClick} assets={assets} />
</p>
)}
{comment.imageUrl && (
<div
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
@@ -530,9 +587,10 @@ export const CommentsPane = memo(function CommentsPane({
</div>
{isEditingReply ? (
<div className="mb-1">
<Textarea
<MentionTextarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onChange={setEditText}
assets={assets}
rows={2}
className="resize-none text-sm mb-1"
autoFocus
@@ -567,7 +625,11 @@ export const CommentsPane = memo(function CommentsPane({
</div>
) : (
<div className="mb-1">
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
{reply.content && (
<p className="text-sm">
<CommentRichText text={reply.content} onAssetMentionClick={onAssetMentionClick} assets={assets} />
</p>
)}
{reply.imageUrl && (
<div
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
@@ -689,9 +751,10 @@ export const CommentsPane = memo(function CommentsPane({
</div>
)}
<Textarea
<MentionTextarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onChange={setReplyText}
assets={assets}
placeholder="Add a note (optional)..."
rows={1}
className="resize-none text-sm"
@@ -725,9 +788,10 @@ export const CommentsPane = memo(function CommentsPane({
</div>
)}
<div className="flex gap-1">
<Textarea
<MentionTextarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onChange={setReplyText}
assets={assets}
placeholder="Write a reply..."
rows={2}
className="resize-none text-sm flex-1"
@@ -807,7 +871,7 @@ export const CommentsPane = memo(function CommentsPane({
)}
</div>
{composer}
{activePane === 'comments' ? composer : null}
</div>
</>
);
@@ -14,6 +14,7 @@ import {
import { toast } from 'sonner';
import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/annotation-canvas';
import type { Comment, CommentActionsConfig, CommentTag, Version, VideoData } from '@/components/video-page/types';
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
interface UseCommentActionsParams extends CommentActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>;
@@ -279,13 +280,9 @@ export function useCommentActions({
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
if (file.size > 10 * 1024 * 1024) {
toast.error('Image must be less than 10MB');
const imageError = validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
@@ -297,27 +294,21 @@ export function useCommentActions({
}, []);
const handlePaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
const items = e.clipboardData?.items;
if (!items) return;
const file = extractPastedImageFile(e.clipboardData);
if (!file) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) {
if (file.size > 10 * 1024 * 1024) {
toast.error('Image must be less than 10MB');
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
e.preventDefault();
break;
}
}
const imageError = validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
if (isReply) {
setReplyImageBlob(file);
} else {
setImageBlob(file);
}
e.preventDefault();
}, []);
const startRecording = useCallback(async () => {
@@ -0,0 +1,235 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import type { VideoAsset } from '@/components/video-page/types';
type BunnyDownloadPreference = 'original' | 'compressed';
type CreateAssetPayload = {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
displayName?: string;
sourceUrl: string;
providerVideoId?: string;
thumbnailUrl?: string;
uploadToken?: string;
};
interface UseVideoAssetsParams {
videoId: string;
isAuthenticated: boolean;
canUploadAssets: boolean;
canDownloadAssets: boolean;
guestName?: string;
}
interface AssetsListResponse {
data?: {
assets?: VideoAsset[];
pagination?: {
limit?: number;
offset?: number;
hasMore?: boolean;
nextOffset?: number | null;
};
canUploadAssets?: boolean;
canDownloadAssets?: boolean;
};
error?: string;
}
interface AssetCreateResponse {
data?: VideoAsset;
error?: string;
}
const ASSET_PAGE_SIZE = 40;
export function useVideoAssets({
videoId,
isAuthenticated,
canUploadAssets,
canDownloadAssets,
guestName,
}: UseVideoAssetsParams) {
const [assets, setAssets] = useState<VideoAsset[]>([]);
const [isLoadingAssets, setIsLoadingAssets] = useState(true);
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
const [activeDeleteAssetId, setActiveDeleteAssetId] = useState<string | null>(null);
const [activeDownloadAssetId, setActiveDownloadAssetId] = useState<string | null>(null);
const [hasMoreAssets, setHasMoreAssets] = useState(false);
const [nextAssetsOffset, setNextAssetsOffset] = useState(0);
const [isLoadingMoreAssets, setIsLoadingMoreAssets] = useState(false);
const fetchAssets = useCallback(async () => {
setIsLoadingAssets(true);
try {
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store' });
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to fetch assets');
return;
}
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
const pagination = payload?.data?.pagination;
setAssets(list);
setHasMoreAssets(!!pagination?.hasMore);
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
} catch {
toast.error('Failed to fetch assets');
} finally {
setIsLoadingAssets(false);
}
}, [videoId]);
const loadMoreAssets = useCallback(async () => {
if (isLoadingMoreAssets || !hasMoreAssets) return;
setIsLoadingMoreAssets(true);
try {
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=${nextAssetsOffset}`, { cache: 'no-store' });
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to load more assets');
return;
}
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
const pagination = payload?.data?.pagination;
setAssets((prev) => [...prev, ...list.filter((asset) => !prev.some((existing) => existing.id === asset.id))]);
setHasMoreAssets(!!pagination?.hasMore);
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
} catch {
toast.error('Failed to load more assets');
} finally {
setIsLoadingMoreAssets(false);
}
}, [hasMoreAssets, isLoadingMoreAssets, nextAssetsOffset, videoId]);
useEffect(() => {
void fetchAssets();
}, [fetchAssets]);
const createAsset = useCallback(async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
if (!canUploadAssets) {
toast.error('You do not have permission to upload assets');
return null;
}
setIsCreatingAsset(true);
try {
const res = await fetch(`/api/videos/${videoId}/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
...(isAuthenticated ? {} : { guestName: guestName?.trim() || 'Guest' }),
}),
});
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
if (!res.ok || !body?.data) {
toast.error(body?.error || 'Failed to create asset');
return null;
}
setAssets((prev) => [body.data!, ...prev]);
setNextAssetsOffset((prev) => prev + 1);
return body.data;
} catch {
toast.error('Failed to create asset');
return null;
} finally {
setIsCreatingAsset(false);
}
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
const deleteAsset = useCallback(async (assetId: string) => {
setActiveDeleteAssetId(assetId);
try {
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
method: 'DELETE',
});
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to delete asset');
return false;
}
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
setNextAssetsOffset((prev) => Math.max(0, prev - 1));
return true;
} catch {
toast.error('Failed to delete asset');
return false;
} finally {
setActiveDeleteAssetId(null);
}
}, [videoId]);
const downloadAsset = useCallback(async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
if (!canDownloadAssets) {
toast.error('Asset downloads require an authenticated account');
return;
}
if (asset.provider === 'YOUTUBE') {
toast.error('YouTube assets cannot be downloaded');
return;
}
setActiveDownloadAssetId(asset.id);
try {
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
if (asset.provider === 'BUNNY') {
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, { cache: 'no-store' });
const prepareBody = (await prepareRes.json().catch(() => null)) as { error?: string } | null;
if (!prepareRes.ok) {
toast.error(prepareBody?.error || 'Download is not available');
return;
}
downloadUrl = `${downloadUrl}?source=${preference}`;
}
const a = document.createElement('a');
a.href = downloadUrl;
document.body.appendChild(a);
a.click();
a.remove();
} catch {
toast.error('Failed to start download');
} finally {
setActiveDownloadAssetId(null);
}
}, [canDownloadAssets, videoId]);
const getGuestUploadToken = useCallback(async (intent: 'image') => {
if (isAuthenticated) return null;
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent }),
});
const payload = (await response.json().catch(() => null)) as
| { data?: { token?: string }; error?: string }
| null;
const token = payload?.data?.token;
if (!response.ok || !token) {
throw new Error(payload?.error || 'Failed to prepare upload');
}
return token;
}, [isAuthenticated, videoId]);
return {
assets,
isLoadingAssets,
isCreatingAsset,
activeDeleteAssetId,
activeDownloadAssetId,
hasMoreAssets,
isLoadingMoreAssets,
fetchAssets,
loadMoreAssets,
createAsset,
deleteAsset,
downloadAsset,
getGuestUploadToken,
};
}
@@ -521,6 +521,10 @@ export function useVideoPlayer({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.querySelector('[data-slot="dialog-content"]')) {
return;
}
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
+76 -53
View File
@@ -9,71 +9,94 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
interface ImagePreviewDialogProps {
previewImage: string | null;
onClose: () => void;
title?: string | null;
downloadFileName?: string | null;
canDownload?: boolean;
}
export const ImagePreviewDialog = memo(function ImagePreviewDialog({
previewImage,
onClose,
title,
downloadFileName,
canDownload = true,
}: ImagePreviewDialogProps) {
const resolvedDownloadName = downloadFileName || (previewImage ? previewImage.split('/').pop() || 'attachment.png' : 'attachment.png');
return (
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
<DialogContent
showCloseButton={false}
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex flex-col items-center justify-center rounded-none"
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex items-center justify-center rounded-none"
onClick={onClose}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}}
>
<DialogTitle className="sr-only">Image Preview</DialogTitle>
<div className="absolute top-4 right-4 flex gap-3 z-50">
<Button
variant="outline"
size="icon"
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
onClick={async (e) => {
e.stopPropagation();
try {
if (!previewImage) return;
const response = await fetch(previewImage);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = previewImage.split('/').pop() || 'attachment.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download image:', error);
toast.error('Failed to download image');
}
}}
<DialogTitle className="sr-only">{title || 'Image Preview'}</DialogTitle>
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={title || undefined}>
{title || 'Image Preview'}
</p>
{canDownload ? (
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async (e) => {
e.stopPropagation();
try {
if (!previewImage) return;
const response = await fetch(previewImage);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = resolvedDownloadName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download image:', error);
toast.error('Failed to download image');
}
}}
>
<Download className="h-4 w-4" />
</Button>
) : null}
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<div
className="relative flex-1 min-h-0 w-full flex items-center justify-center p-2 sm:p-4 cursor-zoom-out"
onClick={onClose}
>
<Download className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-5 w-5" />
</Button>
</div>
<div
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
onClick={onClose}
>
{previewImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImage}
alt="Preview"
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
onClick={(e) => e.stopPropagation()}
/>
)}
{previewImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImage}
alt={title || 'Preview'}
className="max-w-full max-h-full object-contain rounded-md select-none cursor-default"
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,29 @@
'use client';
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
export function validateImageFile(file: File): string | null {
if (!file.type.startsWith('image/')) {
return 'Please select an image file';
}
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
return 'Image must be less than 10MB';
}
return null;
}
export function extractPastedImageFile(data: DataTransfer | null | undefined): File | null {
const items = data?.items;
if (!items) return null;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item.type.startsWith('image/')) continue;
const file = item.getAsFile();
if (file) return file;
}
return null;
}
+180
View File
@@ -0,0 +1,180 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { VideoAsset } from '@/components/video-page/types';
type MentionRange = {
start: number;
end: number;
query: string;
};
interface MentionTextareaProps {
value: string;
onChange: (value: string) => void;
assets: VideoAsset[];
placeholder?: string;
rows?: number;
className?: string;
onPaste?: React.ClipboardEventHandler<HTMLTextAreaElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
autoFocus?: boolean;
disabled?: boolean;
}
function findMentionRange(text: string, caret: number): MentionRange | null {
const before = text.slice(0, caret);
const atIndex = before.lastIndexOf('@');
if (atIndex < 0) return null;
const charBeforeAt = atIndex === 0 ? ' ' : before[atIndex - 1];
if (charBeforeAt && !/\s/.test(charBeforeAt)) return null;
const query = before.slice(atIndex + 1);
if (query.length === 0) {
return { start: atIndex, end: caret, query: '' };
}
if (/\s|\[|\]|\(|\)/.test(query)) return null;
return { start: atIndex, end: caret, query };
}
export function MentionTextarea({
value,
onChange,
assets,
placeholder,
rows = 2,
className,
onPaste,
onKeyDown,
autoFocus,
disabled,
}: MentionTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [mentionRange, setMentionRange] = useState<MentionRange | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const searchableAssets = useMemo(() => {
return assets
.map((asset) => ({
asset,
displayNameLower: asset.displayName.toLowerCase(),
createdAtMs: Date.parse(asset.createdAt),
}))
.sort((a, b) => b.createdAtMs - a.createdAtMs);
}, [assets]);
const filteredAssets = useMemo(() => {
if (!mentionRange) return [];
const query = mentionRange.query.trim().toLowerCase();
if (!query) return searchableAssets.slice(0, 8).map((entry) => entry.asset);
return searchableAssets
.filter((entry) => entry.displayNameLower.includes(query))
.map((entry) => entry.asset)
.slice(0, 8);
}, [mentionRange, searchableAssets]);
const closeMentions = () => {
setMentionRange(null);
setActiveIndex(0);
};
const insertAssetMention = (asset: VideoAsset) => {
if (!mentionRange || !textareaRef.current) return;
const mentionToken = `@[${asset.displayName}](asset:${asset.id}) `;
const nextValue = `${value.slice(0, mentionRange.start)}${mentionToken}${value.slice(mentionRange.end)}`;
onChange(nextValue);
closeMentions();
const nextCursor = mentionRange.start + mentionToken.length;
requestAnimationFrame(() => {
if (!textareaRef.current) return;
textareaRef.current.focus();
textareaRef.current.setSelectionRange(nextCursor, nextCursor);
});
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextValue = event.target.value;
onChange(nextValue);
const caret = event.target.selectionStart ?? nextValue.length;
const range = findMentionRange(nextValue, caret);
setMentionRange(range);
setActiveIndex(0);
};
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
if (mentionRange && filteredAssets.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault();
setActiveIndex((prev) => (prev + 1) % filteredAssets.length);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setActiveIndex((prev) => (prev - 1 + filteredAssets.length) % filteredAssets.length);
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
const selected = filteredAssets[Math.min(activeIndex, filteredAssets.length - 1)];
if (selected) insertAssetMention(selected);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeMentions();
return;
}
}
onKeyDown?.(event);
};
return (
<div className="relative">
<Textarea
ref={textareaRef}
value={value}
onChange={handleChange}
placeholder={placeholder}
rows={rows}
className={className}
onPaste={onPaste}
onKeyDown={handleKeyDown}
autoFocus={autoFocus}
disabled={disabled}
onBlur={() => {
window.setTimeout(closeMentions, 120);
}}
/>
{mentionRange && filteredAssets.length > 0 && (
<div className="absolute left-0 right-0 bottom-full mb-1 z-30 rounded-md border bg-popover shadow-md overflow-hidden">
{filteredAssets.map((asset, index) => (
<button
key={asset.id}
type="button"
className={cn(
'w-full text-left px-2 py-1.5 text-xs hover:bg-accent transition-colors',
index === activeIndex && 'bg-accent'
)}
onMouseDown={(event) => {
event.preventDefault();
insertAssetMention(asset);
}}
>
<span className="font-medium">@{asset.displayName}</span>
<span className="ml-2 text-muted-foreground">{asset.provider}</span>
</button>
))}
</div>
)}
</div>
);
}
+20
View File
@@ -18,6 +18,23 @@ export interface CommentTag {
color: string;
}
export interface VideoAsset {
id: string;
videoId: string;
kind: 'IMAGE' | 'VIDEO';
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
displayName: string;
sourceUrl: string | null;
providerVideoId: string | null;
thumbnailUrl: string | null;
uploadedByUserId: string | null;
uploadedByGuestName: string | null;
createdAt: string;
updatedAt: string;
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
canDelete: boolean;
}
export interface ApprovalDecision {
id: string;
approverId: string;
@@ -111,6 +128,9 @@ export interface VideoData {
canManageTags?: boolean;
canResolveComments?: boolean;
canRequestApproval?: boolean;
canShareVideo?: boolean;
canUploadAssets?: boolean;
canDownloadAssets?: boolean;
}
export interface BunnyQualityOption {
+14 -5
View File
@@ -61,6 +61,7 @@ interface VideoPageHeaderProps {
onCreateVersion: () => void;
onOpenCompare: () => void;
canRequestApproval: boolean;
canShareVideo: boolean;
hasPendingApprovalRequest: boolean;
onOpenApprovalRequest: () => void;
onOpenApprovalsPanel: () => void;
@@ -107,6 +108,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
onCreateVersion,
onOpenCompare,
canRequestApproval,
canShareVideo,
hasPendingApprovalRequest,
onOpenApprovalRequest,
onOpenApprovalsPanel,
@@ -231,17 +233,24 @@ export const VideoPageHeader = memo(function VideoPageHeader({
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<Button variant="outline" size="sm" className="w-7 px-0 self-center">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
{canShareVideo ? (
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share Video
</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled>
<Share2 className="h-4 w-4 mr-2" />
Share Video
</Link>
</DropdownMenuItem>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={onOpenApprovalRequest}
disabled={!canRequestApproval}
+77 -25
View File
@@ -194,19 +194,31 @@ export const getCachedUserBunnyStorage = unstable_cache(
const bunnyStats = await getCachedBunnyStorageStats();
if (bunnyStats.totalBytes < 0) return perUserStorage;
const bunnyVersions = await db.videoVersion.findMany({
where: { providerId: 'bunny' },
select: {
videoId: true,
video: {
select: {
project: {
select: { ownerId: true },
const [bunnyVersions, bunnyAssets] = await Promise.all([
db.videoVersion.findMany({
where: { providerId: 'bunny' },
select: {
videoId: true,
video: {
select: {
project: {
select: { ownerId: true },
},
},
},
},
},
});
}),
db.videoAsset.findMany({
where: {
provider: 'BUNNY',
providerVideoId: { not: null },
},
select: {
providerVideoId: true,
billedUserId: true,
},
}),
]);
const seenVideoIds = new Set<string>();
for (const version of bunnyVersions) {
@@ -218,6 +230,17 @@ export const getCachedUserBunnyStorage = unstable_cache(
const size = bunnyStats.byVideoId[version.videoId] || 0;
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
}
for (const asset of bunnyAssets) {
if (!asset.providerVideoId) continue;
const billedUserId = asset.billedUserId;
const dedupeKey = `${billedUserId}:${asset.providerVideoId}`;
if (seenVideoIds.has(dedupeKey)) continue;
seenVideoIds.add(dedupeKey);
const size = bunnyStats.byVideoId[asset.providerVideoId] || 0;
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
}
} catch (err) {
console.error('Failed to calculate per-user Bunny storage:', err);
}
@@ -234,19 +257,21 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
const snapshot = await getR2StorageSnapshot();
const seenKeys = new Set<string>();
const mediaComments = await db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
select: {
voiceUrl: true,
imageUrl: true,
version: {
select: {
video: {
select: {
project: {
select: {
workspace: {
select: { ownerId: true },
const [mediaComments, mediaAssets] = await Promise.all([
db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
select: {
voiceUrl: true,
imageUrl: true,
version: {
select: {
video: {
select: {
project: {
select: {
workspace: {
select: { ownerId: true },
},
},
},
},
@@ -254,8 +279,15 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
},
},
},
},
});
}),
db.videoAsset.findMany({
where: { provider: 'R2_IMAGE' },
select: {
sourceUrl: true,
billedUserId: true,
},
}),
]);
for (const comment of mediaComments) {
const billedUserId = comment.version.video.project.workspace.ownerId;
@@ -291,6 +323,26 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
}
}
}
for (const asset of mediaAssets) {
const billedUserId = asset.billedUserId;
if (!billedUserId) continue;
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
const keyParts = asset.sourceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
if (!filename) continue;
const r2Key = `images/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (seenKeys.has(dedupeKey)) continue;
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].image += size;
userStorage[billedUserId].total += size;
}
} catch (err) {
console.error('Failed to parse user storage:', err);
}
+194
View File
@@ -0,0 +1,194 @@
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
export type BunnyDownloadSource = {
sourceType: 'original' | 'compressed';
quality: number | null;
url: string;
};
const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
const BUNNY_MAX_PROBE_CANDIDATES = 4;
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000;
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
type BunnyDownloadSourceCacheRecord = {
source: BunnyDownloadSource | null;
expiresAt: number;
};
const bunnyDownloadSourceCache = new Map<string, BunnyDownloadSourceCacheRecord>();
export function resolveBunnyCdnHostname(): string {
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
if (!raw) return DEFAULT_BUNNY_CDN_HOSTNAME;
try {
const parsed = new URL(raw);
return parsed.hostname || DEFAULT_BUNNY_CDN_HOSTNAME;
} catch {
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_CDN_HOSTNAME;
}
}
export async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BUNNY_REMOTE_FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
async function isRemoteFileAvailable(url: string): Promise<boolean> {
try {
const headRes = await fetchWithTimeout(url, { method: 'HEAD', cache: 'no-store' });
if (headRes.ok) return true;
if (headRes.status === 405) {
const rangeRes = await fetchWithTimeout(url, {
method: 'GET',
headers: { Range: 'bytes=0-0' },
cache: 'no-store',
});
return rangeRes.ok || rangeRes.status === 206;
}
return false;
} catch {
return false;
}
}
function buildBunnyOriginalUrl(videoId: string): string {
return `https://${resolveBunnyCdnHostname()}/${videoId}/original`;
}
function extractHeightFromBunnyMp4Url(url: string): number | null {
const match = url.match(/\/play_(\d+)p\.mp4$/);
if (!match?.[1]) return null;
const parsed = Number(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
const hostname = resolveBunnyCdnHostname();
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
let playlistHeights: number[] = [];
try {
const playlistRes = await fetchWithTimeout(playlistUrl, { cache: 'no-store' });
if (playlistRes.ok) {
const playlist = await playlistRes.text();
const matches = [...playlist.matchAll(/RESOLUTION=\d+x(\d+)/g)];
playlistHeights = matches
.map((match) => Number(match[1]))
.filter((height) => Number.isFinite(height) && BUNNY_ALLOWED_QUALITIES.has(height))
.sort((a, b) => b - a);
}
} catch {
// Fall through to static fallback list.
}
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])]
.slice(0, BUNNY_MAX_PROBE_CANDIDATES);
for (const height of candidateHeights) {
const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`;
if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl;
}
const fallbackHeight = candidateHeights[0] ?? 1080;
return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`;
}
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
const originalUrl = buildBunnyOriginalUrl(videoId);
if (await isRemoteFileAvailable(originalUrl)) {
return {
sourceType: 'original',
quality: null,
url: originalUrl,
};
}
return null;
}
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) {
const requestedUrl = `https://${resolveBunnyCdnHostname()}/${videoId}/play_${requestedQuality}p.mp4`;
if (await isRemoteFileAvailable(requestedUrl)) {
return {
sourceType: 'compressed',
quality: extractHeightFromBunnyMp4Url(requestedUrl),
url: requestedUrl,
};
}
}
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
return {
sourceType: 'compressed',
quality: extractHeightFromBunnyMp4Url(fallbackUrl),
url: fallbackUrl,
};
}
function buildSourceCacheKey(videoId: string, requestedQuality: number | null, preference: BunnyDownloadSourcePreference): string {
return `${videoId}:${requestedQuality ?? 'none'}:${preference}`;
}
function getCachedSource(cacheKey: string, now: number): BunnyDownloadSource | null | undefined {
const cached = bunnyDownloadSourceCache.get(cacheKey);
if (!cached) return undefined;
if (cached.expiresAt <= now) {
bunnyDownloadSourceCache.delete(cacheKey);
return undefined;
}
return cached.source;
}
function setCachedSource(cacheKey: string, source: BunnyDownloadSource | null, now: number): void {
bunnyDownloadSourceCache.set(cacheKey, {
source,
expiresAt: now + BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS,
});
}
export async function resolveBunnyDownloadSource(
videoId: string,
requestedQuality: number | null,
preference: BunnyDownloadSourcePreference
): Promise<BunnyDownloadSource | null> {
const now = Date.now();
const cacheKey = buildSourceCacheKey(videoId, requestedQuality, preference);
const cached = getCachedSource(cacheKey, now);
if (cached !== undefined) return cached;
let resolvedSource: BunnyDownloadSource | null;
if (preference === 'original') {
resolvedSource = await resolveBunnyOriginalSource(videoId);
setCachedSource(cacheKey, resolvedSource, now);
return resolvedSource;
}
if (preference === 'compressed') {
resolvedSource = await resolveBunnyCompressedSource(videoId, requestedQuality);
setCachedSource(cacheKey, resolvedSource, now);
return resolvedSource;
}
const originalSource = await resolveBunnyOriginalSource(videoId);
if (originalSource) {
setCachedSource(cacheKey, originalSource, now);
return originalSource;
}
resolvedSource = await resolveBunnyCompressedSource(videoId, requestedQuality);
setCachedSource(cacheKey, resolvedSource, now);
return resolvedSource;
}
+9 -4
View File
@@ -6,10 +6,11 @@ const GUEST_UPLOAD_TOKEN_TYPE = 'guest-upload';
const DEFAULT_GUEST_UPLOAD_TOKEN_TTL_SECONDS = 60 * 3;
const GUEST_UPLOAD_VIDEO_WINDOW_MS = 15 * 60 * 1000;
const GUEST_UPLOAD_VIDEO_MAX_REQUESTS = 12;
const GUEST_BUNNY_UPLOAD_VIDEO_MAX_REQUESTS = 4;
const GUEST_UPLOAD_SESSION_WINDOW_MS = 15 * 60 * 1000;
const GUEST_UPLOAD_SESSION_MAX_REQUESTS = 8;
export type GuestUploadIntent = 'audio' | 'image';
export type GuestUploadIntent = 'audio' | 'image' | 'bunny';
interface GuestUploadTokenPayload {
typ: typeof GUEST_UPLOAD_TOKEN_TYPE;
@@ -71,7 +72,7 @@ function isValidPayload(value: unknown): value is GuestUploadTokenPayload {
&& Number.isFinite(payload.iat)
&& typeof payload.exp === 'number'
&& Number.isFinite(payload.exp)
&& (payload.intent === 'audio' || payload.intent === 'image')
&& (payload.intent === 'audio' || payload.intent === 'image' || payload.intent === 'bunny')
&& typeof payload.ctx === 'string';
}
@@ -150,17 +151,21 @@ export async function enforceGuestUploadQuota(
);
}
const videoScopedMaxRequests = intent === 'bunny'
? GUEST_BUNNY_UPLOAD_VIDEO_MAX_REQUESTS
: GUEST_UPLOAD_VIDEO_MAX_REQUESTS;
const videoScoped = await checkRateLimit(
`${ip}:guest-upload:${intent}:video:${videoId}`,
`guest-upload-${intent}-video`,
{ windowMs: GUEST_UPLOAD_VIDEO_WINDOW_MS, maxRequests: GUEST_UPLOAD_VIDEO_MAX_REQUESTS }
{ windowMs: GUEST_UPLOAD_VIDEO_WINDOW_MS, maxRequests: videoScopedMaxRequests }
);
if (!videoScoped.allowed) {
return NextResponse.json(
{ error: 'Too many uploads for this video. Please wait before uploading again.' },
{
status: 429,
headers: rateLimitHeaders(videoScoped, GUEST_UPLOAD_VIDEO_MAX_REQUESTS),
headers: rateLimitHeaders(videoScoped, videoScopedMaxRequests),
}
);
}
+57 -21
View File
@@ -45,18 +45,30 @@ async function deleteMediaFiles(mediaUrls: string[]) {
* Collect all media URLs from comments under a given video (all versions).
*/
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { videoParentId: videoId },
},
select: { voiceUrl: true, imageUrl: true },
});
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { videoParentId: videoId },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
videoId,
provider: 'R2_IMAGE',
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
@@ -64,18 +76,30 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
* Collect all media URLs from comments under all videos in a project.
*/
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { projectId } },
},
select: { voiceUrl: true, imageUrl: true },
});
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { projectId } },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
provider: 'R2_IMAGE',
video: { projectId },
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
@@ -83,18 +107,30 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
* Collect all media URLs from comments under all projects in a workspace.
*/
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
const comments = await db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { project: { workspaceId } } },
},
select: { voiceUrl: true, imageUrl: true },
});
const [comments, assets] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }],
version: { video: { project: { workspaceId } } },
},
select: { voiceUrl: true, imageUrl: true },
}),
db.videoAsset.findMany({
where: {
provider: 'R2_IMAGE',
video: { project: { workspaceId } },
},
select: { sourceUrl: true },
}),
]);
const urls: string[] = [];
comments.forEach(c => {
if (c.voiceUrl) urls.push(c.voiceUrl);
if (c.imageUrl) urls.push(c.imageUrl);
});
assets.forEach((asset) => {
if (asset.sourceUrl) urls.push(asset.sourceUrl);
});
return urls;
}
+5
View File
@@ -36,6 +36,11 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
'asset-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
'asset-create': { 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-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
// Watch progress — allow frequent updates but prevent abuse
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
+155
View File
@@ -0,0 +1,155 @@
import type { NextRequest } from 'next/server';
import type { VideoAsset } from '@prisma/client';
import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { validateShareLinkAccess } from '@/lib/share-links';
const IMAGE_PROXY_PREFIX = '/api/upload/image/';
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
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;
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
export type VideoAssetAccessContext = {
video: {
id: string;
title: string;
projectId: string;
project: {
id: string;
name: string;
ownerId: string;
workspaceId: string;
visibility: string;
workspace: {
id: string;
ownerId: string;
};
};
};
hasViewAccess: boolean;
canUploadAssets: boolean;
canDownloadAssets: boolean;
canManageAssets: boolean;
viewerUserId: string | null;
viewerGuestIdentityId: string | null;
};
export function sanitizeAssetDisplayName(value: string | null | undefined, fallback: string): string {
const raw = typeof value === 'string' ? value : '';
const normalized = raw
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/[\[\]\(\)]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (normalized.length === 0) return fallback;
return normalized.slice(0, 200);
}
export function extractImageKeyFromProxyUrl(url: string): string | null {
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
if (!filename) return null;
return `images/${filename}`;
}
export function extractImageFileNameFromProxyUrl(url: string): string | null {
if (!SAFE_IMAGE_PROXY_PATH.test(url)) return null;
const filename = url.slice(IMAGE_PROXY_PREFIX.length);
return filename || null;
}
export function mediaUrlToR2Key(url: string): string | null {
if (url.includes(IMAGE_PROXY_PREFIX)) {
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
return filename ? `images/${filename}` : null;
}
if (url.includes(AUDIO_PROXY_PREFIX)) {
const filename = url.slice(url.indexOf(AUDIO_PROXY_PREFIX) + AUDIO_PROXY_PREFIX.length);
return filename ? `voice/${filename}` : null;
}
return null;
}
export function canDeleteAssetForViewer(
asset: Pick<VideoAsset, 'uploadedByUserId' | 'uploadedByGuestIdentityId'>,
viewer: Pick<VideoAssetAccessContext, 'canManageAssets' | 'viewerUserId' | 'viewerGuestIdentityId'>
): boolean {
if (viewer.canManageAssets) return true;
if (viewer.viewerUserId && asset.uploadedByUserId === viewer.viewerUserId) return true;
if (
!viewer.viewerUserId
&& viewer.viewerGuestIdentityId
&& asset.uploadedByGuestIdentityId
&& asset.uploadedByGuestIdentityId === viewer.viewerGuestIdentityId
) {
return true;
}
return false;
}
export async function getVideoAssetAccessContext(
request: NextRequest,
videoId: string,
requiredPermission: 'VIEW' | 'COMMENT' = 'VIEW'
): Promise<VideoAssetAccessContext | null> {
const session = await auth();
const video = await db.video.findUnique({
where: { id: videoId },
select: {
id: true,
title: true,
projectId: true,
project: {
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
workspace: {
select: {
id: true,
ownerId: true,
},
},
},
},
},
});
if (!video) return null;
const access = await checkProjectAccess(video.project, session?.user?.id);
const shareSession = getShareSessionFromRequest(request, video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: video.projectId,
videoId: video.id,
requiredPermission,
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false, link: null };
const hasViewAccess = access.hasAccess || shareAccess.hasAccess;
const canCommentWithMembership = !!session?.user?.id && access.hasAccess;
const canCommentWithShare = shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canUploadAssets = canCommentWithMembership || canCommentWithShare;
const canDownloadAssets = !!session?.user?.id && hasViewAccess;
const viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
return {
video,
hasViewAccess,
canUploadAssets,
canDownloadAssets,
canManageAssets: access.canEdit,
viewerUserId,
viewerGuestIdentityId,
};
}
+41
View File
@@ -29,6 +29,8 @@ model User {
workspaceMemberships WorkspaceMember[]
projects Project[]
comments Comment[]
uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy")
billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo")
projectMemberships ProjectMember[]
notificationSetting NotificationSetting?
watchProgress WatchProgress[]
@@ -65,6 +67,17 @@ enum DownloadEgressSource {
COMPRESSED
}
enum VideoAssetKind {
IMAGE
VIDEO
}
enum VideoAssetProvider {
R2_IMAGE
YOUTUBE
BUNNY
}
model DownloadEgressEvent {
id String @id @default(cuid())
versionId String
@@ -303,6 +316,7 @@ model Video {
// Relations
versions VideoVersion[]
assets VideoAsset[]
shareLinks ShareLink[]
@@index([projectId])
@@ -345,6 +359,33 @@ model VideoVersion {
@@map("video_versions")
}
model VideoAsset {
id String @id @default(cuid())
videoId String
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
kind VideoAssetKind
provider VideoAssetProvider
displayName String
sourceUrl String
providerVideoId String?
thumbnailUrl String?
uploadedByUserId String?
uploadedByUser User? @relation("VideoAssetUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
uploadedByGuestIdentityId String?
uploadedByGuestName String?
billedUserId String
billedUser User @relation("VideoAssetBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([videoId])
@@index([billedUserId])
@@index([provider])
@@index([providerVideoId])
@@index([videoId, createdAt(sort: Desc)])
@@map("video_assets")
}
model Comment {
id String @id @default(cuid())
+20 -8
View File
@@ -142,16 +142,28 @@ async function findReferencedVideoIds(videoIds: string[]): Promise<Set<string>>
const referenced = new Set<string>();
for (const group of chunk(videoIds, CHUNK_SIZE)) {
const rows = await db.videoVersion.findMany({
where: {
providerId: 'bunny',
videoId: { in: group },
},
select: { videoId: true },
});
rows.forEach((row) => {
const [versionRows, assetRows] = await Promise.all([
db.videoVersion.findMany({
where: {
providerId: 'bunny',
videoId: { in: group },
},
select: { videoId: true },
}),
db.videoAsset.findMany({
where: {
provider: 'BUNNY',
providerVideoId: { in: group },
},
select: { providerVideoId: true },
}),
]);
versionRows.forEach((row) => {
if (row.videoId) referenced.add(row.videoId);
});
assetRows.forEach((row) => {
if (row.providerVideoId) referenced.add(row.providerVideoId);
});
}
return referenced;
+8 -1
View File
@@ -79,7 +79,7 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
}).userFeedbackScreenshot;
for (const group of chunk(urls, CHUNK_SIZE)) {
const [commentRows, feedbackRows, feedbackAttachmentRows] = await Promise.all([
const [commentRows, feedbackRows, feedbackAttachmentRows, assetRows] = await Promise.all([
db.comment.findMany({
where: {
OR: [{ voiceUrl: { in: group } }, { imageUrl: { in: group } }],
@@ -99,6 +99,10 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
select: { url: true },
})
: Promise.resolve([] as Array<{ url: string }>),
db.videoAsset.findMany({
where: { sourceUrl: { in: group } },
select: { sourceUrl: true },
}),
]);
for (const row of commentRows) {
@@ -111,6 +115,9 @@ async function findReferencedUrls(urls: string[]): Promise<Set<string>> {
for (const row of feedbackAttachmentRows) {
if (row.url) referenced.add(row.url);
}
for (const row of assetRows) {
if (row.sourceUrl) referenced.add(row.sourceUrl);
}
}
return referenced;