diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..3273657 --- /dev/null +++ b/.env.sample @@ -0,0 +1,16 @@ +# AI Keys +# LLMs +OPENAI_API_KEY="key here" +GOOGLE_AI_API_KEY="key here" +ANTHROPIC_API_KEY="key here" +# TTS +ELEVENLABS_API_KEY="key here" +NEETS_API_KEY="key here" +# Image +PEXELS_API_KEY="key here" + +# Server Config +SERVER_RES_PATH="res" +SERVER_TEMP_PATH="video_temp" +SERVER_IP="localhost" +SERVER_PORT=3001 diff --git a/.gitignore b/.gitignore index c108f85..55ad8f5 100644 --- a/.gitignore +++ b/.gitignore @@ -132,7 +132,6 @@ dist # Old code old/ python/ -ui/ # Video/audio output *.mp4 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..30912ae --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# v0.2.0 +- TODO: Add description +- Inital release of local web UI (Frontend uses Next.js, backend uses Express.js) +- Server API support (--server) (gets API keys from environment variables) +- Reworked AI script generation by giving each video type its own prompt to build its data object +- Support for getting API keys from environment variables +- OpenAI API support +- Google Gemini AI API support +- Anthropic (Claude) API support +- Added video orientation support (vertical, horizontal) +- Forked `ffcreator` and `inkpaint` as internal dependencies in `packages` directory + +# v0.1.1 +- Fixed bug that prevented the program from running + +# v0.1.0 +- Initial release diff --git a/README.md b/README.md index 501bafd..e871063 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ AutoShorts is a fully fledged package that generates shorts videos with the help If you want to support the development of this package, consider buying me a coffee: -[![ko-fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/shafilalam) +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I6SYOFB) -Your support will help me to continue the development of this package. Thank you! +![AutoShorts UI](example/images/ui.png) > [!WARNING] > The package author is not responsible for any misuse of the package, any content generated by the package, and any loss arising from the use of the package. Use at your own risk. Package is subject to change and may have breaking changes in the future. Not meant for production usage. @@ -17,8 +17,6 @@ The package is built with a flexible abstraction layer which allows you to quick This repo includes a CLI interface and JS interface. The CLI interface is built on top of the JS interface. The JS interface is the core of the package and can be used to generate videos programmatically. -**GUI interface is coming soon.** - > [!NOTE] > This package is in the early stages of development and may have bugs - especially when interacting with AI to generate scripts. This package is not meant to be used in production environments yet. Since AI output is unpredictable, work is being done to make the output more predictable and controllable. If the AI generates an incorrect JSON output, then you can manually edit the JSON output to fix the issue. This package is subject to change and may have breaking changes in the future. Use at your own risk. @@ -45,26 +43,67 @@ You can request new video types and tools to be added to the package by creating # Installation -Note: By default, the package use Ollama to generate scripts. Therefore a working Ollama installation is required. It is recommended to use the `llama3.1` model for best results. You can install this model by running the following command: `ollama pull llama3.1`. Using other local models may result in incorrect output. You can use other AI APIs such as OpenAI ChatGPT 4o, Google Gemini AI, and Anthropic Claude by providing the necessary API keys. +Note: By default, the package use Ollama to generate scripts. Therefore a working Ollama installation is required. It is recommended to use the `llama3.2` model for best results. + +You can install this model by running the following command: `ollama pull llama3.2`. + +Using other local models may result in incorrect output. + +You can use other AI APIs such as OpenAI ChatGPT 4o, Google Gemini AI, and Anthropic Claude by providing the necessary API keys and setting the `aiType` parameter to the appropriate value. + +# Example (Web UI) + +AutoShorts comes with a web UI that allows you to generate videos with a simple interface. The UI is built with Next.js and Express.js. The web UI relies on the backend server. + +> [!NOTE] +> The web UI is in the early stages of development and may have bugs. The UI is not meant to be used in production environments yet. If you encounter any issues, please create an issue on the GitHub repo. Feel free to contribute to the UI by creating a pull request. + +This example will clone the repository and start the backend server and frontend server. + +## Clone the repository -## For JS Interface ```bash -# Install the package -npm install auto-shorts +git clone +cd auto-shorts +npm install +``` -# Download the necessary resources (to './res' folder by default) -npx auto-shorts --download +## Setup backend server + +First, create a `.env` file with the following content: + +```bash +# Server Config +SERVER_RES_PATH="[path to res folder]" # Download from "npx auto-shorts --download [path]" +SERVER_TEMP_PATH="[path to temp folder]" # Can be any path like "video_temp" +SERVER_IP="localhost" +SERVER_PORT="[port number]" # Can be any port number like 3001 ``` -## For CLI Interface (global installation) +Then, run the following commands to start the backend server: + ```bash -# Install the package globally -npm install -g auto-shorts +npm run start-server +``` -# Download the necessary resources (to './res' folder by default) -npx auto-shorts --download +## Setup frontend server + +First, create a `.env` file in the `ui` folder with the following content: + +```bash +# Server Config +NEXT_PUBLIC_BACKEND_URL="http://localhost:[port number]" # Use the same port number as the backend server (ex: http://localhost:3001) +``` + +Then, run the following commands to start the frontend server: + +```bash +npm run install-ui-deps +npm run start-ui-dev ``` +The web UI should now be accessible at `http://localhost:3000`. + # Example (CLI Interface) Note: Since LLMs can hallucinate and are not deterministic, the videos may not generate the expected output. You can manually edit the JSON output to fix the issue. @@ -82,7 +121,7 @@ npx auto-shorts --download # Use OpenAI gpt-4o-mini to generate the script, ElevenLabs to generate the voice, and Pexels to generate the image npx auto-shorts -p "make a news short about TypeScript" --aiType OpenAIGen --ttsType ElevenLabs --imageType PexelsImageGen --elevenLabsAPIKey YOUR_ELEVENLABS_API_KEY --pexelsAPIKey YOUR_PEXELS_API_KEY --openaiAPIKey YOUR_OPENAI_API_KEY -# Use local Ollama llama3.1 to generate the script, Built-in TTS to generate the voice, and Google Scraper to generate the image (default, no need to provide API keys) +# Use local Ollama llama3.2 to generate the script, Built-in TTS to generate the voice, and Google Scraper to generate the image (default, no need to provide API keys) npx auto-shorts -p "make a news short about TypeScript" ``` @@ -98,7 +137,18 @@ npx auto-shorts --help # Example (JS Interface) -Note: You will need to download the necessary resources before running the code. You can do this by running the following command: +First, make sure to install the package and download the necessary resources. + +```bash +# Install the package +npm install auto-shorts + +# Download the necessary resources (to './res' folder by default) +npx auto-shorts --download +``` + +You will need to download the necessary resources before running the code. You can do this by running the following command: + ```bash npx auto-shorts --download [path] ``` @@ -178,7 +228,7 @@ task.on('done', (output) => { - OpenAI (and compatible endpoints like Ollama, Groq, etc.) (e.g., GPT-4o) - Google Gemini AI (e.g., Gemini 1.5 Pro/Flash) - Anthropic (e.g, Claude) -- Ollama local LLMs (e.g., llama3.1) +- Ollama local LLMs (e.g., llama3.2) # API Keys @@ -208,23 +258,29 @@ If this package is missing any video types or AI tools that you would like to se The package is structured as follows: - `src`: Contains the source code for the package +- `ui`: Contains the GUI code for the package - `example`: Contains example code to use the package - `test`: Contains test code for the package - `packages`: Contains the internal dependencies for the package (forked versions of `ffcreator` and `inkpaint`) # Todo -- [ ] Add GUI +- [*] Add GUI - [ ] Fix logging -- [ ] Make AI output structured for local LLMs (gpt4, gemini, claude are fine) -- [ ] Add more video types (Twitter/X posts, Reddit posts, etc.) - [ ] Add Docker support -- [ ] Add more AI tools (e.g., OpenAI, Neets.ai, AI Image Generators, etc.) +- [ ] Get GUI production-ready - [ ] Add more customization options (custom fonts, colors, images, etc.) +- [ ] Allow custom images and background music via GUI +- [ ] Work on a more general 'AI-powered' video editor instead of automatic video generation +- [ ] Add support for more general video generation (e.g., long-form videos) +- [ ] Option to convert long video to short video +- [ ] Add more video types (Twitter/X posts, Reddit posts, etc.) +- [ ] Add more AI tools (e.g., OpenAI, Neets.ai, AI Image Generators, etc.) +- [*] Make AI output structured for LLMs - [ ] Add more error handling - [ ] Add more tests - [ ] Add more documentation -- [ ] Add support for more general video generation (e.g., long-form videos) - [ ] Fix external dependencies vulnerabilities (only on dev dependencies) +- [ ] Expose more options for video customization (ElevenLabs voice customization, LLM temperature, etc.) ## Star History diff --git a/example/images/ui.png b/example/images/ui.png new file mode 100644 index 0000000..25a9ea9 Binary files /dev/null and b/example/images/ui.png differ diff --git a/package-lock.json b/package-lock.json index c7e3609..7a5aad3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,9 @@ "cli-progress": "^3.12.0", "command-line-args": "^6.0.0", "command-line-usage": "^7.0.3", + "console-error": "^0.0.4", "console-info": "^0.0.5", + "cors": "^2.8.5", "dotenv": "^16.4.5", "elevenlabs": "^0.16.0", "express": "^4.21.0", @@ -49,6 +51,7 @@ "@types/cli-progress": "^3.11.6", "@types/command-line-args": "^5.2.3", "@types/command-line-usage": "^5.0.4", + "@types/cors": "^2.8.17", "@types/eslint__js": "^8.42.3", "@types/express": "^4.17.21", "@types/fluent-ffmpeg": "^2.1.24", @@ -4203,6 +4206,15 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz", @@ -6954,6 +6966,11 @@ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" }, + "node_modules/console-error": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/console-error/-/console-error-0.0.4.tgz", + "integrity": "sha512-tyQF/oporapv/KZpMBxmd8URCZT40eRVoKhYdtKj3Kozca0TDcwkZLGK8dLFcJbAOveKwGUxPSKRxvLgWJKTSA==" + }, "node_modules/console-info": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/console-info/-/console-info-0.0.5.tgz", @@ -7035,6 +7052,18 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", diff --git a/package.json b/package.json index cf385c3..e32e75d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ ], "scripts": { "start": "tsc && node dist/cli.js", + "start-server": "tsc && node dist/cli.js --server", + "start-ui-dev": "cd ui && npm run dev", + "install-ui-deps": "cd ui && npm install", "build": "tsc", "build-all": "npm -w packages/inkpaint run build", "lint": "eslint .", @@ -40,7 +43,9 @@ "cli-progress": "^3.12.0", "command-line-args": "^6.0.0", "command-line-usage": "^7.0.3", + "console-error": "^0.0.4", "console-info": "^0.0.5", + "cors": "^2.8.5", "dotenv": "^16.4.5", "elevenlabs": "^0.16.0", "express": "^4.21.0", @@ -65,6 +70,7 @@ "@types/cli-progress": "^3.11.6", "@types/command-line-args": "^5.2.3", "@types/command-line-usage": "^5.0.4", + "@types/cors": "^2.8.17", "@types/eslint__js": "^8.42.3", "@types/express": "^4.17.21", "@types/fluent-ffmpeg": "^2.1.24", diff --git a/ui/.env.sample b/ui/.env.sample new file mode 100644 index 0000000..f7bb65c --- /dev/null +++ b/ui/.env.sample @@ -0,0 +1,2 @@ +# The URL of the backend server +NEXT_PUBLIC_BACKEND_URL=http://localhost:3001 diff --git a/ui/.eslintignore b/ui/.eslintignore new file mode 100644 index 0000000..af6ab76 --- /dev/null +++ b/ui/.eslintignore @@ -0,0 +1,20 @@ +.now/* +*.css +.changeset +dist +esm/* +public/* +tests/* +scripts/* +*.config.js +.DS_Store +node_modules +coverage +.next +build +!.commitlintrc.cjs +!.lintstagedrc.cjs +!jest.config.js +!plopfile.js +!react-shim.js +!tsup.config.ts \ No newline at end of file diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 0000000..d3067d4 --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc.json", + "env": { + "browser": false, + "es2021": true, + "node": true + }, + "extends": [ + "plugin:react/recommended", + "plugin:prettier/recommended", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended" + ], + "plugins": ["react", "unused-imports", "import", "@typescript-eslint", "jsx-a11y", "prettier"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 12, + "sourceType": "module" + }, + "settings": { + "react": { + "version": "detect" + } + }, + "rules": { + "no-console": "warn", + "react/prop-types": "off", + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off", + "react-hooks/exhaustive-deps": "off", + "jsx-a11y/click-events-have-key-events": "warn", + "jsx-a11y/interactive-supports-focus": "warn", + "prettier/prettier": "warn", + "no-unused-vars": "off", + "unused-imports/no-unused-vars": "off", + "unused-imports/no-unused-imports": "warn", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "args": "after-used", + "ignoreRestSiblings": false, + "argsIgnorePattern": "^_.*?$" + } + ], + "import/order": [ + "warn", + { + "groups": [ + "type", + "builtin", + "object", + "external", + "internal", + "parent", + "sibling", + "index" + ], + "pathGroups": [ + { + "pattern": "~/**", + "group": "external", + "position": "after" + } + ], + "newlines-between": "always" + } + ], + "react/self-closing-comp": "warn", + "react/jsx-sort-props": [ + "warn", + { + "callbacksLast": true, + "shorthandFirst": true, + "noSortAlphabetically": false, + "reservedFirst": true + } + ], + "padding-line-between-statements": [ + "warn", + {"blankLine": "always", "prev": "*", "next": "return"}, + {"blankLine": "always", "prev": ["const", "let", "var"], "next": "*"}, + { + "blankLine": "any", + "prev": ["const", "let", "var"], + "next": ["const", "let", "var"] + } + ] + } +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..8f322f0 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/ui/.vscode/settings.json b/ui/.vscode/settings.json new file mode 100644 index 0000000..3662b37 --- /dev/null +++ b/ui/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/ui/LICENSE b/ui/LICENSE new file mode 100644 index 0000000..c573ab3 --- /dev/null +++ b/ui/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Shafil Alam +Copyright (c) 2023 Next UI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..08df0a9 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,53 @@ +# Next.js & NextUI Template + +This is a template for creating applications using Next.js 14 (app directory) and NextUI (v2). + +[Try it on CodeSandbox](https://githubbox.com/nextui-org/next-app-template) + +## Technologies Used + +- [Next.js 14](https://nextjs.org/docs/getting-started) +- [NextUI v2](https://nextui.org/) +- [Tailwind CSS](https://tailwindcss.com/) +- [Tailwind Variants](https://tailwind-variants.org) +- [TypeScript](https://www.typescriptlang.org/) +- [Framer Motion](https://www.framer.com/motion/) +- [next-themes](https://github.com/pacocoursey/next-themes) + +## How to Use + +### Use the template with create-next-app + +To create a new project based on this template using `create-next-app`, run the following command: + +```bash +npx create-next-app -e https://github.com/nextui-org/next-app-template +``` + +### Install dependencies + +You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`: + +```bash +npm install +``` + +### Run the development server + +```bash +npm run dev +``` + +### Setup pnpm (optional) + +If you are using `pnpm`, you need to add the following code to your `.npmrc` file: + +```bash +public-hoist-pattern[]=*@nextui-org/* +``` + +After modifying the `.npmrc` file, you need to run `pnpm install` again to ensure that the dependencies are installed correctly. + +## License + +Licensed under the [MIT license](https://github.com/nextui-org/next-app-template/blob/main/LICENSE). diff --git a/ui/app/about/layout.tsx b/ui/app/about/layout.tsx new file mode 100644 index 0000000..98956a5 --- /dev/null +++ b/ui/app/about/layout.tsx @@ -0,0 +1,13 @@ +export default function AboutLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/ui/app/about/page.tsx b/ui/app/about/page.tsx new file mode 100644 index 0000000..8287673 --- /dev/null +++ b/ui/app/about/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { title } from "@/components/primitives"; +import { BACKEND_ENDPOINT } from "@/config/backend"; +import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@nextui-org/table"; + +export default function AboutPage() { + return ( +
+

