diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index b44b20c..446023f 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -9,6 +9,7 @@ import { getShareSessionFromRequest } from '@/lib/share-session'; import { HeadObjectCommand } from '@aws-sdk/client-s3'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { ensureGuestIdentityFromRequest, getGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity'; +import { extractImageFileNameFromProxyUrl, sanitizeAssetDisplayName } from '@/lib/video-assets'; type RouteParams = { params: Promise<{ versionId: string }> }; const SAFE_IMAGE_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; @@ -184,7 +185,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) { include: { video: { include: { - project: true, + project: { + include: { + workspace: { + select: { + ownerId: true, + }, + }, + }, + }, }, }, }, @@ -266,35 +275,64 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const guestIdentity = isGuest ? ensureGuestIdentityFromRequest(request) : null; - const comment = await db.comment.create({ - data: { - content: content?.trim() || null, - timestamp: parsedTimestamp, - timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null, - parentId: parentId || null, - voiceUrl: voiceUrl || null, - voiceDuration: voiceDuration || null, - imageUrl: imageUrl || null, - annotationData: annotationData || null, - authorId: session?.user?.id || null, - guestName: isGuest ? guestName : null, - guestEmail: isGuest ? guestEmail : null, - guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, - tagId: tagId || null, - versionId, - }, - include: { - author: { select: { id: true, name: true, image: true } }, - tag: { select: { id: true, name: true, color: true } }, - replies: { - include: { - author: { select: { id: true, name: true, image: true } }, - tag: { select: { id: true, name: true, color: true } }, + // Use a transaction to create both comment and asset (if image is attached) + const result = await db.$transaction(async (tx) => { + const comment = await tx.comment.create({ + data: { + content: content?.trim() || null, + timestamp: parsedTimestamp, + timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null, + parentId: parentId || null, + voiceUrl: voiceUrl || null, + voiceDuration: voiceDuration || null, + imageUrl: imageUrl || null, + annotationData: annotationData || null, + authorId: session?.user?.id || null, + guestName: isGuest ? guestName : null, + guestEmail: isGuest ? guestEmail : null, + guestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, + tagId: tagId || null, + versionId, + }, + include: { + author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, + replies: { + include: { + author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, + }, }, }, - }, + }); + + // If an image was attached to the comment, also add it to the assets pane + if (imageUrl) { + const fileName = extractImageFileNameFromProxyUrl(imageUrl); + const displayName = sanitizeAssetDisplayName(null, fileName || 'Comment Image'); + const safeGuestName = sanitizeAssetDisplayName(guestName, 'Guest').slice(0, 80); + + await tx.videoAsset.create({ + data: { + videoId: version.video.id, + kind: 'IMAGE', + provider: 'R2_IMAGE', + displayName, + sourceUrl: imageUrl, + thumbnailUrl: imageUrl, + uploadedByUserId: session?.user?.id || null, + uploadedByGuestIdentityId: isGuest ? guestIdentity?.identityId ?? null : null, + uploadedByGuestName: isGuest ? safeGuestName : null, + billedUserId: project.workspace.ownerId, + }, + }); + } + + return comment; }); + const comment = result; + // Notify project owner (fire-and-forget, skip self-notifications) const commentAuthorName = session?.user?.name || guestName || 'Someone'; const isOwnProject = session?.user?.id === project.ownerId; diff --git a/app/api/versions/[versionId]/download/route.ts b/app/api/versions/[versionId]/download/route.ts index d89c6dc..497fa86 100644 --- a/app/api/versions/[versionId]/download/route.ts +++ b/app/api/versions/[versionId]/download/route.ts @@ -18,7 +18,6 @@ type BunnyDownloadSource = { 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_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000; const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000; const SAFE_DOWNLOAD_CONTENT_TYPE = 'application/octet-stream'; @@ -152,17 +151,14 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise { // Continue with static fallback list below. } - const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])] - .slice(0, BUNNY_MAX_PROBE_CANDIDATES); + const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])]; for (const height of candidateHeights) { const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`; if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl; } - // Last-resort fallback - const fallbackHeight = candidateHeights[0] ?? 1080; - return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`; + return ''; } async function resolveBunnyOriginalSource(videoId: string): Promise { diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 70ebbb2..8576efc 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -154,6 +154,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi activeDownloadAssetId, hasMoreAssets, isLoadingMoreAssets, + fetchAssets, loadMoreAssets, createAsset, deleteAsset, @@ -473,6 +474,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi annotationCanvasRef, editAnnotationCanvasRef, fetchVersionComments, + fetchAssets, }); const commentMarkers = useMemo(() => { diff --git a/components/video-page/hooks/use-comment-actions.ts b/components/video-page/hooks/use-comment-actions.ts index 44f0faf..7772f51 100644 --- a/components/video-page/hooks/use-comment-actions.ts +++ b/components/video-page/hooks/use-comment-actions.ts @@ -37,6 +37,7 @@ interface UseCommentActionsParams extends CommentActionsConfig { annotationCanvasRef: RefObject; editAnnotationCanvasRef: RefObject; fetchVersionComments: (versionId: string, useEtag: boolean) => Promise; + fetchAssets: () => Promise; } export function useCommentActions({ @@ -61,6 +62,7 @@ export function useCommentActions({ annotationCanvasRef, editAnnotationCanvasRef, fetchVersionComments, + fetchAssets, }: UseCommentActionsParams) { const [commentText, setCommentText] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); @@ -221,6 +223,11 @@ export function useCommentActions({ ), }; }); + + // If an image was attached, refresh the assets list + if (imageData) { + void fetchAssets(); + } } else { setVideo((prev) => { if (!prev) return prev; @@ -274,6 +281,7 @@ export function useCommentActions({ setIsAnnotating, setViewingAnnotation, setVideo, + fetchAssets, ]); const handleImageSelect = useCallback((e: ChangeEvent, isReply: boolean = false) => { @@ -620,6 +628,11 @@ export function useCommentActions({ ), }; }); + + // If an image was attached, refresh the assets list + if (submittedImageData) { + void fetchAssets(); + } } else { setVideo((prev) => { if (!prev) return prev; @@ -666,7 +679,7 @@ export function useCommentActions({ setIsUploadingReplyImage(false); isMutatingRef.current = false; } - }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, normalizedGuestName, currentUserName, replyImageBlob, videoId, getGuestUploadToken, setVideo]); + }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, normalizedGuestName, currentUserName, replyImageBlob, videoId, getGuestUploadToken, setVideo, fetchAssets]); const startReplyRecording = useCallback(async () => { try { diff --git a/lib/bunny-download.ts b/lib/bunny-download.ts index 73e98d8..5a17e5d 100644 --- a/lib/bunny-download.ts +++ b/lib/bunny-download.ts @@ -10,7 +10,6 @@ export type BunnyDownloadSource = { 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; @@ -88,16 +87,14 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise { // Fall through to static fallback list. } - const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])] - .slice(0, BUNNY_MAX_PROBE_CANDIDATES); + const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])]; 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`; + return ''; } async function resolveBunnyOriginalSource(videoId: string): Promise {