From f7965cb22454242aaa1c8ff792855ebe28811901 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 11:10:30 +0530 Subject: [PATCH 01/35] build: drop external dockerfile frontend directive Railway's Buildkit rejects `# syntax=docker.io/docker/dockerfile:1`. Default frontend works fine for this multi-stage build. --- apps/web/Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 95c28bce770..7f76660ffa6 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker.io/docker/dockerfile:1 - FROM node:24-alpine AS base RUN corepack enable From 308f1113da2e0373b6dd4868028203fc49c3e9b8 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 11:12:27 +0530 Subject: [PATCH 02/35] build(media-server): use standalone Dockerfile from sub-dir context Railway sub-directory deploys (rootDirectory=apps/media-server) need the standalone Dockerfile whose COPY paths are relative to apps/media-server, not the root Dockerfile that expects repo-root paths. --- apps/media-server/railway.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/media-server/railway.toml b/apps/media-server/railway.toml index 559178f4da0..a853244bb9f 100644 --- a/apps/media-server/railway.toml +++ b/apps/media-server/railway.toml @@ -1,5 +1,5 @@ [build] -dockerfilePath = "apps/media-server/Dockerfile" +dockerfilePath = "Dockerfile.standalone" [deploy] startCommand = "bun run src/index.ts" From a6dc7c4c8f2e4dab4934de9921466ac18ebe4f41 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 11:13:23 +0530 Subject: [PATCH 03/35] build: drop pnpm cache mount Railway's Railpack builder rejects `--mount=type=cache,id=pnpm,...` without a cacheKey prefix on the id. Dropping the cache mount; build is slower but correct. --- apps/web/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 7f76660ffa6..bfc15f92f15 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY . . RUN corepack enable pnpm -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm i --frozen-lockfile +RUN pnpm i --frozen-lockfile ARG NEXT_PUBLIC_DOCKER_BUILD=true ENV NEXT_PUBLIC_WEB_URL=http://localhost:3000 From c87c556901f58ac7bfbcf49e8c3aa8a1dd902484 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 11:38:35 +0530 Subject: [PATCH 04/35] build(web-cluster): drop pnpm cache mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same Railway Railpack constraint as apps/web/Dockerfile — id=pnpm without cacheKey prefix is rejected. --- apps/web-cluster/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web-cluster/Dockerfile b/apps/web-cluster/Dockerfile index 41e3881d721..fe55805151d 100644 --- a/apps/web-cluster/Dockerfile +++ b/apps/web-cluster/Dockerfile @@ -8,7 +8,7 @@ COPY . . RUN corepack enable pnpm RUN echo "inject-workspace-packages=true" >> .npmrc -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm i +RUN pnpm i RUN pnpm run --filter=@cap/web-cluster build RUN pnpm deploy --filter=@cap/web-cluster out From a472acfb89fe364b61e83a3c8d81624504b9eef7 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 11:55:13 +0530 Subject: [PATCH 05/35] build(web-cluster): drop ENTRYPOINT, let Railway startCommand be the full command Railway's startCommand exec's the literal string and does NOT append to a Docker ENTRYPOINT. With `ENTRYPOINT ["deno", "run", "--allow-all"]` Railway tries to run the startCommand as a file path, getting "permission denied". Removing ENTRYPOINT means startCommand becomes the CMD via shell, so `deno run --allow-all src/runner/index.ts` works as expected. --- apps/web-cluster/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web-cluster/Dockerfile b/apps/web-cluster/Dockerfile index fe55805151d..dfced05815f 100644 --- a/apps/web-cluster/Dockerfile +++ b/apps/web-cluster/Dockerfile @@ -21,8 +21,8 @@ COPY --from=builder --chown=deno:deno /app/out /app USER deno -ENTRYPOINT ["deno", "run", "--allow-all"] - EXPOSE 8080 EXPOSE 42069 EXPOSE 42169 + +CMD ["deno", "run", "--allow-all", "src/runner/index.ts"] From ee17754dc7a740f91b3b93aaa3c61b5b5c860829 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 12:06:14 +0530 Subject: [PATCH 06/35] build(web-cluster): env-var driven CMD for shard-manager vs runner Railway's startCommand fights with multi-word commands (treats whole string as executable path). Switching strategy: hardcode CMD in Dockerfile to invoke deno + ${CLUSTER_ENTRY}. Each service sets CLUSTER_ENTRY env var (default = runner) so no startCommand override needed. --- apps/web-cluster/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web-cluster/Dockerfile b/apps/web-cluster/Dockerfile index dfced05815f..b08a344be72 100644 --- a/apps/web-cluster/Dockerfile +++ b/apps/web-cluster/Dockerfile @@ -25,4 +25,5 @@ EXPOSE 8080 EXPOSE 42069 EXPOSE 42169 -CMD ["deno", "run", "--allow-all", "src/runner/index.ts"] +ENV CLUSTER_ENTRY=src/runner/index.ts +CMD sh -c "deno run --allow-all $CLUSTER_ENTRY" From a401e13fd0aef104ced84ed7d442461f30c05969 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 12:16:55 +0530 Subject: [PATCH 07/35] build(web-cluster): split into runner Dockerfile (this) + shard-manager Dockerfile Railway's startCommand override mechanism keeps fighting multi-word commands on this Deno-based image. Hardcoding the CMD per Dockerfile sidesteps the fight entirely. This Dockerfile is now Runner-only; ShardManager gets its own Dockerfile.shard-manager. --- apps/web-cluster/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web-cluster/Dockerfile b/apps/web-cluster/Dockerfile index b08a344be72..dfced05815f 100644 --- a/apps/web-cluster/Dockerfile +++ b/apps/web-cluster/Dockerfile @@ -25,5 +25,4 @@ EXPOSE 8080 EXPOSE 42069 EXPOSE 42169 -ENV CLUSTER_ENTRY=src/runner/index.ts -CMD sh -c "deno run --allow-all $CLUSTER_ENTRY" +CMD ["deno", "run", "--allow-all", "src/runner/index.ts"] From 6cda58763267080fe94750cd04ba8fc314ebba88 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 12:17:12 +0530 Subject: [PATCH 08/35] build(web-cluster): add Dockerfile.shard-manager with hardcoded CMD Variant of apps/web-cluster/Dockerfile whose only difference is the final CMD points at src/shard-manager.ts instead of src/runner/index.ts. --- apps/web-cluster/Dockerfile.shard-manager | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 apps/web-cluster/Dockerfile.shard-manager diff --git a/apps/web-cluster/Dockerfile.shard-manager b/apps/web-cluster/Dockerfile.shard-manager new file mode 100644 index 00000000000..27232ad5534 --- /dev/null +++ b/apps/web-cluster/Dockerfile.shard-manager @@ -0,0 +1,27 @@ +FROM node:24-slim AS base +RUN corepack enable + +FROM base AS builder +WORKDIR /app +COPY . . + +RUN corepack enable pnpm + +RUN echo "inject-workspace-packages=true" >> .npmrc +RUN pnpm i + +RUN pnpm run --filter=@cap/web-cluster build +RUN pnpm deploy --filter=@cap/web-cluster out +RUN cd out && node scripts/post-deploy.ts + +FROM denoland/deno:2.5.3 AS runner +WORKDIR /app + +COPY --from=builder --chown=deno:deno /app/out /app + +USER deno + +EXPOSE 8080 +EXPOSE 42069 + +CMD ["deno", "run", "--allow-all", "src/shard-manager.ts"] From 40343f2b032e0232f06b357cad0cf1fa4cdc0d79 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 12:27:14 +0530 Subject: [PATCH 09/35] fix(utils): publishConfig.exports points to dist for deployed builds post-deploy.ts merges publishConfig into the deployed package.json. Without publishConfig.exports, the deployed package keeps `exports: "./src/index.ts"` and Deno tries to load TS source from node_modules, hitting ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING and crashing the runner. Mirroring the pattern @cap/database uses. --- packages/utils/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/utils/package.json b/packages/utils/package.json index 82f842d4c57..06a3c3a80f6 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -9,7 +9,10 @@ "build": "tsdown" }, "publishConfig": { - "main": "./dist/index.js" + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js" + } }, "devDependencies": { "react": "^19.1.1", From 4333fec68abd6bc97dd562944fc8951f6d5439d7 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 14:09:58 +0530 Subject: [PATCH 10/35] fix(shard-manager): bind to 0.0.0.0 instead of localhost Effect Cluster's NodeClusterShardManagerSocket layerSocketServer binds to config.shardManagerAddress (default localhost:8080). With Cap upstream's SST infra they set SHARD_MANAGER_HOST=0.0.0.0 env var to override, but that mapping isn't reliably resolving on Railway. Hardcoding the bind address in shardingConfig works regardless of env-var conventions. --- apps/web-cluster/src/shard-manager.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web-cluster/src/shard-manager.ts b/apps/web-cluster/src/shard-manager.ts index d1ddf618096..193d88bf3cf 100644 --- a/apps/web-cluster/src/shard-manager.ts +++ b/apps/web-cluster/src/shard-manager.ts @@ -1,3 +1,4 @@ +import { RunnerAddress } from "@effect/cluster"; import { NodeClusterShardManagerSocket, NodeRuntime, @@ -8,6 +9,12 @@ import { DatabaseLive, ShardDatabaseLive } from "./shared/database.ts"; NodeClusterShardManagerSocket.layer({ storage: "sql", + shardingConfig: { + shardManagerAddress: RunnerAddress.make({ + host: "0.0.0.0", + port: 8080, + }), + }, }).pipe( Layer.provide(ShardDatabaseLive), Layer.provide(DatabaseLive), From 6c74863e49bcf60917b742288df55638340c91de Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 14:19:52 +0530 Subject: [PATCH 11/35] fix(shard-manager): RunnerAddress.make takes positional args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempt passed an object, which the Schema constructor reads as the host value (an object) → fails NonEmptyString check. Correct call: RunnerAddress.make(host, port). --- apps/web-cluster/src/shard-manager.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web-cluster/src/shard-manager.ts b/apps/web-cluster/src/shard-manager.ts index 193d88bf3cf..dfc1da816a8 100644 --- a/apps/web-cluster/src/shard-manager.ts +++ b/apps/web-cluster/src/shard-manager.ts @@ -10,10 +10,7 @@ import { DatabaseLive, ShardDatabaseLive } from "./shared/database.ts"; NodeClusterShardManagerSocket.layer({ storage: "sql", shardingConfig: { - shardManagerAddress: RunnerAddress.make({ - host: "0.0.0.0", - port: 8080, - }), + shardManagerAddress: RunnerAddress.make("0.0.0.0", 8080), }, }).pipe( Layer.provide(ShardDatabaseLive), From 16c816a529b6f1533e24963cb1aef20ceff3a5f7 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 14:25:22 +0530 Subject: [PATCH 12/35] fix(runner): allow RUNNER_HOST env override for advertised address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ECS metadata isn't available (e.g. on Railway), ipAddress defaults to 0.0.0.0. Runner then registers with the shard manager as 0.0.0.0:42069 and the shard manager can't reach it (0.0.0.0 from one container is its own loopback). Allow RUNNER_HOST env to override — set to cap-workflow-runner.railway.internal on Railway. --- .../src/cluster/container-metadata.ts | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/web-cluster/src/cluster/container-metadata.ts b/apps/web-cluster/src/cluster/container-metadata.ts index 11622861b79..66e977a05c8 100644 --- a/apps/web-cluster/src/cluster/container-metadata.ts +++ b/apps/web-cluster/src/cluster/container-metadata.ts @@ -12,6 +12,9 @@ class EcsContainerMetadata extends Effect.Service()( metadataUri: yield* Config.option( Config.string("ECS_CONTAINER_METADATA_URI_V4"), ), + runnerHostOverride: yield* Config.option( + Config.string("RUNNER_HOST"), + ), }; }), }, @@ -42,20 +45,25 @@ export class ContainerMetadata extends Effect.Service()( { effect: Effect.gen(function* () { const containerMetadata = yield* EcsContainerMetadata; - const metadataUri = containerMetadata.metadataUri; - const ipAddress = yield* Option.match(metadataUri, { - onNone: () => Effect.succeed("0.0.0.0"), - onSome: (uri) => - Effect.tryPromise({ - try: async () => { - const response = await fetch(`${uri}/task`); - const data = await response.json(); - return data.Containers[0].Networks[0].IPv4Addresses[0] as string; - }, - catch: (error) => { - console.error("error", error); - return new FetchIpError(); - }, + const { metadataUri, runnerHostOverride } = containerMetadata; + const ipAddress = yield* Option.match(runnerHostOverride, { + onSome: (host) => Effect.succeed(host), + onNone: () => + Option.match(metadataUri, { + onNone: () => Effect.succeed("0.0.0.0"), + onSome: (uri) => + Effect.tryPromise({ + try: async () => { + const response = await fetch(`${uri}/task`); + const data = await response.json(); + return data.Containers[0].Networks[0] + .IPv4Addresses[0] as string; + }, + catch: (error) => { + console.error("error", error); + return new FetchIpError(); + }, + }), }), }); From 964620e73857d33ea7ac37707064d5647b1842f1 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 15:09:45 +0530 Subject: [PATCH 13/35] skip org email restriction for public (anyone-with-link) videos VideosPolicy was enforcing allowedEmailDomain even when video.public=true, blocking anonymous viewers despite the owner setting "Anyone with link". Public=true is an explicit per-video override; email restriction should only apply to private videos. Password protection (verifyPassword) is preserved for public videos. --- .../web-backend/src/Videos/VideosPolicy.ts | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/packages/web-backend/src/Videos/VideosPolicy.ts b/packages/web-backend/src/Videos/VideosPolicy.ts index 5cc92d209e8..8369a46ec38 100644 --- a/packages/web-backend/src/Videos/VideosPolicy.ts +++ b/packages/web-backend/src/Videos/VideosPolicy.ts @@ -79,37 +79,9 @@ export function buildCanView( return false; } - const allowedEmails = yield* orgsRepo.allowedEmailDomain(video.orgId); - const restriction = Option.isSome(allowedEmails) - ? allowedEmails.value.trim() - : ""; - - if (restriction.length > 0) { - if (Option.isNone(user)) { - yield* Effect.log( - "Email access restriction active and user not logged in. Access denied.", - ); - yield* Effect.fail( - new Policy.PolicyDeniedError({ - reason: "email_restriction_login_required", - }), - ); - } - if ( - Option.isSome(user) && - !isEmailAllowedByRestriction(user.value.email, restriction) - ) { - yield* Effect.log("Email access restriction active. Access denied."); - yield* Effect.fail( - new Policy.PolicyDeniedError({ - reason: "email_restriction_denied", - }), - ); - } - } - + // video.public === true ("Anyone with link") — org email restriction does + // not apply. The owner explicitly made this video public. yield* Video.verifyPassword(video, password); - return true; }), ); From d1e23a0cf124391b05e406e104c63a6c034a8713 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 16:02:35 +0530 Subject: [PATCH 14/35] fix: hasActiveUpload check phase instead of existence Videos with completed uploads had videoUploads records persisting, making hasActiveUpload = true forever and blocking transcription. Only treat as active when phase is uploading/processing/generating_thumbnail. --- apps/web/app/s/[videoId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 8741a78e832..1588725ab08 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -335,7 +335,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { organizationId: sharedVideos.organizationId, }, orgSettings: organizations.settings, - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( + hasActiveUpload: sql`${videoUploads.phase} IN ('uploading', 'processing', 'generating_thumbnail')`.mapWith( Boolean, ), owner: users, From 9bb8dcd149958c3491f20856a870730ddbf75ac9 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 16:03:02 +0530 Subject: [PATCH 15/35] fix: get-status active upload check by phase not existence Same bug as page.tsx: checking if any videoUploads row exists blocked transcription for completed uploads. Now only blocks when phase is uploading/processing/generating_thumbnail. --- apps/web/actions/videos/get-status.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 88055769635..b47fea8ea72 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -6,7 +6,7 @@ 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"; +import { and, eq, inArray } from "drizzle-orm"; import { Effect, Exit } from "effect"; import { startAiGeneration } from "@/lib/generate-ai"; import * as EffectRuntime from "@/lib/server"; @@ -60,7 +60,16 @@ export async function getVideoStatus( const activeUpload = await db() .select({ videoId: videoUploads.videoId }) .from(videoUploads) - .where(eq(videoUploads.videoId, videoId)) + .where( + and( + eq(videoUploads.videoId, videoId), + inArray(videoUploads.phase, [ + "uploading", + "processing", + "generating_thumbnail", + ]), + ), + ) .limit(1); if (activeUpload.length > 0) { From 4b4bde2bdcefece28e89597804e4da0f02434d04 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 16:38:52 +0530 Subject: [PATCH 16/35] fix: treat upload as active only when phase=uploading AND uploaded < total Cap desktop app never updates phase from 'uploading' to 'complete' even when the upload finishes (uploaded == total). All three upload guards were permanently blocking transcription for every desktop-app upload. Fix: active upload = phase IN ('processing','generating_thumbnail') OR (phase = 'uploading' AND uploaded < total). --- apps/web/app/s/[videoId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 1588725ab08..a475ba936bd 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -335,7 +335,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { organizationId: sharedVideos.organizationId, }, orgSettings: organizations.settings, - hasActiveUpload: sql`${videoUploads.phase} IN ('uploading', 'processing', 'generating_thumbnail')`.mapWith( + hasActiveUpload: sql`(${videoUploads.phase} IN ('processing', 'generating_thumbnail') OR (${videoUploads.phase} = 'uploading' AND ${videoUploads.uploaded} < ${videoUploads.total}))`.mapWith( Boolean, ), owner: users, From b60143f81cf15f4a57ad967411caa8e2a47f5957 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 16:39:09 +0530 Subject: [PATCH 17/35] fix(get-status): skip transcription only when upload truly in progress phase='uploading' with uploaded==total means the upload is done but Cap desktop app never advanced the phase. Add uploaded Date: Wed, 6 May 2026 16:39:23 +0530 Subject: [PATCH 18/35] fix(transcribe): allow transcription when phase=uploading but upload complete Same root-cause fix: Cap desktop never updates phase to 'complete'. Select uploaded+total and only block when uploaded < total. --- apps/web/lib/transcribe.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 0b07f04ab8b..1222c696f04 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -101,13 +101,18 @@ export async function transcribeVideo( } const upload = await db() - .select({ phase: videoUploads.phase }) + .select({ + phase: videoUploads.phase, + uploaded: videoUploads.uploaded, + total: videoUploads.total, + }) .from(videoUploads) .where(eq(videoUploads.videoId, videoId)) .limit(1); if ( - upload[0]?.phase === "uploading" || + (upload[0]?.phase === "uploading" && + (upload[0]?.uploaded ?? 0) < (upload[0]?.total ?? 1)) || upload[0]?.phase === "processing" || upload[0]?.phase === "generating_thumbnail" ) { From 5ad304120c86dbd1be317352f4a72b76a4d63dc2 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 17:08:14 +0530 Subject: [PATCH 19/35] =?UTF-8?q?fix(transcribe):=20bypass=20workflow=20st?= =?UTF-8?q?art()=20=E2=80=94=20call=20workflow=20fn=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow@4.2.0-beta.73 start() uses VERCEL_URL for self-callbacks, which doesn't exist on Railway. start() resolves silently without executing any steps, leaving transcriptionStatus = null forever. Fix: set transcriptionStatus = PROCESSING immediately (stops polling loop), then fire-and-forget transcribeVideoWorkflow() as a plain async call. The "use workflow"/"use step" directives don't prevent direct invocation — they only add step-endpoint registration at build time. --- apps/web/lib/transcribe.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 1222c696f04..8a374fc84a1 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -8,7 +8,6 @@ import { import { serverEnv } from "@cap/env"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { start } from "workflow/api"; import { transcribeVideoWorkflow } from "@/workflows/transcribe"; type TranscribeResult = { @@ -127,13 +126,26 @@ export async function transcribeVideo( `[transcribeVideo] Triggering transcription workflow for video ${videoId}`, ); - await start(transcribeVideoWorkflow, [ - { - videoId, - userId, - aiGenerationEnabled, - }, - ]); + // Set PROCESSING immediately so polling stops while the workflow runs. + // workflow@4.2.0-beta.73 start() relies on VERCEL_URL for self-callbacks + // which doesn't exist on Railway, so we call the workflow fn directly. + await db() + .update(videos) + .set({ transcriptionStatus: "PROCESSING" }) + .where(eq(videos.id, videoId)); + + transcribeVideoWorkflow({ + videoId, + userId, + aiGenerationEnabled, + }).catch((error) => { + console.error("[transcribeVideo] Workflow execution failed:", error); + db() + .update(videos) + .set({ transcriptionStatus: null }) + .where(eq(videos.id, videoId)) + .catch(() => {}); + }); return { success: true, From 4a0c3817c722fc11a936ea0104f6b1f07b8fa892 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 17:14:05 +0530 Subject: [PATCH 20/35] revert: use start() again now that VERCEL_URL will be set on Railway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow package enforces start() — direct invocation throws at runtime. VERCEL_URL=cap.devalok.in env var added to Railway so start() can construct the self-callback URL for /.well-known/workflow/v1/step. --- apps/web/lib/transcribe.ts | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 8a374fc84a1..1222c696f04 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -8,6 +8,7 @@ import { import { serverEnv } from "@cap/env"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { start } from "workflow/api"; import { transcribeVideoWorkflow } from "@/workflows/transcribe"; type TranscribeResult = { @@ -126,26 +127,13 @@ export async function transcribeVideo( `[transcribeVideo] Triggering transcription workflow for video ${videoId}`, ); - // Set PROCESSING immediately so polling stops while the workflow runs. - // workflow@4.2.0-beta.73 start() relies on VERCEL_URL for self-callbacks - // which doesn't exist on Railway, so we call the workflow fn directly. - await db() - .update(videos) - .set({ transcriptionStatus: "PROCESSING" }) - .where(eq(videos.id, videoId)); - - transcribeVideoWorkflow({ - videoId, - userId, - aiGenerationEnabled, - }).catch((error) => { - console.error("[transcribeVideo] Workflow execution failed:", error); - db() - .update(videos) - .set({ transcriptionStatus: null }) - .where(eq(videos.id, videoId)) - .catch(() => {}); - }); + await start(transcribeVideoWorkflow, [ + { + videoId, + userId, + aiGenerationEnabled, + }, + ]); return { success: true, From 60af444385170cb0d5d2493bebec08d125b80863 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 17:29:09 +0530 Subject: [PATCH 21/35] fix(transcribe): replace workflow start() with inline direct execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow@4.2.0-beta.73 start() silently no-ops on Railway (needs VERCEL_URL for self-callbacks, not set by default). Direct invocation also blocked — the compiler wraps the fn to throw. Fix: copy the transcription steps from workflows/transcribe.ts into a plain runTranscriptionDirect() with no "use workflow"/"use step" directives. Called fire-and-forget after setting PROCESSING in the DB. --- apps/web/lib/transcribe.ts | 227 ++++++++++++++++++++++++++++++++++--- 1 file changed, 214 insertions(+), 13 deletions(-) diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 1222c696f04..dff92a9f251 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -1,15 +1,33 @@ +import { promises as fs } from "node:fs"; import { db } from "@cap/database"; import { organizations, s3Buckets, + users, videos, videoUploads, } from "@cap/database/schema"; +import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; -import type { Video } from "@cap/web-domain"; +import { userIsPro } from "@cap/utils"; +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 { + checkHasAudioTrackViaMediaServer, + extractAudioViaMediaServer, + isMediaServerConfigured, + probeVideoViaMediaServer, +} from "@/lib/media-client"; +import { runPromise } from "@/lib/server"; +import { type DeepgramResult, formatToWebVTT } from "@/lib/transcribe-utils"; +import { startAiGeneration } from "./generate-ai"; type TranscribeResult = { success: boolean; @@ -124,23 +142,37 @@ export async function transcribeVideo( try { console.log( - `[transcribeVideo] Triggering transcription workflow for video ${videoId}`, + `[transcribeVideo] Triggering transcription for video ${videoId}`, ); - await start(transcribeVideoWorkflow, [ - { - videoId, - userId, - aiGenerationEnabled, + // Mark PROCESSING immediately so polling stops while transcription runs. + // We bypass workflow/api start() — it silently no-ops on Railway + // (needs VERCEL_URL for self-callbacks) and blocks direct invocation. + await db() + .update(videos) + .set({ transcriptionStatus: "PROCESSING" }) + .where(eq(videos.id, videoId)); + + runTranscriptionDirect(videoId, userId, aiGenerationEnabled).catch( + (error) => { + console.error( + `[transcribeVideo] Transcription failed for ${videoId}:`, + error, + ); + db() + .update(videos) + .set({ transcriptionStatus: null }) + .where(eq(videos.id, videoId)) + .catch(() => {}); }, - ]); + ); return { success: true, - message: "Transcription workflow started", + message: "Transcription started", }; } catch (error) { - console.error("[transcribeVideo] Failed to trigger workflow:", error); + console.error("[transcribeVideo] Failed to start transcription:", error); await db() .update(videos) @@ -149,7 +181,176 @@ export async function transcribeVideo( return { success: false, - message: "Failed to start transcription workflow", + message: "Failed to start transcription", }; } } + +async function runTranscriptionDirect( + videoId: Video.VideoId, + userId: string, + aiGenerationEnabled: boolean, +): Promise { + console.log(`[transcribe] Starting direct transcription for ${videoId}`); + + // --- resolve bucket --- + const videoQuery = await db() + .select({ + video: videos, + bucket: s3Buckets, + owner: users, + }) + .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) + .innerJoin(users, eq(videos.ownerId, users.id)) + .where(eq(videos.id, videoId)); + + if (!videoQuery[0]?.video) { + throw new Error(`Video ${videoId} not found`); + } + + const bucketId = (videoQuery[0].bucket?.id ?? null) as S3Bucket.S3BucketId | null; + const isOwnerPro = userIsPro(videoQuery[0].owner); + + console.log( + `[transcribe] Owner check: isOwnerPro=${isOwnerPro}`, + ); + + const [bucket] = await S3Buckets.getBucketAccess( + Option.fromNullable(bucketId), + ).pipe(runPromise); + + // --- resolve video source URL --- + const uploadRow = await db() + .select({ rawFileKey: videoUploads.rawFileKey }) + .from(videoUploads) + .where(eq(videoUploads.videoId, videoId)) + .limit(1); + + const candidateKeys = [ + `${userId}/${videoId}/result.mp4`, + uploadRow[0]?.rawFileKey, + ].filter( + (v, i, arr): v is string => Boolean(v) && arr.indexOf(v) === i, + ); + + let videoUrl: string | null = null; + for (const key of candidateKeys) { + const url = await bucket.getInternalSignedObjectUrl(key).pipe(runPromise); + const probe = await fetch(url, { method: "GET", headers: { range: "bytes=0-0" } }); + if (probe.ok) { + console.log(`[transcribe] Using video source ${key}`); + videoUrl = url; + break; + } + } + + if (!videoUrl) { + throw new Error(`Video file not accessible for ${videoId}`); + } + + // --- check / extract audio --- + const useMediaServer = isMediaServerConfigured(); + console.log(`[transcribe] Audio detection: useMediaServer=${useMediaServer}, videoId=${videoId}`); + + let hasAudio: boolean; + let audioBuffer: Buffer; + + if (useMediaServer) { + try { + const probe = await probeVideoViaMediaServer(videoUrl); + console.log( + `[transcribe] Probe: audioCodec=${probe.audioCodec}, videoCodec=${probe.videoCodec}, duration=${probe.duration}`, + ); + hasAudio = probe.audioCodec !== null; + } catch (probeError) { + console.error(`[transcribe] Probe failed, falling back:`, probeError); + hasAudio = await checkHasAudioTrackViaMediaServer(videoUrl); + } + + if (!hasAudio) { + console.log(`[transcribe] No audio track for ${videoId}`); + await db() + .update(videos) + .set({ transcriptionStatus: "NO_AUDIO" }) + .where(eq(videos.id, videoId)); + return; + } + + audioBuffer = await extractAudioViaMediaServer(videoUrl); + } else { + hasAudio = await checkHasAudioTrack(videoUrl); + console.log(`[transcribe] Local ffmpeg audio check: hasAudio=${hasAudio}`); + + if (!hasAudio) { + await db() + .update(videos) + .set({ transcriptionStatus: "NO_AUDIO" }) + .where(eq(videos.id, videoId)); + return; + } + + const result = await extractAudioFromUrl(videoUrl); + try { + audioBuffer = await fs.readFile(result.filePath); + } finally { + await result.cleanup(); + } + } + + console.log(`[transcribe] Extracted audio: ${audioBuffer.length} bytes`); + + // --- upload temp audio to S3 --- + const audioKey = `${userId}/${videoId}/audio-temp.mp3`; + await bucket.putObject(audioKey, audioBuffer, { contentType: "audio/mpeg" }).pipe(runPromise); + const audioSignedUrl = await bucket.getInternalSignedObjectUrl(audioKey).pipe(runPromise); + + // --- transcribe with Deepgram --- + console.log(`[transcribe] Sending audio to Deepgram for ${videoId}`); + const audioResponse = await fetch(audioSignedUrl); + if (!audioResponse.ok) { + throw new Error(`Audio URL not accessible: ${audioResponse.status}`); + } + + const audioBuf = Buffer.from(await audioResponse.arrayBuffer()); + const deepgram = createClient(serverEnv().DEEPGRAM_API_KEY as string); + + const { result: dgResult, error: dgError } = + await deepgram.listen.prerecorded.transcribeFile(audioBuf, { + model: "nova-3", + smart_format: true, + detect_language: true, + utterances: true, + mime_type: "audio/mpeg", + }); + + if (dgError) { + throw new Error(`Deepgram failed: ${dgError.message}`); + } + + const vtt = formatToWebVTT(dgResult as unknown as DeepgramResult); + + // --- save VTT + mark COMPLETE --- + await bucket + .putObject(`${userId}/${videoId}/transcription.vtt`, vtt, { contentType: "text/vtt" }) + .pipe(runPromise); + + await db() + .update(videos) + .set({ transcriptionStatus: "COMPLETE" }) + .where(eq(videos.id, videoId)); + + console.log(`[transcribe] Transcription COMPLETE for ${videoId}`); + + // --- cleanup temp audio --- + try { + await bucket.deleteObject(audioKey).pipe(runPromise); + } catch { + console.error(`[transcribe] Failed to cleanup ${audioKey}`); + } + + // --- queue AI generation if enabled --- + if (aiGenerationEnabled) { + await startAiGeneration(videoId, userId); + } +} From b179056d22faab855dd595fdfa14bc559c7590ab Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 18:01:03 +0530 Subject: [PATCH 22/35] fix(transcribe): don't block transcription when phase=processing startVideoProcessingWorkflow sets phase="processing" then calls start(processVideoWorkflow) which silently no-ops on Railway. Phase stays "processing" forever, blocking transcription indefinitely. Remove "processing" from blocked phases. rawFileKey is set before the workflow starts, so runTranscriptionDirect falls back to raw-upload.webm. --- apps/web/actions/videos/get-status.ts | 4 ++-- apps/web/lib/transcribe.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 72a47330674..93faf3b8026 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -6,7 +6,7 @@ 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 { and, eq, inArray, or, sql } from "drizzle-orm"; +import { and, eq, or, sql } from "drizzle-orm"; import { Effect, Exit } from "effect"; import { startAiGeneration } from "@/lib/generate-ai"; import * as EffectRuntime from "@/lib/server"; @@ -64,7 +64,7 @@ export async function getVideoStatus( and( eq(videoUploads.videoId, videoId), or( - inArray(videoUploads.phase, ["processing", "generating_thumbnail"]), + eq(videoUploads.phase, "generating_thumbnail"), and( eq(videoUploads.phase, "uploading"), sql`${videoUploads.uploaded} < ${videoUploads.total}`, diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index dff92a9f251..b4cc230683e 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -131,7 +131,6 @@ export async function transcribeVideo( if ( (upload[0]?.phase === "uploading" && (upload[0]?.uploaded ?? 0) < (upload[0]?.total ?? 1)) || - upload[0]?.phase === "processing" || upload[0]?.phase === "generating_thumbnail" ) { return { From 61ae75da859f16ade25faae05585f05fdfe6f10e Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 18:22:31 +0530 Subject: [PATCH 23/35] bypass generateAiWorkflow start() with direct OpenAI call on Railway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start(generateAiWorkflow) dispatches to Effect Cluster runner via RPC but runner never executes it (same issue as transcribeVideoWorkflow). Implement runAIGenerationDirect() that fetches VTT from S3, calls Groq/OpenAI inline, and saves results to DB — all in the Cap Web process without the workflow runner. startAiGeneration() now calls runAIGenerationDirect() fire-and-forget instead of start(). --- apps/web/lib/generate-ai.ts | 479 +++++++++++++++++++++++++++++++++++- 1 file changed, 472 insertions(+), 7 deletions(-) diff --git a/apps/web/lib/generate-ai.ts b/apps/web/lib/generate-ai.ts index 641a6e1545d..cbe90038ff2 100644 --- a/apps/web/lib/generate-ai.ts +++ b/apps/web/lib/generate-ai.ts @@ -1,17 +1,477 @@ 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"; + +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; type GenerateAiResult = { success: boolean; message: string; }; +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; +} + +function getVideoDuration(segments: VttSegment[]): number { + if (segments.length === 0) return 0; + const lastSegment = segments[segments.length - 1]; + return lastSegment ? lastSegment.start + 3 : 0; +} + +function clampChapters( + chapters: { title: string; start: number }[], + videoDuration: number, +): { title: string; start: number }[] { + const filtered = chapters.filter((ch) => ch.start < videoDuration); + + if (filtered.length === 0 && chapters.length > 0) { + const first = chapters[0]; + return first ? [{ title: first.title, start: 0 }] : []; + } + + const minGap = Math.max(5, Math.floor(videoDuration / 10)); + const deduped: { title: string; start: number }[] = []; + for (const chapter of filtered) { + const last = deduped[deduped.length - 1]; + if (!last || Math.abs(chapter.start - last.start) >= minGap) { + deduped.push(chapter); + } + } + + return deduped; +} + +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; +} + +function parseAiResponse(content: string): AiResult { + try { + const data = JSON.parse(cleanJsonResponse(content).trim()); + + const chapters = Array.isArray(data.chapters) + ? data.chapters + .filter( + (ch: { start?: number }) => + typeof ch.start === "number" && ch.start >= 0, + ) + .sort( + (a: { start: number }, b: { start: number }) => a.start - b.start, + ) + : []; + + return { + title: data.title, + summary: data.summary, + chapters, + }; + } catch { + return { + title: "Generated Title", + summary: + "The AI was unable to generate a proper summary for this content.", + chapters: [], + }; + } +} + +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 || "{}"; +} + +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; + } + } else if (serverEnv().OPENAI_API_KEY) { + return callOpenAi(prompt); + } + return "{}"; +} + +async function generateSingleChunk( + segments: VttSegment[], + videoDuration: number, + groqClient: ReturnType, +): Promise { + const transcriptWithTimestamps = segments + .map( + (s) => + `[${Math.floor(s.start / 60)}:${String(s.start % 60).padStart(2, "0")}] ${s.text}`, + ) + .join("\n"); + + const prompt = `You are Cap AI, an expert at analyzing video content and creating comprehensive summaries. + +The video is ${videoDuration} seconds long (${Math.floor(videoDuration / 60)}:${String(Math.floor(videoDuration % 60)).padStart(2, "0")} total). Analyze this timestamped transcript 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 +- IMPORTANT: All chapter "start" values MUST be between 0 and ${videoDuration} seconds. Use the timestamps from the transcript to determine accurate chapter start times. + +Return ONLY valid JSON without any markdown formatting or code blocks. +Transcript: +${transcriptWithTimestamps}`; + + const content = await callAiApi(prompt, groqClient); + return parseAiResponse(content); +} + +async function generateMultipleChunks( + chunks: { text: string; startTime: number; endTime: number }[], + videoDuration: 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 video that is ${videoDuration} seconds long (${Math.floor(videoDuration / 60)}:${String(Math.floor(videoDuration % 60)).padStart(2, "0")} total). This section covers 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)}] +} + +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. +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 {} + } + + const allChapters: { title: string; start: number }[] = []; + const sortedChapters = chunkSummaries + .flatMap((c) => c.chapters) + .sort((a, b) => a.start - b.start); + const minGap = Math.max(5, Math.floor(videoDuration / 10)); + for (const chapter of sortedChapters) { + const lastChapter = allChapters[allChapters.length - 1]; + if (!lastChapter || Math.abs(chapter.start - lastChapter.start) >= minGap) { + 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 { + 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, + }; + } +} + +async function runAIGenerationDirect( + videoId: Video.VideoId, + userId: string, +): Promise { + console.log(`[generate-ai] Starting direct AI generation for ${videoId}`); + + const query = await db() + .select({ video: videos, bucket: s3Buckets }) + .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) + .where(eq(videos.id, videoId)); + + if (!query[0]?.video) { + console.error(`[generate-ai] Video ${videoId} not found`); + return; + } + + const { video, bucket } = query[0]; + const metadata = (video.metadata as VideoMetadata) || {}; + const bucketId = (bucket?.id ?? null) as S3Bucket.S3BucketId | null; + + await db() + .update(videos) + .set({ metadata: { ...metadata, aiGenerationStatus: "PROCESSING" } }) + .where(eq(videos.id, videoId)); + + try { + 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)) { + console.log( + `[generate-ai] No VTT found for ${videoId}, marking SKIPPED`, + ); + await db() + .update(videos) + .set({ metadata: { ...metadata, aiGenerationStatus: "SKIPPED" } }) + .where(eq(videos.id, videoId)); + return; + } + + const segments = parseVttWithTimestamps(vtt.value); + const text = segments + .map((s) => s.text) + .join(" ") + .trim(); + + if (text.length < 10) { + console.log( + `[generate-ai] VTT too short for ${videoId}, marking SKIPPED`, + ); + await db() + .update(videos) + .set({ metadata: { ...metadata, aiGenerationStatus: "SKIPPED" } }) + .where(eq(videos.id, videoId)); + return; + } + + const groqClient = getGroqClient(); + const chunks = chunkTranscriptWithTimestamps(segments); + const videoDuration = getVideoDuration(segments); + + let result: AiResult; + if (chunks.length === 1) { + result = await generateSingleChunk(segments, videoDuration, groqClient); + } else { + result = await generateMultipleChunks(chunks, videoDuration, groqClient); + } + + if (result.chapters) { + result.chapters = clampChapters(result.chapters, videoDuration); + } + + const updatedMetadata: VideoMetadata = { + ...metadata, + aiTitle: result.title || metadata.aiTitle, + summary: result.summary || metadata.summary, + chapters: result.chapters || metadata.chapters, + aiGenerationStatus: "COMPLETE", + }; + + await db() + .update(videos) + .set({ metadata: updatedMetadata }) + .where(eq(videos.id, 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) && + result.title + ) { + await db() + .update(videos) + .set({ name: result.title }) + .where(eq(videos.id, videoId)); + } + + console.log(`[generate-ai] Direct AI generation COMPLETE for ${videoId}`); + } catch (error) { + console.error( + `[generate-ai] Direct AI generation ERROR for ${videoId}:`, + error, + ); + await db() + .update(videos) + .set({ metadata: { ...metadata, aiGenerationStatus: "ERROR" } }) + .where(eq(videos.id, videoId)); + } +} + export async function startAiGeneration( videoId: Video.VideoId, userId: string, @@ -82,11 +542,16 @@ export async function startAiGeneration( }) .where(eq(videos.id, videoId)); - await start(generateAiWorkflow, [{ videoId, userId }]); + runAIGenerationDirect(videoId, userId).catch((error) => { + console.error( + `[generate-ai] Unhandled error in direct generation for ${videoId}:`, + error, + ); + }); return { success: true, - message: "AI generation workflow started", + message: "AI generation started", }; } catch { await db() @@ -101,7 +566,7 @@ export async function startAiGeneration( return { success: false, - message: "Failed to start AI generation workflow", + message: "Failed to start AI generation", }; } } From 7e6f5e1cc24533fe846f7a15aed9a3655d8ee2d1 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 18:27:16 +0530 Subject: [PATCH 24/35] treat QUEUED aiGenerationStatus as retriable on Railway start() workflow dispatch leaves status as QUEUED forever since Runner never executes. Allow QUEUED in shouldTriggerAiGeneration so the browser poll auto-retriggers direct generation. Remove QUEUED from startAiGeneration early- return guard so it actually re-runs instead of returning "already in progress". Also allow QUEUED in retry-ai canRetry for manual retries. --- apps/web/actions/videos/get-status.ts | 3 ++- apps/web/app/api/videos/[videoId]/retry-ai/route.ts | 3 ++- apps/web/lib/generate-ai.ts | 5 +---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 93faf3b8026..a0c55f6745c 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -135,7 +135,8 @@ export async function getVideoStatus( const shouldTriggerAiGeneration = video.transcriptionStatus === "COMPLETE" && - !metadata.aiGenerationStatus && + (!metadata.aiGenerationStatus || + metadata.aiGenerationStatus === "QUEUED") && !metadata.summary && (serverEnv().GROQ_API_KEY || serverEnv().OPENAI_API_KEY); diff --git a/apps/web/app/api/videos/[videoId]/retry-ai/route.ts b/apps/web/app/api/videos/[videoId]/retry-ai/route.ts index 56590c105bd..6cb6886605d 100644 --- a/apps/web/app/api/videos/[videoId]/retry-ai/route.ts +++ b/apps/web/app/api/videos/[videoId]/retry-ai/route.ts @@ -53,7 +53,8 @@ export async function POST( const canRetry = !metadata.aiGenerationStatus || metadata.aiGenerationStatus === "ERROR" || - metadata.aiGenerationStatus === "SKIPPED"; + metadata.aiGenerationStatus === "SKIPPED" || + metadata.aiGenerationStatus === "QUEUED"; if (!canRetry) { return Response.json( diff --git a/apps/web/lib/generate-ai.ts b/apps/web/lib/generate-ai.ts index cbe90038ff2..e369ccfbddc 100644 --- a/apps/web/lib/generate-ai.ts +++ b/apps/web/lib/generate-ai.ts @@ -510,10 +510,7 @@ export async function startAiGeneration( const metadata = (video.metadata as VideoMetadata) || {}; - if ( - metadata.aiGenerationStatus === "PROCESSING" || - metadata.aiGenerationStatus === "QUEUED" - ) { + if (metadata.aiGenerationStatus === "PROCESSING") { return { success: true, message: "AI generation already in progress", From 4fbd30c917a4cfab2425e97b065259c950fa379e Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 20:44:44 +0530 Subject: [PATCH 25/35] bypass processVideoWorkflow start() with direct media server call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start(processVideoWorkflow) dispatches to Effect Cluster runner via RPC but runner never executes it — same issue as transcription and AI generation. Implement runVideoProcessingDirect() that calls media server POST /video/process directly, polls for completion, saves metadata to DB, and cleans up the upload row and raw S3 file. This fixes result.mp4 generation and thumbnail creation for raw recorder uploads on Railway. --- apps/web/lib/video-processing.ts | 257 +++++++++++++++++++++++++++---- 1 file changed, 231 insertions(+), 26 deletions(-) diff --git a/apps/web/lib/video-processing.ts b/apps/web/lib/video-processing.ts index 0b61f28dfdf..f22878b6627 100644 --- a/apps/web/lib/video-processing.ts +++ b/apps/web/lib/video-processing.ts @@ -1,9 +1,11 @@ import { db } from "@cap/database"; -import { videoUploads } from "@cap/database/schema"; +import { videos, videoUploads } from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { S3Buckets } from "@cap/web-backend"; import type { S3Bucket, Video } from "@cap/web-domain"; import { and, eq, ne } from "drizzle-orm"; -import { start } from "workflow/api"; -import { processVideoWorkflow } from "@/workflows/process-video"; +import { Option } from "effect"; +import { runPromise } from "@/lib/server"; export type VideoProcessingStartStatus = "started" | "already-processing"; @@ -87,13 +89,228 @@ export async function transitionVideoToProcessing({ throw new Error("Failed to transition upload to processing"); } +function getInputExtension(rawFileKey: string): string { + const parts = rawFileKey.split("."); + const extension = parts.at(-1)?.toLowerCase(); + return extension ? `.${extension}` : ".mp4"; +} + +function getValidDuration(duration: number) { + return Number.isFinite(duration) && duration > 0 ? duration : undefined; +} + +const MEDIA_SERVER_START_MAX_ATTEMPTS = 6; +const MEDIA_SERVER_START_RETRY_BASE_MS = 2000; + +async function startMediaServerProcessJob( + mediaServerUrl: string, + body: { + videoId: string; + userId: string; + videoUrl: string; + outputPresignedUrl: string; + thumbnailPresignedUrl: string; + webhookUrl: string; + webhookSecret?: string; + inputExtension: string; + }, +): Promise { + for (let attempt = 0; attempt < MEDIA_SERVER_START_MAX_ATTEMPTS; attempt++) { + const headers: Record = { + "Content-Type": "application/json", + }; + if (body.webhookSecret) { + headers["x-media-server-secret"] = body.webhookSecret; + } + + const response = await fetch(`${mediaServerUrl}/video/process`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (response.ok) { + const { jobId } = (await response.json()) as { jobId: string }; + return jobId; + } + + const errorData = (await response.json().catch(() => ({}))) as { + error?: string; + code?: string; + details?: string; + }; + const errorMessage = + errorData.error || errorData.details || "Video processing failed to start"; + const shouldRetry = + response.status === 503 && + (errorData.code === "SERVER_BUSY" || + errorMessage.includes("Server is busy")); + + if (shouldRetry && attempt < MEDIA_SERVER_START_MAX_ATTEMPTS - 1) { + await new Promise((resolve) => + setTimeout(resolve, MEDIA_SERVER_START_RETRY_BASE_MS * 2 ** attempt), + ); + continue; + } + + throw new Error(errorMessage); + } + + throw new Error("Video processing failed to start after retries"); +} + +async function pollForCompletion( + mediaServerUrl: string, + jobId: string, +): Promise<{ metadata: { duration: number; width: number; height: number; fps: number } }> { + const maxAttempts = 360; + const pollIntervalMs = 5000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + + const response = await fetch( + `${mediaServerUrl}/video/process/${jobId}/status`, + { method: "GET", headers: { Accept: "application/json" } }, + ); + + if (!response.ok) { + console.warn( + `[video-processing] Poll failed: ${response.status} for job ${jobId}`, + ); + continue; + } + + const status = (await response.json()) as { + phase: string; + progress: number; + error?: string; + metadata?: { duration: number; width: number; height: number; fps: number }; + }; + + if (status.phase === "complete") { + if (!status.metadata) throw new Error("Processing complete but no metadata"); + return { metadata: status.metadata }; + } + if (status.phase === "error") throw new Error(status.error || "Video processing failed"); + if (status.phase === "cancelled") throw new Error("Video processing cancelled"); + } + + throw new Error("Video processing timed out"); +} + +async function runVideoProcessingDirect(params: { + videoId: Video.VideoId; + userId: string; + rawFileKey: string; + bucketId: string | null; +}): Promise { + const { videoId, userId, rawFileKey, bucketId } = params; + + console.log(`[video-processing] Starting direct processing for ${videoId}`); + + const mediaServerUrl = serverEnv().MEDIA_SERVER_URL; + if (!mediaServerUrl) { + console.error("[video-processing] MEDIA_SERVER_URL not configured"); + await setVideoProcessingError( + videoId, + "Media server not configured", + new Error("MEDIA_SERVER_URL not configured"), + ); + return; + } + + try { + const [bucket] = await S3Buckets.getBucketAccess( + Option.fromNullable(bucketId as S3Bucket.S3BucketId | null), + ).pipe(runPromise); + + const rawVideoUrl = await bucket + .getInternalSignedObjectUrl(rawFileKey) + .pipe(runPromise); + + const outputKey = `${userId}/${videoId}/result.mp4`; + const thumbnailKey = `${userId}/${videoId}/screenshot/screen-capture.jpg`; + + const outputPresignedUrl = await bucket + .getInternalPresignedPutUrl(outputKey, { ContentType: "video/mp4" }) + .pipe(runPromise); + + const thumbnailPresignedUrl = await bucket + .getInternalPresignedPutUrl(thumbnailKey, { ContentType: "image/jpeg" }) + .pipe(runPromise); + + const webhookBaseUrl = + serverEnv().MEDIA_SERVER_WEBHOOK_URL || serverEnv().WEB_URL; + const webhookUrl = `${webhookBaseUrl}/api/webhooks/media-server/progress`; + const webhookSecret = serverEnv().MEDIA_SERVER_WEBHOOK_SECRET; + + const jobId = await startMediaServerProcessJob(mediaServerUrl, { + videoId, + userId, + videoUrl: rawVideoUrl, + outputPresignedUrl, + thumbnailPresignedUrl, + webhookUrl, + webhookSecret: webhookSecret || undefined, + inputExtension: getInputExtension(rawFileKey), + }); + + console.log( + `[video-processing] Media server job ${jobId} started for ${videoId}`, + ); + + const result = await pollForCompletion(mediaServerUrl, jobId); + + // Save metadata and delete upload row + const duration = getValidDuration(result.metadata.duration); + await db() + .update(videos) + .set({ + width: result.metadata.width, + height: result.metadata.height, + fps: result.metadata.fps, + ...(duration === undefined ? {} : { duration }), + }) + .where(eq(videos.id, videoId)); + + await db() + .delete(videoUploads) + .where(eq(videoUploads.videoId, videoId)); + + // Delete raw upload from S3 + try { + const [cleanupBucket] = await S3Buckets.getBucketAccess( + Option.fromNullable(bucketId as S3Bucket.S3BucketId | null), + ).pipe(runPromise); + await cleanupBucket.deleteObject(rawFileKey).pipe(runPromise); + } catch (cleanupErr) { + console.error( + `[video-processing] Failed to delete raw upload for ${videoId}:`, + cleanupErr, + ); + } + + console.log(`[video-processing] Direct processing COMPLETE for ${videoId}`); + } catch (error) { + console.error( + `[video-processing] Direct processing ERROR for ${videoId}:`, + error, + ); + await setVideoProcessingError( + videoId, + "Video processing failed", + error instanceof Error ? error : new Error(String(error)), + ); + } +} + export async function startVideoProcessingWorkflow({ videoId, userId, rawFileKey, bucketId, processingMessage, - startFailureMessage, mode, forceRestart, }: { @@ -118,26 +335,14 @@ export async function startVideoProcessingWorkflow({ return status; } - try { - await start(processVideoWorkflow, [ - { - videoId, - userId, - rawFileKey, - bucketId: bucketId as S3Bucket.S3BucketId | null, - }, - ]); - return "started"; - } catch (error) { - const normalizedError = - error instanceof Error - ? error - : new Error("Video processing could not start"); - await setVideoProcessingError( - videoId, - startFailureMessage, - normalizedError, - ); - throw normalizedError; - } + runVideoProcessingDirect({ videoId, userId, rawFileKey, bucketId }).catch( + (error) => { + console.error( + `[video-processing] Unhandled error for ${videoId}:`, + error, + ); + }, + ); + + return "started"; } From d74e65ea12a4c9acc460211f3bd23851f84af8b1 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 21:13:27 +0530 Subject: [PATCH 26/35] fix(embed): allow /embed/ routes on self-hosted instances proxy.ts whitelist excludes /embed/, so any request to /embed/:videoId redirects to /login on self-hosted (NEXT_PUBLIC_IS_CAP !== "true"). Add /embed/ to the allowed paths so embed iframes load without auth. Backport of upstream PR #1415. --- apps/web/proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index ec57ff223e0..1e587c6c806 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -40,6 +40,7 @@ export async function proxy(request: NextRequest) { if ( !( path.startsWith("/s/") || + path.startsWith("/embed/") || path.startsWith("/middleware") || path.startsWith("/dashboard") || path.startsWith("/onboarding") || From 435eaf63f4542838fb730d07cbecdd9bb7e5d8d0 Mon Sep 17 00:00:00 2001 From: Mudit Lal <46276282+Mudit-Lal@users.noreply.github.com> Date: Wed, 6 May 2026 21:26:47 +0530 Subject: [PATCH 27/35] brand: replace Cap blue with Devalok brand color (#D33163) Replace the full blue color system with Devalok brand pink: - Swap @radix-ui/colors/blue.css for crimson + dark variant - Remap --blue-1..12 to var(--crimson-1..12) so all bg-blue-N Tailwind utilities automatically resolve to crimson - Update --primary/secondary/tertiary custom props to brand colors - Update inner-play-button-border-two gradient from #446fae to brand Brand colors from shilp-sutra: brand=#D33163, brandDark=#B8284F --- apps/web/app/globals.css | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 059e6236c1b..162365433cd 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -3,22 +3,23 @@ @import "@radix-ui/colors/gray.css"; @import "@radix-ui/colors/gray-alpha.css"; @import "@radix-ui/colors/gray-dark.css"; -@import "@radix-ui/colors/blue.css"; +@import "@radix-ui/colors/crimson.css"; +@import "@radix-ui/colors/crimson-dark.css"; @tailwind base; @tailwind components; @tailwind utilities; :root { - --primary: #005cb1; - --primary-2: #004c93; - --primary-3: #003b73; - --secondary: #2eb4ff; - --secondary-2: #1696e0; - --secondary-3: #117ebd; - --tertiary: #c5eaff; - --tertiary-2: #d3e5ff; - --tertiary-3: #e0edff; + --primary: #d33163; + --primary-2: #b8284f; + --primary-3: #912243; + --secondary: #f07ba0; + --secondary-2: #d33163; + --secondary-3: #b8284f; + --tertiary: #ffd5e2; + --tertiary-2: #ffdce8; + --tertiary-3: #ffe8f0; --filler: #efefef; --filler-2: #e4e4e4; --filler-3: #e2e2e2; @@ -32,6 +33,18 @@ --background-start-rgb: 214, 219, 220; --background-end-rgb: 255, 255, 255; --gradient-border-radius: 12px; + --blue-1: var(--crimson-1); + --blue-2: var(--crimson-2); + --blue-3: var(--crimson-3); + --blue-4: var(--crimson-4); + --blue-5: var(--crimson-5); + --blue-6: var(--crimson-6); + --blue-7: var(--crimson-7); + --blue-8: var(--crimson-8); + --blue-9: var(--crimson-9); + --blue-10: var(--crimson-10); + --blue-11: var(--crimson-11); + --blue-12: var(--crimson-12); } @media (prefers-color-scheme: dark) { @@ -150,7 +163,7 @@ html { :where(blockquote p:first-of-type):not( :where([class~="not-prose"], [class~="not-prose"] *) )::before { - content: "“"; + content: """; display: none; } @@ -381,7 +394,7 @@ label { inset: 0; padding: 1px; border-radius: 100px; - background: linear-gradient(to bottom, #446fae 20%, transparent 90%); + background: linear-gradient(to bottom, #b8284f 20%, transparent 90%); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); From f9ab257a7bfd34029c1b2c5527ba230967b6aa75 Mon Sep 17 00:00:00 2001 From: Mudit Lal Date: Wed, 6 May 2026 21:28:43 +0530 Subject: [PATCH 28/35] brand: replace blue scale with Devalok pink in tailwind config and Button Override standard blue-50/900 shades with brand pink scale (H342), update blue-transparent rgba, remap radialblue gradient stops and shadow to brand. --- packages/ui/src/components/Button.tsx | 2 +- packages/ui/style/tailwind.config.js | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 3ace455df2d..9bf9de8d876 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -28,7 +28,7 @@ const buttonVariants = cva( darkgradient: "bg-gradient-to-t button-gradient-border from-[#0f0f0f] to-[#404040] shadow-[0_0_0_1px] hover:brightness-110 shadow-[#383838] text-gray-50 hover:bg-[#383838] disabled:bg-[#383838] border-transparent", radialblue: - "text-gray-50 border button-gradient-border shadow-[0_0_0_1px] shadow-blue-400 disabled:bg-gray-1 border-0 [background:radial-gradient(90%_100%_at_15%_12%,#9BC4FF_0%,#3588FF_100%)] border-transparent hover:opacity-80", + "text-gray-50 border button-gradient-border shadow-[0_0_0_1px] shadow-[#F07BA0] disabled:bg-gray-1 border-0 [background:radial-gradient(90%_100%_at_15%_12%,#F07BA0_0%,#D33163_100%)] border-transparent hover:opacity-80", transparent: "bg-transparent text-gray-10 hover:underline transition-all duration-200 hover:text-gray-12", }, diff --git a/packages/ui/style/tailwind.config.js b/packages/ui/style/tailwind.config.js index d91300eff72..3ddc4640279 100644 --- a/packages/ui/style/tailwind.config.js +++ b/packages/ui/style/tailwind.config.js @@ -74,7 +74,19 @@ module.exports = (__app, _options) => { colors: { gray: getColorScale("gray"), "gray-a": getColorScale("gray-a", true), - blue: getColorScale("blue"), + blue: { + ...getColorScale("blue"), + 50: "#FBEEF2", + 100: "#F7DEE6", + 200: "#EFBDCC", + 300: "#E694AC", + 400: "#DC6A8C", + 500: "#D54D76", + 600: "#D33163", + 700: "#B8284F", + 800: "#912243", + 900: "#6B1932", + }, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", @@ -136,8 +148,8 @@ module.exports = (__app, _options) => { 40: "rgba(255,255,255,0.4)", }, "blue-transparent": { - 10: "rgba(34,64,122,0.1)", - 20: "rgba(34,64,122,0.2)", + 10: "rgba(211,49,99,0.1)", + 20: "rgba(211,49,99,0.2)", }, red: { 50: "#FFEBEE", From 1cce41b083c1de3e6ef2efef2509d63871f21469 Mon Sep 17 00:00:00 2001 From: Mudit Lal Date: Wed, 6 May 2026 21:44:29 +0530 Subject: [PATCH 29/35] fix(css): restore blockquote content unicode escape broken by brand commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our globals.css edit mangled content: "“"; (CSS string containing U+201C) into content: """; (three ASCII quotes), causing a runaway unclosed string that PostCSS didn't detect until line 851. Use CSS escape \201c instead of the literal UTF-8 byte sequence to avoid re-encoding issues. --- apps/web/app/globals.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 162365433cd..588fbb74fdb 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -163,7 +163,7 @@ html { :where(blockquote p:first-of-type):not( :where([class~="not-prose"], [class~="not-prose"] *) )::before { - content: """; + content: "\201c"; display: none; } From 8116535f6d07dc97e1ae400b9042a7ef90dab427 Mon Sep 17 00:00:00 2001 From: Mudit Lal Date: Wed, 6 May 2026 22:06:55 +0530 Subject: [PATCH 30/35] fix(proxy): allow static public files on self-hosted /google.svg and other /public/* assets matched the proxy matcher and got redirected to /login because they didn't match any whitelist prefix. Skip the redirect for any path with a file extension. --- apps/web/proxy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 1e587c6c806..473be9d8133 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -50,7 +50,8 @@ export async function proxy(request: NextRequest) { path.startsWith("/invite") || path.startsWith("/self-hosting") || path.startsWith("/terms") || - path.startsWith("/verify-otp") + path.startsWith("/verify-otp") || + /\.[a-z0-9]+$/i.test(path) ) && process.env.NODE_ENV !== "development" ) From 69c5ace7e6005944559a3641ebcfd4aad11438ad Mon Sep 17 00:00:00 2001 From: Mudit Lal Date: Wed, 6 May 2026 22:58:48 +0530 Subject: [PATCH 31/35] fix(proxy): whitelist remaining authenticated and linked routes Add /privacy (linked from login footer), /admin, /dev, /messenger, and /docs to the self-hosted proxy whitelist. These routes have their own auth checks; the proxy was forcing 307 to /login even for authenticated users, making them unreachable. --- apps/web/proxy.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 473be9d8133..81a50b3e1b6 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -50,7 +50,12 @@ export async function proxy(request: NextRequest) { path.startsWith("/invite") || path.startsWith("/self-hosting") || path.startsWith("/terms") || + path.startsWith("/privacy") || path.startsWith("/verify-otp") || + path.startsWith("/admin") || + path.startsWith("/dev") || + path.startsWith("/messenger") || + path.startsWith("/docs") || /\.[a-z0-9]+$/i.test(path) ) && process.env.NODE_ENV !== "development" From a005faba2d851fac7a1cd089b35dcefbe28bcc22 Mon Sep 17 00:00:00 2001 From: Mudit Lal Date: Thu, 7 May 2026 00:13:19 +0530 Subject: [PATCH 32/35] fix(proxy): whitelist /.well-known/ to unblock in-process workflows workflow npm package on non-Vercel hosts uses world-local, which dispatches step/flow execution by HTTP POSTing to http://localhost:$PORT/.well-known/workflow/v1/{flow,step}. Our self-hosted proxy redirected ALL non-whitelisted paths to /login, so the self-callback hit a 307 instead of the workflow handler, making start() silently no-op for transcribeVideoWorkflow, generateAiWorkflow, processVideoWorkflow, and importLoomVideoWorkflow. This is the underlying cause of the issue we worked around in 60af444, 7e6f5e1, and 4fbd30c. With this fix, those direct bypasses can be reverted in a follow-up if desired. --- apps/web/proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 81a50b3e1b6..fc1478ab21d 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -56,6 +56,7 @@ export async function proxy(request: NextRequest) { path.startsWith("/dev") || path.startsWith("/messenger") || path.startsWith("/docs") || + path.startsWith("/.well-known/") || /\.[a-z0-9]+$/i.test(path) ) && process.env.NODE_ENV !== "development" From 0394476b3adbc82d9aa2865aeb769ca67d1feafd Mon Sep 17 00:00:00 2001 From: Mudit Lal Date: Thu, 7 May 2026 00:26:33 +0530 Subject: [PATCH 33/35] revert: remove direct workflow bypasses now that start() works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /.well-known/ proxy whitelist (a005fab) was the actual fix for workflow execution. Restore upstream behavior: - transcribe.ts: replace runTranscriptionDirect() with start(transcribeVideoWorkflow). Restore 'processing' phase to upload-blocking guard. - generate-ai.ts: replace runAIGenerationDirect() with start(generateAiWorkflow). Restore QUEUED to early-return block. - video-processing.ts: replace runVideoProcessingDirect() with start(processVideoWorkflow). Restore upstream error handling. QUEUED-as-retriable in retry-ai/route.ts and get-status.ts is left in place — harmless with working workflows since QUEUED is transient. --- apps/web/lib/generate-ai.ts | 482 +------------------------------ apps/web/lib/transcribe.ts | 226 +-------------- apps/web/lib/video-processing.ts | 253 ++-------------- 3 files changed, 47 insertions(+), 914 deletions(-) diff --git a/apps/web/lib/generate-ai.ts b/apps/web/lib/generate-ai.ts index e369ccfbddc..cafacc638e8 100644 --- a/apps/web/lib/generate-ai.ts +++ b/apps/web/lib/generate-ai.ts @@ -1,477 +1,17 @@ import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; +import { videos } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; -import type { S3Bucket, Video } from "@cap/web-domain"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Option } from "effect"; -import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client"; -import { runPromise } from "@/lib/server"; - -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; +import { start } from "workflow/api"; +import { generateAiWorkflow } from "@/workflows/generate-ai"; type GenerateAiResult = { success: boolean; message: string; }; -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; -} - -function getVideoDuration(segments: VttSegment[]): number { - if (segments.length === 0) return 0; - const lastSegment = segments[segments.length - 1]; - return lastSegment ? lastSegment.start + 3 : 0; -} - -function clampChapters( - chapters: { title: string; start: number }[], - videoDuration: number, -): { title: string; start: number }[] { - const filtered = chapters.filter((ch) => ch.start < videoDuration); - - if (filtered.length === 0 && chapters.length > 0) { - const first = chapters[0]; - return first ? [{ title: first.title, start: 0 }] : []; - } - - const minGap = Math.max(5, Math.floor(videoDuration / 10)); - const deduped: { title: string; start: number }[] = []; - for (const chapter of filtered) { - const last = deduped[deduped.length - 1]; - if (!last || Math.abs(chapter.start - last.start) >= minGap) { - deduped.push(chapter); - } - } - - return deduped; -} - -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; -} - -function parseAiResponse(content: string): AiResult { - try { - const data = JSON.parse(cleanJsonResponse(content).trim()); - - const chapters = Array.isArray(data.chapters) - ? data.chapters - .filter( - (ch: { start?: number }) => - typeof ch.start === "number" && ch.start >= 0, - ) - .sort( - (a: { start: number }, b: { start: number }) => a.start - b.start, - ) - : []; - - return { - title: data.title, - summary: data.summary, - chapters, - }; - } catch { - return { - title: "Generated Title", - summary: - "The AI was unable to generate a proper summary for this content.", - chapters: [], - }; - } -} - -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 || "{}"; -} - -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; - } - } else if (serverEnv().OPENAI_API_KEY) { - return callOpenAi(prompt); - } - return "{}"; -} - -async function generateSingleChunk( - segments: VttSegment[], - videoDuration: number, - groqClient: ReturnType, -): Promise { - const transcriptWithTimestamps = segments - .map( - (s) => - `[${Math.floor(s.start / 60)}:${String(s.start % 60).padStart(2, "0")}] ${s.text}`, - ) - .join("\n"); - - const prompt = `You are Cap AI, an expert at analyzing video content and creating comprehensive summaries. - -The video is ${videoDuration} seconds long (${Math.floor(videoDuration / 60)}:${String(Math.floor(videoDuration % 60)).padStart(2, "0")} total). Analyze this timestamped transcript 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 -- IMPORTANT: All chapter "start" values MUST be between 0 and ${videoDuration} seconds. Use the timestamps from the transcript to determine accurate chapter start times. - -Return ONLY valid JSON without any markdown formatting or code blocks. -Transcript: -${transcriptWithTimestamps}`; - - const content = await callAiApi(prompt, groqClient); - return parseAiResponse(content); -} - -async function generateMultipleChunks( - chunks: { text: string; startTime: number; endTime: number }[], - videoDuration: 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 video that is ${videoDuration} seconds long (${Math.floor(videoDuration / 60)}:${String(Math.floor(videoDuration % 60)).padStart(2, "0")} total). This section covers 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)}] -} - -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. -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 {} - } - - const allChapters: { title: string; start: number }[] = []; - const sortedChapters = chunkSummaries - .flatMap((c) => c.chapters) - .sort((a, b) => a.start - b.start); - const minGap = Math.max(5, Math.floor(videoDuration / 10)); - for (const chapter of sortedChapters) { - const lastChapter = allChapters[allChapters.length - 1]; - if (!lastChapter || Math.abs(chapter.start - lastChapter.start) >= minGap) { - 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 { - 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, - }; - } -} - -async function runAIGenerationDirect( - videoId: Video.VideoId, - userId: string, -): Promise { - console.log(`[generate-ai] Starting direct AI generation for ${videoId}`); - - const query = await db() - .select({ video: videos, bucket: s3Buckets }) - .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) - .where(eq(videos.id, videoId)); - - if (!query[0]?.video) { - console.error(`[generate-ai] Video ${videoId} not found`); - return; - } - - const { video, bucket } = query[0]; - const metadata = (video.metadata as VideoMetadata) || {}; - const bucketId = (bucket?.id ?? null) as S3Bucket.S3BucketId | null; - - await db() - .update(videos) - .set({ metadata: { ...metadata, aiGenerationStatus: "PROCESSING" } }) - .where(eq(videos.id, videoId)); - - try { - 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)) { - console.log( - `[generate-ai] No VTT found for ${videoId}, marking SKIPPED`, - ); - await db() - .update(videos) - .set({ metadata: { ...metadata, aiGenerationStatus: "SKIPPED" } }) - .where(eq(videos.id, videoId)); - return; - } - - const segments = parseVttWithTimestamps(vtt.value); - const text = segments - .map((s) => s.text) - .join(" ") - .trim(); - - if (text.length < 10) { - console.log( - `[generate-ai] VTT too short for ${videoId}, marking SKIPPED`, - ); - await db() - .update(videos) - .set({ metadata: { ...metadata, aiGenerationStatus: "SKIPPED" } }) - .where(eq(videos.id, videoId)); - return; - } - - const groqClient = getGroqClient(); - const chunks = chunkTranscriptWithTimestamps(segments); - const videoDuration = getVideoDuration(segments); - - let result: AiResult; - if (chunks.length === 1) { - result = await generateSingleChunk(segments, videoDuration, groqClient); - } else { - result = await generateMultipleChunks(chunks, videoDuration, groqClient); - } - - if (result.chapters) { - result.chapters = clampChapters(result.chapters, videoDuration); - } - - const updatedMetadata: VideoMetadata = { - ...metadata, - aiTitle: result.title || metadata.aiTitle, - summary: result.summary || metadata.summary, - chapters: result.chapters || metadata.chapters, - aiGenerationStatus: "COMPLETE", - }; - - await db() - .update(videos) - .set({ metadata: updatedMetadata }) - .where(eq(videos.id, 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) && - result.title - ) { - await db() - .update(videos) - .set({ name: result.title }) - .where(eq(videos.id, videoId)); - } - - console.log(`[generate-ai] Direct AI generation COMPLETE for ${videoId}`); - } catch (error) { - console.error( - `[generate-ai] Direct AI generation ERROR for ${videoId}:`, - error, - ); - await db() - .update(videos) - .set({ metadata: { ...metadata, aiGenerationStatus: "ERROR" } }) - .where(eq(videos.id, videoId)); - } -} - export async function startAiGeneration( videoId: Video.VideoId, userId: string, @@ -510,7 +50,10 @@ export async function startAiGeneration( const metadata = (video.metadata as VideoMetadata) || {}; - if (metadata.aiGenerationStatus === "PROCESSING") { + if ( + metadata.aiGenerationStatus === "PROCESSING" || + metadata.aiGenerationStatus === "QUEUED" + ) { return { success: true, message: "AI generation already in progress", @@ -539,16 +82,11 @@ export async function startAiGeneration( }) .where(eq(videos.id, videoId)); - runAIGenerationDirect(videoId, userId).catch((error) => { - console.error( - `[generate-ai] Unhandled error in direct generation for ${videoId}:`, - error, - ); - }); + await start(generateAiWorkflow, [{ videoId, userId }]); return { success: true, - message: "AI generation started", + message: "AI generation workflow started", }; } catch { await db() diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index b4cc230683e..f3696d086c0 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -1,33 +1,15 @@ -import { promises as fs } from "node:fs"; import { db } from "@cap/database"; import { organizations, s3Buckets, - users, videos, videoUploads, } from "@cap/database/schema"; -import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; -import { userIsPro } from "@cap/utils"; -import { S3Buckets } from "@cap/web-backend"; -import type { S3Bucket, Video } from "@cap/web-domain"; -import { createClient } from "@deepgram/sdk"; +import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Option } from "effect"; -import { - checkHasAudioTrack, - extractAudioFromUrl, -} from "@/lib/audio-extract"; -import { - checkHasAudioTrackViaMediaServer, - extractAudioViaMediaServer, - isMediaServerConfigured, - probeVideoViaMediaServer, -} from "@/lib/media-client"; -import { runPromise } from "@/lib/server"; -import { type DeepgramResult, formatToWebVTT } from "@/lib/transcribe-utils"; -import { startAiGeneration } from "./generate-ai"; +import { start } from "workflow/api"; +import { transcribeVideoWorkflow } from "@/workflows/transcribe"; type TranscribeResult = { success: boolean; @@ -129,8 +111,8 @@ export async function transcribeVideo( .limit(1); if ( - (upload[0]?.phase === "uploading" && - (upload[0]?.uploaded ?? 0) < (upload[0]?.total ?? 1)) || + upload[0]?.phase === "uploading" || + upload[0]?.phase === "processing" || upload[0]?.phase === "generating_thumbnail" ) { return { @@ -141,34 +123,20 @@ export async function transcribeVideo( try { console.log( - `[transcribeVideo] Triggering transcription for video ${videoId}`, + `[transcribeVideo] Triggering transcription workflow for video ${videoId}`, ); - // Mark PROCESSING immediately so polling stops while transcription runs. - // We bypass workflow/api start() — it silently no-ops on Railway - // (needs VERCEL_URL for self-callbacks) and blocks direct invocation. - await db() - .update(videos) - .set({ transcriptionStatus: "PROCESSING" }) - .where(eq(videos.id, videoId)); - - runTranscriptionDirect(videoId, userId, aiGenerationEnabled).catch( - (error) => { - console.error( - `[transcribeVideo] Transcription failed for ${videoId}:`, - error, - ); - db() - .update(videos) - .set({ transcriptionStatus: null }) - .where(eq(videos.id, videoId)) - .catch(() => {}); + await start(transcribeVideoWorkflow, [ + { + videoId, + userId, + aiGenerationEnabled, }, - ); + ]); return { success: true, - message: "Transcription started", + message: "Transcription workflow started", }; } catch (error) { console.error("[transcribeVideo] Failed to start transcription:", error); @@ -185,171 +153,3 @@ export async function transcribeVideo( } } -async function runTranscriptionDirect( - videoId: Video.VideoId, - userId: string, - aiGenerationEnabled: boolean, -): Promise { - console.log(`[transcribe] Starting direct transcription for ${videoId}`); - - // --- resolve bucket --- - const videoQuery = await db() - .select({ - video: videos, - bucket: s3Buckets, - owner: users, - }) - .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) - .innerJoin(users, eq(videos.ownerId, users.id)) - .where(eq(videos.id, videoId)); - - if (!videoQuery[0]?.video) { - throw new Error(`Video ${videoId} not found`); - } - - const bucketId = (videoQuery[0].bucket?.id ?? null) as S3Bucket.S3BucketId | null; - const isOwnerPro = userIsPro(videoQuery[0].owner); - - console.log( - `[transcribe] Owner check: isOwnerPro=${isOwnerPro}`, - ); - - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId), - ).pipe(runPromise); - - // --- resolve video source URL --- - const uploadRow = await db() - .select({ rawFileKey: videoUploads.rawFileKey }) - .from(videoUploads) - .where(eq(videoUploads.videoId, videoId)) - .limit(1); - - const candidateKeys = [ - `${userId}/${videoId}/result.mp4`, - uploadRow[0]?.rawFileKey, - ].filter( - (v, i, arr): v is string => Boolean(v) && arr.indexOf(v) === i, - ); - - let videoUrl: string | null = null; - for (const key of candidateKeys) { - const url = await bucket.getInternalSignedObjectUrl(key).pipe(runPromise); - const probe = await fetch(url, { method: "GET", headers: { range: "bytes=0-0" } }); - if (probe.ok) { - console.log(`[transcribe] Using video source ${key}`); - videoUrl = url; - break; - } - } - - if (!videoUrl) { - throw new Error(`Video file not accessible for ${videoId}`); - } - - // --- check / extract audio --- - const useMediaServer = isMediaServerConfigured(); - console.log(`[transcribe] Audio detection: useMediaServer=${useMediaServer}, videoId=${videoId}`); - - let hasAudio: boolean; - let audioBuffer: Buffer; - - if (useMediaServer) { - try { - const probe = await probeVideoViaMediaServer(videoUrl); - console.log( - `[transcribe] Probe: audioCodec=${probe.audioCodec}, videoCodec=${probe.videoCodec}, duration=${probe.duration}`, - ); - hasAudio = probe.audioCodec !== null; - } catch (probeError) { - console.error(`[transcribe] Probe failed, falling back:`, probeError); - hasAudio = await checkHasAudioTrackViaMediaServer(videoUrl); - } - - if (!hasAudio) { - console.log(`[transcribe] No audio track for ${videoId}`); - await db() - .update(videos) - .set({ transcriptionStatus: "NO_AUDIO" }) - .where(eq(videos.id, videoId)); - return; - } - - audioBuffer = await extractAudioViaMediaServer(videoUrl); - } else { - hasAudio = await checkHasAudioTrack(videoUrl); - console.log(`[transcribe] Local ffmpeg audio check: hasAudio=${hasAudio}`); - - if (!hasAudio) { - await db() - .update(videos) - .set({ transcriptionStatus: "NO_AUDIO" }) - .where(eq(videos.id, videoId)); - return; - } - - const result = await extractAudioFromUrl(videoUrl); - try { - audioBuffer = await fs.readFile(result.filePath); - } finally { - await result.cleanup(); - } - } - - console.log(`[transcribe] Extracted audio: ${audioBuffer.length} bytes`); - - // --- upload temp audio to S3 --- - const audioKey = `${userId}/${videoId}/audio-temp.mp3`; - await bucket.putObject(audioKey, audioBuffer, { contentType: "audio/mpeg" }).pipe(runPromise); - const audioSignedUrl = await bucket.getInternalSignedObjectUrl(audioKey).pipe(runPromise); - - // --- transcribe with Deepgram --- - console.log(`[transcribe] Sending audio to Deepgram for ${videoId}`); - const audioResponse = await fetch(audioSignedUrl); - if (!audioResponse.ok) { - throw new Error(`Audio URL not accessible: ${audioResponse.status}`); - } - - const audioBuf = Buffer.from(await audioResponse.arrayBuffer()); - const deepgram = createClient(serverEnv().DEEPGRAM_API_KEY as string); - - const { result: dgResult, error: dgError } = - await deepgram.listen.prerecorded.transcribeFile(audioBuf, { - model: "nova-3", - smart_format: true, - detect_language: true, - utterances: true, - mime_type: "audio/mpeg", - }); - - if (dgError) { - throw new Error(`Deepgram failed: ${dgError.message}`); - } - - const vtt = formatToWebVTT(dgResult as unknown as DeepgramResult); - - // --- save VTT + mark COMPLETE --- - await bucket - .putObject(`${userId}/${videoId}/transcription.vtt`, vtt, { contentType: "text/vtt" }) - .pipe(runPromise); - - await db() - .update(videos) - .set({ transcriptionStatus: "COMPLETE" }) - .where(eq(videos.id, videoId)); - - console.log(`[transcribe] Transcription COMPLETE for ${videoId}`); - - // --- cleanup temp audio --- - try { - await bucket.deleteObject(audioKey).pipe(runPromise); - } catch { - console.error(`[transcribe] Failed to cleanup ${audioKey}`); - } - - // --- queue AI generation if enabled --- - if (aiGenerationEnabled) { - await startAiGeneration(videoId, userId); - } -} diff --git a/apps/web/lib/video-processing.ts b/apps/web/lib/video-processing.ts index f22878b6627..4aa2c6e73b6 100644 --- a/apps/web/lib/video-processing.ts +++ b/apps/web/lib/video-processing.ts @@ -1,11 +1,9 @@ import { db } from "@cap/database"; -import { videos, videoUploads } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; -import { S3Buckets } from "@cap/web-backend"; +import { videoUploads } from "@cap/database/schema"; import type { S3Bucket, Video } from "@cap/web-domain"; import { and, eq, ne } from "drizzle-orm"; -import { Option } from "effect"; -import { runPromise } from "@/lib/server"; +import { start } from "workflow/api"; +import { processVideoWorkflow } from "@/workflows/process-video"; export type VideoProcessingStartStatus = "started" | "already-processing"; @@ -89,222 +87,6 @@ export async function transitionVideoToProcessing({ throw new Error("Failed to transition upload to processing"); } -function getInputExtension(rawFileKey: string): string { - const parts = rawFileKey.split("."); - const extension = parts.at(-1)?.toLowerCase(); - return extension ? `.${extension}` : ".mp4"; -} - -function getValidDuration(duration: number) { - return Number.isFinite(duration) && duration > 0 ? duration : undefined; -} - -const MEDIA_SERVER_START_MAX_ATTEMPTS = 6; -const MEDIA_SERVER_START_RETRY_BASE_MS = 2000; - -async function startMediaServerProcessJob( - mediaServerUrl: string, - body: { - videoId: string; - userId: string; - videoUrl: string; - outputPresignedUrl: string; - thumbnailPresignedUrl: string; - webhookUrl: string; - webhookSecret?: string; - inputExtension: string; - }, -): Promise { - for (let attempt = 0; attempt < MEDIA_SERVER_START_MAX_ATTEMPTS; attempt++) { - const headers: Record = { - "Content-Type": "application/json", - }; - if (body.webhookSecret) { - headers["x-media-server-secret"] = body.webhookSecret; - } - - const response = await fetch(`${mediaServerUrl}/video/process`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - - if (response.ok) { - const { jobId } = (await response.json()) as { jobId: string }; - return jobId; - } - - const errorData = (await response.json().catch(() => ({}))) as { - error?: string; - code?: string; - details?: string; - }; - const errorMessage = - errorData.error || errorData.details || "Video processing failed to start"; - const shouldRetry = - response.status === 503 && - (errorData.code === "SERVER_BUSY" || - errorMessage.includes("Server is busy")); - - if (shouldRetry && attempt < MEDIA_SERVER_START_MAX_ATTEMPTS - 1) { - await new Promise((resolve) => - setTimeout(resolve, MEDIA_SERVER_START_RETRY_BASE_MS * 2 ** attempt), - ); - continue; - } - - throw new Error(errorMessage); - } - - throw new Error("Video processing failed to start after retries"); -} - -async function pollForCompletion( - mediaServerUrl: string, - jobId: string, -): Promise<{ metadata: { duration: number; width: number; height: number; fps: number } }> { - const maxAttempts = 360; - const pollIntervalMs = 5000; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - - const response = await fetch( - `${mediaServerUrl}/video/process/${jobId}/status`, - { method: "GET", headers: { Accept: "application/json" } }, - ); - - if (!response.ok) { - console.warn( - `[video-processing] Poll failed: ${response.status} for job ${jobId}`, - ); - continue; - } - - const status = (await response.json()) as { - phase: string; - progress: number; - error?: string; - metadata?: { duration: number; width: number; height: number; fps: number }; - }; - - if (status.phase === "complete") { - if (!status.metadata) throw new Error("Processing complete but no metadata"); - return { metadata: status.metadata }; - } - if (status.phase === "error") throw new Error(status.error || "Video processing failed"); - if (status.phase === "cancelled") throw new Error("Video processing cancelled"); - } - - throw new Error("Video processing timed out"); -} - -async function runVideoProcessingDirect(params: { - videoId: Video.VideoId; - userId: string; - rawFileKey: string; - bucketId: string | null; -}): Promise { - const { videoId, userId, rawFileKey, bucketId } = params; - - console.log(`[video-processing] Starting direct processing for ${videoId}`); - - const mediaServerUrl = serverEnv().MEDIA_SERVER_URL; - if (!mediaServerUrl) { - console.error("[video-processing] MEDIA_SERVER_URL not configured"); - await setVideoProcessingError( - videoId, - "Media server not configured", - new Error("MEDIA_SERVER_URL not configured"), - ); - return; - } - - try { - const [bucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId as S3Bucket.S3BucketId | null), - ).pipe(runPromise); - - const rawVideoUrl = await bucket - .getInternalSignedObjectUrl(rawFileKey) - .pipe(runPromise); - - const outputKey = `${userId}/${videoId}/result.mp4`; - const thumbnailKey = `${userId}/${videoId}/screenshot/screen-capture.jpg`; - - const outputPresignedUrl = await bucket - .getInternalPresignedPutUrl(outputKey, { ContentType: "video/mp4" }) - .pipe(runPromise); - - const thumbnailPresignedUrl = await bucket - .getInternalPresignedPutUrl(thumbnailKey, { ContentType: "image/jpeg" }) - .pipe(runPromise); - - const webhookBaseUrl = - serverEnv().MEDIA_SERVER_WEBHOOK_URL || serverEnv().WEB_URL; - const webhookUrl = `${webhookBaseUrl}/api/webhooks/media-server/progress`; - const webhookSecret = serverEnv().MEDIA_SERVER_WEBHOOK_SECRET; - - const jobId = await startMediaServerProcessJob(mediaServerUrl, { - videoId, - userId, - videoUrl: rawVideoUrl, - outputPresignedUrl, - thumbnailPresignedUrl, - webhookUrl, - webhookSecret: webhookSecret || undefined, - inputExtension: getInputExtension(rawFileKey), - }); - - console.log( - `[video-processing] Media server job ${jobId} started for ${videoId}`, - ); - - const result = await pollForCompletion(mediaServerUrl, jobId); - - // Save metadata and delete upload row - const duration = getValidDuration(result.metadata.duration); - await db() - .update(videos) - .set({ - width: result.metadata.width, - height: result.metadata.height, - fps: result.metadata.fps, - ...(duration === undefined ? {} : { duration }), - }) - .where(eq(videos.id, videoId)); - - await db() - .delete(videoUploads) - .where(eq(videoUploads.videoId, videoId)); - - // Delete raw upload from S3 - try { - const [cleanupBucket] = await S3Buckets.getBucketAccess( - Option.fromNullable(bucketId as S3Bucket.S3BucketId | null), - ).pipe(runPromise); - await cleanupBucket.deleteObject(rawFileKey).pipe(runPromise); - } catch (cleanupErr) { - console.error( - `[video-processing] Failed to delete raw upload for ${videoId}:`, - cleanupErr, - ); - } - - console.log(`[video-processing] Direct processing COMPLETE for ${videoId}`); - } catch (error) { - console.error( - `[video-processing] Direct processing ERROR for ${videoId}:`, - error, - ); - await setVideoProcessingError( - videoId, - "Video processing failed", - error instanceof Error ? error : new Error(String(error)), - ); - } -} - export async function startVideoProcessingWorkflow({ videoId, userId, @@ -335,14 +117,27 @@ export async function startVideoProcessingWorkflow({ return status; } - runVideoProcessingDirect({ videoId, userId, rawFileKey, bucketId }).catch( - (error) => { - console.error( - `[video-processing] Unhandled error for ${videoId}:`, - error, - ); - }, - ); + try { + await start(processVideoWorkflow, [ + { + videoId, + userId, + rawFileKey, + bucketId: bucketId as S3Bucket.S3BucketId | null, + }, + ]); + } catch (error) { + const normalizedError = + error instanceof Error + ? error + : new Error("Video processing could not start"); + await setVideoProcessingError( + videoId, + "Video processing failed to start", + normalizedError, + ); + throw normalizedError; + } return "started"; } From 39ce8b3224a7d52ac1084299d146a109655adc4e Mon Sep 17 00:00:00 2001 From: Mudit Lal Date: Thu, 7 May 2026 00:38:26 +0530 Subject: [PATCH 34/35] fix(transcribe): mark transcriptionStatus=PROCESSING before start() Without this, the get-status poll endpoint sees null transcriptionStatus and triggers transcribeVideo() repeatedly while the workflow's first step (validateVideo, which sets PROCESSING) hasn't executed yet. Result: ~10 parallel transcribe workflow runs for the same video. startAiGeneration already follows this pattern (sets QUEUED before start). Mirror it here to debounce poll-driven triggers. --- apps/web/lib/transcribe.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index f3696d086c0..5ae2bea489a 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -126,6 +126,11 @@ export async function transcribeVideo( `[transcribeVideo] Triggering transcription workflow for video ${videoId}`, ); + await db() + .update(videos) + .set({ transcriptionStatus: "PROCESSING" }) + .where(eq(videos.id, videoId)); + await start(transcribeVideoWorkflow, [ { videoId, From 0f2da37109ed07b16b8b0a383935d7a5721b7267 Mon Sep 17 00:00:00 2001 From: Mudit Lal Date: Sat, 18 Jul 2026 18:30:05 +0530 Subject: [PATCH 35/35] feat(web): add PostHog ui_host for f.devalok.in proxy api_host already comes from NEXT_PUBLIC_POSTHOG_HOST (flipped to the shared Devalok first-party proxy https://f.devalok.in in prod). Add ui_host so the PostHog toolbar / session-replay links resolve to the real EU app, which the ingest proxy does not front. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rs2hzKzJf9ar1yCwVcBi9o --- apps/web/app/Layout/providers.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/Layout/providers.tsx b/apps/web/app/Layout/providers.tsx index de6d03b41c5..74584b65f2a 100644 --- a/apps/web/app/Layout/providers.tsx +++ b/apps/web/app/Layout/providers.tsx @@ -37,6 +37,7 @@ export function PostHogProvider({ if (!host) return undefined; const base = { api_host: host, + ui_host: "https://eu.posthog.com", capture_pageview: false, capture_pageleave: true, bootstrap: initialBootstrap.current?.distinctID