From 7fc140a3aafcfe5dd8877588f3e7fc7e028498e2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 18 May 2026 20:34:02 +0100 Subject: [PATCH] feat: add AI generation language preferences --- .../__tests__/unit/generate-ai-title.test.ts | 17 ++- .../unit/transcribe-language.test.ts | 88 +++++++++++++ apps/web/actions/organization/settings.ts | 28 +++- .../actions/videos/translation-languages.ts | 33 +---- .../components/CapSettingsCard.tsx | 123 +++++++++++++++++- apps/web/app/s/[videoId]/Share.tsx | 3 +- apps/web/workflows/generate-ai.ts | 54 +++++++- apps/web/workflows/transcribe.ts | 109 +++++++++++++--- packages/database/schema.ts | 2 + packages/web-domain/src/Language.ts | 117 +++++++++++++++++ packages/web-domain/src/index.ts | 2 + 11 files changed, 513 insertions(+), 63 deletions(-) create mode 100644 apps/web/__tests__/unit/transcribe-language.test.ts create mode 100644 packages/web-domain/src/Language.ts diff --git a/apps/web/__tests__/unit/generate-ai-title.test.ts b/apps/web/__tests__/unit/generate-ai-title.test.ts index 3a231cd4ab5..a21560b65e2 100644 --- a/apps/web/__tests__/unit/generate-ai-title.test.ts +++ b/apps/web/__tests__/unit/generate-ai-title.test.ts @@ -31,7 +31,10 @@ vi.mock("workflow", () => ({ vi.mock("server-only", () => ({})); -import { shouldReplaceVideoTitle } from "@/workflows/generate-ai"; +import { + getAiLanguageInstruction, + shouldReplaceVideoTitle, +} from "@/workflows/generate-ai"; describe("shouldReplaceVideoTitle", () => { it("replaces default Cap titles", () => { @@ -84,3 +87,15 @@ describe("shouldReplaceVideoTitle", () => { ).toBe(false); }); }); + +describe("getAiLanguageInstruction", () => { + it("uses transcript language when auto-detect is selected", () => { + expect(getAiLanguageInstruction("auto")).toContain( + "same language as the transcript", + ); + }); + + it("uses the selected language name", () => { + expect(getAiLanguageInstruction("es")).toContain("Spanish"); + }); +}); diff --git a/apps/web/__tests__/unit/transcribe-language.test.ts b/apps/web/__tests__/unit/transcribe-language.test.ts new file mode 100644 index 00000000000..4e8a9d646b4 --- /dev/null +++ b/apps/web/__tests__/unit/transcribe-language.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/database", () => ({ + db: vi.fn(), +})); + +vi.mock("@cap/env", () => ({ + serverEnv: vi.fn(() => ({})), +})); + +vi.mock("@cap/utils", () => ({ + userIsPro: vi.fn(), +})); + +vi.mock("@cap/web-backend", () => ({ + Storage: {}, +})); + +vi.mock("@deepgram/sdk", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/lib/audio-enhance", () => ({ + ENHANCED_AUDIO_CONTENT_TYPE: "audio/mpeg", + ENHANCED_AUDIO_EXTENSION: "mp3", + enhanceAudioFromUrl: vi.fn(), +})); + +vi.mock("@/lib/audio-extract", () => ({ + checkHasAudioTrack: vi.fn(), + extractAudioFromUrl: vi.fn(), +})); + +vi.mock("@/lib/generate-ai", () => ({ + startAiGeneration: vi.fn(), +})); + +vi.mock("@/lib/media-client", () => ({ + checkHasAudioTrackViaMediaServer: vi.fn(), + extractAudioViaMediaServer: vi.fn(), + isMediaServerConfigured: vi.fn(), + probeVideoViaMediaServer: vi.fn(), +})); + +vi.mock("@/lib/server", () => ({ + runPromise: vi.fn(), +})); + +vi.mock("@/lib/transcribe-utils", () => ({ + formatToWebVTT: vi.fn(), +})); + +vi.mock("@/lib/video-storage", () => ({ + decodeStorageVideo: vi.fn(), +})); + +vi.mock("workflow", () => ({ + FatalError: class FatalError extends Error {}, +})); + +import { + AI_GENERATION_LANGUAGES, + isAiGenerationLanguage, + parseAiGenerationLanguage, +} from "@cap/web-domain"; +import { getDeepgramTranscriptionOptions } from "@/workflows/transcribe"; + +describe("AI generation language support", () => { + it("does not expose unsupported transcription languages", () => { + expect(AI_GENERATION_LANGUAGES).not.toHaveProperty("pa"); + expect(isAiGenerationLanguage("pa")).toBe(false); + expect(parseAiGenerationLanguage("pa")).toBe("auto"); + }); + + it("constrains Deepgram auto-detection to detectable languages", () => { + expect(getDeepgramTranscriptionOptions("auto")).toMatchObject({ + model: "nova-3", + detect_language: expect.arrayContaining(["en", "es", "zh"]), + }); + }); + + it("passes explicit languages to Deepgram", () => { + expect(getDeepgramTranscriptionOptions("zh")).toMatchObject({ + model: "nova-3", + language: "zh", + }); + }); +}); diff --git a/apps/web/actions/organization/settings.ts b/apps/web/actions/organization/settings.ts index 6e3b8901a4c..08da887c44d 100644 --- a/apps/web/actions/organization/settings.ts +++ b/apps/web/actions/organization/settings.ts @@ -4,6 +4,11 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; import { userIsPro } from "@cap/utils"; +import { + AI_GENERATION_LANGUAGE_AUTO, + type AiGenerationLanguage, + isAiGenerationLanguage, +} from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requireOrganizationSettingsManager } from "./authorization"; @@ -17,6 +22,7 @@ type OrganizationSettingsInput = { disableComments?: boolean; hideShareableLinkCapLogo?: boolean; shareableLinkUseOrganizationIcon?: boolean; + aiGenerationLanguage?: AiGenerationLanguage; }; const proOrganizationSettingKeys = [ @@ -25,8 +31,21 @@ const proOrganizationSettingKeys = [ "disableTranscript", "hideShareableLinkCapLogo", "shareableLinkUseOrganizationIcon", + "aiGenerationLanguage", ] as const satisfies readonly (keyof OrganizationSettingsInput)[]; +const defaultProOrganizationSettings = { + disableSummary: false, + disableChapters: false, + disableTranscript: false, + hideShareableLinkCapLogo: false, + shareableLinkUseOrganizationIcon: false, + aiGenerationLanguage: AI_GENERATION_LANGUAGE_AUTO, +} as const satisfies Pick< + Required, + (typeof proOrganizationSettingKeys)[number] +>; + const preserveProSettings = ( submittedSettings: OrganizationSettingsInput, existingSettings: OrganizationSettingsInput | null | undefined, @@ -35,7 +54,7 @@ const preserveProSettings = ( ...Object.fromEntries( proOrganizationSettingKeys.map((key) => [ key, - existingSettings?.[key] ?? false, + existingSettings?.[key] ?? defaultProOrganizationSettings[key], ]), ), }); @@ -53,6 +72,13 @@ export async function updateOrganizationSettings( throw new Error("Settings are required"); } + if ( + settings.aiGenerationLanguage !== undefined && + !isAiGenerationLanguage(settings.aiGenerationLanguage) + ) { + throw new Error("Unsupported AI generation language"); + } + if (!user.activeOrganizationId) { throw new Error("Organization not found"); } diff --git a/apps/web/actions/videos/translation-languages.ts b/apps/web/actions/videos/translation-languages.ts index 7f39e0f1ec3..c0e88143620 100644 --- a/apps/web/actions/videos/translation-languages.ts +++ b/apps/web/actions/videos/translation-languages.ts @@ -1,29 +1,4 @@ -export const SUPPORTED_LANGUAGES = { - en: "English", - es: "Spanish", - fr: "French", - de: "German", - pt: "Portuguese", - it: "Italian", - nl: "Dutch", - pl: "Polish", - sk: "Slovak", - ru: "Russian", - tr: "Turkish", - ja: "Japanese", - ko: "Korean", - zh: "Chinese (Simplified)", - ar: "Arabic", - hi: "Hindi", - bn: "Bengali", - ta: "Tamil", - te: "Telugu", - mr: "Marathi", - gu: "Gujarati", - pa: "Punjabi", - ur: "Urdu", - fa: "Persian", - he: "Hebrew", -} as const; - -export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES; +export { + type LanguageCode, + SUPPORTED_LANGUAGES, +} from "@cap/web-domain"; diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx index 9c3ea1217c6..df4e4158bbf 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx @@ -1,8 +1,16 @@ "use client"; import { Card, CardDescription, CardHeader, CardTitle, Switch } from "@cap/ui"; +import { + AI_GENERATION_LANGUAGE_AUTO, + AI_GENERATION_LANGUAGES, + type AiGenerationLanguage, + getAiGenerationLanguageName, + isAiGenerationLanguage, +} from "@cap/web-domain"; import { useDebounce } from "@uidotdev/usehooks"; import clsx from "clsx"; +import { ChevronDown, Globe } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { updateOrganizationSettings } from "@/actions/organization/settings"; @@ -18,11 +26,17 @@ const defaultSettings: OrganizationSettings = { disableTranscript: false, hideShareableLinkCapLogo: false, shareableLinkUseOrganizationIcon: false, + aiGenerationLanguage: AI_GENERATION_LANGUAGE_AUTO, }; +type BooleanOrganizationSettingKey = Exclude< + keyof OrganizationSettings, + "aiGenerationLanguage" +>; + const options: Array<{ label: string; - value: keyof OrganizationSettings; + value: BooleanOrganizationSettingKey; description: string; pro?: boolean; }> = [ @@ -67,9 +81,19 @@ const options: Array<{ }, ]; -const mergeSettings = (settings?: OrganizationSettings | null) => ({ +const languageOptions = Object.entries(AI_GENERATION_LANGUAGES) as [ + AiGenerationLanguage, + string, +][]; + +const mergeSettings = ( + settings?: OrganizationSettings | null, +): OrganizationSettings => ({ ...defaultSettings, ...(settings ?? {}), + aiGenerationLanguage: isAiGenerationLanguage(settings?.aiGenerationLanguage) + ? settings.aiGenerationLanguage + : AI_GENERATION_LANGUAGE_AUTO, }); const CapSettingsCard = () => { @@ -77,10 +101,14 @@ const CapSettingsCard = () => { const initialSettings = mergeSettings(organizationSettings); const [settings, setSettings] = useState(initialSettings); + const [showLanguageMenu, setShowLanguageMenu] = useState(false); const lastSavedSettings = useRef(initialSettings); + const languageMenuRef = useRef(null); const debouncedUpdateSettings = useDebounce(settings, 1000); + const selectedLanguage = + settings.aiGenerationLanguage ?? AI_GENERATION_LANGUAGE_AUTO; useEffect(() => { const next = mergeSettings(organizationSettings); @@ -88,6 +116,20 @@ const CapSettingsCard = () => { lastSavedSettings.current = next; }, [organizationSettings]); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + languageMenuRef.current && + !languageMenuRef.current.contains(event.target as Node) + ) { + setShowLanguageMenu(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + useEffect(() => { if ( debouncedUpdateSettings && @@ -113,6 +155,16 @@ const CapSettingsCard = () => { await updateOrganizationSettings(debouncedUpdateSettings); changedKeys.forEach((changedKey) => { + if (changedKey === "aiGenerationLanguage") { + const language = + debouncedUpdateSettings.aiGenerationLanguage ?? + AI_GENERATION_LANGUAGE_AUTO; + toast.success( + `AI language set to ${getAiGenerationLanguageName(language)}`, + ); + return; + } + const option = options.find((opt) => opt.value === changedKey); if (changedKey === "hideShareableLinkCapLogo") { toast.success( @@ -121,7 +173,7 @@ const CapSettingsCard = () => { : "Cap logo shown", ); } else { - const isDisabled = debouncedUpdateSettings[changedKey]; + const isDisabled = Boolean(debouncedUpdateSettings[changedKey]); const action = isDisabled ? "disabled" : "enabled"; const label = option?.label.split(" ")[1] || changedKey; toast.success( @@ -142,7 +194,7 @@ const CapSettingsCard = () => { } }, [debouncedUpdateSettings, organizationSettings]); - const handleToggle = (key: keyof OrganizationSettings) => { + const handleToggle = (key: BooleanOrganizationSettingKey) => { setSettings((prev) => { const newValue = !prev?.[key]; @@ -162,6 +214,18 @@ const CapSettingsCard = () => { }); }; + const handleLanguageChange = (language: AiGenerationLanguage) => { + if (!isAiGenerationLanguage(language)) { + return; + } + + setShowLanguageMenu(false); + setSettings((prev) => ({ + ...prev, + aiGenerationLanguage: language, + })); + }; + return ( @@ -206,6 +270,57 @@ const CapSettingsCard = () => { ))} + +
+
+
+

AI generation language

+

+ Pro +

+
+

+ Set the language used for transcripts, titles, summaries, and + chapters. +

+
+
+ + {showLanguageMenu && ( +
+ {languageOptions.map(([code, name], index) => ( +
+ {index === 1 && ( +
+ )} + +
+ ))} +
+ )} +
+
); }; diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 1e2b5a851cc..e4b577f459b 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -1,6 +1,7 @@ "use client"; import type { comments as commentsSchema } from "@cap/database/schema"; +import type { ViewerSettingKey } from "@cap/web-backend"; import type { ImageUpload, Video } from "@cap/web-domain"; import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "next/navigation"; @@ -448,7 +449,7 @@ export const Share = ({ }, 100); }, []); - const isDisabled = (setting: keyof NonNullable) => + const isDisabled = (setting: ViewerSettingKey) => videoSettings?.[setting] ?? data.orgSettings?.[setting] ?? false; const areChaptersDisabled = isDisabled("disableChapters"); diff --git a/apps/web/workflows/generate-ai.ts b/apps/web/workflows/generate-ai.ts index ebe222ea74a..9a6d8b11096 100644 --- a/apps/web/workflows/generate-ai.ts +++ b/apps/web/workflows/generate-ai.ts @@ -1,9 +1,15 @@ import { db } from "@cap/database"; -import { videos } from "@cap/database/schema"; +import { organizations, videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; import { Storage } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; +import { + AI_GENERATION_LANGUAGE_AUTO, + type AiGenerationLanguage, + getAiGenerationLanguageName, + parseAiGenerationLanguage, + type Video, +} from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { FatalError } from "workflow"; @@ -19,6 +25,7 @@ interface GenerateAiWorkflowPayload { interface VideoData { video: typeof videos.$inferSelect; metadata: VideoMetadata; + aiGenerationLanguage: AiGenerationLanguage; } interface VttSegment { @@ -76,7 +83,10 @@ export async function generateAiWorkflow(payload: GenerateAiWorkflowPayload) { }; } - const result = await generateWithAi(transcript); + const result = await generateWithAi( + transcript, + videoData.aiGenerationLanguage, + ); await saveResults(videoId, videoData, result); @@ -92,8 +102,9 @@ async function validateAndSetProcessing(videoId: string): Promise { } const query = await db() - .select({ video: videos }) + .select({ video: videos, orgSettings: organizations.settings }) .from(videos) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) .where(eq(videos.id, videoId as Video.VideoId)); if (query.length === 0 || !query[0]?.video) { @@ -124,6 +135,9 @@ async function validateAndSetProcessing(videoId: string): Promise { return { video, metadata, + aiGenerationLanguage: parseAiGenerationLanguage( + query[0]?.orgSettings?.aiGenerationLanguage, + ), }; } @@ -175,13 +189,17 @@ async function markSkipped( .where(eq(videos.id, videoId as Video.VideoId)); } -async function generateWithAi(transcript: TranscriptData): Promise { +async function generateWithAi( + transcript: TranscriptData, + language: AiGenerationLanguage, +): Promise { "use step"; const groqClient = getGroqClient(); const chunks = chunkTranscriptWithTimestamps(transcript.segments); const videoDuration = getVideoDuration(transcript.segments); + const languageInstruction = getAiLanguageInstruction(language); let result: AiResult; if (chunks.length === 1) { @@ -189,9 +207,15 @@ async function generateWithAi(transcript: TranscriptData): Promise { transcript.segments, videoDuration, groqClient, + languageInstruction, ); } else { - result = await generateMultipleChunks(chunks, videoDuration, groqClient); + result = await generateMultipleChunks( + chunks, + videoDuration, + groqClient, + languageInstruction, + ); } if (result.chapters) { @@ -201,6 +225,16 @@ async function generateWithAi(transcript: TranscriptData): Promise { return result; } +export function getAiLanguageInstruction( + language: AiGenerationLanguage, +): string { + if (language === AI_GENERATION_LANGUAGE_AUTO) { + return "Write the title, summary, chapter titles, section summaries, and key points in the same language as the transcript."; + } + + return `Write the title, summary, chapter titles, section summaries, and key points in ${getAiGenerationLanguageName(language)}.`; +} + function getVideoDuration(segments: VttSegment[]): number { if (segments.length === 0) return 0; const lastSegment = segments[segments.length - 1]; @@ -388,6 +422,7 @@ async function generateSingleChunk( segments: VttSegment[], videoDuration: number, groqClient: ReturnType, + languageInstruction: string, ): Promise { const transcriptWithTimestamps = segments .map( @@ -406,6 +441,8 @@ The video is ${videoDuration} seconds long (${Math.floor(videoDuration / 60)}:${ } Guidelines: +- ${languageInstruction} +- Keep JSON property names exactly as shown - 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 @@ -425,6 +462,7 @@ async function generateMultipleChunks( chunks: { text: string; startTime: number; endTime: number }[], videoDuration: number, groqClient: ReturnType, + languageInstruction: string, ): Promise { const chunkSummaries: { summary: string; @@ -447,6 +485,8 @@ Analyze this section thoroughly and provide JSON: "chapters": [{"title": "string (descriptive title for this topic/section)", "start": number (seconds from video start)}] } +${languageInstruction} +Keep JSON property names exactly as shown. IMPORTANT: All chapter "start" values MUST be between ${chunk.startTime} and ${chunk.endTime} seconds. The total video is only ${videoDuration} seconds long. 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. @@ -505,6 +545,8 @@ Provide JSON in the following format: } The summary must be detailed and comprehensive - not a brief overview. Capture all the important information from every section. +${languageInstruction} +Keep JSON property names exactly as shown. Return ONLY valid JSON without any markdown formatting or code blocks.`; const finalContent = await callAiApi(finalPrompt, groqClient); diff --git a/apps/web/workflows/transcribe.ts b/apps/web/workflows/transcribe.ts index a08ca3b2298..1f985ce47a2 100644 --- a/apps/web/workflows/transcribe.ts +++ b/apps/web/workflows/transcribe.ts @@ -10,7 +10,13 @@ import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; import { Storage } from "@cap/web-backend"; -import type { Video } from "@cap/web-domain"; +import { + AI_GENERATION_LANGUAGE_AUTO, + type AiGenerationLanguage, + type AiGenerationLanguageCode, + parseAiGenerationLanguage, + type Video, +} from "@cap/web-domain"; import { createClient } from "@deepgram/sdk"; import { eq } from "drizzle-orm"; import { FatalError } from "workflow"; @@ -41,6 +47,7 @@ interface VideoData { video: typeof videos.$inferSelect; transcriptionDisabled: boolean; isOwnerPro: boolean; + aiGenerationLanguage: AiGenerationLanguage; } export async function transcribeVideoWorkflow( @@ -57,19 +64,27 @@ export async function transcribeVideoWorkflow( return { success: true, message: "Transcription disabled - skipped" }; } - const audioUrl = await extractAudio(videoId, userId, videoData.video); - - if (!audioUrl) { - await markNoAudio(videoId); - return { - success: true, - message: "Video has no audio track - skipped transcription", - }; - } + try { + const audioUrl = await extractAudio(videoId, userId, videoData.video); + + if (!audioUrl) { + await markNoAudio(videoId); + return { + success: true, + message: "Video has no audio track - skipped transcription", + }; + } - const [transcription] = await Promise.all([transcribeWithDeepgram(audioUrl)]); + const [transcription] = await Promise.all([ + transcribeWithDeepgram(audioUrl, videoData.aiGenerationLanguage), + ]); - await saveTranscription(videoId, userId, videoData.video, transcription); + await saveTranscription(videoId, userId, videoData.video, transcription); + } catch (error) { + await markError(videoId); + await cleanupTempAudio(videoId, userId, videoData.video); + throw error; + } await cleanupTempAudio(videoId, userId, videoData.video); @@ -128,6 +143,9 @@ async function validateVideo(videoId: string): Promise { video: result.video, transcriptionDisabled, isOwnerPro, + aiGenerationLanguage: parseAiGenerationLanguage( + result.orgSettings?.aiGenerationLanguage, + ), }; } @@ -149,6 +167,15 @@ async function markNoAudio(videoId: string): Promise { .where(eq(videos.id, videoId as Video.VideoId)); } +async function markError(videoId: string): Promise { + "use step"; + + await db() + .update(videos) + .set({ transcriptionStatus: "ERROR" }) + .where(eq(videos.id, videoId as Video.VideoId)); +} + async function extractAudio( videoId: string, userId: string, @@ -274,7 +301,33 @@ async function resolveVideoSourceUrl( throw new Error("Video file not accessible"); } -async function transcribeWithDeepgram(audioUrl: string): Promise { +export function getDeepgramTranscriptionOptions( + language: AiGenerationLanguage, +) { + const baseOptions = { + model: "nova-3", + smart_format: true, + utterances: true, + mime_type: "audio/mpeg", + } as const; + + if (language === AI_GENERATION_LANGUAGE_AUTO) { + return { + ...baseOptions, + detect_language: [...DEEPGRAM_DETECTABLE_LANGUAGES], + }; + } + + return { + ...baseOptions, + language, + }; +} + +async function transcribeWithDeepgram( + audioUrl: string, + language: AiGenerationLanguage, +): Promise { "use step"; const audioResponse = await fetch(audioUrl); @@ -290,22 +343,36 @@ async function transcribeWithDeepgram(audioUrl: string): Promise { const { result, error } = await deepgram.listen.prerecorded.transcribeFile( audioBuffer, - { - model: "nova-3", - smart_format: true, - detect_language: true, - utterances: true, - mime_type: "audio/mpeg", - }, + getDeepgramTranscriptionOptions(language), ); if (error) { - throw new Error(`Deepgram transcription failed: ${error.message}`); + throw new Error( + `Deepgram transcription failed (language=${language}): ${error.message}`, + ); } return formatToWebVTT(result as unknown as DeepgramResult); } +const DEEPGRAM_DETECTABLE_LANGUAGES = [ + "en", + "es", + "fr", + "de", + "pt", + "it", + "nl", + "pl", + "sk", + "ru", + "tr", + "ja", + "ko", + "zh", + "hi", +] as const satisfies readonly AiGenerationLanguageCode[]; + async function saveTranscription( videoId: string, userId: string, diff --git a/packages/database/schema.ts b/packages/database/schema.ts index ce7778766d6..b9e75d34bdf 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1,4 +1,5 @@ import type { + AiGenerationLanguage, Comment, Folder, ImageUpload, @@ -202,6 +203,7 @@ export const organizations = mysqlTable( disableComments?: boolean; hideShareableLinkCapLogo?: boolean; shareableLinkUseOrganizationIcon?: boolean; + aiGenerationLanguage?: AiGenerationLanguage; }>(), iconUrl: varchar("iconUrl", { length: 1024, diff --git a/packages/web-domain/src/Language.ts b/packages/web-domain/src/Language.ts new file mode 100644 index 00000000000..5c366239ce3 --- /dev/null +++ b/packages/web-domain/src/Language.ts @@ -0,0 +1,117 @@ +export const SUPPORTED_LANGUAGES = { + en: "English", + es: "Spanish", + fr: "French", + de: "German", + pt: "Portuguese", + it: "Italian", + nl: "Dutch", + pl: "Polish", + sk: "Slovak", + ru: "Russian", + tr: "Turkish", + ja: "Japanese", + ko: "Korean", + zh: "Chinese (Simplified)", + ar: "Arabic", + hi: "Hindi", + bn: "Bengali", + ta: "Tamil", + te: "Telugu", + mr: "Marathi", + gu: "Gujarati", + pa: "Punjabi", + ur: "Urdu", + fa: "Persian", + he: "Hebrew", +} as const; + +export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES; + +export const AI_GENERATION_LANGUAGE_AUTO = "auto"; + +export const AI_GENERATION_LANGUAGE_CODES = [ + "en", + "es", + "fr", + "de", + "pt", + "it", + "nl", + "pl", + "sk", + "ru", + "tr", + "ja", + "ko", + "zh", + "ar", + "hi", + "bn", + "ta", + "te", + "mr", + "gu", + "ur", + "fa", + "he", +] as const satisfies readonly LanguageCode[]; + +export type AiGenerationLanguageCode = + (typeof AI_GENERATION_LANGUAGE_CODES)[number]; + +export type AiGenerationLanguage = + | typeof AI_GENERATION_LANGUAGE_AUTO + | AiGenerationLanguageCode; + +export const AI_GENERATION_LANGUAGES = { + [AI_GENERATION_LANGUAGE_AUTO]: "Auto-detect", + en: SUPPORTED_LANGUAGES.en, + es: SUPPORTED_LANGUAGES.es, + fr: SUPPORTED_LANGUAGES.fr, + de: SUPPORTED_LANGUAGES.de, + pt: SUPPORTED_LANGUAGES.pt, + it: SUPPORTED_LANGUAGES.it, + nl: SUPPORTED_LANGUAGES.nl, + pl: SUPPORTED_LANGUAGES.pl, + sk: SUPPORTED_LANGUAGES.sk, + ru: SUPPORTED_LANGUAGES.ru, + tr: SUPPORTED_LANGUAGES.tr, + ja: SUPPORTED_LANGUAGES.ja, + ko: SUPPORTED_LANGUAGES.ko, + zh: SUPPORTED_LANGUAGES.zh, + ar: SUPPORTED_LANGUAGES.ar, + hi: SUPPORTED_LANGUAGES.hi, + bn: SUPPORTED_LANGUAGES.bn, + ta: SUPPORTED_LANGUAGES.ta, + te: SUPPORTED_LANGUAGES.te, + mr: SUPPORTED_LANGUAGES.mr, + gu: SUPPORTED_LANGUAGES.gu, + ur: SUPPORTED_LANGUAGES.ur, + fa: SUPPORTED_LANGUAGES.fa, + he: SUPPORTED_LANGUAGES.he, +} as const; + +export function isLanguageCode(value: unknown): value is LanguageCode { + return typeof value === "string" && Object.hasOwn(SUPPORTED_LANGUAGES, value); +} + +export function isAiGenerationLanguage( + value: unknown, +): value is AiGenerationLanguage { + return ( + typeof value === "string" && Object.hasOwn(AI_GENERATION_LANGUAGES, value) + ); +} + +export function parseAiGenerationLanguage( + value: unknown, +): AiGenerationLanguage { + return isAiGenerationLanguage(value) ? value : AI_GENERATION_LANGUAGE_AUTO; +} + +export function getAiGenerationLanguageName( + language: AiGenerationLanguage, +): string { + return AI_GENERATION_LANGUAGES[language]; +} diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index 8db2b2c1b05..b8aca7049a4 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -6,6 +6,8 @@ export * from "./Errors.ts"; export * as Folder from "./Folder.ts"; export * as Http from "./Http/index.ts"; export * as ImageUpload from "./ImageUpload.ts"; +export * as Language from "./Language.ts"; +export * from "./Language.ts"; export * as Loom from "./Loom.ts"; export * as Organisation from "./Organisation.ts"; export * from "./Organisation.ts";