About

+ + + NAME + VALUE + + + + Backend URL Endpoint + {BACKEND_ENDPOINT} + + +
+
+ ); +} diff --git a/ui/app/docs/layout.tsx b/ui/app/docs/layout.tsx new file mode 100644 index 0000000..eaf63f3 --- /dev/null +++ b/ui/app/docs/layout.tsx @@ -0,0 +1,13 @@ +export default function DocsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/ui/app/docs/page.tsx b/ui/app/docs/page.tsx new file mode 100644 index 0000000..4a2f19b --- /dev/null +++ b/ui/app/docs/page.tsx @@ -0,0 +1,9 @@ +import { title } from "@/components/primitives"; + +export default function DocsPage() { + return ( +
+

Docs

+
+ ); +} diff --git a/ui/app/error.tsx b/ui/app/error.tsx new file mode 100644 index 0000000..9ed5104 --- /dev/null +++ b/ui/app/error.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; + +export default function Error({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + useEffect(() => { + // Log the error to an error reporting service + /* eslint-disable no-console */ + console.error(error); + }, [error]); + + return ( +
+

Something went wrong!

+ +
+ ); +} diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx new file mode 100644 index 0000000..08bd38f --- /dev/null +++ b/ui/app/layout.tsx @@ -0,0 +1,65 @@ +import "@/styles/globals.css"; +import { Metadata, Viewport } from "next"; +import clsx from "clsx"; + +import { Providers } from "./providers"; + +import { siteConfig } from "@/config/site"; +import { fontSans } from "@/config/fonts"; +import { Navbar } from "@/components/navbar"; + +export const metadata: Metadata = { + title: { + default: siteConfig.name, + template: `%s - ${siteConfig.name}`, + }, + description: siteConfig.description, + icons: { + icon: "/favicon.ico", + }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "white" }, + { media: "(prefers-color-scheme: dark)", color: "black" }, + ], +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + +
+ +
+ {children} +
+ {/* */} +
+
+ + + ); +} diff --git a/ui/app/page.tsx b/ui/app/page.tsx new file mode 100644 index 0000000..0c279bd --- /dev/null +++ b/ui/app/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Card, CardBody } from "@nextui-org/card"; +import { Tabs, Tab } from "@nextui-org/tabs"; +import { Tooltip } from "@nextui-org/tooltip"; + +import { WavyBackground } from "@/components/wavy-background"; + +import AIGen from "@/components/ai"; +import { VideoGenerator } from "@/components/video"; + +import { FaCode, FaMagic } from "react-icons/fa"; + +export default function Home() { + return ( + + + +
+ + +
+ + Generate with AI +
+ + }> + +
+ +
+ + Generate Manually +
+ + }> + +
+
+
+
+
+
+ ); +} diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx new file mode 100644 index 0000000..9a1ac92 --- /dev/null +++ b/ui/app/providers.tsx @@ -0,0 +1,22 @@ +"use client"; + +import * as React from "react"; +import { NextUIProvider } from "@nextui-org/system"; +import { useRouter } from "next/navigation"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { ThemeProviderProps } from "next-themes/dist/types"; + +export interface ProvidersProps { + children: React.ReactNode; + themeProps?: ThemeProviderProps; +} + +export function Providers({ children, themeProps }: ProvidersProps) { + const router = useRouter(); + + return ( + + {children} + + ); +} diff --git a/ui/components/ai.tsx b/ui/components/ai.tsx new file mode 100644 index 0000000..b88d497 --- /dev/null +++ b/ui/components/ai.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; + +import { Accordion, AccordionItem } from "@nextui-org/accordion"; +import { useDisclosure } from "@nextui-org/modal"; +import { Input } from '@nextui-org/input'; +import { Button } from '@nextui-org/button'; +import { Divider } from '@nextui-org/divider'; +import { Progress } from '@nextui-org/progress'; +import { Code } from "@nextui-org/code"; +import { Chip } from "@nextui-org/chip"; + +import AdvancedOptions from '@/components/options'; +import { subtitle, title } from "@/components/primitives"; +import { ConfirmModal } from "@/components/modal"; +import { defaultVideoOptions, VideoOptions } from "@/config/options"; + +import { FaArrowLeft, FaCogs, FaMagic } from "react-icons/fa"; +import { VideoGenerator } from "./video"; +import { BACKEND_ENDPOINT } from "@/config/backend"; + +export default function AIGen() { + const confirmModal = useDisclosure(); + + const [prompt, setPrompt] = useState(''); + const [advancedOptions, setAdvancedOptions] = useState(defaultVideoOptions); + const [usedDefaultOptions, setUsedDefaultOptions] = useState(false); + + const [isAIRunning, setIsAIRunning] = useState(false); + const [aiRepsonse, setAIResponse] = useState(null); + const [aiError, setAIError] = useState(null); + + // TODO: Use server-side rendering and fetch AI response from the server + + async function fetchAI() { + console.log('Fetching AI response...'); + setAIError(null); + + try { + let json = advancedOptions; + // Add prompt + json.aiPrompt = prompt; + + const postData = JSON.stringify(json); + + // POST request to fetch AI JSON + let res = await fetch(`${BACKEND_ENDPOINT}/generateAIJSON`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: postData + }); + + let data = await res.json(); + + if (!res.ok) { + setAIError('Failed to fetch AI models: ' + (data.error ?? data.toString())); + return; + } + + setAIResponse(data.result); + + } catch (e: any) { + setAIError('Failed to fetch AI models due to internal error: ' + (e.message ?? e.toString())); + } + } + + function openModal() { + // Check if advanced options are selected, if not, set it to default values + if (advancedOptions === defaultVideoOptions) setUsedDefaultOptions(true); + confirmModal.onOpen(); + } + + function renderVideo() { + setIsAIRunning(true); + fetchAI(); + } + + return ( + isAIRunning ? : +
+ setPrompt(e.target.value)} + /> + + + } title="Advanced Options" subtitle='Change options such as AI model, TTS voice, background music, etc.'> + + + + + +
+ ); +} + +export const AIOutput = ({ aiRepsonse, aiError, options }: { aiRepsonse: string | null, aiError: string | null, options: VideoOptions }) => { + return ( + aiRepsonse + ? + <> +
+ {/*

The AI has successfully generated the video script. You can now render the video.

*/} + + {/* */} +
+ + : + <> + {aiError ?
+

Error generating video with AI

+

An error occurred while generating the video script with AI. Please check the error message below.

+ {aiError} + +
+ :
+

AI is generating the video script

+

Please wait while the AI generates the video script. This may take a few minutes.

+ + Loading... + {/* */} +
+ } + + ); +} diff --git a/ui/components/counter.tsx b/ui/components/counter.tsx new file mode 100644 index 0000000..d16f862 --- /dev/null +++ b/ui/components/counter.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@nextui-org/button"; + +export const Counter = () => { + const [count, setCount] = useState(0); + + return ( + + ); +}; diff --git a/ui/components/icons.tsx b/ui/components/icons.tsx new file mode 100644 index 0000000..a3d7332 --- /dev/null +++ b/ui/components/icons.tsx @@ -0,0 +1,215 @@ +import * as React from "react"; + +import { IconSvgProps } from "@/types"; + +export const Logo: React.FC = ({ + size = 36, + width, + height, + ...props +}) => ( + + + +); + +export const DiscordIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const TwitterIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const GithubIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const MoonFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const SunFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const HeartFilledIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const SearchIcon = (props: IconSvgProps) => ( + +); + +export const NextUILogo: React.FC = (props) => { + const { width, height = 40 } = props; + + return ( + + + + + + ); +}; diff --git a/ui/components/modal.tsx b/ui/components/modal.tsx new file mode 100644 index 0000000..82646e7 --- /dev/null +++ b/ui/components/modal.tsx @@ -0,0 +1,96 @@ +import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, getKeyValue } from "@nextui-org/table"; +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, useDisclosure } from "@nextui-org/modal"; +import { Accordion, AccordionItem } from "@nextui-org/accordion"; +import { Button } from "@nextui-org/button"; +import { Chip } from "@nextui-org/chip"; + +import { VideoOptions } from "@/config/options"; + +export function ConfirmModal({ confirmModal, usedDefaultOptions, advancedOptions, renderVideo }: + { confirmModal: ReturnType, usedDefaultOptions: boolean, advancedOptions: VideoOptions | null, renderVideo: () => void } +) { + return ( + + + {(onClose) => ( + <> + Do you want to render the video? + +

