Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions .github/workflows/docker-build-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ on:
required: false
default: "latest"
type: string
publish_ecr:
description: "Build for Cap production and publish the web image to ECR"
required: false
default: false
type: boolean

jobs:
build:
Expand All @@ -24,6 +29,9 @@ jobs:
permissions:
contents: read
packages: write
env:
PUBLIC_WEB_URL: ${{ inputs.publish_ecr && 'https://cap.take3tech.dev' || 'http://localhost:3000' }}
RELEASE_REPOSITORY: ${{ inputs.publish_ecr && github.repository || 'CapSoftware/Cap' }}

steps:
- name: Checkout Repository
Expand All @@ -34,7 +42,7 @@ jobs:

- name: Create .env file
run: |
echo "WEB_URL=http://localhost:3000" > .env
echo "WEB_URL=${PUBLIC_WEB_URL}" > .env
echo "NEXT_PUBLIC_DOCKER_BUILD=true" >> .env

- name: Login to GitHub Container Registry
Expand All @@ -55,6 +63,11 @@ jobs:
context: .
file: apps/web/Dockerfile
platforms: linux/${{ matrix.platform }}
build-args: |
NEXT_PUBLIC_DOCKER_BUILD=true
NEXT_PUBLIC_WEB_URL=${{ env.PUBLIC_WEB_URL }}
TAKE3_DESKTOP_RELEASE_REPOSITORY=${{ env.RELEASE_REPOSITORY }}
TAKE3_DESKTOP_RELEASE_TAG_PREFIX=take3-desktop-
push: true
outputs: type=image,name=ghcr.io/${{ env.REPOSITORY_OWNER }}/cap-web,push-by-digest=true
cache-from: type=gha,scope=buildx-${{ matrix.platform }}
Expand All @@ -78,6 +91,8 @@ jobs:
name: Create Multi-Architecture Manifest
needs: build
runs-on: ubuntu-latest
outputs:
production_image: ${{ steps.manifest.outputs.production_image }}
permissions:
contents: read
packages: write
Expand Down Expand Up @@ -105,6 +120,55 @@ jobs:
echo "REPOSITORY_OWNER=${GITHUB_REPOSITORY_OWNER@L}" >>${GITHUB_ENV}

- name: Create Image Manifest
id: manifest
env:
IMAGE_TAG: ${{ inputs.tag || 'latest' }}
run: |
docker buildx imagetools create -t ghcr.io/${{ env.REPOSITORY_OWNER }}/cap-web:${{ inputs.tag || 'latest' }} \
production_image="ghcr.io/${REPOSITORY_OWNER}/cap-web:run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
docker buildx imagetools create \
-t "ghcr.io/${REPOSITORY_OWNER}/cap-web:${IMAGE_TAG}" \
-t "$production_image" \
$(find /tmp/digests -type f -not -path "*/\.*" -exec basename {} \; | xargs -I {} echo "ghcr.io/${{ env.REPOSITORY_OWNER }}/cap-web@sha256:{}")
echo "production_image=$production_image" >> "$GITHUB_OUTPUT"

publish-ecr:
name: Publish Production Web Image
if: ${{ inputs.publish_ecr }}
needs: merge
runs-on: ubuntu-24.04
environment: production
permissions:
contents: read
packages: read
id-token: write
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::159207264407:role/cap-github-actions-ecr-publisher
aws-region: us-east-2

- name: Log in to Amazon ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2

