├── frontend ├── .env.example ├── context.md ├── .gitignore ├── public │ ├── favicon.ico │ ├── favicon.png │ ├── logo-dark.png │ └── logo-light.png ├── postcss.config.js ├── types │ └── index.ts ├── vercel.json ├── app │ ├── utils │ │ └── theme.server.ts │ ├── tailwind.css │ ├── remotion │ │ ├── Root.tsx │ │ └── VideoWithSubtitles.tsx │ ├── routes │ │ ├── favicon.ico.ts │ │ ├── dashboard.tsx │ │ ├── AcmeIcon.tsx │ │ ├── dashboard.settings.tsx │ │ ├── dashboard.$id.tsx │ │ ├── login.tsx │ │ └── pricing.tsx │ ├── entry.client.tsx │ ├── cn.ts │ ├── stores │ │ └── authStore.ts │ ├── root.tsx │ ├── contexts │ │ └── AuthContext.tsx │ └── entry.server.tsx ├── api │ └── index.js ├── .vscode │ └── settings.json ├── .prettierrc ├── remix.config.js ├── utils │ ├── types │ │ └── supabase.d.ts │ ├── services │ │ ├── api │ │ │ ├── ProjectApi.ts │ │ │ └── AuthApi.ts │ │ └── axios.ts │ └── toasts.ts ├── scripts │ └── generate-favicon.js ├── tsconfig.json ├── README.md ├── components │ ├── primitives.ts │ ├── dashboard │ │ ├── VideoSegmentCard.tsx │ │ ├── ProjectsList.tsx │ │ └── FileUploader.tsx │ └── navbar │ │ ├── NotificationItem.tsx │ │ └── NavbarLayout.tsx ├── config │ └── site.ts ├── tailwind.config.ts ├── vite.config.ts ├── eslint.config.mjs └── package.json ├── .DS_Store ├── .vscode └── settings.json ├── server ├── README.md ├── src │ ├── types │ │ ├── user.d.ts │ │ ├── supabase.d.ts │ │ ├── transcription.d.ts │ │ └── openai.d.ts │ ├── routes │ │ ├── auth.ts │ │ └── project.ts │ ├── config │ │ └── config.ts │ ├── middlewares │ │ └── authMiddleware.ts │ ├── constants │ │ └── constants.ts │ ├── index.ts │ ├── services │ │ ├── ffmpegService.ts │ │ ├── transcriptionService.ts │ │ ├── openaiService.ts │ │ ├── promptService.ts │ │ ├── awsService.ts │ │ ├── projectService.ts │ │ └── supabaseService.ts │ └── controllers │ │ ├── authController.ts │ │ └── projectController.ts ├── .env.exemple ├── docker-compose.yml ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── package.json └── Dockerfile └── README.md /frontend/.env.example: -------------------------------------------------------------------------------- 1 | BASE_URL_BACKEND=YOUR_BACKEND_URL -------------------------------------------------------------------------------- /frontend/context.md: -------------------------------------------------------------------------------- 1 | This a remix.js app with zustand and axios. 2 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinrss01/magicCuts/HEAD/.DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.prettierPath": "./server/node_modules/prettier" 3 | } -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinrss01/magicCuts/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinrss01/magicCuts/HEAD/frontend/public/favicon.png -------------------------------------------------------------------------------- /frontend/public/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinrss01/magicCuts/HEAD/frontend/public/logo-dark.png -------------------------------------------------------------------------------- /frontend/public/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinrss01/magicCuts/HEAD/frontend/public/logo-light.png -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | npm install 3 | npm run dev 4 | ``` 5 | 6 | ``` 7 | open http://localhost:3000 8 | ``` 9 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/types/index.ts: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react"; 2 | 3 | export type IconSvgProps = SVGProps & { 4 | size?: number; 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "pnpm run build", 3 | "framework": "remix", 4 | "rewrites": [{ "source": "/(.*)", "destination": "/api" }] 5 | } 6 | -------------------------------------------------------------------------------- /server/src/types/user.d.ts: -------------------------------------------------------------------------------- 1 | export interface UserData { 2 | id: string; 3 | email: string; 4 | name: string; 5 | avatar: string; 6 | accessToken?: string; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/app/utils/theme.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookie } from "@remix-run/node"; 2 | 3 | export const themeCookie = createCookie("theme", { 4 | maxAge: 60 * 60 * 24 * 365, 5 | }); -------------------------------------------------------------------------------- /frontend/api/index.js: -------------------------------------------------------------------------------- 1 | // This file is the entry point for the Vercel serverless function 2 | // It re-exports the handler from the built server file 3 | 4 | export * from "../build/server/index.js"; 5 | -------------------------------------------------------------------------------- /frontend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.prettierPath": "./node_modules/prettier", 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "prettier.printWidth": 100 6 | } -------------------------------------------------------------------------------- /frontend/app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | @apply bg-white dark:bg-gray-950; 8 | 9 | @media (prefers-color-scheme: dark) { 10 | color-scheme: dark; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { 3 | googleLogin, 4 | authCallback, 5 | signIn, 6 | } from "../controllers/authController"; 7 | 8 | export const authRoutes = new Hono(); 9 | 10 | authRoutes.get("/google", googleLogin); 11 | authRoutes.post("/callback", authCallback); 12 | authRoutes.post("/signin", signIn); 13 | -------------------------------------------------------------------------------- /server/.env.exemple: -------------------------------------------------------------------------------- 1 | SUPABASE_URL=YOUR_URL 2 | SUPABASE_KEY=YOUR_KEY 3 | JWT_SECRET=YOUR_JWT 4 | SERVICE_ROLE_KEY=YOUR_ROLE_KEY 5 | PORT=4000 6 | 7 | #AWS 8 | 9 | #AWS 10 | AWS_S3_REGION=YOUR_URL 11 | AWS_ACCESS_KEY_ID= 12 | AWS_SECRET_ACCESS_KEY= 13 | AWS_BUCKET_NAME= 14 | 15 | DEEPGRAM_API_KEY= 16 | 17 | OPEN_AI_API_KEY= 18 | 19 | DOCKER=TRUE_OR_FALSE 20 | -------------------------------------------------------------------------------- /server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | app: 5 | container_name: hono-app-prod 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | args: 10 | - NODE_ENV=production 11 | env_file: 12 | - ./.env 13 | ports: 14 | - "4001:4000" 15 | volumes: 16 | - ./temp:/app/temp 17 | restart: unless-stopped 18 | -------------------------------------------------------------------------------- /server/src/types/supabase.d.ts: -------------------------------------------------------------------------------- 1 | import { DetectedSegments } from "./openai"; 2 | 3 | export interface ProjectDocument { 4 | id: string; 5 | user_id: string; 6 | original_video_url: string; 7 | detected_segments: DetectedSegments[]; 8 | state: "pending" | "completed" | "failed"; 9 | name?: string; 10 | createdDate?: string; 11 | tokens: number; 12 | is_premium: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.prettierPath": "./node_modules/prettier", 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "always" 5 | }, 6 | "editor.formatOnSave": true, 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "typescript", 11 | "typescriptreact" 12 | ], 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | } 15 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.prettierPath": "./node_modules/prettier", 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "always" 5 | }, 6 | "editor.formatOnSave": true, 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "typescript", 11 | "typescriptreact" 12 | ], 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | } 15 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # dev 2 | .yarn/ 3 | !.yarn/releases 4 | .vscode/* 5 | !.vscode/launch.json 6 | !.vscode/*.code-snippets 7 | .idea/workspace.xml 8 | .idea/usage.statistics.xml 9 | .idea/shelf 10 | 11 | # deps 12 | node_modules/ 13 | 14 | # env 15 | .env 16 | .env.production 17 | 18 | # logs 19 | logs/ 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # misc 28 | .DS_Store 29 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "strict": true, 6 | "verbatimModuleSyntax": true, 7 | "skipLibCheck": true, 8 | "types": ["node"], 9 | "jsx": "react-jsx", 10 | "jsxImportSource": "hono/jsx", 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "noEmit": true, 14 | "resolveJsonModule": true, 15 | "allowJs": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | ignoredRouteFiles: ["**/.*"], 4 | serverModuleFormat: "esm", 5 | serverPlatform: "node", 6 | // This is important for Vercel deployment 7 | publicPath: "/build/", 8 | serverBuildPath: "build/server/index.js", 9 | assetsBuildDirectory: "build/client", 10 | future: { 11 | v2_errorBoundary: true, 12 | v2_meta: true, 13 | v2_normalizeFormMethod: true, 14 | v2_routeConvention: true, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/app/remotion/Root.tsx: -------------------------------------------------------------------------------- 1 | import { Composition } from "remotion"; 2 | import { VideoWithSubtitles } from "./VideoWithSubtitles"; 3 | 4 | export const RemotionRoot: React.FC = () => { 5 | return ( 6 | <> 7 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/app/routes/favicon.ico.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/node"; 2 | 3 | export const loader = async ({ request }: LoaderFunctionArgs) => { 4 | // Get the favicon.png from the public directory 5 | const favicon = await fetch(new URL("/favicon.png", request.url)); 6 | 7 | // Return the favicon with the correct content type 8 | return new Response(await favicon.arrayBuffer(), { 9 | headers: { 10 | "Content-Type": "image/x-icon", 11 | "Cache-Control": "public, max-age=31536000", 12 | }, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /server/src/types/transcription.d.ts: -------------------------------------------------------------------------------- 1 | export interface Utterance { 2 | start: number; 3 | end: number; 4 | confidence: number; 5 | channel: number; 6 | transcript: string; 7 | speaker: number; 8 | id: string; 9 | words: Word[]; 10 | } 11 | 12 | export interface Word { 13 | word: string; 14 | start: number; 15 | end: number; 16 | confidence: number; 17 | speaker: number; 18 | speaker_confidence: number; 19 | punctuated_word: string; 20 | } 21 | 22 | export interface FormattedSegment { 23 | start: number; 24 | end: number; 25 | transcript: string; 26 | speaker: number; 27 | } 28 | -------------------------------------------------------------------------------- /server/src/config/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | 3 | dotenv.config(); 4 | 5 | export const config = { 6 | supabaseUrl: process.env.SUPABASE_URL!, 7 | supabaseKey: process.env.SUPABASE_KEY!, 8 | jwtSecret: process.env.JWT_SECRET!, 9 | serviceRoleKey: process.env.SERVICE_ROLE_KEY!, 10 | awsBucketName: process.env.AWS_BUCKET_NAME!, 11 | awsRegion: process.env.AWS_S3_REGION!, 12 | awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID!, 13 | awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 14 | deepgramApiKey: process.env.DEEPGRAM_API_KEY!, 15 | openAiApiKey: process.env.OPEN_AI_API_KEY!, 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/utils/types/supabase.d.ts: -------------------------------------------------------------------------------- 1 | export interface UserData { 2 | id: string; 3 | email: string; 4 | name: string; 5 | avatar: string; 6 | accessToken: string; 7 | tokens: number; 8 | is_premium: boolean; 9 | } 10 | 11 | export interface DetectedSegments { 12 | rank: number; 13 | start: number; 14 | end: number; 15 | reason: string; 16 | filePath?: string; 17 | } 18 | 19 | export interface ProjectDocument { 20 | id: string; 21 | user_id: string; 22 | original_video_url: string; 23 | detected_segments: DetectedSegments[]; 24 | state: "pending" | "completed" | "failed"; 25 | name?: string; 26 | createdDate?: string; 27 | } 28 | -------------------------------------------------------------------------------- /server/src/types/openai.d.ts: -------------------------------------------------------------------------------- 1 | export interface Models { 2 | gpt4Turbo: OpenAIModel; 3 | gpt4: OpenAIModel; 4 | gpt3Turbo: OpenAIModel; 5 | gpt3_16k: OpenAIModel; 6 | gpt4o: OpenAIModel; 7 | gpt4oMini: OpenAIModel; 8 | o1: OpenAIModel; 9 | o1Mini: OpenAIModel; 10 | o3Mini: OpenAIModel; 11 | } 12 | 13 | export type OpenAIModel = 14 | | "gpt-4-turbo" 15 | | "gpt-4" 16 | | "gpt-3.5-turbo-0125" 17 | | "gpt-3.5-turbo-16k" 18 | | "gpt-4o" 19 | | "gpt-4o-mini" 20 | | "o1-mini" 21 | | "o1" 22 | | "o3-mini"; 23 | 24 | export interface DetectedSegments { 25 | rank: number; 26 | start: number; 27 | end: number; 28 | reason: string; 29 | filePath?: string; 30 | } 31 | -------------------------------------------------------------------------------- /frontend/app/routes/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, useLocation } from "@remix-run/react"; 2 | import FileUploader from "../../components/dashboard/FileUploader"; 3 | import ProjectsList from "../../components/dashboard/ProjectsList"; 4 | 5 | const Dashboard = () => { 6 | const location = useLocation(); 7 | const isIndexRoute = location.pathname === "/dashboard"; 8 | 9 | return ( 10 |
11 | {isIndexRoute ? ( 12 | <> 13 | 14 | 15 | 16 | ) : ( 17 | 18 | )} 19 |
20 | ); 21 | }; 22 | 23 | export default Dashboard; 24 | -------------------------------------------------------------------------------- /frontend/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | import process from "process"; 12 | window.process = process; 13 | 14 | startTransition(() => { 15 | hydrateRoot( 16 | document, 17 | 18 | 19 | 20 | ); 21 | }); 22 | -------------------------------------------------------------------------------- /server/src/middlewares/authMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'hono'; 2 | import jwt from 'jsonwebtoken'; 3 | import { config } from '../config/config'; 4 | 5 | export const authMiddleware = async (c: Context, next: () => Promise) => { 6 | const authHeader = c.req.header('Authorization'); 7 | if (!authHeader) { 8 | return c.json({ error: 'No token provided' }, 401); 9 | } 10 | 11 | const token = authHeader.split(' ')[1]; 12 | if (!token) { 13 | return c.json({ error: 'Token format invalid' }, 401); 14 | } 15 | 16 | try { 17 | const decoded = jwt.verify(token, config.jwtSecret); 18 | c.set('user', decoded); 19 | await next(); 20 | } catch (err) { 21 | return c.json({ error: 'Invalid token' }, 401); 22 | } 23 | }; -------------------------------------------------------------------------------- /frontend/app/routes/AcmeIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react"; 2 | 3 | export type IconSvgProps = SVGProps & { 4 | size?: number; 5 | }; 6 | 7 | import React from "react"; 8 | 9 | export const AcmeIcon: React.FC = ({ 10 | size = 32, 11 | width, 12 | height, 13 | ...props 14 | }) => ( 15 | 22 | 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /frontend/app/cn.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from "clsx"; 2 | 3 | import clsx from "clsx"; 4 | import { extendTailwindMerge } from "tailwind-merge"; 5 | 6 | const COMMON_UNITS = ["small", "medium", "large"]; 7 | 8 | /** 9 | * We need to extend the tailwind merge to include NextUI's custom classes. 10 | * 11 | * So we can use classes like `text-small` or `text-default-500` and override them. 12 | */ 13 | const twMerge = extendTailwindMerge({ 14 | extend: { 15 | theme: { 16 | spacing: ["divider"], 17 | }, 18 | classGroups: { 19 | shadow: [{ shadow: COMMON_UNITS }], 20 | "font-size": [{ text: ["tiny", ...COMMON_UNITS] }], 21 | "bg-image": ["bg-stripe-gradient"], 22 | }, 23 | }, 24 | }); 25 | 26 | export function cn(...inputs: ClassValue[]) { 27 | return twMerge(clsx(inputs)); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/scripts/generate-favicon.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import sharp from "sharp"; 4 | 5 | async function generateFavicon() { 6 | // Get the correct path to the public directory 7 | const publicDir = path.resolve("frontend/public"); 8 | const pngPath = path.join(publicDir, "favicon.png"); 9 | const icoPath = path.join(publicDir, "favicon.ico"); 10 | 11 | try { 12 | // Check if favicon.png exists 13 | if (!fs.existsSync(pngPath)) { 14 | console.error("favicon.png not found in public directory:", pngPath); 15 | return; 16 | } 17 | 18 | // Convert PNG to ICO (actually it's just a resized PNG with .ico extension) 19 | await sharp(pngPath) 20 | .resize(32, 32) // Standard favicon size 21 | .toFile(icoPath); 22 | 23 | console.log("favicon.ico generated successfully"); 24 | } catch (error) { 25 | console.error("Error generating favicon.ico:", error); 26 | } 27 | } 28 | 29 | generateFavicon(); 30 | -------------------------------------------------------------------------------- /frontend/app/stores/authStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import type { UserData, ProjectDocument } from "~/utils/types/supabase"; 3 | 4 | interface AuthState { 5 | isAuthenticated: boolean; 6 | accessToken: string | null; 7 | userData: UserData | null; 8 | userProjects: ProjectDocument[]; 9 | setAuthenticated: (value: boolean) => void; 10 | setUserProjects: (projects: ProjectDocument[]) => void; 11 | setAccessToken: (token: string | null) => void; 12 | setUserData: (data: UserData | null) => void; 13 | } 14 | 15 | export const useAuthStore = create((set) => ({ 16 | isAuthenticated: false, 17 | accessToken: null, 18 | userProjects: [], 19 | userData: null, 20 | setAuthenticated: (value: boolean) => set({ isAuthenticated: value }), 21 | setUserProjects: (projects: ProjectDocument[]) => 22 | set({ userProjects: projects }), 23 | setAccessToken: (token: string | null) => set({ accessToken: token }), 24 | setUserData: (data: UserData | null) => set({ userData: data }), 25 | })); 26 | -------------------------------------------------------------------------------- /frontend/utils/services/api/ProjectApi.ts: -------------------------------------------------------------------------------- 1 | import { ProjectDocument } from "~/utils/types/supabase"; 2 | import AxiosCallApi from "../axios"; 3 | 4 | const PREFIX = "projects"; 5 | 6 | const formatSuffix = (suffix: string) => `${PREFIX}/${suffix}`; 7 | 8 | export class ProjectAPI { 9 | static async getProject(projectDocumentId: string) { 10 | return AxiosCallApi.post<{ projectDocumentId: string }, ProjectDocument>( 11 | formatSuffix("getProject"), 12 | { 13 | projectDocumentId, 14 | }, 15 | ); 16 | } 17 | 18 | static async createProject(formData: FormData) { 19 | return AxiosCallApi.post( 20 | formatSuffix("createProject"), 21 | formData, 22 | { 23 | headers: { 24 | "Content-Type": "multipart/form-data", 25 | }, 26 | }, 27 | ); 28 | } 29 | 30 | static async getAllProjects() { 31 | return AxiosCallApi.post<{}, ProjectDocument[]>( 32 | formatSuffix("getAllProjects"), 33 | {}, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/app/remotion/VideoWithSubtitles.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AbsoluteFill, Video } from "remotion"; 3 | 4 | interface VideoWithSubtitlesProps { 5 | videoUrl: string; 6 | subtitleText: string; 7 | } 8 | 9 | export const VideoWithSubtitles: React.FC = ({ 10 | videoUrl, 11 | subtitleText, 12 | }) => { 13 | return ( 14 | 15 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "resolveJsonModule": true, 19 | "target": "ES2022", 20 | "strict": true, 21 | "allowJs": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "~/*": ["./app/*"], 27 | "~/utils/*": ["./utils/*"], 28 | "~/types/*": ["./utils/types/*"], 29 | "~/stores/*": ["./app/stores/*"] 30 | }, 31 | "sourceMap": true, 32 | "inlineSources": true, 33 | 34 | // Vite takes care of building everything, not tsc. 35 | "noEmit": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-project", 3 | "type": "module", 4 | "scripts": { 5 | "dev": "tsx watch src/index.ts", 6 | "start": "tsx src/index.ts", 7 | "build": "tsc" 8 | }, 9 | "dependencies": { 10 | "@aws-sdk/client-s3": "^3.750.0", 11 | "@aws-sdk/lib-storage": "^3.750.0", 12 | "@aws-sdk/s3-request-presigner": "^3.750.0", 13 | "@deepgram/sdk": "^3.11.1", 14 | "@hono/node-server": "^1.13.8", 15 | "@remotion/renderer": "^4.0.271", 16 | "@supabase/supabase-js": "^2.48.1", 17 | "@types/ffprobe-static": "^2.0.3", 18 | "@types/fluent-ffmpeg": "^2.1.27", 19 | "dotenv": "^16.4.7", 20 | "fluent-ffmpeg": "^2.1.3", 21 | "hono": "^4.7.2", 22 | "jsonwebtoken": "^9.0.2", 23 | "openai": "^4.85.3", 24 | "prettier": "^3.5.1", 25 | "remotion": "^4.0.271", 26 | "stripe": "^17.6.0", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0" 29 | }, 30 | "devDependencies": { 31 | "@types/jsonwebtoken": "^9.0.9", 32 | "@types/node": "^20.11.17", 33 | "tsx": "^4.7.1", 34 | "@types/react": "^18.2.0", 35 | "@types/react-dom": "^18.2.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/src/routes/project.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { ProjectController } from "../controllers/projectController"; 3 | export const projectRoutes = new Hono(); 4 | 5 | projectRoutes.post("/createProject", async (c) => { 6 | console.debug("Starting createProject route..."); 7 | const projectController = new ProjectController(); 8 | const result = await projectController.createProject(c); 9 | console.debug("createProject route completed"); 10 | return c.json(result); 11 | }); 12 | 13 | projectRoutes.post("/getProject", async (c) => { 14 | const projectController = new ProjectController(); 15 | const result = await projectController.getProject(c); 16 | return c.json(result); 17 | }); 18 | 19 | projectRoutes.post("/getAllProjects", async (c) => { 20 | const projectController = new ProjectController(); 21 | const result = await projectController.getAllProjects(c); 22 | return c.json(result); 23 | }); 24 | 25 | projectRoutes.post("/addSubtitle", async (c) => { 26 | const projectController = new ProjectController(); 27 | const result = await projectController.addSubtitle(c); 28 | // @ts-ignore 29 | return c.json(result); 30 | }); 31 | -------------------------------------------------------------------------------- /frontend/utils/services/api/AuthApi.ts: -------------------------------------------------------------------------------- 1 | import { ProjectDocument, UserData } from "~/utils/types/supabase"; 2 | import AxiosCallApi from "../axios"; 3 | 4 | const PREFIX = "auth"; 5 | 6 | const formatSuffix = (suffix: string) => `${PREFIX}/${suffix}`; 7 | 8 | export class AuthAPI { 9 | static async getAuth() { 10 | return AxiosCallApi.get<{ url: string }>(formatSuffix("google")); 11 | } 12 | 13 | static async signIn(accessToken: string) { 14 | return AxiosCallApi.post< 15 | { accessToken: string }, 16 | { 17 | accessToken: string; 18 | userData: { 19 | userRelativeData: UserData; 20 | userDocuments: ProjectDocument[]; 21 | }; 22 | } 23 | >(formatSuffix("signin"), { 24 | accessToken: accessToken, 25 | }); 26 | } 27 | 28 | static async callback(accessToken: string) { 29 | return AxiosCallApi.post< 30 | { accessToken: string }, 31 | { 32 | accessToken: string; 33 | userInfo: { 34 | userRelativeData: UserData; 35 | userDocuments: ProjectDocument[]; 36 | }; 37 | } 38 | >(formatSuffix("callback"), { 39 | accessToken: accessToken, 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # ----------------------------------- 2 | # STEP 1: BASE 3 | # ----------------------------------- 4 | FROM node:20-alpine3.18 AS base 5 | 6 | # Install ffmpeg, which includes ffprobe (system binary). 7 | # Also install build tools (g++, make, etc.) 8 | RUN apk add --update \ 9 | python3 py3-pip \ 10 | ffmpeg ttf-dejavu \ 11 | && apk add --no-cache --virtual .gyp \ 12 | python3 \ 13 | make \ 14 | g++ 15 | 16 | # PNPM 17 | ENV PNPM_HOME="/usr/local/share/pnpm" 18 | ENV PATH="$PNPM_HOME:$PATH" 19 | 20 | RUN npm i -g pnpm && \ 21 | mkdir -p ${PNPM_HOME} && \ 22 | pnpm config set global-bin-dir ${PNPM_HOME} 23 | 24 | # ----------------------------------- 25 | # STEP 2: DEPENDENCIES & DEPLOYMENT 26 | # ----------------------------------- 27 | FROM base AS deploy 28 | 29 | WORKDIR /app 30 | 31 | # Copy package files 32 | COPY package.json pnpm-lock.yaml ./ 33 | 34 | # Install dependencies 35 | RUN pnpm install --no-frozen-lockfile 36 | 37 | # Copy source code 38 | COPY . . 39 | 40 | # Create necessary directories for file processing 41 | RUN mkdir -p /app/temp/input /app/temp/output 42 | 43 | ENV NODE_ENV=production 44 | ENV DOCKER=true 45 | EXPOSE 4000 46 | 47 | # Launch the Hono application using tsx directly 48 | CMD [ "pnpm", "start" ] -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Remix & HeroUI Template 2 | 3 | This is a template for creating applications using Next.js 14 (app directory) and HeroUI (v2). 4 | 5 | ## Technologies Used 6 | 7 | - [Remix 2](https://remix.run/docs/en/main/start/quickstart) 8 | - [HeroUI v2](https://heroui.com/) 9 | - [Tailwind CSS](https://tailwindcss.com/) 10 | - [Tailwind Variants](https://tailwind-variants.org) 11 | - [TypeScript](https://www.typescriptlang.org/) 12 | - [Framer Motion](https://www.framer.com/motion/) 13 | 14 | ## How to Use 15 | 16 | ### Use the template with create-remix 17 | 18 | To create a new project based on this template using `create-remix`, run the following command: 19 | 20 | ```bash 21 | npx create-next-app -e https://github.com/frontio-ai/remix-template.git 22 | ``` 23 | 24 | ### Install dependencies 25 | 26 | You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`: 27 | 28 | ```bash 29 | npm install 30 | ``` 31 | 32 | ### Run the development server 33 | 34 | ```bash 35 | npm run dev 36 | ``` 37 | 38 | ### Setup pnpm (optional) 39 | 40 | If you are using `pnpm`, you need to add the following code to your `.npmrc` file: 41 | 42 | ```bash 43 | public-hoist-pattern[]=*@heroui/* 44 | ``` 45 | 46 | After modifying the `.npmrc` file, you need to run `pnpm install` again to ensure that the dependencies are installed correctly. 47 | -------------------------------------------------------------------------------- /server/src/constants/constants.ts: -------------------------------------------------------------------------------- 1 | export const responseFormat = { 2 | type: "json_schema", 3 | json_schema: { 4 | name: "segments_schema", 5 | strict: true, 6 | schema: { 7 | type: "object", 8 | properties: { 9 | segments: { 10 | type: "array", 11 | description: 12 | "A list of segments containing rank, start, end, and reason.", 13 | items: { 14 | type: "object", 15 | properties: { 16 | rank: { 17 | type: "number", 18 | description: "The rank of the segment.", 19 | }, 20 | start: { 21 | type: "number", 22 | description: "The start position of the segment.", 23 | }, 24 | end: { 25 | type: "number", 26 | description: "The end position of the segment.", 27 | }, 28 | reason: { 29 | type: "string", 30 | description: "The reason for defining this segment.", 31 | }, 32 | }, 33 | required: ["rank", "start", "end", "reason"], 34 | additionalProperties: false, 35 | }, 36 | }, 37 | }, 38 | required: ["segments"], 39 | additionalProperties: false, 40 | }, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/components/primitives.ts: -------------------------------------------------------------------------------- 1 | import { tv } from "tailwind-variants"; 2 | 3 | export const title = tv({ 4 | base: "tracking-tight inline font-semibold", 5 | variants: { 6 | color: { 7 | violet: "from-[#FF1CF7] to-[#b249f8]", 8 | yellow: "from-[#FF705B] to-[#FFB457]", 9 | blue: "from-[#5EA2EF] to-[#0072F5]", 10 | cyan: "from-[#00b7fa] to-[#01cfea]", 11 | green: "from-[#6FEE8D] to-[#17c964]", 12 | pink: "from-[#FF72E1] to-[#F54C7A]", 13 | foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]", 14 | }, 15 | size: { 16 | sm: "text-3xl lg:text-4xl", 17 | md: "text-[2.3rem] lg:text-5xl leading-9", 18 | lg: "text-4xl lg:text-6xl", 19 | }, 20 | fullWidth: { 21 | true: "w-full block", 22 | }, 23 | }, 24 | defaultVariants: { 25 | size: "md", 26 | }, 27 | compoundVariants: [ 28 | { 29 | color: [ 30 | "violet", 31 | "yellow", 32 | "blue", 33 | "cyan", 34 | "green", 35 | "pink", 36 | "foreground", 37 | ], 38 | class: "bg-clip-text text-transparent bg-gradient-to-b", 39 | }, 40 | ], 41 | }); 42 | 43 | export const subtitle = tv({ 44 | base: "w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full", 45 | variants: { 46 | fullWidth: { 47 | true: "!w-full", 48 | }, 49 | }, 50 | defaultVariants: { 51 | fullWidth: true, 52 | }, 53 | }); -------------------------------------------------------------------------------- /frontend/config/site.ts: -------------------------------------------------------------------------------- 1 | export type SiteConfig = typeof siteConfig; 2 | 3 | export const siteConfig = { 4 | name: "Next.js + HeroUI", 5 | description: "Make beautiful websites regardless of your design experience.", 6 | navItems: [ 7 | { 8 | label: "Home", 9 | href: "/dashboard", 10 | }, 11 | { 12 | label: "Docs", 13 | href: "/docs", 14 | }, 15 | { 16 | label: "Pricing", 17 | href: "/pricing", 18 | }, 19 | { 20 | label: "Blog", 21 | href: "/blog", 22 | }, 23 | { 24 | label: "About", 25 | href: "/about", 26 | }, 27 | ], 28 | navMenuItems: [ 29 | { 30 | label: "Profile", 31 | href: "/profile", 32 | }, 33 | { 34 | label: "Dashboard", 35 | href: "/dashboard", 36 | }, 37 | { 38 | label: "Projects", 39 | href: "/projects", 40 | }, 41 | { 42 | label: "Team", 43 | href: "/team", 44 | }, 45 | { 46 | label: "Calendar", 47 | href: "/calendar", 48 | }, 49 | { 50 | label: "Settings", 51 | href: "/settings", 52 | }, 53 | { 54 | label: "Help & Feedback", 55 | href: "/help-feedback", 56 | }, 57 | { 58 | label: "Logout", 59 | href: "/logout", 60 | }, 61 | ], 62 | links: { 63 | github: "https://github.com/frontio-ai/heroui", 64 | twitter: "https://twitter.com/getnextui", 65 | docs: "https://heroui.com", 66 | discord: "https://discord.gg/9b6yyZKmH4", 67 | sponsor: "https://patreon.com/jrgarciadev", 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { heroui } from "@heroui/theme"; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}", 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: [ 14 | "Inter", 15 | "ui-sans-serif", 16 | "system-ui", 17 | "sans-serif", 18 | "Apple Color Emoji", 19 | "Segoe UI Emoji", 20 | "Segoe UI Symbol", 21 | "Noto Color Emoji", 22 | ], 23 | }, 24 | }, 25 | }, 26 | darkMode: "class", 27 | plugins: [ 28 | heroui({ 29 | themes: { 30 | light: { 31 | colors: { 32 | warning: { 33 | DEFAULT: "#D97706", // Couleur légèrement plus foncée pour le thème clair 34 | foreground: "#ffffff", 35 | }, 36 | primary: { 37 | DEFAULT: "#404040", 38 | foreground: "#ffffff", 39 | }, 40 | //hover 41 | }, 42 | }, 43 | dark: { 44 | colors: { 45 | warning: { 46 | DEFAULT: "#B45309", // Couleur encore plus foncée pour le thème sombre 47 | foreground: "#ffffff", 48 | }, 49 | primary: { 50 | foreground: "white", 51 | DEFAULT: "#404040", 52 | }, 53 | }, 54 | }, 55 | }, 56 | }), 57 | ], 58 | }; 59 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | import { Hono } from "hono"; 3 | import { authRoutes } from "./routes/auth"; 4 | import { cors } from "hono/cors"; 5 | import type { JwtVariables } from "hono/jwt"; 6 | import { projectRoutes } from "./routes/project"; 7 | import { jwt } from "hono/jwt"; 8 | import { config } from "./config/config"; 9 | type Variables = JwtVariables; 10 | 11 | import ffmpeg from "fluent-ffmpeg"; 12 | import { prettyJSON } from "hono/pretty-json"; 13 | 14 | if (process.env.DOCKER === "true") { 15 | // En Docker, on utilise les chemins absolus 16 | ffmpeg.setFfmpegPath("/usr/bin/ffmpeg"); 17 | ffmpeg.setFfprobePath("/usr/bin/ffprobe"); 18 | } else { 19 | ffmpeg.setFfmpegPath("ffmpeg"); 20 | ffmpeg.setFfprobePath("ffprobe"); 21 | } 22 | 23 | const app = new Hono<{ Variables: Variables }>(); 24 | 25 | app.use("*", cors()); 26 | 27 | app.use(prettyJSON()); 28 | 29 | app.use("*", async (c, next) => { 30 | const méthode = c.req.method; 31 | const url = c.req.url; 32 | 33 | console.debug(`${méthode} ${url}`); 34 | 35 | await next(); 36 | }); 37 | 38 | app.use( 39 | "/projects/*", 40 | jwt({ 41 | secret: config.jwtSecret, 42 | }), 43 | ); 44 | 45 | app.route("/auth", authRoutes); 46 | app.route("/projects", projectRoutes); 47 | 48 | app.get("/", (c) => { 49 | return c.text("Hello Hono!"); 50 | }); 51 | 52 | serve( 53 | { 54 | fetch: app.fetch, 55 | port: process.env.PORT ? parseInt(process.env.PORT) : 4000, 56 | }, 57 | (info) => { 58 | console.log(`Server is running on http://localhost:${info.port}`); 59 | }, 60 | ); 61 | -------------------------------------------------------------------------------- /frontend/app/routes/dashboard.settings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const DashboardSettings = () => { 4 | return ( 5 |
6 |
7 |

Settings

8 |
9 |
10 | 17 | 23 | 24 |
25 |

Not Available Yet

26 |

27 | The settings page is currently under development. Please check back 28 | later for updates. 29 |

30 |
31 |

32 | We're working hard to bring you more features. Thank you for your 33 | patience! 34 |

35 |
36 |
37 | ); 38 | }; 39 | 40 | export default DashboardSettings; 41 | -------------------------------------------------------------------------------- /frontend/utils/toasts.ts: -------------------------------------------------------------------------------- 1 | import { toast } from "sonner"; 2 | type ToastPosition = 3 | | "top-left" 4 | | "top-right" 5 | | "bottom-left" 6 | | "bottom-right" 7 | | "top-center" 8 | | "bottom-center"; 9 | 10 | export const toastMsg = { 11 | default: (message: string, position?: ToastPosition) => 12 | toast(message, { 13 | position: position || "top-right", 14 | }), 15 | success: (message: string, position?: ToastPosition, duration?: number) => 16 | toast.success(message, { 17 | position: position || "top-right", 18 | duration: duration || 3000, 19 | dismissible: true, 20 | closeButton: false, 21 | }), 22 | info: (message: string, position?: ToastPosition, isDismissible?: boolean) => 23 | toast.info(message, { 24 | position: position || "top-right", 25 | dismissible: isDismissible !== undefined ? isDismissible : true, 26 | closeButton: true, 27 | }), 28 | description: ( 29 | message: string, 30 | description: string, 31 | position?: ToastPosition, 32 | ) => 33 | toast.message(message, { 34 | description: description, 35 | position: position || "top-right", 36 | }), 37 | warning: (message: string, position?: ToastPosition) => 38 | toast.warning(message, { 39 | position: position || "top-right", 40 | }), 41 | error: ( 42 | message: string, 43 | position?: ToastPosition, 44 | duration?: number, 45 | isDismissible?: boolean, 46 | ) => 47 | toast.error(message, { 48 | position: position || "top-right", 49 | closeButton: isDismissible !== undefined ? isDismissible : true, 50 | duration: duration || 5000, 51 | }), 52 | }; 53 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { defineConfig, Plugin } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | declare module "@remix-run/node" { 6 | interface Future { 7 | v3_singleFetch: true; 8 | } 9 | } 10 | 11 | // Custom plugin to filter out sourcemap warnings 12 | const filterSourcemapWarnings = (): Plugin => { 13 | const originalConsoleWarn = console.warn; 14 | 15 | return { 16 | name: "filter-sourcemap-warnings", 17 | apply: "build", 18 | configResolved() { 19 | console.warn = function (message, ...args) { 20 | if ( 21 | typeof message === "string" && 22 | (message.includes( 23 | "Error when using sourcemap for reporting an error", 24 | ) || 25 | message.includes("Source maps are enabled in production")) 26 | ) { 27 | return; // Suppress sourcemap warnings 28 | } 29 | originalConsoleWarn.call(console, message, ...args); 30 | }; 31 | }, 32 | buildEnd() { 33 | // Restore original console.warn 34 | console.warn = originalConsoleWarn; 35 | }, 36 | }; 37 | }; 38 | 39 | export default defineConfig({ 40 | plugins: [ 41 | remix({ 42 | future: { 43 | v3_fetcherPersist: true, 44 | v3_relativeSplatPath: true, 45 | v3_throwAbortReason: true, 46 | v3_singleFetch: true, 47 | v3_lazyRouteDiscovery: true, 48 | }, 49 | }), 50 | tsconfigPaths(), 51 | filterSourcemapWarnings(), 52 | ], 53 | build: { 54 | sourcemap: true, 55 | rollupOptions: { 56 | output: { 57 | sourcemapExcludeSources: false, 58 | }, 59 | }, 60 | }, 61 | server: { 62 | open: true, 63 | port: 3000, 64 | }, 65 | logLevel: "warn", // Suppress info-level logs 66 | }); 67 | -------------------------------------------------------------------------------- /frontend/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction } from "@remix-run/node"; 2 | import { 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "@remix-run/react"; 9 | import { AuthProvider } from "./contexts/AuthContext"; 10 | import { NuqsAdapter } from "nuqs/adapters/remix"; 11 | import { useLocation } from "@remix-run/react"; 12 | import "./tailwind.css"; 13 | import NavbarLayout from "components/navbar/NavbarLayout"; 14 | import { HeroUIProvider } from "@heroui/react"; 15 | import { Toaster, toast } from "sonner"; 16 | 17 | export const links: LinksFunction = () => [ 18 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 19 | { 20 | rel: "icon", 21 | href: "/favicon.png", // Le chemin est relatif au dossier public 22 | type: "image/png", 23 | }, 24 | { 25 | rel: "preconnect", 26 | href: "https://fonts.gstatic.com", 27 | crossOrigin: "anonymous", 28 | }, 29 | { 30 | rel: "stylesheet", 31 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 32 | }, 33 | ]; 34 | 35 | export function Layout({ children }: { children: React.ReactNode }) { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | {children} 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | export default function App() { 54 | const pathname = useLocation(); 55 | 56 | return ( 57 | 58 | 59 | 60 | 61 | {pathname.pathname === "/login" ? ( 62 | 63 | ) : ( 64 | 65 | 66 | 67 | )} 68 | 69 | 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /server/src/services/ffmpegService.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | import ffmpeg from "fluent-ffmpeg"; 3 | import path from "path"; 4 | import fs from "fs"; 5 | import type { DetectedSegments } from "../types/openai"; 6 | 7 | interface SegmentsInput { 8 | segments: DetectedSegments[]; 9 | } 10 | 11 | export class FfmpegService { 12 | constructor() { 13 | // 14 | } 15 | 16 | async cutAndTransformSegments( 17 | segmentsData: SegmentsInput, 18 | originalVideoPath: string, 19 | ): Promise { 20 | const sortedSegments = [...segmentsData.segments].sort( 21 | (a, b) => a.rank - b.rank, 22 | ); 23 | 24 | const tempDir = path.join(process.cwd(), "temp"); 25 | if (!fs.existsSync(tempDir)) { 26 | fs.mkdirSync(tempDir, { recursive: true }); 27 | } 28 | 29 | const segmentPromises = sortedSegments.map((segment) => { 30 | const uniqueId = crypto.randomUUID(); 31 | const outputFileName = `segment_${segment.rank}_${uniqueId}.mp4`; 32 | const outputPath = path.join(tempDir, outputFileName); 33 | 34 | const segmentDuration = segment.end - segment.start; 35 | 36 | return new Promise((resolve, reject) => { 37 | // Use fluent-ffmpeg to cut and convert 38 | ffmpeg(originalVideoPath) 39 | .setStartTime(segment.start) 40 | .setDuration(segmentDuration) 41 | .videoFilters([ 42 | "crop='ih*(9/16)':ih:(iw - ih*(9/16))/2:0", 43 | "scale=1080:1920", 44 | ]) 45 | .outputOptions("-c:a copy") 46 | .on("end", () => { 47 | resolve({ 48 | ...segment, 49 | filePath: outputPath, 50 | }); 51 | }) 52 | .on("error", (err) => { 53 | reject(err); 54 | }) 55 | .save(outputPath); 56 | }); 57 | }); 58 | 59 | const segments: DetectedSegments[] = []; 60 | 61 | //promise all is too greedy, so we use a for loop 62 | for (const segment of segmentPromises) { 63 | segments.push(await segment); 64 | } 65 | 66 | return segments; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/app/routes/dashboard.$id.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@heroui/react"; 2 | import { useParams } from "@remix-run/react"; 3 | import { VideoSegmentCard } from "components/dashboard/VideoSegmentCard"; 4 | import { useState } from "react"; 5 | import { useEffect } from "react"; 6 | import { ProjectDocument } from "~/types/supabase"; 7 | import { ProjectAPI } from "~/utils/services/api/ProjectApi"; 8 | import { toastMsg } from "~/utils/toasts"; 9 | 10 | const DashboardItem = () => { 11 | const [loading, setLoading] = useState(true); 12 | const [projectDocument, setProjectDocument] = 13 | useState(null); 14 | const [error, setError] = useState(null); 15 | const params = useParams(); 16 | 17 | const getProjectData = async (id: string) => { 18 | try { 19 | if (!id) { 20 | throw new Error("No id provided"); 21 | } 22 | const projectDocument = await ProjectAPI.getProject(id); 23 | setProjectDocument(projectDocument); 24 | } catch (error) { 25 | toastMsg.error("Erreur lors de la récupération du projet"); 26 | console.error(error); 27 | setError("Erreur lors de la récupération du projet"); 28 | } finally { 29 | setLoading(false); 30 | } 31 | }; 32 | 33 | useEffect(() => { 34 | if (params.id) { 35 | getProjectData(params.id); 36 | } else { 37 | setError("No project ID provided"); 38 | setLoading(false); 39 | } 40 | }, [params.id]); 41 | 42 | return ( 43 |
44 | {loading ? ( 45 |
46 | 47 |
48 | ) : ( 49 | <> 50 | {error ? ( 51 |
{error}
52 | ) : ( 53 | <> 54 |

Top 10 Viral Moments

55 |
56 | {projectDocument?.detected_segments 57 | .sort((a, b) => a.rank - b.rank) 58 | .map((segment) => ( 59 | 60 | ))} 61 |
62 | 63 | )} 64 | 65 | )} 66 |
67 | ); 68 | }; 69 | 70 | export default DashboardItem; 71 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tsEslintPlugin from '@typescript-eslint/eslint-plugin'; 2 | import tsEslintParser from '@typescript-eslint/parser'; 3 | import reactPlugin from 'eslint-plugin-react'; 4 | import reactHooksPlugin from 'eslint-plugin-react-hooks'; 5 | import tailwindcssPlugin from 'eslint-plugin-tailwindcss'; 6 | import globals from 'globals'; 7 | 8 | export default [ 9 | { 10 | files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], 11 | languageOptions: { 12 | parser: tsEslintParser, // Use the TypeScript parser 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | }, 20 | globals: globals.browser, // Define global variables (like `window`, `document`, etc.) 21 | }, 22 | plugins: { 23 | '@typescript-eslint': tsEslintPlugin, // TypeScript plugin 24 | react: reactPlugin, // React plugin 25 | 'react-hooks': reactHooksPlugin, // React Hooks plugin 26 | tailwindcss: tailwindcssPlugin, //Tailwind CSS plugin 27 | }, 28 | rules: { 29 | // Core ESLint rules 30 | quotes: ['error', 'single', { avoidEscape: true }], 31 | 'react/jsx-curly-brace-presence': [ 32 | 'error', 33 | { props: 'always', children: 'never' }, 34 | ], 35 | '@typescript-eslint/no-unused-vars': [ 36 | 'off', 37 | { vars: 'all', args: 'none', ignoreRestSiblings: false }, 38 | ], 39 | 40 | // Disable specific Next.js rules 41 | '@next/next/no-page-custom-font': 'off', 42 | 43 | // React-specific rules 44 | 'react/no-unescaped-entities': 'off', 45 | 'react/jsx-uses-react': 'off', 46 | 'react/react-in-jsx-scope': 'off', 47 | 'react-hooks/rules-of-hooks': 'error', // Enforce hook rules 48 | 'react-hooks/exhaustive-deps': 'warn', // Warn about missing dependencies in hooks 49 | 50 | // TypeScript-specific rules 51 | '@typescript-eslint/no-unused-vars': [ 52 | 'warn', 53 | { 54 | vars: 'all', 55 | args: 'after-used', 56 | ignoreRestSiblings: false, 57 | varsIgnorePattern: '^_', 58 | argsIgnorePattern: '^_', 59 | }, 60 | ], 61 | }, 62 | }, 63 | { 64 | files: ['**/*.{ts,tsx}'], // Apply these rules to TypeScript files 65 | languageOptions: { 66 | parser: tsEslintParser, // Use the TypeScript parser 67 | }, 68 | rules: { 69 | // Additional TypeScript-specific rules can go here 70 | }, 71 | }, 72 | { 73 | files: ['**/*.{jsx,tsx}'], // Apply React-specific rules to JSX/TSX files 74 | rules: { 75 | // React-specific rules for JSX/TSX files 76 | }, 77 | }, 78 | ]; 79 | -------------------------------------------------------------------------------- /server/src/services/transcriptionService.ts: -------------------------------------------------------------------------------- 1 | import { createClient, DeepgramClient } from "@deepgram/sdk"; 2 | 3 | import { config } from "../config/config"; 4 | import type { Utterance } from "../types/transcription"; 5 | 6 | export class TranscriptionService { 7 | deepgramApiKey: string | null = null; 8 | deepgramClient: DeepgramClient | null = null; 9 | 10 | constructor() { 11 | const apiKey = config.deepgramApiKey; 12 | 13 | if (!apiKey) { 14 | throw new Error("Deepgram API key is not set"); 15 | } 16 | 17 | this.deepgramApiKey = apiKey; 18 | this.deepgramClient = createClient(apiKey); 19 | } 20 | 21 | async transcribeVideo(videoUrl: string) { 22 | console.debug("Transcribing video", videoUrl); 23 | try { 24 | const { result, error } = 25 | await this.deepgramClient!.listen.prerecorded.transcribeUrl( 26 | { url: videoUrl }, 27 | { 28 | model: "nova-3", 29 | detect_language: true, 30 | smart_format: true, 31 | punctuate: true, 32 | paragraphs: true, 33 | utterances: true, 34 | diarize: true, 35 | }, 36 | ); 37 | 38 | if (error) { 39 | throw new Error("Error transcribing video: " + error); 40 | } 41 | 42 | if (!result) { 43 | throw new Error("No result from transcription"); 44 | } 45 | 46 | console.debug("Transcription done"); 47 | 48 | const utterances = result.results.utterances; 49 | 50 | if (!utterances) { 51 | throw new Error("No utterances from transcription"); 52 | } 53 | 54 | const formattedTranscription = this.formatTranscription( 55 | utterances as Utterance[], 56 | ); 57 | 58 | return formattedTranscription; 59 | } catch (error) { 60 | console.error("Error transcribing video", error); 61 | throw new Error("Error transcribing video"); 62 | } 63 | } 64 | 65 | formatTranscription(transcription: Utterance[]): string { 66 | // Basic validation 67 | if (!Array.isArray(transcription)) { 68 | throw new Error( 69 | "Invalid transcription: expected an array of Utterance objects.", 70 | ); 71 | } 72 | 73 | const formatted = transcription.map((item) => { 74 | if ( 75 | typeof item.start !== "number" || 76 | typeof item.end !== "number" || 77 | typeof item.transcript !== "string" || 78 | typeof item.speaker !== "number" 79 | ) { 80 | throw new Error( 81 | `Invalid Utterance object detected: ${JSON.stringify(item)}`, 82 | ); 83 | } 84 | 85 | return { 86 | start: item.start, 87 | end: item.end, 88 | transcript: item.transcript, 89 | speaker: item.speaker, 90 | }; 91 | }); 92 | 93 | return JSON.stringify(formatted); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /frontend/components/dashboard/VideoSegmentCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, CardBody, Button } from "@heroui/react"; 3 | import { Icon } from "@iconify/react"; 4 | import { DetectedSegments } from "~/utils/types/supabase"; 5 | 6 | interface VideoSegmentCardProps { 7 | segment: DetectedSegments; 8 | } 9 | 10 | export function VideoSegmentCard({ segment }: VideoSegmentCardProps) { 11 | const formatTime = (seconds: number) => { 12 | const minutes = Math.floor(seconds / 60); 13 | const remainingSeconds = Math.floor(seconds % 60); 14 | return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; 15 | }; 16 | 17 | const duration = segment.end - segment.start; 18 | 19 | const handleDownload = () => { 20 | if (!segment.filePath) return; 21 | 22 | // Create a temporary anchor element to trigger download 23 | const a = document.createElement("a"); 24 | a.href = segment.filePath; 25 | a.download = `segment-${segment.rank}.mp4`; 26 | document.body.appendChild(a); 27 | a.click(); 28 | document.body.removeChild(a); 29 | }; 30 | 31 | return ( 32 | 33 | 34 |
35 |
36 |
44 |
45 |
46 |
47 |
48 | #{segment.rank} 49 |
50 |
51 |

Viral Potential

52 |

{segment.reason}

53 |
54 |
55 |
56 |
57 | Start: {formatTime(segment.start)} 58 | 59 | End: {formatTime(segment.end)} 60 | 61 | Duration: {formatTime(duration)} seconds 62 |
63 | 72 |
73 |
74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "node --experimental-json-modules scripts/generate-favicon.js && remix vite:build", 8 | "dev": "node --experimental-json-modules scripts/generate-favicon.js && remix vite:dev", 9 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"", 10 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 11 | "lint:fix": "eslint --fix --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 12 | "start": "remix-serve ./build/server/index.js", 13 | "typecheck": "tsc" 14 | }, 15 | "dependencies": { 16 | "@heroui/button": "2.2.9", 17 | "@heroui/code": "2.2.6", 18 | "@heroui/input": "2.4.9", 19 | "@heroui/kbd": "2.2.6", 20 | "@heroui/link": "2.2.7", 21 | "@heroui/navbar": "2.2.8", 22 | "@heroui/react": "^2.7.2", 23 | "@heroui/snippet": "2.2.10", 24 | "@heroui/switch": "2.2.8", 25 | "@heroui/system": "2.4.6", 26 | "@heroui/theme": "2.4.5", 27 | "@heroui/toast": "^2.0.5", 28 | "@heroui/use-theme": "2.1.1", 29 | "@iconify/react": "^5.2.0", 30 | "@react-aria/ssr": "^3.9.7", 31 | "@react-aria/visually-hidden": "^3.8.18", 32 | "@remix-run/node": "^2.15.1", 33 | "@remix-run/react": "^2.15.1", 34 | "@remix-run/serve": "^2.15.1", 35 | "@remotion/renderer": "^4.0.271", 36 | "axios": "^1.7.9", 37 | "clsx": "^2.1.1", 38 | "framer-motion": "^11.14.3", 39 | "isbot": "^4.1.0", 40 | "next-themes": "^0.4.4", 41 | "nuqs": "^2.4.0", 42 | "process": "^0.11.10", 43 | "react": "^18.3.1", 44 | "react-dom": "^18.3.1", 45 | "remix-utils": "^8.0.0", 46 | "remotion": "^4.0.271", 47 | "sonner": "^2.0.1", 48 | "tailwind-merge": "^3.0.2", 49 | "tailwind-variants": "^0.3.0", 50 | "zod": "^3.24.2", 51 | "zustand": "^5.0.3" 52 | }, 53 | "devDependencies": { 54 | "@remix-run/dev": "^2.15.1", 55 | "@types/react": "^18.3.18", 56 | "@types/react-dom": "^18.3.5", 57 | "@typescript-eslint/eslint-plugin": "^6.7.4", 58 | "@typescript-eslint/parser": "^6.7.4", 59 | "autoprefixer": "^10.4.19", 60 | "eslint": "^8.38.0", 61 | "eslint-config-prettier": "^9.1.0", 62 | "eslint-import-resolver-typescript": "^3.6.1", 63 | "eslint-plugin-import": "^2.28.1", 64 | "eslint-plugin-jsx-a11y": "^6.7.1", 65 | "eslint-plugin-prettier": "^5.1.3", 66 | "eslint-plugin-react": "^7.33.2", 67 | "eslint-plugin-react-hooks": "^4.6.0", 68 | "postcss": "^8.4.38", 69 | "prettier": "^3.5.1", 70 | "sharp": "^0.33.5", 71 | "tailwindcss": "^3.4.4", 72 | "typescript": "^5.1.6", 73 | "vite": "^5.1.0", 74 | "vite-tsconfig-paths": "^4.2.1" 75 | }, 76 | "packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee", 77 | "engines": { 78 | "node": ">=20.0.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/src/services/openaiService.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | 3 | import { config } from "../config/config"; 4 | import type { Models } from "../types/openai"; 5 | import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions"; 6 | 7 | export const models: Models = { 8 | gpt4o: "gpt-4o", 9 | gpt4Turbo: "gpt-4-turbo", 10 | gpt4: "gpt-4", 11 | gpt3Turbo: "gpt-3.5-turbo-0125", 12 | gpt3_16k: "gpt-3.5-turbo-16k", 13 | gpt4oMini: "gpt-4o-mini", 14 | o1: "o1", 15 | o1Mini: "o1-mini", 16 | o3Mini: "o3-mini", 17 | }; 18 | 19 | const oModelsWithoutInstructions: string[] = [ 20 | models.o1Mini, 21 | models.o1, 22 | models.o3Mini, 23 | ]; 24 | 25 | const oModelsWithAdjustableReasoningEffort: string[] = [ 26 | models.o1, 27 | models.o3Mini, 28 | ]; 29 | 30 | export class OpenaiService { 31 | private openAi: OpenAI; 32 | 33 | constructor() { 34 | this.openAi = new OpenAI({ 35 | apiKey: config.openAiApiKey, 36 | }); 37 | } 38 | 39 | async requestToGPT({ 40 | prompt, 41 | maxTokens, 42 | temperature, 43 | responseFormat, 44 | model, 45 | instructions, 46 | topP, 47 | customResponseFormat, 48 | }: { 49 | prompt: string; 50 | maxTokens: number; 51 | temperature: number; 52 | responseFormat: "text" | "json_object" | "custom"; 53 | model: string; 54 | instructions?: string; 55 | topP?: number; 56 | customResponseFormat?: any; 57 | }): Promise { 58 | console.debug("requestToGPT..."); 59 | if (oModelsWithoutInstructions.includes(model) && instructions) { 60 | prompt = `${instructions}\n\n-------\n\n${prompt}`; 61 | instructions = undefined; 62 | } 63 | 64 | const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; 65 | if (instructions) { 66 | messages.push({ role: "system", content: instructions }); 67 | } 68 | messages.push({ role: "user", content: prompt }); 69 | 70 | const params: ChatCompletionCreateParamsNonStreaming = { 71 | model: model, 72 | messages: messages, 73 | response_format: 74 | responseFormat === "custom" 75 | ? customResponseFormat 76 | : { type: responseFormat }, 77 | }; 78 | 79 | if (oModelsWithAdjustableReasoningEffort.includes(model)) { 80 | (params as any).reasoning_effort = "high"; 81 | } else { 82 | params.max_tokens = maxTokens; 83 | params.temperature = temperature; 84 | params.top_p = topP || 1; 85 | params.presence_penalty = 0; 86 | params.frequency_penalty = 0; 87 | } 88 | 89 | try { 90 | const response = await this.openAi.chat.completions.create(params); 91 | 92 | if (!response.choices?.[0]?.message?.content) { 93 | throw new Error("Invalid response from OpenAI"); 94 | } 95 | 96 | console.debug("AI job done"); 97 | 98 | return response.choices[0].message.content; 99 | } catch (error: any) { 100 | throw new Error(`Error with OPEN AI: ${error.message || error}`); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /frontend/components/navbar/NotificationItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { Avatar, Badge, Button } from "@heroui/react"; 5 | import { Icon } from "@iconify/react"; 6 | import { cn } from "@heroui/react"; 7 | 8 | export type NotificationType = "default" | "request" | "file"; 9 | 10 | export type NotificationItem = { 11 | id: string; 12 | isRead?: boolean; 13 | avatar: string; 14 | description: string; 15 | name: string; 16 | time: string; 17 | type?: NotificationType; 18 | }; 19 | 20 | export type NotificationItemProps = React.HTMLAttributes & 21 | NotificationItem; 22 | 23 | const NotificationItem = React.forwardRef< 24 | HTMLDivElement, 25 | NotificationItemProps 26 | >( 27 | ( 28 | { 29 | children, 30 | avatar, 31 | name, 32 | description, 33 | type, 34 | time, 35 | isRead, 36 | className, 37 | ...props 38 | }, 39 | ref, 40 | ) => { 41 | /** 42 | * Defines the content for different types of notifications. 43 | */ 44 | const contentByType: Record = { 45 | default: null, 46 | request: ( 47 |
48 | 51 | 54 |
55 | ), 56 | file: ( 57 |
58 | 63 |
64 | 65 | Brand_Logo_v1.2.fig 66 | 67 |

3.4 MB

68 |
69 |
70 | ), 71 | }; 72 | 73 | return ( 74 |
85 |
86 | 93 | 94 | 95 |
96 |
97 |

98 | {name}{" "} 99 | {description || children} 100 |

101 | 102 | {type && contentByType[type]} 103 |
104 |
105 | ); 106 | }, 107 | ); 108 | 109 | NotificationItem.displayName = "NotificationItem"; 110 | 111 | export default NotificationItem; 112 | -------------------------------------------------------------------------------- /server/src/services/promptService.ts: -------------------------------------------------------------------------------- 1 | export class PromptService { 2 | constructor() { 3 | // 4 | } 5 | 6 | createPromptForViralSegments({ 7 | stringifiedTranscription, 8 | desiredSegmentDuration, 9 | }: { 10 | stringifiedTranscription: string; 11 | desiredSegmentDuration: string; // Exemple: "30-60 secondes" 12 | }): string { 13 | return ` 14 | You are an advanced, highly experienced AI model specializing in analyzing video or audio transcripts to detect the most viral-worthy moments. 15 | Your objective is to return exactly **10** segments from the provided transcription that have the best chance of going viral. 16 | 17 | IMPORTANT DETAILS AND REQUIREMENTS: 18 | 1. **Transcription**: 19 | - Below is the entire transcription in a single JSON string format (without line breaks). 20 | - Analyze it to identify which parts are the most engaging or have the highest virality potential (e.g., emotional peaks, humor, dramatic tension, etc.). 21 | 22 | 2. **Segment Duration**: 23 | - Each of the 10 segments you select should be within the range of ${desiredSegmentDuration}. 24 | - You can merge multiple consecutive sections from the transcript if needed to reach that duration range. 25 | - If the transcript is too short or too long to precisely fit the requested length, pick the best approximations that still produce 10 distinct moments. 26 | 27 | 3. **Output Format**: 28 | - Return your result as a **valid JSON array** of exactly **10** objects, with **no extra keys** and **no additional commentary** outside the JSON. 29 | - Each object must have the form: 30 | \`\`\` 31 | { 32 | "rank": number, // An integer from 1 to 10 (1 = highest viral potential) 33 | "start": number, // Start time in seconds (approximate or exact if known) 34 | "end": number, // End time in seconds (approximate or exact if known) 35 | "reason": string // Why this segment is likely to go viral MUST BE IN TRANSCRIPTION LANGUAGE 36 | } 37 | \`\`\` 38 | - The key \`rank\` indicates the order of virality potential: 39 | - \`rank = 1\` means the segment has the strongest chance to go viral, 40 | - \`rank = 10\` is still potentially viral but the least among the chosen top 10. 41 | - Use short, direct \`reason\` explanations focusing on what makes each segment stand out (e.g., emotional punch, surprising facts, humor, etc.). 42 | 43 | 4. **Additional Instructions**: 44 | - Do **not** add text or keys beyond the JSON array (e.g., no introductions, disclaimers, or disclaimers after the JSON). 45 | - If timestamps (start/end) are not entirely clear, you may estimate them. 46 | - Be concise yet clear in your \`reason\` fields. 47 | 48 | 5. **Transcription Provided**: 49 | \`\`\` 50 | ${stringifiedTranscription} 51 | \`\`\` 52 | 53 | Now, based on this transcription and the requirement that each chosen segment be about ${desiredSegmentDuration}, please: 54 | 1) Select **exactly 10** segments. 55 | 2) Provide them in descending order of virality potential (rank 1 to 10). 56 | 3) Return only the JSON array described, with no extra commentary. 57 | 58 | return something like this: 59 | { 60 | "segments": [ 61 | { 62 | "rank": 1, 63 | "start": 0, 64 | "end": 10, 65 | "reason": "This is a reason" 66 | }, 67 | ... 68 | ] 69 | } 70 | 71 | `; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /frontend/utils/services/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseURLBackEnd = 4 | import.meta.env.VITE_BASE_URL_BACKEND || process.env.VITE_BASE_URL_BACKEND; 5 | 6 | const formatUrl = (baseURL: string, url: string) => { 7 | console.log("baseURL", baseURL); 8 | console.log("url", url); 9 | 10 | return `${baseURL}/${url}`; 11 | }; 12 | const defaultHeaders = { headers: {}, timeout: 3600000 }; // 1 hour 13 | 14 | interface Headers { 15 | headers: { [key: string]: string | number | undefined }; 16 | } 17 | 18 | const handleAxiosError = (error: any): never => { 19 | if (error.response) { 20 | console.error(error.response); 21 | throw error.response.data.message; 22 | } else if (error.request) { 23 | console.error(error.request); 24 | throw error.request; 25 | } else if (error.data) { 26 | console.error(error.data.message); 27 | throw error.data.message; 28 | } else { 29 | console.error("Error", error.message); 30 | throw error.message; 31 | } 32 | }; 33 | 34 | class AxiosCallApi { 35 | constructor() { 36 | if (!baseURLBackEnd) { 37 | throw new Error("baseURLBackEnd is not defined"); 38 | } 39 | } 40 | 41 | static async get(url: string, headers?: Headers): Promise { 42 | try { 43 | const response = await axios.get( 44 | formatUrl(baseURLBackEnd, url), 45 | headers ? headers : defaultHeaders, 46 | ); 47 | return response.data; 48 | } catch (error: any) { 49 | console.error(error?.response ? error.response?.data?.message : error); 50 | return handleAxiosError(error); // Fix: return handleAxiosError 51 | } 52 | } 53 | 54 | static async post(url: string, data: T, headers?: Headers): Promise { 55 | try { 56 | const response = await axios.post( 57 | formatUrl(baseURLBackEnd, url), 58 | data, 59 | headers ? headers : defaultHeaders, 60 | ); 61 | 62 | return response.data; 63 | } catch (error: any) { 64 | console.error(error); 65 | return handleAxiosError(error); 66 | } 67 | } 68 | 69 | static async delete( 70 | url: string, 71 | data: T, 72 | headers?: Headers, 73 | ): Promise { 74 | try { 75 | const config = { 76 | data, 77 | headers: headers ? headers : {}, 78 | }; 79 | 80 | const response = await axios.delete( 81 | formatUrl(baseURLBackEnd, url), 82 | config, 83 | ); 84 | return response.data; 85 | } catch (error: any) { 86 | console.error( 87 | error?.response ? error.response?.constants?.message : error, 88 | ); 89 | throw new Error(error); 90 | } 91 | } 92 | 93 | // static async put(url: string, constants: T, headers: Headers) { 94 | // try { 95 | // const response = await axios.put(formatUrl(url), constants, headers) 96 | // return response.constants 97 | // } catch (error: any) { 98 | // console.error(error?.response ? error.response?.constants?.message : error) 99 | // throw new Error(error) 100 | // } 101 | // } 102 | 103 | static async patch( 104 | url: string, 105 | data: T, 106 | headers?: Headers, 107 | ): Promise { 108 | try { 109 | const response = await axios.patch( 110 | formatUrl(baseURLBackEnd, url), 111 | data, 112 | headers ? headers : defaultHeaders, 113 | ); 114 | return response.data; 115 | } catch (error: any) { 116 | return handleAxiosError(error); 117 | } 118 | } 119 | 120 | static saveToken(token: any) { 121 | axios.defaults.headers.common.Authorization = `Bearer ${token}`; 122 | } 123 | } 124 | 125 | export default AxiosCallApi; 126 | -------------------------------------------------------------------------------- /frontend/app/contexts/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState } from "react"; 2 | import { useNavigate, useLocation } from "@remix-run/react"; 3 | import { AuthAPI } from "../../utils/services/api/AuthApi"; 4 | import { useAuthStore } from "../stores/authStore"; 5 | import AxiosCallApi from "~/utils/services/axios"; 6 | import { toastMsg } from "~/utils/toasts"; 7 | import { Spinner } from "@heroui/react"; 8 | 9 | // Define a simple context type; you can extend it if needed 10 | interface AuthContextProps { 11 | loading: boolean; 12 | } 13 | 14 | const AuthContext = createContext(undefined); 15 | 16 | export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ 17 | children, 18 | }) => { 19 | // Use type annotation for useState 20 | const [loading, setLoading] = useState(true); 21 | const setAuthenticated = useAuthStore((state) => state.setAuthenticated); 22 | const setAccessToken = useAuthStore((state) => state.setAccessToken); 23 | const setUserData = useAuthStore((state) => state.setUserData); 24 | const setUserProjects = useAuthStore((state) => state.setUserProjects); 25 | const userData = useAuthStore((state) => state.userData); 26 | const navigate = useNavigate(); 27 | const location = useLocation(); 28 | 29 | useEffect(() => { 30 | // On page load, check for accessToken and a stored userId 31 | const token = localStorage.getItem("accessToken"); 32 | 33 | if (userData?.id) { 34 | if (location.pathname === "/login" || location.pathname === "/") { 35 | navigate("/dashboard"); 36 | } 37 | return; 38 | } 39 | 40 | if (token) { 41 | AuthAPI.signIn(token) 42 | .then((response) => { 43 | setAuthenticated(true); 44 | setAccessToken(response.accessToken); 45 | setLoading(false); 46 | localStorage.setItem("accessToken", response.accessToken); 47 | AxiosCallApi.saveToken(response.accessToken); 48 | 49 | setUserData(response.userData.userRelativeData); 50 | setUserProjects(response.userData.userDocuments); 51 | }) 52 | .catch((error) => { 53 | console.error(error); 54 | toastMsg.error( 55 | "Erreur lors de la connexion, veuillez réessayer ou contacter l'assistance", 56 | ); 57 | // If the signIn call fails, clear storage and redirect to /login. 58 | localStorage.removeItem("accessToken"); 59 | localStorage.removeItem("userId"); 60 | setAuthenticated(false); 61 | setLoading(false); 62 | if (location.pathname !== "/login") { 63 | navigate("/login"); 64 | } 65 | }) 66 | .finally(() => { 67 | if (location.pathname === "/") { 68 | navigate("/dashboard"); 69 | } 70 | }); 71 | } else { 72 | setLoading(false); 73 | // If no token exists and the user is not on the login page, redirect 74 | if (location.pathname !== "/login") { 75 | navigate("/login"); 76 | } 77 | } 78 | }, [location.pathname, navigate, setAuthenticated, setAccessToken]); 79 | 80 | return ( 81 | 82 | {loading ? ( 83 |
84 | 85 |
86 | ) : ( 87 | children 88 | )} 89 |
90 | ); 91 | }; 92 | 93 | export function useAuth() { 94 | const context = useContext(AuthContext); 95 | if (!context) { 96 | throw new Error( 97 | "useAuth doit être utilisé à l’intérieur d’un AuthProvider", 98 | ); 99 | } 100 | return context; 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MagicCuts 🎬✂️ 2 | 3 | MagicCuts is a powerful application that transforms long videos into viral short-form content. The platform uses AI to analyze video transcriptions, identify the most engaging moments, and automatically cut them into vertical short format videos optimized for social media platforms. 4 | 5 | ## 🚀 Project Overview 6 | 7 | This project was created during a 12-hour YouTube challenge to build a tool that could help content creators repurpose their long-form content (like YouTube videos and livestreams) into viral shorts without manual editing. 8 | 9 | MagicCuts: 10 | 1. Analyzes video transcriptions to find the most engaging segments 11 | 2. Automatically cuts these segments into vertical short-format videos 12 | 3. Provides an intuitive dashboard to review and manage your viral clips 13 | 14 | ## 🏗️ Project Structure 15 | 16 | The project consists of two main components: 17 | 18 | ### Frontend (Remix.js + React) 19 | - Modern UI built with Remix, React, and TailwindCSS 20 | - User authentication and project management 21 | - Video preview and management dashboard 22 | 23 | ### Backend (Node.js + Hono) 24 | - RESTful API built with Hono 25 | - Video processing and transcription with Deepgram 26 | - AI analysis with OpenAI 27 | - Video transformation with FFMPEG 28 | - Storage with AWS S3 29 | - Database management with Supabase 30 | 31 | ## 🛠️ Technologies Used 32 | 33 | ### Frontend 34 | - **Remix.js**: React framework for building modern web applications 35 | - **React**: UI library 36 | - **TailwindCSS**: Utility-first CSS framework 37 | - **HeroUI**: UI component library 38 | - **TypeScript**: Type-safe JavaScript 39 | - **Zustand**: State management 40 | - **Remotion**: JavaScript library for creating videos programmatically 41 | - **Framer Motion**: Animation library 42 | 43 | ### Backend 44 | - **Node.js**: JavaScript runtime 45 | - **Hono**: Lightweight, fast web framework 46 | - **TypeScript**: Type-safe JavaScript 47 | - **Deepgram**: Audio transcription service 48 | - **OpenAI**: AI for content analysis 49 | - **FFMPEG**: Video processing tool 50 | - **AWS S3**: Cloud storage 51 | - **Supabase**: Backend-as-a-Service with PostgreSQL database 52 | - **Docker**: Containerization 53 | 54 | ## 🚀 Deployment 55 | 56 | - **Frontend**: Deployed on Vercel 57 | - **Backend**: Deployed on Railway 58 | 59 | ## 🏁 Getting Started 60 | 61 | ### Prerequisites 62 | - Node.js (v20+) 63 | - PNPM package manager 64 | - FFMPEG installed on your system 65 | - AWS, Supabase, Deepgram, and OpenAI accounts 66 | 67 | ### Environment Setup 68 | 69 | #### Frontend 70 | 1. Navigate to the frontend directory and copy the example environment file: 71 | ```bash 72 | cd frontend 73 | cp .env.example .env 74 | ``` 75 | 76 | 2. Update the `.env` file with your API keys and endpoints. 77 | 78 | #### Backend 79 | 1. Navigate to the server directory and copy the example environment file: 80 | ```bash 81 | cd server 82 | cp .env.exemple .env 83 | ``` 84 | 85 | 2. Update the `.env` file with your API keys and service credentials. 86 | 87 | ### Installation 88 | 89 | #### Frontend 90 | ```bash 91 | cd frontend 92 | pnpm install 93 | pnpm run dev 94 | ``` 95 | 96 | #### Backend 97 | ```bash 98 | cd server 99 | pnpm install 100 | pnpm run dev 101 | ``` 102 | 103 | Alternatively, you can use Docker for the backend: 104 | ```bash 105 | cd server 106 | docker compose up -d --build 107 | docker compose logs -f 108 | ``` 109 | 110 | ## 🔍 How It Works 111 | 112 | 1. **Upload**: Users upload their long-form video content 113 | 2. **Transcription**: The system transcribes the video using Deepgram 114 | 3. **Analysis**: AI analyzes the transcription to identify viral-worthy moments 115 | 4. **Processing**: The system cuts the video into short segments 116 | 5. **Delivery**: Users can preview and download the generated short videos 117 | 118 | ## 💡 Features 119 | 120 | - AI-powered identification of viral-worthy moments 121 | - Automatic video cutting and formatting 122 | - User-friendly dashboard 123 | - Video preview 124 | - Transcription review 125 | - Cloud storage integration 126 | - Customizable output format 127 | 128 | ## 🤝 Contributing 129 | 130 | Contributions are welcome! This project was built during a 12-hour challenge but is open to improvements and new features. 131 | 132 | ## 📄 License 133 | 134 | This project is licensed under the MIT License. -------------------------------------------------------------------------------- /frontend/app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { AuthAPI } from "~/utils/services/api/AuthApi"; 2 | import { useEffect } from "react"; 3 | import { useAuthStore } from "~/stores/authStore"; 4 | import { toastMsg } from "~/utils/toasts"; 5 | import { useNavigate } from "@remix-run/react"; 6 | import { Button, User } from "@heroui/react"; 7 | import { Icon } from "@iconify/react"; 8 | 9 | import { AcmeIcon } from "./AcmeIcon"; 10 | 11 | const login = () => { 12 | const { setAuthenticated, setAccessToken, setUserData } = useAuthStore(); 13 | const navigate = useNavigate(); 14 | const handleLogin = async () => { 15 | try { 16 | const response = await AuthAPI.getAuth(); 17 | window.location.href = response.url; 18 | } catch (error) { 19 | toastMsg.error("Erreur lors de la connexion"); 20 | console.error(error); 21 | } 22 | }; 23 | 24 | const handleAccessToken = async (accessToken: string) => { 25 | try { 26 | const response = await AuthAPI.callback(accessToken); 27 | localStorage.setItem("accessToken", response.accessToken); 28 | setAuthenticated(true); 29 | setAccessToken(response.accessToken); 30 | setUserData(response.userInfo.userRelativeData); 31 | navigate("/dashboard"); 32 | } catch (error) { 33 | toastMsg.error("Erreur lors de la connexion"); 34 | console.error(error); 35 | } 36 | }; 37 | 38 | useEffect(() => { 39 | const hash = window.location.hash.substring(1); 40 | if (hash) { 41 | const params = new URLSearchParams(hash); 42 | const accessToken = params.get("access_token"); 43 | 44 | if (accessToken) { 45 | handleAccessToken(accessToken); 46 | } 47 | } 48 | }, []); 49 | 50 | return ( 51 |
52 | {/* Brand Logo */} 53 |
54 |
55 | 56 |

MagicCuts

57 |
58 |
59 | 60 | {/* Sign Up Form */} 61 |
62 |
63 |
64 |

Welcome to MagicCuts

65 |

66 | Sign up or login to your account 67 |

68 |
69 | 70 |
71 | 78 |
79 |
80 |
81 | 82 | {/* Right side */} 83 |
92 |
93 | 105 |

106 | 107 | 108 | MagicCuts is the best software to create viral short videos from 109 | longs videos. 110 | 111 | 112 |

113 |
114 |
115 |
116 | ); 117 | }; 118 | 119 | export default login; 120 | -------------------------------------------------------------------------------- /frontend/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import { isbot } from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext 26 | ) { 27 | return isbot(request.headers.get("user-agent") || "") 28 | ? handleBotRequest( 29 | request, 30 | responseStatusCode, 31 | responseHeaders, 32 | remixContext 33 | ) 34 | : handleBrowserRequest( 35 | request, 36 | responseStatusCode, 37 | responseHeaders, 38 | remixContext 39 | ); 40 | } 41 | 42 | function handleBotRequest( 43 | request: Request, 44 | responseStatusCode: number, 45 | responseHeaders: Headers, 46 | remixContext: EntryContext 47 | ) { 48 | return new Promise((resolve, reject) => { 49 | let shellRendered = false; 50 | const { pipe, abort } = renderToPipeableStream( 51 | , 56 | { 57 | onAllReady() { 58 | shellRendered = true; 59 | const body = new PassThrough(); 60 | const stream = createReadableStreamFromReadable(body); 61 | 62 | responseHeaders.set("Content-Type", "text/html"); 63 | 64 | resolve( 65 | new Response(stream, { 66 | headers: responseHeaders, 67 | status: responseStatusCode, 68 | }) 69 | ); 70 | 71 | pipe(body); 72 | }, 73 | onShellError(error: unknown) { 74 | reject(error); 75 | }, 76 | onError(error: unknown) { 77 | responseStatusCode = 500; 78 | // Log streaming rendering errors from inside the shell. Don't log 79 | // errors encountered during initial shell rendering since they'll 80 | // reject and get logged in handleDocumentRequest. 81 | if (shellRendered) { 82 | console.error(error); 83 | } 84 | }, 85 | } 86 | ); 87 | 88 | setTimeout(abort, ABORT_DELAY); 89 | }); 90 | } 91 | 92 | function handleBrowserRequest( 93 | request: Request, 94 | responseStatusCode: number, 95 | responseHeaders: Headers, 96 | remixContext: EntryContext 97 | ) { 98 | return new Promise((resolve, reject) => { 99 | let shellRendered = false; 100 | const { pipe, abort } = renderToPipeableStream( 101 | , 106 | { 107 | onShellReady() { 108 | shellRendered = true; 109 | const body = new PassThrough(); 110 | const stream = createReadableStreamFromReadable(body); 111 | 112 | responseHeaders.set("Content-Type", "text/html"); 113 | 114 | resolve( 115 | new Response(stream, { 116 | headers: responseHeaders, 117 | status: responseStatusCode, 118 | }) 119 | ); 120 | 121 | pipe(body); 122 | }, 123 | onShellError(error: unknown) { 124 | reject(error); 125 | }, 126 | onError(error: unknown) { 127 | responseStatusCode = 500; 128 | // Log streaming rendering errors from inside the shell. Don't log 129 | // errors encountered during initial shell rendering since they'll 130 | // reject and get logged in handleDocumentRequest. 131 | if (shellRendered) { 132 | console.error(error); 133 | } 134 | }, 135 | } 136 | ); 137 | 138 | setTimeout(abort, ABORT_DELAY); 139 | }); 140 | } 141 | -------------------------------------------------------------------------------- /server/src/controllers/authController.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from "hono"; 2 | import jwt from "jsonwebtoken"; 3 | import { 4 | createUserDocument, 5 | getAllUserData, 6 | isUserDocumentExists, 7 | supabase, 8 | } from "../services/supabaseService"; 9 | import { config } from "../config/config"; 10 | import { HTTPException } from "hono/http-exception"; 11 | 12 | /** 13 | * Endpoint to initiate Google OAuth login. 14 | * It generates a URL for Google OAuth via Supabase. 15 | */ 16 | export const googleLogin = async (c: Context) => { 17 | const redirectTo = 18 | process.env.NODE_ENV === "production" 19 | ? "https://app.magiccuts.pro" 20 | : process.env.FRONTEND_URL; 21 | 22 | console.log("redirectTo", redirectTo); 23 | const { data, error } = await supabase.auth.signInWithOAuth({ 24 | provider: "google", 25 | options: { 26 | redirectTo: `${redirectTo}/login`, 27 | }, 28 | }); 29 | 30 | if (error) { 31 | throw new HTTPException(500, { 32 | message: error.message, 33 | }); 34 | } 35 | 36 | // The client should be redirected to the URL provided by Supabase. 37 | return c.json({ url: data.url }); 38 | }; 39 | 40 | export const signIn = async (c: Context) => { 41 | const { accessToken } = await c.req.json(); 42 | if (!accessToken) { 43 | throw new HTTPException(400, { 44 | message: "Access token is required", 45 | }); 46 | } 47 | 48 | let decodedUser: any; 49 | let tokenExpired = false; 50 | 51 | try { 52 | decodedUser = jwt.verify(accessToken, config.jwtSecret); 53 | } catch (error: any) { 54 | if (error.name === "TokenExpiredError") { 55 | tokenExpired = true; 56 | decodedUser = jwt.decode(accessToken); 57 | } else { 58 | throw new HTTPException(401, { 59 | message: "Invalid token", 60 | }); 61 | } 62 | } 63 | 64 | // Ensure that the decoded payload contains a 'user' object with an 'id' 65 | if ( 66 | !decodedUser || 67 | typeof decodedUser !== "object" || 68 | !decodedUser.user || 69 | !decodedUser.user.id 70 | ) { 71 | console.error("token payload is invalid: ", decodedUser); 72 | throw new HTTPException(401, { 73 | message: "Token payload is invalid", 74 | }); 75 | } 76 | 77 | const userId = decodedUser.user.id; 78 | 79 | const userData = await getAllUserData(userId); 80 | 81 | const { userRelativeData } = userData; 82 | 83 | const newToken = jwt.sign({ user: userRelativeData }, config.jwtSecret, { 84 | expiresIn: "1h", 85 | }); 86 | 87 | return c.json({ accessToken: newToken, userData: userData }); 88 | }; 89 | 90 | export const authCallback = async (c: Context) => { 91 | console.log("authCallback"); 92 | try { 93 | const body = await c.req.json(); 94 | 95 | if (!body) { 96 | console.error("No body provided"); 97 | throw new HTTPException(401, { 98 | message: "Unauthorized", 99 | }); 100 | } 101 | 102 | const access_token = body.accessToken; 103 | 104 | if (!access_token) { 105 | console.error("No access token provided"); 106 | throw new HTTPException(401, { 107 | message: "Unauthorized", 108 | }); 109 | } 110 | 111 | const { data: userData, error } = await supabase.auth.getUser(access_token); 112 | if (error || !userData) { 113 | console.error("Error fetching user data", error); 114 | throw new HTTPException(401, { 115 | message: "Unauthorized", 116 | }); 117 | } 118 | 119 | const userDocumentExists = await isUserDocumentExists( 120 | userData.user.email || "", 121 | ); 122 | 123 | if (!userDocumentExists) { 124 | console.debug("User document does not exist, creating it"); 125 | await createUserDocument({ 126 | email: userData.user.email || "", 127 | name: userData.user.user_metadata.name || "", 128 | }); 129 | } else { 130 | console.debug("User document already exists"); 131 | } 132 | 133 | const userCompleteData = await getAllUserData(userData.user.id); 134 | 135 | const userInfo = { 136 | name: userData.user.user_metadata.name, 137 | email: userData.user.email, 138 | avatar: userData.user.user_metadata.avatar_url, 139 | id: userData.user.id, 140 | }; 141 | 142 | const token = jwt.sign({ user: userInfo }, config.jwtSecret, { 143 | expiresIn: "24h", 144 | }); 145 | 146 | return c.json({ 147 | accessToken: token, 148 | userInfo: { 149 | userRelativeData: userCompleteData.userRelativeData, 150 | userDocuments: userCompleteData.userDocuments, 151 | }, 152 | }); 153 | } catch (error) { 154 | if (error instanceof HTTPException) { 155 | throw error; 156 | } 157 | console.error("Error in authCallback", error); 158 | throw new HTTPException(500, { 159 | message: "Internal server error", 160 | }); 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /frontend/components/navbar/NavbarLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Navbar, 4 | NavbarBrand, 5 | NavbarContent, 6 | NavbarItem, 7 | NavbarMenu, 8 | NavbarMenuItem, 9 | NavbarMenuToggle, 10 | Link, 11 | Dropdown, 12 | DropdownTrigger, 13 | DropdownMenu, 14 | DropdownItem, 15 | Avatar, 16 | } from "@heroui/react"; 17 | import { useLocation } from "@remix-run/react"; 18 | import { useNavigate } from "@remix-run/react"; 19 | 20 | import { AcmeIcon } from "../../app/routes/AcmeIcon"; 21 | import { useAuthStore } from "~/stores/authStore"; 22 | 23 | export default function NavbarLayout({ 24 | children, 25 | }: { 26 | children: React.ReactNode; 27 | }) { 28 | const userData = useAuthStore((state) => state.userData); 29 | const { pathname } = useLocation(); 30 | 31 | const navigate = useNavigate(); 32 | 33 | const handleLogout = () => { 34 | localStorage.removeItem("accessToken"); 35 | useAuthStore.setState({ 36 | userData: null, 37 | isAuthenticated: false, 38 | accessToken: null, 39 | }); 40 | navigate("/login"); 41 | }; 42 | 43 | return ( 44 | <> 45 | 53 | 54 | 55 | 56 |

MagicCuts

57 |
58 | 62 | 63 | 64 | Home 65 | 66 | 67 | 68 | 73 | Pricing 74 | 75 | 76 | 77 | 81 | Settings 82 | 83 | 84 | 85 | 89 | 90 | 91 | 92 | 95 | 96 | 97 | 98 |

Signed in as

99 |

{userData?.email}

100 |
101 | 102 |
103 |

Available Tokens

104 |

{userData?.tokens || 0}

105 |
106 |
107 | { 110 | navigate("/dashboard/settings"); 111 | }} 112 | > 113 | My Settings 114 | 115 | 120 | Log Out 121 | 122 |
123 |
124 |
125 |
126 | 127 | {/* Mobile Menu */} 128 | 129 | 130 | 131 | Dashboard 132 | 133 | 134 | 135 | 136 | Settings 137 | 138 | 139 | 140 |
141 | {children} 142 | 143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /server/src/services/awsService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HeadObjectCommand, 3 | S3Client, 4 | DeleteObjectCommand, 5 | } from "@aws-sdk/client-s3"; 6 | import { config } from "../config/config"; 7 | import { Upload } from "@aws-sdk/lib-storage"; 8 | import { Readable } from "stream"; 9 | 10 | export class AwsService { 11 | s3client: S3Client; 12 | s3BucketName: string; 13 | s3Region: string; 14 | s3AccessKeyId: string; 15 | s3SecretAccessKey: string; 16 | 17 | constructor() { 18 | const s3bucketName = config.awsBucketName; 19 | const s3Region = config.awsRegion; 20 | const s3AccessKeyId = config.awsAccessKeyId; 21 | const s3SecretAccessKey = config.awsSecretAccessKey; 22 | 23 | if (!s3bucketName || !s3Region || !s3AccessKeyId || !s3SecretAccessKey) { 24 | throw new Error("AWS S3 configuration is missing"); 25 | } 26 | 27 | this.s3BucketName = s3bucketName; 28 | this.s3Region = s3Region; 29 | this.s3AccessKeyId = s3AccessKeyId; 30 | this.s3SecretAccessKey = s3SecretAccessKey; 31 | 32 | this.s3client = new S3Client({ 33 | region: this.s3Region, 34 | }); 35 | } 36 | 37 | private getExpirationDate(): string { 38 | //1 year expiration date 39 | const date = new Date(); 40 | date.setFullYear(date.getFullYear() + 1); 41 | return date.toISOString(); 42 | } 43 | 44 | private async fileExists(filePath: string): Promise { 45 | try { 46 | await this.s3client.send( 47 | new HeadObjectCommand({ 48 | Bucket: this.s3BucketName, 49 | Key: filePath, 50 | }), 51 | ); 52 | return true; 53 | } catch (error: any) { 54 | if (error.name === "NotFound") { 55 | return false; 56 | } 57 | throw error; 58 | } 59 | } 60 | 61 | async uploadFileFromStreamToAWS( 62 | fileStream: Readable, 63 | filePath: string, 64 | addContentType: boolean = false, 65 | ): Promise { 66 | if (await this.fileExists(filePath)) { 67 | console.debug("File already exists in AWS S3"); 68 | return `https://${this.s3BucketName}.s3.${this.s3Region}.amazonaws.com/${filePath}`; 69 | } 70 | 71 | console.debug("Uploading file to AWS"); 72 | 73 | return new Promise((resolve, reject) => { 74 | let fileSize = 0; 75 | 76 | fileStream.on("data", (chunk) => { 77 | fileSize += chunk.length; 78 | }); 79 | 80 | fileStream.on("end", () => { 81 | console.debug(`Total file size: ${fileSize} bytes`); 82 | }); 83 | 84 | fileStream.on("error", (error) => { 85 | console.error("Error in file stream:", error); 86 | reject(error); 87 | }); 88 | 89 | const uploadParams: { 90 | Bucket: string; 91 | Key: string; 92 | Body: Readable; 93 | Metadata: Record; 94 | ContentType?: string; 95 | ContentDisposition?: string; 96 | } = { 97 | Bucket: this.s3BucketName, 98 | Key: filePath, 99 | Body: fileStream, 100 | Metadata: { 101 | "x-amz-meta-expiration-date": this.getExpirationDate(), 102 | }, 103 | }; 104 | 105 | if (filePath.includes(".mp4") && addContentType) { 106 | const fileName = filePath.split("/").pop(); 107 | 108 | uploadParams.ContentType = "video/mp4"; 109 | uploadParams.ContentDisposition = 110 | 'attachment; filename="' + fileName + '"'; 111 | } 112 | 113 | const upload = new Upload({ 114 | client: this.s3client, 115 | params: uploadParams, 116 | }); 117 | 118 | upload 119 | .done() 120 | .then(() => { 121 | console.debug("File uploaded to AWS S3."); 122 | resolve( 123 | `https://${this.s3BucketName}.s3.${this.s3Region}.amazonaws.com/${filePath}`, 124 | ); 125 | }) 126 | .catch((error) => { 127 | console.error("Error during upload:", error); 128 | reject(new Error(`Failed to upload file: ${error.message}`)); 129 | }); 130 | }); 131 | } 132 | 133 | /** 134 | * Deletes a file from AWS S3 bucket based on its URL 135 | * @param fileUrl The complete URL of the file to delete 136 | * @returns A promise that resolves to true if deletion was successful 137 | */ 138 | async deleteFileFromAWS(fileUrl: string): Promise { 139 | try { 140 | // Extract the file path from the URL 141 | // URL format: https://{bucketName}.s3.{region}.amazonaws.com/{filePath} 142 | const urlPattern = new RegExp( 143 | `https://${this.s3BucketName}\\.s3\\.${this.s3Region}\\.amazonaws\\.com/(.+)`, 144 | ); 145 | const match = fileUrl.match(urlPattern); 146 | 147 | if (!match || !match[1]) { 148 | throw new Error("Invalid file URL format"); 149 | } 150 | 151 | const filePath = match[1]; 152 | 153 | // Check if file exists before attempting to delete 154 | const exists = await this.fileExists(filePath); 155 | if (!exists) { 156 | console.debug("File does not exist in AWS S3"); 157 | return false; 158 | } 159 | 160 | console.debug("Deleting file from AWS S3:", filePath); 161 | 162 | // Send delete command to S3 163 | await this.s3client.send( 164 | new DeleteObjectCommand({ 165 | Bucket: this.s3BucketName, 166 | Key: filePath, 167 | }), 168 | ); 169 | 170 | console.debug("File successfully deleted from AWS S3"); 171 | return true; 172 | } catch (error: any) { 173 | console.error("Error deleting file from AWS S3:", error); 174 | throw new Error(`Failed to delete file: ${error.message}`); 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /server/src/controllers/projectController.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from "hono"; 2 | import { ProjectService } from "../services/projectService"; 3 | import { HTTPException } from "hono/http-exception"; 4 | import { getUserDocument, updateUserTokens } from "../services/supabaseService"; 5 | 6 | export class ProjectController { 7 | constructor() { 8 | // 9 | } 10 | 11 | async createProject(c: Context) { 12 | try { 13 | console.debug("Starting controller verification..."); 14 | if (!c.req || !c.req?.formData) { 15 | console.error("No request provided."); 16 | throw new HTTPException(400, { 17 | message: "No request provided.", 18 | }); 19 | } 20 | 21 | const formData = await c.req.formData(); 22 | 23 | if (!formData) { 24 | console.error("No form data provided."); 25 | throw new HTTPException(400, { 26 | message: "No form data provided.", 27 | }); 28 | } 29 | 30 | const video = formData.get("video"); 31 | const payload = c.get("jwtPayload"); 32 | const projectId = formData.get("projectId"); 33 | 34 | if (!projectId || typeof projectId !== "string") { 35 | console.error("No projectId provided."); 36 | throw new HTTPException(400, { 37 | message: "No projectId provided.", 38 | }); 39 | } 40 | 41 | if (!payload || !payload.user) { 42 | console.error("No payload provided."); 43 | throw new HTTPException(400, { 44 | message: "No payload provided.", 45 | }); 46 | } 47 | 48 | // Verify user has at least one token 49 | const { tokens } = await getUserDocument(payload.user.email); 50 | if (tokens < 1) { 51 | console.error("User does not have enough tokens."); 52 | throw new HTTPException(403, { 53 | message: 54 | "Insufficient tokens. Please purchase more tokens to create a project.", 55 | }); 56 | } 57 | 58 | // Deduct one token from the user's account 59 | await updateUserTokens(payload.user.email, 1); 60 | console.debug( 61 | `Deducted 1 token from user ${payload.user.email}. Remaining tokens: ${tokens - 1}`, 62 | ); 63 | 64 | if (!video || typeof video === "string") { 65 | console.error("No video uploaded or invalid video."); 66 | throw new HTTPException(400, { 67 | message: "No video uploaded or invalid video.", 68 | }); 69 | } 70 | 71 | const uploadedVideo = video as File; 72 | 73 | if (!uploadedVideo.type || !uploadedVideo.type.startsWith("video/")) { 74 | console.error("Uploaded video is not a video file."); 75 | throw new HTTPException(400, { 76 | message: "Uploaded video is not a video file.", 77 | }); 78 | } 79 | 80 | if (uploadedVideo.size > 1_073_741_824) { 81 | console.error("Video exceeds maximum allowed size (1 GB)."); 82 | throw new HTTPException(400, { 83 | message: "Video exceeds maximum allowed size (1 GB).", 84 | }); 85 | } 86 | 87 | const projectService = new ProjectService(); 88 | const fileExtension = uploadedVideo.name.split(".").pop(); 89 | 90 | const fileName = `${crypto.randomUUID()}.${fileExtension}`; 91 | 92 | const timeRequested = formData.get("timeRequested"); 93 | 94 | if (!timeRequested || typeof timeRequested !== "string") { 95 | console.error("No time requested provided."); 96 | throw new HTTPException(400, { 97 | message: "No time requested provided.", 98 | }); 99 | } 100 | 101 | console.debug( 102 | "Controller verification completed. Starting project creation...", 103 | ); 104 | const res = await projectService.createProject({ 105 | file: uploadedVideo, 106 | fileName, 107 | userId: payload.user.id, 108 | timeRequested: timeRequested, 109 | projectDocumentId: projectId, 110 | projectName: 111 | (formData.get("name") as string) || 112 | uploadedVideo.name.replace(" ", ""), 113 | }); 114 | 115 | return res; 116 | } catch (error) { 117 | console.error(error); 118 | if (error instanceof HTTPException) { 119 | throw error; 120 | } 121 | throw new HTTPException(500, { 122 | message: "Internal server error.", 123 | }); 124 | } 125 | } 126 | 127 | async getProject(c: Context) { 128 | try { 129 | const userId = c.get("jwtPayload").user.id; 130 | const body = await c.req.json(); 131 | const projectDocumentId = body.projectDocumentId; 132 | 133 | if (!projectDocumentId || typeof projectDocumentId !== "string") { 134 | console.error("No projectDocumentId provided."); 135 | throw new HTTPException(400, { 136 | message: "No projectDocumentId provided.", 137 | }); 138 | } 139 | 140 | const projectService = new ProjectService(); 141 | const res = await projectService.getProject({ 142 | userId, 143 | projectDocumentId: projectDocumentId, 144 | }); 145 | return res; 146 | } catch (error) { 147 | console.error(error); 148 | if (error instanceof HTTPException) { 149 | throw error; 150 | } 151 | throw new HTTPException(500, { 152 | message: "Internal server error.", 153 | }); 154 | } 155 | } 156 | 157 | async getAllProjects(c: Context) { 158 | try { 159 | const userId = c.get("jwtPayload").user.id; 160 | const projectService = new ProjectService(); 161 | const res = await projectService.getAllProjects(userId); 162 | return res; 163 | } catch (error) { 164 | console.error(error); 165 | if (error instanceof HTTPException) { 166 | throw error; 167 | } 168 | throw new HTTPException(500, { 169 | message: "Internal server error.", 170 | }); 171 | } 172 | } 173 | 174 | async addSubtitle(c: Context) { 175 | try { 176 | // 177 | } catch (error) { 178 | console.error(error); 179 | if (error instanceof HTTPException) { 180 | throw error; 181 | } 182 | throw new HTTPException(500, { 183 | message: "Internal server error.", 184 | }); 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /server/src/services/projectService.ts: -------------------------------------------------------------------------------- 1 | import { AwsService } from "./awsService"; 2 | import { promises as fsPromises } from "fs"; 3 | import { createReadStream } from "fs"; 4 | import fs from "fs"; 5 | import { TranscriptionService } from "./transcriptionService"; 6 | import { PromptService } from "./promptService"; 7 | import { models, OpenaiService } from "./openaiService"; 8 | import { responseFormat } from "../constants/constants"; 9 | import { FfmpegService } from "./ffmpegService"; 10 | import type { DetectedSegments } from "../types/openai"; 11 | import { HTTPException } from "hono/http-exception"; 12 | import { 13 | createProjectDocument, 14 | updateProjectDocument, 15 | getProjectDocument, 16 | getProjectsByUserId, 17 | } from "./supabaseService"; 18 | 19 | export class ProjectService { 20 | constructor() { 21 | // 22 | } 23 | 24 | async createProject({ 25 | file, 26 | fileName, 27 | userId, 28 | timeRequested, 29 | projectDocumentId, 30 | projectName, 31 | }: { 32 | file: File; 33 | fileName: string; 34 | userId: string; 35 | timeRequested: string; 36 | projectDocumentId: string; 37 | projectName: string; 38 | }) { 39 | const tempFilePath = `temp/${crypto.randomUUID()}-${fileName.replace( 40 | " ", 41 | "_", 42 | )}`; 43 | 44 | const arrayBuffer = await file.arrayBuffer(); 45 | const buffer = Buffer.from(arrayBuffer); 46 | let originalUrlVideo = ""; 47 | 48 | try { 49 | console.debug("Creating project document..."); 50 | await createProjectDocument({ 51 | userId, 52 | documentId: projectDocumentId, 53 | projectName, 54 | }); 55 | console.debug("Project document created"); 56 | 57 | await fsPromises.mkdir("temp", { recursive: true }); 58 | await fsPromises.writeFile(tempFilePath, buffer); 59 | const stream = createReadStream(tempFilePath); 60 | const awsService = new AwsService(); 61 | 62 | const awsFilePath = `magicscuts/${userId}/${crypto.randomUUID()}-${fileName}`; 63 | 64 | console.debug("Uploading file to AWS..."); 65 | originalUrlVideo = await awsService.uploadFileFromStreamToAWS( 66 | stream, 67 | awsFilePath, 68 | ); 69 | console.debug("File uploaded to AWS"); 70 | const transcriptionService = new TranscriptionService(); 71 | 72 | console.debug("Transcribing video..."); 73 | const transcription = 74 | await transcriptionService.transcribeVideo(originalUrlVideo); 75 | console.debug("Video transcribed"); 76 | 77 | const promptService = new PromptService(); 78 | 79 | console.debug("Creating prompt..."); 80 | const prompt = promptService.createPromptForViralSegments({ 81 | stringifiedTranscription: transcription, 82 | desiredSegmentDuration: timeRequested, 83 | }); 84 | console.debug("Prompt created"); 85 | 86 | const openaiService = new OpenaiService(); 87 | 88 | console.debug("Requesting to GPT..."); 89 | const bestSegments = await openaiService.requestToGPT({ 90 | prompt, 91 | model: models.o3Mini, 92 | maxTokens: 20000, 93 | temperature: 0.5, 94 | responseFormat: "custom", 95 | customResponseFormat: responseFormat, 96 | }); 97 | console.debug("GPT requested"); 98 | const bestSegmentJSON = JSON.parse(bestSegments) as { 99 | segments: DetectedSegments[]; 100 | }; 101 | 102 | const ffmpegService = new FfmpegService(); 103 | console.debug("Cutting and transforming segments..."); 104 | const segments = await ffmpegService.cutAndTransformSegments( 105 | bestSegmentJSON, 106 | originalUrlVideo, 107 | ); 108 | 109 | console.debug("Segments cut and transformed"); 110 | console.debug("Saving each short in S3..."); 111 | 112 | const segmentsWithUrl = await this.saveEachShortInS3( 113 | segments, 114 | userId, 115 | fileName, 116 | ); 117 | 118 | console.debug("Segments saved in S3"); 119 | console.debug("final Update project document..."); 120 | await updateProjectDocument({ 121 | documentId: projectDocumentId, 122 | detectedSegments: segmentsWithUrl, 123 | state: "completed", 124 | }); 125 | 126 | console.debug("Project updated"); 127 | return segmentsWithUrl; 128 | } catch (error) { 129 | console.error(error); 130 | 131 | await updateProjectDocument({ 132 | documentId: projectDocumentId, 133 | detectedSegments: [], 134 | state: "failed", 135 | }); 136 | 137 | if (error instanceof HTTPException) { 138 | throw error; 139 | } 140 | 141 | throw new HTTPException(500, { 142 | message: "Error creating project", 143 | }); 144 | } finally { 145 | if (fs.existsSync(tempFilePath)) { 146 | await fsPromises.unlink(tempFilePath); 147 | } 148 | 149 | const awsService = new AwsService(); 150 | if (originalUrlVideo) { 151 | await awsService.deleteFileFromAWS(originalUrlVideo); 152 | } 153 | } 154 | } 155 | 156 | async getProject({ 157 | userId, 158 | projectDocumentId, 159 | }: { 160 | userId: string; 161 | projectDocumentId: string; 162 | }) { 163 | const projectDocument = await getProjectDocument(projectDocumentId); 164 | 165 | if (projectDocument.user_id !== userId) { 166 | throw new HTTPException(401, { 167 | message: "Unauthorized access to this project", 168 | }); 169 | } 170 | 171 | return projectDocument; 172 | } 173 | 174 | async saveEachShortInS3( 175 | segments: DetectedSegments[], 176 | userId: string, 177 | fileName: string, 178 | ) { 179 | const awsService = new AwsService(); 180 | const res = await Promise.all( 181 | segments.map(async (segment) => { 182 | if (!segment.filePath) { 183 | throw new HTTPException(500, { 184 | message: "Segment file path is missing", 185 | }); 186 | } 187 | const stream = createReadStream(segment.filePath); 188 | const awsFilePath = `magicscuts/${userId}/${crypto.randomUUID()}-${fileName}`; 189 | const url = await awsService.uploadFileFromStreamToAWS( 190 | stream, 191 | awsFilePath, 192 | ); 193 | 194 | if (fs.existsSync(segment.filePath)) { 195 | await fsPromises.unlink(segment.filePath); 196 | } 197 | 198 | return { 199 | ...segment, 200 | filePath: url, 201 | }; 202 | }), 203 | ); 204 | 205 | return res; 206 | } 207 | 208 | async getAllProjects(userId: string) { 209 | const projects = await getProjectsByUserId(userId); 210 | return projects; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /frontend/components/dashboard/ProjectsList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useCallback, useState } from "react"; 4 | import { Card, CardBody, Spinner } from "@heroui/react"; 5 | import { useNavigate } from "@remix-run/react"; 6 | import { useAuthStore } from "../../app/stores/authStore"; 7 | import { ProjectAPI } from "../../utils/services/api/ProjectApi"; 8 | import { cn } from "~/cn"; 9 | import { ProjectDocument } from "~/utils/types/supabase"; 10 | 11 | export const ProjectsList: React.FC = () => { 12 | const userProjects = useAuthStore((state) => state.userProjects); 13 | const setUserProjects = useAuthStore((state) => state.setUserProjects); 14 | const navigate = useNavigate(); 15 | const [newProjectIds, setNewProjectIds] = useState<{ 16 | [key: string]: boolean; 17 | }>({}); 18 | 19 | const projects = userProjects || []; 20 | 21 | useEffect(() => { 22 | if (!projects.length) return; 23 | 24 | const currentProjectIds = projects.reduce( 25 | (acc, project) => { 26 | if (project.state === "pending" && !newProjectIds[project.id]) { 27 | acc[project.id] = true; 28 | } 29 | return acc; 30 | }, 31 | {} as { [key: string]: boolean }, 32 | ); 33 | 34 | if (Object.keys(currentProjectIds).length > 0) { 35 | setNewProjectIds((prev) => ({ ...prev, ...currentProjectIds })); 36 | } 37 | }, [projects]); 38 | 39 | const checkPendingProjects = useCallback(async () => { 40 | const pendingProjects = projects.filter( 41 | (project) => project.state === "pending", 42 | ); 43 | 44 | if (pendingProjects.length === 0) return; 45 | 46 | try { 47 | const updatedProjectsPromises = pendingProjects.map((project) => 48 | ProjectAPI.getProject(project.id), 49 | ); 50 | 51 | const updatedProjectsResults = await Promise.all(updatedProjectsPromises); 52 | 53 | let hasChanges = false; 54 | 55 | const newProjectsList = [...projects]; 56 | 57 | updatedProjectsResults.forEach((updatedProject) => { 58 | const projectIndex = newProjectsList.findIndex( 59 | (p) => p.id === updatedProject.id, 60 | ); 61 | 62 | if ( 63 | projectIndex !== -1 && 64 | newProjectsList[projectIndex].state !== updatedProject.state 65 | ) { 66 | newProjectsList[projectIndex] = updatedProject; 67 | hasChanges = true; 68 | } 69 | }); 70 | 71 | if (hasChanges) { 72 | setUserProjects(newProjectsList); 73 | } 74 | } catch (error) { 75 | console.error("Error checking pending projects:", error); 76 | } 77 | }, [projects, setUserProjects]); 78 | 79 | // Effet pour gérer les vérifications régulières 80 | useEffect(() => { 81 | // Vérification initiale pour les projets existants qui ne sont pas nouveaux 82 | const existingPendingProjects = projects.filter( 83 | (project) => project.state === "pending" && !newProjectIds[project.id], 84 | ); 85 | 86 | if (existingPendingProjects.length > 0) { 87 | checkPendingProjects(); 88 | } 89 | 90 | const intervalId = setInterval(checkPendingProjects, 5000); 91 | 92 | return () => clearInterval(intervalId); 93 | }, [checkPendingProjects, newProjectIds, projects]); 94 | 95 | useEffect(() => { 96 | const newProjectsIds = Object.keys(newProjectIds); 97 | if (newProjectsIds.length === 0) return; 98 | 99 | const timeoutIds = newProjectsIds.map((projectId) => { 100 | return setTimeout(() => { 101 | checkPendingProjects(); 102 | setNewProjectIds((prev) => { 103 | const updated = { ...prev }; 104 | delete updated[projectId]; 105 | return updated; 106 | }); 107 | }, 10000); 108 | }); 109 | 110 | return () => { 111 | timeoutIds.forEach((id) => clearTimeout(id)); 112 | }; 113 | }, [newProjectIds, checkPendingProjects]); 114 | 115 | // Render project status icon based on state 116 | const renderStatusIcon = (state: "pending" | "completed" | "failed") => { 117 | switch (state) { 118 | case "pending": 119 | return ; 120 | case "completed": 121 | return ( 122 |
123 | 129 | 134 | 135 |
136 | ); 137 | case "failed": 138 | return ( 139 |
140 | 146 | 151 | 152 |
153 | ); 154 | default: 155 | return null; 156 | } 157 | }; 158 | 159 | return ( 160 |
161 |

Your Projects

162 | {projects.length === 0 ? ( 163 | 164 | 165 | No projects found. Upload a video to get started. 166 | 167 | 168 | ) : ( 169 |
170 | {projects 171 | .sort((a, b) => { 172 | const dateA = a.createdDate 173 | ? new Date(a.createdDate).getTime() 174 | : 0; 175 | const dateB = b.createdDate 176 | ? new Date(b.createdDate).getTime() 177 | : 0; 178 | return dateB - dateA; // Sort in descending order (newest first) 179 | }) 180 | .map((project) => ( 181 | navigate(`/dashboard/${project.id}`)} 190 | > 191 | 192 |
193 |

194 | {project.name || "Untitled Project"} 195 |

196 | {project.createdDate && ( 197 | 198 | {new Date(project.createdDate).toLocaleDateString( 199 | "en-US", 200 | { 201 | year: "numeric", 202 | month: "short", 203 | day: "numeric", 204 | hour: "2-digit", 205 | minute: "2-digit", 206 | }, 207 | )} 208 | 209 | )} 210 |
211 |
212 | 213 | {project.state} 214 | 215 | {renderStatusIcon(project.state)} 216 |
217 |
218 |
219 | ))} 220 |
221 | )} 222 |
223 | ); 224 | }; 225 | 226 | export default ProjectsList; 227 | -------------------------------------------------------------------------------- /server/src/services/supabaseService.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | import { config } from "../config/config"; 3 | import type { UserData } from "../types/user"; 4 | import type { DetectedSegments } from "../types/openai"; 5 | import { HTTPException } from "hono/http-exception"; 6 | 7 | export const supabase = createClient( 8 | config.supabaseUrl, 9 | config.serviceRoleKey, 10 | { 11 | auth: { 12 | autoRefreshToken: true, 13 | persistSession: true, 14 | }, 15 | }, 16 | ); 17 | 18 | export const getUserData = async (userId: string) => { 19 | try { 20 | const { data: user, error } = await supabase.auth.admin.getUserById(userId); 21 | 22 | if (error) { 23 | console.error("Error fetching user data", error); 24 | throw error; 25 | } 26 | 27 | const userData: UserData = { 28 | name: user.user.user_metadata.name, 29 | email: user.user.email!, 30 | avatar: user.user.user_metadata.avatar_url, 31 | id: user.user.id, 32 | }; 33 | 34 | return userData; 35 | } catch (error) { 36 | console.error("Error fetching user data", error); 37 | throw error; 38 | } 39 | }; 40 | 41 | export const getAllUserData = async (userId: string) => { 42 | try { 43 | const userRelativeData = await getUserData(userId); 44 | const otherUserData = await getUserDocument(userRelativeData.email); 45 | 46 | const { data: userDocuments, error: userDocumentsError } = await supabase 47 | .from("project_documents") 48 | .select("*") 49 | .eq("user_id", userId); 50 | 51 | if (userDocumentsError) { 52 | console.error("Error fetching user documents", userDocumentsError); 53 | throw userDocumentsError; 54 | } 55 | 56 | return { 57 | userRelativeData: { 58 | ...userRelativeData, 59 | ...otherUserData, 60 | }, 61 | userDocuments, 62 | }; 63 | } catch (error) { 64 | if (error instanceof HTTPException) { 65 | throw error; 66 | } 67 | console.error("Error fetching all user data", error); 68 | throw new HTTPException(500, { 69 | message: "Error fetching all user data", 70 | }); 71 | } 72 | }; 73 | 74 | // Mise à jour de la fonction pour insérer un document dans la table : 75 | // Ajoutez les paramètres userId, originalVideoUrl, et detectedSegments. 76 | export const createProjectDocument = async ({ 77 | userId, 78 | documentId, 79 | projectName, 80 | }: { 81 | userId: string; 82 | documentId: string; 83 | projectName: string; 84 | }) => { 85 | // Check if document already exists 86 | const { data: existingDocument, error: checkError } = await supabase 87 | .from("project_documents") 88 | .select() 89 | .eq("id", documentId) 90 | .single(); 91 | 92 | if (checkError && checkError.code !== "PGRST116") { 93 | // PGRST116 means no rows returned, which is what we want 94 | console.error("Error checking for existing document", checkError); 95 | throw new HTTPException(500, { 96 | message: "Error checking for existing document", 97 | }); 98 | } 99 | 100 | if (existingDocument) { 101 | throw new HTTPException(400, { 102 | message: "A document with this ID already exists", 103 | }); 104 | } 105 | 106 | try { 107 | const { data, error } = await supabase.from("project_documents").insert([ 108 | { 109 | id: documentId, 110 | user_id: userId, 111 | original_video_url: null, 112 | detected_segments: null, 113 | state: "pending", 114 | name: projectName, 115 | createdDate: new Date().toISOString(), 116 | }, 117 | ]); 118 | 119 | if (error) { 120 | console.error("Error creating project document", error); 121 | throw new HTTPException(500, { 122 | message: "Error creating project document", 123 | }); 124 | } 125 | 126 | return data; 127 | } catch (error) { 128 | if (error instanceof HTTPException) { 129 | throw error; 130 | } 131 | console.error("Error creating project document", error); 132 | throw new HTTPException(500, { 133 | message: "Error creating project document", 134 | }); 135 | } 136 | }; 137 | 138 | // Fonction pour mettre à jour un document dans la table "project_documents" 139 | export const updateProjectDocument = async ({ 140 | documentId, 141 | detectedSegments, 142 | state, 143 | }: { 144 | documentId: string; 145 | detectedSegments: DetectedSegments[]; 146 | state: string; 147 | }) => { 148 | const { data, error } = await supabase 149 | .from("project_documents") 150 | .update({ 151 | detected_segments: detectedSegments, 152 | state: state, 153 | }) 154 | .eq("id", documentId); 155 | 156 | if (error) { 157 | console.error("Error updating project document", error); 158 | throw error; 159 | } 160 | 161 | return data; 162 | }; 163 | 164 | export const getProjectDocument = async (documentId: string) => { 165 | try { 166 | const { data, error } = await supabase 167 | .from("project_documents") 168 | .select("*") 169 | .eq("id", documentId) 170 | .single(); 171 | 172 | if (error) { 173 | console.error("Error fetching project document", error); 174 | throw new HTTPException(500, { 175 | message: "Error fetching project document", 176 | }); 177 | } 178 | 179 | return data; 180 | } catch (error) { 181 | console.error("Error fetching project document", error); 182 | throw new HTTPException(500, { 183 | message: "Error fetching project document", 184 | }); 185 | } 186 | }; 187 | 188 | export const getProjectsByUserId = async (userId: string) => { 189 | try { 190 | const { data, error } = await supabase 191 | .from("project_documents") 192 | .select("*") 193 | .eq("user_id", userId); 194 | 195 | if (error) { 196 | console.error("Error fetching projects by user id", error); 197 | throw new HTTPException(500, { 198 | message: "Error fetching projects by user id", 199 | }); 200 | } 201 | 202 | return data; 203 | } catch (error) { 204 | console.error("Error fetching projects by user id", error); 205 | throw error; 206 | } 207 | }; 208 | 209 | export const createUserDocument = async ({ 210 | email, 211 | name, 212 | }: { 213 | email: string; 214 | name: string; 215 | }) => { 216 | try { 217 | const { data, error } = await supabase 218 | .from("users") 219 | .insert([{ email, name, tokens: 1, is_premium: false }]); 220 | 221 | if (error) { 222 | console.error("Error creating user", error); 223 | throw new HTTPException(500, { 224 | message: "Error creating user", 225 | }); 226 | } 227 | 228 | return data; 229 | } catch (error) { 230 | console.error("Error creating user", error); 231 | throw new HTTPException(500, { 232 | message: "Error creating user", 233 | }); 234 | } 235 | }; 236 | 237 | export const isUserDocumentExists = async (email: string) => { 238 | try { 239 | const { data, error } = await supabase 240 | .from("users") 241 | .select("*") 242 | .eq("email", email); 243 | 244 | if (error) { 245 | console.error("Error checking if user document exists", error); 246 | throw new HTTPException(500, { 247 | message: "Error checking if user document exists", 248 | }); 249 | } 250 | 251 | return data.length > 0 ? true : false; 252 | } catch (error) { 253 | console.error("Error checking if user document exists", error); 254 | throw new HTTPException(500, { 255 | message: "Error checking if user document exists", 256 | }); 257 | } 258 | }; 259 | 260 | export const getUserDocument = async (email: string) => { 261 | try { 262 | const { data, error } = await supabase 263 | .from("users") 264 | .select("tokens, is_premium") 265 | .eq("email", email) 266 | .single(); 267 | 268 | if (error) { 269 | console.error("Error fetching user document", error); 270 | throw new HTTPException(500, { 271 | message: "Error fetching user document", 272 | }); 273 | } 274 | 275 | return { 276 | tokens: data.tokens, 277 | is_premium: data.is_premium, 278 | }; 279 | } catch (error) { 280 | console.error("Error fetching user tokens and premium status", error); 281 | throw new HTTPException(500, { 282 | message: "Error fetching user tokens and premium status", 283 | }); 284 | } 285 | }; 286 | 287 | /** 288 | * Updates a user's token count 289 | * @param email The user's email 290 | * @param tokensToDeduct Number of tokens to deduct (positive number) 291 | * @returns The updated user data 292 | */ 293 | export const updateUserTokens = async ( 294 | email: string, 295 | tokensToDeduct: number, 296 | ) => { 297 | try { 298 | // First get current token count 299 | const { tokens } = await getUserDocument(email); 300 | 301 | // Check if user has enough tokens 302 | if (tokens < tokensToDeduct) { 303 | throw new HTTPException(403, { 304 | message: "Insufficient tokens. Please purchase more tokens.", 305 | }); 306 | } 307 | 308 | // Update tokens 309 | const { data, error } = await supabase 310 | .from("users") 311 | .update({ tokens: tokens - tokensToDeduct }) 312 | .eq("email", email) 313 | .select("tokens, is_premium"); 314 | 315 | if (error) { 316 | console.error("Error updating user tokens", error); 317 | throw new HTTPException(500, { 318 | message: "Error updating user tokens", 319 | }); 320 | } 321 | 322 | return { 323 | tokens: data[0].tokens, 324 | is_premium: data[0].is_premium, 325 | }; 326 | } catch (error) { 327 | if (error instanceof HTTPException) { 328 | throw error; 329 | } 330 | console.error("Error updating user tokens", error); 331 | throw new HTTPException(500, { 332 | message: "Error updating user tokens", 333 | }); 334 | } 335 | }; 336 | -------------------------------------------------------------------------------- /frontend/components/dashboard/FileUploader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, ChangeEvent } from "react"; 4 | import { 5 | Alert, 6 | Button, 7 | Card, 8 | CardBody, 9 | Spinner, 10 | Select, 11 | SelectItem, 12 | } from "@heroui/react"; 13 | import { useAuthStore } from "../../app/stores/authStore"; 14 | import { ProjectAPI } from "../../utils/services/api/ProjectApi"; 15 | import { ProjectDocument } from "~/utils/types/supabase"; 16 | import { toastMsg } from "~/utils/toasts"; 17 | 18 | // Time options for the segments 19 | const TIME_OPTIONS = [ 20 | { value: "15-30seconds", label: "15-30 seconds (recommended)" }, 21 | { value: "30-60seconds", label: "30-60 seconds" }, 22 | { value: "60-90seconds", label: "1-1.5 minutes" }, 23 | { value: "90-120seconds", label: "1.5-2 minutes" }, 24 | { value: "120-180seconds", label: "2-3 minutes" }, 25 | ]; 26 | 27 | export const FileUploader: React.FC = () => { 28 | // State for file upload 29 | const [dragging, setDragging] = useState(false); 30 | const [file, setFile] = useState(null); 31 | const [isUploading, setIsUploading] = useState(false); 32 | const [error, setError] = useState(null); 33 | const [showProcessingMessage, setShowProcessingMessage] = 34 | useState(false); 35 | const [selectedTime, setSelectedTime] = useState(""); 36 | 37 | // Get user data and projects update function from auth store 38 | const { userData, userProjects, setUserProjects } = useAuthStore(); 39 | 40 | // Handle drag events 41 | const handleDragIn = (e: React.DragEvent) => { 42 | e.preventDefault(); 43 | e.stopPropagation(); 44 | setDragging(true); 45 | }; 46 | 47 | const handleDragOut = (e: React.DragEvent) => { 48 | e.preventDefault(); 49 | e.stopPropagation(); 50 | setDragging(false); 51 | }; 52 | 53 | const handleDragOver = (e: React.DragEvent) => { 54 | e.preventDefault(); 55 | e.stopPropagation(); 56 | }; 57 | 58 | // Check file constraints 59 | const checkFileConstraints = async ( 60 | file: File, 61 | ): Promise<{ isValid: boolean; error?: string }> => { 62 | // Check if user has tokens 63 | if (!userData?.is_premium && (userData?.tokens || 0) <= 0) { 64 | return { 65 | isValid: false, 66 | error: 67 | "You don't have any tokens left. Please upgrade to premium or purchase more tokens.", 68 | }; 69 | } 70 | 71 | // Check file size - 1GB for premium users, 500MB for regular users 72 | const maxSize = userData?.is_premium 73 | ? 1024 * 1024 * 1024 // 1GB for premium users 74 | : 500 * 1024 * 1024; // 500MB for regular users 75 | 76 | if (file.size > maxSize) { 77 | return { 78 | isValid: false, 79 | error: userData?.is_premium 80 | ? "File size must be less than 1 GB" 81 | : "File size must be less than 500 MB. Upgrade to premium to upload files up to 1 GB.", 82 | }; 83 | } 84 | 85 | // Check file type 86 | if (!file.type.startsWith("video/")) { 87 | return { 88 | isValid: false, 89 | error: "Only video files are supported", 90 | }; 91 | } 92 | 93 | // Check video duration 94 | return new Promise((resolve) => { 95 | const video = document.createElement("video"); 96 | video.preload = "metadata"; 97 | 98 | video.onloadedmetadata = () => { 99 | window.URL.revokeObjectURL(video.src); 100 | const duration = video.duration; 101 | 102 | if (duration > 1800) { 103 | resolve({ 104 | isValid: false, 105 | error: "Video duration must be less than 30 minutes", 106 | }); 107 | } 108 | 109 | resolve({ isValid: true }); 110 | }; 111 | 112 | video.onerror = () => { 113 | resolve({ 114 | isValid: false, 115 | error: "Error reading video file", 116 | }); 117 | }; 118 | 119 | video.src = URL.createObjectURL(file); 120 | }); 121 | }; 122 | 123 | // Handle file drop 124 | const handleDrop = async (e: React.DragEvent) => { 125 | e.preventDefault(); 126 | e.stopPropagation(); 127 | setDragging(false); 128 | setError(null); 129 | 130 | const files = e.dataTransfer.files; 131 | if (files && files.length > 0) { 132 | const validation = await checkFileConstraints(files[0]); 133 | if (!validation.isValid) { 134 | setError(validation.error || "Invalid video file"); 135 | return; 136 | } 137 | setFile(files[0]); 138 | } 139 | }; 140 | 141 | // Handle file input change 142 | const handleFileChange = async (e: ChangeEvent) => { 143 | setError(null); 144 | if (e.target.files && e.target.files.length > 0) { 145 | const validation = await checkFileConstraints(e.target.files[0]); 146 | if (!validation.isValid) { 147 | setError(validation.error || "Invalid video file"); 148 | return; 149 | } 150 | setFile(e.target.files[0]); 151 | } 152 | }; 153 | 154 | // Handle file upload 155 | const handleUpload = async () => { 156 | if (!file) { 157 | setError("No file selected"); 158 | return; 159 | } 160 | 161 | if (!selectedTime) { 162 | setError("Please select a segment time length"); 163 | return; 164 | } 165 | 166 | if (!userData) { 167 | setError("You must be logged in to upload files"); 168 | return; 169 | } 170 | 171 | // Check if user has tokens 172 | if (!userData.is_premium && userData.tokens <= 0) { 173 | setError( 174 | "You don't have any tokens left. Please upgrade to premium or purchase more tokens.", 175 | ); 176 | return; 177 | } 178 | 179 | setIsUploading(true); 180 | setError(null); 181 | 182 | // Show processing message after a short delay 183 | const processingTimer = setTimeout(() => { 184 | setShowProcessingMessage(true); 185 | }, 2000); 186 | 187 | try { 188 | // Create a unique project ID 189 | const projectId = crypto.randomUUID(); 190 | 191 | // Create FormData object 192 | const formData = new FormData(); 193 | formData.append("video", file); 194 | formData.append("projectId", projectId); 195 | formData.append("timeRequested", selectedTime); 196 | formData.append("name", file.name); 197 | 198 | const pendingProject: ProjectDocument = { 199 | id: projectId, 200 | user_id: userData.id, 201 | original_video_url: "", 202 | detected_segments: [], 203 | state: "pending" as const, 204 | name: file.name, 205 | createdDate: new Date().toISOString(), 206 | }; 207 | 208 | setUserProjects([...userProjects, pendingProject]); 209 | 210 | // Don't await the API call, let it run in the background 211 | ProjectAPI.createProject(formData).catch((error) => { 212 | console.error("Project creation failed:", error); 213 | // Optional: Update the project state to failed if needed 214 | // This would require finding the project in the state and updating it 215 | }); 216 | 217 | userData.tokens -= 1; 218 | 219 | // Reset states immediately 220 | setFile(null); 221 | setSelectedTime(""); 222 | setError(null); 223 | toastMsg.success( 224 | "Video uploaded successfully, please wait for the process to finish.", 225 | ); 226 | } catch (error) { 227 | console.error("Upload initialization failed:", error); 228 | 229 | // Handle different types of errors 230 | if (error instanceof Error) { 231 | setError(`Upload failed: ${error.message}`); 232 | } else { 233 | setError("Upload failed. Please try again later."); 234 | } 235 | } finally { 236 | clearTimeout(processingTimer); 237 | setIsUploading(false); 238 | setShowProcessingMessage(false); 239 | } 240 | }; 241 | 242 | return ( 243 |
244 |

245 | Import a video to transform into a viral short 246 |

247 | 248 | 249 |
259 | 328 |
329 | 330 | {/* Time segment selector */} 331 |
332 | 350 |
351 | 352 | 360 |
361 |
362 | 363 | {/* Available tokens display */} 364 |
365 |
366 | Available tokens: 367 | 374 | {userData?.tokens || 0} 375 | 376 | {(userData?.tokens || 0) === 0 && ( 377 | 378 | No tokens left! 379 | 380 | )} 381 |
382 | 383 | Each upload uses 1 token 384 | 385 |
386 |
387 | {error && ( 388 | setError(null)} 394 | /> 395 | )} 396 |
397 |
398 | ); 399 | }; 400 | 401 | export default FileUploader; 402 | -------------------------------------------------------------------------------- /frontend/app/routes/pricing.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardBody, 4 | CardHeader, 5 | Button, 6 | Divider, 7 | Modal, 8 | ModalContent, 9 | ModalHeader, 10 | ModalBody, 11 | ModalFooter, 12 | } from "@heroui/react"; 13 | import { Icon } from "@iconify/react"; 14 | import { useNavigate } from "@remix-run/react"; 15 | import { useState } from "react"; 16 | 17 | export default function Pricing() { 18 | const [isModalOpen, setIsModalOpen] = useState(false); 19 | 20 | const features = [ 21 | { 22 | name: "Monthly price", 23 | basic: "€10", 24 | pro: "€20", 25 | premium: "€30", 26 | }, 27 | { 28 | name: "Cost per video", 29 | basic: "€1.00", 30 | pro: "€0.80", 31 | premium: "€0.75", 32 | }, 33 | { 34 | name: "Cost per short", 35 | basic: "€0.10", 36 | pro: "€0.08", 37 | premium: "€0.075", 38 | }, 39 | { 40 | name: "Upload video size", 41 | basic: "Up to 1GB", 42 | pro: "Up to 1GB", 43 | premium: "Up to 1GB", 44 | }, 45 | { 46 | name: "Video resolution", 47 | basic: "Up to 4K", 48 | pro: "Up to 4K", 49 | premium: "Up to 4K", 50 | }, 51 | { 52 | name: "Video length", 53 | basic: "Unlimited", 54 | pro: "Unlimited", 55 | premium: "Unlimited", 56 | }, 57 | { name: "AI-powered short creation", basic: "✓", pro: "✓", premium: "✓" }, 58 | { name: "Shorts per video", basic: "10", pro: "10", premium: "10" }, 59 | ]; 60 | 61 | const navigate = useNavigate(); 62 | return ( 63 |
64 |
65 | {/* Header */} 66 |
67 |

68 | Simple, transparent pricing 69 |

70 |

71 | Transform your long videos into viral short content with our 72 | AI-powered platform. Choose the plan that works for you. 73 |

74 |
75 | 76 | {/* Pricing Cards */} 77 |
78 | 79 | 80 |

Basic

81 |
82 | €10 83 | /month 84 |
85 |

Perfect for getting started

86 |
87 | 88 | 89 |
90 |
91 |
92 | 93 | 10 Videos 94 |
95 |

96 | Upload up to 10 videos per month 97 |

98 |
99 |
100 |
101 | 102 | 100 Shorts 103 |
104 |

105 | Create up to 100 short clips (10 per video) 106 |

107 |
108 | 116 |
117 |
118 |
119 | 120 | {/* Pro Plan */} 121 | 122 |
123 | MOST POPULAR 124 |
125 | 126 |

Pro

127 |
128 | €20 129 | /month 130 |
131 |

132 | Best value for content creators 133 |

134 |
135 | 136 | 137 |
138 |
139 |
140 | 141 | 25 Videos 142 |
143 |

144 | Upload up to 25 videos per month 145 |

146 |
147 |
148 |
149 | 150 | 250 Shorts 151 |
152 |

153 | Create up to 250 short clips (10 per video) 154 |

155 |
156 | 164 |
165 |
166 |
167 | 168 | {/* Premium Plan */} 169 | 170 | 171 |

Premium

172 |
173 | €30 174 | /month 175 |
176 |

For serious content creators

177 |
178 | 179 | 180 |
181 |
182 |
183 | 184 | 40 Videos 185 |
186 |

187 | Upload up to 40 videos per month 188 |

189 |
190 |
191 |
192 | 193 | 400 Shorts 194 |
195 |

196 | Create up to 400 short clips (10 per video) 197 |

198 |
199 | 207 |
208 |
209 |
210 |
211 | 212 | {/* Features Comparison */} 213 |
214 |

Compare Plans

215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | {features.slice(0, 3).map((feature, index) => ( 227 | 235 | 236 | 237 | 238 | 239 | 240 | ))} 241 | 242 | 243 | 244 | {features.slice(3).map((feature, index) => ( 245 | 249 | 250 | 262 | 274 | 286 | 287 | ))} 288 | 289 |
FeaturesBasicProPremium
{feature.name}{feature.basic}{feature.pro}{feature.premium}
{feature.name} 251 | {feature.basic === "✓" ? ( 252 | 256 | ) : feature.basic === "✗" ? ( 257 | 258 | ) : ( 259 | feature.basic 260 | )} 261 | 263 | {feature.pro === "✓" ? ( 264 | 268 | ) : feature.pro === "✗" ? ( 269 | 270 | ) : ( 271 | feature.pro 272 | )} 273 | 275 | {feature.premium === "✓" ? ( 276 | 280 | ) : feature.premium === "✗" ? ( 281 | 282 | ) : ( 283 | feature.premium 284 | )} 285 |
290 |
291 |
292 | 293 | {/* FAQ Section */} 294 |
295 |

296 | Frequently Asked Questions 297 |

298 |
299 | 300 | 301 |

302 | What happens if I exceed my video limit? 303 |

304 |

305 | If you reach your monthly video limit, you can upgrade to a 306 | higher plan or wait until the next billing cycle. 307 |

308 |
309 |
310 | 311 | 312 |

313 | Can I cancel my subscription anytime? 314 |

315 |

316 | Yes, you can cancel your subscription at any time. You'll 317 | continue to have access until the end of your billing period. 318 |

319 |
320 |
321 | 322 | 323 |

324 | How does the AI create short clips? 325 |

326 |

327 | Our AI analyzes your videos for engaging moments, transitions, 328 | and key points to create the most shareable short-form 329 | content. 330 |

331 |
332 |
333 | 334 | 335 |

336 | What video formats are supported? 337 |

338 |

339 | We support most common video formats including MP4, MOV, AVI, 340 | and more. Videos can be up to 1GB in size. 341 |

342 |
343 |
344 |
345 |
346 | 347 | {/* CTA Section */} 348 |
349 |

350 | Ready to transform your content? 351 |

352 |

353 | Join thousands of content creators who are already using our 354 | platform to increase their reach and engagement. 355 |

356 | 365 |
366 |
367 | 368 | {/* Not Available Modal */} 369 | setIsModalOpen(false)}> 370 | 371 | 372 | Payment Not Available 373 | 374 | 375 |

376 | Payment options are not available at the moment. Send us a message 377 | if you need to pay for a subscription. 378 |

379 |
380 | 381 | 384 | 385 |
386 |
387 |
388 | ); 389 | } 390 | --------------------------------------------------------------------------------