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">) => ( + + + +
+
+ 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 ( + <> +
+
+
+
+ + Cap Logo + + + + + + + + { + 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 ( + <> + + + + + + Instant Mode Recorder + + {open && ( + + + setSettingsOpen(false)} + onRememberDevicesChange={handleRememberDevicesChange} + /> + setHowItWorksOpen(false)} + /> + + + {screenCaptureWarning && ( +
+ {screenCaptureWarning} +
+ )} + { + setCameraSelectOpen(isOpen); + if (isOpen) { + setMicSelectOpen(false); + } + }} + onCameraChange={handleCameraChange} + onRefreshDevices={refreshCameras} + /> + { + setMicSelectOpen(isOpen); + if (isOpen) { + setCameraSelectOpen(false); + } + }} + onMicChange={handleMicChange} + onRefreshDevices={refreshMics} + /> + + {!isBrowserSupported && unsupportedReason && ( +
+ {unsupportedReason} +
+ )} + +
+ )} +
+
+
+ {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