diff --git a/apps/web/actions/videos/generate-ai-metadata.ts b/apps/web/actions/videos/generate-ai-metadata.ts index 744d4d7cc9..8f3ebb2f50 100644 --- a/apps/web/actions/videos/generate-ai-metadata.ts +++ b/apps/web/actions/videos/generate-ai-metadata.ts @@ -1,7 +1,5 @@ "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 } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; @@ -9,6 +7,46 @@ import { serverEnv } from "@cap/env"; import { eq } from "drizzle-orm"; import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client"; import { createBucketProvider } from "@/utils/s3"; + +async function callOpenAI(prompt: string): Promise { + 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(); + return aiJson.choices?.[0]?.message?.content || "{}"; +} + +async function setAiProcessingFlag( + videoId: string, + processing: boolean, + currentMetadata: VideoMetadata, +) { + await db() + .update(videos) + .set({ + metadata: { + ...currentMetadata, + aiProcessing: processing, + }, + }) + .where(eq(videos.id, videoId)); +} + export async function generateAiMetadata(videoId: string, userId: string) { const groqClient = getGroqClient(); if (!groqClient && !serverEnv().OPENAI_API_KEY) { @@ -17,38 +55,30 @@ export async function generateAiMetadata(videoId: string, userId: string) { ); return; } - const videoQuery = await db() - .select({ video: videos }) + + // Single optimized query to get video data with bucket info + const query = await db() + .select({ video: videos, bucket: s3Buckets }) .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) .where(eq(videos.id, videoId)); - if (videoQuery.length === 0 || !videoQuery[0]?.video) { + if (query.length === 0 || !query[0]?.video) { console.error( `[generateAiMetadata] Video ${videoId} not found in database`, ); return; } - const videoData = videoQuery[0].video; - const metadata = (videoData.metadata as VideoMetadata) || {}; + const { video: videoData, bucket: bucketData } = query[0]; + const metadata: VideoMetadata = (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, - }, - }) - .where(eq(videos.id, videoId)); + if (Date.now() - updatedAtTime > tenMinutesInMs) { + await setAiProcessingFlag(videoId, false, metadata); metadata.aiProcessing = false; } else { return; @@ -57,66 +87,23 @@ export async function generateAiMetadata(videoId: string, userId: string) { if (metadata.summary || metadata.chapters) { if (metadata.aiProcessing) { - await db() - .update(videos) - .set({ - metadata: { - ...metadata, - aiProcessing: false, - }, - }) - .where(eq(videos.id, videoId)); + await setAiProcessingFlag(videoId, false, metadata); } return; } if (videoData?.transcriptionStatus !== "COMPLETE") { if (metadata.aiProcessing) { - await db() - .update(videos) - .set({ - metadata: { - ...metadata, - aiProcessing: false, - }, - }) - .where(eq(videos.id, videoId)); + await setAiProcessingFlag(videoId, false, metadata); } 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}`); - } + // Set processing flag + await setAiProcessingFlag(videoId, true, metadata); - 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 } = row; - - const awsBucket = video.awsBucket; + const awsBucket = videoData.awsBucket; if (!awsBucket) { console.error( `[generateAiMetadata] AWS bucket not found for video ${videoId}`, @@ -124,7 +111,7 @@ export async function generateAiMetadata(videoId: string, userId: string) { throw new Error(`AWS bucket not found for video ${videoId}`); } - const bucket = await createBucketProvider(row.bucket); + const bucket = await createBucketProvider(bucketData); const transcriptKey = `${userId}/${videoId}/transcription.vtt`; const vtt = await bucket.getObject(transcriptKey); @@ -172,62 +159,69 @@ ${transcriptText}`; ); // Fallback to OpenAI if Groq fails and OpenAI key exists if (serverEnv().OPENAI_API_KEY) { - 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(); - content = aiJson.choices?.[0]?.message?.content || "{}"; + content = await callOpenAI(prompt); } else { throw groqError; } } } else if (serverEnv().OPENAI_API_KEY) { // Use OpenAI if Groq client is not available - 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(); - content = aiJson.choices?.[0]?.message?.content || "{}"; + content = await callOpenAI(prompt); } - let data: { + // Type-safe AI response interface + interface AIResponse { title?: string; summary?: string; chapters?: { title: string; start: number }[]; - } = {}; + } + + // Helper function to validate AI response + function validateAIResponse(obj: unknown): AIResponse { + const validated: AIResponse = {}; + + if (typeof obj === "object" && obj !== null) { + const data = obj as Record; + + if (typeof data.title === "string" && data.title.trim()) { + validated.title = data.title.trim(); + } + + if (typeof data.summary === "string" && data.summary.trim()) { + validated.summary = data.summary.trim(); + } + + if (Array.isArray(data.chapters)) { + const validChapters = data.chapters.filter( + (chapter: unknown): chapter is { title: string; start: number } => { + if (typeof chapter !== "object" || chapter === null) { + return false; + } + + const chapterObj = chapter as Record; + const title = chapterObj.title; + const start = chapterObj.start; + + return ( + typeof title === "string" && + typeof start === "number" && + title.trim().length > 0 && + start >= 0 + ); + }, + ); + + validated.chapters = validChapters.map((chapter) => ({ + title: chapter.title.trim(), + start: Math.floor(chapter.start), + })); + } + } + + return validated; + } + + let data: AIResponse = {}; try { // Remove markdown code blocks if present let cleanContent = content; @@ -238,9 +232,19 @@ ${transcriptText}`; } else if (content.includes("```")) { cleanContent = content.replace(/```\s*/g, ""); } - data = JSON.parse(cleanContent.trim()); + + const parsedData = JSON.parse(cleanContent.trim()); + data = validateAIResponse(parsedData); + + // Log if validation removed invalid data + if (Object.keys(parsedData).length !== Object.keys(data).length) { + console.warn( + `[generateAiMetadata] Some AI response data was invalid and filtered out`, + ); + } } catch (e) { console.error(`[generateAiMetadata] Error parsing AI response: ${e}`); + console.error(`[generateAiMetadata] Raw content: ${content}`); data = { title: "Generated Title", summary: @@ -249,8 +253,7 @@ ${transcriptText}`; }; } - const currentMetadata: VideoMetadata = - (video.metadata as VideoMetadata) || {}; + const currentMetadata: VideoMetadata = metadata; const updatedMetadata: VideoMetadata = { ...currentMetadata, aiTitle: data.title || currentMetadata.aiTitle, @@ -259,45 +262,35 @@ ${transcriptText}`; aiProcessing: false, }; - await db() - .update(videos) - .set({ metadata: updatedMetadata }) - .where(eq(videos.id, videoId)); - + // Batch database updates const hasDatePattern = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test( - video.name || "", + videoData.name || "", ); - if ( - (video.name?.startsWith("Cap Recording -") || hasDatePattern) && - data.title - ) { + const shouldUpdateName = + (videoData.name?.startsWith("Cap Recording -") || hasDatePattern) && + data.title; + + if (shouldUpdateName) { + // Update both metadata and name in a single query + await db() + .update(videos) + .set({ + metadata: updatedMetadata, + name: data.title, + }) + .where(eq(videos.id, videoId)); + } else { + // Update only metadata await db() .update(videos) - .set({ name: data.title }) + .set({ metadata: updatedMetadata }) .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, - }, - }) - .where(eq(videos.id, videoId)); - } + await setAiProcessingFlag(videoId, false, metadata); } catch (updateError) { console.error( `[generateAiMetadata] Failed to reset processing flag:`, diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 1a1210a742..0bc34a4078 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -9,7 +9,7 @@ import { eq } from "drizzle-orm"; import { Effect, Exit } from "effect"; import { revalidatePath } from "next/cache"; import * as EffectRuntime from "@/lib/server"; -import { isAiGenerationEnabled } from "@/utils/flags"; +import { FeatureFlagUser, isAiGenerationEnabled } from "@/utils/flags"; import { transcribeVideo } from "../../lib/transcribe"; import { generateAiMetadata } from "./generate-ai-metadata"; @@ -161,98 +161,76 @@ export async function getVideoStatus( .where(eq(users.id, video.ownerId)) .limit(1); - if ( - videoOwnerQuery.length > 0 && - videoOwnerQuery[0] && - (await isAiGenerationEnabled(videoOwnerQuery[0])) - ) { + const isAiGenEnabled: boolean = await isAiGenerationEnabled( + videoOwnerQuery[0] as FeatureFlagUser, + ); + + if (videoOwnerQuery.length > 0 && videoOwnerQuery[0] && isAiGenEnabled) { console.log( `[Get Status] Feature flag enabled, triggering AI generation for video ${videoId}`, ); - (async () => { - try { - console.log( - `[Get Status] Starting AI metadata generation for video ${videoId}`, - ); - await generateAiMetadata(videoId, video.ownerId); + // Set aiProcessing to true immediately + await db() + .update(videos) + .set({ + metadata: { + ...metadata, + aiProcessing: true, + }, + }) + .where(eq(videos.id, videoId)); + + // Start AI generation asynchronously (it will skip setting aiProcessing since it's already true) + generateAiMetadata(videoId, video.ownerId) + .then(() => { console.log( `[Get Status] AI metadata generation completed for video ${videoId}`, ); - // Revalidate the share page to reflect new AI data revalidatePath(`/s/${videoId}`); - } catch (error) { + }) + .catch((error) => { console.error( `[Get Status] Error generating AI metadata for video ${videoId}:`, error, ); + // Reset aiProcessing flag on error + db() + .select() + .from(videos) + .where(eq(videos.id, videoId)) + .then((currentVideo) => { + if (currentVideo.length > 0 && currentVideo[0]) { + const currentMetadata = + (currentVideo[0].metadata as VideoMetadata) || {}; + return db() + .update(videos) + .set({ + metadata: { + ...currentMetadata, + aiProcessing: false, + }, + }) + .where(eq(videos.id, videoId)); + } + }) + .then(() => revalidatePath(`/s/${videoId}`)) + .catch((resetError) => { + console.error( + "[Get Status] Failed to reset AI processing flag for video:", + videoId, + resetError, + ); + }); + }); - 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( - `[Get Status] Failed to reset AI processing flag for video ${videoId}:`, - resetError, - ); - } - } - })(); - - const updatedVideo = await db() - .select({ - transcriptionStatus: videos.transcriptionStatus, - metadata: videos.metadata, - }) - .from(videos) - .where(eq(videos.id, videoId)) - .limit(1); - if (updatedVideo.length > 0) { - const row = updatedVideo[0]; - if (!row) { - return { - transcriptionStatus: - (video.transcriptionStatus as - | "PROCESSING" - | "COMPLETE" - | "ERROR") || null, - aiProcessing: metadata.aiProcessing || false, - aiTitle: metadata.aiTitle || null, - summary: metadata.summary || null, - chapters: metadata.chapters || null, - // generationError: metadata.generationError || null, - }; - } - const updatedMetadata = (row.metadata as VideoMetadata) || {}; - - return { - transcriptionStatus: - (row.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || - null, - aiProcessing: updatedMetadata.aiProcessing || false, - aiTitle: updatedMetadata.aiTitle || null, - summary: updatedMetadata.summary || null, - chapters: updatedMetadata.chapters || null, - // generationError: updatedMetadata.generationError || null, - }; - } + return { + transcriptionStatus: "COMPLETE", + aiProcessing: true, + aiTitle: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + }; } else { const videoOwner = videoOwnerQuery[0]; console.log( diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index e0a3316966..bdf2cd238f 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -114,15 +114,12 @@ const useVideoStatus = ( } if (data.transcriptionStatus === "COMPLETE") { - if (aiGenerationEnabled) { - const noAiData = !( - data.aiTitle || - data.summary || - (data.chapters && data.chapters.length > 0) - ); - if (data.aiProcessing || noAiData) { - return true; - } + if (!aiGenerationEnabled) { + return false; + } + + if (data.aiProcessing) { + return true; } return false; } @@ -181,35 +178,43 @@ export const Share = ({ [videoStatus], ); - const shouldShowLoading = () => { - // Show loading while transcription is pending or processing regardless of AI flag - if (!transcriptionStatus || transcriptionStatus === "PROCESSING") { - return true; + const shouldShowLoading = useCallback(() => { + // Don't show loading if AI generation is not enabled + if (!aiGenerationEnabled) { + return false; } + // If transcription failed, don't show loading if (transcriptionStatus === "ERROR") { return false; } + // Show loading while transcription is pending or processing + if (!transcriptionStatus || transcriptionStatus === "PROCESSING") { + return true; + } + if (transcriptionStatus === "COMPLETE") { - if (aiGenerationEnabled) { - const noAiData = !( - aiData.title || - aiData.summary || - (aiData.chapters && aiData.chapters.length > 0) - ); - // Show loading if AI is processing OR if no AI data exists yet - if (aiData.processing === true || noAiData) { - return true; - } + // If AI is processing, show loading + if (aiData.processing === true) { + return true; } + + // Don't show loading if processing is false - means AI won't run or already finished + return false; } return false; - }; + }, [transcriptionStatus, aiData, aiGenerationEnabled]); const aiLoading = shouldShowLoading(); + console.log({ + aiLoading, + aiData, + transcriptionStatus, + }); + const handleSeek = (time: number) => { if (playerRef.current) { playerRef.current.currentTime = time; diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 48c2823758..a29722b1bc 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -246,7 +246,7 @@ export const ShareHeader = ({