Skip to content

Commit d9c4961

Browse files
authored
fix: transcript race condition (#999)
* transcription status pending while recording * use null as a value instead of pending * Update Transcript.tsx * cleanup * Delete route.ts * Update Transcript.tsx * Update transcribe.ts
1 parent 736ff6c commit d9c4961

File tree

5 files changed

+171
-16
lines changed

5 files changed

+171
-16
lines changed

apps/web/actions/videos/get-status.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ const MAX_AI_PROCESSING_TIME = 10 * 60 * 1000;
1616

1717
export interface VideoStatusResult {
1818
transcriptionStatus: "PROCESSING" | "COMPLETE" | "ERROR" | null;
19-
aiProcessing: boolean;
2019
aiTitle: string | null;
20+
aiProcessing: boolean;
2121
summary: string | null;
2222
chapters: { title: string; start: number }[] | null;
23-
// generationError: string | null;
2423
error?: string;
2524
}
2625

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { db } from "@cap/database";
2+
import { getCurrentUser } from "@cap/database/auth/session";
3+
import { videos } from "@cap/database/schema";
4+
import { eq } from "drizzle-orm";
5+
import { revalidatePath } from "next/cache";
6+
7+
export async function POST(
8+
_request: Request,
9+
{ params }: { params: { videoId: string } },
10+
) {
11+
try {
12+
const user = await getCurrentUser();
13+
if (!user) {
14+
return Response.json({ error: "Unauthorized" }, { status: 401 });
15+
}
16+
17+
const { videoId } = params;
18+
if (!videoId) {
19+
return Response.json({ error: "Video ID is required" }, { status: 400 });
20+
}
21+
22+
// Verify user owns the video
23+
const videoQuery = await db()
24+
.select()
25+
.from(videos)
26+
.where(eq(videos.id, videoId))
27+
.limit(1);
28+
29+
if (videoQuery.length === 0) {
30+
return Response.json({ error: "Video not found" }, { status: 404 });
31+
}
32+
33+
const video = videoQuery[0];
34+
if (!video || video.ownerId !== user.id) {
35+
return Response.json({ error: "Unauthorized" }, { status: 403 });
36+
}
37+
38+
// Reset status to null - this will trigger automatic retry via get-status.ts
39+
await db()
40+
.update(videos)
41+
.set({ transcriptionStatus: null })
42+
.where(eq(videos.id, videoId));
43+
44+
// Revalidate the video page to ensure UI updates with fresh data
45+
revalidatePath(`/s/${videoId}`);
46+
47+
return Response.json({
48+
success: true,
49+
message: "Transcription retry triggered",
50+
});
51+
} catch (error) {
52+
console.error("Error resetting transcription status:", error);
53+
return Response.json({ error: "Internal server error" }, { status: 500 });
54+
}
55+
}

apps/web/app/s/[videoId]/_components/tabs/Transcript.tsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import type { videos } from "@cap/database/schema";
44
import { Button } from "@cap/ui";
5+
import { useMutation } from "@tanstack/react-query";
56
import { useInvalidateTranscript, useTranscript } from "hooks/use-transcript";
67
import { Check, Copy, Download, Edit3, MessageSquare, X } from "lucide-react";
78
import { useEffect, useState } from "react";
@@ -142,6 +143,33 @@ export const Transcript: React.FC<TranscriptProps> = ({
142143

143144
const invalidateTranscript = useInvalidateTranscript();
144145

146+
const retryTranscriptionMutation = useMutation({
147+
mutationFn: async () => {
148+
const response = await fetch(
149+
`/api/videos/${data.id}/retry-transcription`,
150+
{
151+
method: "POST",
152+
headers: { "Content-Type": "application/json" },
153+
},
154+
);
155+
156+
if (!response.ok) {
157+
const errorText = await response.text();
158+
throw new Error(`Failed to retry transcription: ${errorText}`);
159+
}
160+
161+
return response.json();
162+
},
163+
onSuccess: () => {
164+
// Reset status - Share.tsx polling will automatically detect the change and trigger transcription
165+
setIsTranscriptionProcessing(true);
166+
invalidateTranscript(data.id);
167+
},
168+
onError: (error) => {
169+
console.error("Failed to retry transcription:", error);
170+
},
171+
});
172+
145173
useEffect(() => {
146174
if (transcriptContent) {
147175
const parsed = parseVTT(transcriptContent);
@@ -212,11 +240,6 @@ export const Transcript: React.FC<TranscriptProps> = ({
212240
}
213241
}, [data.id, data.transcriptionStatus, data.createdAt]);
214242

215-
const handleReset = () => {
216-
setIsLoading(true);
217-
invalidateTranscript(data.id);
218-
};
219-
220243
const handleTranscriptClick = (entry: TranscriptEntry) => {
221244
if (editingEntry === entry.id) {
222245
return;
@@ -421,14 +444,39 @@ export const Transcript: React.FC<TranscriptProps> = ({
421444
);
422445
}
423446

424-
if (hasTimedOut || (!transcriptData.length && !isTranscriptionProcessing)) {
447+
const showRetryButton =
448+
data.transcriptionStatus === "ERROR" ||
449+
data.transcriptionStatus === null ||
450+
(!transcriptData.length && !isTranscriptionProcessing);
451+
452+
if (showRetryButton) {
425453
return (
426454
<div className="flex justify-center items-center h-full text-gray-1">
427455
<div className="text-center">
428456
<MessageSquare className="mx-auto mb-2 w-8 h-8 text-gray-300" />
429-
<p className="text-sm font-medium text-gray-12">
430-
No transcript available
457+
<p className="mb-4 text-sm font-medium text-gray-12">
458+
{data.transcriptionStatus === "ERROR"
459+
? "Transcript not available"
460+
: "No transcript available"}
431461
</p>
462+
{canEdit &&
463+
(data.transcriptionStatus === "ERROR" ||
464+
data.transcriptionStatus === null ||
465+
hasTimedOut) && (
466+
<Button
467+
onClick={() => {
468+
retryTranscriptionMutation.mutate();
469+
}}
470+
disabled={retryTranscriptionMutation.isPending}
471+
variant="primary"
472+
size="sm"
473+
spinner={retryTranscriptionMutation.isPending}
474+
>
475+
{retryTranscriptionMutation.isPending
476+
? "Retrying..."
477+
: "Retry Transcription"}
478+
</Button>
479+
)}
432480
</div>
433481
</div>
434482
);
@@ -518,6 +566,7 @@ export const Transcript: React.FC<TranscriptProps> = ({
518566
e.stopPropagation();
519567
startEditing(entry);
520568
}}
569+
type="button"
521570
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-gray-3 rounded-md transition-all duration-200"
522571
title="Edit transcript"
523572
>

apps/web/hooks/use-transcript.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,6 @@ export const useTranscript = (
1313
if (result.success && result.content) {
1414
return result.content;
1515
} else {
16-
console.error(
17-
"[useTranscript] Failed to fetch transcript:",
18-
result.message,
19-
);
2016
if (result.message === "Transcript is not ready yet") {
2117
throw new Error("TRANSCRIPT_NOT_READY");
2218
}

apps/web/lib/transcribe.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export async function transcribeVideo(
1616
videoId: Video.VideoId,
1717
userId: string,
1818
aiGenerationEnabled = false,
19+
isRetry = false,
1920
): Promise<TranscribeResult> {
2021
if (!serverEnv().DEEPGRAM_API_KEY) {
2122
return {
@@ -77,8 +78,39 @@ export async function transcribeVideo(
7778

7879
const videoUrl = await bucket.getSignedObjectUrl(videoKey);
7980

81+
// Check if video file actually exists before transcribing
82+
try {
83+
const headResponse = await fetch(videoUrl, { method: "HEAD" });
84+
if (!headResponse.ok) {
85+
// Video not ready yet - reset to null for retry
86+
await db()
87+
.update(videos)
88+
.set({ transcriptionStatus: null })
89+
.where(eq(videos.id, videoId));
90+
91+
return {
92+
success: false,
93+
message: "Video file not ready yet - will retry automatically",
94+
};
95+
}
96+
} catch {
97+
console.log(
98+
`[transcribeVideo] Video file not accessible yet for ${videoId}, will retry later`,
99+
);
100+
await db()
101+
.update(videos)
102+
.set({ transcriptionStatus: null })
103+
.where(eq(videos.id, videoId));
104+
105+
return {
106+
success: false,
107+
message: "Video file not ready yet - will retry automatically",
108+
};
109+
}
110+
80111
const transcription = await transcribeAudio(videoUrl);
81112

113+
// Note: Empty transcription is valid for silent videos (just contains "WEBVTT\n\n")
82114
if (transcription === "") {
83115
throw new Error("Failed to transcribe audio");
84116
}
@@ -127,19 +159,43 @@ export async function transcribeVideo(
127159
};
128160
} catch (error) {
129161
console.error("Error transcribing video:", error);
162+
163+
// Determine if this is a temporary or permanent error
164+
const errorMessage = error instanceof Error ? error.message : String(error);
165+
const isTemporaryError =
166+
errorMessage.includes("not found") ||
167+
errorMessage.includes("access denied") ||
168+
errorMessage.includes("network") ||
169+
!isRetry; // First attempt failures are often temporary
170+
171+
const newStatus = isTemporaryError ? null : "ERROR";
172+
130173
await db()
131174
.update(videos)
132-
.set({ transcriptionStatus: "ERROR" })
175+
.set({ transcriptionStatus: newStatus })
133176
.where(eq(videos.id, videoId));
134177

135-
return { success: false, message: "Error processing video file" };
178+
return {
179+
success: false,
180+
message: isTemporaryError
181+
? "Video not ready - will retry"
182+
: "Transcription failed permanently",
183+
};
136184
}
137185
}
138186

139187
function formatToWebVTT(result: any): string {
140188
let output = "WEBVTT\n\n";
141189
let captionIndex = 1;
142190

191+
// Handle case where there are no utterances (silent video)
192+
if (!result.results.utterances || result.results.utterances.length === 0) {
193+
console.log(
194+
"[formatToWebVTT] No utterances found - video appears to be silent",
195+
);
196+
return output; // Return valid but empty VTT file
197+
}
198+
143199
result.results.utterances.forEach((utterance: any) => {
144200
const words = utterance.words;
145201
let group = [];

0 commit comments

Comments
 (0)