The video will be rendered with these settings:

+ {usedDefaultOptions ? No advanced options selected! Using default values! : null} + +
+ + + + + + )} +
+
+ ); +} + +export const TableAdvancedOptions = ({ advancedOptions }: { advancedOptions: VideoOptions | null }) => { + if (!advancedOptions) return null; + + const mainRows = Object.entries(advancedOptions) + .filter(([_, value]) => typeof value !== "object") + .map(([key, value]) => ({ key, name: key, value })); + + const internalRows = advancedOptions?.internalOptions + ? Object.entries(advancedOptions.internalOptions).map(([key, value]) => { + return { key, name: key, value: value.toString() }; + }) : []; + + const columns = [ + { + key: "name", + label: "NAME", + }, + { + key: "value", + label: "VALUE", + }, + ]; + + return ( +
+ + + {(column) => {column.label}} + + + {(item) => ( + + {(columnKey) => {getKeyValue(item, columnKey)}} + + )} + +
+ + + + + {(column) => {column.label}} + + + {(item) => ( + + {(columnKey) => {getKeyValue(item, columnKey)}} + + )} + +
+
+
+
+ ); +} diff --git a/ui/components/navbar.tsx b/ui/components/navbar.tsx new file mode 100644 index 0000000..52ce190 --- /dev/null +++ b/ui/components/navbar.tsx @@ -0,0 +1,139 @@ +import { + Navbar as NextUINavbar, + NavbarContent, + NavbarMenu, + NavbarMenuToggle, + NavbarBrand, + NavbarItem, + NavbarMenuItem, +} from "@nextui-org/navbar"; +import { Button } from "@nextui-org/button"; +import { Kbd } from "@nextui-org/kbd"; +import { Link } from "@nextui-org/link"; +import { Input } from "@nextui-org/input"; +import { link as linkStyles } from "@nextui-org/theme"; +import NextLink from "next/link"; +import clsx from "clsx"; + +import { siteConfig } from "@/config/site"; +import { ThemeSwitch } from "@/components/theme-switch"; +import { + GithubIcon, + HeartFilledIcon, + SearchIcon, + Logo, +} from "@/components/icons"; + +export const Navbar = () => { + const searchInput = ( + + K + + } + labelPlacement="outside" + placeholder="Search..." + startContent={ + + } + type="search" + /> + ); + + return ( + + + + + +

auto-shorts

+
+
+
    + {siteConfig.navItems.map((item) => ( + + + {item.label} + + + ))} +
+
+ + + + {/* + + + + + */} + + + + + + {searchInput} + {/* + + */} + + + + + + + + + + + + {searchInput} +
+ {siteConfig.navMenuItems.map((item, index) => ( + + + {item.label} + + + ))} +
+
+
+ ); +}; diff --git a/ui/components/options.tsx b/ui/components/options.tsx new file mode 100644 index 0000000..988a126 --- /dev/null +++ b/ui/components/options.tsx @@ -0,0 +1,530 @@ +import React, { useEffect, useState } from 'react'; + +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from '@nextui-org/modal'; +import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@nextui-org/dropdown'; +import { Checkbox } from '@nextui-org/checkbox'; +import { Input } from '@nextui-org/input'; +import { Button, ButtonGroup } from '@nextui-org/button'; +import { Divider } from '@nextui-org/divider'; +import { Chip } from '@nextui-org/chip'; +import { Tooltip } from '@nextui-org/tooltip'; + +import { BACKEND_ENDPOINT } from '@/config/backend'; +import { title, subtitle } from '@/components/primitives'; +import { VideoOptions } from '@/config/options'; + +import { FaAngleDown, FaExclamationTriangle, FaGlobe, FaPhotoVideo, FaRandom, FaRegFileVideo, FaRobot, FaSave, FaSlidersH, FaSync, FaTextHeight, FaVideo, FaVolumeUp } from 'react-icons/fa'; + +const config = { + aiOptions: { + types: [ + { + "name": "Ollama (Local LLMs)", + "description": "Local LLMs (llama3.2, etc.) via Ollama (local/free)", + "type": "OllamaAIGen", + }, + { + "name": "OpenAI", + "description": "OpenAI models GPT-4, o1, etc. (API key required)", + "type": "OpenAIGen", + }, + { + "name": "Anthropic (Claude)", + "description": "Anthropic's Claude AI models (API key required)", + "type": "AnthropicAIGen", + }, + { + "name": "Google Gemini", + "description": "Google Gemini AI models (API key required)", + "type": "GoogleAIGen", + } + ] + }, + ttsOptions: [ + { + "name": "Local Built-in TTS", + "description": "Local built-in TTS (local/free)", + "type": "BuiltinTTS", + }, + { + "name": "ElevenLabs", + "description": "ElevenLabs advanced high-quality TTS (API key required)", + "type": "ElevenLabsTTS", + }, + { + "name": "Neets.ai", + "description": "Neets.ai TTS (cheaper but less quality) (API key required)", + "type": "NeetsTTS", + } + ], + imageTypes: [ + { + "name": "Google Search", + "description": "Google image search (scraping) (local/free)", + "type": "GoogleScraperImageGen", + }, + { + "name": "Pexels", + "description": "Pexels image search (API key required)", + "type": "PexelsImageGen", + }, + ], + subtitleOptions: [ + { + "name": "Whisper (en-tiny)", + "description": "Whisper AI speech-to-text model (en-tiny) (local/free)", + "type": "whisper-en-tiny", + } + ], + videoOptions: { + orientations: ['Vertical', 'Horizontal'] + }, + miscOptions: [ + { + name: 'changePhotos', + fullName: 'Change Photos', + defaultValue: true, + description: 'Change photos in video. Used to prevent overriding wanted photos. (default: true)' + }, + { + name: 'disableTTS', + fullName: 'Disable TTS', + defaultValue: false, + description: 'Disable TTS in video. Used to prevent overriding wanted TTS. (default: false)' + }, + { + name: 'disableSubtitles', + fullName: 'Disable Subtitles', + defaultValue: false, + description: 'Disable subtitles in video. (default: false)' + }, + { + name: 'useMock', + fullName: 'Use internal testing mock data', + defaultValue: false, + description: 'Use mock JSON data. (default: false)' + }, + ] +}; + +export default function AdvancedOptions({ setAdvancedOptions }: { setAdvancedOptions: React.Dispatch> }) { + // Modals + const modelModal = useDisclosure(); + const bgVideoModal = useDisclosure(); + const bgAudioModal = useDisclosure(); + + // TODO: Use server-side to fetch data instead of client-side; improve error handling + + // AI Model fetch + const [aiModels, setAiModels] = useState([]); + const [isAiModelError, setIsAiModelError] = useState(''); + const [selectedAIModel, setSelectedAIModel] = useState(''); + + async function fetchModels(aiType: string = selectedAIType.type) { + console.log('Fetching AI models... Type: ' + aiType); + setIsAiModelError(''); + + try { + let res = await fetch(`${BACKEND_ENDPOINT}/types/ai/models?type=${aiType}`) + + let data = await res.json() + + // Check if response is ok + if (!res.ok) { + setIsAiModelError('Failed to fetch AI models: ' + (data.error ?? data.toString())) + return; + } + + setAiModels(data.models) + setSelectedAIModel(data.models[0]) + } catch (e: any) { + setIsAiModelError('Failed to fetch AI models due to internal error: ' + (e.message ?? e.toString())); + } + } + + // Background video fetch + const [bgVideos, setBgVideos] = useState([]); + const [isBgVideosError, setIsBgVideosError] = useState(''); + const [selectedBgVideo, setSelectedBgVideo] = useState(''); + + async function fetchBgVideos() { + console.log('Fetching background videos...'); + setIsBgVideosError(''); + + try { + let res = await fetch(`${BACKEND_ENDPOINT}/types/backgrounds`) + + let data = await res.json() + + // Check if response is ok + if (!res.ok) { + setIsBgVideosError('Failed to fetch background videos: ' + (data.error ?? data.toString())) + return; + } + + setBgVideos(data.videos) + setSelectedBgVideo(data.videos[0]) + } catch (e: any) { + setIsBgVideosError('Failed to fetch background videos due to internal error: ' + (e.message ?? e.toString())); + } + } + + // Background audio fetch + const [bgAudio, setBgAudio] = useState([]); + const [isBgAudioError, setIsBgAudioError] = useState(''); + const [selectedBgAudio, setSelectedBgAudio] = useState(''); + + async function fetchBgAudio() { + console.log('Fetching background audio...'); + setIsBgAudioError(''); + + try { + let res = await fetch(`${BACKEND_ENDPOINT}/types/bgaudio`) + + let data = await res.json() + + // Check if response is ok + if (!res.ok) { + setIsBgAudioError('Failed to fetch background audio: ' + (data.error ?? data.toString())) + return; + } + + setBgAudio(data.audios) + setSelectedBgAudio(data.audios[0]) + } catch (e: any) { + setIsBgAudioError('Failed to fetch background audio due to internal error: ' + (e.message ?? e.toString())); + } + } + + // State + const [selectedAIType, setSelectedAIType] = useState(config.aiOptions.types[0]); + const [selectedTTSProvider, setSelectedTTSProvider] = useState(config.ttsOptions[0]); + const [selectedImageType, setSelectedImageType] = useState(config.imageTypes[0]); + const [selectedSubtitleModel, setSelectedSubtitleModel] = useState(config.subtitleOptions[0]); + const [selectedOrientation, setSelectedOrientation] = useState(config.videoOptions.orientations[0]); + const [miscOptions, setMiscOptions] = useState(config.miscOptions.map(option => option.defaultValue)); + + useEffect(() => { + fetchModels(); + fetchBgVideos(); + fetchBgAudio(); + }, []) + + return ( +
+
+ +

