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" diff --git a/apps/web-cluster/Dockerfile b/apps/web-cluster/Dockerfile index 41e3881d721..dfced05815f 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 @@ -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"] 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"] 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(); + }, + }), }), }); diff --git a/apps/web-cluster/src/shard-manager.ts b/apps/web-cluster/src/shard-manager.ts index d1ddf618096..dfc1da816a8 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,9 @@ import { DatabaseLive, ShardDatabaseLive } from "./shared/database.ts"; NodeClusterShardManagerSocket.layer({ storage: "sql", + shardingConfig: { + shardManagerAddress: RunnerAddress.make("0.0.0.0", 8080), + }, }).pipe( Layer.provide(ShardDatabaseLive), Layer.provide(DatabaseLive), diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 95c28bce770..bfc15f92f15 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 @@ -8,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 diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 88055769635..a0c55f6745c 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, or, sql } from "drizzle-orm"; import { Effect, Exit } from "effect"; import { startAiGeneration } from "@/lib/generate-ai"; import * as EffectRuntime from "@/lib/server"; @@ -60,7 +60,18 @@ 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), + or( + eq(videoUploads.phase, "generating_thumbnail"), + and( + eq(videoUploads.phase, "uploading"), + sql`${videoUploads.uploaded} < ${videoUploads.total}`, + ), + ), + ), + ) .limit(1); if (activeUpload.length > 0) { @@ -124,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/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 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/app/globals.css b/apps/web/app/globals.css index 059e6236c1b..588fbb74fdb 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: "\201c"; 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); diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 8741a78e832..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.videoId} IS NOT NULL`.mapWith( + hasActiveUpload: sql`(${videoUploads.phase} IN ('processing', 'generating_thumbnail') OR (${videoUploads.phase} = 'uploading' AND ${videoUploads.uploaded} < ${videoUploads.total}))`.mapWith( Boolean, ), owner: users, diff --git a/apps/web/lib/generate-ai.ts b/apps/web/lib/generate-ai.ts index 641a6e1545d..cafacc638e8 100644 --- a/apps/web/lib/generate-ai.ts +++ b/apps/web/lib/generate-ai.ts @@ -101,7 +101,7 @@ export async function startAiGeneration( return { success: false, - message: "Failed to start AI generation workflow", + message: "Failed to start AI generation", }; } } diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 0b07f04ab8b..5ae2bea489a 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -101,7 +101,11 @@ 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); @@ -122,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, @@ -135,7 +144,7 @@ export async function transcribeVideo( message: "Transcription workflow started", }; } catch (error) { - console.error("[transcribeVideo] Failed to trigger workflow:", error); + console.error("[transcribeVideo] Failed to start transcription:", error); await db() .update(videos) @@ -144,7 +153,8 @@ export async function transcribeVideo( return { success: false, - message: "Failed to start transcription workflow", + message: "Failed to start transcription", }; } } + diff --git a/apps/web/lib/video-processing.ts b/apps/web/lib/video-processing.ts index 0b61f28dfdf..4aa2c6e73b6 100644 --- a/apps/web/lib/video-processing.ts +++ b/apps/web/lib/video-processing.ts @@ -93,7 +93,6 @@ export async function startVideoProcessingWorkflow({ rawFileKey, bucketId, processingMessage, - startFailureMessage, mode, forceRestart, }: { @@ -127,7 +126,6 @@ export async function startVideoProcessingWorkflow({ bucketId: bucketId as S3Bucket.S3BucketId | null, }, ]); - return "started"; } catch (error) { const normalizedError = error instanceof Error @@ -135,9 +133,11 @@ export async function startVideoProcessingWorkflow({ : new Error("Video processing could not start"); await setVideoProcessingError( videoId, - startFailureMessage, + "Video processing failed to start", normalizedError, ); throw normalizedError; } + + return "started"; } diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index ec57ff223e0..fc1478ab21d 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") || @@ -49,7 +50,14 @@ export async function proxy(request: NextRequest) { path.startsWith("/invite") || path.startsWith("/self-hosting") || path.startsWith("/terms") || - path.startsWith("/verify-otp") + path.startsWith("/privacy") || + path.startsWith("/verify-otp") || + path.startsWith("/admin") || + path.startsWith("/dev") || + path.startsWith("/messenger") || + path.startsWith("/docs") || + path.startsWith("/.well-known/") || + /\.[a-z0-9]+$/i.test(path) ) && process.env.NODE_ENV !== "development" ) 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", 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", 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; }), );