diff --git a/.env.example b/.env.example
index 964cc694..f2af00b7 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,7 @@
NEXT_PUBLIC_ENVIRONMENT=development
NEXT_PUBLIC_URL=http://localhost:3000
+NEXT_PUBLIC_TASKS_URL=http://localhost:3002
# IMPORTANT FOR LOCAL DEV:
# This determines whether or not the app will run in "local mode".
@@ -41,6 +42,9 @@ CAP_AWS_SECRET_KEY=
CAP_AWS_BUCKET=
CAP_AWS_REGION=
+# -- Deepgram (for transcription) ****************
+DEEPGRAM_API_KEY=
+
# -- resend ****************
## For use with email authentication (sign up, sign in, forgot password)
RESEND_API_KEY=
diff --git a/README.md b/README.md
index 493c1953..8f5fdaea 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
Cap
- Effortless, instant screen sharing. Open source and cross-platform.
+ The open source Loom alternative.
Cap.so ยป
@@ -24,7 +24,7 @@
> NOTE: Cap is under active development, and is currently in public beta. This repository is updated regularly with changes and new releases.
-Cap is an open source alternative to Loom. It's a video messaging tool that allows you to record, edit and share videos in seconds.
+Cap is the open source alternative to Loom. It's a video messaging tool that allows you to record, edit and share videos in seconds.
![cap-emoji-banner](https://github.com/CapSoftware/cap/assets/33632126/85425396-ad31-463b-b209-7c4bdf7e2e4f)
diff --git a/apps/desktop/src-tauri/src/capture/src/quartz/capturer.rs b/apps/desktop/src-tauri/src/capture/src/quartz/capturer.rs
index 6d83f693..5dd74761 100644
--- a/apps/desktop/src-tauri/src/capture/src/quartz/capturer.rs
+++ b/apps/desktop/src-tauri/src/capture/src/quartz/capturer.rs
@@ -56,6 +56,10 @@ impl Capturer {
stream
};
+ if queue.is_null() {
+ return Err(CGError::Failure);
+ }
+
match unsafe { CGDisplayStreamStart(stream) } {
CGError::Success => Ok(Capturer {
stream, queue, width, height, format, display
diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs
index 2c339f0f..55cf324d 100644
--- a/apps/desktop/src-tauri/src/upload.rs
+++ b/apps/desktop/src-tauri/src/upload.rs
@@ -95,6 +95,8 @@ pub async fn upload_file(
"audio/aac"
} else if file_path.to_lowercase().ends_with(".webm") {
"audio/webm"
+ } else if file_path.to_lowercase().ends_with(".mp3") {
+ "audio/mpeg"
} else {
"video/mp2t"
};
@@ -155,14 +157,14 @@ pub fn get_video_duration(file_path: &str) -> Result {
.output()?;
let output_str = str::from_utf8(&output.stderr).unwrap();
- let duration_regex = Regex::new(r"Duration: (\d{2}):(\d{2}):(\d{2})\.\d{2}").unwrap();
+ let duration_regex = Regex::new(r"Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})").unwrap();
let caps = duration_regex.captures(output_str).unwrap();
let hours: f64 = caps.get(1).unwrap().as_str().parse().unwrap();
let minutes: f64 = caps.get(2).unwrap().as_str().parse().unwrap();
- let seconds: f64 = caps.get(3).unwrap().as_str().parse().unwrap();
-
- let duration = hours * 3600.0 + minutes * 60.0 + seconds;
+ let seconds: f64 = caps.get(3).unwrap().as_str().parse::().unwrap();
+ let milliseconds: f64 = caps.get(4).unwrap().as_str().parse::().unwrap() / 100.0;
+ let duration = hours * 3600.0 + minutes * 60.0 + seconds + milliseconds;
Ok(duration)
}
diff --git a/apps/embed/.eslintrc.cjs b/apps/embed/.eslintrc.cjs
new file mode 100644
index 00000000..625ebb14
--- /dev/null
+++ b/apps/embed/.eslintrc.cjs
@@ -0,0 +1,7 @@
+module.exports = {
+ extends: [require.resolve("config/eslint/web.js")],
+ parserOptions: {
+ tsconfigRootDir: __dirname,
+ project: "./tsconfig.json",
+ },
+};
diff --git a/apps/embed/.gitignore b/apps/embed/.gitignore
new file mode 100644
index 00000000..90f1072a
--- /dev/null
+++ b/apps/embed/.gitignore
@@ -0,0 +1,36 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+pnpm-lock.yaml
\ No newline at end of file
diff --git a/apps/embed/README.md b/apps/embed/README.md
new file mode 100644
index 00000000..c0ed8b35
--- /dev/null
+++ b/apps/embed/README.md
@@ -0,0 +1,3 @@
+# Cap Embed App
+
+This Next.js app is used for Cap web embeds.
diff --git a/apps/embed/app/favicon.ico b/apps/embed/app/favicon.ico
new file mode 100644
index 00000000..718d6fea
Binary files /dev/null and b/apps/embed/app/favicon.ico differ
diff --git a/apps/embed/app/globals.css b/apps/embed/app/globals.css
new file mode 100644
index 00000000..235a1426
--- /dev/null
+++ b/apps/embed/app/globals.css
@@ -0,0 +1,26 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+.wrapper {
+ @apply mx-auto w-[92%] max-w-screen-xl;
+}
+
+.wrapper-max {
+ @apply max-w-screen-2xl;
+}
+
+.wrapper-sm {
+ @apply max-w-5xl;
+}
+
+*,
+*:before,
+*:after {
+ box-sizing: border-box;
+}
+
+* {
+ min-width: 0;
+ min-height: 0;
+}
diff --git a/apps/embed/app/layout.tsx b/apps/embed/app/layout.tsx
new file mode 100644
index 00000000..7628e7cf
--- /dev/null
+++ b/apps/embed/app/layout.tsx
@@ -0,0 +1,57 @@
+import "@/app/globals.css";
+import { Toaster } from "react-hot-toast";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title:
+ "Cap โ Effortless, instant screen sharing. Open source and cross-platform.",
+ description:
+ "Cap is the open source alternative to Loom. Lightweight, powerful, and stunning. Record and share in seconds.",
+ openGraph: {
+ title:
+ "Cap โ Effortless, instant screen sharing. Open source and cross-platform.",
+ description:
+ "Cap is the open source alternative to Loom. Lightweight, powerful, and stunning. Record and share in seconds.",
+ type: "website",
+ url: "https://cap.so",
+ images: ["https://cap.so/og.png"],
+ },
+};
+
+export default async function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/embed/app/page.tsx b/apps/embed/app/page.tsx
new file mode 100644
index 00000000..52c0113a
--- /dev/null
+++ b/apps/embed/app/page.tsx
@@ -0,0 +1,7 @@
+export default async function EmbedPage() {
+ return (
+
+ );
+}
diff --git a/apps/embed/app/view/[videoId]/Share.tsx b/apps/embed/app/view/[videoId]/Share.tsx
new file mode 100644
index 00000000..5a10e2df
--- /dev/null
+++ b/apps/embed/app/view/[videoId]/Share.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { ShareVideo } from "./_components/ShareVideo";
+import { comments as commentsSchema, videos } from "@cap/database/schema";
+import { userSelectProps } from "@cap/database/auth/session";
+
+export const Share = ({
+ data,
+ user,
+ comments,
+}: {
+ data: typeof videos.$inferSelect;
+ user: typeof userSelectProps | null;
+ comments: (typeof commentsSchema.$inferSelect)[];
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/apps/embed/app/view/[videoId]/_components/AudioPlayer.tsx b/apps/embed/app/view/[videoId]/_components/AudioPlayer.tsx
new file mode 100644
index 00000000..b7eead87
--- /dev/null
+++ b/apps/embed/app/view/[videoId]/_components/AudioPlayer.tsx
@@ -0,0 +1,42 @@
+import { memo, forwardRef, useEffect } from "react";
+import Hls from "hls.js";
+
+export const AudioPlayer = memo(
+ forwardRef void }>(
+ ({ src, onReady }, ref) => {
+ useEffect(() => {
+ const audio = ref as React.MutableRefObject;
+
+ if (!audio.current) return;
+
+ let hls: Hls | null = null;
+
+ if (Hls.isSupported()) {
+ hls = new Hls();
+ hls.loadSource(src);
+ hls.attachMedia(audio.current);
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
+ onReady();
+ });
+ } else if (audio.current.canPlayType("application/vnd.apple.mpegurl")) {
+ audio.current.src = src;
+ audio.current.addEventListener(
+ "loadedmetadata",
+ () => {
+ onReady();
+ },
+ { once: true }
+ );
+ }
+
+ return () => {
+ if (hls) {
+ hls.destroy();
+ }
+ };
+ }, [src, onReady, ref]);
+
+ return ;
+ }
+ )
+);
diff --git a/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx b/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx
new file mode 100644
index 00000000..98204c36
--- /dev/null
+++ b/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx
@@ -0,0 +1,644 @@
+import { comments as commentsSchema, videos } from "@cap/database/schema";
+import { VideoPlayer } from "./VideoPlayer";
+import { useState, useEffect, useRef } from "react";
+import {
+ Play,
+ Pause,
+ Maximize,
+ VolumeX,
+ Volume2,
+ MessageSquare,
+ LinkIcon,
+ ThumbsUp,
+} from "lucide-react";
+import { Button, LogoBadge, LogoSpinner } from "@cap/ui";
+import { userSelectProps } from "@cap/database/auth/session";
+import { Tooltip } from "react-tooltip";
+import { fromVtt, Subtitle } from "subtitles-parser-vtt";
+import { useRouter } from "next/navigation";
+import toast from "react-hot-toast";
+import moment from "moment";
+import { Toolbar } from "./Toolbar";
+
+declare global {
+ interface Window {
+ MSStream: any;
+ }
+}
+
+const formatTime = (time: number) => {
+ const minutes = Math.floor(time / 60);
+ const seconds = Math.floor(time % 60);
+ return `${minutes.toString().padStart(2, "0")}:${seconds
+ .toString()
+ .padStart(2, "0")}`;
+};
+
+export const ShareVideo = ({
+ data,
+ user,
+ comments,
+}: {
+ data: typeof videos.$inferSelect;
+ user: typeof userSelectProps | null;
+ comments: (typeof commentsSchema.$inferSelect)[];
+}) => {
+ const videoRef = useRef(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [isLoading, setIsLoading] = useState(true);
+ const [longestDuration, setLongestDuration] = useState(0);
+ const [seeking, setSeeking] = useState(false);
+ const [videoMetadataLoaded, setVideoMetadataLoaded] = useState(false);
+ const [overlayVisible, setOverlayVisible] = useState(true);
+ const [subtitles, setSubtitles] = useState([]);
+ const [subtitlesVisible, setSubtitlesVisible] = useState(true);
+ const [isTranscriptionProcessing, setIsTranscriptionProcessing] =
+ useState(false);
+ const [videoSpeed, setVideoSpeed] = useState(1);
+ const overlayTimeoutRef = useRef(null);
+ const [showReactionBar, setShowReactionBar] = useState(false);
+ const { push } = useRouter();
+
+ useEffect(() => {
+ const handleMouseMove = () => {
+ setOverlayVisible(true);
+ if (overlayTimeoutRef.current) {
+ clearTimeout(overlayTimeoutRef.current);
+ }
+ overlayTimeoutRef.current = setTimeout(() => {
+ setOverlayVisible(false);
+ }, 1000);
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ if (overlayTimeoutRef.current) {
+ clearTimeout(overlayTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (videoMetadataLoaded) {
+ console.log("Metadata loaded");
+ setIsLoading(false);
+ }
+ }, [videoMetadataLoaded]);
+
+ useEffect(() => {
+ const onVideoLoadedMetadata = () => {
+ console.log("Video metadata loaded");
+ setVideoMetadataLoaded(true);
+ if (videoRef.current) {
+ setLongestDuration(videoRef.current.duration);
+ }
+ };
+
+ const videoElement = videoRef.current;
+
+ videoElement?.addEventListener("loadedmetadata", onVideoLoadedMetadata);
+
+ return () => {
+ videoElement?.removeEventListener(
+ "loadedmetadata",
+ onVideoLoadedMetadata
+ );
+ };
+ }, []);
+
+ const handlePlayPauseClick = async () => {
+ const videoElement = videoRef.current;
+
+ if (!videoElement) return;
+
+ if (isPlaying) {
+ videoElement.pause();
+ setIsPlaying(false);
+ } else {
+ try {
+ await videoElement.play();
+ setIsPlaying(true);
+ videoElement.muted = false;
+ } catch (error) {
+ console.error("Error with playing:", error);
+ }
+ }
+ };
+
+ const applyTimeToVideos = (time: number) => {
+ if (videoRef.current) videoRef.current.currentTime = time;
+ setCurrentTime(time);
+ };
+
+ useEffect(() => {
+ const syncPlayback = () => {
+ const videoElement = videoRef.current;
+
+ if (!isPlaying || isLoading || !videoElement) return;
+
+ const handleTimeUpdate = () => {
+ setCurrentTime(videoElement.currentTime);
+ };
+
+ videoElement.play().catch((error) => {
+ console.error("Error playing video", error);
+ setIsPlaying(false);
+ });
+ videoElement.addEventListener("timeupdate", handleTimeUpdate);
+
+ return () =>
+ videoElement.removeEventListener("timeupdate", handleTimeUpdate);
+ };
+
+ syncPlayback();
+ }, [isPlaying, isLoading]);
+
+ useEffect(() => {
+ const handleSeeking = () => {
+ if (seeking && videoRef.current) {
+ setCurrentTime(videoRef.current.currentTime);
+ }
+ };
+
+ const videoElement = videoRef.current;
+
+ videoElement?.addEventListener("seeking", handleSeeking);
+
+ return () => {
+ videoElement?.removeEventListener("seeking", handleSeeking);
+ };
+ }, [seeking]);
+
+ const calculateNewTime = (event: any, seekBar: any) => {
+ const rect = seekBar.getBoundingClientRect();
+ const offsetX = event.clientX - rect.left;
+ const relativePosition = offsetX / rect.width;
+ return relativePosition * longestDuration;
+ };
+
+ const handleSeekMouseDown = () => {
+ setSeeking(true);
+ };
+
+ const handleSeekMouseUp = (event: any) => {
+ if (!seeking) return;
+ setSeeking(false);
+ const seekBar = event.currentTarget;
+ const seekTo = calculateNewTime(event, seekBar);
+ applyTimeToVideos(seekTo);
+ if (isPlaying) {
+ videoRef.current?.play();
+ }
+ };
+
+ const handleSeekMouseMove = (event: any) => {
+ if (!seeking) return;
+ const seekBar = event.currentTarget;
+ const seekTo = calculateNewTime(event, seekBar);
+ applyTimeToVideos(seekTo);
+ };
+
+ const handleMuteClick = () => {
+ if (videoRef.current) {
+ console.log("Mute clicked");
+ videoRef.current.muted = videoRef.current.muted ? false : true;
+ }
+ };
+
+ const handleFullscreenClick = () => {
+ const player = document.getElementById("player");
+ const isIOS =
+ /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+ if (!document.fullscreenElement && !isIOS) {
+ player
+ ?.requestFullscreen()
+ .catch((err) =>
+ console.error(
+ `Error attempting to enable full-screen mode: ${err.message} (${err.name})`
+ )
+ );
+ } else if (isIOS && videoRef.current) {
+ const videoUrl = videoRef.current.src;
+ window.open(videoUrl, "_blank");
+ } else {
+ document.exitFullscreen();
+ }
+ };
+
+ const handleSpeedChange = () => {
+ let newSpeed;
+ if (videoSpeed === 1) {
+ newSpeed = 1.5;
+ } else if (videoSpeed === 1.5) {
+ newSpeed = 2;
+ } else {
+ newSpeed = 1;
+ }
+ setVideoSpeed(newSpeed);
+ if (videoRef.current) {
+ videoRef.current.playbackRate = newSpeed;
+ }
+ };
+
+ const watchedPercentage =
+ longestDuration > 0 ? (currentTime / longestDuration) * 100 : 0;
+
+ useEffect(() => {
+ if (isPlaying) {
+ videoRef.current?.play();
+ } else {
+ videoRef.current?.pause();
+ }
+ }, [isPlaying]);
+
+ useEffect(() => {
+ const syncPlay = () => {
+ if (videoRef.current && !isLoading) {
+ const playPromise2 = videoRef.current.play();
+ playPromise2.catch((e) => console.log("Play failed for video 2", e));
+ }
+ };
+
+ if (isPlaying) {
+ syncPlay();
+ }
+ }, [isPlaying, isLoading]);
+
+ const parseSubTime = (timeString: number) => {
+ const [hours, minutes, seconds] = timeString
+ .toString()
+ .split(":")
+ .map(Number);
+ return hours * 3600 + minutes * 60 + seconds;
+ };
+
+ useEffect(() => {
+ const fetchSubtitles = () => {
+ fetch(`https://v.cap.so/${data.ownerId}/${data.id}/transcription.vtt`)
+ .then((response) => response.text())
+ .then((text) => {
+ const parsedSubtitles = fromVtt(text);
+ setSubtitles(parsedSubtitles);
+ });
+ };
+
+ if (data.transcriptionStatus === "COMPLETE") {
+ fetchSubtitles();
+ } else {
+ const intervalId = setInterval(() => {
+ fetch(`/api/video/transcribe/status?videoId=${data.id}`)
+ .then((response) => response.json())
+ .then(({ transcriptionStatus }) => {
+ if (transcriptionStatus === "PROCESSING") {
+ setIsTranscriptionProcessing(true);
+ } else if (transcriptionStatus === "COMPLETE") {
+ fetchSubtitles();
+ clearInterval(intervalId);
+ } else if (transcriptionStatus === "FAILED") {
+ clearInterval(intervalId);
+ }
+ });
+ }, 1000);
+
+ return () => clearInterval(intervalId);
+ }
+ }, [data]);
+
+ const currentSubtitle = subtitles.find(
+ (subtitle) =>
+ parseSubTime(subtitle.startTime) <= currentTime &&
+ parseSubTime(subtitle.endTime) >= currentTime
+ );
+
+ if (data.jobStatus === "ERROR") {
+ return (
+
+
+
+ There was an error when processing the video. Please contact
+ support.
+
+
+
+ );
+ }
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+ {isLoading === false && (
+
+
+
+ )}
+
+
+
+
+
+
{data.name}
+
+ {moment(data.createdAt).fromNow()}
+
+
+
+
+
+ {showReactionBar && (
+
+
+
+ )}
+
+ {currentSubtitle && currentSubtitle.text && subtitlesVisible && (
+
+
+ {currentSubtitle.text
+ .replace("- ", "")
+ .replace(".", "")
+ .replace(",", "")}
+
+
+ )}
+
+
+
+
setSeeking(false)}
+ onTouchEnd={handleSeekMouseUp}
+ >
+ {!isLoading && comments !== null && (
+
+ {comments.map((comment) => {
+ if (comment.timestamp === null) return null;
+
+ return (
+
+
+
+ {comment.type === "text" ? (
+
+ ) : (
+ comment.content
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatTime(currentTime)} - {formatTime(longestDuration)}
+
+
+
+
+
+
+
+
+
+
+ {isTranscriptionProcessing && subtitles.length === 0 && (
+
+
+
+ )}
+ {subtitles.length > 0 && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/embed/app/view/[videoId]/_components/Toolbar.tsx b/apps/embed/app/view/[videoId]/_components/Toolbar.tsx
new file mode 100644
index 00000000..6e5e5b3d
--- /dev/null
+++ b/apps/embed/app/view/[videoId]/_components/Toolbar.tsx
@@ -0,0 +1,267 @@
+"use client";
+import { useEffect, useRef, useState } from "react";
+import { MessageSquare } from "lucide-react";
+import { videos } from "@cap/database/schema";
+import { userSelectProps } from "@cap/database/auth/session";
+import { useRouter } from "next/navigation";
+import { Button } from "@cap/ui";
+import toast from "react-hot-toast";
+
+// million-ignore
+export const Toolbar = ({
+ data,
+ user,
+}: {
+ data: typeof videos.$inferSelect;
+ user: typeof userSelectProps | null;
+}) => {
+ const { refresh, push } = useRouter();
+ const [commentBoxOpen, setCommentBoxOpen] = useState(false);
+ const [comment, setComment] = useState("");
+ const videoElement = useRef(null);
+ useEffect(() => {
+ videoElement.current = document.getElementById(
+ "video-player"
+ ) as HTMLVideoElement;
+ }, []);
+
+ const [currentEmoji, setCurrentEmoji] = useState<{
+ emoji: string;
+ id: number;
+ } | null>(null);
+ const clearEmojiTimeout = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (clearEmojiTimeout.current) {
+ clearTimeout(clearEmojiTimeout.current);
+ }
+ };
+ }, []);
+
+ const timestamp =
+ videoElement && videoElement.current ? videoElement.current.currentTime : 0;
+
+ const handleEmojiClick = async (emoji: string) => {
+ // Clear any existing timeout
+ if (clearEmojiTimeout.current) {
+ clearTimeout(clearEmojiTimeout.current);
+ }
+
+ // Set the current emoji with a unique identifier
+ setCurrentEmoji({ emoji, id: Date.now() });
+
+ // Remove the emoji after the animation duration
+ clearEmojiTimeout.current = setTimeout(() => {
+ setCurrentEmoji(null);
+ }, 3000);
+
+ const videoElement = document.getElementById(
+ "video-player"
+ ) as HTMLVideoElement;
+ console.log("videoElement", videoElement.currentTime);
+ const timestamp = videoElement ? videoElement.currentTime : 0;
+
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_URL}/api/video/comment`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ type: "emoji",
+ content: emoji,
+ videoId: data.id,
+ parentCommentId: null,
+ timestamp: timestamp,
+ }),
+ }
+ );
+
+ if (response.status === 429) {
+ toast.error("Too many requests - please try again later.");
+ return;
+ }
+
+ if (!response.ok) {
+ console.error("Failed to record emoji reaction");
+ }
+
+ refresh();
+ };
+
+ const handleCommentSubmit = async () => {
+ if (comment.length === 0) {
+ return;
+ }
+
+ const response = await fetch("/api/video/comment", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ type: "text",
+ content: comment,
+ videoId: data.id,
+ parentCommentId: null,
+ timestamp: timestamp,
+ }),
+ });
+
+ if (!response.ok) {
+ console.error("Failed to record comment");
+ }
+
+ setComment("");
+ setCommentBoxOpen(false);
+
+ refresh();
+ };
+
+ const Emoji = ({ label, emoji }: { label: string; emoji: string }) => (
+
+
+
+ );
+
+ return (
+ <>
+
+
+
+ {commentBoxOpen === true ? (
+
+
setComment(e.target.value)}
+ placeholder="Add a comment"
+ className="flex-grow h-full outline-none px-3"
+ maxLength={255}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleCommentSubmit();
+ }
+ if (e.key === "Escape") {
+ setCommentBoxOpen(false);
+ setComment("");
+ }
+ }}
+ />
+
+
+
+
+
+ ) : (
+
+ {REACTIONS.map((reaction) => (
+
+ ))}
+
+
+
+
+
+ )}
+
+
+
+ >
+ );
+};
+
+const REACTIONS = [
+ {
+ emoji: "๐",
+ label: "joy",
+ },
+ {
+ emoji: "๐",
+ label: "love",
+ },
+ {
+ emoji: "๐ฎ",
+ label: "wow",
+ },
+ {
+ emoji: "๐",
+ label: "yay",
+ },
+ {
+ emoji: "๐",
+ label: "up",
+ },
+ {
+ emoji: "๐",
+ label: "down",
+ },
+];
diff --git a/apps/embed/app/view/[videoId]/_components/VideoPlayer.tsx b/apps/embed/app/view/[videoId]/_components/VideoPlayer.tsx
new file mode 100644
index 00000000..9524f148
--- /dev/null
+++ b/apps/embed/app/view/[videoId]/_components/VideoPlayer.tsx
@@ -0,0 +1,76 @@
+import {
+ memo,
+ forwardRef,
+ useEffect,
+ useRef,
+ useImperativeHandle,
+} from "react";
+import Hls from "hls.js";
+
+interface VideoPlayerProps {
+ videoSrc: string;
+}
+
+export const VideoPlayer = memo(
+ forwardRef(({ videoSrc }, ref) => {
+ const videoRef = useRef(null);
+ const videoHlsInstance = useRef(null);
+
+ useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement);
+
+ const initializeHls = (
+ src: string,
+ media: HTMLMediaElement,
+ hlsInstance: React.MutableRefObject
+ ) => {
+ const hls = new Hls();
+ hls.on(Hls.Events.ERROR, (event, data) => {
+ console.error("HLS error:", data);
+ if (data.fatal) {
+ switch (data.type) {
+ case Hls.ErrorTypes.NETWORK_ERROR:
+ if (data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR) {
+ setTimeout(() => {
+ console.log("Retrying...");
+ hls.loadSource(src);
+ }, 500);
+ }
+ break;
+ case Hls.ErrorTypes.MEDIA_ERROR:
+ hls.recoverMediaError();
+ break;
+ default:
+ break;
+ }
+ }
+ });
+ hlsInstance.current = hls;
+ hls.loadSource(src);
+ hls.attachMedia(media);
+ };
+
+ useEffect(() => {
+ if (!videoRef.current) return;
+
+ initializeHls(videoSrc, videoRef.current, videoHlsInstance);
+
+ return () => {
+ videoHlsInstance.current?.destroy();
+ };
+ }, [videoSrc]);
+
+ return (
+
+ );
+ })
+);
diff --git a/apps/embed/app/view/[videoId]/page.tsx b/apps/embed/app/view/[videoId]/page.tsx
new file mode 100644
index 00000000..e0cf92f3
--- /dev/null
+++ b/apps/embed/app/view/[videoId]/page.tsx
@@ -0,0 +1,110 @@
+"use server";
+import { Share } from "./Share";
+import { db } from "@cap/database";
+import { eq } from "drizzle-orm";
+import { videos, comments } from "@cap/database/schema";
+import { getCurrentUser, userSelectProps } from "@cap/database/auth/session";
+import type { Metadata, ResolvingMetadata } from "next";
+import { notFound } from "next/navigation";
+
+type Props = {
+ params: { [key: string]: string | string[] | undefined };
+};
+
+export async function generateMetadata(
+ { params }: Props,
+ parent: ResolvingMetadata
+): Promise {
+ const videoId = params.videoId as string;
+ const query = await db.select().from(videos).where(eq(videos.id, videoId));
+
+ if (query.length === 0) {
+ return notFound();
+ }
+
+ const video = query[0];
+
+ if (video.public === false) {
+ return {
+ title: "Cap: This video is private",
+ description: "This video is private and cannot be shared.",
+ openGraph: {
+ images: [
+ `${process.env.NEXT_PUBLIC_URL}/api/video/og?videoId=${videoId}`,
+ ],
+ },
+ };
+ }
+
+ return {
+ title: "Cap: " + video.name,
+ description: "Watch this video on Cap",
+ openGraph: {
+ images: [
+ `${process.env.NEXT_PUBLIC_URL}/api/video/og?videoId=${videoId}`,
+ ],
+ },
+ };
+}
+
+export default async function ShareVideoPage(props: Props) {
+ const params = props.params;
+ const videoId = params.videoId as string;
+ const user = (await getCurrentUser()) as typeof userSelectProps | null;
+ const userId = user?.id as string | undefined;
+ const query = await db.select().from(videos).where(eq(videos.id, videoId));
+
+ if (query.length === 0) {
+ return No video found
;
+ }
+
+ const video = query[0];
+
+ if (video.jobId === null && video.skipProcessing === false) {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_URL}/api/upload/mux/create?videoId=${videoId}&userId=${video.ownerId}`,
+ {
+ method: "GET",
+ credentials: "include",
+ cache: "no-store",
+ }
+ );
+
+ await res.json();
+ }
+
+ if (video.transcriptionStatus !== "COMPLETE") {
+ fetch(
+ `${process.env.NEXT_PUBLIC_URL}/api/video/transcribe?videoId=${videoId}&userId=${video.ownerId}`,
+ {
+ method: "GET",
+ credentials: "include",
+ cache: "no-store",
+ }
+ );
+ }
+
+ if (video.jobStatus !== "COMPLETE" && video.skipProcessing === false) {
+ fetch(
+ `${process.env.NEXT_PUBLIC_URL}/api/upload/mux/status?videoId=${videoId}&userId=${video.ownerId}`,
+ {
+ method: "GET",
+ credentials: "include",
+ cache: "no-store",
+ }
+ );
+ }
+
+ if (video.public === false) {
+ if (video.public === false && userId !== video.ownerId) {
+ return This video is private
;
+ }
+ }
+
+ const commentsQuery = await db
+ .select()
+ .from(comments)
+ .where(eq(comments.videoId, videoId));
+
+ return ;
+}
diff --git a/apps/embed/app/view/loading.tsx b/apps/embed/app/view/loading.tsx
new file mode 100644
index 00000000..972527c4
--- /dev/null
+++ b/apps/embed/app/view/loading.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { SkeletonPage } from "@cap/ui";
+
+export default function Loading() {
+ return ;
+}
diff --git a/apps/embed/app/view/page.tsx b/apps/embed/app/view/page.tsx
new file mode 100644
index 00000000..1f49e29e
--- /dev/null
+++ b/apps/embed/app/view/page.tsx
@@ -0,0 +1,6 @@
+"use server";
+import { redirect } from "next/navigation";
+
+export default async function SharePage() {
+ redirect("/");
+}
diff --git a/apps/embed/next.config.mjs b/apps/embed/next.config.mjs
new file mode 100644
index 00000000..488d3b21
--- /dev/null
+++ b/apps/embed/next.config.mjs
@@ -0,0 +1,97 @@
+import million from "million/compiler";
+
+/** @type {import('next').NextConfig} */
+
+import("dotenv").then(({ config }) => config({ path: "../../.env" }));
+
+import fs from "fs";
+import path from "path";
+
+const packageJson = JSON.parse(
+ fs.readFileSync(path.resolve("./package.json"), "utf8")
+);
+const { version } = packageJson;
+
+const nextConfig = {
+ reactStrictMode: true,
+ swcMinify: true,
+ transpilePackages: ["@cap/ui", "@cap/utils"],
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+ typescript: {
+ ignoreBuildErrors: true,
+ },
+ experimental: {
+ optimizePackageImports: ["@cap/ui", "@cap/utils"],
+ serverComponentsExternalPackages: [
+ "@react-email/components",
+ "@react-email/render",
+ "@react-email/tailwind",
+ ],
+ },
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "*.amazonaws.com",
+ port: "",
+ pathname: "**",
+ },
+ {
+ protocol: "https",
+ hostname: "*.cloudfront.net",
+ port: "",
+ pathname: "**",
+ },
+ {
+ protocol: "https",
+ hostname: "*v.cap.so",
+ port: "",
+ pathname: "**",
+ },
+ {
+ protocol: "https",
+ hostname: "*tasks.cap.so",
+ port: "",
+ pathname: "**",
+ },
+ ],
+ },
+ async rewrites() {
+ return [
+ {
+ source: "/r/:path*",
+ destination: "https://dub.cap.link/:path*",
+ },
+ ];
+ },
+ async redirects() {
+ return [
+ {
+ source: "/roadmap",
+ destination:
+ "https://capso.notion.site/7aac740edeee49b5a23be901a7cb734e?v=9d4a3bf3d72d488cad9b899ab73116a1",
+ permanent: true,
+ },
+ ];
+ },
+ env: {
+ appVersion: version,
+ },
+};
+
+const millionConfig = {
+ auto: {
+ rsc: true,
+ skip: [
+ "Parallax",
+ "Toolbar",
+ "react-scroll-parallax",
+ "Record",
+ "ActionButton",
+ ],
+ },
+};
+
+export default million.next(nextConfig, millionConfig);
diff --git a/apps/embed/package.json b/apps/embed/package.json
new file mode 100644
index 00000000..e95ff155
--- /dev/null
+++ b/apps/embed/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "embed",
+ "version": "0.2.6",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3003",
+ "build": "next build",
+ "start": "next start -p 3003",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@cap/database": "workspace:*",
+ "@cap/ui": "workspace:*",
+ "@cap/utils": "workspace:*",
+ "@headlessui/react": "^1.7.17",
+ "clsx": "^2.0.0",
+ "date-fns": "^3.2.0",
+ "dotenv": "^16.3.1",
+ "drizzle-orm": "0.30.9",
+ "hls.js": "^1.5.3",
+ "lucide-react": "^0.294.0",
+ "million": "^2.6.4",
+ "moment": "^2.30.1",
+ "mysql2": "^3.9.7",
+ "next": "14.2.3",
+ "next-auth": "^4.24.5",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-hls-player": "^3.0.7",
+ "react-hot-toast": "^2.4.1",
+ "react-player": "^2.14.1",
+ "react-rnd": "^10.4.1",
+ "react-tooltip": "^5.26.3",
+ "server-only": "^0.0.1",
+ "subtitles-parser-vtt": "^0.1.0",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@types/node": "^20.11.5",
+ "@types/react": "^18.2.7",
+ "@types/react-dom": "^18",
+ "autoprefixer": "^10.4.14",
+ "eslint": "^8.41.0",
+ "eslint-config-next": "14.1.0",
+ "postcss": "^8.4.23",
+ "tailwindcss": "^3",
+ "typescript": "^5.3.2"
+ },
+ "engines": {
+ "node": "20"
+ }
+}
diff --git a/apps/embed/postcss.config.js b/apps/embed/postcss.config.js
new file mode 100644
index 00000000..a1d89157
--- /dev/null
+++ b/apps/embed/postcss.config.js
@@ -0,0 +1 @@
+module.exports = require("@cap/ui/postcss");
diff --git a/apps/embed/tailwind.config.js b/apps/embed/tailwind.config.js
new file mode 100644
index 00000000..cbe4f4a2
--- /dev/null
+++ b/apps/embed/tailwind.config.js
@@ -0,0 +1 @@
+module.exports = require("@cap/ui/tailwind")("web");
diff --git a/apps/embed/tsconfig.json b/apps/embed/tsconfig.json
new file mode 100644
index 00000000..095d594a
--- /dev/null
+++ b/apps/embed/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "baseUrl": ".",
+ "paths": {
+ "@/app/*": ["app/*"],
+ "@/components/*": ["components/*"],
+ "@/pages/*": ["components/pages/*"],
+ "@/utils/*": ["utils/*"]
+ }
+ },
+
+ "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/embed/tsconfig.node.json b/apps/embed/tsconfig.node.json
new file mode 100644
index 00000000..42872c59
--- /dev/null
+++ b/apps/embed/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/tasks/.dockerignore b/apps/tasks/.dockerignore
new file mode 100644
index 00000000..7c22185e
--- /dev/null
+++ b/apps/tasks/.dockerignore
@@ -0,0 +1,18 @@
+# Ignore node_modules directory
+node_modules/
+
+# Ignore .git directory
+.git/
+
+# Ignore .env file which might contain sensitive information
+.env
+
+# Ignore dist directory if you're building your app inside the Docker container
+dist/
+
+# Ignore logs
+*.log
+
+# Ignore OS generated files
+.DS_Store
+Thumbs.db
\ No newline at end of file
diff --git a/apps/tasks/.eslintrc b/apps/tasks/.eslintrc
new file mode 100644
index 00000000..f9298703
--- /dev/null
+++ b/apps/tasks/.eslintrc
@@ -0,0 +1,37 @@
+{
+ "root": true,
+ "env": {
+ "jest": true
+ },
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "project": [
+ "./tsconfig.json"
+ ]
+ },
+ "extends": "airbnb-typescript/base",
+ "plugins": [
+ "import",
+ "@typescript-eslint"
+ ],
+ "rules": {
+ "comma-dangle": 0,
+ "no-underscore-dangle": 0,
+ "no-param-reassign": 0,
+ "no-return-assign": 0,
+ "camelcase": 0,
+ "import/extensions": 0,
+ "@typescript-eslint/no-redeclare": 0
+ },
+ "settings": {
+ "import/parsers": {
+ "@typescript-eslint/parser": [
+ ".ts",
+ ".tsx"
+ ]
+ },
+ "import/resolver": {
+ "typescript": {}
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/tasks/.gitignore b/apps/tasks/.gitignore
new file mode 100644
index 00000000..6a04e814
--- /dev/null
+++ b/apps/tasks/.gitignore
@@ -0,0 +1,77 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# next.js build output
+.next
+
+# nuxt.js build output
+.nuxt
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless
+
+dist
+
+output
\ No newline at end of file
diff --git a/apps/tasks/Dockerfile b/apps/tasks/Dockerfile
new file mode 100644
index 00000000..d69529ea
--- /dev/null
+++ b/apps/tasks/Dockerfile
@@ -0,0 +1,23 @@
+# Use an official Node runtime as the base image
+FROM node:20
+
+# Declaring env
+ENV NODE_ENV=production
+
+# Set the working directory in the container to /app
+WORKDIR /app
+
+# Copy all the files from the projectโs root to the working directory in the container
+COPY . .
+
+# Install all the dependencies
+RUN npm install
+
+# Build the app
+RUN npm run build
+
+# Install ffmpeg
+RUN apt-get update && apt-get install -y ffmpeg
+
+# Start the app
+CMD ["npm", "start"]
\ No newline at end of file
diff --git a/apps/tasks/README.md b/apps/tasks/README.md
new file mode 100644
index 00000000..9836e35a
--- /dev/null
+++ b/apps/tasks/README.md
@@ -0,0 +1,3 @@
+# Cap Tasks Server
+
+This is a simple Node.js Express server for handling various types of tasks.
diff --git a/apps/tasks/jest.config.js b/apps/tasks/jest.config.js
new file mode 100644
index 00000000..33721e17
--- /dev/null
+++ b/apps/tasks/jest.config.js
@@ -0,0 +1,6 @@
+/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ modulePathIgnorePatterns: ['/dist/'],
+};
\ No newline at end of file
diff --git a/apps/tasks/package.json b/apps/tasks/package.json
new file mode 100644
index 00000000..5109f416
--- /dev/null
+++ b/apps/tasks/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "tasks",
+ "version": "0.2.6",
+ "private": true,
+ "main": "src/index.ts",
+ "scripts": {
+ "start": "node dist/src/index.js",
+ "dev": "ts-node src/index.ts",
+ "build": "tsc",
+ "start:dist": "node dist/src/index.js",
+ "lint": "eslint --fix src test",
+ "test": "jest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "fluent-ffmpeg": "^2.1.3",
+ "helmet": "^7.1.0",
+ "morgan": "^1.10.0",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.4.4",
+ "@types/cors": "^2.8.17",
+ "@types/express": "^4.17.21",
+ "@types/fluent-ffmpeg": "^2.1.24",
+ "@types/jest": "^29.5.12",
+ "@types/morgan": "^1.9.9",
+ "@types/node": "^20.12.6",
+ "@types/supertest": "^6.0.2"
+ },
+ "devDependencies": {
+ "@typescript-eslint/eslint-plugin": "^7.6.0",
+ "@typescript-eslint/parser": "^7.6.0",
+ "eslint": "^8.57.0",
+ "eslint-config-airbnb-typescript": "^18.0.0",
+ "eslint-import-resolver-typescript": "^3.6.1",
+ "eslint-plugin-import": "^2.29.1",
+ "jest": "^29.7.0",
+ "nodemon": "^3.1.0",
+ "supertest": "^6.3.4",
+ "ts-jest": "^29.1.2"
+ },
+ "engines": {
+ "node": "20"
+ }
+}
diff --git a/apps/tasks/src/api/index.ts b/apps/tasks/src/api/index.ts
new file mode 100644
index 00000000..fc6a7b8c
--- /dev/null
+++ b/apps/tasks/src/api/index.ts
@@ -0,0 +1,16 @@
+import express from "express";
+
+import MessageResponse from "../interfaces/MessageResponse";
+import mergeAudioSegments from "./mergeAudioSegments";
+
+const router = express.Router();
+
+router.get<{}, MessageResponse>("/", (req, res) => {
+ res.json({
+ message: "OK",
+ });
+});
+
+router.use("/merge-audio-segments", mergeAudioSegments);
+
+export default router;
diff --git a/apps/tasks/src/api/mergeAudioSegments.ts b/apps/tasks/src/api/mergeAudioSegments.ts
new file mode 100644
index 00000000..6b2141dc
--- /dev/null
+++ b/apps/tasks/src/api/mergeAudioSegments.ts
@@ -0,0 +1,62 @@
+import express from "express";
+import ffmpeg from "fluent-ffmpeg";
+import fs from "fs";
+
+const router = express.Router();
+
+router.post<{}>("/", async (req, res) => {
+ const body = req.body;
+
+ if (
+ !body.segments ||
+ body.segments.length === 0 ||
+ !body.uploadUrl ||
+ !body.videoId
+ ) {
+ res.status(400).json({ response: "FAILED" });
+ return;
+ }
+
+ const outputDir = "./output";
+ if (!fs.existsSync(outputDir)) {
+ fs.mkdirSync(outputDir);
+ }
+
+ const command = ffmpeg();
+ const filePath = `./output/merged_${body.videoId}.mp3`;
+
+ for (const url of body.segments) {
+ command.input(url);
+ }
+
+ command
+ .on("error", (err: any) => {
+ console.log("An error occurred: " + err.message);
+ })
+ .on("end", async () => {
+ console.log("Merging finished!");
+
+ const buffer = fs.readFileSync(filePath);
+
+ const uploadResponse = await fetch(body.uploadUrl, {
+ method: "PUT",
+ body: buffer,
+ headers: {
+ "Content-Type": "audio/mpeg",
+ },
+ });
+
+ fs.unlinkSync(filePath);
+
+ if (!uploadResponse.ok) {
+ console.error("Upload failed: ", await uploadResponse.text());
+ res.status(500).json({ response: "FAILED" });
+ return;
+ }
+
+ res.status(200).json({ response: "COMPLETE" });
+ })
+ .mergeToFile(filePath, "./");
+});
+
+export default router;
diff --git a/apps/tasks/src/app.ts b/apps/tasks/src/app.ts
new file mode 100644
index 00000000..593f10a8
--- /dev/null
+++ b/apps/tasks/src/app.ts
@@ -0,0 +1,29 @@
+import express from "express";
+import morgan from "morgan";
+import helmet from "helmet";
+import cors from "cors";
+
+import * as middlewares from "./middlewares";
+import api from "./api";
+import MessageResponse from "./interfaces/MessageResponse";
+
+require("dotenv").config();
+
+const app = express();
+
+app.use(morgan("dev"));
+app.use(helmet());
+app.use(cors());
+app.use(express.json());
+
+app.get<{}, MessageResponse>("/", (req, res) => {
+ res.json({
+ message: "OK",
+ });
+});
+
+app.use("/api/v1", api);
+app.use(middlewares.notFound);
+app.use(middlewares.errorHandler);
+
+export default app;
diff --git a/apps/tasks/src/index.ts b/apps/tasks/src/index.ts
new file mode 100644
index 00000000..c527c9c3
--- /dev/null
+++ b/apps/tasks/src/index.ts
@@ -0,0 +1,7 @@
+import app from "./app";
+
+const port = Number(process.env.PORT) || 3002;
+
+app.listen(port, "0.0.0.0", function () {
+ console.log(`Listening: http://localhost:${port}`);
+});
diff --git a/apps/tasks/src/interfaces/ErrorResponse.ts b/apps/tasks/src/interfaces/ErrorResponse.ts
new file mode 100644
index 00000000..170768a2
--- /dev/null
+++ b/apps/tasks/src/interfaces/ErrorResponse.ts
@@ -0,0 +1,5 @@
+import MessageResponse from './MessageResponse';
+
+export default interface ErrorResponse extends MessageResponse {
+ stack?: string;
+}
\ No newline at end of file
diff --git a/apps/tasks/src/interfaces/MessageResponse.ts b/apps/tasks/src/interfaces/MessageResponse.ts
new file mode 100644
index 00000000..1f3d556d
--- /dev/null
+++ b/apps/tasks/src/interfaces/MessageResponse.ts
@@ -0,0 +1,3 @@
+export default interface MessageResponse {
+ message: string;
+}
diff --git a/apps/tasks/src/middlewares.ts b/apps/tasks/src/middlewares.ts
new file mode 100644
index 00000000..249ff67c
--- /dev/null
+++ b/apps/tasks/src/middlewares.ts
@@ -0,0 +1,19 @@
+import { NextFunction, Request, Response } from 'express';
+
+import ErrorResponse from './interfaces/ErrorResponse';
+
+export function notFound(req: Request, res: Response, next: NextFunction) {
+ res.status(404);
+ const error = new Error(`๐ - Not Found - ${req.originalUrl}`);
+ next(error);
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
+ const statusCode = res.statusCode !== 200 ? res.statusCode : 500;
+ res.status(statusCode);
+ res.json({
+ message: err.message,
+ stack: process.env.NODE_ENV === 'production' ? '๐ฅ' : err.stack,
+ });
+}
diff --git a/apps/tasks/test/api.test.ts b/apps/tasks/test/api.test.ts
new file mode 100644
index 00000000..67f65585
--- /dev/null
+++ b/apps/tasks/test/api.test.ts
@@ -0,0 +1,19 @@
+import request from "supertest";
+
+import app from "../src/app";
+
+describe("GET /api/v1", () => {
+ it("responds with a json message", (done) => {
+ request(app)
+ .get("/api/v1")
+ .set("Accept", "application/json")
+ .expect("Content-Type", /json/)
+ .expect(
+ 200,
+ {
+ message: "OK",
+ },
+ done
+ );
+ });
+});
diff --git a/apps/tasks/test/app.test.ts b/apps/tasks/test/app.test.ts
new file mode 100644
index 00000000..f2bf80fa
--- /dev/null
+++ b/apps/tasks/test/app.test.ts
@@ -0,0 +1,29 @@
+import request from "supertest";
+
+import app from "../src/app";
+
+describe("app", () => {
+ it("responds with a not found message", (done) => {
+ request(app)
+ .get("/what-is-this-even")
+ .set("Accept", "application/json")
+ .expect("Content-Type", /json/)
+ .expect(404, done);
+ });
+});
+
+describe("GET /", () => {
+ it("responds with a json message", (done) => {
+ request(app)
+ .get("/")
+ .set("Accept", "application/json")
+ .expect("Content-Type", /json/)
+ .expect(
+ 200,
+ {
+ message: "OK",
+ },
+ done
+ );
+ });
+});
diff --git a/apps/tasks/tsconfig.json b/apps/tasks/tsconfig.json
new file mode 100644
index 00000000..a3665414
--- /dev/null
+++ b/apps/tasks/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "outDir": "dist",
+ "sourceMap": true,
+ "target": "esnext",
+ "module": "commonjs",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "noImplicitAny": true,
+ "strict": true,
+ "skipLibCheck": true
+ },
+ "include": [
+ "./*.js",
+ "src/**/*.ts",
+ "test/**/*.ts",
+ ],
+}
diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts
index 84069430..ec39fa7a 100644
--- a/apps/web/app/api/playlist/route.ts
+++ b/apps/web/app/api/playlist/route.ts
@@ -14,27 +14,16 @@ import {
generateM3U8Playlist,
generateMasterPlaylist,
} from "@/utils/video/ffmpeg/helpers";
+import { getHeaders } from "@/utils/helpers";
export const revalidate = 3599;
-const allowedOrigins = [
- process.env.NEXT_PUBLIC_URL,
- "https://cap.link",
- "cap.link",
-];
-
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get("origin") as string;
return new Response(null, {
status: 200,
- headers: {
- "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
- ? origin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- },
+ headers: getHeaders(origin),
});
}
@@ -54,13 +43,7 @@ export async function GET(request: NextRequest) {
}),
{
status: 401,
- headers: {
- "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
- ? origin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- },
+ headers: getHeaders(origin),
}
);
}
@@ -72,13 +55,7 @@ export async function GET(request: NextRequest) {
JSON.stringify({ error: true, message: "Video does not exist" }),
{
status: 401,
- headers: {
- "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
- ? origin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- },
+ headers: getHeaders(origin),
}
);
}
@@ -93,13 +70,7 @@ export async function GET(request: NextRequest) {
JSON.stringify({ error: true, message: "Video is not public" }),
{
status: 401,
- headers: {
- "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
- ? origin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- },
+ headers: getHeaders(origin),
}
);
}
@@ -127,13 +98,7 @@ export async function GET(request: NextRequest) {
JSON.stringify({ error: true, message: "Invalid video type" }),
{
status: 401,
- headers: {
- "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
- ? origin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- },
+ headers: getHeaders(origin),
}
);
}
@@ -213,14 +178,7 @@ export async function GET(request: NextRequest) {
return new Response(generatedPlaylist, {
status: 200,
- headers: {
- "content-type": "application/vnd.apple.mpegurl",
- "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
- ? origin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- },
+ headers: getHeaders(origin),
});
}
@@ -264,14 +222,7 @@ export async function GET(request: NextRequest) {
return new Response(generatedPlaylist, {
status: 200,
- headers: {
- "content-type": "application/vnd.apple.mpegurl",
- "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
- ? origin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- },
+ headers: getHeaders(origin),
});
} catch (error) {
console.error("Error generating video segment URLs", error);
@@ -279,13 +230,7 @@ export async function GET(request: NextRequest) {
JSON.stringify({ error: error, message: "Error generating video URLs" }),
{
status: 500,
- headers: {
- "Access-Control-Allow-Origin": allowedOrigins.includes(origin)
- ? origin
- : "null",
- "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- },
+ headers: getHeaders(origin),
}
);
}
diff --git a/apps/web/app/api/settings/billing/usage/route.ts b/apps/web/app/api/settings/billing/usage/route.ts
new file mode 100644
index 00000000..58cd65e9
--- /dev/null
+++ b/apps/web/app/api/settings/billing/usage/route.ts
@@ -0,0 +1,58 @@
+import { isUserOnProPlan } from "@cap/utils";
+import { getCurrentUser } from "@cap/database/auth/session";
+import { NextRequest } from "next/server";
+import { count, eq } from "drizzle-orm";
+import { db } from "@cap/database";
+import { videos } from "@cap/database/schema";
+
+export async function GET(request: NextRequest) {
+ const user = await getCurrentUser();
+
+ if (!user) {
+ return new Response(JSON.stringify({ auth: false }), {
+ status: 401,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ }
+
+ const numberOfVideos = await db
+ .select({ count: count() })
+ .from(videos)
+ .where(eq(videos.ownerId, user.id));
+
+ if (
+ isUserOnProPlan({
+ subscriptionStatus: user.stripeSubscriptionStatus as string,
+ })
+ ) {
+ return new Response(
+ JSON.stringify({
+ subscription: true,
+ videoLimit: 0,
+ videoCount: numberOfVideos[0].count,
+ }),
+ {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ } else {
+ return new Response(
+ JSON.stringify({
+ subscription: false,
+ videoLimit: 25,
+ videoCount: numberOfVideos[0].count,
+ }),
+ {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ }
+}
diff --git a/apps/web/app/api/upload/signed/route.ts b/apps/web/app/api/upload/signed/route.ts
index 71ffac9f..33dc5569 100644
--- a/apps/web/app/api/upload/signed/route.ts
+++ b/apps/web/app/api/upload/signed/route.ts
@@ -44,6 +44,8 @@ export async function POST(request: NextRequest) {
? "audio/webm"
: fileKey.endsWith(".mp4")
? "video/mp4"
+ : fileKey.endsWith(".mp3")
+ ? "audio/mpeg"
: "video/mp2t";
const Fields = {
diff --git a/apps/web/app/api/video/comment/route.ts b/apps/web/app/api/video/comment/route.ts
index 212b08b3..f944badc 100644
--- a/apps/web/app/api/video/comment/route.ts
+++ b/apps/web/app/api/video/comment/route.ts
@@ -3,8 +3,10 @@ import { getCurrentUser } from "@cap/database/auth/session";
import { nanoId } from "@cap/database/helpers";
import { comments } from "@cap/database/schema";
import { db } from "@cap/database";
+import { rateLimitMiddleware } from "@/utils/helpers";
+import { headers } from "next/headers";
-export async function POST(request: NextRequest) {
+async function handlePost(request: NextRequest) {
const user = await getCurrentUser();
const { type, content, videoId, timestamp, parentCommentId } =
await request.json();
@@ -42,3 +44,8 @@ export async function POST(request: NextRequest) {
})
);
}
+
+export const POST = (request: NextRequest) => {
+ const headersList = headers();
+ return rateLimitMiddleware(10, handlePost(request), headersList);
+};
diff --git a/apps/web/app/api/video/playlistUrl/route.ts b/apps/web/app/api/video/playlistUrl/route.ts
new file mode 100644
index 00000000..7786c4be
--- /dev/null
+++ b/apps/web/app/api/video/playlistUrl/route.ts
@@ -0,0 +1,72 @@
+import { type NextRequest } from "next/server";
+import { db } from "@cap/database";
+import { videos } from "@cap/database/schema";
+import { eq } from "drizzle-orm";
+import { getHeaders } from "@/utils/helpers";
+
+export const revalidate = 0;
+
+export async function OPTIONS(request: NextRequest) {
+ const origin = request.headers.get("origin") as string;
+
+ return new Response(null, {
+ status: 200,
+ headers: getHeaders(origin),
+ });
+}
+
+export async function GET(request: NextRequest) {
+ const searchParams = request.nextUrl.searchParams;
+ const userId = searchParams.get("userId") || "";
+ const videoId = searchParams.get("videoId") || "";
+ const origin = request.headers.get("origin") as string;
+
+ if (!userId || !videoId) {
+ return new Response(
+ JSON.stringify({
+ error: true,
+ message: "userId or videoId not supplied",
+ }),
+ {
+ status: 401,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+
+ const query = await db.select().from(videos).where(eq(videos.id, videoId));
+
+ if (query.length === 0) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video does not exist" }),
+ {
+ status: 401,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+
+ const video = query[0];
+
+ if (video.jobStatus === "COMPLETE") {
+ const playlistUrl = `https://v.cap.so/${video.ownerId}/${video.id}/output/video_recording_000_output.m3u8`;
+ return new Response(
+ JSON.stringify({ playlistOne: playlistUrl, playlistTwo: null }),
+ {
+ status: 200,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+
+ return new Response(
+ JSON.stringify({
+ playlistOne: `${process.env.NEXT_PUBLIC_URL}/api/playlist?userId=${video.ownerId}&videoId=${video.id}&videoType=video`,
+ playlistTwo: `${process.env.NEXT_PUBLIC_URL}/api/playlist?userId=${video.ownerId}&videoId=${video.id}&videoType=audio`,
+ }),
+ {
+ status: 200,
+ headers: getHeaders(origin),
+ }
+ );
+}
diff --git a/apps/web/app/api/video/title/route.ts b/apps/web/app/api/video/title/route.ts
index 290de1d2..aaed8d67 100644
--- a/apps/web/app/api/video/title/route.ts
+++ b/apps/web/app/api/video/title/route.ts
@@ -4,8 +4,10 @@ import { nanoId } from "@cap/database/helpers";
import { videos } from "@cap/database/schema";
import { db } from "@cap/database";
import { eq } from "drizzle-orm";
+import { headers } from "next/headers";
+import { rateLimitMiddleware } from "@/utils/helpers";
-export async function PUT(request: NextRequest) {
+export async function handlePut(request: NextRequest) {
const user = await getCurrentUser();
const { title, videoId } = await request.json();
const userId = user?.id as string;
@@ -59,3 +61,8 @@ export async function PUT(request: NextRequest) {
})
);
}
+
+export const PUT = (request: NextRequest) => {
+ const headersList = headers();
+ return rateLimitMiddleware(10, handlePut(request), headersList);
+};
diff --git a/apps/web/app/api/video/transcribe/route.ts b/apps/web/app/api/video/transcribe/route.ts
new file mode 100644
index 00000000..9a4b1f59
--- /dev/null
+++ b/apps/web/app/api/video/transcribe/route.ts
@@ -0,0 +1,280 @@
+import { NextRequest } from "next/server";
+import {
+ S3Client,
+ ListObjectsV2Command,
+ GetObjectCommand,
+ HeadObjectCommand,
+ PutObjectCommand,
+} from "@aws-sdk/client-s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
+import { createClient } from "@deepgram/sdk";
+import { getHeaders } from "@/utils/helpers";
+import { db } from "@cap/database";
+import { videos } from "@cap/database/schema";
+import { eq } from "drizzle-orm";
+
+export const maxDuration = 120;
+
+export async function OPTIONS(request: NextRequest) {
+ const origin = request.headers.get("origin") as string;
+
+ return new Response(null, {
+ status: 200,
+ headers: getHeaders(origin),
+ });
+}
+
+export async function GET(request: NextRequest) {
+ const searchParams = request.nextUrl.searchParams;
+ const userId = searchParams.get("userId") || "";
+ const videoId = searchParams.get("videoId") || "";
+ const origin = request.headers.get("origin") as string;
+
+ if (
+ !process.env.CAP_AWS_BUCKET ||
+ !process.env.CAP_AWS_REGION ||
+ !process.env.CAP_AWS_ACCESS_KEY ||
+ !process.env.CAP_AWS_SECRET_KEY ||
+ !process.env.DEEPGRAM_API_KEY
+ ) {
+ return new Response(
+ JSON.stringify({
+ error: true,
+ message: "Missing necessary environment variables",
+ }),
+ {
+ status: 500,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+
+ if (!userId || !videoId) {
+ return new Response(
+ JSON.stringify({
+ error: true,
+ message: "userId or videoId not supplied",
+ }),
+ {
+ status: 401,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+
+ const query = await db.select().from(videos).where(eq(videos.id, videoId));
+
+ if (query.length === 0) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video does not exist" }),
+ {
+ status: 401,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+
+ const video = query[0];
+
+ if (
+ video.transcriptionStatus === "COMPLETE" ||
+ video.transcriptionStatus === "PROCESSING"
+ ) {
+ return new Response(
+ JSON.stringify({
+ message: "Transcription already completed or in progress",
+ }),
+ {
+ status: 200,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+
+ await db
+ .update(videos)
+ .set({ transcriptionStatus: "PROCESSING" })
+ .where(eq(videos.id, videoId));
+
+ const bucket = process.env.CAP_AWS_BUCKET || "";
+ const audioPrefix = `${userId}/${videoId}/audio/`;
+
+ const s3Client = new S3Client({
+ region: process.env.CAP_AWS_REGION || "",
+ credentials: {
+ accessKeyId: process.env.CAP_AWS_ACCESS_KEY || "",
+ secretAccessKey: process.env.CAP_AWS_SECRET_KEY || "",
+ },
+ });
+
+ try {
+ const audioSegmentCommand = new ListObjectsV2Command({
+ Bucket: bucket,
+ Prefix: audioPrefix,
+ });
+
+ const objects = await s3Client.send(audioSegmentCommand);
+
+ const audioFiles = await Promise.all(
+ (objects.Contents || []).map(async (object) => {
+ const presignedUrl = await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({
+ Bucket: bucket,
+ Key: object.Key,
+ })
+ );
+
+ return presignedUrl;
+ })
+ );
+
+ const uploadUrl = await getSignedUrl(
+ s3Client,
+ new PutObjectCommand({
+ Bucket: bucket,
+ Key: `${userId}/${videoId}/merged/audio.mp3`,
+ })
+ );
+
+ const tasksServerResponse = await fetch(
+ `${process.env.NEXT_PUBLIC_TASKS_URL}/api/v1/merge-audio-segments`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ videoId,
+ segments: audioFiles,
+ uploadUrl,
+ }),
+ }
+ );
+
+ if (tasksServerResponse.status !== 200) {
+ throw new Error("Failed to merge audio segments");
+ }
+
+ const uploadedFileUrl = await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({
+ Bucket: bucket,
+ Key: `${userId}/${videoId}/merged/audio.mp3`,
+ })
+ );
+
+ const transcription = await transcribeAudio(uploadedFileUrl);
+
+ if (transcription === "") {
+ throw new Error("Failed to transcribe audio");
+ }
+
+ const uploadCommand = new PutObjectCommand({
+ Bucket: bucket,
+ Key: `${userId}/${videoId}/transcription.vtt`,
+ Body: transcription,
+ ContentType: "text/vtt",
+ });
+
+ await s3Client.send(uploadCommand);
+
+ await db
+ .update(videos)
+ .set({ transcriptionStatus: "COMPLETE" })
+ .where(eq(videos.id, videoId));
+
+ return new Response(
+ JSON.stringify({
+ message: "VTT file generated and uploaded successfully",
+ }),
+ {
+ status: 200,
+ headers: getHeaders(origin),
+ }
+ );
+ } catch (error) {
+ console.error("Error processing audio files", error);
+ await db
+ .update(videos)
+ .set({ transcriptionStatus: "ERROR" })
+ .where(eq(videos.id, videoId));
+
+ return new Response(
+ JSON.stringify({ error: true, message: "Error processing audio files" }),
+ {
+ status: 500,
+ headers: getHeaders(origin),
+ }
+ );
+ }
+}
+
+function formatToWebVTT(result: any): string {
+ let output = "WEBVTT\n\n";
+ let captionIndex = 1;
+
+ result.results.utterances.forEach((utterance: any) => {
+ let words = utterance.words;
+ let group = [];
+ let start = formatTimestamp(words[0].start);
+ let wordCount = 0;
+
+ for (let i = 0; i < words.length; i++) {
+ let word = words[i];
+ group.push(word.word);
+ wordCount++;
+
+ if (
+ word.punctuated_word.endsWith(",") ||
+ word.punctuated_word.endsWith(".") ||
+ (words[i + 1] && words[i + 1].start - word.end > 0.5) ||
+ wordCount === 8
+ ) {
+ let end = formatTimestamp(word.end);
+ let groupText = group.join(" ");
+
+ output += `${captionIndex}\n${start} --> ${end}\n${groupText}\n\n`;
+ captionIndex++;
+
+ group = [];
+ start = words[i + 1] ? formatTimestamp(words[i + 1].start) : start;
+ wordCount = 0; // Reset the counter for the next group
+ }
+ }
+ });
+
+ return output;
+}
+
+function formatTimestamp(seconds: number): string {
+ const date = new Date(seconds * 1000);
+ const hours = date.getUTCHours().toString().padStart(2, "0");
+ const minutes = date.getUTCMinutes().toString().padStart(2, "0");
+ const secs = date.getUTCSeconds().toString().padStart(2, "0");
+ const millis = (date.getUTCMilliseconds() / 1000).toFixed(3).slice(2, 5);
+
+ return `${hours}:${minutes}:${secs}.${millis}`;
+}
+
+async function transcribeAudio(audioUrl: string): Promise {
+ const deepgram = createClient(process.env.DEEPGRAM_API_KEY as string);
+
+ const { result, error } = await deepgram.listen.prerecorded.transcribeUrl(
+ {
+ url: audioUrl,
+ },
+ {
+ model: "nova-2",
+ smart_format: true,
+ detect_language: true,
+ utterances: true,
+ }
+ );
+
+ if (error) return "";
+
+ const captions = formatToWebVTT(result);
+
+ return captions;
+}
diff --git a/apps/web/app/api/video/transcribe/status/route.ts b/apps/web/app/api/video/transcribe/status/route.ts
new file mode 100644
index 00000000..bb101e19
--- /dev/null
+++ b/apps/web/app/api/video/transcribe/status/route.ts
@@ -0,0 +1,59 @@
+import { isUserOnProPlan } from "@cap/utils";
+import { getCurrentUser } from "@cap/database/auth/session";
+import { NextRequest } from "next/server";
+import { count, eq } from "drizzle-orm";
+import { db } from "@cap/database";
+import { videos } from "@cap/database/schema";
+
+export async function GET(request: NextRequest) {
+ const user = await getCurrentUser();
+ const url = new URL(request.url);
+ const videoId = url.searchParams.get("videoId");
+
+ if (!user) {
+ return new Response(JSON.stringify({ auth: false }), {
+ status: 401,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ }
+
+ if (!videoId) {
+ return new Response(
+ JSON.stringify({ error: true, message: "videoId not supplied" }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ }
+
+ const video = await db.select().from(videos).where(eq(videos.id, videoId));
+
+ if (video.length === 0) {
+ return new Response(
+ JSON.stringify({ error: true, message: "Video does not exist" }),
+ {
+ status: 404,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ }
+
+ return new Response(
+ JSON.stringify({
+ transcriptionStatus: video[0].transcriptionStatus,
+ }),
+ {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+}
diff --git a/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx b/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx
index 72dd4550..3d25ae09 100644
--- a/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx
+++ b/apps/web/app/dashboard/_components/DynamicSharedLayout.tsx
@@ -11,7 +11,7 @@ import {
DropdownMenuTrigger,
Button,
} from "@cap/ui";
-
+import { UsageButton } from "@/components/UsageButton";
import { users, spaces } from "@cap/database/schema";
import Link from "next/link";
import { isUserOnProPlan } from "@cap/utils";
@@ -19,7 +19,7 @@ import { isUserOnProPlan } from "@cap/utils";
type SharedContext = {
spaceData: (typeof spaces.$inferSelect)[] | null;
activeSpace: typeof spaces.$inferSelect | null;
- user: typeof users.$inferSelect | null;
+ user: typeof users.$inferSelect;
};
const Context = createContext({} as SharedContext);
@@ -43,20 +43,7 @@ export default function DynamicSharedLayout({
- {isUserOnProPlan({
- subscriptionStatus: user?.stripeSubscriptionStatus as string,
- }) ? (
-
- Cap Pro
-
- ) : (
-
- Upgrade to Cap Pro
-
- )}
+
{
const { refresh } = useRouter();
const params = useSearchParams();
const page = Number(params.get("page")) || 1;
- console.log("page: ", page);
const [analytics, setAnalytics] = useState>({});
const { user } = useSharedContext();
- const limit = 16;
+ const limit = 15;
const totalPages = Math.ceil(count / limit);
const [isEditing, setIsEditing] = useState(null);
+ const [isDownloading, setIsDownloading] = useState(null);
const [titles, setTitles] = useState>({});
const handleTitleBlur = async ({ id }: { id: string }) => {
@@ -106,6 +108,46 @@ export const Caps = ({ data, count }: { data: videoData; count: number }) => {
fetchAnalytics();
}, [data]);
+ const downloadCap = async (videoId: string) => {
+ if (isDownloading !== null) {
+ toast.error(
+ "You are already downloading a Cap. Please wait for it to finish downloading."
+ );
+ return;
+ }
+
+ setIsDownloading(videoId);
+
+ toast
+ .promise(
+ (async () => {
+ const video = data.find((cap) => cap.id === videoId);
+ if (!video) {
+ throw new Error("Video not found");
+ }
+
+ const videoName = video.name || "Cap Video";
+ const mp4Blob = await playlistToMp4(user.id, video.id, video.name);
+ const downloadUrl = window.URL.createObjectURL(mp4Blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = downloadUrl;
+ a.download = `${videoName}.mp4`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(downloadUrl);
+ })(),
+ {
+ loading: "Downloading Cap...",
+ success: "Cap downloaded",
+ error: "Failed to download Cap",
+ }
+ )
+ .finally(() => {
+ setIsDownloading(null); // Reset downloading state after completion or failure
+ });
+ };
+
const deleteCap = async (videoId: string) => {
if (
!window.confirm(
@@ -162,7 +204,7 @@ export const Caps = ({ data, count }: { data: videoData; count: number }) => {
My Caps
-
+
{data.map((cap, index) => {
const videoAnalytics = analytics[cap.id];
@@ -174,7 +216,7 @@ export const Caps = ({ data, count }: { data: videoData; count: number }) => {
+
{
);
})}
-
-
-
- {page > 1 && (
-
-
-
- )}
-
-
- 1
-
-
- {page !== 1 && (
+ {(data.length > limit || data.length === limit || page !== 1) && (
+
+
+
+ {page > 1 && (
+
+
+
+ )}
- {page}
+ 1
- )}
- {totalPages > page + 1 && (
+ {page !== 1 && (
+
+
+ {page}
+
+
+ )}
+ {totalPages > page + 1 && (
+
+
+ {page + 1}
+
+
+ )}
+ {page > 2 && }
-
- {page + 1}
-
+ />
- )}
- {page > 2 && }
-
-
-
-
-
-
+
+
+
+ )}
)}
diff --git a/apps/web/app/dashboard/caps/page.tsx b/apps/web/app/dashboard/caps/page.tsx
index b2f59038..93b130be 100644
--- a/apps/web/app/dashboard/caps/page.tsx
+++ b/apps/web/app/dashboard/caps/page.tsx
@@ -18,7 +18,7 @@ export default async function CapsPage({
searchParams: { [key: string]: string | string[] | undefined };
}) {
const page = Number(searchParams.page) || 1;
- const limit = Number(searchParams.limit) || 16;
+ const limit = Number(searchParams.limit) || 15;
const user = await getCurrentUser();
const userId = user?.id as string;
diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx
index a181ac62..859e28fd 100644
--- a/apps/web/app/dashboard/layout.tsx
+++ b/apps/web/app/dashboard/layout.tsx
@@ -14,7 +14,7 @@ export default async function DashboardLayout({
}) {
const user = await getCurrentUser();
- if (!user) {
+ if (!user || !user.id) {
redirect("/login");
}
diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx
index 7e89913d..267c24dc 100644
--- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx
+++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx
@@ -29,6 +29,12 @@ export const ShareHeader = ({
body: JSON.stringify({ title, videoId: data.id }),
}
);
+
+ if (response.status === 429) {
+ toast.error("Too many requests - please try again later.");
+ return;
+ }
+
if (!response.ok) {
toast.error("Failed to update title - please try again.");
return;
diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
index ece53755..bfcf4e18 100644
--- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
+++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx
@@ -12,6 +12,9 @@ import {
import { LogoSpinner } from "@cap/ui";
import { userSelectProps } from "@cap/database/auth/session";
import { Tooltip } from "react-tooltip";
+import { fromVtt, Subtitle } from "subtitles-parser-vtt";
+import { is } from "drizzle-orm";
+import toast from "react-hot-toast";
declare global {
interface Window {
@@ -44,6 +47,12 @@ export const ShareVideo = ({
const [seeking, setSeeking] = useState(false);
const [videoMetadataLoaded, setVideoMetadataLoaded] = useState(false);
const [overlayVisible, setOverlayVisible] = useState(true);
+ const [subtitles, setSubtitles] = useState([]);
+ const [subtitlesVisible, setSubtitlesVisible] = useState(true);
+ const [isTranscriptionProcessing, setIsTranscriptionProcessing] =
+ useState(false);
+
+ const [videoSpeed, setVideoSpeed] = useState(1);
const overlayTimeoutRef = useRef(null);
useEffect(() => {
@@ -215,6 +224,21 @@ export const ShareVideo = ({
}
};
+ const handleSpeedChange = () => {
+ let newSpeed;
+ if (videoSpeed === 1) {
+ newSpeed = 1.5;
+ } else if (videoSpeed === 1.5) {
+ newSpeed = 2;
+ } else {
+ newSpeed = 1;
+ }
+ setVideoSpeed(newSpeed);
+ if (videoRef.current) {
+ videoRef.current.playbackRate = newSpeed;
+ }
+ };
+
const watchedPercentage =
longestDuration > 0 ? (currentTime / longestDuration) * 100 : 0;
@@ -239,6 +263,52 @@ export const ShareVideo = ({
}
}, [isPlaying, isLoading]);
+ const parseSubTime = (timeString: number) => {
+ const [hours, minutes, seconds] = timeString
+ .toString()
+ .split(":")
+ .map(Number);
+ return hours * 3600 + minutes * 60 + seconds;
+ };
+
+ useEffect(() => {
+ const fetchSubtitles = () => {
+ fetch(`https://v.cap.so/${data.ownerId}/${data.id}/transcription.vtt`)
+ .then((response) => response.text())
+ .then((text) => {
+ const parsedSubtitles = fromVtt(text);
+ setSubtitles(parsedSubtitles);
+ });
+ };
+
+ if (data.transcriptionStatus === "COMPLETE") {
+ fetchSubtitles();
+ } else {
+ const intervalId = setInterval(() => {
+ fetch(`/api/video/transcribe/status?videoId=${data.id}`)
+ .then((response) => response.json())
+ .then(({ transcriptionStatus }) => {
+ if (transcriptionStatus === "PROCESSING") {
+ setIsTranscriptionProcessing(true);
+ } else if (transcriptionStatus === "COMPLETE") {
+ fetchSubtitles();
+ clearInterval(intervalId);
+ } else if (transcriptionStatus === "FAILED") {
+ clearInterval(intervalId);
+ }
+ });
+ }, 1000);
+
+ return () => clearInterval(intervalId);
+ }
+ }, [data]);
+
+ const currentSubtitle = subtitles.find(
+ (subtitle) =>
+ parseSubTime(subtitle.startTime) <= currentTime &&
+ parseSubTime(subtitle.endTime) >= currentTime
+ );
+
if (data.jobStatus === "ERROR") {
return (
@@ -290,6 +360,16 @@ export const ShareVideo = ({
className="relative block w-full h-full rounded-lg bg-black"
style={{ paddingBottom: "min(806px, 56.25%)" }}
>
+ {currentSubtitle && currentSubtitle.text && subtitlesVisible && (
+
+
+ {currentSubtitle.text
+ .replace("- ", "")
+ .replace(".", "")
+ .replace(",", "")}
+
+
+ )}
+
+ {isTranscriptionProcessing && subtitles.length === 0 && (
+
+
+
+ )}
+ {subtitles.length > 0 && (
+
+
+
+ )}
+
+