diff --git a/.env.example b/.env.example index 318d355bc..9e67c7ec7 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,8 @@ NEXTAUTH_SECRET= # Audio transcription # DEEPGRAM_API_KEY= +# Cap AI +# OPENAI_API_KEY= # Only needed by cap.so cloud # NEXT_LOOPS_KEY= diff --git a/Cargo.lock b/Cargo.lock index f521f0bb9..2da95d142 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -964,6 +964,7 @@ dependencies = [ "nix 0.29.0", "objc", "objc2-app-kit 0.3.0", + "percent-encoding", "png", "rand 0.8.5", "relative-path", diff --git a/Cargo.toml b/Cargo.toml index 246e5f3ea..716aa8863 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ cidre = { git = "https://github.com/yury/cidre", rev = "ef04aaabe14ffbbce4a33097 windows = "0.58.0" windows-sys = "0.59.0" windows-capture = "1.4.2" +percent-encoding = "2.3.1" [workspace.lints.clippy] dbg_macro = "deny" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 004892db7..14f40f18c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -76,6 +76,7 @@ cpal.workspace = true keyed_priority_queue = "0.4.2" sentry.workspace = true clipboard-rs = "0.2.2" +percent-encoding = { workspace = true } cap-utils = { path = "../../../crates/utils" } cap-project = { path = "../../../crates/project" } diff --git a/apps/web/actions/videos/generate-ai-metadata.ts b/apps/web/actions/videos/generate-ai-metadata.ts new file mode 100644 index 000000000..20360a47d --- /dev/null +++ b/apps/web/actions/videos/generate-ai-metadata.ts @@ -0,0 +1,258 @@ +"use server"; + +import { GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { db } from "@cap/database"; +import { s3Buckets, videos, users } from "@cap/database/schema"; +import { VideoMetadata } from "@cap/database/types"; +import { eq } from "drizzle-orm"; +import { serverEnv } from "@cap/env"; +import { createS3Client } from "@/utils/s3"; +import { isAiGenerationEnabled } from "@/utils/flags"; + +export async function generateAiMetadata(videoId: string, userId: string) { + + if (!serverEnv().OPENAI_API_KEY) { + console.error("[generateAiMetadata] Missing OpenAI API key, skipping AI metadata generation"); + return; + } + const userQuery = await db() + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (userQuery.length === 0 || !userQuery[0]) { + console.error(`[generateAiMetadata] User ${userId} not found for feature flag check`); + return; + } + + const user = userQuery[0]; + if (!isAiGenerationEnabled(user)) { + return; + } + const videoQuery = await db() + .select({ video: videos }) + .from(videos) + .where(eq(videos.id, videoId)); + + if (videoQuery.length === 0 || !videoQuery[0]?.video) { + console.error(`[generateAiMetadata] Video ${videoId} not found in database`); + return; + } + + const videoData = videoQuery[0].video; + const metadata = videoData.metadata as VideoMetadata || {}; + + if (metadata.aiProcessing === true) { + + const updatedAtTime = new Date(videoData.updatedAt).getTime(); + const currentTime = new Date().getTime(); + const tenMinutesInMs = 10 * 60 * 1000; + const minutesElapsed = Math.round((currentTime - updatedAtTime) / 60000); + + if (currentTime - updatedAtTime > tenMinutesInMs) { + await db() + .update(videos) + .set({ + metadata: { + ...metadata, + aiProcessing: false, + generationError: null + } + }) + .where(eq(videos.id, videoId)); + + metadata.aiProcessing = false; + metadata.generationError = null; + } else { + return; + } + } + + if (metadata.summary || metadata.chapters) { + + if (metadata.aiProcessing) { + await db() + .update(videos) + .set({ + metadata: { + ...metadata, + aiProcessing: false + } + }) + .where(eq(videos.id, videoId)); + } + return; + } + + if (videoData?.transcriptionStatus !== "COMPLETE") { + + if (metadata.aiProcessing) { + await db() + .update(videos) + .set({ + metadata: { + ...metadata, + aiProcessing: false + } + }) + .where(eq(videos.id, videoId)); + } + return; + } + + try { + await db() + .update(videos) + .set({ + metadata: { + ...metadata, + aiProcessing: true + } + }) + .where(eq(videos.id, videoId)); + const query = await db() + .select({ video: videos, bucket: s3Buckets }) + .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) + .where(eq(videos.id, videoId)); + + if (query.length === 0 || !query[0]) { + console.error(`[generateAiMetadata] Video data not found for ${videoId}`); + throw new Error(`Video data not found for ${videoId}`); + } + + const row = query[0]; + if (!row || !row.video) { + console.error(`[generateAiMetadata] Video record not found for ${videoId}`); + throw new Error(`Video record not found for ${videoId}`); + } + + const { video, bucket } = row; + + const awsBucket = video.awsBucket; + if (!awsBucket) { + console.error(`[generateAiMetadata] AWS bucket not found for video ${videoId}`); + throw new Error(`AWS bucket not found for video ${videoId}`); + } + const [s3Client] = await createS3Client(bucket); + + const transcriptKey = `${userId}/${videoId}/transcription.vtt`; + const transcriptUrl = await getSignedUrl( + s3Client, + new GetObjectCommand({ + Bucket: awsBucket, + Key: transcriptKey, + }) + ); + const res = await fetch(transcriptUrl); + if (!res.ok) { + console.error(`[generateAiMetadata] Failed to fetch transcript: ${res.status} ${res.statusText}`); + throw new Error(`Failed to fetch transcript: ${res.status} ${res.statusText}`); + } + + const vtt = await res.text(); + + if (!vtt || vtt.length < 10) { + console.error(`[generateAiMetadata] Transcript is empty or too short (${vtt.length} chars)`); + throw new Error("Transcript is empty or too short"); + } + + const transcriptText = vtt + .split("\n") + .filter( + (l) => + l.trim() && + l !== "WEBVTT" && + !/^\d+$/.test(l.trim()) && + !l.includes("-->") + ) + .join(" "); + + const prompt = `You are Cap AI. Summarize the transcript and provide JSON in the following format: +{ + "title": "string", + "summary": "string (write from 1st person perspective if appropriate, e.g. 'In this video, I demonstrate...' to make it feel personable)", + "chapters": [{"title": "string", "start": number}] +} +Transcript: +${transcriptText}`; + const aiRes = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${serverEnv().OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }), + }); + if (!aiRes.ok) { + const errorText = await aiRes.text(); + console.error(`[generateAiMetadata] OpenAI API error: ${aiRes.status} ${errorText}`); + throw new Error(`OpenAI API error: ${aiRes.status} ${errorText}`); + } + + const aiJson = await aiRes.json(); + const content = aiJson.choices?.[0]?.message?.content || "{}"; + + let data: { title?: string; summary?: string; chapters?: { title: string; start: number }[] } = {}; + try { + data = JSON.parse(content); + } catch (e) { + console.error(`[generateAiMetadata] Error parsing OpenAI response: ${e}`); + data = { + title: "Generated Title", + summary: "The AI was unable to generate a proper summary for this content.", + chapters: [] + }; + } + + const currentMetadata: VideoMetadata = (video.metadata as VideoMetadata) || {}; + const updatedMetadata: VideoMetadata = { + ...currentMetadata, + aiTitle: data.title || currentMetadata.aiTitle, + summary: data.summary || currentMetadata.summary, + chapters: data.chapters || currentMetadata.chapters, + aiProcessing: false, + }; + + await db() + .update(videos) + .set({ metadata: updatedMetadata }) + .where(eq(videos.id, videoId)); + + if (video.name?.startsWith("Cap Recording -") && data.title) { + await db() + .update(videos) + .set({ name: data.title }) + .where(eq(videos.id, videoId)); + } + } catch (error) { + console.error(`[generateAiMetadata] Error for video ${videoId}:`, error); + + try { + const currentVideo = await db().select().from(videos).where(eq(videos.id, videoId)); + if (currentVideo.length > 0 && currentVideo[0]) { + const currentMetadata: VideoMetadata = (currentVideo[0].metadata as VideoMetadata) || {}; + await db() + .update(videos) + .set({ + metadata: { + ...currentMetadata, + aiProcessing: false, + generationError: error instanceof Error ? error.message : String(error) + } + }) + .where(eq(videos.id, videoId)); + } + } catch (updateError) { + console.error(`[generateAiMetadata] Failed to reset processing flag:`, updateError); + } + } +} diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index 4f2b76a5c..fe07fa64d 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -22,10 +22,8 @@ export async function getVideoAnalytics(videoId: string) { return { count: analytics }; } catch (error: any) { if (error.code === "not_found") { - // Return 0 views if link not found instead of throwing an error return { count: 0 }; } - console.error("Error fetching video analytics:", error); return { count: 0 }; } } diff --git a/apps/web/actions/videos/transcribe.ts b/apps/web/actions/videos/transcribe.ts index de7145197..55340129b 100644 --- a/apps/web/actions/videos/transcribe.ts +++ b/apps/web/actions/videos/transcribe.ts @@ -2,10 +2,12 @@ import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { createClient } from "@deepgram/sdk"; import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; +import { s3Buckets, videos, users } from "@cap/database/schema"; import { eq } from "drizzle-orm"; import { createS3Client } from "@/utils/s3"; import { serverEnv } from "@cap/env"; +import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; +import { isAiGenerationEnabled } from "@/utils/flags"; type TranscribeResult = { success: boolean; @@ -112,11 +114,37 @@ export async function transcribeVideo( .set({ transcriptionStatus: "COMPLETE" }) .where(eq(videos.id, videoId)); + console.log(`[transcribeVideo] Transcription completed for video ${videoId}, checking AI generation feature flag`); + + const userQuery = await db() + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (userQuery.length > 0 && userQuery[0] && isAiGenerationEnabled(userQuery[0])) { + console.log(`[transcribeVideo] AI generation feature enabled, triggering AI metadata generation for video ${videoId}`); + try { + generateAiMetadata(videoId, userId).catch(error => { + console.error(`[transcribeVideo] Error generating AI metadata for video ${videoId}:`, error); + }); + } catch (error) { + console.error(`[transcribeVideo] Error starting AI metadata generation for video ${videoId}:`, error); + } + } else { + const user = userQuery[0]; + console.log(`[transcribeVideo] AI generation feature disabled for user ${userId} (email: ${user?.email}, pro: ${user?.stripeSubscriptionStatus})`); + } + return { success: true, message: "VTT file generated and uploaded successfully", }; } catch (error) { + console.error("Error transcribing video:", error); await db() .update(videos) .set({ transcriptionStatus: "ERROR" }) @@ -174,6 +202,7 @@ function formatTimestamp(seconds: number): string { } async function transcribeAudio(videoUrl: string): Promise { + console.log("[transcribeAudio] Starting transcription for URL:", videoUrl); const deepgram = createClient(serverEnv().DEEPGRAM_API_KEY as string); const { result, error } = await deepgram.listen.prerecorded.transcribeUrl( @@ -190,10 +219,13 @@ async function transcribeAudio(videoUrl: string): Promise { ); if (error) { + console.error("[transcribeAudio] Deepgram transcription error:", error); return ""; } + console.log("[transcribeAudio] Transcription result received, formatting to WebVTT"); const captions = formatToWebVTT(result); + console.log("[transcribeAudio] Transcription complete, returning captions"); return captions; } diff --git a/apps/web/app/api/desktop/video/app.ts b/apps/web/app/api/desktop/video/app.ts index 70748525f..54ee08475 100644 --- a/apps/web/app/api/desktop/video/app.ts +++ b/apps/web/app/api/desktop/video/app.ts @@ -146,4 +146,4 @@ app.get( aws_bucket: bucketName, }); } -); +); \ No newline at end of file diff --git a/apps/web/app/api/video/ai/route.ts b/apps/web/app/api/video/ai/route.ts new file mode 100644 index 000000000..a518b2a45 --- /dev/null +++ b/apps/web/app/api/video/ai/route.ts @@ -0,0 +1,103 @@ +import { NextRequest } from "next/server"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { db } from "@cap/database"; +import { videos, users } from "@cap/database/schema"; +import { VideoMetadata } from "@cap/database/types"; +import { eq } from "drizzle-orm"; +import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; +import { isAiGenerationEnabled } from "@/utils/flags"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + const url = new URL(request.url); + const videoId = url.searchParams.get("videoId"); + + if (!user) { + return Response.json({ auth: false }, { status: 401 }); + } + + if (!videoId) { + return Response.json({ error: true, message: "Video ID not provided" }, { status: 400 }); + } + + const result = await db().select().from(videos).where(eq(videos.id, videoId)); + if (result.length === 0 || !result[0]) { + return Response.json({ error: true, message: "Video not found" }, { status: 404 }); + } + + const video = result[0]; + const metadata: VideoMetadata = (video.metadata as VideoMetadata) || {}; + + // If we have AI data, return it + if (metadata.summary || metadata.chapters) { + console.log(`[AI API] Returning existing AI metadata for video ${videoId}`); + return Response.json( + { + processing: false, + title: metadata.aiTitle ?? null, + summary: metadata.summary ?? null, + chapters: metadata.chapters ?? null, + }, + { status: 200 } + ); + } + + if (metadata.aiProcessing) { + console.log(`[AI API] AI processing already in progress for video ${videoId}`); + return Response.json({ + processing: true, + message: "AI metadata generation in progress" + }, { status: 200 }); + } + + if (video.transcriptionStatus !== "COMPLETE") { + return Response.json({ + processing: false, + message: `Cannot generate AI metadata - transcription status: ${video.transcriptionStatus || "unknown"}` + }, { status: 200 }); + } + + const videoOwnerQuery = await db() + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus + }) + .from(users) + .where(eq(users.id, video.ownerId)) + .limit(1); + + if (videoOwnerQuery.length === 0 || !videoOwnerQuery[0] || !isAiGenerationEnabled(videoOwnerQuery[0])) { + const videoOwner = videoOwnerQuery[0]; + return Response.json({ + processing: false, + message: "AI generation feature is not available for this user" + }, { status: 403 }); + } + + try { + generateAiMetadata(videoId, video.ownerId).catch(error => { + console.error("[AI API] Error generating AI metadata:", error); + }); + + return Response.json({ + processing: true, + message: "AI metadata generation started" + }, { status: 200 }); + } catch (error) { + console.error("[AI API] Error starting AI metadata generation:", error); + return Response.json({ + processing: false, + error: "Failed to start AI metadata generation" + }, { status: 500 }); + } + } catch (error) { + console.error("[AI API] Unexpected error:", error); + return Response.json({ + processing: false, + error: "An unexpected error occurred" + }, { status: 500 }); + } +} diff --git a/apps/web/app/api/video/status/route.ts b/apps/web/app/api/video/status/route.ts new file mode 100644 index 000000000..5c93716d3 --- /dev/null +++ b/apps/web/app/api/video/status/route.ts @@ -0,0 +1,170 @@ +import { NextRequest } from "next/server"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { db } from "@cap/database"; +import { videos, users } from "@cap/database/schema"; +import { VideoMetadata } from "@cap/database/types"; +import { eq } from "drizzle-orm"; +import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; +import { transcribeVideo } from "@/actions/videos/transcribe"; +import { isAiGenerationEnabled } from "@/utils/flags"; + +export const dynamic = "force-dynamic"; + +const MAX_AI_PROCESSING_TIME = 10 * 60 * 1000; + +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + const url = new URL(request.url); + const videoId = url.searchParams.get("videoId"); + + if (!user) { + return Response.json({ auth: false }, { status: 401 }); + } + + if (!videoId) { + return Response.json({ error: true, message: "Video ID not provided" }, { status: 400 }); + } + + const result = await db().select().from(videos).where(eq(videos.id, videoId)); + if (result.length === 0 || !result[0]) { + return Response.json({ error: true, message: "Video not found" }, { status: 404 }); + } + + const video = result[0]; + const metadata: VideoMetadata = (video.metadata as VideoMetadata) || {}; + + // Trigger transcription if it hasn't started yet + if (!video.transcriptionStatus || video.transcriptionStatus === "ERROR") { + console.log(`[Status API] Transcription not started for video ${videoId}, triggering transcription`); + try { + // Trigger transcription in the background + transcribeVideo(videoId, video.ownerId).catch(error => { + console.error(`[Status API] Error starting transcription for video ${videoId}:`, error); + }); + + return Response.json({ + transcriptionStatus: "PROCESSING", + aiProcessing: false, + aiTitle: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + }, { status: 200 }); + } catch (error) { + console.error(`[Status API] Error triggering transcription for video ${videoId}:`, error); + } + } + + if (metadata.aiProcessing) { + const updatedAtTime = new Date(video.updatedAt).getTime(); + const currentTime = new Date().getTime(); + + if (currentTime - updatedAtTime > MAX_AI_PROCESSING_TIME) { + console.log(`[Status API] AI processing appears stuck for video ${videoId} (${Math.round((currentTime - updatedAtTime) / 60000)} minutes), resetting flag`); + + await db() + .update(videos) + .set({ + metadata: { + ...metadata, + aiProcessing: false, + generationError: "AI processing timed out and was reset" + } + }) + .where(eq(videos.id, videoId)); + + const updatedResult = await db().select().from(videos).where(eq(videos.id, videoId)); + if (updatedResult.length > 0 && updatedResult[0]) { + const updatedVideo = updatedResult[0]; + const updatedMetadata = updatedVideo.metadata as VideoMetadata || {}; + + return Response.json({ + transcriptionStatus: updatedVideo.transcriptionStatus || null, + aiProcessing: false, + aiTitle: updatedMetadata.aiTitle || null, + summary: updatedMetadata.summary || null, + chapters: updatedMetadata.chapters || null, + error: "AI processing timed out and was reset" + }, { status: 200 }); + } + } + } + + if ( + video.transcriptionStatus === "COMPLETE" && + !metadata.aiProcessing && + !metadata.summary && + !metadata.chapters && + !metadata.generationError + ) { + console.log(`[Status API] Transcription complete but no AI data, checking feature flag for video owner ${video.ownerId}`); + + const videoOwnerQuery = await db() + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus + }) + .from(users) + .where(eq(users.id, video.ownerId)) + .limit(1); + + if (videoOwnerQuery.length > 0 && videoOwnerQuery[0] && isAiGenerationEnabled(videoOwnerQuery[0])) { + console.log(`[Status API] Feature flag enabled, triggering AI generation for video ${videoId}`); + + (async () => { + try { + console.log(`[Status API] Starting AI metadata generation for video ${videoId}`); + await generateAiMetadata(videoId, video.ownerId); + console.log(`[Status API] AI metadata generation completed for video ${videoId}`); + } catch (error) { + console.error(`[Status API] Error generating AI metadata for video ${videoId}:`, error); + + try { + const currentVideo = await db().select().from(videos).where(eq(videos.id, videoId)); + if (currentVideo.length > 0 && currentVideo[0]) { + const currentMetadata = (currentVideo[0].metadata as VideoMetadata) || {}; + await db() + .update(videos) + .set({ + metadata: { + ...currentMetadata, + aiProcessing: false, + generationError: error instanceof Error ? error.message : String(error) + } + }) + .where(eq(videos.id, videoId)); + } + } catch (resetError) { + console.error(`[Status API] Failed to reset AI processing flag for video ${videoId}:`, resetError); + } + } + })(); + + return Response.json({ + transcriptionStatus: video.transcriptionStatus || null, + aiProcessing: true, + aiTitle: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + }, { status: 200 }); + } else { + const videoOwner = videoOwnerQuery[0]; + console.log(`[Status API] AI generation feature disabled for video owner ${video.ownerId} (email: ${videoOwner?.email}, pro: ${videoOwner?.stripeSubscriptionStatus})`); + } + } + + return Response.json({ + transcriptionStatus: video.transcriptionStatus || null, + aiProcessing: metadata.aiProcessing || false, + aiTitle: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + }, { status: 200 }); + } catch (error) { + console.error("Error in video status endpoint:", error); + return Response.json({ + error: true, + message: "An unexpected error occurred" + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/apps/web/app/api/video/transcribe/status/route.ts b/apps/web/app/api/video/transcribe/status/route.ts index bdcb93c99..72b910178 100644 --- a/apps/web/app/api/video/transcribe/status/route.ts +++ b/apps/web/app/api/video/transcribe/status/route.ts @@ -4,6 +4,8 @@ import { NextRequest } from "next/server"; import { count, eq } from "drizzle-orm"; import { db } from "@cap/database"; import { videos } from "@cap/database/schema"; +import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; +import { isAiGenerationEnabled } from "@/utils/flags"; export const dynamic = "force-dynamic"; @@ -32,6 +34,18 @@ export async function GET(request: NextRequest) { ); } + if (video[0].transcriptionStatus === "COMPLETE") { + if (isAiGenerationEnabled(user)) { + Promise.resolve().then(() => { + generateAiMetadata(videoId, user.id).catch(error => { + console.error("Error generating AI metadata:", error); + }); + }); + } else { + console.log(`[transcribe-status] AI generation feature disabled for user ${user.id} (email: ${user.email}, pro: ${user.stripeSubscriptionStatus})`); + } + } + return Response.json( { transcriptionStatus: video[0].transcriptionStatus }, { status: 200 } diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index d084a7033..e5d8dc1f9 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -10,16 +10,18 @@ import { ShareVideo } from "./_components/ShareVideo"; import { Sidebar } from "./_components/Sidebar"; import { Toolbar } from "./_components/Toolbar"; +const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; +}; + type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; }; -interface Analytics { - views: number; - comments: number; - reactions: number; -} - type VideoWithOrganizationInfo = typeof videos.$inferSelect & { organizationMembers?: string[]; organizationId?: string; @@ -38,6 +40,14 @@ interface ShareProps { customDomain: string | null; domainVerified: boolean; userOrganizations?: { id: string; name: string }[]; + initialAiData?: { + title?: string | null; + summary?: string | null; + chapters?: { title: string; start: number }[] | null; + processing?: boolean; + } | null; + aiGenerationEnabled: boolean; + aiUiEnabled: boolean; } export const Share: React.FC = ({ @@ -48,13 +58,175 @@ export const Share: React.FC = ({ customDomain, domainVerified, userOrganizations = [], + initialAiData, + aiGenerationEnabled, + aiUiEnabled, }) => { const [analytics, setAnalytics] = useState(initialAnalytics); - const effectiveDate = data.metadata?.customCreatedAt + const effectiveDate: Date = data.metadata?.customCreatedAt ? new Date(data.metadata.customCreatedAt) : data.createdAt; const videoRef = useRef(null); + const [transcriptionStatus, setTranscriptionStatus] = useState( + data.transcriptionStatus || null + ); + + useEffect(() => { + if (initialAiData) { + } else { + } + }, [initialAiData]); + + const [aiData, setAiData] = useState<{ + title?: string | null; + summary?: string | null; + chapters?: { title: string; start: number }[] | null; + processing?: boolean; + } | null>(initialAiData || null); + + const shouldShowLoading = () => { + if (!aiGenerationEnabled) { + return false; + } + + if ( + !transcriptionStatus || + transcriptionStatus === "PROCESSING" || + transcriptionStatus === "ERROR" + ) { + return true; + } + + if (transcriptionStatus === "COMPLETE") { + if (!initialAiData || initialAiData.processing === true) { + return true; + } + if (!initialAiData.summary && !initialAiData.chapters) { + return true; + } + } + + return false; + }; + + const [aiLoading, setAiLoading] = useState(shouldShowLoading()); + + const aiDataRef = useRef(aiData); + useEffect(() => { + aiDataRef.current = aiData; + }, [aiData]); + + useEffect(() => { + let active = true; + let pollInterval: NodeJS.Timeout | null = null; + let pollCount = 0; + const MAX_POLLS = 300; + const POLL_INTERVAL = 2000; + + const shouldPoll = () => { + if (pollCount >= MAX_POLLS) { + return false; + } + + if (!transcriptionStatus || transcriptionStatus === "PROCESSING") { + return true; + } + + if (transcriptionStatus === "ERROR") { + return true; + } + + if (transcriptionStatus === "COMPLETE") { + if (!aiGenerationEnabled) { + return false; + } + + const currentAiData = aiDataRef.current; + + if (!currentAiData || currentAiData.processing) { + return true; + } + + if (!currentAiData.summary && !currentAiData.chapters) { + return true; + } + + return false; + } + + return false; + }; + + const pollStatus = async () => { + if (!active) return; + + pollCount++; + + try { + const res = await fetch(`/api/video/status?videoId=${data.id}`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + const json = await res.json(); + + if (!active) return; + + if ( + json.transcriptionStatus && + json.transcriptionStatus !== transcriptionStatus + ) { + setTranscriptionStatus(json.transcriptionStatus); + } + + const hasAiData = json.summary || json.chapters || json.aiTitle; + if (hasAiData) { + const newAiData = { + title: json.aiTitle || null, + summary: json.summary || null, + chapters: json.chapters || null, + processing: json.aiProcessing || false, + }; + setAiData(newAiData); + setAiLoading(json.aiProcessing || false); + } else if (json.aiProcessing) { + setAiData((prev) => ({ ...prev, processing: true })); + setAiLoading(true); + } else if ( + json.transcriptionStatus === "COMPLETE" && + !json.aiProcessing + ) { + setAiData((prev) => ({ ...prev, processing: false })); + } + + if (!shouldPoll()) { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + setAiLoading(false); + } + } catch (err) { + console.error("[Share] Error polling video status:", err); + } + }; + + if (shouldPoll()) { + pollStatus(); + + pollInterval = setInterval(pollStatus, POLL_INTERVAL); + } else { + setAiLoading(false); + } + + return () => { + active = false; + if (pollInterval) { + clearInterval(pollInterval); + } + }; + }, [data.id, transcriptionStatus]); const handleSeek = (time: number) => { if (videoRef.current) { @@ -87,11 +259,16 @@ export const Share: React.FC = ({ })); }, [comments]); + const headerData = + aiData && aiData.title && !aiData.processing + ? { ...data, name: aiData.title, createdAt: effectiveDate } + : { ...data, createdAt: effectiveDate }; + return (
= ({
@@ -117,12 +296,19 @@ export const Share: React.FC = ({
@@ -130,6 +316,83 @@ export const Share: React.FC = ({
+ +
+ {aiLoading && + (transcriptionStatus === "PROCESSING" || + transcriptionStatus === "COMPLETE" || + transcriptionStatus === "ERROR") && ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+
+
+ )} + + {!aiLoading && + (aiData?.summary || + (aiData?.chapters && aiData.chapters.length > 0)) && ( +
+ {aiData?.summary && ( + <> +

Summary

+
+ + Generated by Cap AI + +
+

+ {aiData.summary} +

+ + )} + + {aiData?.chapters && aiData.chapters.length > 0 && ( +
+

Chapters

+
+ {aiData.chapters.map((chapter) => ( +
handleSeek(chapter.start)} + > + + {formatTime(chapter.start)} + + + {chapter.title} + +
+ ))} +
+
+ )} +
+ )} +
diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 3512b5d04..730b61d97 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -5,7 +5,7 @@ import { userSelectProps } from "@cap/database/auth/session"; import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { toast } from "sonner"; import { Copy, Globe2 } from "lucide-react"; import { buildEnv, NODE_ENV } from "@cap/env"; @@ -44,6 +44,10 @@ export const ShareHeader = ({ const { webUrl } = usePublicEnv(); + useEffect(() => { + setTitle(data.name); + }, [data.name]); + const handleBlur = async () => { setIsEditing(false); diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index d54f6be90..f133c4ae3 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -43,22 +43,20 @@ const formatTime = (time: number) => { .padStart(2, "0")}`; }; -// million-ignore -// Add this type definition at the top of the file type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; }; -// Update the component props type export const ShareVideo = forwardRef< HTMLVideoElement, { data: typeof videos.$inferSelect; user: typeof userSelectProps | null; comments: CommentWithAuthor[]; + chapters?: { title: string; start: number }[]; + aiProcessing?: boolean; } ->(({ data, user, comments }, ref) => { - // Forward the ref to the video element +>(({ data, user, comments, chapters = [], aiProcessing = false }, ref) => { useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement); const videoRef = useRef(null); @@ -73,41 +71,34 @@ export const ShareVideo = forwardRef< const [overlayVisible, setOverlayVisible] = useState(true); const [subtitles, setSubtitles] = useState([]); const [subtitlesVisible, setSubtitlesVisible] = useState(true); - const [isTranscriptionProcessing, setIsTranscriptionProcessing] = - useState(false); + const [isTranscriptionProcessing, setIsTranscriptionProcessing] = useState( + data.transcriptionStatus === "PROCESSING" + ); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [tempOverlayVisible, setTempOverlayVisible] = useState(false); - // Scrubbing preview states const [showPreview, setShowPreview] = useState(false); const [previewTime, setPreviewTime] = useState(0); const [previewPosition, setPreviewPosition] = useState(0); const [previewLoaded, setPreviewLoaded] = useState(false); const [previewWidth, setPreviewWidth] = useState(160); const [previewHeight, setPreviewHeight] = useState(90); - // Track if we're actually showing MP4 content that supports thumbnails const [isMP4Source, setIsMP4Source] = useState(false); - // Store the current preview image URL const [thumbnailUrl, setThumbnailUrl] = useState(null); const [videoSpeed, setVideoSpeed] = useState(1); - const [isHovering, setIsHovering] = useState(false); - const hideControlsTimeoutRef = useRef(null); - const [forceHideControls, setForceHideControls] = useState(false); + const [isHoveringVideo, setIsHoveringVideo] = useState(false); const [isHoveringControls, setIsHoveringControls] = useState(false); - const enterControlsTimeoutRef = useRef(null); + const hideControlsTimeoutRef = useRef(null); - // Add to the state variables section const [scrubbingVideo, setScrubbingVideo] = useState( null ); - // Simplify state variables and refs - remove the throttling mechanism that's causing issues const [isPreviewSeeking, setIsPreviewSeeking] = useState(false); const lastUpdateTimeRef = useRef(0); const lastMousePosRef = useRef(0); - // Add a state to track if we're on a large screen const [isLargeScreen, setIsLargeScreen] = useState(false); useEffect(() => { @@ -120,25 +111,19 @@ export const ShareVideo = forwardRef< } }, [ref]); - // Initialize thumbnail preview capability useEffect(() => { if (!videoMetadataLoaded) return; - // Only enable preview for desktopMP4 sources setIsMP4Source(data.source.type === "desktopMP4"); - // Pre-fetch the first thumbnail to check if it exists if (data.source.type === "desktopMP4") { const thumbUrl = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&thumbnailTime=0`; - // Check if the thumbnail exists fetch(thumbUrl, { method: "HEAD" }) .then((response) => { if (response.ok) { - console.log("Thumbnails available for this video"); setIsMP4Source(true); } else { - console.log("No thumbnails available for this video"); setIsMP4Source(false); } }) @@ -151,78 +136,84 @@ export const ShareVideo = forwardRef< const showControls = () => { setOverlayVisible(true); - setIsHovering(true); - setForceHideControls(false); if (hideControlsTimeoutRef.current) { clearTimeout(hideControlsTimeoutRef.current); + hideControlsTimeoutRef.current = null; } }; - const hideControls = () => { - if (!isHoveringControls) { + const scheduleHideControls = () => { + if (hideControlsTimeoutRef.current) { + clearTimeout(hideControlsTimeoutRef.current); + } + + if (isPlaying && !isHoveringControls) { hideControlsTimeoutRef.current = setTimeout(() => { setOverlayVisible(false); - setIsHovering(false); - setForceHideControls(true); - }, 250); + }, 1000); } }; useEffect(() => { const handleMouseMove = () => { - if (forceHideControls) { - setForceHideControls(false); - } showControls(); - if (!isHoveringControls) { - hideControls(); - } + scheduleHideControls(); + }; + + const handleMouseEnter = () => { + setIsHoveringVideo(true); + showControls(); }; const handleMouseLeave = () => { - setIsHovering(false); - setIsHoveringControls(false); - hideControls(); + setIsHoveringVideo(false); + if (!isHoveringControls) { + scheduleHideControls(); + } }; const videoContainer = document.getElementById("video-container"); if (videoContainer) { videoContainer.addEventListener("mousemove", handleMouseMove); + videoContainer.addEventListener("mouseenter", handleMouseEnter); videoContainer.addEventListener("mouseleave", handleMouseLeave); } return () => { if (videoContainer) { videoContainer.removeEventListener("mousemove", handleMouseMove); + videoContainer.removeEventListener("mouseenter", handleMouseEnter); videoContainer.removeEventListener("mouseleave", handleMouseLeave); } if (hideControlsTimeoutRef.current) { clearTimeout(hideControlsTimeoutRef.current); } - if (enterControlsTimeoutRef.current) { - clearTimeout(enterControlsTimeoutRef.current); - } }; - }, [forceHideControls, isHoveringControls]); + }, [isPlaying, isHoveringControls]); useEffect(() => { if (isPlaying) { - hideControls(); + scheduleHideControls(); } else { showControls(); } - }, [isPlaying]); + }, [isPlaying, isHoveringControls]); + + useEffect(() => { + if (isHoveringControls) { + showControls(); + } else if (isPlaying && !isHoveringVideo) { + scheduleHideControls(); + } + }, [isHoveringControls, isPlaying, isHoveringVideo]); useEffect(() => { if (videoMetadataLoaded) { - // Don't immediately set isLoading to false when metadata loads - // We'll wait for canplay event to ensure video is ready } }, [videoMetadataLoaded]); useEffect(() => { const handleShortcuts = (e: KeyboardEvent) => { - // Skip handling if user is typing in form controls const target = e.target as HTMLElement; const isFormElement = target.tagName === "INPUT" || @@ -231,10 +222,9 @@ export const ShareVideo = forwardRef< target.isContentEditable || target.getAttribute("role") === "textbox"; - // Only handle shortcuts if not typing in a form control if (!isFormElement && videoRef.current) { if (e.code === "Space") { - e.preventDefault(); // Prevent page scrolling + e.preventDefault(); if (isPlaying) { videoRef.current.pause(); setIsPlaying(false); @@ -258,7 +248,6 @@ export const ShareVideo = forwardRef< if (videoRef.current) { setLongestDuration(videoRef.current.duration); setVideoMetadataLoaded(true); - // Don't set isLoading to false here } }; @@ -266,33 +255,26 @@ export const ShareVideo = forwardRef< setVideoMetadataLoaded(true); setVideoReadyToPlay(true); - // Autoplay the video when it's ready on initial render setIsPlaying(true); if (videoRef.current) { videoRef.current.play().catch((error) => { console.error("Error auto-playing video:", error); - // If autoplay fails (common on mobile), don't set isPlaying setIsPlaying(false); }); } - // If the video is already playing (user clicked play before it was ready), - // ensure it actually starts playing now if (isPlaying && videoRef.current) { - // Store the current position before playing const currentPosition = videoRef.current.currentTime; videoRef.current.play().catch((error) => { console.error("Error playing video in onCanPlay:", error); }); - // If the video was reset to the beginning, restore the position if (videoRef.current.currentTime === 0 && currentPosition > 0) { videoRef.current.currentTime = currentPosition; } } - // Set a small delay before removing the loading state to ensure smooth transition setTimeout(() => { setIsLoading(false); }, 100); @@ -325,31 +307,20 @@ export const ShareVideo = forwardRef< setIsPlaying(false); } else { try { - // Make sure video is ready to play if (!videoReadyToPlay) { - // If video is not ready yet, wait for it - console.log("Video not ready to play yet, waiting..."); - // Show the controls to indicate we received the click - showControls(); - // We'll attempt to play once the video is ready in the onCanPlay handler setIsPlaying(true); } else { - // Ensure video is not muted before playing videoElement.muted = false; - // Store the current position before playing const currentPosition = videoElement.currentTime; - // Use a promise to ensure play() completes const playPromise = videoElement.play(); if (playPromise !== undefined) { playPromise .then(() => { setIsPlaying(true); - console.log("Video playback started successfully"); - // If the video was reset to the beginning, restore the position if (videoElement.currentTime === 0 && currentPosition > 0) { videoElement.currentTime = currentPosition; } @@ -357,20 +328,16 @@ export const ShareVideo = forwardRef< .catch((error) => { console.error("Error with playing:", error); - // If autoplay is prevented by browser policy, try again with muted if (error.name === "NotAllowedError") { - console.log("Autoplay prevented, trying with muted..."); videoElement.muted = true; videoElement .play() .then(() => { setIsPlaying(true); - // After successful play, unmute if possible setTimeout(() => { videoElement.muted = false; }, 100); - // If the video was reset to the beginning, restore the position if ( videoElement.currentTime === 0 && currentPosition > 0 @@ -387,10 +354,8 @@ export const ShareVideo = forwardRef< } }); } else { - // For older browsers that don't return a promise setIsPlaying(true); - // If the video was reset to the beginning, restore the position if (videoElement.currentTime === 0 && currentPosition > 0) { videoElement.currentTime = currentPosition; } @@ -403,12 +368,10 @@ export const ShareVideo = forwardRef< }; const applyTimeToVideos = (time: number) => { - // Validate time to ensure it's a finite number if (!Number.isFinite(time)) { console.warn("Attempted to set non-finite time:", time); return; } - // Clamp time between 0 and video duration const validTime = Math.max(0, Math.min(time, longestDuration)); if (videoRef.current) videoRef.current.currentTime = validTime; setCurrentTime(validTime); @@ -418,27 +381,22 @@ export const ShareVideo = forwardRef< const videoElement = videoRef.current; if (!videoElement || !videoReadyToPlay) return; - // Set up the time update handler only once const handleTimeUpdate = () => { setCurrentTime(videoElement.currentTime); }; - // Add the event listener videoElement.addEventListener("timeupdate", handleTimeUpdate); - // Clean up return () => { videoElement.removeEventListener("timeupdate", handleTimeUpdate); }; - }, [videoReadyToPlay]); // Only re-run when video becomes ready + }, [videoReadyToPlay]); - // Separate effect for handling play state changes useEffect(() => { const videoElement = videoRef.current; if (!videoElement || !videoReadyToPlay) return; if (isPlaying) { - // Don't reset the currentTime when playing const currentPosition = videoElement.currentTime; videoElement.play().catch((error) => { @@ -446,7 +404,6 @@ export const ShareVideo = forwardRef< setIsPlaying(false); }); - // If the video was reset to the beginning, restore the position if (videoElement.currentTime === 0 && currentPosition > 0) { videoElement.currentTime = currentPosition; } @@ -481,28 +438,20 @@ export const ShareVideo = forwardRef< }; }, [seeking]); - // Show overlay temporarily when play state changes useEffect(() => { - // When video play state changes, show overlay temporarily setTempOverlayVisible(true); - // Hide temporary overlay after 500ms const timer = setTimeout(() => { setTempOverlayVisible(false); }, 500); - // Clean up timer on unmount or when isPlaying changes again return () => clearTimeout(timer); }, [isPlaying]); - // Set up a hidden video element for scrubbing previews useEffect(() => { - // Only set up scrubbing video on large screens if (isMP4Source && data && isLargeScreen) { - console.log("Setting up scrubbing video"); const scrubVideo = document.createElement("video"); - // Use the same MP4 source construction as the main video const mp4Source = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=mp4`; scrubVideo.src = mp4Source; @@ -511,25 +460,18 @@ export const ShareVideo = forwardRef< scrubVideo.muted = true; scrubVideo.style.display = "none"; - // Add event listener for when metadata is loaded scrubVideo.addEventListener("loadedmetadata", () => { - console.log("Scrubbing video metadata loaded"); - // Preload the first frame to ensure we have something to show on first hover scrubVideo.currentTime = 0; }); - // Wait for the video to be ready to use scrubVideo.addEventListener("canplay", () => { - console.log("Scrubbing video ready for preview"); setScrubbingVideo(scrubVideo); - // Preload the first frame after the video is ready if (previewCanvasRef.current) { const canvas = previewCanvasRef.current; const ctx = canvas.getContext("2d"); if (ctx) { - // Set canvas dimensions if ( canvas.width !== previewWidth || canvas.height !== previewHeight @@ -538,11 +480,9 @@ export const ShareVideo = forwardRef< canvas.height = previewHeight; } - // Draw the initial frame (at time 0) try { ctx.drawImage(scrubVideo, 0, 0, canvas.width, canvas.height); setPreviewLoaded(true); - console.log("Preloaded initial frame for preview"); } catch (err) { console.error("Error preloading initial frame:", err); } @@ -550,49 +490,37 @@ export const ShareVideo = forwardRef< } }); - // Handle errors scrubVideo.addEventListener("error", (e) => { console.error("Error loading scrubbing video:", e); }); - // Append to document body (invisible) document.body.appendChild(scrubVideo); - // Clean up on component unmount return () => { scrubVideo.remove(); setScrubbingVideo(null); }; } else if (!isLargeScreen) { - // Clean up any existing scrubbing video on small screens setScrubbingVideo(null); } }, [isMP4Source, data, previewWidth, previewHeight, isLargeScreen]); - // Function to update the preview thumbnail const updatePreviewFrame = (time: number) => { - // Skip preview operations on small screens if (!isLargeScreen) return; if (!isMP4Source) return; - - console.log("Updating preview frame to time:", time); setPreviewTime(time); - // Don't attempt to seek again if already seeking to a position if (isPreviewSeeking) { - console.log("Already seeking, skipping update"); return; } - // Try to capture frames from the scrubbing video try { if (scrubbingVideo && previewCanvasRef.current) { const canvas = previewCanvasRef.current; const ctx = canvas.getContext("2d"); if (ctx) { - // Set canvas dimensions only if they haven't been set yet if ( canvas.width !== previewWidth || canvas.height !== previewHeight @@ -601,19 +529,14 @@ export const ShareVideo = forwardRef< canvas.height = previewHeight; } - // Set a flag that we're seeking to avoid multiple seeks setIsPreviewSeeking(true); - // Seek to the specific time scrubbingVideo.currentTime = time; - // Listen for the seeked event const handleSeeked = () => { try { - // Draw the current frame onto the canvas ctx.drawImage(scrubbingVideo, 0, 0, canvas.width, canvas.height); - // Add timestamp overlay ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect(0, canvas.height - 20, canvas.width, 20); ctx.fillStyle = "white"; @@ -627,24 +550,19 @@ export const ShareVideo = forwardRef< setPreviewLoaded(true); setIsPreviewSeeking(false); - console.log(`Drew frame at time ${time}`); } catch (err) { console.error("Error drawing frame:", err); setIsPreviewSeeking(false); } - // Remove the event listener after use scrubbingVideo.removeEventListener("seeked", handleSeeked); }; - // Add the seeked event listener scrubbingVideo.addEventListener("seeked", handleSeeked); - // Set a timeout to ensure we don't get stuck waiting for the seeked event const timeoutId = setTimeout(() => { if (isPreviewSeeking) { try { - // If we're still seeking after 250ms, draw the frame anyway ctx.drawImage( scrubbingVideo, 0, @@ -653,7 +571,6 @@ export const ShareVideo = forwardRef< canvas.height ); - // Add timestamp overlay ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect(0, canvas.height - 20, canvas.width, 20); ctx.fillStyle = "white"; @@ -666,36 +583,29 @@ export const ShareVideo = forwardRef< ); setPreviewLoaded(true); - console.log(`Drew frame at time ${time} after timeout`); } catch (err) { console.error("Error drawing frame after timeout:", err); } finally { - // Always reset the seeking state after timeout setIsPreviewSeeking(false); scrubbingVideo.removeEventListener("seeked", handleSeeked); } } }, 250); - // Clean up the timeout if the component unmounts return () => clearTimeout(timeoutId); } } else if (videoRef.current && previewCanvasRef.current) { - // Fallback to main video if scrubbing video isn't ready const canvas = previewCanvasRef.current; const video = videoRef.current; const ctx = canvas.getContext("2d"); if (ctx) { try { - // Set canvas dimensions canvas.width = previewWidth; canvas.height = previewHeight; - // Draw the current frame from the main video ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - // Add timestamp overlay ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect(0, canvas.height - 20, canvas.width, 20); ctx.fillStyle = "white"; @@ -715,22 +625,18 @@ export const ShareVideo = forwardRef< } }; - // Handle hovering over the timeline const handleTimelineHover = ( event: React.MouseEvent | React.TouchEvent ) => { if (isLoading) return; - // Skip preview operations on small screens if (!isLargeScreen) return; const seekBar = event.currentTarget; const time = calculateNewTime(event, seekBar); - // Set the preview position based on mouse/touch position const rect = seekBar.getBoundingClientRect(); - // Get clientX from either mouse or touch event let clientX = 0; if ("touches" in event && event.touches && event.touches[0]) { clientX = event.touches[0].clientX; @@ -740,30 +646,22 @@ export const ShareVideo = forwardRef< const previewPos = clientX - rect.left - previewWidth / 2; - // Ensure preview stays within bounds of the video player const maxLeft = rect.width - previewWidth; const boundedPos = Math.max(0, Math.min(previewPos, maxLeft)); setPreviewPosition(boundedPos); - // Always show the preview when hovering if (!showPreview) { setShowPreview(true); - // Force an update on the first hover updatePreviewFrame(time); lastUpdateTimeRef.current = Date.now(); return; } - // Store the current mouse position const currentMousePos = clientX; const lastMousePos = lastMousePosRef.current; lastMousePosRef.current = currentMousePos; - // Only update the frame if: - // 1. It's been more than 500ms since the last update - // 2. The mouse has moved significantly (more than 10px) since the last update - // 3. We're not currently in the middle of seeking const now = Date.now(); const timeSinceLastUpdate = now - lastUpdateTimeRef.current; const significantMouseMove = Math.abs(currentMousePos - lastMousePos) > 10; @@ -780,7 +678,6 @@ export const ShareVideo = forwardRef< const calculateNewTime = (event: any, seekBar: any) => { const rect = seekBar.getBoundingClientRect(); - // Handle both mouse and touch events let clientX = 0; if (event.touches && event.touches.length > 0) { clientX = event.touches[0].clientX; @@ -806,8 +703,6 @@ export const ShareVideo = forwardRef< setSeeking(false); const seekBar = event.currentTarget; const seekTo = calculateNewTime(event, seekBar); - // we don't want to apply time to videos if it's a touch event (mobile) - // as it's already being handled by handleSeekMouseMove if (!isTouch) { applyTimeToVideos(seekTo); } @@ -825,14 +720,10 @@ export const ShareVideo = forwardRef< applyTimeToVideos(seekTo); }; - // Clean up any pending timeout when timeline hover ends const handleTimelineLeave = () => { - // Skip preview operations on small screens if (!isLargeScreen) return; setShowPreview(false); - // Reset the last update time when leaving the timeline - // This ensures the preview will update immediately on the next hover lastUpdateTimeRef.current = 0; }; @@ -853,7 +744,6 @@ export const ShareVideo = forwardRef< const isAndroid = /Android/.test(navigator.userAgent); if (isIOS || isAndroid) { - // For mobile devices, use the video element's fullscreen API if (video.requestFullscreen) { video .requestFullscreen() @@ -928,8 +818,6 @@ export const ShareVideo = forwardRef< if (playPromise !== undefined) { playPromise.catch((error) => { console.error("Error in useEffect play:", error); - // If autoplay is prevented, don't change the isPlaying state - // as it will be handled by the click handler }); } } else { @@ -938,11 +826,9 @@ export const ShareVideo = forwardRef< }, [isPlaying, videoReadyToPlay]); const parseSubTime = (timeString: number) => { - // Convert number to string and ensure it's in the format HH:MM:SS const timeStr = timeString.toString(); const timeParts = timeStr.split(":"); - // Map parts to numbers with proper fallbacks const hoursValue = timeParts.length > 2 ? Number(timeParts[0]) || 0 : 0; const minutesValue = timeParts.length > 1 ? Number(timeParts[timeParts.length - 2]) || 0 : 0; @@ -959,10 +845,8 @@ export const ShareVideo = forwardRef< let transcriptionUrl; if (data.bucket && data.awsBucket !== publicEnv.awsBucket) { - // For custom S3 buckets, fetch through the API transcriptionUrl = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&fileType=transcription`; } else { - // For default Cap storage transcriptionUrl = `${publicEnv.s3BucketUrl}/${data.ownerId}/${data.id}/transcription.vtt`; } @@ -971,43 +855,29 @@ export const ShareVideo = forwardRef< const text = await response.text(); const parsedSubtitles = fromVtt(text); setSubtitles(parsedSubtitles); + setIsTranscriptionProcessing(false); } catch (error) { console.error("Error fetching subtitles:", error); + setIsTranscriptionProcessing(false); } }; - if (data.transcriptionStatus === "COMPLETE") { + if (data.transcriptionStatus === "PROCESSING") { + setIsTranscriptionProcessing(true); + } else if (data.transcriptionStatus === "COMPLETE") { fetchSubtitles(); - } else { - const startTime = Date.now(); - const maxDuration = 2 * 60 * 1000; - - const intervalId = setInterval(() => { - if (Date.now() - startTime > maxDuration) { - clearInterval(intervalId); - return; - } - - apiClient.video - .getTranscribeStatus({ query: { videoId: data.id } }) - .then((data) => { - if (data.status !== 200) return; - - const { transcriptionStatus } = data.body; - if (transcriptionStatus === "PROCESSING") { - setIsTranscriptionProcessing(true); - } else if (transcriptionStatus === "COMPLETE") { - fetchSubtitles(); - clearInterval(intervalId); - } else if (transcriptionStatus === "ERROR") { - clearInterval(intervalId); - } - }); - }, 1000); - - return () => clearInterval(intervalId); + } else if (data.transcriptionStatus === "ERROR") { + setIsTranscriptionProcessing(false); } - }, [data]); + }, [ + data.transcriptionStatus, + data.bucket, + data.awsBucket, + data.ownerId, + data.id, + publicEnv.awsBucket, + publicEnv.s3BucketUrl, + ]); const currentSubtitle = subtitles.find( (subtitle) => @@ -1015,40 +885,30 @@ export const ShareVideo = forwardRef< parseSubTime(subtitle.endTime) >= currentTime ); - // Check screen size on mount and when window resizes useEffect(() => { const checkScreenSize = () => { - // lg breakpoint in Tailwind is typically 1024px setIsLargeScreen(window.innerWidth >= 1024); }; - // Check on mount checkScreenSize(); - // Add resize listener window.addEventListener("resize", checkScreenSize); - // Clean up return () => window.removeEventListener("resize", checkScreenSize); }, []); - // Initialize the preview canvas useEffect(() => { - // Only initialize preview canvas on large screens if (previewCanvasRef.current && isLargeScreen) { const canvas = previewCanvasRef.current; const ctx = canvas.getContext("2d"); if (ctx) { - // Set canvas dimensions canvas.width = previewWidth; canvas.height = previewHeight; - // Draw a black background initially ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, canvas.width, canvas.height); - // Add a "hover to preview" text ctx.fillStyle = "white"; ctx.font = "12px Arial"; ctx.textAlign = "center"; @@ -1058,7 +918,6 @@ export const ShareVideo = forwardRef< }, [previewCanvasRef, previewWidth, previewHeight, isLargeScreen]); useEffect(() => { - // Safari detection for applying specific styles const detectSafari = () => { const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) || @@ -1074,6 +933,14 @@ export const ShareVideo = forwardRef< detectSafari(); }, []); + useEffect(() => { + if (data.transcriptionStatus === "PROCESSING") { + setIsTranscriptionProcessing(true); + } else if (data.transcriptionStatus === "ERROR") { + setIsTranscriptionProcessing(false); + } + }, [data.transcriptionStatus]); + if (data.jobStatus === "ERROR") { return (
@@ -1095,7 +962,6 @@ export const ShareVideo = forwardRef< if (data.source.type === "desktopMP4") { videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=mp4`; } else if ( - // v.cap.so is only available in prod NODE_ENV === "development" || ((data.skipProcessing === true || data.jobStatus !== "COMPLETE") && data.source.type === "MediaConvert") @@ -1129,11 +995,8 @@ export const ShareVideo = forwardRef<
{!isLoading && (
{ if (!videoReadyToPlay) { - // If video is not ready, set a visual indicator but don't try to play yet - console.log("Video not ready to play yet, waiting..."); - // Show the controls to indicate we received the click - showControls(); - // We'll attempt to play once the video is ready in the onCanPlay handler setIsPlaying(true); } else { - // Normal play/pause behavior when video is ready handlePlayPauseClick(); - showControls(); - hideControls(); } }} > @@ -1161,21 +1016,21 @@ export const ShareVideo = forwardRef< {isPlaying ? ( - + ) : ( - + )} @@ -1192,7 +1047,11 @@ export const ShareVideo = forwardRef<
)} {currentSubtitle && currentSubtitle.text && subtitlesVisible && ( -
+
{currentSubtitle.text .replace("- ", "") @@ -1203,7 +1062,6 @@ export const ShareVideo = forwardRef< )}
- {/* Thumbnail preview - MP4 only, visible only on screens larger than lg */} {showPreview && !isLoading && isMP4Source && isLargeScreen && (
{ - console.log( - "Thumbnail failed to load, using canvas preview instead" - ); setThumbnailUrl(null); }} /> @@ -1242,49 +1097,27 @@ export const ShareVideo = forwardRef< )}
{ - if (enterControlsTimeoutRef.current) { - clearTimeout(enterControlsTimeoutRef.current); - } - enterControlsTimeoutRef.current = setTimeout(() => { - setIsHoveringControls(true); - }, 100); - }} - onMouseLeave={() => { - if (enterControlsTimeoutRef.current) { - clearTimeout(enterControlsTimeoutRef.current); - } - setIsHoveringControls(false); - }} >
{ if (seeking) { handleSeekMouseMove(e); - } else { - handleTimelineHover(e); } }} onMouseUp={handleSeekMouseUp} onMouseLeave={() => { setSeeking(false); - handleTimelineLeave(); }} onTouchStart={handleSeekMouseDown} onTouchMove={(e) => { if (seeking) { handleSeekMouseMove(e); - } else { - handleTimelineHover(e); } }} onTouchEnd={(e) => handleSeekMouseUp(e, true)} @@ -1336,22 +1169,80 @@ export const ShareVideo = forwardRef< })}
)} -
-
+
+ className="relative w-full h-full" + onMouseMove={handleTimelineHover} + onMouseLeave={handleTimelineLeave} + > +
+
+
+
+ + {chapters.length > 0 && longestDuration > 0 && ( +
+ {chapters.map((ch) => { + const pos = (ch.start / longestDuration) * 100; + return ( +
{ + e.stopPropagation(); + applyTimeToVideos(ch.start); + }} + > +
+ +
+ ); + })} +
+ )}
+
+ +
{ + setIsHoveringControls(true); + }} + onMouseLeave={() => { + setIsHoveringControls(false); + }} + >
@@ -1378,7 +1269,7 @@ export const ShareVideo = forwardRef< + + )} {subtitles.length > 0 && ( +
+
+
+ ); + } + + if (isLoading || aiData?.processing) { + return ( +
+
+ +
+
+ ); + } + + if (!aiData?.summary && (!aiData?.chapters || aiData.chapters.length === 0)) { + return ( +
+
+ + + +

+ No summary available +

+

+ AI summary has not been generated for this video yet. +

+
+
+ ); + } + + return ( +
+
+
+ {aiData?.summary && ( +
+

Summary

+
+ + Generated by Cap AI + +
+

+ {aiData.summary} +

+
+ )} + + {aiData?.chapters && aiData.chapters.length > 0 && ( +
+

Chapters

+
+ {aiData.chapters.map((chapter) => ( +
handleSeek(chapter.start)} + > + + {formatTime(chapter.start)} + + {chapter.title} +
+ ))} +
+
+ )} +
+
+
+ ); +}; diff --git a/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx b/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx index 503dd6df5..0f2943d20 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx @@ -122,10 +122,8 @@ export const Transcript: React.FC = ({ data, onSeek }) => { let transcriptionUrl; if (data.bucket && data.awsBucket !== publicEnv.awsBucket) { - // For custom S3 buckets, fetch through the API transcriptionUrl = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&fileType=transcription`; } else { - // For default Cap storage transcriptionUrl = `${publicEnv.s3BucketUrl}/${data.ownerId}/${data.id}/transcription.vtt`; } @@ -134,23 +132,36 @@ export const Transcript: React.FC = ({ data, onSeek }) => { const vttContent = await response.text(); const parsed = parseVTT(vttContent); setTranscriptData(parsed); + setIsTranscriptionProcessing(false); } catch (error) { console.error("Error loading transcript:", error); } setIsLoading(false); }; + console.log("[Transcript] Transcription status:", data.transcriptionStatus); + const videoCreationTime = new Date(data.createdAt).getTime(); const fiveMinutesInMs = 5 * 60 * 1000; const isVideoOlderThanFiveMinutes = Date.now() - videoCreationTime > fiveMinutesInMs; if (data.transcriptionStatus === "COMPLETE") { + console.log("[Transcript] Transcription complete, fetching transcript"); fetchTranscript(); + } else if (data.transcriptionStatus === "PROCESSING") { + console.log("[Transcript] Transcription in progress"); + setIsTranscriptionProcessing(true); + setIsLoading(true); + } else if (data.transcriptionStatus === "ERROR") { + console.log("[Transcript] Transcription error"); + setIsTranscriptionProcessing(false); + setIsLoading(false); } else if (isVideoOlderThanFiveMinutes && !data.transcriptionStatus) { setIsLoading(false); setHasTimedOut(true); } else { + // Transcription hasn't started or status unknown, start our own polling const startTime = Date.now(); const maxDuration = 2 * 60 * 1000; @@ -185,6 +196,8 @@ export const Transcript: React.FC = ({ data, onSeek }) => { data.awsBucket, data.transcriptionStatus, data.createdAt, + publicEnv.awsBucket, + publicEnv.s3BucketUrl, ]); const handleReset = () => { diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 501752caa..d35f6f346 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -9,6 +9,7 @@ import { organizationMembers, organizations, } from "@cap/database/schema"; +import { VideoMetadata } from "@cap/database/types"; import { getCurrentUser, userSelectProps } from "@cap/database/auth/session"; import type { Metadata, ResolvingMetadata } from "next"; import { notFound } from "next/navigation"; @@ -18,6 +19,8 @@ import { getVideoAnalytics } from "@/actions/videos/get-analytics"; import { transcribeVideo } from "@/actions/videos/transcribe"; import { getScreenshot } from "@/actions/screenshots/get-screenshot"; import { headers } from "next/headers"; +import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; +import { isAiGenerationEnabled, isAiUiEnabled } from "@/utils/flags"; export const dynamic = "auto"; export const dynamicParams = true; @@ -40,10 +43,6 @@ type VideoWithOrganization = typeof videos.$inferSelect & { sharedOrganizations?: { id: string; name: string }[]; }; -type OrganizationMember = { - userId: string; -}; - export async function generateMetadata( { params }: Props, parent: ResolvingMetadata @@ -269,17 +268,115 @@ export default async function ShareVideoPage(props: Props) { } } - if (video.transcriptionStatus !== "COMPLETE") { + if ( + video.transcriptionStatus !== "COMPLETE" && + video.transcriptionStatus !== "PROCESSING" + ) { console.log("[ShareVideoPage] Starting transcription for video:", videoId); await transcribeVideo(videoId, video.ownerId); + + // Re-fetch video data to get updated transcription status + const updatedVideoQuery = await db() + .select({ + id: videos.id, + name: videos.name, + ownerId: videos.ownerId, + createdAt: videos.createdAt, + updatedAt: videos.updatedAt, + awsRegion: videos.awsRegion, + awsBucket: videos.awsBucket, + bucket: videos.bucket, + metadata: videos.metadata, + public: videos.public, + videoStartTime: videos.videoStartTime, + audioStartTime: videos.audioStartTime, + xStreamInfo: videos.xStreamInfo, + jobId: videos.jobId, + jobStatus: videos.jobStatus, + isScreenshot: videos.isScreenshot, + skipProcessing: videos.skipProcessing, + transcriptionStatus: videos.transcriptionStatus, + source: videos.source, + sharedOrganization: { + organizationId: sharedVideos.organizationId, + }, + }) + .from(videos) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .where(eq(videos.id, videoId)) + .execute(); + + if (updatedVideoQuery[0]) { + Object.assign(video, updatedVideoQuery[0]); + console.log( + "[ShareVideoPage] Updated transcription status:", + video.transcriptionStatus + ); + } + } + + const currentMetadata = (video.metadata as VideoMetadata) || {}; + const metadata = currentMetadata; // Keep existing reference for compatibility + let initialAiData = null; + let aiGenerationEnabled = false; + + const videoOwnerQuery = await db() + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, + }) + .from(users) + .where(eq(users.id, video.ownerId)) + .limit(1); + + if (videoOwnerQuery.length > 0 && videoOwnerQuery[0]) { + const videoOwner = videoOwnerQuery[0]; + aiGenerationEnabled = isAiGenerationEnabled(videoOwner); + } + + if (metadata.summary || metadata.chapters || metadata.aiTitle) { + initialAiData = { + title: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + processing: metadata.aiProcessing || false, + }; + } else if (metadata.aiProcessing) { + initialAiData = { + title: null, + summary: null, + chapters: null, + processing: true, + }; + } + + if ( + video.transcriptionStatus === "COMPLETE" && + !currentMetadata.aiProcessing && + !currentMetadata.summary && + !currentMetadata.chapters && + !currentMetadata.generationError && + aiGenerationEnabled + ) { + try { + generateAiMetadata(videoId, video.ownerId).catch((error) => { + console.error( + `[ShareVideoPage] Error generating AI metadata for video ${videoId}:`, + error + ); + }); + } catch (error) { + console.error( + `[ShareVideoPage] Error starting AI metadata generation for video ${videoId}:`, + error + ); + } } if (video.public === false && userId !== video.ownerId) { - console.log("[ShareVideoPage] Access denied - private video:", videoId); return

This video is private

; } - console.log("[ShareVideoPage] Fetching comments for video:", videoId); const commentsQuery: CommentWithAuthor[] = await db() .select({ id: comments.id, @@ -299,7 +396,6 @@ export default async function ShareVideoPage(props: Props) { let screenshotUrl; if (video.isScreenshot === true) { - console.log("[ShareVideoPage] Fetching screenshot for video:", videoId); try { const data = await getScreenshot(video.ownerId, videoId); screenshotUrl = data.url; @@ -318,7 +414,6 @@ export default async function ShareVideoPage(props: Props) { } } - console.log("[ShareVideoPage] Fetching analytics for video:", videoId); const analyticsData = await getVideoAnalytics(videoId); const initialAnalytics = { @@ -435,6 +530,18 @@ export default async function ShareVideoPage(props: Props) { sharedOrganizations: sharedOrganizationsData, }; + // Check if AI UI should be shown for the current viewer + let aiUiEnabled = false; + if (user?.email) { + aiUiEnabled = isAiUiEnabled({ + email: user.email, + stripeSubscriptionStatus: user.stripeSubscriptionStatus, + }); + console.log( + `[ShareVideoPage] AI UI feature flag check for viewer ${user.id}: ${aiUiEnabled} (email: ${user.email})` + ); + } + return ( ); } diff --git a/apps/web/scripts/reset-ai-processing-flags.ts b/apps/web/scripts/reset-ai-processing-flags.ts new file mode 100644 index 000000000..04dbbf101 --- /dev/null +++ b/apps/web/scripts/reset-ai-processing-flags.ts @@ -0,0 +1,44 @@ +import { db } from "@cap/database"; +import { videos } from "@cap/database/schema"; +import { VideoMetadata } from "@cap/database/types"; +import { sql } from "drizzle-orm"; + +async function resetStuckAiProcessingFlags() { + console.log("Checking for stuck AI processing flags..."); + + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); + + const stuckVideos = await db() + .select() + .from(videos) + .where(sql` + (metadata->>'aiProcessing')::boolean = true + AND updated_at < ${tenMinutesAgo} + `); + + console.log(`Found ${stuckVideos.length} videos with stuck AI processing flags`); + + for (const video of stuckVideos) { + const metadata = (video.metadata as VideoMetadata) || {}; + console.log(`Resetting AI processing flag for video ${video.id} (updated ${video.updatedAt})`); + + await db() + .update(videos) + .set({ + metadata: { + ...metadata, + aiProcessing: false, + generationError: "AI processing was stuck and has been reset" + } + }) + .where(sql`id = ${video.id}`); + } + + console.log("Done resetting stuck AI processing flags"); + process.exit(0); +} + +resetStuckAiProcessingFlags().catch(error => { + console.error("Error resetting AI processing flags:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/apps/web/utils/flags.ts b/apps/web/utils/flags.ts new file mode 100644 index 000000000..7703f1705 --- /dev/null +++ b/apps/web/utils/flags.ts @@ -0,0 +1,34 @@ +import { isUserOnProPlan } from "@cap/utils"; + +export interface FeatureFlagUser { + email: string; + stripeSubscriptionStatus?: string | null; +} + +export function isAiUiEnabled(user: FeatureFlagUser): boolean { + if (!user.email) { + return false; + } + + const allowedDomains = ["@cap.so", "@mcilroy.co"]; + return allowedDomains.some(domain => + user.email.includes(domain) + ); +} + +export function isAiGenerationEnabled(user: FeatureFlagUser): boolean { + if (!user.email) { + return false; + } + + const allowedDomains = ["@cap.so", "@mcilroy.co"]; + const hasAllowedEmail = allowedDomains.some(domain => + user.email.includes(domain) + ); + + const isProUser = isUserOnProPlan({ + subscriptionStatus: user.stripeSubscriptionStatus || null, + }); + + return hasAllowedEmail && isProUser; +} \ No newline at end of file diff --git a/packages/database/types/metadata.ts b/packages/database/types/metadata.ts index 3ee362794..ea60fe87b 100644 --- a/packages/database/types/metadata.ts +++ b/packages/database/types/metadata.ts @@ -11,6 +11,35 @@ export interface VideoMetadata { * This overrides the display of the actual createdAt timestamp */ customCreatedAt?: string; + /** + * Title of the captured monitor or window + */ + sourceName?: string; + /** + * Duration of the video in seconds + */ + duration?: number; + /** + * Resolution of the recording (e.g. 1920x1080) + */ + resolution?: string; + /** + * Frames per second of the recording + */ + fps?: number; + /** + * AI generated title for the video + */ + aiTitle?: string; + /** + * AI generated summary of the content + */ + summary?: string; + /** + * Chapter markers generated from the transcript + */ + chapters?: { title: string; start: number }[]; + aiProcessing?: boolean; [key: string]: any; } diff --git a/packages/env/server.ts b/packages/env/server.ts index b9c0c406c..af0809e19 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -41,6 +41,7 @@ function createServerEnv() { STRIPE_SECRET_KEY_LIVE: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), DISCORD_FEEDBACK_WEBHOOK_URL: z.string().optional(), + OPENAI_API_KEY: z.string().optional(), INTERCOM_SECRET: z.string().optional(), VERCEL_TEAM_ID: z.string().optional(), VERCEL_PROJECT_ID: z.string().optional(),