- name: Copy production image
env:
SOURCE_IMAGE: ${{ needs.merge.outputs.production_image }}
ECR_REGISTRY: ${{ steps.ecr.outputs.registry }}
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y skopeo
skopeo copy --all --preserve-digests \
--src-authfile "$HOME/.docker/config.json" \
--dest-authfile "$HOME/.docker/config.json" \
"docker://${SOURCE_IMAGE}" \
"docker://${ECR_REGISTRY}/cap-web:sha-${GITHUB_SHA}"
8 changes: 5 additions & 3 deletions apps/web/__tests__/integration/transcribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@cap/env", () => ({
serverEnv: vi.fn(() => ({
DEEPGRAM_API_KEY: "test-deepgram-api-key",
AI_GATEWAY_TRANSCRIPTION_URL:
"http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000/media/cap/nova-3/transcribe",
AI_GATEWAY_TRANSCRIPTION_API_KEY: "test-gateway-api-key",
DATABASE_URL: "mysql://test@localhost/test",
})),
}));
Expand Down Expand Up @@ -78,10 +80,10 @@ describe("transcribeVideo", () => {
});

describe("input validation", () => {
it("requires DEEPGRAM_API_KEY environment variable", async () => {
it("requires transcription gateway configuration", async () => {
const { serverEnv } = await import("@cap/env");
vi.mocked(serverEnv).mockReturnValueOnce({
DEEPGRAM_API_KEY: undefined,
AI_GATEWAY_TRANSCRIPTION_API_KEY: undefined,
} as ReturnType<typeof serverEnv>);

const result = await transcribeVideo(
Expand Down
4 changes: 2 additions & 2 deletions apps/web/actions/videos/get-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { db } from "@cap/database";
import { users, videos, videoUploads } from "@cap/database/schema";
import type { VideoMetadata } from "@cap/database/types";
import { serverEnv } from "@cap/env";
import { provideOptionalAuth, VideosPolicy } from "@cap/web-backend";
import { Policy, type Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
Expand All @@ -15,6 +14,7 @@ import {
} from "@/lib/desktop-segments-finalization";
import { startAiGeneration } from "@/lib/generate-ai";
import * as EffectRuntime from "@/lib/server";
import { isTranscriptionConfigured } from "@/lib/transcription-gateway";
import { transcribeVideo } from "../../lib/transcribe";
import { isAiGenerationEnabled } from "../../utils/flags";

Expand Down Expand Up @@ -62,7 +62,7 @@ export async function getVideoStatus(

const metadata: VideoMetadata = (video.metadata as VideoMetadata) || {};

if (!video.transcriptionStatus && serverEnv().DEEPGRAM_API_KEY) {
if (!video.transcriptionStatus && isTranscriptionConfigured()) {
const activeUpload = await db()
.select({
videoId: videoUploads.videoId,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/s/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
SOCIAL_REFERRER_DOMAINS,
} from "@/lib/social-crawlers";
import { transcribeVideo } from "@/lib/transcribe";
import { isTranscriptionConfigured } from "@/lib/transcription-gateway";
import {
isEditSourceKey,
reconcileStaleEditUpload,
Expand Down Expand Up @@ -549,9 +550,8 @@ async function AuthorizedContent({
organizationSettings: video.orgSettings,
spaces: sharedSpaces.filter((space) => space.id !== space.organizationId),
});
const env = serverEnv();
const transcriptionGenerationAvailable =
Boolean(env.DEEPGRAM_API_KEY) && !rules.settings.disableTranscript;
isTranscriptionConfigured() && !rules.settings.disableTranscript;
const aiProviderAvailable = isAiProviderConfigured();

let aiGenerationEnabled = false;
Expand Down
10 changes: 9 additions & 1 deletion apps/web/content/docs/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,19 @@ By default, login links are printed to the server logs. To send emails:
For video transcription and AI summaries:

```bash
DEEPGRAM_API_KEY=your-key # Transcription
AI_GATEWAY_TRANSCRIPTION_URL=https://ai-gateway.take3tech.dev/media/cap/nova-3/transcribe
AI_GATEWAY_TRANSCRIPTION_API_KEY=your-scoped-gateway-key
AI_GATEWAY_CF_ACCESS_CLIENT_ID=your-access-client-id
AI_GATEWAY_CF_ACCESS_CLIENT_SECRET=your-access-client-secret
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
GROQ_API_KEY=your-key # AI summaries (preferred)
OPENAI_API_KEY=your-key # AI summaries (fallback)
```

The public gateway requires both Cloudflare Access credentials. The only
alternative is the internal cluster URL
`http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000/media/cap/nova-3/transcribe`.
Keep this exact path and omit query strings and fragments.

### Google OAuth

```bash
Expand Down
4 changes: 2 additions & 2 deletions apps/web/lib/transcribe.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { db } from "@cap/database";
import { organizations, videos, videoUploads } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
import type { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { start } from "workflow/api";
import { isTranscriptionConfigured } from "@/lib/transcription-gateway";
import { transcribeVideoWorkflow } from "@/workflows/transcribe";

type TranscribeResult = {
Expand All @@ -17,7 +17,7 @@ export async function transcribeVideo(
aiGenerationEnabled = false,
_isRetry = false,
): Promise<TranscribeResult> {
if (!serverEnv().DEEPGRAM_API_KEY) {
if (!isTranscriptionConfigured()) {
return {
success: false,
message: "Missing necessary environment variables",
Expand Down
49 changes: 49 additions & 0 deletions apps/web/lib/transcription-gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { serverEnv } from "@cap/env";

const TRANSCRIPTION_PATH = "/media/cap/nova-3/transcribe";
const PUBLIC_GATEWAY = "https://ai-gateway.take3tech.dev";
const INTERNAL_GATEWAY =
"http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000";

export function getTranscriptionGatewayConfig() {
const env = serverEnv();
const endpoint = env.AI_GATEWAY_TRANSCRIPTION_URL?.trim();
const apiKey = env.AI_GATEWAY_TRANSCRIPTION_API_KEY?.trim();
if (!endpoint || !apiKey) return null;

try {
const url = new URL(endpoint);
if (
![PUBLIC_GATEWAY, INTERNAL_GATEWAY].includes(url.origin) ||
url.pathname !== TRANSCRIPTION_PATH ||
url.username ||
url.password ||
url.search ||
url.hash
) {
return null;
}
const accessId = env.AI_GATEWAY_CF_ACCESS_CLIENT_ID?.trim();
const accessSecret = env.AI_GATEWAY_CF_ACCESS_CLIENT_SECRET?.trim();
if (
Boolean(accessId) !== Boolean(accessSecret) ||
(url.origin === PUBLIC_GATEWAY && !accessId)
) {
return null;
}
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
};
if (accessId && accessSecret) {
headers["CF-Access-Client-Id"] = accessId;
headers["CF-Access-Client-Secret"] = accessSecret;
}
return { endpoint: url.toString(), apiKey, headers };
} catch {
return null;
}
}

export function isTranscriptionConfigured() {
return getTranscriptionGatewayConfig() !== null;
}
30 changes: 26 additions & 4 deletions apps/web/workflows/transcribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
videoUploads,
} from "@cap/database/schema";
import type { VideoMetadata } from "@cap/database/types";
import { serverEnv } from "@cap/env";
import { userIsPro } from "@cap/utils";
import { Storage } from "@cap/web-backend";
import {
Expand Down Expand Up @@ -35,6 +34,10 @@ import {
} from "@/lib/media-client";
import { runPromise } from "@/lib/server";
import { type DeepgramResult, formatToWebVTT } from "@/lib/transcribe-utils";
import {
getTranscriptionGatewayConfig,
isTranscriptionConfigured,
} from "@/lib/transcription-gateway";
import { decodeStorageVideo } from "@/lib/video-storage";

interface TranscribeWorkflowPayload {
Expand Down Expand Up @@ -98,8 +101,10 @@ export async function transcribeVideoWorkflow(
async function validateVideo(videoId: string): Promise<VideoData> {
"use step";

if (!serverEnv().DEEPGRAM_API_KEY) {
throw new FatalError("Missing DEEPGRAM_API_KEY");
if (!isTranscriptionConfigured()) {
throw new FatalError(
"Missing or invalid transcription gateway configuration",
);
}

const query = await db()
Expand Down Expand Up @@ -339,11 +344,28 @@ async function transcribeWithDeepgram(

const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());

const deepgram = createClient(serverEnv().DEEPGRAM_API_KEY as string);
const gateway = getTranscriptionGatewayConfig();
if (!gateway)
throw new FatalError(
"Missing or invalid transcription gateway configuration",
);
const gatewayUrl = new URL(gateway.endpoint);
const deepgram = createClient(gateway.apiKey, {
global: {
fetch: {
options: {
url: gatewayUrl.origin,
headers: gateway.headers,
redirect: "error",
},
},
},
});

const { result, error } = await deepgram.listen.prerecorded.transcribeFile(
audioBuffer,
getDeepgramTranscriptionOptions(language),
gatewayUrl.pathname,
);

if (error) {
Expand Down
3 changes: 3 additions & 0 deletions deploy/k8s/chart/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ data:
AI_GATEWAY_BASE_URL: {{ required "aiGateway.baseUrl is required when aiGateway.enabled=true" .Values.aiGateway.baseUrl | quote }}
AI_GATEWAY_MODEL: {{ default "openai/gpt-oss-120b" .Values.aiGateway.model | quote }}
{{- end }}
{{- if .Values.transcriptionGateway.enabled }}
AI_GATEWAY_TRANSCRIPTION_URL: {{ required "transcriptionGateway.url is required when transcriptionGateway.enabled=true" .Values.transcriptionGateway.url | quote }}
{{- end }}
{{- range $key, $value := .Values.web.configEnv }}
{{ $key }}: {{ $value | quote }}
{{- end }}
4 changes: 2 additions & 2 deletions deploy/k8s/chart/templates/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ stringData:
{{- with .Values.runtimeSecret.values.groqApiKey }}
{{ $.Values.runtimeSecret.keys.groqApiKey }}: {{ . | quote }}
{{- end }}
{{- with .Values.runtimeSecret.values.deepgramApiKey }}
{{ $.Values.runtimeSecret.keys.deepgramApiKey }}: {{ . | quote }}
{{- with .Values.runtimeSecret.values.transcriptionGatewayApiKey }}
{{ $.Values.runtimeSecret.keys.transcriptionGatewayApiKey }}: {{ . | quote }}
{{- end }}
{{- with .Values.runtimeSecret.values.replicateApiToken }}
{{ $.Values.runtimeSecret.keys.replicateApiToken }}: {{ . | quote }}
Expand Down
17 changes: 12 additions & 5 deletions deploy/k8s/chart/templates/web.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,6 @@ spec:
name: {{ include "cap.secretName" . }}
key: {{ .Values.runtimeSecret.keys.mediaServerWebhookSecret }}
{{- if .Values.providerKeys.enabled }}
- name: DEEPGRAM_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "cap.secretName" . }}
key: {{ .Values.runtimeSecret.keys.deepgramApiKey }}
{{- if not .Values.aiGateway.enabled }}
- name: GROQ_API_KEY
valueFrom:
Expand Down Expand Up @@ -136,6 +131,18 @@ spec:
name: {{ include "cap.configName" . }}
key: AI_GATEWAY_MODEL
{{- end }}
{{- if .Values.transcriptionGateway.enabled }}
- name: AI_GATEWAY_TRANSCRIPTION_URL
valueFrom:
configMapKeyRef:
name: {{ include "cap.configName" . }}
key: AI_GATEWAY_TRANSCRIPTION_URL
- name: AI_GATEWAY_TRANSCRIPTION_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "cap.secretName" . }}
key: {{ .Values.runtimeSecret.keys.transcriptionGatewayApiKey }}
{{- end }}
{{- if .Values.workflow.enabled }}
- name: WORKFLOW_TARGET_WORLD
valueFrom:
Expand Down
Loading
Loading