AI Options

+
+ +
+
+
+

Choose AI Type

+

Select the type of AI to use

+
+ + + + + + { + setSelectedAIType(config.aiOptions.types.find(type => type.type === key)!) + fetchModels(key.toString()) + }}> + {config.aiOptions.types.map(type => {type.name})} + + +
+
+
+

Choose AI Model

+

Select the AI model

+
+
+ + {isAiModelError + ? <> + + + + {(onClose) => ( + <> + Erorr fetching models + +

{isAiModelError}

+
+ + + + + )} +
+
+ + : + + + + + setSelectedAIModel(key.toString())}> + {aiModels.map(model => {model})} + + + } + + + +
+
+
+ {selectedAIType.type === 'OpenAIGen' && ( +
+
+

API Endpoint

+

Custom OpenAI compliant endpoint

+
+ } isClearable placeholder="Enter API Endpoint" className="w-96" /> +
+ )} +
+
+ +

TTS Options

+
+ +
+
+
+

Choose TTS Provider

+

Select the TTS provider

+
+ + + + + + setSelectedTTSProvider(config.ttsOptions.find(provider => provider.type === key)!)}> + {config.ttsOptions.map(provider => {provider.name})} + + +
+
+
+ +

Image

+
+ +
+
+
+

Choose image provider

