Skip to content

Commit 4c9e3e2

Browse files
committed
Add caption translation, context, and UI
1 parent 78dd5c9 commit 4c9e3e2

10 files changed

Lines changed: 917 additions & 164 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"use server";
2+
3+
import { db } from "@cap/database";
4+
import { s3Buckets, videos } from "@cap/database/schema";
5+
import { S3Buckets } from "@cap/web-backend";
6+
import type { Video } from "@cap/web-domain";
7+
import { eq } from "drizzle-orm";
8+
import { Effect, Option } from "effect";
9+
import { runPromise } from "@/lib/server";
10+
import { type LanguageCode, SUPPORTED_LANGUAGES } from "./translate-transcript";
11+
12+
interface AvailableTranslation {
13+
code: LanguageCode;
14+
name: string;
15+
}
16+
17+
interface GetAvailableTranslationsResult {
18+
success: boolean;
19+
hasOriginal: boolean;
20+
translations: AvailableTranslation[];
21+
message?: string;
22+
}
23+
24+
export async function getAvailableTranslations(
25+
videoId: Video.VideoId,
26+
): Promise<GetAvailableTranslationsResult> {
27+
if (!videoId) {
28+
return {
29+
success: false,
30+
hasOriginal: false,
31+
translations: [],
32+
message: "Missing video ID",
33+
};
34+
}
35+
36+
const query = await db()
37+
.select({
38+
video: videos,
39+
bucket: s3Buckets,
40+
})
41+
.from(videos)
42+
.leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id))
43+
.where(eq(videos.id, videoId));
44+
45+
if (query.length === 0 || !query[0]?.video) {
46+
return {
47+
success: false,
48+
hasOriginal: false,
49+
translations: [],
50+
message: "Video not found",
51+
};
52+
}
53+
54+
const { video } = query[0];
55+
const prefix = `${video.ownerId}/${videoId}/transcription`;
56+
57+
try {
58+
const result = await Effect.gen(function* () {
59+
const [bucket] = yield* S3Buckets.getBucketAccess(
60+
Option.fromNullable(query[0]?.bucket?.id),
61+
);
62+
63+
const listResult = yield* bucket.listObjects({
64+
prefix,
65+
maxKeys: 50,
66+
});
67+
68+
return listResult;
69+
}).pipe(runPromise);
70+
71+
const contents = result.Contents || [];
72+
73+
let hasOriginal = false;
74+
const translations: AvailableTranslation[] = [];
75+
76+
for (const obj of contents) {
77+
const key = obj.Key;
78+
if (!key) continue;
79+
80+
if (key.endsWith("/transcription.vtt")) {
81+
hasOriginal = true;
82+
continue;
83+
}
84+
85+
const match = key.match(/transcription\.([a-z]{2})\.vtt$/);
86+
if (match) {
87+
const langCode = match[1] as LanguageCode;
88+
if (SUPPORTED_LANGUAGES[langCode]) {
89+
translations.push({
90+
code: langCode,
91+
name: SUPPORTED_LANGUAGES[langCode],
92+
});
93+
}
94+
}
95+
}
96+
97+
return {
98+
success: true,
99+
hasOriginal,
100+
translations,
101+
};
102+
} catch (error) {
103+
console.error("[getAvailableTranslations] Error:", error);
104+
return {
105+
success: false,
106+
hasOriginal: false,
107+
translations: [],
108+
message: "Failed to list translations",
109+
};
110+
}
111+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"use server";
2+
3+
import { db } from "@cap/database";
4+
import { s3Buckets, videos } from "@cap/database/schema";
5+
import { S3Buckets } from "@cap/web-backend";
6+
import type { Video } from "@cap/web-domain";
7+
import { eq } from "drizzle-orm";
8+
import { Effect, Option } from "effect";
9+
import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client";
10+
import { runPromise } from "@/lib/server";
11+
12+
export const SUPPORTED_LANGUAGES = {
13+
en: "English",
14+
es: "Spanish",
15+
fr: "French",
16+
de: "German",
17+
pt: "Portuguese",
18+
it: "Italian",
19+
nl: "Dutch",
20+
pl: "Polish",
21+
ru: "Russian",
22+
ja: "Japanese",
23+
ko: "Korean",
24+
zh: "Chinese (Simplified)",
25+
ar: "Arabic",
26+
hi: "Hindi",
27+
} as const;
28+
29+
export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
30+
31+
interface TranslateResult {
32+
success: boolean;
33+
translatedVtt?: string;
34+
message: string;
35+
}
36+
37+
export async function translateTranscript(
38+
videoId: Video.VideoId,
39+
targetLanguage: LanguageCode,
40+
): Promise<TranslateResult> {
41+
if (!videoId || !targetLanguage) {
42+
return {
43+
success: false,
44+
message: "Missing required parameters",
45+
};
46+
}
47+
48+
if (!SUPPORTED_LANGUAGES[targetLanguage]) {
49+
return {
50+
success: false,
51+
message: "Unsupported language",
52+
};
53+
}
54+
55+
const groq = getGroqClient();
56+
if (!groq) {
57+
return {
58+
success: false,
59+
message: "Translation service not configured",
60+
};
61+
}
62+
63+
const query = await db()
64+
.select({
65+
video: videos,
66+
bucket: s3Buckets,
67+
})
68+
.from(videos)
69+
.leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id))
70+
.where(eq(videos.id, videoId));
71+
72+
if (query.length === 0 || !query[0]?.video) {
73+
return { success: false, message: "Video not found" };
74+
}
75+
76+
const { video } = query[0];
77+
78+
const translatedKey = `${video.ownerId}/${videoId}/transcription.${targetLanguage}.vtt`;
79+
80+
try {
81+
const existingTranslation = await Effect.gen(function* () {
82+
const [bucket] = yield* S3Buckets.getBucketAccess(
83+
Option.fromNullable(query[0]?.bucket?.id),
84+
);
85+
return yield* bucket.getObject(translatedKey);
86+
}).pipe(runPromise);
87+
88+
if (Option.isSome(existingTranslation)) {
89+
return {
90+
success: true,
91+
translatedVtt: existingTranslation.value,
92+
message: "Retrieved cached translation",
93+
};
94+
}
95+
} catch (e) {
96+
console.debug("[translateTranscript] No cached translation found:", e);
97+
}
98+
99+
const originalVtt = await Effect.gen(function* () {
100+
const [bucket] = yield* S3Buckets.getBucketAccess(
101+
Option.fromNullable(query[0]?.bucket?.id),
102+
);
103+
return yield* bucket.getObject(
104+
`${video.ownerId}/${videoId}/transcription.vtt`,
105+
);
106+
}).pipe(runPromise);
107+
108+
if (Option.isNone(originalVtt)) {
109+
return { success: false, message: "Original transcript not found" };
110+
}
111+
112+
const translatedVtt = await translateVttContent(
113+
originalVtt.value,
114+
targetLanguage,
115+
groq,
116+
);
117+
118+
if (!translatedVtt) {
119+
return { success: false, message: "Translation failed" };
120+
}
121+
122+
try {
123+
await Effect.gen(function* () {
124+
const [bucket] = yield* S3Buckets.getBucketAccess(
125+
Option.fromNullable(query[0]?.bucket?.id),
126+
);
127+
yield* bucket.putObject(translatedKey, translatedVtt, {
128+
contentType: "text/vtt",
129+
});
130+
}).pipe(runPromise);
131+
} catch (error) {
132+
console.error("[translateTranscript] Failed to cache translation:", error);
133+
}
134+
135+
return {
136+
success: true,
137+
translatedVtt,
138+
message: "Translation completed",
139+
};
140+
}
141+
142+
async function translateVttContent(
143+
vttContent: string,
144+
targetLanguage: LanguageCode,
145+
groq: NonNullable<ReturnType<typeof getGroqClient>>,
146+
): Promise<string | null> {
147+
const targetLanguageName = SUPPORTED_LANGUAGES[targetLanguage];
148+
149+
const prompt = `Translate the following WebVTT subtitle file to ${targetLanguageName}.
150+
151+
IMPORTANT RULES:
152+
1. Keep the "WEBVTT" header exactly as is
153+
2. Keep all timestamp lines exactly as they are (e.g., "00:00:01.234 --> 00:00:03.456")
154+
3. Keep all cue numbers exactly as they are
155+
4. Only translate the actual text content on each line
156+
5. Preserve all newlines and formatting
157+
6. Do not add any explanations or comments
158+
7. Return ONLY the translated VTT content
159+
160+
VTT content to translate:
161+
162+
${vttContent}`;
163+
164+
try {
165+
const response = await groq.chat.completions.create({
166+
model: GROQ_MODEL,
167+
messages: [{ role: "user", content: prompt }],
168+
temperature: 0.3,
169+
max_tokens: 8000,
170+
});
171+
172+
const content = response.choices[0]?.message?.content;
173+
if (content?.includes("WEBVTT")) {
174+
return content.trim();
175+
}
176+
177+
return null;
178+
} catch (error) {
179+
console.error("[translateVttContent] Translation error:", error);
180+
return null;
181+
}
182+
}

0 commit comments

Comments
 (0)