diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts
index 4c4b3a748c..2e5c66cc2f 100644
--- a/apps/web/actions/video/upload.ts
+++ b/apps/web/actions/video/upload.ts
@@ -11,12 +11,20 @@ import { s3Buckets, videos, videoUploads } from "@cap/database/schema";
import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
import { dub, userIsPro } from "@cap/utils";
import { AwsCredentials, S3Buckets } from "@cap/web-backend";
-import { type Folder, type Organisation, Video } from "@cap/web-domain";
+import {
+ type Folder,
+ type Organisation,
+ S3Bucket,
+ Video,
+} from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { Effect, Option } from "effect";
import { revalidatePath } from "next/cache";
import { runPromise } from "@/lib/server";
+const MAX_S3_DELETE_ATTEMPTS = 3;
+const S3_DELETE_RETRY_BACKOFF_MS = 250;
+
async function getVideoUploadPresignedUrl({
fileKey,
duration,
@@ -203,7 +211,7 @@ export async function createVideoAndGetUploadUrl({
} - ${formattedDate}`,
ownerId: user.id,
orgId,
- source: { type: "desktopMP4" as const },
+ source: { type: "webMP4" as const },
isScreenshot,
bucket: customBucket?.id,
public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
@@ -255,3 +263,131 @@ export async function createVideoAndGetUploadUrl({
);
}
}
+
+export async function deleteVideoResultFile({
+ videoId,
+}: {
+ videoId: Video.VideoId;
+}) {
+ const user = await getCurrentUser();
+
+ if (!user) throw new Error("Unauthorized");
+
+ const [video] = await db()
+ .select({
+ id: videos.id,
+ ownerId: videos.ownerId,
+ bucketId: videos.bucket,
+ })
+ .from(videos)
+ .where(eq(videos.id, videoId));
+
+ if (!video) throw new Error("Video not found");
+ if (video.ownerId !== user.id) throw new Error("Forbidden");
+
+ const bucketIdOption = Option.fromNullable(video.bucketId).pipe(
+ Option.map((id) => S3Bucket.S3BucketId.make(id)),
+ );
+ const fileKey = `${video.ownerId}/${video.id}/result.mp4`;
+ const logContext = {
+ videoId: video.id,
+ ownerId: video.ownerId,
+ bucketId: video.bucketId ?? null,
+ fileKey,
+ };
+
+ try {
+ await db().transaction(async (tx) => {
+ await tx.delete(videoUploads).where(eq(videoUploads.videoId, videoId));
+ });
+ } catch (error) {
+ console.error("video.result.delete.transaction_failure", {
+ ...logContext,
+ error: serializeError(error),
+ });
+ throw error;
+ }
+
+ try {
+ await deleteResultObjectWithRetry({
+ bucketIdOption,
+ fileKey,
+ logContext,
+ });
+ } catch (error) {
+ console.error("video.result.delete.s3_failure", {
+ ...logContext,
+ error: serializeError(error),
+ });
+ throw error;
+ }
+
+ revalidatePath(`/s/${videoId}`);
+ revalidatePath("/dashboard/caps");
+ revalidatePath("/dashboard/folder");
+ revalidatePath("/dashboard/spaces");
+
+ return { success: true };
+}
+
+async function deleteResultObjectWithRetry({
+ bucketIdOption,
+ fileKey,
+ logContext,
+}: {
+ bucketIdOption: Option.Option;
+ fileKey: string;
+ logContext: {
+ videoId: Video.VideoId;
+ ownerId: string;
+ bucketId: string | null;
+ fileKey: string;
+ };
+}) {
+ let attempt = 0;
+ let lastError: unknown;
+ while (attempt < MAX_S3_DELETE_ATTEMPTS) {
+ attempt += 1;
+ try {
+ await Effect.gen(function* () {
+ const [bucket] = yield* S3Buckets.getBucketAccess(bucketIdOption);
+ yield* bucket.deleteObject(fileKey);
+ }).pipe(runPromise);
+ return;
+ } catch (error) {
+ lastError = error;
+ console.error("video.result.delete.s3_failure", {
+ ...logContext,
+ attempt,
+ maxAttempts: MAX_S3_DELETE_ATTEMPTS,
+ error: serializeError(error),
+ });
+
+ if (attempt < MAX_S3_DELETE_ATTEMPTS) {
+ await sleep(S3_DELETE_RETRY_BACKOFF_MS * attempt);
+ }
+ }
+ }
+
+ throw lastError instanceof Error
+ ? lastError
+ : new Error("Failed to delete video result from S3");
+}
+
+function serializeError(error: unknown) {
+ if (error instanceof Error) {
+ return {
+ name: error.name,
+ message: error.message,
+ stack: error.stack,
+ };
+ }
+
+ return { name: "UnknownError", message: String(error) };
+}
+
+function sleep(durationMs: number) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, durationMs);
+ });
+}
diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx
index 6d27c4b0da..2974ec8e72 100644
--- a/apps/web/app/(org)/dashboard/caps/Caps.tsx
+++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx
@@ -17,6 +17,7 @@ import {
SelectedCapsBar,
UploadCapButton,
UploadPlaceholderCard,
+ WebRecorderDialog,
} from "./components";
import { CapCard } from "./components/CapCard/CapCard";
import { CapPagination } from "./components/CapPagination";
@@ -240,6 +241,7 @@ export const Caps = ({
New Folder
+
{folders.length > 0 && (
<>
diff --git a/apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx b/apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx
index 4d17339ad9..5911c8b6bb 100644
--- a/apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx
+++ b/apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRive } from "@rive-app/react-canvas";
import { useTheme } from "../../Contexts";
import { UploadCapButton } from "./UploadCapButton";
+import { WebRecorderDialog } from "./web-recorder-dialog/web-recorder-dialog";
interface EmptyCapStateProps {
userName?: string;
@@ -30,7 +31,7 @@ export const EmptyCapState: React.FC = ({ userName }) => {
Craft your narrative with Cap - get projects done quicker.
-
+
or
+
+
or
diff --git a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx
index 885642e06c..7be95c89de 100644
--- a/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx
+++ b/apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx
@@ -18,6 +18,7 @@ import {
} from "@/app/(org)/dashboard/caps/UploadingContext";
import { UpgradeModal } from "@/components/UpgradeModal";
import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest";
+import { sendProgressUpdate } from "./sendProgressUpdate";
export const UploadCapButton = ({
size = "md",
@@ -517,29 +518,3 @@ async function legacyUploadCap(
setUploadStatus(undefined);
return false;
}
-
-const sendProgressUpdate = async (
- videoId: string,
- uploaded: number,
- total: number,
-) => {
- try {
- const response = await fetch("/api/desktop/video/progress", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- videoId,
- uploaded,
- total,
- updatedAt: new Date().toISOString(),
- }),
- });
-
- if (!response.ok)
- console.error("Failed to send progress update:", response.status);
- } catch (err) {
- console.error("Error sending progress update:", err);
- }
-};
diff --git a/apps/web/app/(org)/dashboard/caps/components/index.ts b/apps/web/app/(org)/dashboard/caps/components/index.ts
index ced28c433b..1e068a7375 100644
--- a/apps/web/app/(org)/dashboard/caps/components/index.ts
+++ b/apps/web/app/(org)/dashboard/caps/components/index.ts
@@ -5,3 +5,4 @@ export * from "./NewFolderDialog";
export * from "./SelectedCapsBar";
export * from "./UploadCapButton";
export * from "./UploadPlaceholderCard";
+export * from "./web-recorder-dialog/web-recorder-dialog";
diff --git a/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts b/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts
new file mode 100644
index 0000000000..01651c0b66
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts
@@ -0,0 +1,24 @@
+import { EffectRuntime } from "@/lib/EffectRuntime";
+import { withRpc } from "@/lib/Rpcs";
+import type { VideoId } from "./web-recorder-dialog/web-recorder-types";
+
+export const sendProgressUpdate = async (
+ videoId: VideoId,
+ uploaded: number,
+ total: number,
+) => {
+ try {
+ await EffectRuntime.runPromise(
+ withRpc((rpc) =>
+ rpc.VideoUploadProgressUpdate({
+ videoId,
+ uploaded,
+ total,
+ updatedAt: new Date(),
+ }),
+ ),
+ );
+ } catch (error) {
+ console.error("Failed to send progress update:", error);
+ }
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/CameraPreviewWindow.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/CameraPreviewWindow.tsx
new file mode 100644
index 0000000000..85ce0c5eab
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/CameraPreviewWindow.tsx
@@ -0,0 +1,631 @@
+"use client";
+
+import { LoadingSpinner } from "@cap/ui";
+import clsx from "clsx";
+import {
+ Circle,
+ FlipHorizontal,
+ Maximize2,
+ PictureInPicture,
+ RectangleHorizontal,
+ Square,
+ X,
+} from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+type CameraPreviewSize = "sm" | "lg";
+type CameraPreviewShape = "round" | "square" | "full";
+type VideoDimensions = {
+ width: number;
+ height: number;
+};
+type AutoPictureInPictureDocument = Document & {
+ autoPictureInPictureEnabled?: boolean;
+};
+type AutoPictureInPictureVideo = HTMLVideoElement & {
+ autoPictureInPicture?: boolean;
+};
+
+const WINDOW_PADDING = 20;
+const BAR_HEIGHT = 52;
+
+const getPreviewMetrics = (
+ previewSize: CameraPreviewSize,
+ previewShape: CameraPreviewShape,
+ dimensions: VideoDimensions | null,
+) => {
+ const base = previewSize === "sm" ? 230 : 400;
+
+ if (!dimensions || dimensions.height === 0) {
+ return {
+ base,
+ width: base,
+ height: base,
+ aspectRatio: 1,
+ };
+ }
+
+ const aspectRatio = dimensions.width / dimensions.height;
+
+ if (previewShape !== "full") {
+ return {
+ base,
+ width: base,
+ height: base,
+ aspectRatio,
+ };
+ }
+
+ if (aspectRatio >= 1) {
+ return {
+ base,
+ width: base * aspectRatio,
+ height: base,
+ aspectRatio,
+ };
+ }
+
+ return {
+ base,
+ width: base,
+ height: base / aspectRatio,
+ aspectRatio,
+ };
+};
+
+interface CameraPreviewWindowProps {
+ cameraId: string;
+ onClose: () => void;
+}
+
+export const CameraPreviewWindow = ({
+ cameraId,
+ onClose,
+}: CameraPreviewWindowProps) => {
+ const [size, setSize] = useState("sm");
+ const [shape, setShape] = useState("round");
+ const [mirrored, setMirrored] = useState(false);
+ const [position, setPosition] = useState<{ x: number; y: number } | null>(
+ null,
+ );
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
+ const videoRef = useRef(null);
+ const streamRef = useRef(null);
+ const containerRef = useRef(null);
+ const [videoDimensions, setVideoDimensions] =
+ useState(null);
+ const [mounted, setMounted] = useState(false);
+ const [isInPictureInPicture, setIsInPictureInPicture] = useState(false);
+ const autoPictureInPictureRef = useRef(false);
+ const isPictureInPictureSupported =
+ typeof document !== "undefined" && document.pictureInPictureEnabled;
+ const canUseAutoPiPAttribute = useMemo(() => {
+ if (
+ typeof document === "undefined" ||
+ typeof HTMLVideoElement === "undefined"
+ ) {
+ return false;
+ }
+
+ const doc = document as AutoPictureInPictureDocument;
+ const autoPiPAllowed =
+ typeof doc.autoPictureInPictureEnabled === "boolean"
+ ? doc.autoPictureInPictureEnabled
+ : true;
+
+ if (!doc.pictureInPictureEnabled || !autoPiPAllowed) {
+ return false;
+ }
+
+ const proto = HTMLVideoElement.prototype as unknown as {
+ autoPictureInPicture?: boolean;
+ };
+
+ return "autoPictureInPicture" in proto;
+ }, []);
+
+ useEffect(() => {
+ if (!canUseAutoPiPAttribute) {
+ return;
+ }
+
+ let rafId: number | null = null;
+ let pipVideo: AutoPictureInPictureVideo | null = null;
+ let disposed = false;
+
+ const attachAttribute = () => {
+ if (disposed) return;
+
+ const maybeVideo = videoRef.current as AutoPictureInPictureVideo | null;
+ if (!maybeVideo) {
+ rafId = requestAnimationFrame(attachAttribute);
+ return;
+ }
+
+ pipVideo = maybeVideo;
+ pipVideo.autoPictureInPicture = true;
+ };
+
+ attachAttribute();
+
+ return () => {
+ disposed = true;
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ }
+ if (pipVideo) {
+ pipVideo.autoPictureInPicture = false;
+ }
+ };
+ }, [canUseAutoPiPAttribute]);
+
+ useEffect(() => {
+ setMounted(true);
+ return () => {
+ setMounted(false);
+ };
+ }, []);
+
+ useEffect(() => {
+ const startCamera = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: {
+ deviceId: { exact: cameraId },
+ },
+ });
+
+ streamRef.current = stream;
+
+ if (videoRef.current) {
+ videoRef.current.srcObject = stream;
+ }
+ } catch (err) {
+ console.error("Failed to start camera", err);
+ }
+ };
+
+ startCamera();
+
+ return () => {
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach((track) => {
+ track.stop();
+ });
+ streamRef.current = null;
+ }
+ };
+ }, [cameraId]);
+
+ useEffect(() => {
+ const metrics = getPreviewMetrics(size, shape, videoDimensions);
+
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const totalHeight = metrics.height + BAR_HEIGHT;
+ const maxX = Math.max(0, window.innerWidth - metrics.width);
+ const maxY = Math.max(0, window.innerHeight - totalHeight);
+
+ setPosition((prev) => {
+ const defaultX = window.innerWidth - metrics.width - WINDOW_PADDING;
+ const defaultY = window.innerHeight - totalHeight - WINDOW_PADDING;
+ const nextX = prev?.x ?? defaultX;
+ const nextY = prev?.y ?? defaultY;
+
+ return {
+ x: Math.max(0, Math.min(nextX, maxX)),
+ y: Math.max(0, Math.min(nextY, maxY)),
+ };
+ });
+ }, [size, shape, videoDimensions]);
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ if ((e.target as HTMLElement).closest("[data-controls]")) {
+ return;
+ }
+ e.stopPropagation();
+ e.preventDefault();
+ setIsDragging(true);
+ setDragStart({
+ x: e.clientX - (position?.x || 0),
+ y: e.clientY - (position?.y || 0),
+ });
+ },
+ [position],
+ );
+
+ const handleMouseMove = useCallback(
+ (e: MouseEvent) => {
+ if (!isDragging) return;
+
+ const newX = e.clientX - dragStart.x;
+ const newY = e.clientY - dragStart.y;
+
+ const metrics = getPreviewMetrics(size, shape, videoDimensions);
+ const totalHeight = metrics.height + BAR_HEIGHT;
+ const maxX = Math.max(0, window.innerWidth - metrics.width);
+ const maxY = Math.max(0, window.innerHeight - totalHeight);
+
+ setPosition({
+ x: Math.max(0, Math.min(newX, maxX)),
+ y: Math.max(0, Math.min(newY, maxY)),
+ });
+ },
+ [isDragging, dragStart, size, shape, videoDimensions],
+ );
+
+ const handleMouseUp = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ useEffect(() => {
+ if (isDragging) {
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }
+ }, [isDragging, handleMouseMove, handleMouseUp]);
+
+ const handleClose = useCallback(async () => {
+ if (
+ videoRef.current &&
+ document.pictureInPictureElement === videoRef.current
+ ) {
+ try {
+ autoPictureInPictureRef.current = false;
+ await document.exitPictureInPicture();
+ } catch (err) {
+ console.error("Failed to exit Picture-in-Picture", err);
+ }
+ }
+ onClose();
+ }, [onClose]);
+
+ const handleTogglePictureInPicture = useCallback(async () => {
+ const video = videoRef.current;
+ if (!video || !isPictureInPictureSupported) return;
+
+ try {
+ autoPictureInPictureRef.current = false;
+ if (document.pictureInPictureElement === video) {
+ await document.exitPictureInPicture();
+ } else {
+ await video.requestPictureInPicture();
+ }
+ } catch (err) {
+ console.error("Failed to toggle Picture-in-Picture", err);
+ }
+ }, [isPictureInPictureSupported]);
+
+ useEffect(() => {
+ if (!videoRef.current || !videoDimensions || !isPictureInPictureSupported)
+ return;
+
+ const video = videoRef.current;
+
+ const handlePipEnter = () => {
+ setIsInPictureInPicture(true);
+ };
+
+ const handlePipLeave = () => {
+ autoPictureInPictureRef.current = false;
+ setIsInPictureInPicture(false);
+ };
+
+ video.addEventListener("enterpictureinpicture", handlePipEnter);
+ video.addEventListener("leavepictureinpicture", handlePipLeave);
+
+ if (document.pictureInPictureElement === video) {
+ setIsInPictureInPicture(true);
+ }
+
+ return () => {
+ video.removeEventListener("enterpictureinpicture", handlePipEnter);
+ video.removeEventListener("leavepictureinpicture", handlePipLeave);
+ };
+ }, [videoDimensions, isPictureInPictureSupported]);
+
+ useEffect(() => {
+ if (typeof document === "undefined") {
+ return;
+ }
+
+ if (!isPictureInPictureSupported || canUseAutoPiPAttribute) {
+ return;
+ }
+
+ const handleVisibilityChange = () => {
+ const video = videoRef.current;
+
+ if (!video || !videoDimensions) {
+ return;
+ }
+
+ const currentElement = document.pictureInPictureElement;
+ const hasActiveUserGesture =
+ typeof navigator !== "undefined" && navigator.userActivation?.isActive;
+
+ if (
+ currentElement &&
+ currentElement !== video &&
+ document.visibilityState === "hidden"
+ ) {
+ return;
+ }
+
+ if (document.visibilityState === "hidden") {
+ if (currentElement === video) {
+ return;
+ }
+
+ if (!hasActiveUserGesture) {
+ // Browsers reject PiP requests without a direct user gesture, so skip instead of spamming errors.
+ return;
+ }
+
+ video
+ .requestPictureInPicture()
+ .then(() => {
+ autoPictureInPictureRef.current = true;
+ })
+ .catch((err) => {
+ autoPictureInPictureRef.current = false;
+ console.error(
+ "Failed to enter Picture-in-Picture on tab change",
+ err,
+ );
+ });
+
+ return;
+ }
+
+ if (
+ autoPictureInPictureRef.current &&
+ currentElement === video &&
+ document.visibilityState === "visible"
+ ) {
+ document
+ .exitPictureInPicture()
+ .catch((err) => {
+ console.error(
+ "Failed to exit Picture-in-Picture after returning",
+ err,
+ );
+ })
+ .finally(() => {
+ autoPictureInPictureRef.current = false;
+ });
+ return;
+ }
+
+ autoPictureInPictureRef.current = false;
+ };
+
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ };
+ }, [videoDimensions, isPictureInPictureSupported, canUseAutoPiPAttribute]);
+
+ useEffect(() => {
+ return () => {
+ if (
+ typeof document !== "undefined" &&
+ videoRef.current &&
+ document.pictureInPictureElement === videoRef.current
+ ) {
+ document.exitPictureInPicture().catch((err) => {
+ console.error("Failed to exit Picture-in-Picture on cleanup", err);
+ });
+ }
+ };
+ }, []);
+
+ if (!mounted || !position) {
+ return null;
+ }
+
+ const metrics = getPreviewMetrics(size, shape, videoDimensions);
+ const totalHeight = metrics.height + BAR_HEIGHT;
+ const videoStyle = videoDimensions
+ ? {
+ transform: mirrored ? "scaleX(-1)" : "scaleX(1)",
+ opacity: isInPictureInPicture ? 0 : 1,
+ }
+ : { opacity: 0 };
+ // Keep the video node rendered even in PiP mode so the track keeps producing frames.
+
+ const borderRadius =
+ shape === "round" ? "9999px" : size === "sm" ? "3rem" : "4rem";
+
+ return createPortal(
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ handleMouseDown(e);
+ }}
+ >
+
+
+
+
e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ e.stopPropagation();
+ handleClose();
+ }
+ }}
+ >
+
+
+
+
+ {isPictureInPictureSupported && (
+
+ )}
+
+
+
+
+
+
+
+
,
+ document.body,
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/CameraSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/CameraSelector.tsx
new file mode 100644
index 0000000000..1ae4d3dc94
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/CameraSelector.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import {
+ SelectContent,
+ SelectItem,
+ SelectRoot,
+ SelectTrigger,
+ SelectValue,
+} from "@cap/ui";
+import clsx from "clsx";
+import { CameraIcon, CameraOffIcon } from "lucide-react";
+import type { KeyboardEvent, MouseEvent } from "react";
+import { toast } from "sonner";
+import { useMediaPermission } from "./useMediaPermission";
+import { NO_CAMERA, NO_CAMERA_VALUE } from "./web-recorder-constants";
+
+interface CameraSelectorProps {
+ selectedCameraId: string | null;
+ availableCameras: MediaDeviceInfo[];
+ dialogOpen: boolean;
+ disabled?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onCameraChange: (cameraId: string | null) => void;
+ onRefreshDevices: () => Promise | void;
+}
+
+export const CameraSelector = ({
+ selectedCameraId,
+ availableCameras,
+ dialogOpen,
+ disabled = false,
+ open,
+ onOpenChange,
+ onCameraChange,
+ onRefreshDevices,
+}: CameraSelectorProps) => {
+ const cameraEnabled = selectedCameraId !== null;
+ const { state: permissionState, requestPermission } = useMediaPermission(
+ "camera",
+ dialogOpen,
+ );
+
+ const permissionSupported = permissionState !== "unsupported";
+ const shouldRequestPermission =
+ permissionSupported && permissionState !== "granted";
+
+ const statusPillDisabled = !shouldRequestPermission && !cameraEnabled;
+
+ const statusPillClassName = clsx(
+ "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]",
+ statusPillDisabled ? "cursor-default" : "cursor-pointer",
+ shouldRequestPermission
+ ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]"
+ : cameraEnabled
+ ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)] hover:bg-[var(--blue-4)] dark:hover:bg-[var(--blue-5)]"
+ : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]",
+ );
+
+ const handleStatusPillClick = async (
+ event: MouseEvent | KeyboardEvent,
+ ) => {
+ if (shouldRequestPermission) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ try {
+ const granted = await requestPermission();
+ if (granted) {
+ await Promise.resolve(onRefreshDevices());
+ }
+ } catch (error) {
+ console.error("Camera permission request failed", error);
+ toast.error("Unable to access your camera. Check browser permissions.");
+ }
+
+ return;
+ }
+
+ if (!cameraEnabled) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ onCameraChange(null);
+ };
+
+ const handleStatusPillKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Enter" || event.key === " ") {
+ handleStatusPillClick(event);
+ }
+ };
+
+ return (
+
+
{
+ onCameraChange(value === NO_CAMERA_VALUE ? null : value);
+ }}
+ disabled={disabled}
+ open={open}
+ onOpenChange={onOpenChange}
+ >
+
+ svg]:hidden",
+ disabled || shouldRequestPermission
+ ? "cursor-default"
+ : undefined,
+ )}
+ onPointerDown={(event) => {
+ if (shouldRequestPermission) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }}
+ onKeyDown={(event: KeyboardEvent) => {
+ if (shouldRequestPermission) {
+ const keys = ["Enter", " ", "ArrowDown", "ArrowUp"];
+ if (keys.includes(event.key)) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ }}
+ aria-disabled={disabled || shouldRequestPermission}
+ >
+
+
+
+
+
+
+
+
+ {NO_CAMERA}
+
+
+ {availableCameras.map((camera, index) => (
+
+
+
+ {camera.label?.trim() || `Camera ${index + 1}`}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/HowItWorksButton.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/HowItWorksButton.tsx
new file mode 100644
index 0000000000..24784370ed
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/HowItWorksButton.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { CircleHelpIcon } from "lucide-react";
+
+interface HowItWorksButtonProps {
+ onClick: () => void;
+}
+
+export const HowItWorksButton = ({ onClick }: HowItWorksButtonProps) => {
+ return (
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/HowItWorksPanel.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/HowItWorksPanel.tsx
new file mode 100644
index 0000000000..383d249fae
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/HowItWorksPanel.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import type { LucideIcon } from "lucide-react";
+import {
+ ArrowLeftIcon,
+ CloudUploadIcon,
+ LinkIcon,
+ PictureInPictureIcon,
+} from "lucide-react";
+
+const HOW_IT_WORKS_ITEMS = [
+ {
+ title: "Uploads while you record",
+ description:
+ "On compatible browsers, your capture uploads in the background while you record. Otherwise, it records first and uploads right after you stop.",
+ Icon: CloudUploadIcon,
+ accent: "bg-blue-3 text-blue-11 dark:bg-blue-4 dark:text-blue-10",
+ },
+ {
+ title: "Instant shareable link",
+ description:
+ "Stopping the recording finalizes the upload immediately so you can copy your link right away.",
+ Icon: LinkIcon,
+ accent: "bg-green-3 text-green-11 dark:bg-green-4 dark:text-green-10",
+ },
+ {
+ title: "Keep your webcam visible",
+ description:
+ "On compatible browsers, selecting a camera opens a picture‑in‑picture window that's captured when you record fullscreen. We recommend fullscreen to keep it on top. If PiP capture isn't supported, your camera is limited to the Cap recorder page.",
+ Icon: PictureInPictureIcon,
+ accent: "bg-purple-3 text-purple-11 dark:bg-purple-4 dark:text-purple-10",
+ },
+] as const satisfies Array<{
+ title: string;
+ description: string;
+ Icon: LucideIcon;
+ accent: string;
+}>;
+
+interface HowItWorksPanelProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const HowItWorksPanel = ({ open, onClose }: HowItWorksPanelProps) => {
+ return (
+
+ {open && (
+
+
+
+
+ How it works
+
+
+
+
+
+ {HOW_IT_WORKS_ITEMS.map(
+ ({ title, description, Icon, accent }) => (
+
+
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ ),
+ )}
+
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/InProgressRecordingBar.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/InProgressRecordingBar.tsx
new file mode 100644
index 0000000000..e0605029cc
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/InProgressRecordingBar.tsx
@@ -0,0 +1,576 @@
+"use client";
+import clsx from "clsx";
+import {
+ Mic,
+ MicOff,
+ MoreVertical,
+ PauseCircle,
+ PlayCircle,
+ RotateCcw,
+ StopCircle,
+} from "lucide-react";
+import {
+ type ComponentProps,
+ type MouseEvent as ReactMouseEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import { createPortal } from "react-dom";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import type { ChunkUploadState, RecorderPhase } from "./web-recorder-types";
+
+const phaseMessages: Partial> = {
+ recording: "Recording",
+ paused: "Paused",
+ creating: "Finishing up",
+ converting: "Converting",
+ uploading: "Uploading",
+};
+
+const clamp = (value: number, min: number, max: number) => {
+ if (Number.isNaN(value)) return min;
+ if (max < min) return min;
+ return Math.min(Math.max(value, min), max);
+};
+
+const formatDuration = (durationMs: number) => {
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+};
+
+interface InProgressRecordingBarProps {
+ phase: RecorderPhase;
+ durationMs: number;
+ hasAudioTrack: boolean;
+ chunkUploads: ChunkUploadState[];
+ onStop: () => void | Promise;
+ onPause?: () => void | Promise;
+ onResume?: () => void | Promise;
+ onRestart?: () => void | Promise;
+ isRestarting?: boolean;
+}
+
+const DRAG_PADDING = 12;
+
+export const InProgressRecordingBar = ({
+ phase,
+ durationMs,
+ hasAudioTrack,
+ chunkUploads,
+ onStop,
+ onPause,
+ onResume,
+ onRestart,
+ isRestarting = false,
+}: InProgressRecordingBarProps) => {
+ const [mounted, setMounted] = useState(false);
+ const [position, setPosition] = useState({ x: 0, y: 24 });
+ const [isDragging, setIsDragging] = useState(false);
+ const dragOffsetRef = useRef({ x: 0, y: 0 });
+ const containerRef = useRef(null);
+ const initializedPositionRef = useRef(false);
+
+ useEffect(() => {
+ setMounted(true);
+ return () => setMounted(false);
+ }, []);
+
+ useEffect(() => {
+ if (!mounted || initializedPositionRef.current) return;
+ if (typeof window === "undefined") return;
+
+ const raf = window.requestAnimationFrame(() => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return;
+
+ const maxX = window.innerWidth - rect.width - DRAG_PADDING;
+ initializedPositionRef.current = true;
+ setPosition({
+ x: clamp((window.innerWidth - rect.width) / 2, DRAG_PADDING, maxX),
+ y: DRAG_PADDING * 2,
+ });
+ });
+
+ return () => {
+ if (typeof window !== "undefined") {
+ window.cancelAnimationFrame(raf);
+ }
+ };
+ }, [mounted]);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const handleResize = () => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return;
+
+ setPosition((prev) => {
+ const maxX = window.innerWidth - rect.width - DRAG_PADDING;
+ const maxY = window.innerHeight - rect.height - DRAG_PADDING;
+ return {
+ x: clamp(prev.x, DRAG_PADDING, maxX),
+ y: clamp(prev.y, DRAG_PADDING, maxY),
+ };
+ });
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ const handlePointerDown = useCallback(
+ (event: ReactMouseEvent) => {
+ if (
+ (event.target as HTMLElement)?.closest("[data-no-drag]") ||
+ event.button !== 0
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ setIsDragging(true);
+ dragOffsetRef.current = {
+ x: event.clientX - position.x,
+ y: event.clientY - position.y,
+ };
+ },
+ [position],
+ );
+
+ useEffect(() => {
+ if (!isDragging || typeof window === "undefined") {
+ return undefined;
+ }
+
+ const handleMouseMove = (event: MouseEvent) => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ const width = rect?.width ?? 360;
+ const height = rect?.height ?? 64;
+ const maxX = window.innerWidth - width - DRAG_PADDING;
+ const maxY = window.innerHeight - height - DRAG_PADDING;
+
+ setPosition({
+ x: clamp(event.clientX - dragOffsetRef.current.x, DRAG_PADDING, maxX),
+ y: clamp(event.clientY - dragOffsetRef.current.y, DRAG_PADDING, maxY),
+ });
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isDragging]);
+
+ if (!mounted || typeof document === "undefined") {
+ return null;
+ }
+
+ const isPaused = phase === "paused";
+ const canStop = phase === "recording" || isPaused;
+ const showTimer = phase === "recording" || isPaused;
+ const statusText = showTimer
+ ? formatDuration(durationMs)
+ : (phaseMessages[phase] ?? "Processing");
+
+ const handleStop = () => {
+ try {
+ const result = onStop();
+ Promise.resolve(result).catch((error) => {
+ console.error("Failed to stop recording", error);
+ });
+ } catch (error) {
+ console.error("Failed to stop recording", error);
+ }
+ };
+
+ const handlePauseToggle = () => {
+ if (isPaused) {
+ if (!onResume) return;
+ try {
+ const result = onResume();
+ Promise.resolve(result).catch((error) => {
+ console.error("Failed to resume recording", error);
+ });
+ } catch (error) {
+ console.error("Failed to resume recording", error);
+ }
+ return;
+ }
+
+ if (phase === "recording" && onPause) {
+ try {
+ const result = onPause();
+ Promise.resolve(result).catch((error) => {
+ console.error("Failed to pause recording", error);
+ });
+ } catch (error) {
+ console.error("Failed to pause recording", error);
+ }
+ }
+ };
+
+ const canTogglePause =
+ (phase === "recording" && Boolean(onPause)) ||
+ (isPaused && Boolean(onResume));
+ const canRestart =
+ Boolean(onRestart) && !isRestarting && (phase === "recording" || isPaused);
+
+ const handleRestart = () => {
+ if (!onRestart || !canRestart) return;
+ try {
+ const result = onRestart();
+ if (result instanceof Promise) {
+ void result.catch(() => {
+ /* ignore */
+ });
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ return createPortal(
+
+
+
+
+
+
+
+
+ {hasAudioTrack ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ {isPaused ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
,
+ document.body,
+ );
+};
+
+const ActionButton = ({ className, ...props }: ComponentProps<"button">) => (
+
+);
+
+const InlineChunkProgress = ({
+ chunkUploads,
+}: {
+ chunkUploads: ChunkUploadState[];
+}) => {
+ if (chunkUploads.length === 0) return null;
+
+ const completedCount = chunkUploads.filter(
+ (chunk) => chunk.status === "complete",
+ ).length;
+ const failed = chunkUploads.some((chunk) => chunk.status === "error");
+ const uploadingCount = chunkUploads.filter(
+ (chunk) => chunk.status === "uploading",
+ ).length;
+ const queuedCount = chunkUploads.filter(
+ (chunk) => chunk.status === "queued",
+ ).length;
+ const totalBytes = chunkUploads.reduce(
+ (total, chunk) => total + chunk.sizeBytes,
+ 0,
+ );
+ const uploadedBytes = chunkUploads.reduce(
+ (total, chunk) => total + chunk.uploadedBytes,
+ 0,
+ );
+ const progressRatio = Math.max(
+ 0,
+ Math.min(
+ 1,
+ totalBytes > 0
+ ? uploadedBytes / totalBytes
+ : completedCount / chunkUploads.length,
+ ),
+ );
+ const radius = 15.9155;
+ const circumference = 2 * Math.PI * radius;
+ const strokeDashoffset = circumference * (1 - progressRatio);
+ const colorClass = failed
+ ? "text-red-9"
+ : completedCount === chunkUploads.length
+ ? "text-green-9"
+ : "text-blue-9";
+
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const hoverTimeoutRef = useRef | null>(null);
+
+ const clearHoverTimeout = useCallback(() => {
+ if (!hoverTimeoutRef.current) return;
+ clearTimeout(hoverTimeoutRef.current);
+ hoverTimeoutRef.current = null;
+ }, []);
+
+ const openPopover = useCallback(() => {
+ clearHoverTimeout();
+ setIsPopoverOpen(true);
+ }, [clearHoverTimeout]);
+
+ const closePopover = useCallback(() => {
+ clearHoverTimeout();
+ hoverTimeoutRef.current = setTimeout(() => {
+ setIsPopoverOpen(false);
+ }, 180);
+ }, [clearHoverTimeout]);
+
+ useEffect(() => () => clearHoverTimeout(), [clearHoverTimeout]);
+
+ const statusSummary = [
+ { label: "Uploading", count: uploadingCount, color: "text-blue-11" },
+ { label: "Pending", count: queuedCount, color: "text-amber-11" },
+ { label: "Completed", count: completedCount, color: "text-green-11" },
+ {
+ label: "Failed",
+ count: chunkUploads.filter((chunk) => chunk.status === "error").length,
+ color: "text-red-11",
+ },
+ ].filter((item) => item.count > 0);
+
+ const statusLabels: Record = {
+ uploading: "Uploading",
+ queued: "Pending",
+ complete: "Completed",
+ error: "Failed",
+ };
+
+ const statusAccent: Record = {
+ uploading: "text-blue-11",
+ queued: "text-amber-11",
+ complete: "text-green-11",
+ error: "text-red-11",
+ };
+
+ return (
+ {
+ if (!next) {
+ clearHoverTimeout();
+ }
+ setIsPopoverOpen(next);
+ }}
+ >
+
+
+
+
+
+
+ Uploaded {formatBytes(uploadedBytes)} of {formatBytes(totalBytes)}
+
+
+ {statusSummary.length === 0 ? (
+
+ Preparing chunks…
+
+ ) : (
+ statusSummary.map((item) => (
+
+ {item.label}: {item.count}
+
+ ))
+ )}
+
+
+ {chunkUploads.map((chunk) => (
+
+
+
+ Part {chunk.partNumber}
+
+
+ {statusLabels[chunk.status]}
+
+
+
+ {chunk.status === "uploading"
+ ? `${Math.round(chunk.progress * 100)}% of ${formatBytes(chunk.sizeBytes)}`
+ : chunk.status === "complete"
+ ? `Uploaded ${formatBytes(chunk.sizeBytes)}`
+ : chunk.status === "queued"
+ ? `Waiting • ${formatBytes(chunk.sizeBytes)}`
+ : `Needs attention • ${formatBytes(chunk.sizeBytes)}`}
+
+
+ ))}
+
+
+
+
+ );
+};
+
+const formatBytes = (bytes: number) => {
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ const exponent = Math.min(
+ units.length - 1,
+ Math.floor(Math.log(bytes) / Math.log(1024)),
+ );
+ const value = bytes / 1024 ** exponent;
+ const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2;
+ return `${value.toFixed(decimals)} ${units[exponent]}`;
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/MicrophoneSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/MicrophoneSelector.tsx
new file mode 100644
index 0000000000..96a49611ab
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/MicrophoneSelector.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import {
+ SelectContent,
+ SelectItem,
+ SelectRoot,
+ SelectTrigger,
+ SelectValue,
+} from "@cap/ui";
+import clsx from "clsx";
+import { MicIcon, MicOffIcon } from "lucide-react";
+import type { KeyboardEvent, MouseEvent } from "react";
+import { toast } from "sonner";
+import { useMediaPermission } from "./useMediaPermission";
+import { NO_MICROPHONE, NO_MICROPHONE_VALUE } from "./web-recorder-constants";
+
+interface MicrophoneSelectorProps {
+ selectedMicId: string | null;
+ availableMics: MediaDeviceInfo[];
+ dialogOpen: boolean;
+ disabled?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onMicChange: (micId: string | null) => void;
+ onRefreshDevices: () => Promise | void;
+}
+
+export const MicrophoneSelector = ({
+ selectedMicId,
+ availableMics,
+ dialogOpen,
+ disabled = false,
+ open,
+ onOpenChange,
+ onMicChange,
+ onRefreshDevices,
+}: MicrophoneSelectorProps) => {
+ const micEnabled = selectedMicId !== null;
+ const { state: permissionState, requestPermission } = useMediaPermission(
+ "microphone",
+ dialogOpen,
+ );
+
+ const permissionSupported = permissionState !== "unsupported";
+ const shouldRequestPermission =
+ permissionSupported && permissionState !== "granted";
+
+ const statusPillDisabled =
+ disabled || (!shouldRequestPermission && !micEnabled);
+
+ const statusPillClassName = clsx(
+ "px-[0.375rem] h-[1.25rem] min-w-[2.5rem] rounded-full text-[0.75rem] leading-[1.25rem] flex items-center justify-center font-normal transition-colors duration-200 disabled:opacity-100 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-[var(--blue-8)]",
+ statusPillDisabled ? "cursor-default" : "cursor-pointer",
+ shouldRequestPermission
+ ? "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]"
+ : micEnabled
+ ? "bg-[var(--blue-3)] text-[var(--blue-11)] dark:bg-[var(--blue-4)] dark:text-[var(--blue-12)] hover:bg-[var(--blue-4)] dark:hover:bg-[var(--blue-5)]"
+ : "bg-[var(--red-3)] text-[var(--red-11)] dark:bg-[var(--red-4)] dark:text-[var(--red-12)]",
+ );
+
+ const handleStatusPillClick = async (
+ event: MouseEvent | KeyboardEvent,
+ ) => {
+ if (disabled) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ if (shouldRequestPermission) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ try {
+ const granted = await requestPermission();
+ if (granted) {
+ await Promise.resolve(onRefreshDevices());
+ }
+ } catch (error) {
+ console.error("Microphone permission request failed", error);
+ toast.error(
+ "Unable to access your microphone. Check browser permissions.",
+ );
+ }
+
+ return;
+ }
+
+ if (!micEnabled) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ onMicChange(null);
+ };
+
+ const handleStatusPillKeyDown = (event: KeyboardEvent) => {
+ if (disabled) {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ handleStatusPillClick(event);
+ }
+ };
+
+ return (
+
+
{
+ onMicChange(value === NO_MICROPHONE_VALUE ? null : value);
+ }}
+ disabled={disabled}
+ open={open}
+ onOpenChange={onOpenChange}
+ >
+
+ svg]:hidden",
+ disabled || shouldRequestPermission
+ ? "cursor-default"
+ : undefined,
+ )}
+ onPointerDown={(event) => {
+ if (shouldRequestPermission) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }}
+ onKeyDown={(event: KeyboardEvent) => {
+ if (shouldRequestPermission) {
+ const keys = ["Enter", " ", "ArrowDown", "ArrowUp"];
+ if (keys.includes(event.key)) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ }}
+ aria-disabled={disabled || shouldRequestPermission}
+ >
+
+
+
+
+
+
+
+
+ {NO_MICROPHONE}
+
+
+ {availableMics.map((mic, index) => (
+
+
+
+ {mic.label?.trim() || `Microphone ${index + 1}`}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingButton.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingButton.tsx
new file mode 100644
index 0000000000..02420e4484
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingButton.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import type { SVGProps } from "react";
+
+interface RecordingButtonProps {
+ isRecording: boolean;
+ disabled?: boolean;
+ onStart: () => void;
+ onStop: () => void;
+}
+
+const InstantIcon = ({ className, ...props }: SVGProps) => (
+
+);
+
+export const RecordingButton = ({
+ isRecording,
+ disabled = false,
+ onStart,
+ onStop,
+}: RecordingButtonProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx
new file mode 100644
index 0000000000..acbf7f9844
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import {
+ SelectContent,
+ SelectItem,
+ SelectRoot,
+ SelectTrigger,
+ SelectValue,
+} from "@cap/ui";
+import {
+ CameraIcon,
+ Globe,
+ type LucideIcon,
+ MonitorIcon,
+ RectangleHorizontal,
+} from "lucide-react";
+
+export type RecordingMode = "fullscreen" | "window" | "tab" | "camera";
+
+interface RecordingModeSelectorProps {
+ mode: RecordingMode;
+ disabled?: boolean;
+ onModeChange: (mode: RecordingMode) => void;
+}
+
+export const RecordingModeSelector = ({
+ mode,
+ disabled = false,
+ onModeChange,
+}: RecordingModeSelectorProps) => {
+ const recordingModeOptions: Record<
+ RecordingMode,
+ {
+ label: string;
+ displayLabel: string;
+ icon: LucideIcon;
+ }
+ > = {
+ fullscreen: {
+ label: "Full Screen (Recommended)",
+ displayLabel: "Full Screen",
+ icon: MonitorIcon,
+ },
+ window: {
+ label: "Window",
+ displayLabel: "Window",
+ icon: RectangleHorizontal,
+ },
+ tab: {
+ label: "Current tab",
+ displayLabel: "Current tab",
+ icon: Globe,
+ },
+ camera: {
+ label: "Camera only",
+ displayLabel: "Camera only",
+ icon: CameraIcon,
+ },
+ };
+
+ const selectedOption = mode ? recordingModeOptions[mode] : null;
+ const SelectedIcon = selectedOption?.icon;
+
+ return (
+
+ {
+ onModeChange(value as RecordingMode);
+ }}
+ disabled={disabled}
+ >
+
+
+ {selectedOption && SelectedIcon && (
+
+
+ {selectedOption.displayLabel}
+
+ )}
+
+
+
+ {Object.entries(recordingModeOptions).map(([value, option]) => {
+ const OptionIcon = option.icon;
+ const isFullscreen = value === "fullscreen";
+
+ return (
+
+
+
+
+ {option.label}
+
+ {isFullscreen && (
+
+ Recommended to capture camera window when picture in
+ picture is activated
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/SettingsButton.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/SettingsButton.tsx
new file mode 100644
index 0000000000..5491bce8ba
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/SettingsButton.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import CogIcon from "@/app/(org)/dashboard/_components/AnimatedIcons/Cog";
+
+interface SettingsButtonProps {
+ visible: boolean;
+ onClick: () => void;
+}
+
+export const SettingsButton = ({ visible, onClick }: SettingsButtonProps) => {
+ if (!visible) return null;
+
+ return (
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/SettingsPanel.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/SettingsPanel.tsx
new file mode 100644
index 0000000000..5261b98724
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/SettingsPanel.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { Switch } from "@cap/ui";
+import { AnimatePresence, motion } from "framer-motion";
+import { ArrowLeftIcon } from "lucide-react";
+
+interface SettingsPanelProps {
+ open: boolean;
+ rememberDevices: boolean;
+ onClose: () => void;
+ onRememberDevicesChange: (value: boolean) => void;
+}
+
+export const SettingsPanel = ({
+ open,
+ rememberDevices,
+ onClose,
+ onRememberDevicesChange,
+}: SettingsPanelProps) => {
+ return (
+
+ {open && (
+
+
+
+
+ Recorder settings
+
+
+
+
+
+
+
+ Automatically select your last webcam/microphone
+
+
+ If available, the last used camera and mic will be
+ automatically selected.
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/instant-mp4-uploader.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/instant-mp4-uploader.ts
new file mode 100644
index 0000000000..86f9bc8e44
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/instant-mp4-uploader.ts
@@ -0,0 +1,391 @@
+import type { UploadStatus } from "../../UploadingContext";
+import type { ChunkUploadState, VideoId } from "./web-recorder-types";
+
+const MIN_PART_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
+
+type SetUploadStatus = (status: UploadStatus | undefined) => void;
+
+type ProgressUpdater = (uploaded: number, total: number) => Promise;
+
+interface UploadedPartPayload {
+ partNumber: number;
+ etag: string;
+ size: number;
+}
+
+interface MultipartCompletePayload {
+ durationSeconds: number;
+ width?: number;
+ height?: number;
+ fps?: number;
+}
+
+const postJson = async (
+ url: string,
+ body: Record,
+): Promise => {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "same-origin",
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const message = await response.text();
+ throw new Error(`Request to ${url} failed: ${response.status} ${message}`);
+ }
+
+ return (await response.json()) as TResponse;
+};
+
+export const initiateMultipartUpload = async (videoId: VideoId) => {
+ const result = await postJson<{ uploadId: string }>(
+ "/api/upload/multipart/initiate",
+ { videoId, contentType: "video/mp4" },
+ );
+
+ if (!result.uploadId)
+ throw new Error("Multipart initiate response missing uploadId");
+
+ return result.uploadId;
+};
+
+const presignMultipartPart = async (
+ videoId: VideoId,
+ uploadId: string,
+ partNumber: number,
+): Promise => {
+ const result = await postJson<{ presignedUrl: string }>(
+ "/api/upload/multipart/presign-part",
+ { videoId, uploadId, partNumber },
+ );
+
+ if (!result.presignedUrl) {
+ throw new Error(`Missing presigned URL for part ${partNumber}`);
+ }
+
+ return result.presignedUrl;
+};
+
+const completeMultipartUpload = async (
+ videoId: VideoId,
+ uploadId: string,
+ parts: UploadedPartPayload[],
+ meta: MultipartCompletePayload,
+) => {
+ await postJson<{ success: boolean }>("/api/upload/multipart/complete", {
+ videoId,
+ uploadId,
+ parts,
+ durationInSecs: meta.durationSeconds,
+ width: meta.width,
+ height: meta.height,
+ fps: meta.fps,
+ });
+};
+
+const abortMultipartUpload = async (videoId: VideoId, uploadId: string) => {
+ await postJson<{ success: boolean }>("/api/upload/multipart/abort", {
+ videoId,
+ uploadId,
+ });
+};
+
+interface FinalizeOptions extends MultipartCompletePayload {
+ finalBlob: Blob;
+ thumbnailUrl?: string;
+}
+
+export class InstantMp4Uploader {
+ private readonly videoId: VideoId;
+ private readonly uploadId: string;
+ private readonly mimeType: string;
+ private readonly setUploadStatus: SetUploadStatus;
+ private readonly sendProgressUpdate: ProgressUpdater;
+ private readonly onChunkStateChange?: (chunks: ChunkUploadState[]) => void;
+
+ private bufferedChunks: Blob[] = [];
+ private bufferedBytes = 0;
+ private totalRecordedBytes = 0;
+ private uploadedBytes = 0;
+ private uploadPromise: Promise = Promise.resolve();
+ private readonly parts: UploadedPartPayload[] = [];
+ private nextPartNumber = 1;
+ private finished = false;
+ private finalTotalBytes: number | null = null;
+ private thumbnailUrl: string | undefined;
+ private readonly chunkStates = new Map();
+
+ constructor(options: {
+ videoId: VideoId;
+ uploadId: string;
+ mimeType: string;
+ setUploadStatus: SetUploadStatus;
+ sendProgressUpdate: ProgressUpdater;
+ onChunkStateChange?: (chunks: ChunkUploadState[]) => void;
+ }) {
+ this.videoId = options.videoId;
+ this.uploadId = options.uploadId;
+ this.mimeType = options.mimeType;
+ this.setUploadStatus = options.setUploadStatus;
+ this.sendProgressUpdate = options.sendProgressUpdate;
+ this.onChunkStateChange = options.onChunkStateChange;
+ }
+
+ private emitChunkSnapshot() {
+ if (!this.onChunkStateChange) return;
+ const ordered = Array.from(this.chunkStates.values()).sort(
+ (a, b) => a.partNumber - b.partNumber,
+ );
+ this.onChunkStateChange(ordered);
+ }
+
+ private updateChunkState(
+ partNumber: number,
+ updates: Partial,
+ ) {
+ const current = this.chunkStates.get(partNumber);
+ if (!current) return;
+
+ const next: ChunkUploadState = {
+ ...current,
+ ...updates,
+ };
+
+ if (updates.uploadedBytes !== undefined) {
+ next.uploadedBytes = Math.max(
+ 0,
+ Math.min(current.sizeBytes, updates.uploadedBytes),
+ );
+ }
+
+ if (updates.progress !== undefined) {
+ next.progress = Math.min(1, Math.max(0, updates.progress));
+ } else if (updates.uploadedBytes !== undefined) {
+ const denominator = current.sizeBytes || 1;
+ next.progress = Math.min(
+ 1,
+ Math.max(0, next.uploadedBytes / denominator),
+ );
+ }
+
+ this.chunkStates.set(partNumber, next);
+ this.emitChunkSnapshot();
+ }
+
+ private registerChunk(partNumber: number, sizeBytes: number) {
+ this.chunkStates.set(partNumber, {
+ partNumber,
+ sizeBytes,
+ uploadedBytes: 0,
+ progress: 0,
+ status: "queued",
+ });
+ this.emitChunkSnapshot();
+ }
+
+ private clearChunkStates() {
+ this.chunkStates.clear();
+ this.emitChunkSnapshot();
+ }
+
+ setThumbnailUrl(previewUrl: string | undefined) {
+ this.thumbnailUrl = previewUrl;
+ }
+
+ handleChunk(blob: Blob, recordedTotalBytes: number) {
+ if (this.finished || blob.size === 0) return;
+
+ this.totalRecordedBytes = recordedTotalBytes;
+ this.bufferedChunks.push(blob);
+ this.bufferedBytes += blob.size;
+
+ if (this.bufferedBytes >= MIN_PART_SIZE_BYTES) {
+ this.flushBuffer();
+ }
+ }
+
+ private flushBuffer(force = false) {
+ if (this.bufferedBytes === 0) return;
+ if (!force && this.bufferedBytes < MIN_PART_SIZE_BYTES) return;
+
+ const chunk = new Blob(this.bufferedChunks, { type: this.mimeType });
+ this.bufferedChunks = [];
+ this.bufferedBytes = 0;
+
+ this.enqueueUpload(chunk);
+ }
+
+ private enqueueUpload(part: Blob) {
+ const partNumber = this.nextPartNumber++;
+ this.registerChunk(partNumber, part.size);
+ this.uploadPromise = this.uploadPromise
+ .then(() => this.uploadPart(partNumber, part))
+ .catch((error) => {
+ this.updateChunkState(partNumber, { status: "error" });
+ throw error;
+ });
+ }
+
+ private async uploadPart(partNumber: number, part: Blob) {
+ const presignedUrl = await presignMultipartPart(
+ this.videoId,
+ this.uploadId,
+ partNumber,
+ );
+
+ const etag = await this.uploadBlobWithProgress({
+ url: presignedUrl,
+ partNumber,
+ part,
+ });
+
+ this.parts.push({ partNumber, etag, size: part.size });
+ this.uploadedBytes += part.size;
+ this.emitProgress();
+ }
+
+ private uploadBlobWithProgress({
+ url,
+ partNumber,
+ part,
+ }: {
+ url: string;
+ partNumber: number;
+ part: Blob;
+ }): Promise {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("PUT", url);
+ xhr.responseType = "text";
+ if (this.mimeType) {
+ xhr.setRequestHeader("Content-Type", this.mimeType);
+ }
+
+ this.updateChunkState(partNumber, {
+ status: "uploading",
+ uploadedBytes: 0,
+ progress: 0,
+ });
+
+ xhr.upload.onprogress = (event) => {
+ const uploaded = event.lengthComputable
+ ? event.loaded
+ : Math.min(part.size, event.loaded);
+ const total = event.lengthComputable ? event.total : part.size;
+ const ratio = total > 0 ? Math.min(1, uploaded / total) : 0;
+ this.updateChunkState(partNumber, {
+ status: "uploading",
+ uploadedBytes: uploaded,
+ progress: ratio,
+ });
+ };
+
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ const etagHeader = xhr.getResponseHeader("ETag");
+ const etag = etagHeader?.replace(/"/g, "");
+ if (!etag) {
+ this.updateChunkState(partNumber, { status: "error" });
+ reject(new Error(`Missing ETag for part ${partNumber}`));
+ return;
+ }
+ this.updateChunkState(partNumber, {
+ status: "complete",
+ uploadedBytes: part.size,
+ progress: 1,
+ });
+ resolve(etag);
+ return;
+ }
+
+ this.updateChunkState(partNumber, { status: "error" });
+ reject(
+ new Error(
+ `Failed to upload part ${partNumber}: ${xhr.status} ${xhr.statusText}`,
+ ),
+ );
+ };
+
+ xhr.onerror = () => {
+ this.updateChunkState(partNumber, { status: "error" });
+ reject(new Error(`Failed to upload part ${partNumber}: network error`));
+ };
+
+ xhr.send(part);
+ });
+ }
+
+ private emitProgress() {
+ const totalBytes =
+ this.finalTotalBytes ??
+ Math.max(this.totalRecordedBytes, this.uploadedBytes);
+ const progress =
+ totalBytes > 0
+ ? Math.min(100, (this.uploadedBytes / totalBytes) * 100)
+ : 0;
+
+ this.setUploadStatus({
+ status: "uploadingVideo",
+ capId: this.videoId,
+ progress,
+ thumbnailUrl: this.thumbnailUrl,
+ });
+
+ void this.sendProgressUpdate(this.uploadedBytes, totalBytes).catch(
+ (error) => {
+ console.error("Failed to send upload progress", error);
+ },
+ );
+ }
+
+ async finalize(options: FinalizeOptions) {
+ if (this.finished) return;
+
+ this.finalTotalBytes = options.finalBlob.size;
+ this.thumbnailUrl = options.thumbnailUrl;
+ this.flushBuffer(true);
+
+ await this.uploadPromise;
+
+ if (this.parts.length === 0) {
+ this.enqueueUpload(options.finalBlob);
+ await this.uploadPromise;
+ }
+
+ await completeMultipartUpload(this.videoId, this.uploadId, this.parts, {
+ durationSeconds: options.durationSeconds,
+ width: options.width,
+ height: options.height,
+ fps: options.fps,
+ });
+
+ this.finished = true;
+ this.uploadedBytes = this.finalTotalBytes ?? this.uploadedBytes;
+ this.setUploadStatus({
+ status: "uploadingVideo",
+ capId: this.videoId,
+ progress: 100,
+ thumbnailUrl: this.thumbnailUrl,
+ });
+ await this.sendProgressUpdate(this.uploadedBytes, this.uploadedBytes);
+ }
+
+ async cancel() {
+ if (this.finished) return;
+ this.finished = true;
+ this.bufferedChunks = [];
+ this.bufferedBytes = 0;
+ this.clearChunkStates();
+ const pendingUpload = this.uploadPromise.catch(() => {
+ // Swallow errors during cancellation cleanup.
+ });
+ try {
+ await abortMultipartUpload(this.videoId, this.uploadId);
+ } catch (error) {
+ console.error("Failed to abort multipart upload", error);
+ }
+ await pendingUpload;
+ }
+}
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-conversion.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-conversion.ts
new file mode 100644
index 0000000000..49ab674682
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-conversion.ts
@@ -0,0 +1,137 @@
+import type { UploadStatus } from "../../UploadingContext";
+import type { VideoId } from "./web-recorder-types";
+
+const MAX_THUMBNAIL_WIDTH = 1000;
+const MAX_THUMBNAIL_HEIGHT = 562;
+const JPEG_QUALITY = 0.65;
+
+export const captureThumbnail = (
+ source: Blob,
+ dimensions: { width?: number; height?: number },
+) =>
+ new Promise((resolve) => {
+ const video = document.createElement("video");
+ const objectUrl = URL.createObjectURL(source);
+ video.src = objectUrl;
+ video.muted = true;
+ video.playsInline = true;
+
+ let timeoutId: number;
+
+ const cleanup = () => {
+ video.pause();
+ video.removeAttribute("src");
+ video.load();
+ URL.revokeObjectURL(objectUrl);
+ };
+
+ const finalize = (result: Blob | null) => {
+ window.clearTimeout(timeoutId);
+ cleanup();
+ resolve(result);
+ };
+
+ timeoutId = window.setTimeout(() => finalize(null), 10000);
+
+ video.addEventListener(
+ "error",
+ () => {
+ finalize(null);
+ },
+ { once: true },
+ );
+
+ video.addEventListener(
+ "loadedmetadata",
+ () => {
+ try {
+ const duration = Number.isFinite(video.duration) ? video.duration : 0;
+ const targetTime = duration > 0 ? Math.min(1, duration / 4) : 0;
+ video.currentTime = targetTime;
+ } catch {
+ finalize(null);
+ }
+ },
+ { once: true },
+ );
+
+ video.addEventListener(
+ "seeked",
+ () => {
+ try {
+ const canvas = document.createElement("canvas");
+ const sourceWidth = video.videoWidth || dimensions.width || 640;
+ const sourceHeight = video.videoHeight || dimensions.height || 360;
+ const scale = Math.min(
+ MAX_THUMBNAIL_WIDTH / sourceWidth,
+ MAX_THUMBNAIL_HEIGHT / sourceHeight,
+ 1,
+ );
+ const width = Math.round(sourceWidth * scale);
+ const height = Math.round(sourceHeight * scale);
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ finalize(null);
+ return;
+ }
+ ctx.drawImage(video, 0, 0, width, height);
+ canvas.toBlob(
+ (blob) => {
+ finalize(blob ?? null);
+ },
+ "image/jpeg",
+ JPEG_QUALITY,
+ );
+ } catch {
+ finalize(null);
+ }
+ },
+ { once: true },
+ );
+ });
+
+export const convertToMp4 = async (
+ blob: Blob,
+ hasAudio: boolean,
+ currentVideoId: VideoId,
+ setUploadStatus: (status: UploadStatus | undefined) => void,
+ onPhaseChange?: (phase: "converting") => void,
+) => {
+ onPhaseChange?.("converting");
+ setUploadStatus({
+ status: "converting",
+ capId: currentVideoId,
+ progress: 0,
+ });
+
+ const file = new File([blob], "recording.webm", { type: blob.type });
+ const { convertMedia } = await import("@remotion/webcodecs");
+
+ const result = await convertMedia({
+ src: file,
+ container: "mp4",
+ videoCodec: "h264",
+ ...(hasAudio ? { audioCodec: "aac" as const } : {}),
+ onProgress: ({ overallProgress }) => {
+ if (overallProgress !== null) {
+ const percent = Math.min(100, Math.max(0, overallProgress * 100));
+ setUploadStatus({
+ status: "converting",
+ capId: currentVideoId,
+ progress: percent,
+ });
+ }
+ },
+ });
+
+ const savedFile = await result.save();
+ if (savedFile.size === 0) {
+ throw new Error("Conversion produced empty file");
+ }
+ if (savedFile.type !== "video/mp4") {
+ return new File([savedFile], "result.mp4", { type: "video/mp4" });
+ }
+ return savedFile;
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-upload.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-upload.ts
new file mode 100644
index 0000000000..f449cccea4
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-upload.ts
@@ -0,0 +1,67 @@
+import type { UploadStatus } from "../../UploadingContext";
+import { sendProgressUpdate } from "../sendProgressUpdate";
+import type { PresignedPost, VideoId } from "./web-recorder-types";
+
+export const uploadRecording = (
+ blob: Blob,
+ upload: PresignedPost,
+ currentVideoId: VideoId,
+ thumbnailPreviewUrl: string | undefined,
+ setUploadStatus: (status: UploadStatus | undefined) => void,
+) =>
+ new Promise((resolve, reject) => {
+ if (blob.size === 0) {
+ reject(new Error("Cannot upload empty file"));
+ return;
+ }
+
+ const fileBlob =
+ blob instanceof File && blob.type === "video/mp4"
+ ? blob
+ : new File([blob], "result.mp4", { type: "video/mp4" });
+
+ const formData = new FormData();
+ Object.entries(upload.fields).forEach(([key, value]) => {
+ formData.append(key, value);
+ });
+ formData.append("file", fileBlob, "result.mp4");
+
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", upload.url);
+
+ xhr.upload.onprogress = (event) => {
+ if (event.lengthComputable) {
+ const percent = (event.loaded / event.total) * 100;
+ setUploadStatus({
+ status: "uploadingVideo",
+ capId: currentVideoId,
+ progress: percent,
+ thumbnailUrl: thumbnailPreviewUrl,
+ });
+ sendProgressUpdate(currentVideoId, event.loaded, event.total);
+ }
+ };
+
+ xhr.onload = async () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ await sendProgressUpdate(currentVideoId, blob.size, blob.size);
+ resolve();
+ } else {
+ const errorText = xhr.responseText || xhr.statusText || "Unknown error";
+ console.error("Upload failed:", {
+ status: xhr.status,
+ statusText: xhr.statusText,
+ responseText: errorText,
+ });
+ reject(
+ new Error(`Upload failed with status ${xhr.status}: ${errorText}`),
+ );
+ }
+ };
+
+ xhr.onerror = () => {
+ reject(new Error("Upload failed due to network error"));
+ };
+
+ xhr.send(formData);
+ });
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useCameraDevices.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useCameraDevices.ts
new file mode 100644
index 0000000000..82b4aebd18
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useCameraDevices.ts
@@ -0,0 +1,63 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export const useCameraDevices = (open: boolean) => {
+ const [availableCameras, setAvailableCameras] = useState(
+ [],
+ );
+ const isMountedRef = useRef(false);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ const enumerateDevices = useCallback(async () => {
+ if (typeof navigator === "undefined" || !navigator.mediaDevices) return;
+
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const videoInputs = devices.filter((device) => {
+ if (device.kind !== "videoinput") {
+ return false;
+ }
+ return device.deviceId.trim().length > 0;
+ });
+ if (isMountedRef.current) {
+ setAvailableCameras(videoInputs);
+ }
+ } catch (err) {
+ console.error("Failed to enumerate devices", err);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+
+ enumerateDevices();
+
+ const handleDeviceChange = () => {
+ enumerateDevices();
+ };
+
+ navigator.mediaDevices?.addEventListener(
+ "devicechange",
+ handleDeviceChange,
+ );
+
+ return () => {
+ navigator.mediaDevices?.removeEventListener(
+ "devicechange",
+ handleDeviceChange,
+ );
+ };
+ }, [open, enumerateDevices]);
+
+ return {
+ devices: availableCameras,
+ refresh: enumerateDevices,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useDevicePreferences.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useDevicePreferences.ts
new file mode 100644
index 0000000000..c22175ffb1
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useDevicePreferences.ts
@@ -0,0 +1,147 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const REMEMBER_DEVICES_KEY = "cap-web-recorder-remember-devices";
+const PREFERRED_CAMERA_KEY = "cap-web-recorder-preferred-camera";
+const PREFERRED_MICROPHONE_KEY = "cap-web-recorder-preferred-microphone";
+
+interface DevicePreferencesOptions {
+ open: boolean;
+ availableCameras: Array<{ deviceId: string }>;
+ availableMics: Array<{ deviceId: string }>;
+}
+
+export const useDevicePreferences = ({
+ open,
+ availableCameras,
+ availableMics,
+}: DevicePreferencesOptions) => {
+ const [rememberDevices, setRememberDevices] = useState(false);
+ const [selectedCameraId, setSelectedCameraId] = useState(null);
+ const [selectedMicId, setSelectedMicId] = useState(null);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ try {
+ const storedRemember = window.localStorage.getItem(REMEMBER_DEVICES_KEY);
+ if (storedRemember === "true") {
+ setRememberDevices(true);
+ }
+ } catch (error) {
+ console.error("Failed to load recorder preferences", error);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!open || !rememberDevices) return;
+ if (typeof window === "undefined") return;
+
+ try {
+ const storedCameraId = window.localStorage.getItem(PREFERRED_CAMERA_KEY);
+ if (storedCameraId) {
+ const hasCamera = availableCameras.some(
+ (camera) => camera.deviceId === storedCameraId,
+ );
+ if (hasCamera && storedCameraId !== selectedCameraId) {
+ setSelectedCameraId(storedCameraId);
+ }
+ }
+
+ const storedMicId = window.localStorage.getItem(PREFERRED_MICROPHONE_KEY);
+ if (storedMicId) {
+ const hasMic = availableMics.some(
+ (microphone) => microphone.deviceId === storedMicId,
+ );
+ if (hasMic && storedMicId !== selectedMicId) {
+ setSelectedMicId(storedMicId);
+ }
+ }
+ } catch (error) {
+ console.error("Failed to restore recorder device selection", error);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- effect restores saved device IDs and intentionally updates them
+ }, [open, rememberDevices, availableCameras, availableMics]);
+
+ const handleCameraChange = (cameraId: string | null) => {
+ setSelectedCameraId(cameraId);
+
+ if (!rememberDevices || typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ if (cameraId) {
+ window.localStorage.setItem(PREFERRED_CAMERA_KEY, cameraId);
+ } else {
+ window.localStorage.removeItem(PREFERRED_CAMERA_KEY);
+ }
+ } catch (error) {
+ console.error("Failed to persist preferred camera", error);
+ }
+ };
+
+ const handleMicChange = (micId: string | null) => {
+ setSelectedMicId(micId);
+
+ if (!rememberDevices || typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ if (micId) {
+ window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, micId);
+ } else {
+ window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY);
+ }
+ } catch (error) {
+ console.error("Failed to persist preferred microphone", error);
+ }
+ };
+
+ const handleRememberDevicesChange = (next: boolean) => {
+ setRememberDevices(next);
+
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(
+ REMEMBER_DEVICES_KEY,
+ next ? "true" : "false",
+ );
+
+ if (next) {
+ if (selectedCameraId) {
+ window.localStorage.setItem(PREFERRED_CAMERA_KEY, selectedCameraId);
+ } else {
+ window.localStorage.removeItem(PREFERRED_CAMERA_KEY);
+ }
+
+ if (selectedMicId) {
+ window.localStorage.setItem(PREFERRED_MICROPHONE_KEY, selectedMicId);
+ } else {
+ window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY);
+ }
+ } else {
+ window.localStorage.removeItem(PREFERRED_CAMERA_KEY);
+ window.localStorage.removeItem(PREFERRED_MICROPHONE_KEY);
+ }
+ } catch (error) {
+ console.error("Failed to update recorder preferences", error);
+ }
+ };
+
+ return {
+ rememberDevices,
+ selectedCameraId,
+ selectedMicId,
+ setSelectedCameraId,
+ setSelectedMicId,
+ handleCameraChange,
+ handleMicChange,
+ handleRememberDevicesChange,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useDialogInteractions.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useDialogInteractions.ts
new file mode 100644
index 0000000000..c0e39f4cf6
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useDialogInteractions.ts
@@ -0,0 +1,119 @@
+"use client";
+
+import type * as DialogPrimitive from "@radix-ui/react-dialog";
+import type { RefObject } from "react";
+
+type PointerDownOutsideEvent = Parameters<
+ NonNullable
+>[0];
+type FocusOutsideEvent = Parameters<
+ NonNullable
+>[0];
+type InteractOutsideEvent = Parameters<
+ NonNullable
+>[0];
+
+const isInsideDialog = (el: Element, dialogContent: HTMLElement | null) => {
+ if (!dialogContent) return false;
+ return dialogContent.contains(el);
+};
+
+const isWhitelisted = (el: Element, dialogContent: HTMLElement | null) => {
+ if (isInsideDialog(el, dialogContent)) return true;
+ if (el.closest('[data-slot="select-content"]')) return true;
+ if (el.closest("[data-radix-select-content]")) return true;
+ if (el.closest("[data-radix-select-viewport]")) return true;
+ if (el.closest("[data-radix-select-item]")) return true;
+ if (el.closest("[data-camera-preview]")) return true;
+ return false;
+};
+
+const shouldPreventDefault = (
+ target: Element | null | undefined,
+ path: Array,
+ dialogContent: HTMLElement | null,
+) => {
+ if (!target) return false;
+
+ return (
+ isWhitelisted(target, dialogContent) ||
+ path.some(
+ (t) => t instanceof Element && isWhitelisted(t as Element, dialogContent),
+ )
+ );
+};
+
+interface UseDialogInteractionsOptions {
+ dialogContentRef: RefObject;
+ isRecording: boolean;
+ isBusy: boolean;
+}
+
+export const useDialogInteractions = ({
+ dialogContentRef,
+ isRecording,
+ isBusy,
+}: UseDialogInteractionsOptions) => {
+ const handlePointerDownOutside = (event: PointerDownOutsideEvent) => {
+ const originalEvent = event.detail.originalEvent;
+ const target = originalEvent?.target as Element | null | undefined;
+
+ if (!target) return;
+
+ if (isRecording || isBusy) {
+ event.preventDefault();
+ return;
+ }
+
+ const path = originalEvent?.composedPath() || [];
+ const dialogContent = dialogContentRef.current;
+
+ if (shouldPreventDefault(target, path, dialogContent)) {
+ event.preventDefault();
+ }
+ };
+
+ const handleFocusOutside = (event: FocusOutsideEvent) => {
+ const target = event.target as Element | null | undefined;
+
+ if (!target) return;
+
+ if (isRecording || isBusy) {
+ event.preventDefault();
+ return;
+ }
+
+ const path =
+ (event.detail?.originalEvent as FocusEvent)?.composedPath?.() || [];
+ const dialogContent = dialogContentRef.current;
+
+ if (shouldPreventDefault(target, path, dialogContent)) {
+ event.preventDefault();
+ }
+ };
+
+ const handleInteractOutside = (event: InteractOutsideEvent) => {
+ const originalEvent = event.detail.originalEvent;
+ const target = originalEvent?.target as Element | null | undefined;
+
+ if (!target) return;
+
+ if (isRecording || isBusy) {
+ event.preventDefault();
+ return;
+ }
+
+ const path = originalEvent?.composedPath?.() || [];
+ const dialogContent = dialogContentRef.current;
+
+ if (shouldPreventDefault(target, path, dialogContent)) {
+ event.preventDefault();
+ }
+ };
+
+ return {
+ handlePointerDownOutside,
+ handleFocusOutside,
+ handleInteractOutside,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMediaPermission.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMediaPermission.ts
new file mode 100644
index 0000000000..bdb8c006fa
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMediaPermission.ts
@@ -0,0 +1,111 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+type MediaPermissionKind = "camera" | "microphone";
+
+type MediaPermissionState = PermissionState | "unsupported" | "unknown";
+
+const permissionNameMap: Record = {
+ camera: "camera",
+ microphone: "microphone",
+};
+
+const mediaConstraintsMap: Record =
+ {
+ camera: {
+ video: { width: { ideal: 1280 }, height: { ideal: 720 } },
+ audio: false,
+ },
+ microphone: { audio: true, video: false },
+ };
+
+export const useMediaPermission = (
+ kind: MediaPermissionKind,
+ enabled: boolean,
+) => {
+ const [state, setState] = useState("unknown");
+ const permissionStatusRef = useRef(null);
+
+ const updateState = useCallback((next: MediaPermissionState) => {
+ setState((prev) => {
+ if (prev === next) return prev;
+ return next;
+ });
+ }, []);
+
+ const refreshPermission = useCallback(async () => {
+ if (!enabled) return;
+ if (typeof navigator === "undefined" || !navigator.permissions?.query) {
+ updateState("unsupported");
+ return;
+ }
+
+ try {
+ const descriptor = {
+ name: permissionNameMap[kind],
+ } as PermissionDescriptor;
+
+ const permissionStatus = await navigator.permissions.query(descriptor);
+ if (permissionStatusRef.current) {
+ permissionStatusRef.current.onchange = null;
+ }
+ permissionStatusRef.current = permissionStatus;
+
+ updateState(permissionStatus.state);
+
+ permissionStatus.onchange = () => {
+ updateState(permissionStatus.state);
+ };
+ } catch (error) {
+ updateState("unsupported");
+ }
+ }, [enabled, kind, updateState]);
+
+ useEffect(() => {
+ if (!enabled) return;
+ refreshPermission();
+
+ return () => {
+ if (permissionStatusRef.current) {
+ permissionStatusRef.current.onchange = null;
+ }
+ permissionStatusRef.current = null;
+ };
+ }, [enabled, refreshPermission]);
+
+ const requestPermission = useCallback(async () => {
+ if (
+ typeof navigator === "undefined" ||
+ !navigator.mediaDevices?.getUserMedia
+ ) {
+ updateState("unsupported");
+ return false;
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia(
+ mediaConstraintsMap[kind],
+ );
+ stream.getTracks().forEach((track) => track.stop());
+ updateState("granted");
+ await refreshPermission();
+ return true;
+ } catch (error) {
+ if (error instanceof DOMException) {
+ if (
+ error.name === "NotAllowedError" ||
+ error.name === "SecurityError"
+ ) {
+ updateState("denied");
+ }
+ }
+ throw error;
+ }
+ }, [kind, refreshPermission, updateState]);
+
+ return {
+ state,
+ requestPermission,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMediaRecorderSetup.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMediaRecorderSetup.ts
new file mode 100644
index 0000000000..8727340330
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMediaRecorderSetup.ts
@@ -0,0 +1,93 @@
+import { useCallback, useRef } from "react";
+import type { RecorderErrorEvent } from "./web-recorder-types";
+
+export const useMediaRecorderSetup = () => {
+ const mediaRecorderRef = useRef(null);
+ const recordedChunksRef = useRef([]);
+ const totalRecordedBytesRef = useRef(0);
+ const stopPromiseResolverRef = useRef<((blob: Blob) => void) | null>(null);
+ const stopPromiseRejectRef = useRef<((reason?: unknown) => void) | null>(
+ null,
+ );
+ const isStoppingRef = useRef(false);
+
+ const onRecorderDataAvailable = useCallback(
+ (event: BlobEvent, onChunk?: (chunk: Blob, totalBytes: number) => void) => {
+ if (event.data && event.data.size > 0) {
+ recordedChunksRef.current.push(event.data);
+ totalRecordedBytesRef.current += event.data.size;
+ onChunk?.(event.data, totalRecordedBytesRef.current);
+ }
+ },
+ [],
+ );
+
+ const onRecorderStop = useCallback(() => {
+ if (recordedChunksRef.current.length === 0) {
+ const rejecter = stopPromiseRejectRef.current;
+ stopPromiseResolverRef.current = null;
+ stopPromiseRejectRef.current = null;
+ isStoppingRef.current = false;
+ rejecter?.(new Error("No recorded data"));
+ return;
+ }
+
+ const blob = new Blob(recordedChunksRef.current, {
+ type: recordedChunksRef.current[0]?.type ?? "video/webm;codecs=vp8,opus",
+ });
+ recordedChunksRef.current = [];
+ const resolver = stopPromiseResolverRef.current;
+ stopPromiseResolverRef.current = null;
+ stopPromiseRejectRef.current = null;
+ isStoppingRef.current = false;
+ resolver?.(blob);
+ }, []);
+
+ const onRecorderError = useCallback((event: RecorderErrorEvent) => {
+ const error = event.error ?? new DOMException("Recording error");
+ const rejecter = stopPromiseRejectRef.current;
+ stopPromiseResolverRef.current = null;
+ stopPromiseRejectRef.current = null;
+ isStoppingRef.current = false;
+ rejecter?.(error);
+ }, []);
+
+ const stopRecordingInternal = useCallback(
+ async (cleanupStreams: () => void, clearTimer: () => void) => {
+ const recorder = mediaRecorderRef.current;
+ if (!recorder || recorder.state === "inactive") return null;
+ if (isStoppingRef.current) return null;
+
+ isStoppingRef.current = true;
+
+ const stopPromise = new Promise((resolve, reject) => {
+ stopPromiseResolverRef.current = resolve;
+ stopPromiseRejectRef.current = reject;
+ });
+
+ recorder.stop();
+ cleanupStreams();
+ clearTimer();
+
+ return stopPromise;
+ },
+ [],
+ );
+
+ const resetRecorder = useCallback(() => {
+ mediaRecorderRef.current = null;
+ recordedChunksRef.current = [];
+ totalRecordedBytesRef.current = 0;
+ }, []);
+
+ return {
+ mediaRecorderRef,
+ recordedChunksRef,
+ totalRecordedBytesRef,
+ onRecorderDataAvailable,
+ onRecorderStop,
+ onRecorderError,
+ stopRecordingInternal,
+ resetRecorder,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices.ts
new file mode 100644
index 0000000000..c841558c7a
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices.ts
@@ -0,0 +1,61 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export const useMicrophoneDevices = (open: boolean) => {
+ const [availableMics, setAvailableMics] = useState([]);
+ const isMountedRef = useRef(false);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ const enumerateDevices = useCallback(async () => {
+ if (typeof navigator === "undefined" || !navigator.mediaDevices) return;
+
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const audioInputs = devices.filter((device) => {
+ if (device.kind !== "audioinput") {
+ return false;
+ }
+ return device.deviceId.trim().length > 0;
+ });
+ if (isMountedRef.current) {
+ setAvailableMics(audioInputs);
+ }
+ } catch (err) {
+ console.error("Failed to enumerate devices", err);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+
+ enumerateDevices();
+
+ const handleDeviceChange = () => {
+ enumerateDevices();
+ };
+
+ navigator.mediaDevices?.addEventListener(
+ "devicechange",
+ handleDeviceChange,
+ );
+
+ return () => {
+ navigator.mediaDevices?.removeEventListener(
+ "devicechange",
+ handleDeviceChange,
+ );
+ };
+ }, [open, enumerateDevices]);
+
+ return {
+ devices: availableMics,
+ refresh: enumerateDevices,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useRecordingTimer.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useRecordingTimer.ts
new file mode 100644
index 0000000000..6d858529c1
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useRecordingTimer.ts
@@ -0,0 +1,102 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export const useRecordingTimer = () => {
+ const [durationMs, setDurationMs] = useState(0);
+ const timerRef = useRef(null);
+ const startTimeRef = useRef(null);
+ const pauseStartRef = useRef(null);
+ const pausedDurationRef = useRef(0);
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current !== null) {
+ window.clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ clearTimer();
+ };
+ }, [clearTimer]);
+
+ const commitPausedDuration = useCallback((timestamp?: number) => {
+ if (pauseStartRef.current === null) return;
+ const now = timestamp ?? performance.now();
+ pausedDurationRef.current += now - pauseStartRef.current;
+ pauseStartRef.current = null;
+ }, []);
+
+ const syncDurationFromClock = useCallback((timestamp?: number) => {
+ const startTime = startTimeRef.current;
+ if (startTime === null) {
+ setDurationMs(0);
+ return 0;
+ }
+
+ const now = timestamp ?? performance.now();
+ const pausedPending =
+ pauseStartRef.current !== null ? now - pauseStartRef.current : 0;
+ const totalPaused = pausedDurationRef.current + pausedPending;
+ const elapsed = Math.max(0, now - startTime - totalPaused);
+
+ setDurationMs(elapsed);
+ return elapsed;
+ }, []);
+
+ const startTimer = useCallback(() => {
+ const now = performance.now();
+ startTimeRef.current = now;
+ pauseStartRef.current = null;
+ pausedDurationRef.current = 0;
+ setDurationMs(0);
+
+ if (timerRef.current !== null) {
+ window.clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+
+ timerRef.current = window.setInterval(() => {
+ if (startTimeRef.current !== null) {
+ syncDurationFromClock();
+ }
+ }, 250);
+ }, [syncDurationFromClock]);
+
+ const resetTimer = useCallback(() => {
+ clearTimer();
+ startTimeRef.current = null;
+ pauseStartRef.current = null;
+ pausedDurationRef.current = 0;
+ setDurationMs(0);
+ }, [clearTimer]);
+
+ const pauseTimer = useCallback(
+ (timestamp?: number) => {
+ const now = timestamp ?? performance.now();
+ pauseStartRef.current = now;
+ syncDurationFromClock(now);
+ },
+ [syncDurationFromClock],
+ );
+
+ const resumeTimer = useCallback(
+ (timestamp?: number) => {
+ const now = timestamp ?? performance.now();
+ commitPausedDuration(now);
+ syncDurationFromClock(now);
+ },
+ [commitPausedDuration, syncDurationFromClock],
+ );
+
+ return {
+ durationMs,
+ clearTimer,
+ startTimer,
+ resetTimer,
+ pauseTimer,
+ resumeTimer,
+ commitPausedDuration,
+ syncDurationFromClock,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useStreamManagement.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useStreamManagement.ts
new file mode 100644
index 0000000000..3b25e13133
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useStreamManagement.ts
@@ -0,0 +1,59 @@
+import { useCallback, useRef } from "react";
+
+export const useStreamManagement = () => {
+ const displayStreamRef = useRef(null);
+ const cameraStreamRef = useRef(null);
+ const micStreamRef = useRef(null);
+ const mixedStreamRef = useRef(null);
+ const videoRef = useRef(null);
+ const detectionTimeoutsRef = useRef([]);
+ const detectionCleanupRef = useRef void>>([]);
+
+ const clearDetectionTracking = useCallback(() => {
+ detectionTimeoutsRef.current.forEach((timeoutId) => {
+ window.clearTimeout(timeoutId);
+ });
+ detectionTimeoutsRef.current = [];
+ detectionCleanupRef.current.forEach((cleanup) => {
+ try {
+ cleanup();
+ } catch {
+ /* ignore */
+ }
+ });
+ detectionCleanupRef.current = [];
+ }, []);
+
+ const cleanupStreams = useCallback(() => {
+ clearDetectionTracking();
+ const stopTracks = (stream: MediaStream | null) => {
+ stream?.getTracks().forEach((track) => {
+ track.stop();
+ });
+ };
+ stopTracks(displayStreamRef.current);
+ stopTracks(cameraStreamRef.current);
+ stopTracks(micStreamRef.current);
+ stopTracks(mixedStreamRef.current);
+ displayStreamRef.current = null;
+ cameraStreamRef.current = null;
+ micStreamRef.current = null;
+ mixedStreamRef.current = null;
+
+ if (videoRef.current) {
+ videoRef.current.srcObject = null;
+ }
+ }, [clearDetectionTracking]);
+
+ return {
+ displayStreamRef,
+ cameraStreamRef,
+ micStreamRef,
+ mixedStreamRef,
+ videoRef,
+ detectionTimeoutsRef,
+ detectionCleanupRef,
+ clearDetectionTracking,
+ cleanupStreams,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useSurfaceDetection.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useSurfaceDetection.ts
new file mode 100644
index 0000000000..edb76a0528
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useSurfaceDetection.ts
@@ -0,0 +1,95 @@
+import { useCallback, useRef } from "react";
+import type { RecordingMode } from "./RecordingModeSelector";
+import type { DetectedDisplayRecordingMode } from "./web-recorder-constants";
+import { DETECTION_RETRY_DELAYS } from "./web-recorder-constants";
+import { detectRecordingModeFromTrack } from "./web-recorder-utils";
+
+export const useSurfaceDetection = (
+ onRecordingSurfaceDetected?: (mode: DetectedDisplayRecordingMode) => void,
+ detectionTimeoutsRef?: React.MutableRefObject,
+ detectionCleanupRef?: React.MutableRefObject void>>,
+) => {
+ const recordingModeRef = useRef("camera");
+
+ const clearDetectionTracking = useCallback(() => {
+ if (detectionTimeoutsRef) {
+ detectionTimeoutsRef.current.forEach((timeoutId) => {
+ window.clearTimeout(timeoutId);
+ });
+ detectionTimeoutsRef.current = [];
+ }
+ if (detectionCleanupRef) {
+ detectionCleanupRef.current.forEach((cleanup) => {
+ try {
+ cleanup();
+ } catch {
+ /* ignore */
+ }
+ });
+ detectionCleanupRef.current = [];
+ }
+ }, [detectionTimeoutsRef, detectionCleanupRef]);
+
+ const notifyDetectedMode = useCallback(
+ (detected: DetectedDisplayRecordingMode | null) => {
+ if (!detected) return;
+ if (detected === recordingModeRef.current) return;
+ recordingModeRef.current = detected;
+ onRecordingSurfaceDetected?.(detected);
+ },
+ [onRecordingSurfaceDetected],
+ );
+
+ const scheduleSurfaceDetection = useCallback(
+ (track: MediaStreamTrack | null, initialSettings?: MediaTrackSettings) => {
+ if (!track || !onRecordingSurfaceDetected) {
+ return;
+ }
+
+ clearDetectionTracking();
+
+ const attemptDetection = (settingsOverride?: MediaTrackSettings) => {
+ notifyDetectedMode(
+ detectRecordingModeFromTrack(track, settingsOverride),
+ );
+ };
+
+ attemptDetection(initialSettings);
+
+ if (detectionTimeoutsRef) {
+ DETECTION_RETRY_DELAYS.forEach((delay) => {
+ const timeoutId = window.setTimeout(() => {
+ attemptDetection();
+ }, delay);
+ detectionTimeoutsRef.current.push(timeoutId);
+ });
+ }
+
+ const handleTrackReady = () => {
+ attemptDetection();
+ };
+
+ if (detectionCleanupRef) {
+ track.addEventListener("unmute", handleTrackReady, { once: true });
+ track.addEventListener("mute", handleTrackReady, { once: true });
+ detectionCleanupRef.current.push(() => {
+ track.removeEventListener("unmute", handleTrackReady);
+ track.removeEventListener("mute", handleTrackReady);
+ });
+ }
+ },
+ [
+ clearDetectionTracking,
+ notifyDetectedMode,
+ onRecordingSurfaceDetected,
+ detectionTimeoutsRef,
+ detectionCleanupRef,
+ ],
+ );
+
+ return {
+ recordingModeRef,
+ scheduleSurfaceDetection,
+ clearDetectionTracking,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts
new file mode 100644
index 0000000000..a649fe43de
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/useWebRecorder.ts
@@ -0,0 +1,1022 @@
+"use client";
+
+import { Organisation } from "@cap/web-domain";
+import { useQueryClient } from "@tanstack/react-query";
+import { Cause, Exit, Option } from "effect";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import {
+ createVideoAndGetUploadUrl,
+ deleteVideoResultFile,
+} from "@/actions/video/upload";
+import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime";
+import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest";
+import { useUploadingContext } from "../../UploadingContext";
+import { sendProgressUpdate } from "../sendProgressUpdate";
+import {
+ InstantMp4Uploader,
+ initiateMultipartUpload,
+} from "./instant-mp4-uploader";
+import type { RecordingMode } from "./RecordingModeSelector";
+import { captureThumbnail, convertToMp4 } from "./recording-conversion";
+import { uploadRecording } from "./recording-upload";
+import { useMediaRecorderSetup } from "./useMediaRecorderSetup";
+import { useRecordingTimer } from "./useRecordingTimer";
+import { useStreamManagement } from "./useStreamManagement";
+import { useSurfaceDetection } from "./useSurfaceDetection";
+import {
+ type DetectedDisplayRecordingMode,
+ DISPLAY_MEDIA_VIDEO_CONSTRAINTS,
+ DISPLAY_MODE_PREFERENCES,
+ type DisplaySurfacePreference,
+ type ExtendedDisplayMediaStreamOptions,
+ FREE_PLAN_MAX_RECORDING_MS,
+ MP4_MIME_TYPES,
+ RECORDING_MODE_TO_DISPLAY_SURFACE,
+ WEBM_MIME_TYPES,
+} from "./web-recorder-constants";
+import type {
+ ChunkUploadState,
+ PresignedPost,
+ RecorderPhase,
+ VideoId,
+} from "./web-recorder-types";
+import {
+ detectCapabilities,
+ pickSupportedMimeType,
+ type RecorderCapabilities,
+ shouldRetryDisplayMediaWithoutPreferences,
+} from "./web-recorder-utils";
+
+interface UseWebRecorderOptions {
+ organisationId: string | undefined;
+ selectedMicId: string | null;
+ micEnabled: boolean;
+ recordingMode: RecordingMode;
+ selectedCameraId: string | null;
+ isProUser: boolean;
+ onPhaseChange?: (phase: RecorderPhase) => void;
+ onRecordingSurfaceDetected?: (mode: RecordingMode) => void;
+ onRecordingStart?: () => void;
+ onRecordingStop?: () => void;
+}
+
+const INSTANT_UPLOAD_REQUEST_INTERVAL_MS = 1000;
+const INSTANT_CHUNK_GUARD_DELAY_MS = INSTANT_UPLOAD_REQUEST_INTERVAL_MS * 3;
+
+type InstantChunkingMode = "manual" | "timeslice";
+
+const unwrapExitOrThrow = (exit: Exit.Exit) => {
+ if (Exit.isFailure(exit)) {
+ throw Cause.squash(exit.cause);
+ }
+
+ return exit.value;
+};
+
+export const useWebRecorder = ({
+ organisationId,
+ selectedMicId,
+ micEnabled,
+ recordingMode,
+ selectedCameraId,
+ isProUser,
+ onPhaseChange,
+ onRecordingSurfaceDetected,
+ onRecordingStart,
+ onRecordingStop,
+}: UseWebRecorderOptions) => {
+ const [phase, setPhase] = useState("idle");
+ const [videoId, setVideoId] = useState(null);
+ const [hasAudioTrack, setHasAudioTrack] = useState(false);
+ const [isSettingUp, setIsSettingUp] = useState(false);
+ const [isRestarting, setIsRestarting] = useState(false);
+ const [chunkUploads, setChunkUploads] = useState([]);
+ const [capabilities, setCapabilities] = useState(() =>
+ detectCapabilities(),
+ );
+
+ const {
+ displayStreamRef,
+ cameraStreamRef,
+ micStreamRef,
+ mixedStreamRef,
+ videoRef,
+ detectionTimeoutsRef,
+ detectionCleanupRef,
+ clearDetectionTracking,
+ cleanupStreams,
+ } = useStreamManagement();
+
+ const {
+ durationMs,
+ clearTimer,
+ startTimer,
+ resetTimer,
+ pauseTimer,
+ resumeTimer,
+ commitPausedDuration,
+ syncDurationFromClock,
+ } = useRecordingTimer();
+
+ const {
+ mediaRecorderRef,
+ recordedChunksRef,
+ totalRecordedBytesRef,
+ onRecorderDataAvailable,
+ onRecorderStop,
+ onRecorderError,
+ stopRecordingInternal,
+ resetRecorder,
+ } = useMediaRecorderSetup();
+
+ const { scheduleSurfaceDetection } = useSurfaceDetection(
+ onRecordingSurfaceDetected,
+ detectionTimeoutsRef,
+ detectionCleanupRef,
+ );
+
+ const supportCheckCompleted = capabilities.assessed;
+ const rawCanRecordCamera =
+ capabilities.hasMediaRecorder && capabilities.hasUserMedia;
+ const rawCanRecordDisplay =
+ rawCanRecordCamera && capabilities.hasDisplayMedia;
+ const supportsCameraRecording = supportCheckCompleted
+ ? rawCanRecordCamera
+ : true;
+ const supportsDisplayRecording = supportCheckCompleted
+ ? rawCanRecordDisplay
+ : true;
+ const requiresDisplayMedia = recordingMode !== "camera";
+ const isBrowserSupported = requiresDisplayMedia
+ ? supportsDisplayRecording
+ : supportsCameraRecording;
+ const screenCaptureWarning =
+ supportCheckCompleted && rawCanRecordCamera && !capabilities.hasDisplayMedia
+ ? "Screen sharing isn't supported in this browser. We'll switch to camera-only recording. Try Chrome, Edge, or our desktop app for screen capture."
+ : null;
+ const unsupportedReason = supportCheckCompleted
+ ? !capabilities.hasMediaRecorder
+ ? "This browser doesn't support in-browser recording. Try the latest Chrome, Edge, or Safari, or use the desktop app."
+ : !capabilities.hasUserMedia
+ ? "Camera and microphone access are unavailable in this browser. Check permissions or switch browsers."
+ : requiresDisplayMedia && !capabilities.hasDisplayMedia
+ ? "Screen capture isn't supported in this browser. Switch to Camera only or use Chrome, Edge, or Safari."
+ : null
+ : null;
+
+ const dimensionsRef = useRef<{ width?: number; height?: number }>({});
+ const stopRecordingRef = useRef<(() => Promise) | null>(null);
+ const instantUploaderRef = useRef(null);
+ const videoCreationRef = useRef<{
+ id: VideoId;
+ upload: PresignedPost;
+ shareUrl: string;
+ } | null>(null);
+ const instantMp4ActiveRef = useRef(false);
+ const pendingInstantVideoIdRef = useRef(null);
+ const dataRequestIntervalRef = useRef(null);
+ const instantChunkModeRef = useRef(null);
+ const chunkStartGuardTimeoutRef = useRef(null);
+ const lastInstantChunkAtRef = useRef(null);
+ const freePlanAutoStopTriggeredRef = useRef(false);
+ const requestInstantRecorderData = useCallback(() => {
+ if (instantChunkModeRef.current !== "manual") return;
+ const recorder = mediaRecorderRef.current;
+ if (!recorder || recorder.state !== "recording") return;
+ try {
+ recorder.requestData();
+ } catch (error) {
+ console.warn("Failed to request recorder data", error);
+ }
+ }, [mediaRecorderRef]);
+
+ const rpc = useRpcClient();
+ type RpcClient = typeof rpc;
+ type VideoInstantCreateVariables = Parameters<
+ RpcClient["VideoInstantCreate"]
+ >[0];
+ const router = useRouter();
+ const { setUploadStatus } = useUploadingContext();
+ const queryClient = useQueryClient();
+ const deleteVideo = useEffectMutation({
+ mutationFn: (id: VideoId) => rpc.VideoDelete(id),
+ });
+ const videoInstantCreate = useEffectMutation({
+ mutationFn: (variables: VideoInstantCreateVariables) =>
+ rpc.VideoInstantCreate(variables),
+ });
+
+ const isFreePlan = !isProUser;
+
+ const stopInstantChunkInterval = useCallback(() => {
+ if (!dataRequestIntervalRef.current) return;
+ clearInterval(dataRequestIntervalRef.current);
+ dataRequestIntervalRef.current = null;
+ }, []);
+
+ const startInstantChunkInterval = useCallback(() => {
+ if (instantChunkModeRef.current !== "manual") return;
+ if (typeof window === "undefined") return;
+ requestInstantRecorderData();
+ if (dataRequestIntervalRef.current) return;
+ dataRequestIntervalRef.current = window.setInterval(
+ requestInstantRecorderData,
+ INSTANT_UPLOAD_REQUEST_INTERVAL_MS,
+ );
+ }, [requestInstantRecorderData]);
+
+ const clearInstantChunkGuard = useCallback(() => {
+ if (!chunkStartGuardTimeoutRef.current) return;
+ if (typeof window !== "undefined") {
+ window.clearTimeout(chunkStartGuardTimeoutRef.current);
+ } else {
+ clearTimeout(chunkStartGuardTimeoutRef.current);
+ }
+ chunkStartGuardTimeoutRef.current = null;
+ }, []);
+
+ const beginManualInstantChunking = useCallback(() => {
+ instantChunkModeRef.current = "manual";
+ lastInstantChunkAtRef.current = null;
+ clearInstantChunkGuard();
+ startInstantChunkInterval();
+ }, [clearInstantChunkGuard, startInstantChunkInterval]);
+
+ const scheduleInstantChunkGuard = useCallback(() => {
+ clearInstantChunkGuard();
+ if (typeof window === "undefined") return;
+ chunkStartGuardTimeoutRef.current = window.setTimeout(() => {
+ if (instantChunkModeRef.current !== "timeslice") return;
+ if (lastInstantChunkAtRef.current !== null) return;
+ console.warn(
+ "Instant recorder did not emit data after start; falling back to manual chunk requests",
+ );
+ beginManualInstantChunking();
+ }, INSTANT_CHUNK_GUARD_DELAY_MS);
+ }, [beginManualInstantChunking, clearInstantChunkGuard]);
+
+ const updatePhase = useCallback(
+ (newPhase: RecorderPhase) => {
+ setPhase(newPhase);
+ onPhaseChange?.(newPhase);
+ },
+ [onPhaseChange],
+ );
+
+ const cleanupRecordingState = useCallback(
+ (options?: { preserveInstantVideo?: boolean }) => {
+ cleanupStreams();
+ clearTimer();
+ resetRecorder();
+ resetTimer();
+ stopInstantChunkInterval();
+ clearInstantChunkGuard();
+ instantChunkModeRef.current = null;
+ lastInstantChunkAtRef.current = null;
+ instantMp4ActiveRef.current = false;
+ if (instantUploaderRef.current) {
+ void instantUploaderRef.current.cancel();
+ }
+ instantUploaderRef.current = null;
+ setUploadStatus(undefined);
+ setChunkUploads([]);
+ setHasAudioTrack(false);
+
+ if (!options?.preserveInstantVideo) {
+ const pendingInstantVideoId = pendingInstantVideoIdRef.current;
+ pendingInstantVideoIdRef.current = null;
+ videoCreationRef.current = null;
+ setVideoId(null);
+ if (pendingInstantVideoId) {
+ void deleteVideo.mutateAsync(pendingInstantVideoId);
+ }
+ }
+ },
+ [
+ cleanupStreams,
+ clearTimer,
+ resetRecorder,
+ resetTimer,
+ stopInstantChunkInterval,
+ clearInstantChunkGuard,
+ deleteVideo,
+ setUploadStatus,
+ setChunkUploads,
+ setHasAudioTrack,
+ setVideoId,
+ ],
+ );
+
+ const resetState = useCallback(() => {
+ cleanupRecordingState();
+ updatePhase("idle");
+ }, [cleanupRecordingState, updatePhase]);
+
+ const resetStateRef = useRef(resetState);
+
+ useEffect(() => {
+ resetStateRef.current = resetState;
+ }, [resetState]);
+
+ useEffect(() => {
+ setCapabilities(detectCapabilities());
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ resetStateRef.current();
+ };
+ }, []);
+
+ const handleRecorderDataAvailable = useCallback(
+ (event: BlobEvent) => {
+ onRecorderDataAvailable(event, (chunk: Blob, totalBytes: number) => {
+ if (instantMp4ActiveRef.current && chunk.size > 0) {
+ lastInstantChunkAtRef.current =
+ typeof performance !== "undefined" ? performance.now() : Date.now();
+ if (instantChunkModeRef.current === "timeslice") {
+ clearInstantChunkGuard();
+ }
+ }
+ instantUploaderRef.current?.handleChunk(chunk, totalBytes);
+ });
+ },
+ [onRecorderDataAvailable, clearInstantChunkGuard],
+ );
+
+ const stopRecordingInternalWrapper = useCallback(async () => {
+ return stopRecordingInternal(cleanupStreams, clearTimer);
+ }, [stopRecordingInternal, cleanupStreams, clearTimer]);
+
+ const startRecording = async (options?: { reuseInstantVideo?: boolean }) => {
+ if (!organisationId) {
+ toast.error("Select an organization before recording.");
+ return;
+ }
+
+ if (recordingMode === "camera" && !selectedCameraId) {
+ toast.error("Select a camera before recording.");
+ return;
+ }
+
+ if (!isBrowserSupported) {
+ const fallbackMessage =
+ unsupportedReason ??
+ "Recording isn't supported in this browser. Try another browser or use the desktop app.";
+ toast.error(fallbackMessage);
+ return;
+ }
+
+ setChunkUploads([]);
+ setIsSettingUp(true);
+
+ try {
+ let videoStream: MediaStream | null = null;
+ let firstTrack: MediaStreamTrack | null = null;
+
+ if (recordingMode === "camera") {
+ if (!selectedCameraId) {
+ throw new Error("Camera ID is required for camera-only mode");
+ }
+ videoStream = await navigator.mediaDevices.getUserMedia({
+ video: {
+ deviceId: { exact: selectedCameraId },
+ frameRate: { ideal: 30 },
+ width: { ideal: 1920 },
+ height: { ideal: 1080 },
+ },
+ });
+ cameraStreamRef.current = videoStream;
+ firstTrack = videoStream.getVideoTracks()[0] ?? null;
+ } else {
+ const desiredSurface =
+ RECORDING_MODE_TO_DISPLAY_SURFACE[
+ recordingMode as DetectedDisplayRecordingMode
+ ];
+ const videoConstraints: MediaTrackConstraints & {
+ displaySurface?: DisplaySurfacePreference;
+ } = {
+ ...DISPLAY_MEDIA_VIDEO_CONSTRAINTS,
+ displaySurface: desiredSurface,
+ };
+
+ const baseDisplayRequest: ExtendedDisplayMediaStreamOptions = {
+ video: videoConstraints,
+ audio: false,
+ preferCurrentTab: recordingMode === "tab",
+ };
+
+ const preferredOptions = DISPLAY_MODE_PREFERENCES[recordingMode];
+
+ if (preferredOptions) {
+ const preferredDisplayRequest: DisplayMediaStreamOptions = {
+ ...baseDisplayRequest,
+ ...preferredOptions,
+ video: videoConstraints,
+ };
+
+ try {
+ videoStream = await navigator.mediaDevices.getDisplayMedia(
+ preferredDisplayRequest,
+ );
+ } catch (displayError) {
+ if (shouldRetryDisplayMediaWithoutPreferences(displayError)) {
+ console.warn(
+ "Display media preferences not supported, retrying without them",
+ displayError,
+ );
+ videoStream =
+ await navigator.mediaDevices.getDisplayMedia(
+ baseDisplayRequest,
+ );
+ } else {
+ throw displayError;
+ }
+ }
+ }
+
+ if (!videoStream) {
+ videoStream =
+ await navigator.mediaDevices.getDisplayMedia(baseDisplayRequest);
+ }
+ displayStreamRef.current = videoStream;
+ firstTrack = videoStream.getVideoTracks()[0] ?? null;
+ }
+
+ const settings = firstTrack?.getSettings();
+
+ if (recordingMode !== "camera") {
+ scheduleSurfaceDetection(firstTrack, settings);
+ }
+
+ dimensionsRef.current = {
+ width: settings?.width || undefined,
+ height: settings?.height || undefined,
+ };
+
+ let micStream: MediaStream | null = null;
+ if (micEnabled && selectedMicId) {
+ try {
+ micStream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ deviceId: { exact: selectedMicId },
+ echoCancellation: true,
+ autoGainControl: true,
+ noiseSuppression: true,
+ },
+ });
+ } catch (micError) {
+ console.warn("Microphone permission denied", micError);
+ toast.warning("Microphone unavailable. Recording without audio.");
+ micStream = null;
+ }
+ }
+
+ if (micStream) {
+ micStreamRef.current = micStream;
+ }
+
+ const mixedStream = new MediaStream([
+ ...videoStream.getVideoTracks(),
+ ...(micStream ? micStream.getAudioTracks() : []),
+ ]);
+
+ mixedStreamRef.current = mixedStream;
+ const hasAudio = mixedStream.getAudioTracks().length > 0;
+ setHasAudioTrack(hasAudio);
+
+ recordedChunksRef.current = [];
+ totalRecordedBytesRef.current = 0;
+ instantUploaderRef.current = null;
+ instantMp4ActiveRef.current = false;
+
+ const mp4Candidates = hasAudio
+ ? [...MP4_MIME_TYPES.withAudio, ...MP4_MIME_TYPES.videoOnly]
+ : [...MP4_MIME_TYPES.videoOnly, ...MP4_MIME_TYPES.withAudio];
+ const supportedMp4MimeType = pickSupportedMimeType(mp4Candidates);
+ const webmCandidates = hasAudio
+ ? [...WEBM_MIME_TYPES.withAudio, ...WEBM_MIME_TYPES.videoOnly]
+ : [...WEBM_MIME_TYPES.videoOnly, ...WEBM_MIME_TYPES.withAudio];
+ const fallbackMimeType = pickSupportedMimeType(webmCandidates);
+ const mimeType = supportedMp4MimeType ?? fallbackMimeType;
+ const useInstantMp4 = Boolean(supportedMp4MimeType);
+ instantMp4ActiveRef.current = useInstantMp4;
+ const shouldReuseInstantVideo = Boolean(
+ options?.reuseInstantVideo && videoCreationRef.current,
+ );
+
+ if (useInstantMp4) {
+ let creationResult = videoCreationRef.current;
+ const width = dimensionsRef.current.width;
+ const height = dimensionsRef.current.height;
+ const resolution = width && height ? `${width}x${height}` : undefined;
+ if (!shouldReuseInstantVideo || !creationResult) {
+ const creation = unwrapExitOrThrow(
+ await videoInstantCreate.mutateAsync({
+ orgId: Organisation.OrganisationId.make(organisationId),
+ folderId: Option.none(),
+ resolution,
+ width,
+ height,
+ videoCodec: "h264",
+ audioCodec: hasAudio ? "aac" : undefined,
+ supportsUploadProgress: true,
+ }),
+ );
+ creationResult = {
+ id: creation.id,
+ upload: creation.upload,
+ shareUrl: creation.shareUrl,
+ };
+ videoCreationRef.current = creationResult;
+ }
+ if (creationResult) {
+ setVideoId(creationResult.id);
+ pendingInstantVideoIdRef.current = creationResult.id;
+ }
+
+ let uploadId: string | null = null;
+ try {
+ if (!creationResult)
+ throw new Error("Missing instant recording context");
+ uploadId = await initiateMultipartUpload(creationResult.id);
+ } catch (initError) {
+ const orphanId = creationResult?.id;
+ if (orphanId) {
+ await deleteVideo.mutateAsync(orphanId);
+ }
+ pendingInstantVideoIdRef.current = null;
+ videoCreationRef.current = null;
+ throw initError;
+ }
+
+ if (!creationResult) {
+ throw new Error("Instant recording metadata missing");
+ }
+ instantUploaderRef.current = new InstantMp4Uploader({
+ videoId: creationResult.id,
+ uploadId,
+ mimeType: supportedMp4MimeType ?? "",
+ setUploadStatus,
+ sendProgressUpdate: (uploaded, total) =>
+ sendProgressUpdate(creationResult.id, uploaded, total),
+ onChunkStateChange: setChunkUploads,
+ });
+ } else {
+ if (!shouldReuseInstantVideo) {
+ videoCreationRef.current = null;
+ pendingInstantVideoIdRef.current = null;
+ }
+ }
+
+ const recorder = new MediaRecorder(
+ mixedStream,
+ mimeType ? { mimeType } : undefined,
+ );
+ recorder.ondataavailable = handleRecorderDataAvailable;
+ recorder.onstop = onRecorderStop;
+ recorder.onerror = onRecorderError;
+
+ const handleVideoEnded = () => {
+ stopRecordingRef.current?.().catch(() => {
+ /* ignore */
+ });
+ };
+
+ firstTrack?.addEventListener("ended", handleVideoEnded, { once: true });
+
+ mediaRecorderRef.current = recorder;
+ instantChunkModeRef.current = null;
+ lastInstantChunkAtRef.current = null;
+ clearInstantChunkGuard();
+ stopInstantChunkInterval();
+ if (useInstantMp4) {
+ let startedWithTimeslice = false;
+ try {
+ recorder.start(INSTANT_UPLOAD_REQUEST_INTERVAL_MS);
+ instantChunkModeRef.current = "timeslice";
+ startedWithTimeslice = true;
+ } catch (startError) {
+ console.warn(
+ "Failed to start recorder with timeslice chunks, falling back to manual flush",
+ startError,
+ );
+ }
+
+ if (startedWithTimeslice) {
+ scheduleInstantChunkGuard();
+ } else {
+ recorder.start();
+ beginManualInstantChunking();
+ }
+ } else {
+ recorder.start(200);
+ }
+ onRecordingStart?.();
+
+ startTimer();
+ updatePhase("recording");
+ } catch (err) {
+ const orphanVideoId =
+ instantMp4ActiveRef.current && videoCreationRef.current?.id
+ ? videoCreationRef.current.id
+ : null;
+ if (orphanVideoId) {
+ instantUploaderRef.current = null;
+ instantMp4ActiveRef.current = false;
+ videoCreationRef.current = null;
+ pendingInstantVideoIdRef.current = null;
+ await deleteVideo.mutateAsync(orphanVideoId);
+ }
+
+ console.error("Failed to start recording", err);
+ toast.error("Could not start recording.");
+ resetState();
+ } finally {
+ setIsSettingUp(false);
+ }
+ };
+
+ const pauseRecording = useCallback(() => {
+ if (phase !== "recording") return;
+ const recorder = mediaRecorderRef.current;
+ if (!recorder || recorder.state !== "recording") return;
+
+ try {
+ const timestamp = performance.now();
+ recorder.pause();
+ pauseTimer(timestamp);
+ updatePhase("paused");
+ } catch (error) {
+ console.error("Failed to pause recording", error);
+ toast.error("Could not pause recording.");
+ }
+ }, [phase, pauseTimer, updatePhase, mediaRecorderRef]);
+
+ const resumeRecording = useCallback(() => {
+ if (phase !== "paused") return;
+ const recorder = mediaRecorderRef.current;
+ if (!recorder || recorder.state !== "paused") return;
+
+ try {
+ const timestamp = performance.now();
+ resumeTimer(timestamp);
+ recorder.resume();
+ if (instantMp4ActiveRef.current) {
+ startInstantChunkInterval();
+ }
+ updatePhase("recording");
+ } catch (error) {
+ console.error("Failed to resume recording", error);
+ toast.error("Could not resume recording.");
+ }
+ }, [
+ phase,
+ resumeTimer,
+ updatePhase,
+ mediaRecorderRef,
+ startInstantChunkInterval,
+ ]);
+
+ const stopRecording = useCallback(async () => {
+ stopInstantChunkInterval();
+ clearInstantChunkGuard();
+ instantChunkModeRef.current = null;
+ lastInstantChunkAtRef.current = null;
+ if (phase !== "recording" && phase !== "paused") return;
+
+ const orgId = organisationId;
+ if (!orgId) {
+ updatePhase("error");
+ return;
+ }
+
+ const timestamp = performance.now();
+ commitPausedDuration(timestamp);
+ const recordedDurationMs = syncDurationFromClock(timestamp);
+
+ const brandedOrgId = Organisation.OrganisationId.make(orgId);
+ let thumbnailBlob: Blob | null = null;
+ let thumbnailPreviewUrl: string | undefined;
+ let createdVideoId: VideoId | null = videoCreationRef.current?.id ?? null;
+ const instantUploader = instantUploaderRef.current;
+ const useInstantMp4 = Boolean(instantUploader);
+
+ try {
+ onRecordingStop?.();
+ updatePhase("creating");
+
+ const blob = await stopRecordingInternalWrapper();
+ if (!blob) throw new Error("No recording available");
+
+ const durationSeconds = Math.max(
+ 1,
+ Math.round(recordedDurationMs / 1000),
+ );
+ const width = dimensionsRef.current.width;
+ const height = dimensionsRef.current.height;
+ const resolution = width && height ? `${width}x${height}` : undefined;
+
+ setUploadStatus({ status: "creating" });
+
+ let creationResult = videoCreationRef.current;
+ if (!creationResult) {
+ const result = unwrapExitOrThrow(
+ await videoInstantCreate.mutateAsync({
+ orgId: brandedOrgId,
+ folderId: Option.none(),
+ resolution,
+ durationSeconds,
+ width,
+ height,
+ videoCodec: "h264",
+ audioCodec: hasAudioTrack ? "aac" : undefined,
+ supportsUploadProgress: true,
+ }),
+ );
+ creationResult = {
+ id: result.id,
+ upload: result.upload,
+ shareUrl: result.shareUrl,
+ };
+ videoCreationRef.current = creationResult;
+ setVideoId(result.id);
+ }
+
+ createdVideoId = creationResult.id;
+
+ let mp4Blob: Blob;
+ if (useInstantMp4) {
+ mp4Blob =
+ blob.type === "video/mp4"
+ ? blob
+ : new File([blob], "result.mp4", { type: "video/mp4" });
+ } else {
+ mp4Blob = await convertToMp4(
+ blob,
+ hasAudioTrack,
+ creationResult.id,
+ setUploadStatus,
+ updatePhase,
+ );
+ }
+
+ thumbnailBlob = await captureThumbnail(mp4Blob, dimensionsRef.current);
+ thumbnailPreviewUrl = thumbnailBlob
+ ? URL.createObjectURL(thumbnailBlob)
+ : undefined;
+
+ updatePhase("uploading");
+ setUploadStatus({
+ status: "uploadingVideo",
+ capId: creationResult.id,
+ progress: 0,
+ thumbnailUrl: thumbnailPreviewUrl,
+ });
+
+ if (useInstantMp4 && instantUploader) {
+ instantUploader.setThumbnailUrl(thumbnailPreviewUrl);
+ await instantUploader.finalize({
+ finalBlob: mp4Blob,
+ durationSeconds,
+ width,
+ height,
+ thumbnailUrl: thumbnailPreviewUrl,
+ });
+ instantUploaderRef.current = null;
+ instantMp4ActiveRef.current = false;
+ } else {
+ await uploadRecording(
+ mp4Blob,
+ creationResult.upload,
+ creationResult.id,
+ thumbnailPreviewUrl,
+ setUploadStatus,
+ );
+ }
+
+ pendingInstantVideoIdRef.current = null;
+
+ if (thumbnailBlob) {
+ try {
+ const screenshotData = await createVideoAndGetUploadUrl({
+ videoId: creationResult.id,
+ isScreenshot: true,
+ orgId: brandedOrgId,
+ });
+
+ const screenshotFormData = new FormData();
+ Object.entries(screenshotData.presignedPostData.fields).forEach(
+ ([key, value]) => {
+ screenshotFormData.append(key, value as string);
+ },
+ );
+ screenshotFormData.append(
+ "file",
+ thumbnailBlob,
+ "screen-capture.jpg",
+ );
+
+ setUploadStatus({
+ status: "uploadingThumbnail",
+ capId: creationResult.id,
+ progress: 90,
+ });
+
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", screenshotData.presignedPostData.url);
+
+ xhr.upload.onprogress = (event) => {
+ if (event.lengthComputable) {
+ const percent = 90 + (event.loaded / event.total) * 10;
+ setUploadStatus({
+ status: "uploadingThumbnail",
+ capId: creationResult.id,
+ progress: percent,
+ });
+ }
+ };
+
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Screenshot upload failed with status ${xhr.status}`,
+ ),
+ );
+ }
+ };
+
+ xhr.onerror = () => {
+ reject(new Error("Screenshot upload failed"));
+ };
+
+ xhr.send(screenshotFormData);
+ });
+
+ queryClient.refetchQueries({
+ queryKey: ThumbnailRequest.queryKey(creationResult.id),
+ });
+ } catch (thumbnailError) {
+ console.error("Failed to upload thumbnail", thumbnailError);
+ toast.warning("Recording uploaded, but thumbnail failed to upload.");
+ }
+ }
+
+ setUploadStatus(undefined);
+ updatePhase("completed");
+ toast.success("Recording uploaded");
+ if (creationResult.shareUrl) {
+ window.open(creationResult.shareUrl, "_blank", "noopener,noreferrer");
+ }
+ router.refresh();
+ } catch (err) {
+ console.error("Failed to process recording", err);
+ setUploadStatus(undefined);
+ updatePhase("error");
+
+ const idToDelete = createdVideoId ?? videoId;
+ if (idToDelete) {
+ await deleteVideo.mutateAsync(idToDelete);
+ if (pendingInstantVideoIdRef.current === idToDelete) {
+ pendingInstantVideoIdRef.current = null;
+ }
+ }
+ } finally {
+ if (thumbnailPreviewUrl) {
+ URL.revokeObjectURL(thumbnailPreviewUrl);
+ }
+ }
+ }, [
+ stopInstantChunkInterval,
+ phase,
+ organisationId,
+ hasAudioTrack,
+ videoId,
+ updatePhase,
+ setUploadStatus,
+ deleteVideo,
+ videoInstantCreate,
+ router,
+ stopRecordingInternalWrapper,
+ queryClient,
+ onRecordingStop,
+ commitPausedDuration,
+ syncDurationFromClock,
+ ]);
+
+ useEffect(() => {
+ stopRecordingRef.current = stopRecording;
+ }, [stopRecording]);
+
+ useEffect(() => {
+ if (!isFreePlan) {
+ freePlanAutoStopTriggeredRef.current = false;
+ return;
+ }
+
+ const isRecordingPhase = phase === "recording" || phase === "paused";
+ if (!isRecordingPhase) {
+ freePlanAutoStopTriggeredRef.current = false;
+ return;
+ }
+
+ if (
+ durationMs >= FREE_PLAN_MAX_RECORDING_MS &&
+ !freePlanAutoStopTriggeredRef.current
+ ) {
+ freePlanAutoStopTriggeredRef.current = true;
+ toast.info(
+ "Free plan recordings are limited to 5 minutes. Recording stopped automatically.",
+ );
+ stopRecording().catch((error) => {
+ console.error("Failed to stop recording at free plan limit", error);
+ });
+ }
+ }, [durationMs, isFreePlan, phase, stopRecording]);
+
+ const restartRecording = useCallback(async () => {
+ if (isRestarting) return;
+ if (phase !== "recording" && phase !== "paused") return;
+
+ const creationToReuse = videoCreationRef.current;
+ const shouldReuseInstantVideo = Boolean(creationToReuse);
+ setIsRestarting(true);
+
+ try {
+ try {
+ await stopRecordingInternalWrapper();
+ } catch (error) {
+ console.warn("Failed to stop recorder before restart", error);
+ }
+
+ cleanupRecordingState({ preserveInstantVideo: shouldReuseInstantVideo });
+ updatePhase("idle");
+
+ if (shouldReuseInstantVideo && creationToReuse) {
+ await deleteVideoResultFile({ videoId: creationToReuse.id });
+ }
+
+ await startRecording({ reuseInstantVideo: shouldReuseInstantVideo });
+ } catch (error) {
+ console.error("Failed to restart recording", error);
+ toast.error("Could not restart recording. Please try again.");
+ cleanupRecordingState();
+ updatePhase("idle");
+ } finally {
+ setIsRestarting(false);
+ }
+ }, [
+ cleanupRecordingState,
+ isRestarting,
+ phase,
+ startRecording,
+ stopRecordingInternalWrapper,
+ updatePhase,
+ ]);
+
+ const canStartRecording =
+ Boolean(organisationId) &&
+ !isSettingUp &&
+ !isRestarting &&
+ isBrowserSupported;
+ const isPaused = phase === "paused";
+ const isRecordingActive = phase === "recording" || isPaused;
+ const isBusyPhase =
+ phase === "recording" ||
+ phase === "paused" ||
+ phase === "creating" ||
+ phase === "converting" ||
+ phase === "uploading";
+ const isBusyState = isBusyPhase || isRestarting;
+
+ return {
+ phase,
+ durationMs,
+ videoId,
+ hasAudioTrack,
+ chunkUploads,
+ isSettingUp,
+ isRecording: isRecordingActive,
+ isPaused,
+ isBusy: isBusyState,
+ canStartRecording,
+ startRecording,
+ pauseRecording,
+ resumeRecording,
+ stopRecording,
+ restartRecording,
+ resetState,
+ isRestarting,
+ isBrowserSupported,
+ unsupportedReason,
+ supportsDisplayRecording,
+ supportCheckCompleted,
+ screenCaptureWarning,
+ };
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-constants.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-constants.ts
new file mode 100644
index 0000000000..be9a36c6f2
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-constants.ts
@@ -0,0 +1,121 @@
+export const NO_MICROPHONE = "No Microphone";
+export const NO_MICROPHONE_VALUE = "__no_microphone__";
+export const NO_CAMERA = "No Camera";
+export const NO_CAMERA_VALUE = "__no_camera__";
+
+export const dialogVariants = {
+ hidden: {
+ opacity: 0,
+ scale: 0.9,
+ y: 20,
+ },
+ visible: {
+ opacity: 1,
+ scale: 1,
+ y: 0,
+ transition: {
+ type: "spring",
+ duration: 0.4,
+ damping: 25,
+ stiffness: 500,
+ },
+ },
+ exit: {
+ opacity: 0,
+ scale: 0.95,
+ y: 10,
+ transition: {
+ duration: 0.2,
+ },
+ },
+};
+
+export const DISPLAY_MEDIA_VIDEO_CONSTRAINTS: MediaTrackConstraints = {
+ frameRate: { ideal: 30 },
+ width: { ideal: 1920 },
+ height: { ideal: 1080 },
+};
+
+export type ExtendedDisplayMediaStreamOptions = DisplayMediaStreamOptions & {
+ monitorTypeSurfaces?: "include" | "exclude";
+ surfaceSwitching?: "include" | "exclude";
+ selfBrowserSurface?: "include" | "exclude";
+ preferCurrentTab?: boolean;
+};
+
+export type DetectedDisplayRecordingMode = Exclude<
+ import("./RecordingModeSelector").RecordingMode,
+ "camera"
+>;
+
+export type DisplaySurfacePreference =
+ | "monitor"
+ | "window"
+ | "browser"
+ | "application";
+
+export const DISPLAY_MODE_PREFERENCES: Record<
+ DetectedDisplayRecordingMode,
+ Partial
+> = {
+ fullscreen: {
+ monitorTypeSurfaces: "include",
+ selfBrowserSurface: "exclude",
+ surfaceSwitching: "exclude",
+ preferCurrentTab: false,
+ },
+ window: {
+ monitorTypeSurfaces: "exclude",
+ selfBrowserSurface: "exclude",
+ surfaceSwitching: "exclude",
+ preferCurrentTab: false,
+ },
+ tab: {
+ monitorTypeSurfaces: "exclude",
+ selfBrowserSurface: "include",
+ surfaceSwitching: "exclude",
+ preferCurrentTab: true,
+ },
+};
+
+export const DISPLAY_SURFACE_TO_RECORDING_MODE: Record<
+ string,
+ DetectedDisplayRecordingMode
+> = {
+ monitor: "fullscreen",
+ screen: "fullscreen",
+ window: "window",
+ application: "window",
+ browser: "tab",
+ tab: "tab",
+};
+
+export const RECORDING_MODE_TO_DISPLAY_SURFACE: Record<
+ DetectedDisplayRecordingMode,
+ DisplaySurfacePreference
+> = {
+ fullscreen: "monitor",
+ window: "window",
+ tab: "browser",
+};
+
+export const MP4_MIME_TYPES = {
+ withAudio: [
+ 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
+ 'video/mp4;codecs="avc1.4d401e,mp4a.40.2"',
+ ],
+ videoOnly: [
+ 'video/mp4;codecs="avc1.42E01E"',
+ 'video/mp4;codecs="avc1.4d401e"',
+ "video/mp4",
+ ],
+} as const;
+
+export const WEBM_MIME_TYPES = {
+ withAudio: ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus"],
+ videoOnly: ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"],
+} as const;
+
+export const DETECTION_RETRY_DELAYS = [120, 450, 1000];
+
+export const FREE_PLAN_MAX_RECORDING_MS = 5 * 60 * 1000;
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog-header.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog-header.tsx
new file mode 100644
index 0000000000..984c626d6c
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog-header.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import clsx from "clsx";
+import { useDashboardContext } from "../../../Contexts";
+
+interface WebRecorderDialogHeaderProps {
+ isBusy: boolean;
+ onClose: () => void;
+}
+
+export const WebRecorderDialogHeader = ({
+ isBusy,
+ onClose,
+}: WebRecorderDialogHeaderProps) => {
+ const { user, setUpgradeModalOpen } = useDashboardContext();
+ const planLabel = user.isPro ? "Pro" : "Free";
+ const planClassName = clsx(
+ "ml-2 inline-flex items-center rounded-full px-2 text-[0.7rem] font-medium transition-colors",
+ user.isPro
+ ? "bg-blue-9 text-gray-1"
+ : "cursor-pointer bg-gray-3 text-gray-12 hover:bg-gray-4",
+ );
+
+ return (
+ <>
+
+
+
+
+
{
+ if (!user.isPro) setUpgradeModalOpen(true);
+ }}
+ className={planClassName}
+ >
+ {planLabel}
+
+
+
+ >
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog.tsx
new file mode 100644
index 0000000000..ab593e4d0f
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog.tsx
@@ -0,0 +1,356 @@
+"use client";
+
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ DialogTrigger,
+} from "@cap/ui";
+import { AnimatePresence, motion } from "framer-motion";
+import { MonitorIcon } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { useDashboardContext } from "../../../Contexts";
+import { CameraPreviewWindow } from "./CameraPreviewWindow";
+import { CameraSelector } from "./CameraSelector";
+import { HowItWorksButton } from "./HowItWorksButton";
+import { HowItWorksPanel } from "./HowItWorksPanel";
+import { InProgressRecordingBar } from "./InProgressRecordingBar";
+import { MicrophoneSelector } from "./MicrophoneSelector";
+import { RecordingButton } from "./RecordingButton";
+import {
+ type RecordingMode,
+ RecordingModeSelector,
+} from "./RecordingModeSelector";
+import { SettingsButton } from "./SettingsButton";
+import { SettingsPanel } from "./SettingsPanel";
+import { useCameraDevices } from "./useCameraDevices";
+import { useDevicePreferences } from "./useDevicePreferences";
+import { useDialogInteractions } from "./useDialogInteractions";
+import { useMicrophoneDevices } from "./useMicrophoneDevices";
+import { useWebRecorder } from "./useWebRecorder";
+import {
+ dialogVariants,
+ FREE_PLAN_MAX_RECORDING_MS,
+} from "./web-recorder-constants";
+import { WebRecorderDialogHeader } from "./web-recorder-dialog-header";
+
+export const WebRecorderDialog = () => {
+ const [open, setOpen] = useState(false);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [howItWorksOpen, setHowItWorksOpen] = useState(false);
+ const [recordingMode, setRecordingMode] =
+ useState("fullscreen");
+ const [cameraSelectOpen, setCameraSelectOpen] = useState(false);
+ const [micSelectOpen, setMicSelectOpen] = useState(false);
+ const dialogContentRef = useRef(null);
+ const startSoundRef = useRef(null);
+ const stopSoundRef = useRef(null);
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const startSound = new Audio("/sounds/start-recording.ogg");
+ startSound.preload = "auto";
+ const stopSound = new Audio("/sounds/stop-recording.ogg");
+ stopSound.preload = "auto";
+
+ startSoundRef.current = startSound;
+ stopSoundRef.current = stopSound;
+
+ return () => {
+ startSound.pause();
+ stopSound.pause();
+ startSoundRef.current = null;
+ stopSoundRef.current = null;
+ };
+ }, []);
+
+ const playAudio = useCallback((audio: HTMLAudioElement | null) => {
+ if (!audio) {
+ return;
+ }
+ audio.currentTime = 0;
+ void audio.play().catch(() => {
+ /* ignore */
+ });
+ }, []);
+
+ const handleRecordingStartSound = useCallback(() => {
+ playAudio(startSoundRef.current);
+ }, [playAudio]);
+
+ const handleRecordingStopSound = useCallback(() => {
+ playAudio(stopSoundRef.current);
+ }, [playAudio]);
+
+ const { activeOrganization, user } = useDashboardContext();
+ const organisationId = activeOrganization?.organization.id;
+ const { devices: availableMics, refresh: refreshMics } =
+ useMicrophoneDevices(open);
+ const { devices: availableCameras, refresh: refreshCameras } =
+ useCameraDevices(open);
+
+ const {
+ rememberDevices,
+ selectedCameraId,
+ selectedMicId,
+ setSelectedCameraId,
+ handleCameraChange,
+ handleMicChange,
+ handleRememberDevicesChange,
+ } = useDevicePreferences({
+ open,
+ availableCameras,
+ availableMics,
+ });
+
+ const micEnabled = selectedMicId !== null;
+
+ useEffect(() => {
+ if (
+ recordingMode === "camera" &&
+ !selectedCameraId &&
+ availableCameras.length > 0
+ ) {
+ setSelectedCameraId(availableCameras[0]?.deviceId ?? null);
+ }
+ }, [recordingMode, selectedCameraId, availableCameras, setSelectedCameraId]);
+
+ const {
+ phase,
+ durationMs,
+ hasAudioTrack,
+ chunkUploads,
+ isRecording,
+ isBusy,
+ isRestarting,
+ canStartRecording,
+ isBrowserSupported,
+ unsupportedReason,
+ supportsDisplayRecording,
+ supportCheckCompleted,
+ screenCaptureWarning,
+ startRecording,
+ pauseRecording,
+ resumeRecording,
+ stopRecording,
+ restartRecording,
+ resetState,
+ } = useWebRecorder({
+ organisationId,
+ selectedMicId,
+ micEnabled,
+ recordingMode,
+ selectedCameraId,
+ isProUser: user.isPro,
+ onRecordingSurfaceDetected: (mode) => {
+ setRecordingMode(mode);
+ },
+ onRecordingStart: handleRecordingStartSound,
+ onRecordingStop: handleRecordingStopSound,
+ });
+
+ useEffect(() => {
+ if (
+ !supportCheckCompleted ||
+ supportsDisplayRecording ||
+ recordingMode === "camera"
+ ) {
+ return;
+ }
+
+ setRecordingMode("camera");
+ }, [
+ supportCheckCompleted,
+ supportsDisplayRecording,
+ recordingMode,
+ setRecordingMode,
+ ]);
+
+ const {
+ handlePointerDownOutside,
+ handleFocusOutside,
+ handleInteractOutside,
+ } = useDialogInteractions({
+ dialogContentRef,
+ isRecording,
+ isBusy,
+ });
+
+ const handleOpenChange = (next: boolean) => {
+ if (next && supportCheckCompleted && !isBrowserSupported) {
+ toast.error(
+ "This browser isn't compatible with Cap's web recorder. We recommend Google Chrome or other Chromium-based browsers.",
+ );
+ return;
+ }
+
+ if (!next && isBusy) {
+ toast.info("Keep this dialog open while your upload finishes.");
+ return;
+ }
+
+ if (!next) {
+ resetState();
+ setSelectedCameraId(null);
+ setRecordingMode("fullscreen");
+ setSettingsOpen(false);
+ setHowItWorksOpen(false);
+ }
+ setOpen(next);
+ };
+
+ const handleStopClick = () => {
+ stopRecording().catch((err: unknown) => {
+ console.error("Stop recording error", err);
+ });
+ };
+
+ const handleClose = () => {
+ if (!isBusy) {
+ handleOpenChange(false);
+ }
+ };
+
+ const handleSettingsOpen = () => {
+ setSettingsOpen(true);
+ setHowItWorksOpen(false);
+ };
+
+ const handleHowItWorksOpen = () => {
+ setHowItWorksOpen(true);
+ setSettingsOpen(false);
+ };
+
+ const showInProgressBar = isRecording || isBusy;
+ const recordingTimerDisplayMs = user.isPro
+ ? durationMs
+ : Math.max(0, FREE_PLAN_MAX_RECORDING_MS - durationMs);
+
+ return (
+ <>
+
+ {showInProgressBar && (
+
+ )}
+ {selectedCameraId && (
+ handleCameraChange(null)}
+ />
+ )}
+ >
+ );
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types.ts
new file mode 100644
index 0000000000..6b1e76a694
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types.ts
@@ -0,0 +1,23 @@
+export type RecorderPhase =
+ | "idle"
+ | "recording"
+ | "paused"
+ | "creating"
+ | "converting"
+ | "uploading"
+ | "completed"
+ | "error";
+
+export type RecorderErrorEvent = Event & { error?: DOMException };
+
+type VideoNamespace = typeof import("@cap/web-domain").Video;
+export type PresignedPost = VideoNamespace["PresignedPost"]["Type"];
+export type VideoId = VideoNamespace["VideoId"]["Type"];
+
+export type ChunkUploadState = {
+ partNumber: number;
+ sizeBytes: number;
+ uploadedBytes: number;
+ progress: number; // 0-1 ratio for the chunk itself
+ status: "queued" | "uploading" | "complete" | "error";
+};
diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-utils.ts b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-utils.ts
new file mode 100644
index 0000000000..7074c9370e
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-utils.ts
@@ -0,0 +1,94 @@
+import type { RecordingMode } from "./RecordingModeSelector";
+import {
+ type DetectedDisplayRecordingMode,
+ DISPLAY_SURFACE_TO_RECORDING_MODE,
+} from "./web-recorder-constants";
+
+export type { DetectedDisplayRecordingMode } from "./web-recorder-constants";
+
+export type RecorderCapabilities = {
+ assessed: boolean;
+ hasMediaRecorder: boolean;
+ hasUserMedia: boolean;
+ hasDisplayMedia: boolean;
+};
+
+export const detectCapabilities = (): RecorderCapabilities => {
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
+ return {
+ assessed: false,
+ hasMediaRecorder: false,
+ hasUserMedia: false,
+ hasDisplayMedia: false,
+ };
+ }
+
+ const mediaDevices = navigator.mediaDevices;
+
+ return {
+ assessed: true,
+ hasMediaRecorder: typeof MediaRecorder !== "undefined",
+ hasUserMedia: typeof mediaDevices?.getUserMedia === "function",
+ hasDisplayMedia: typeof mediaDevices?.getDisplayMedia === "function",
+ };
+};
+
+export const detectRecordingModeFromTrack = (
+ track: MediaStreamTrack | null,
+ settings?: MediaTrackSettings,
+): DetectedDisplayRecordingMode | null => {
+ if (!track) return null;
+
+ const trackSettings = settings ?? track.getSettings();
+ const maybeDisplaySurface = (
+ trackSettings as Partial<{ displaySurface?: unknown }>
+ ).displaySurface;
+ const rawSurface =
+ typeof maybeDisplaySurface === "string" ? maybeDisplaySurface : "";
+ const normalizedSurface = rawSurface.toLowerCase();
+
+ if (normalizedSurface) {
+ const mapped = DISPLAY_SURFACE_TO_RECORDING_MODE[normalizedSurface];
+ if (mapped) {
+ return mapped;
+ }
+ }
+
+ const label = track.label?.toLowerCase() ?? "";
+
+ if (
+ label.includes("screen") ||
+ label.includes("display") ||
+ label.includes("monitor")
+ ) {
+ return "fullscreen";
+ }
+
+ if (label.includes("window") || label.includes("application")) {
+ return "window";
+ }
+
+ if (label.includes("tab") || label.includes("browser")) {
+ return "tab";
+ }
+
+ return null;
+};
+
+export const pickSupportedMimeType = (candidates: readonly string[]) => {
+ if (typeof MediaRecorder === "undefined") return undefined;
+ return candidates.find((candidate) =>
+ MediaRecorder.isTypeSupported(candidate),
+ );
+};
+
+export const shouldRetryDisplayMediaWithoutPreferences = (error: unknown) => {
+ if (error instanceof DOMException) {
+ return (
+ error.name === "OverconstrainedError" ||
+ error.name === "NotSupportedError"
+ );
+ }
+
+ return error instanceof TypeError;
+};
diff --git a/apps/web/app/api/erpc/route.ts b/apps/web/app/api/erpc/route.ts
index 63ab0463c6..89ec4aa760 100644
--- a/apps/web/app/api/erpc/route.ts
+++ b/apps/web/app/api/erpc/route.ts
@@ -5,13 +5,15 @@ import { RpcSerialization, RpcServer } from "@effect/rpc";
import { Layer } from "effect";
import { Dependencies } from "@/lib/server";
+const rpcLayer = Layer.mergeAll(
+ RpcAuthMiddlewareLive,
+ RpcsLive,
+ RpcSerialization.layerJson,
+ HttpServer.layerContext,
+);
+
const { handler } = RpcServer.toWebHandler(Rpcs, {
- layer: Layer.mergeAll(
- RpcAuthMiddlewareLive,
- RpcsLive,
- RpcSerialization.layerJson,
- HttpServer.layerContext,
- ).pipe(Layer.provideMerge(Dependencies)),
+ layer: Layer.provide(Dependencies)(rpcLayer),
});
export const GET = (r: Request) => handler(r);
diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts
index 7bf28deb68..7636468546 100644
--- a/apps/web/app/api/playlist/route.ts
+++ b/apps/web/app/api/playlist/route.ts
@@ -82,11 +82,13 @@ const getPlaylistResponse = (
) =>
Effect.gen(function* () {
const [s3, customBucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
+ const isMp4Source =
+ video.source.type === "desktopMP4" || video.source.type === "webMP4";
if (Option.isNone(customBucket)) {
let redirect = `${video.ownerId}/${video.id}/combined-source/stream.m3u8`;
- if (video.source.type === "desktopMP4" || urlParams.videoType === "mp4")
+ if (isMp4Source || urlParams.videoType === "mp4")
redirect = `${video.ownerId}/${video.id}/result.mp4`;
else if (video.source.type === "MediaConvert")
redirect = `${video.ownerId}/${video.id}/output/video_recording_000.m3u8`;
@@ -147,7 +149,7 @@ const getPlaylistResponse = (
return HttpServerResponse.text(playlist, {
headers: CACHE_CONTROL_HEADERS,
});
- } else if (video.source.type === "desktopMP4") {
+ } else if (isMp4Source) {
yield* Effect.log(
`Returning path ${`${video.ownerId}/${video.id}/result.mp4`}`,
);
diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts
index 1cbeb34e43..23dac0c22d 100644
--- a/apps/web/app/api/upload/[...route]/multipart.ts
+++ b/apps/web/app/api/upload/[...route]/multipart.ts
@@ -20,7 +20,7 @@ import { CurrentUser, Policy, Video } from "@cap/web-domain";
import { zValidator } from "@hono/zod-validator";
import { and, eq } from "drizzle-orm";
import { Effect, Option, Schedule } from "effect";
-import { Hono } from "hono";
+import { Hono, type MiddlewareHandler } from "hono";
import { z } from "zod";
import { withAuth } from "@/app/api/utils";
import { runPromise } from "@/lib/server";
@@ -29,6 +29,34 @@ import { parseVideoIdOrFileKey } from "../utils";
export const app = new Hono().use(withAuth);
+const runPromiseAnyEnv = runPromise as (
+ effect: Effect.Effect,
+) => Promise;
+
+const abortRequestSchema = z
+ .object({
+ uploadId: z.string(),
+ })
+ .and(
+ z.union([
+ z.object({ videoId: z.string() }),
+ // deprecated
+ z.object({ fileKey: z.string() }),
+ ]),
+ );
+
+type AbortRequestInput = z.input;
+
+type AbortValidatorInput = {
+ in: { json: AbortRequestInput };
+ out: { json: z.output };
+};
+
+const abortRequestValidator = zValidator(
+ "json",
+ abortRequestSchema,
+) as MiddlewareHandler;
+
app.post(
"/initiate",
zValidator(
@@ -82,7 +110,8 @@ app.post(
);
}),
Effect.provide(makeCurrentUserLayer(user)),
- runPromise,
+ provideOptionalAuth,
+ runPromiseAnyEnv,
);
if (resp) return resp;
@@ -117,7 +146,7 @@ app.post(
);
return UploadId;
- }).pipe(runPromise);
+ }).pipe(provideOptionalAuth, runPromiseAnyEnv);
return c.json({ uploadId: uploadId });
} catch (s3Error) {
@@ -187,7 +216,7 @@ app.post(
);
return presignedUrl;
- }).pipe(runPromise);
+ }).pipe(provideOptionalAuth, runPromiseAnyEnv);
return c.json({ presignedUrl });
} catch (s3Error) {
@@ -479,6 +508,74 @@ app.post(
);
}),
);
- }).pipe(Effect.provide(makeCurrentUserLayer(user)), runPromise);
+ }).pipe(
+ Effect.provide(makeCurrentUserLayer(user)),
+ provideOptionalAuth,
+ runPromiseAnyEnv,
+ );
},
);
+
+app.post("/abort", abortRequestValidator, (c) => {
+ const { uploadId, ...body } = c.req.valid("json");
+ const user = c.get("user");
+
+ const fileKey = parseVideoIdOrFileKey(user.id, {
+ ...body,
+ subpath: "result.mp4",
+ });
+
+ const videoIdFromFileKey = fileKey.split("/")[1];
+ const videoIdRaw = "videoId" in body ? body.videoId : videoIdFromFileKey;
+ if (!videoIdRaw) return c.text("Video id not found", 400);
+ const videoId = Video.VideoId.make(videoIdRaw);
+
+ return Effect.gen(function* () {
+ const repo = yield* VideosRepo;
+ const policy = yield* VideosPolicy;
+ const db = yield* Database;
+
+ const maybeVideo = yield* repo
+ .getById(videoId)
+ .pipe(Policy.withPolicy(policy.isOwner(videoId)));
+ if (Option.isNone(maybeVideo)) {
+ c.status(404);
+ return c.text(`Video '${encodeURIComponent(videoId)}' not found`);
+ }
+ const [video] = maybeVideo.value;
+
+ const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
+ type MultipartWithAbort = typeof bucket.multipart & {
+ abort: (
+ ...args: Parameters
+ ) => ReturnType;
+ };
+ const multipart = bucket.multipart as MultipartWithAbort;
+
+ console.log(`Aborting multipart upload ${uploadId} for key: ${fileKey}`);
+ yield* multipart.abort(fileKey, uploadId);
+
+ yield* db.use((db) =>
+ db.delete(Db.videoUploads).where(eq(Db.videoUploads.videoId, videoId)),
+ );
+
+ return c.json({ success: true, fileKey, uploadId });
+ }).pipe(
+ Effect.catchAll((error) => {
+ console.error("Failed to abort multipart upload:", error);
+
+ return Effect.succeed(
+ c.json(
+ {
+ error: "Failed to abort multipart upload",
+ details: error instanceof Error ? error.message : String(error),
+ },
+ 500,
+ ),
+ );
+ }),
+ Effect.provide(makeCurrentUserLayer(user)),
+ provideOptionalAuth,
+ runPromiseAnyEnv,
+ );
+});
diff --git a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx
index d3325d0d65..b6bacce12d 100644
--- a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx
+++ b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx
@@ -148,12 +148,14 @@ export const EmbedVideo = forwardRef<
}
}, [chapters]);
+ const isMp4Source =
+ data.source.type === "desktopMP4" || data.source.type === "webMP4";
let videoSrc: string;
let enableCrossOrigin = false;
- if (data.source.type === "desktopMP4") {
+ if (isMp4Source) {
videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=mp4`;
- // Start with CORS enabled for desktopMP4, but CapVideoPlayer will dynamically disable if needed
+ // Start with CORS enabled for MP4 sources, CapVideoPlayer will disable if needed
enableCrossOrigin = true;
} else if (
NODE_ENV === "development" ||
@@ -195,7 +197,7 @@ export const EmbedVideo = forwardRef<
return (
<>
- {data.source.type === "desktopMP4" ? (
+ {isMp4Source ? (
- {data.source.type === "desktopMP4" ? (
+ {isMp4Source ? (
) {
orgId: videos.orgId,
createdAt: videos.createdAt,
updatedAt: videos.updatedAt,
+ effectiveCreatedAt: videos.effectiveCreatedAt,
bucket: videos.bucket,
metadata: videos.metadata,
public: videos.public,
@@ -458,6 +459,7 @@ async function AuthorizedContent({
name: videos.name,
createdAt: videos.createdAt,
updatedAt: videos.updatedAt,
+ effectiveCreatedAt: videos.effectiveCreatedAt,
bucket: videos.bucket,
metadata: videos.metadata,
public: videos.public,
diff --git a/apps/web/components/ui/popover.tsx b/apps/web/components/ui/popover.tsx
new file mode 100644
index 0000000000..f9c2046ad9
--- /dev/null
+++ b/apps/web/components/ui/popover.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
diff --git a/apps/web/package.json b/apps/web/package.json
index ae311637ca..c5a229d493 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -53,7 +53,9 @@
"@opentelemetry/sdk-trace-node": "^2.1.0",
"@opentelemetry/sdk-trace-web": "^2.1.0",
"@radix-ui/colors": "^3.0.0",
+ "@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
diff --git a/apps/web/public/sounds/start-recording.ogg b/apps/web/public/sounds/start-recording.ogg
new file mode 100644
index 0000000000..3c29375678
Binary files /dev/null and b/apps/web/public/sounds/start-recording.ogg differ
diff --git a/apps/web/public/sounds/stop-recording.ogg b/apps/web/public/sounds/stop-recording.ogg
new file mode 100644
index 0000000000..fb17285302
Binary files /dev/null and b/apps/web/public/sounds/stop-recording.ogg differ
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index df19e0f834..14a2e9685e 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -316,7 +316,10 @@ export const videos = mysqlTable(
>(),
source: json("source")
.$type<
- { type: "MediaConvert" } | { type: "local" } | { type: "desktopMP4" }
+ | { type: "MediaConvert" }
+ | { type: "local" }
+ | { type: "desktopMP4" }
+ | { type: "webMP4" }
>()
.notNull()
.default({ type: "MediaConvert" }),
diff --git a/packages/web-backend/package.json b/packages/web-backend/package.json
index 22b0d0ef47..7da887abb1 100644
--- a/packages/web-backend/package.json
+++ b/packages/web-backend/package.json
@@ -11,6 +11,7 @@
"build": "tsdown"
},
"dependencies": {
+ "@cap/env": "workspace:*",
"@aws-sdk/client-s3": "^3.485.0",
"@aws-sdk/cloudfront-signer": "^3.821.0",
"@aws-sdk/credential-providers": "^3.908.0",
diff --git a/packages/web-backend/src/S3Buckets/S3BucketAccess.ts b/packages/web-backend/src/S3Buckets/S3BucketAccess.ts
index 5338c7dbeb..b37292842e 100644
--- a/packages/web-backend/src/S3Buckets/S3BucketAccess.ts
+++ b/packages/web-backend/src/S3Buckets/S3BucketAccess.ts
@@ -107,7 +107,7 @@ export const createS3BucketAccess = Effect.gen(function* () {
provider.getInternal.pipe(
Effect.flatMap((client) =>
Effect.gen(function* () {
- let _body;
+ let _body: S3.PutObjectCommandInput["Body"];
if (typeof body === "string" || body instanceof Uint8Array) {
_body = body;
@@ -284,6 +284,28 @@ export const createS3BucketAccess = Effect.gen(function* () {
),
),
),
+ abort: (
+ key: string,
+ uploadId: string,
+ args?: Omit<
+ S3.AbortMultipartUploadCommandInput,
+ "Key" | "Bucket" | "UploadId"
+ >,
+ ) =>
+ wrapS3Promise(
+ provider.getInternal.pipe(
+ Effect.map((client) =>
+ client.send(
+ new S3.AbortMultipartUploadCommand({
+ Bucket: provider.bucket,
+ Key: key,
+ UploadId: uploadId,
+ ...args,
+ }),
+ ),
+ ),
+ ),
+ ),
},
};
});
diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts
index 8f843fa20b..dc4aa4de8d 100644
--- a/packages/web-backend/src/Videos/VideosRpcs.ts
+++ b/packages/web-backend/src/Videos/VideosRpcs.ts
@@ -11,47 +11,80 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer(
return {
VideoDelete: (videoId) =>
videos.delete(videoId).pipe(
- Effect.catchTags({
- DatabaseError: () => new InternalError({ type: "database" }),
- S3Error: () => new InternalError({ type: "s3" }),
- }),
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })),
),
VideoDuplicate: (videoId) =>
videos.duplicate(videoId).pipe(
- Effect.catchTags({
- DatabaseError: () => new InternalError({ type: "database" }),
- S3Error: () => new InternalError({ type: "s3" }),
- }),
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })),
),
GetUploadProgress: (videoId) =>
videos.getUploadProgress(videoId).pipe(
provideOptionalAuth,
- Effect.catchTags({
- DatabaseError: () => new InternalError({ type: "database" }),
- UnknownException: () => new InternalError({ type: "unknown" }),
- }),
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag(
+ "UnknownException",
+ () => new InternalError({ type: "unknown" }),
+ ),
),
+ VideoInstantCreate: (input) =>
+ videos.createInstantRecording(input).pipe(
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })),
+ ),
+
+ VideoUploadProgressUpdate: (input) =>
+ videos
+ .updateUploadProgress(input)
+ .pipe(
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ ),
+
VideoGetDownloadInfo: (videoId) =>
videos.getDownloadInfo(videoId).pipe(
provideOptionalAuth,
- Effect.catchTags({
- DatabaseError: () => new InternalError({ type: "database" }),
- UnknownException: () => new InternalError({ type: "unknown" }),
- S3Error: () => new InternalError({ type: "s3" }),
- }),
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag(
+ "UnknownException",
+ () => new InternalError({ type: "unknown" }),
+ ),
+ Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })),
),
VideosGetThumbnails: (videoIds) =>
Effect.all(
videoIds.map((id) =>
videos.getThumbnailURL(id).pipe(
- Effect.catchTags({
- DatabaseError: () => new InternalError({ type: "database" }),
- S3Error: () => new InternalError({ type: "s3" }),
- }),
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag(
+ "S3Error",
+ () => new InternalError({ type: "s3" }),
+ ),
Effect.matchEffect({
onSuccess: (v) => Effect.succeed(Exit.succeed(v)),
onFailure: (e) =>
@@ -65,20 +98,28 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer(
{ concurrency: 10 },
).pipe(
provideOptionalAuth,
- Effect.catchTags({
- DatabaseError: () => new InternalError({ type: "database" }),
- UnknownException: () => new InternalError({ type: "unknown" }),
- }),
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag(
+ "UnknownException",
+ () => new InternalError({ type: "unknown" }),
+ ),
),
VideosGetAnalytics: (videoIds) =>
Effect.all(
videoIds.map((id) =>
videos.getAnalytics(id).pipe(
- Effect.catchTags({
- DatabaseError: () => new InternalError({ type: "database" }),
- UnknownException: () => new InternalError({ type: "unknown" }),
- }),
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag(
+ "UnknownException",
+ () => new InternalError({ type: "unknown" }),
+ ),
Effect.matchEffect({
onSuccess: (v) => Effect.succeed(Exit.succeed(v)),
onFailure: (e) =>
@@ -92,10 +133,14 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer(
{ concurrency: 10 },
).pipe(
provideOptionalAuth,
- Effect.catchTags({
- DatabaseError: () => new InternalError({ type: "database" }),
- UnknownException: () => new InternalError({ type: "unknown" }),
- }),
+ Effect.catchTag(
+ "DatabaseError",
+ () => new InternalError({ type: "database" }),
+ ),
+ Effect.catchTag(
+ "UnknownException",
+ () => new InternalError({ type: "unknown" }),
+ ),
),
};
}),
diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts
index b85a464e7e..49f7587705 100644
--- a/packages/web-backend/src/Videos/index.ts
+++ b/packages/web-backend/src/Videos/index.ts
@@ -1,14 +1,29 @@
import * as Db from "@cap/database/schema";
+import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
import { dub } from "@cap/utils";
-import { CurrentUser, Policy, Video } from "@cap/web-domain";
+import { CurrentUser, type Folder, Policy, Video } from "@cap/web-domain";
import * as Dz from "drizzle-orm";
-import { Array, Effect, Option, pipe } from "effect";
+import { Array, Context, Effect, Option, pipe } from "effect";
+import type { Schema } from "effect/Schema";
import { Database } from "../Database.ts";
import { S3Buckets } from "../S3Buckets/index.ts";
import { VideosPolicy } from "./VideosPolicy.ts";
+import type { CreateVideoInput as RepoCreateVideoInput } from "./VideosRepo.ts";
import { VideosRepo } from "./VideosRepo.ts";
+type UploadProgressUpdateInput = Schema.Type<
+ typeof Video.UploadProgressUpdateInput
+>;
+type InstantRecordingCreateInput = Schema.Type<
+ typeof Video.InstantRecordingCreateInput
+>;
+type OptionValue = T extends Option.Option ? Value : never;
+type RepoMetadataValue = OptionValue;
+type RepoTranscriptionStatusValue = OptionValue<
+ RepoCreateVideoInput["transcriptionStatus"]
+>;
+
export class Videos extends Effect.Service()("Videos", {
effect: Effect.gen(function* () {
const db = yield* Database;
@@ -36,11 +51,10 @@ export class Videos extends Effect.Service()("Videos", {
* Delete a video. Will fail if the user does not have access.
*/
delete: Effect.fn("Videos.delete")(function* (videoId: Video.VideoId) {
- const [video] = yield* repo
- .getById(videoId)
- .pipe(
- Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())),
- );
+ const maybeVideo = yield* repo.getById(videoId);
+ if (Option.isNone(maybeVideo))
+ return yield* Effect.fail(new Video.NotFoundError());
+ const [video] = maybeVideo.value;
const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId);
@@ -50,9 +64,7 @@ export class Videos extends Effect.Service()("Videos", {
yield* Effect.log(`Deleted video ${video.id}`);
- const user = yield* CurrentUser;
-
- const prefix = `${user.id}/${video.id}/`;
+ const prefix = `${video.ownerId}/${video.id}/`;
const listedObjects = yield* bucket.listObjects({ prefix });
@@ -72,12 +84,12 @@ export class Videos extends Effect.Service()("Videos", {
duplicate: Effect.fn("Videos.duplicate")(function* (
videoId: Video.VideoId,
) {
- const [video] = yield* repo
+ const maybeVideo = yield* repo
.getById(videoId)
- .pipe(
- Effect.flatMap(Effect.catchAll(() => new Video.NotFoundError())),
- Policy.withPolicy(policy.isOwner(videoId)),
- );
+ .pipe(Policy.withPolicy(policy.isOwner(videoId)));
+ if (Option.isNone(maybeVideo))
+ return yield* Effect.fail(new Video.NotFoundError());
+ const [video] = maybeVideo.value;
const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId);
@@ -124,88 +136,228 @@ export class Videos extends Effect.Service()("Videos", {
)
.pipe(Policy.withPublicPolicy(policy.canView(videoId)));
- return pipe(
- result,
- Option.fromNullable,
- Option.map((r) => new Video.UploadProgress(r)),
+ if (result == null) return Option.none();
+ return Option.some(new Video.UploadProgress(result));
+ }),
+
+ updateUploadProgress: Effect.fn("Videos.updateUploadProgress")(function* (
+ input: UploadProgressUpdateInput,
+ ) {
+ const uploaded = Math.min(input.uploaded, input.total);
+ const total = input.total;
+ const updatedAt = input.updatedAt;
+ const videoId = input.videoId;
+
+ const [record] = yield* db
+ .use((db) =>
+ db
+ .select({
+ video: Db.videos,
+ upload: Db.videoUploads,
+ })
+ .from(Db.videos)
+ .leftJoin(
+ Db.videoUploads,
+ Dz.eq(Db.videos.id, Db.videoUploads.videoId),
+ )
+ .where(Dz.eq(Db.videos.id, videoId)),
+ )
+ .pipe(Policy.withPolicy(policy.isOwner(videoId)));
+
+ if (!record) return yield* Effect.fail(new Video.NotFoundError());
+
+ yield* db.use((db) =>
+ db.transaction(async (tx) => {
+ if (record.upload) {
+ if (uploaded === total && record.upload.mode === "singlepart") {
+ await tx
+ .delete(Db.videoUploads)
+ .where(Dz.eq(Db.videoUploads.videoId, videoId));
+ return;
+ }
+
+ await tx
+ .update(Db.videoUploads)
+ .set({
+ uploaded,
+ total,
+ updatedAt,
+ })
+ .where(
+ Dz.and(
+ Dz.eq(Db.videoUploads.videoId, videoId),
+ Dz.lte(Db.videoUploads.updatedAt, updatedAt),
+ ),
+ );
+ return;
+ }
+
+ await tx.insert(Db.videoUploads).values({
+ videoId,
+ uploaded,
+ total,
+ updatedAt,
+ });
+ }),
);
+
+ return true as const;
}),
+ createInstantRecording: Effect.fn("Videos.createInstantRecording")(
+ function* (input: InstantRecordingCreateInput) {
+ const user = yield* CurrentUser;
+
+ if (user.activeOrganizationId !== input.orgId)
+ return yield* Effect.fail(new Policy.PolicyDeniedError());
+
+ const [customBucket] = yield* db.use((db) =>
+ db
+ .select()
+ .from(Db.s3Buckets)
+ .where(Dz.eq(Db.s3Buckets.ownerId, user.id)),
+ );
+
+ const bucketId: RepoCreateVideoInput["bucketId"] =
+ Option.fromNullable(customBucket?.id);
+ const folderId: RepoCreateVideoInput["folderId"] =
+ input.folderId ?? Option.none();
+ const width: RepoCreateVideoInput["width"] = Option.fromNullable(
+ input.width,
+ );
+ const height: RepoCreateVideoInput["height"] = Option.fromNullable(
+ input.height,
+ );
+ const duration: RepoCreateVideoInput["duration"] =
+ Option.fromNullable(input.durationSeconds);
+
+ const now = new Date();
+ const formattedDate = `${now.getDate()} ${now.toLocaleString(
+ "default",
+ {
+ month: "long",
+ },
+ )} ${now.getFullYear()}`;
+
+ const createData: RepoCreateVideoInput = {
+ ownerId: user.id,
+ orgId: input.orgId,
+ name: `Cap Recording - ${formattedDate}`,
+ public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
+ source: { type: "webMP4" },
+ bucketId,
+ folderId,
+ width,
+ height,
+ duration,
+ metadata: Option.none(),
+ transcriptionStatus: Option.none(),
+ };
+ const videoId = yield* repo.create(createData);
+
+ if (input.supportsUploadProgress ?? true)
+ yield* db.use((db) =>
+ db.insert(Db.videoUploads).values({
+ videoId,
+ mode: "singlepart",
+ }),
+ );
+
+ const fileKey = `${user.id}/${videoId}/result.mp4`;
+ const [bucket] = yield* s3Buckets.getBucketAccess(bucketId);
+ const presignedPostData = yield* bucket.getPresignedPostUrl(fileKey, {
+ Fields: {
+ "Content-Type": "video/mp4",
+ "x-amz-meta-userid": user.id,
+ "x-amz-meta-duration": input.durationSeconds
+ ? input.durationSeconds.toString()
+ : "",
+ "x-amz-meta-resolution": input.resolution ?? "",
+ "x-amz-meta-videocodec": input.videoCodec ?? "",
+ "x-amz-meta-audiocodec": input.audioCodec ?? "",
+ },
+ Expires: 1800,
+ });
+
+ const shareUrl = `${serverEnv().WEB_URL}/s/${videoId}`;
+
+ if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production")
+ yield* Effect.tryPromise(() =>
+ dub().links.create({
+ url: shareUrl,
+ domain: "cap.link",
+ key: videoId,
+ }),
+ ).pipe(
+ Effect.catchAll((error) =>
+ Effect.logWarning(`Dub link create failed: ${String(error)}`),
+ ),
+ );
+
+ return {
+ id: videoId,
+ shareUrl,
+ upload: {
+ url: presignedPostData.url,
+ fields: presignedPostData.fields,
+ },
+ };
+ },
+ ),
+
create: Effect.fn("Videos.create")(repo.create),
getDownloadInfo: Effect.fn("Videos.getDownloadInfo")(function* (
videoId: Video.VideoId,
) {
- const [video] = yield* repo
+ const maybeVideo = yield* repo
.getById(videoId)
- .pipe(
- Effect.flatMap(
- Effect.catchTag(
- "NoSuchElementException",
- () => new Video.NotFoundError(),
- ),
- ),
- Policy.withPublicPolicy(policy.canView(videoId)),
- );
+ .pipe(Policy.withPublicPolicy(policy.canView(videoId)));
+ if (Option.isNone(maybeVideo))
+ return yield* Effect.fail(new Video.NotFoundError());
+ const [video] = maybeVideo.value;
- const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
+ const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId);
- return yield* Option.fromNullable(Video.Video.getSource(video)).pipe(
- Option.filter((v) => v._tag === "Mp4Source"),
- Option.map((v) =>
- bucket.getSignedObjectUrl(v.getFileKey()).pipe(
- Effect.map((downloadUrl) => ({
- fileName: `${video.name}.mp4`,
- downloadUrl,
- })),
- ),
- ),
- Effect.transposeOption,
- );
+ const src = Video.Video.getSource(video);
+ if (!src) return Option.none();
+ if (!(src instanceof Video.Mp4Source)) return Option.none();
+
+ const downloadUrl = yield* bucket.getSignedObjectUrl(src.getFileKey());
+ return Option.some({ fileName: `${video.name}.mp4`, downloadUrl });
}),
getThumbnailURL: Effect.fn("Videos.getThumbnailURL")(function* (
videoId: Video.VideoId,
) {
- const videoOpt = yield* repo
+ const maybeVideo = yield* repo
.getById(videoId)
.pipe(Policy.withPublicPolicy(policy.canView(videoId)));
+ if (Option.isNone(maybeVideo)) return Option.none();
+ const [video] = maybeVideo.value;
- return yield* videoOpt.pipe(
- Effect.transposeMapOption(
- Effect.fn(function* ([video]) {
- const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
-
- const listResponse = yield* bucket.listObjects({
- prefix: `${video.ownerId}/${video.id}/`,
- });
- const contents = listResponse.Contents || [];
-
- const thumbnailKey = contents.find((item) =>
- item.Key?.endsWith("screen-capture.jpg"),
- )?.Key;
-
- if (!thumbnailKey) return Option.none();
-
- return Option.some(
- yield* bucket.getSignedObjectUrl(thumbnailKey),
- );
- }),
- ),
- Effect.map(Option.flatten),
- );
+ const [bucket] = yield* s3Buckets.getBucketAccess(video.bucketId);
+ const listResponse = yield* bucket.listObjects({
+ prefix: `${video.ownerId}/${video.id}/`,
+ });
+ const contents = listResponse.Contents || [];
+ const thumbnailKey = contents.find((item) =>
+ item.Key?.endsWith("screen-capture.jpg"),
+ )?.Key;
+ if (!thumbnailKey) return Option.none();
+ const url = yield* bucket.getSignedObjectUrl(thumbnailKey);
+ return Option.some(url);
}),
getAnalytics: Effect.fn("Videos.getAnalytics")(function* (
videoId: Video.VideoId,
) {
- const [video] = yield* getByIdForViewing(videoId).pipe(
- Effect.flatten,
- Effect.catchTag(
- "NoSuchElementException",
- () => new Video.NotFoundError(),
- ),
- );
+ const maybeVideo = yield* repo
+ .getById(videoId)
+ .pipe(Policy.withPublicPolicy(policy.canView(videoId)));
+ if (Option.isNone(maybeVideo))
+ return yield* Effect.fail(new Video.NotFoundError());
+ const [video] = maybeVideo.value;
const response = yield* Effect.tryPromise(() =>
dub().analytics.retrieve({
diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts
index 98d8c633bf..53112f6c61 100644
--- a/packages/web-domain/src/Video.ts
+++ b/packages/web-domain/src/Video.ts
@@ -20,7 +20,7 @@ export class Video extends Schema.Class