+

Select the image search provider

+
+ + + + + + setSelectedImageType(config.imageTypes.find(provider => provider.type === key)!)}> + {config.imageTypes.map(provider => {provider.name})} + + +
+
+
+ +

Subtitle Options

+
+ +
+
+
+

Choose Subtitle Model

+

Select the subtitle model

+
+ + + + + + setSelectedSubtitleModel(config.subtitleOptions.find(model => model.type === key)!)}> + {config.subtitleOptions.map(model => {model.name})} + + +
+
+
+ +

Video Options

+
+ +
+
+
+

Choose Video Background

+

Select the video background

+
+ + + { + isBgVideosError + ? <> + + + + {(onClose) => ( + <> + Erorr fetching videos + +

{isBgVideosError}

+
+ + + + + )} +
+
+ + : + + + } + + setSelectedBgVideo(key.toString())}> + {bgVideos.map(bg => }>{bg})} + +
+ + + + + + +
+
+
+
+

Choose Sound

+

Select the sound

+
+ + + { + isBgAudioError + ? <> + + + + {(onClose) => ( + <> + Erorr fetching audio + +

{isBgAudioError}

+
+ + + + + )} +
+
+ + : + + + } + + setSelectedBgAudio(key.toString())}> + {bgAudio.map(sound => }>{sound})} + +
+ + + + + + +
+
+
+
+

