From 2d8be0d101be01974605e0244086146ac7b232ae Mon Sep 17 00:00:00 2001 From: oaris-dev <76177243+oaris-dev@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:24:08 +0100 Subject: [PATCH 1/2] fix: bypass broken workflow package for self-hosted transcription and AI generation The `workflow` package (4.0.1-beta.42) `[local world]` mode crashes with `TypeError: Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer` on all Node versions (20, 22, 24), breaking transcription and AI generation for every self-hosted Docker deployment. See #1550. This replaces `start(transcribeVideoWorkflow, ...)` and `start(generateAiWorkflow, ...)` with direct async function calls that perform the same operations without workflow/step directives. Changes: - lib/transcribe.ts: Replace workflow dispatch with transcribeVideoDirect() that validates, extracts audio, calls Deepgram, saves VTT, and cleans up - lib/generate-ai.ts: Replace workflow dispatch with generateAiDirect() that fetches transcript, calls AI APIs, and saves metadata - actions/videos/get-status.ts: Set PROCESSING before firing transcription to prevent re-trigger loops, add 3-minute stale PROCESSING timeout The workflow files are preserved so Cap Cloud's distributed execution (via web-cluster/WORKFLOWS_RPC_URL) continues to work unchanged. Co-Authored-By: Claude Opus 4.6 --- apps/web/actions/videos/get-status.ts | 46 +-- apps/web/lib/generate-ai.ts | 441 +++++++++++++++++++++++++- apps/web/lib/transcribe.ts | 143 ++++++++- 3 files changed, 586 insertions(+), 44 deletions(-) diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 8805576963..3a056ddf25 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -77,27 +77,37 @@ export async function getVideoStatus( console.log( `[Get Status] Transcription not started for video ${videoId}, triggering transcription`, ); - try { - transcribeVideo(videoId, video.ownerId).catch((error) => { - console.error( - `[Get Status] Error starting transcription for video ${videoId}:`, - error, - ); - }); - return { - transcriptionStatus: "PROCESSING", - aiGenerationStatus: - (metadata.aiGenerationStatus as AiGenerationStatus) || null, - aiTitle: metadata.aiTitle || null, - summary: metadata.summary || null, - chapters: metadata.chapters || null, - }; - } catch (error) { + await db() + .update(videos) + .set({ transcriptionStatus: "PROCESSING" }) + .where(eq(videos.id, videoId)); + + transcribeVideo(videoId, video.ownerId, false, true).catch((error) => { console.error( - `[Get Status] Error triggering transcription for video ${videoId}:`, + `[Get Status] Error starting transcription for video ${videoId}:`, error, ); + }); + + return { + transcriptionStatus: "PROCESSING", + aiGenerationStatus: + (metadata.aiGenerationStatus as AiGenerationStatus) || null, + aiTitle: metadata.aiTitle || null, + summary: metadata.summary || null, + chapters: metadata.chapters || null, + }; + } + + if (video.transcriptionStatus === "PROCESSING") { + const threeMinutesAgo = new Date(Date.now() - 3 * 60 * 1000); + if (video.updatedAt < threeMinutesAgo) { + await db() + .update(videos) + .set({ transcriptionStatus: "ERROR" }) + .where(eq(videos.id, videoId)); + return { transcriptionStatus: "ERROR", aiGenerationStatus: @@ -105,7 +115,7 @@ export async function getVideoStatus( aiTitle: metadata.aiTitle || null, summary: metadata.summary || null, chapters: metadata.chapters || null, - error: "Failed to start transcription", + error: "Transcription timed out", }; } } diff --git a/apps/web/lib/generate-ai.ts b/apps/web/lib/generate-ai.ts index 641a6e1545..a47bceff6a 100644 --- a/apps/web/lib/generate-ai.ts +++ b/apps/web/lib/generate-ai.ts @@ -1,11 +1,13 @@ import { db } from "@cap/database"; -import { videos } from "@cap/database/schema"; +import { s3Buckets, videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; -import type { Video } from "@cap/web-domain"; +import { S3Buckets } from "@cap/web-backend"; +import type { S3Bucket, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { start } from "workflow/api"; -import { generateAiWorkflow } from "@/workflows/generate-ai"; +import { Effect, Option } from "effect"; +import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client"; +import { runPromise } from "@/lib/server"; type GenerateAiResult = { success: boolean; @@ -77,23 +79,32 @@ export async function startAiGeneration( .set({ metadata: { ...metadata, - aiGenerationStatus: "QUEUED", + aiGenerationStatus: "PROCESSING", }, }) .where(eq(videos.id, videoId)); - await start(generateAiWorkflow, [{ videoId, userId }]); + await generateAiDirect(videoId, userId); return { success: true, - message: "AI generation workflow started", + message: "AI generation completed", }; - } catch { + } catch (error) { + console.error("[startAiGeneration] AI generation failed:", error); + + const freshVideo = await db() + .select({ metadata: videos.metadata }) + .from(videos) + .where(eq(videos.id, videoId)); + const freshMetadata = + (freshVideo[0]?.metadata as VideoMetadata) || metadata; + await db() .update(videos) .set({ metadata: { - ...metadata, + ...freshMetadata, aiGenerationStatus: "ERROR", }, }) @@ -101,7 +112,417 @@ export async function startAiGeneration( return { success: false, - message: "Failed to start AI generation workflow", + message: "AI generation failed", + }; + } +} + +interface VttSegment { + start: number; + text: string; +} + +interface TranscriptData { + segments: VttSegment[]; + text: string; +} + +interface AiResult { + title?: string; + summary?: string; + chapters?: { title: string; start: number }[]; +} + +const MAX_CHARS_PER_CHUNK = 24000; + +async function generateAiDirect( + videoId: string, + userId: string, +): Promise { + const query = await db() + .select({ video: videos, bucket: s3Buckets }) + .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) + .where(eq(videos.id, videoId as Video.VideoId)); + + if (query.length === 0 || !query[0]?.video) { + throw new Error("Video does not exist"); + } + + const { video, bucket } = query[0]; + const metadata = (video.metadata as VideoMetadata) || {}; + const bucketId = (bucket?.id ?? null) as S3Bucket.S3BucketId | null; + + if (video.transcriptionStatus !== "COMPLETE") { + throw new Error("Transcription not complete"); + } + + const vtt = await Effect.gen(function* () { + const [s3Bucket] = yield* S3Buckets.getBucketAccess( + Option.fromNullable(bucketId), + ); + return yield* s3Bucket.getObject(`${userId}/${videoId}/transcription.vtt`); + }).pipe(runPromise); + + if (Option.isNone(vtt)) { + await db() + .update(videos) + .set({ metadata: { ...metadata, aiGenerationStatus: "SKIPPED" } }) + .where(eq(videos.id, videoId as Video.VideoId)); + return; + } + + const segments = parseVttWithTimestamps(vtt.value); + const text = segments + .map((s) => s.text) + .join(" ") + .trim(); + + if (text.length < 10) { + await db() + .update(videos) + .set({ metadata: { ...metadata, aiGenerationStatus: "SKIPPED" } }) + .where(eq(videos.id, videoId as Video.VideoId)); + return; + } + + const transcript: TranscriptData = { segments, text }; + const groqClient = getGroqClient(); + const chunks = chunkTranscriptWithTimestamps(transcript.segments); + + let aiResult: AiResult; + if (chunks.length === 1) { + aiResult = await generateSingleChunk(transcript.text, groqClient); + } else { + aiResult = await generateMultipleChunks(chunks, groqClient); + } + + const updatedMetadata: VideoMetadata = { + ...metadata, + aiTitle: aiResult.title || metadata.aiTitle, + summary: aiResult.summary || metadata.summary, + chapters: aiResult.chapters || metadata.chapters, + aiGenerationStatus: "COMPLETE", + }; + + await db() + .update(videos) + .set({ metadata: updatedMetadata }) + .where(eq(videos.id, videoId as Video.VideoId)); + + const hasDatePattern = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test( + video.name || "", + ); + + if ( + (video.name?.startsWith("Cap Recording -") || hasDatePattern) && + aiResult.title + ) { + await db() + .update(videos) + .set({ name: aiResult.title }) + .where(eq(videos.id, videoId as Video.VideoId)); + } +} + +function parseVttWithTimestamps(vttContent: string): VttSegment[] { + const lines = vttContent.split("\n"); + const segments: VttSegment[] = []; + let currentStart = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim() ?? ""; + if (line.includes("-->")) { + const timeMatch = line.match(/(\d{2}):(\d{2}):(\d{2})[.,](\d{3})/); + if (timeMatch) { + currentStart = + parseInt(timeMatch[1] ?? "0", 10) * 3600 + + parseInt(timeMatch[2] ?? "0", 10) * 60 + + parseInt(timeMatch[3] ?? "0", 10); + } + } else if ( + line && + line !== "WEBVTT" && + !/^\d+$/.test(line) && + !line.includes("-->") + ) { + segments.push({ start: currentStart, text: line }); + } + } + + return segments; +} + +function chunkTranscriptWithTimestamps( + segments: VttSegment[], +): { text: string; startTime: number; endTime: number }[] { + const chunks: { text: string; startTime: number; endTime: number }[] = []; + let currentChunk: VttSegment[] = []; + let currentLength = 0; + + for (const segment of segments) { + if ( + currentLength + segment.text.length > MAX_CHARS_PER_CHUNK && + currentChunk.length > 0 + ) { + chunks.push({ + text: currentChunk.map((s) => s.text).join(" "), + startTime: currentChunk[0]?.start ?? 0, + endTime: currentChunk[currentChunk.length - 1]?.start ?? 0, + }); + currentChunk = []; + currentLength = 0; + } + currentChunk.push(segment); + currentLength += segment.text.length + 1; + } + + if (currentChunk.length > 0) { + chunks.push({ + text: currentChunk.map((s) => s.text).join(" "), + startTime: currentChunk[0]?.start ?? 0, + endTime: currentChunk[currentChunk.length - 1]?.start ?? 0, + }); + } + + return chunks; +} + +async function callAiApi( + prompt: string, + groqClient: ReturnType, +): Promise { + if (groqClient) { + try { + const completion = await groqClient.chat.completions.create({ + messages: [{ role: "user", content: prompt }], + model: GROQ_MODEL, + }); + return completion.choices?.[0]?.message?.content || "{}"; + } catch (groqError) { + if (serverEnv().OPENAI_API_KEY) { + return callOpenAi(prompt); + } + throw groqError; + } + } + if (serverEnv().OPENAI_API_KEY) { + return callOpenAi(prompt); + } + return "{}"; +} + +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(); + throw new Error(`OpenAI API error: ${aiRes.status} ${errorText}`); + } + const aiJson = await aiRes.json(); + return aiJson.choices?.[0]?.message?.content || "{}"; +} + +function cleanJsonResponse(content: string): string { + if (content.includes("```json")) { + return content.replace(/```json\s*/g, "").replace(/```\s*/g, ""); + } + if (content.includes("```")) { + return content.replace(/```\s*/g, ""); + } + return content; +} + +async function generateSingleChunk( + transcriptText: string, + groqClient: ReturnType, +): Promise { + const prompt = `You are Cap AI, an expert at analyzing video content and creating comprehensive summaries. + +Analyze this transcript thoroughly and provide a detailed JSON response: +{ + "title": "string (concise but descriptive title that captures the main topic)", + "summary": "string (detailed summary that covers ALL key points discussed. For meetings: include decisions made, action items, and key discussion points. For tutorials: cover all steps and concepts explained. For presentations: summarize all main arguments and supporting points. Write from 1st person perspective if the speaker is teaching/presenting, e.g. 'In this video, I walk through...'. Make it comprehensive enough that someone could understand the full content without watching.)", + "chapters": [{"title": "string (descriptive chapter title)", "start": number (seconds from start)}] +} + +Guidelines: +- The summary should be detailed and comprehensive, not a brief overview +- Capture ALL important topics, not just the main theme +- For longer content, organize the summary by topic or chronologically +- Include specific details, names, numbers, and conclusions mentioned +- Chapters should mark distinct topic changes or sections + +Return ONLY valid JSON without any markdown formatting or code blocks. +Transcript: +${transcriptText}`; + + const content = await callAiApi(prompt, groqClient); + return parseAiResponse(content); +} + +async function generateMultipleChunks( + chunks: { text: string; startTime: number; endTime: number }[], + groqClient: ReturnType, +): Promise { + const chunkSummaries: { + summary: string; + keyPoints: string[]; + chapters: { title: string; start: number }[]; + startTime: number; + endTime: number; + }[] = []; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + if (!chunk) continue; + + const chunkPrompt = `You are Cap AI, an expert at analyzing video content. This is section ${i + 1} of ${chunks.length} from a longer video (timestamp ${Math.floor(chunk.startTime / 60)}:${String(chunk.startTime % 60).padStart(2, "0")} to ${Math.floor(chunk.endTime / 60)}:${String(chunk.endTime % 60).padStart(2, "0")}). + +Analyze this section thoroughly and provide JSON: +{ + "summary": "string (detailed summary of this section - capture ALL key points, topics discussed, decisions made, or concepts explained. Include specific details like names, numbers, action items, and conclusions. This should be 3-6 sentences minimum.)", + "keyPoints": ["string (specific key point or takeaway)", ...], + "chapters": [{"title": "string (descriptive title for this topic/section)", "start": number (seconds from video start)}] +} + +Be thorough - this summary will be combined with other sections to create a comprehensive overview. +Return ONLY valid JSON without any markdown formatting or code blocks. +Transcript section: +${chunk.text}`; + + const chunkContent = await callAiApi(chunkPrompt, groqClient); + try { + const parsed = JSON.parse(cleanJsonResponse(chunkContent).trim()); + chunkSummaries.push({ + summary: parsed.summary || "", + keyPoints: parsed.keyPoints || [], + chapters: parsed.chapters || [], + startTime: chunk.startTime, + endTime: chunk.endTime, + }); + } catch (parseError) { + console.error( + `[generateAiDirect] Failed to parse AI chunk ${i + 1} response:`, + parseError, + ); + } + } + + if (chunkSummaries.length === 0) { + throw new Error("All AI chunk summary parses failed"); + } + + const allChapters: { title: string; start: number }[] = []; + const sortedChapters = chunkSummaries + .flatMap((c) => c.chapters) + .sort((a, b) => a.start - b.start); + for (const chapter of sortedChapters) { + const lastChapter = allChapters[allChapters.length - 1]; + if (!lastChapter || Math.abs(chapter.start - lastChapter.start) >= 30) { + allChapters.push(chapter); + } + } + + const allKeyPoints = chunkSummaries.flatMap((c) => c.keyPoints); + + const sectionDetails = chunkSummaries + .map((c, i) => { + const timeRange = `${Math.floor(c.startTime / 60)}:${String(c.startTime % 60).padStart(2, "0")} - ${Math.floor(c.endTime / 60)}:${String(c.endTime % 60).padStart(2, "0")}`; + const keyPointsList = + c.keyPoints.length > 0 ? `\nKey points: ${c.keyPoints.join("; ")}` : ""; + return `Section ${i + 1} (${timeRange}):\n${c.summary}${keyPointsList}`; + }) + .join("\n\n"); + + const finalPrompt = `You are Cap AI, an expert at synthesizing information into comprehensive, well-organized summaries. + +Based on these detailed section analyses of a video, create a thorough final summary that captures EVERYTHING important. + +Section analyses: +${sectionDetails} + +${allKeyPoints.length > 0 ? `All key points identified:\n${allKeyPoints.map((p, i) => `${i + 1}. ${p}`).join("\n")}\n` : ""} + +Provide JSON in the following format: +{ + "title": "string (concise but descriptive title that captures the main topic/purpose)", + "summary": "string (COMPREHENSIVE summary that covers the entire video thoroughly. This should be detailed enough that someone could understand all the important content without watching. Include: main topics covered, key decisions or conclusions, important details mentioned, action items if any. Organize it logically - for meetings use topics/agenda items, for tutorials use steps/concepts, for presentations use main arguments. Write from 1st person perspective if appropriate. This should be several paragraphs for longer content.)" +} + +The summary must be detailed and comprehensive - not a brief overview. Capture all the important information from every section. +Return ONLY valid JSON without any markdown formatting or code blocks.`; + + const finalContent = await callAiApi(finalPrompt, groqClient); + try { + const parsed = JSON.parse(cleanJsonResponse(finalContent).trim()); + return { + title: parsed.title, + summary: parsed.summary, + chapters: allChapters, + }; + } catch (parseError) { + console.error( + "[generateAiDirect] Failed to parse final summary response, using fallback:", + parseError, + ); + const fallbackSummary = chunkSummaries + .map((c, i) => `**Part ${i + 1}:** ${c.summary}`) + .join("\n\n"); + const keyPointsSummary = + allKeyPoints.length > 0 + ? `\n\n**Key Points:**\n${allKeyPoints.map((p) => `- ${p}`).join("\n")}` + : ""; + return { + title: "Video Summary", + summary: fallbackSummary + keyPointsSummary, + chapters: allChapters, + }; + } +} + +function parseAiResponse(content: string): AiResult { + try { + const data = JSON.parse(cleanJsonResponse(content).trim()); + + if (data.chapters && data.chapters.length > 0) { + const sortedChapters = data.chapters.sort( + (a: { start: number }, b: { start: number }) => a.start - b.start, + ); + const dedupedChapters: { title: string; start: number }[] = []; + for (const chapter of sortedChapters) { + const lastChapter = dedupedChapters[dedupedChapters.length - 1]; + if (!lastChapter || Math.abs(chapter.start - lastChapter.start) >= 30) { + dedupedChapters.push(chapter); + } + } + data.chapters = dedupedChapters; + } + + return { + title: data.title, + summary: data.summary, + chapters: data.chapters, + }; + } catch (parseError) { + console.error( + "[generateAiDirect] Failed to parse final AI response:", + parseError, + ); + return { + title: "Generated Title", + summary: + "The AI was unable to generate a proper summary for this content.", + chapters: [], }; } } diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 8a7c874832..dcff227952 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -1,10 +1,21 @@ +import { promises as fs } from "node:fs"; import { db } from "@cap/database"; import { organizations, s3Buckets, videos } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import type { Video } from "@cap/web-domain"; +import { S3Buckets } from "@cap/web-backend"; +import type { S3Bucket, Video } from "@cap/web-domain"; +import { createClient } from "@deepgram/sdk"; import { eq } from "drizzle-orm"; -import { start } from "workflow/api"; -import { transcribeVideoWorkflow } from "@/workflows/transcribe"; +import { Option } from "effect"; +import { checkHasAudioTrack, extractAudioFromUrl } from "@/lib/audio-extract"; +import { startAiGeneration } from "@/lib/generate-ai"; +import { + checkHasAudioTrackViaMediaServer, + extractAudioViaMediaServer, + isMediaServerConfigured, +} from "@/lib/media-client"; +import { runPromise } from "@/lib/server"; +import { type DeepgramResult, formatToWebVTT } from "@/lib/transcribe-utils"; type TranscribeResult = { success: boolean; @@ -85,7 +96,7 @@ export async function transcribeVideo( if ( video.transcriptionStatus === "COMPLETE" || - video.transcriptionStatus === "PROCESSING" || + (!_isRetry && video.transcriptionStatus === "PROCESSING") || video.transcriptionStatus === "SKIPPED" || video.transcriptionStatus === "NO_AUDIO" ) { @@ -97,32 +108,132 @@ export async function transcribeVideo( try { console.log( - `[transcribeVideo] Triggering transcription workflow for video ${videoId}`, + `[transcribeVideo] Starting direct transcription for video ${videoId}`, ); - await start(transcribeVideoWorkflow, [ - { - videoId, - userId, - aiGenerationEnabled, - }, - ]); + await transcribeVideoDirect(videoId, userId, aiGenerationEnabled); return { success: true, - message: "Transcription workflow started", + message: "Transcription completed", }; } catch (error) { - console.error("[transcribeVideo] Failed to trigger workflow:", error); + console.error("[transcribeVideo] Transcription failed:", error); await db() .update(videos) - .set({ transcriptionStatus: null }) + .set({ transcriptionStatus: "ERROR" }) .where(eq(videos.id, videoId)); return { success: false, - message: "Failed to start transcription workflow", + message: "Transcription failed", }; } } + +async function transcribeVideoDirect( + videoId: string, + userId: string, + aiGenerationEnabled: boolean, +): Promise { + await db() + .update(videos) + .set({ transcriptionStatus: "PROCESSING" }) + .where(eq(videos.id, videoId as Video.VideoId)); + + const query = await db() + .select({ + bucket: s3Buckets, + }) + .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) + .where(eq(videos.id, videoId as Video.VideoId)); + + const row = query[0]; + if (!row) { + throw new Error("Video does not exist"); + } + + const bucketId = (row.bucket?.id ?? null) as S3Bucket.S3BucketId | null; + + const [s3Bucket] = await S3Buckets.getBucketAccess( + Option.fromNullable(bucketId), + ).pipe(runPromise); + + const videoKey = `${userId}/${videoId}/result.mp4`; + const videoUrl = await s3Bucket.getSignedObjectUrl(videoKey).pipe(runPromise); + + const headResponse = await fetch(videoUrl, { + method: "GET", + headers: { range: "bytes=0-0" }, + }); + if (!headResponse.ok) { + throw new Error("Video file not accessible"); + } + + const useMediaServer = isMediaServerConfigured(); + let hasAudio: boolean; + let audioBuffer: Buffer; + + if (useMediaServer) { + hasAudio = await checkHasAudioTrackViaMediaServer(videoUrl); + if (!hasAudio) { + await db() + .update(videos) + .set({ transcriptionStatus: "NO_AUDIO" }) + .where(eq(videos.id, videoId as Video.VideoId)); + return; + } + audioBuffer = await extractAudioViaMediaServer(videoUrl); + } else { + hasAudio = await checkHasAudioTrack(videoUrl); + if (!hasAudio) { + await db() + .update(videos) + .set({ transcriptionStatus: "NO_AUDIO" }) + .where(eq(videos.id, videoId as Video.VideoId)); + return; + } + const extracted = await extractAudioFromUrl(videoUrl); + try { + audioBuffer = await fs.readFile(extracted.filePath); + } finally { + await extracted.cleanup(); + } + } + + const deepgram = createClient(serverEnv().DEEPGRAM_API_KEY as string); + + const { result, error } = await deepgram.listen.prerecorded.transcribeFile( + audioBuffer, + { + model: "nova-3", + smart_format: true, + detect_language: true, + utterances: true, + mime_type: "audio/mpeg", + }, + ); + + if (error) { + throw new Error(`Deepgram transcription failed: ${error.message}`); + } + + const transcription = formatToWebVTT(result as unknown as DeepgramResult); + + await s3Bucket + .putObject(`${userId}/${videoId}/transcription.vtt`, transcription, { + contentType: "text/vtt", + }) + .pipe(runPromise); + + await db() + .update(videos) + .set({ transcriptionStatus: "COMPLETE" }) + .where(eq(videos.id, videoId as Video.VideoId)); + + if (aiGenerationEnabled) { + await startAiGeneration(videoId as Video.VideoId, userId); + } +} From ce2819dc6c2f0b5235d6dec79001ca9fd54bdb13 Mon Sep 17 00:00:00 2001 From: oaris-dev <76177243+oaris-dev@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:24:17 +0100 Subject: [PATCH 2/2] fix: resolve TypeScript strict mode errors in HomePage components Fix 'possibly undefined' errors in IntersectionObserver callbacks and array indexing across four HomePage components: - InstantModeDetail: guard IntersectionObserver entry and TABS indexing - RecordingModePicker: guard IntersectionObserver entry and modes indexing - ScreenshotModeDetail: guard IntersectionObserver entry and AUTO_CONFIGS - StudioModeDetail: guard AUTO_CONFIGS indexing Co-Authored-By: Claude Opus 4.6 --- apps/web/components/pages/HomePage/InstantModeDetail.tsx | 8 +++++--- .../web/components/pages/HomePage/RecordingModePicker.tsx | 8 +++++--- .../components/pages/HomePage/ScreenshotModeDetail.tsx | 6 ++++-- apps/web/components/pages/HomePage/StudioModeDetail.tsx | 1 + 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/components/pages/HomePage/InstantModeDetail.tsx b/apps/web/components/pages/HomePage/InstantModeDetail.tsx index ebd3887eb4..d2ce348271 100644 --- a/apps/web/components/pages/HomePage/InstantModeDetail.tsx +++ b/apps/web/components/pages/HomePage/InstantModeDetail.tsx @@ -111,8 +111,9 @@ const MockSharePage = () => { if (!container || !video) return; const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting) { if (!videoLoadedRef.current) { video.src = "/illustrations/homepage-animation.mp4"; videoLoadedRef.current = true; @@ -138,7 +139,8 @@ const MockSharePage = () => { let index = 0; const interval = setInterval(() => { index = (index + 1) % TABS.length; - setActiveTab(TABS[index]); + const tab = TABS[index]; + if (tab) setActiveTab(tab); }, 3000); return () => clearInterval(interval); }, [tabInteracted]); diff --git a/apps/web/components/pages/HomePage/RecordingModePicker.tsx b/apps/web/components/pages/HomePage/RecordingModePicker.tsx index 25c2b4e106..dbf61647a9 100644 --- a/apps/web/components/pages/HomePage/RecordingModePicker.tsx +++ b/apps/web/components/pages/HomePage/RecordingModePicker.tsx @@ -101,7 +101,8 @@ const RecordingModePicker = () => { const interval = setInterval(() => { setSelected((prev) => { const currentIndex = modes.findIndex((m) => m.id === prev); - return modes[(currentIndex + 1) % modes.length].id; + const next = modes[(currentIndex + 1) % modes.length]; + return next ? next.id : prev; }); }, AUTO_CYCLE_INTERVAL); @@ -113,8 +114,9 @@ const RecordingModePicker = () => { if (!el) return; const observer = new IntersectionObserver( - ([entry]) => { - setIsInView(entry.isIntersecting); + (entries) => { + const entry = entries[0]; + if (entry) setIsInView(entry.isIntersecting); }, { threshold: 0.3 }, ); diff --git a/apps/web/components/pages/HomePage/ScreenshotModeDetail.tsx b/apps/web/components/pages/HomePage/ScreenshotModeDetail.tsx index 22ffd6e50a..270f41cfe2 100644 --- a/apps/web/components/pages/HomePage/ScreenshotModeDetail.tsx +++ b/apps/web/components/pages/HomePage/ScreenshotModeDetail.tsx @@ -373,8 +373,9 @@ const MockScreenshotEditor = () => { if (!el) return; const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !isInView) { + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting && !isInView) { setIsInView(true); } }, @@ -413,6 +414,7 @@ const MockScreenshotEditor = () => { if (cancelled) return; const next = (current + 1) % AUTO_CONFIGS.length; const cfg = AUTO_CONFIGS[next]; + if (!cfg) return; setGradientIndex(cfg.gradientIndex); setPadding(cfg.padding); setRounded(cfg.rounded); diff --git a/apps/web/components/pages/HomePage/StudioModeDetail.tsx b/apps/web/components/pages/HomePage/StudioModeDetail.tsx index 34a5642c42..e8b39f71e2 100644 --- a/apps/web/components/pages/HomePage/StudioModeDetail.tsx +++ b/apps/web/components/pages/HomePage/StudioModeDetail.tsx @@ -283,6 +283,7 @@ const MockEditor = () => { if (cancelled) return; const next = (current + 1) % AUTO_CONFIGS.length; const cfg = AUTO_CONFIGS[next]; + if (!cfg) return; setGradientIndex(cfg.gradientIndex); setPadding(cfg.padding); setRounded(cfg.rounded);