Orientation

+

Select the video orientation

+ {(selectedOrientation != 'Vertical') ? Horizontal orientation may produce issues due to WIP : null} +
+ + + + + + + setSelectedOrientation(key.toString())} > + {config.videoOptions.orientations.map(orientation => {orientation})} + + +
+
+
+ +

Misc Options

+
+ +
+ {config.miscOptions.map(option => ( +
+
+

{option.fullName}

+

{option.description}

+
+ { + const newOptions = [...miscOptions]; + newOptions[config.miscOptions.indexOf(option)] = e; + setMiscOptions(newOptions); + }}>{miscOptions[config.miscOptions.indexOf(option)] + ? 'Enabled' : 'Disabled'} +
+ ))} +
+ +
+ +
+
+ + ); +} diff --git a/ui/components/primitives.ts b/ui/components/primitives.ts new file mode 100644 index 0000000..2f0f057 --- /dev/null +++ b/ui/components/primitives.ts @@ -0,0 +1,65 @@ +import { tv } from "tailwind-variants"; + +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export const title = tv({ + base: "font-semibold", + variants: { + color: { + violet: "from-[#FF1CF7] to-[#b249f8]", + yellow: "from-[#FF705B] to-[#FFB457]", + blue: "from-[#5EA2EF] to-[#0072F5]", + cyan: "from-[#00b7fa] to-[#01cfea]", + green: "from-[#6FEE8D] to-[#17c964]", + pink: "from-[#FF72E1] to-[#F54C7A]", + foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]", + }, + size: { + sm: "text-lg", + md: "text-2xl", + lg: "text-4xl lg:text-6xl", + }, + fullWidth: { + true: "w-full block", + }, + }, + defaultVariants: { + size: "md", + }, + compoundVariants: [ + { + color: [ + "violet", + "yellow", + "blue", + "cyan", + "green", + "pink", + "foreground", + ], + class: "bg-clip-text text-transparent bg-gradient-to-b", + }, + ], +}); + +export const subtitle = tv({ + base: "block text-gray-500", + variants: { + fullWidth: { + true: "!w-full", + }, + size: { + sm: "text-sm", + md: "text-xl", + }, + }, + defaultVariants: { + fullWidth: false, + size: "md", + }, +}); diff --git a/ui/components/theme-switch.tsx b/ui/components/theme-switch.tsx new file mode 100644 index 0000000..55bcb0f --- /dev/null +++ b/ui/components/theme-switch.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { FC } from "react"; +import { VisuallyHidden } from "@react-aria/visually-hidden"; +import { SwitchProps, useSwitch } from "@nextui-org/switch"; +import { useTheme } from "next-themes"; +import { useIsSSR } from "@react-aria/ssr"; +import clsx from "clsx"; + +import { SunFilledIcon, MoonFilledIcon } from "@/components/icons"; + +export interface ThemeSwitchProps { + className?: string; + classNames?: SwitchProps["classNames"]; +} + +export const ThemeSwitch: FC = ({ + className, + classNames, +}) => { + const { theme, setTheme } = useTheme(); + const isSSR = useIsSSR(); + + const onChange = () => { + theme === "light" ? setTheme("dark") : setTheme("light"); + }; + + const { + Component, + slots, + isSelected, + getBaseProps, + getInputProps, + getWrapperProps, + } = useSwitch({ + isSelected: theme === "light" || isSSR, + "aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`, + onChange, + }); + + return ( + + + + +
+ {!isSelected || isSSR ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/ui/components/video.tsx b/ui/components/video.tsx new file mode 100644 index 0000000..ae6fc1c --- /dev/null +++ b/ui/components/video.tsx @@ -0,0 +1,768 @@ +import React, { useEffect, useState } from 'react'; + +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, useDisclosure } from '@nextui-org/modal'; +import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@nextui-org/dropdown'; +import { Accordion, AccordionItem } from '@nextui-org/accordion'; +import { Input, Textarea } from '@nextui-org/input'; +import { Button } from '@nextui-org/button'; +import { Divider } from '@nextui-org/divider'; +import { Code } from '@nextui-org/code'; +import { Progress } from '@nextui-org/progress'; +import { Chip } from '@nextui-org/chip'; + +import AdvancedOptions from '@/components/options'; +import { subtitle, title } from '@/components/primitives'; +import { ConfirmModal } from '@/components/modal'; +import { BACKEND_ENDPOINT } from '@/config/backend'; +import { defaultVideoOptions, VideoOptions } from '@/config/options'; +import { MessageVideoData, QuizVideoData, RankVideoData, RatherVideoData, TopicVideoData, VideoData } from '@/config/video'; + +import { FaAngleDown, FaArrowLeft, FaArrowRight, FaArrowsAltH, FaCogs, FaComment, FaCommentAlt, FaImage, FaList, FaMagic, FaNewspaper, FaPhone, FaPlus, FaQuestion, FaQuestionCircle, FaSave, FaSearch, FaTextHeight, FaTrash, FaVolumeUp } from 'react-icons/fa'; + +const videoTypes = [ + { + type: "message", + name: "Message", + icon: , + description: "Send a message to a person" + }, + { + type: "topic", + name: "Topic", + icon: , + description: "Create a topic video" + }, + { + type: "quiz", + name: "Quiz", + icon: , + description: "Create a quiz video" + }, + { + type: "rank", + name: "Rank", + icon: , + description: "Create a rank video" + }, + { + type: "rather", + name: "Rather", + icon: , + description: "Create a rather video" + } +]; + +export function VideoGenerator({ json = null, isAI = false, options = null }: { json?: string | null, isAI?: boolean, options?: VideoOptions | null }) { + const confirmModal = useDisclosure(); + const emptyDataModal = useDisclosure(); + + const [selectedType, setSelectedType] = useState(videoTypes[0]); + const [formData, setFormData] = useState(null); + + const [advancedOptions, setAdvancedOptions] = useState(defaultVideoOptions); + const [usedDefaultOptions, setUsedDefaultOptions] = useState(false); + + const [genError, setGenError] = useState(null); + const [renderResult, setRenderResult] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [isGenerated, setIsGenerated] = useState(false); + const [videoId, setVideoId] = useState(null); + + function handleGenerateVideo() { + // Check if advanced options are selected, if not, set it to default values + if ((options ?? advancedOptions) === defaultVideoOptions) setUsedDefaultOptions(true); + + // Check if data is entered + if (!formData) { + emptyDataModal.onOpen(); + return; + } + + if (isAI) { + renderVideo(); + } else { + confirmModal.onOpen(); + } + } + + function renderVideo() { + setIsGenerating(true); + callAPIRender(); + } + + // TODO: Use server-side rendering and fetch AI response from the server + + async function callAPIRender() { + setGenError(null); + + try { + // JSON must be compliant with the API (server type of APIVideoData) + let json = { + data: formData, + options: options ?? advancedOptions + } + + const postData = JSON.stringify(json); + + // POST request to fetch AI JSON + let res = await fetch(`${BACKEND_ENDPOINT}/generateVideo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: postData + }); + + if (!res.ok) { + setGenError('Failed to generate video: ' + (res.statusText ?? res.toString())); + return; + } + + if (res.body == null) { + setGenError('Failed to generate video: Response body is empty!'); + return; + } + + // Read the stream + const reader = res.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const stringDecodedData = new TextDecoder("utf-8").decode(value); + + const dataObjects = stringDecodedData.split("data: ").slice(1); + + for (const dataString of dataObjects) { + if (dataString.trim() === "[DONE]") break; + + const data = JSON.parse(dataString); + + // Check if error + if (data.error) { + setGenError('Failed to generate video: ' + data.error); + return; + } + + // Update render result (log is the output from the server) + if (data.log) { + setRenderResult(renderResult ? renderResult + '\n' + data.log : data.log); + } + + // Check if JSON has 'videoPath' field + if (data.videoId) { + setIsGenerated(true); + setGenError(null); + setVideoId(data.videoId); + return; + } + } + } + } catch (e: any) { + setGenError('Failed to generate video due to internal error: ' + (e.message ?? e.toString())); + } + } + + const renderForm = () => { + let type = selectedType.type; + if (json) { + type = JSON.parse(json).type; + } + + switch (type) { + case "message": + return ; + case "quiz": + return ; + case "rank": + return ; + case "rather": + return ; + case "topic": + return ; + default: + return null; + } + }; + + return ( + isGenerating ? : +
+ {isAI + ?
+

Review AI generated video script

+

AI has generated the video script. You can edit it below if needed. Click on "Render Video" to generate the video.

+
+ :
+ + + + + setSelectedType(videoTypes.find(type => type.type === key.toString())!)} > + {videoTypes.map(type => {type.name})} + + +

{selectedType.description}

+
+ } + + {renderForm()} + {options ? null : + <> + + + } title="Advanced Options" subtitle='Change options such as AI model, TTS voice, background music, etc.'> + + + + + } + +
+ +
+ + + + {(onClose) => ( + <> + Error: No data entered! + +

You need to enter data for the video to be generated.

+
+ + + + + )} +
+
+
+ ); +} + +export const RenderingOutput = ({ renderResult, genError, isGenerated, videoId }: { renderResult: string | null, genError: string | null, isGenerated: boolean, videoId: string | null }) => { + return ( + isGenerated + ? + <> +
+

Video Rendered Successfully!

+

The video has been rendered successfully. You can download it from the link below.

+ {"video.mp4"} +
+ + : + <> + {genError ? +
+

Error Rendering Video

+

An error occurred while rendering the video. Please check the error message below.

+ {genError} + +
+ :
+

Rendering Video...

+

Please wait while the video is being rendered. This may 5-10 minutes depending on the video length and more.

+ + {renderResult ?? "Loading"} + +
+ } + + ); +} + +type MessageVideoFormProps = { + setFormData: React.Dispatch>; + json: string | null; + isAI: boolean; +}; + +const MessageVideoForm: React.FC = ({ setFormData, json, isAI }) => { + const [contactName, setContactName] = useState(''); + const [script, setScript] = useState([{ voice: 'male' as const, message: '', msgtype: 'sender' as const }]); + const [extra, setExtra] = useState(''); + + const handleAddMessage = () => { + setScript([...script, { voice: 'male', message: '', msgtype: 'sender' }]); + }; + + const handleChange = (index: number, field: string, value: string) => { + const newScript = [...script]; + (newScript[index] as any)[field] = value; + setScript(newScript); + }; + + const handleSubmit = () => { + const data: MessageVideoData = { + type: 'message', + contactname: contactName, + script, + extra, + }; + setFormData(data); + }; + + // Handle JSON if not null on initial load + useEffect(() => { + if (json) { + const { contactname, script, extra } = JSON.parse(json); + if (contactname) setContactName(contactname); + if (script) setScript(script); + if (extra) setExtra(extra); + } + }, []); + + return ( +
+
+
+ +

Contact name of person

+
+

Enter the name of the person you want to send the message to.

+
+
+ setContactName(e.target.value)} onClear={() => setContactName('')} isClearable /> +
+ +
+
+ +

{`Message list for ${contactName}`}

+
+

Enter the messages you want to send to the person.

+
+ {script.map((msg, index) => ( +
+