├── .dockerignore ├── .env.example ├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── auto-yt-shorts.png ├── config.py ├── docker-compose.yml ├── environment.yml ├── example.env ├── fonts └── bold_font.ttf ├── main.py ├── readme.md ├── requirements.txt ├── test.http ├── upload_video.py └── utils ├── audio.py ├── llm.py ├── metadata.py ├── stock_videos.py ├── tiktok.py ├── video.py └── yt.py /.dockerignore: -------------------------------------------------------------------------------- 1 | output 2 | secondary_video 3 | temp 4 | music 5 | cookies.txt 6 | client_secrets.json 7 | upload_video.py-oauth2.json -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY_AUTO_YT_SHORTS= 2 | PEXELS_API_KEY= 3 | ASSEMBLY_AI_API_KEY= -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [ "main" ] 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | env: 15 | # Use docker.io for Docker Hub if empty 16 | REGISTRY: ghcr.io 17 | # github.repository as / 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | 21 | jobs: 22 | build: 23 | 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | packages: write 28 | # This is used to complete the identity challenge 29 | # with sigstore/fulcio when running outside of PRs. 30 | id-token: write 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v3 35 | 36 | # Install the cosign tool except on PR 37 | # https://github.com/sigstore/cosign-installer 38 | - name: Install cosign 39 | if: github.event_name != 'pull_request' 40 | uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 41 | with: 42 | cosign-release: 'v2.1.1' 43 | 44 | # Set up BuildKit Docker container builder to be able to build 45 | # multi-platform images and export cache 46 | # https://github.com/docker/setup-buildx-action 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 49 | 50 | # Login against a Docker registry except on PR 51 | # https://github.com/docker/login-action 52 | - name: Log into registry ${{ env.REGISTRY }} 53 | if: github.event_name != 'pull_request' 54 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 55 | with: 56 | registry: ${{ env.REGISTRY }} 57 | username: ${{ github.actor }} 58 | password: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | # Extract metadata (tags, labels) for Docker 61 | # https://github.com/docker/metadata-action 62 | - name: Extract Docker metadata 63 | id: meta 64 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 65 | with: 66 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 67 | 68 | # Build and push Docker image with Buildx (don't push on PR) 69 | # https://github.com/docker/build-push-action 70 | - name: Build and push Docker image 71 | id: build-and-push 72 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 73 | with: 74 | context: . 75 | push: ${{ github.event_name != 'pull_request' }} 76 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 77 | labels: ${{ steps.meta.outputs.labels }} 78 | cache-from: type=gha 79 | cache-to: type=gha,mode=max 80 | 81 | # Sign the resulting Docker image digest except on PRs. 82 | # This will only write to the public Rekor transparency log when the Docker 83 | # repository is public to avoid leaking data. If you would like to publish 84 | # transparency data even for private images, pass --force to cosign below. 85 | # https://github.com/sigstore/cosign 86 | - name: Sign the published Docker image 87 | if: ${{ github.event_name != 'pull_request' }} 88 | env: 89 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 90 | TAGS: ${{ steps.meta.outputs.tags }} 91 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 92 | # This step uses the identity token to provision an ephemeral certificate 93 | # against the sigstore community Fulcio instance. 94 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Icon must end with two \r 7 | Icon 8 | 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | # Byte-compiled / optimized / DLL files 30 | __pycache__/ 31 | *.py[cod] 32 | *$py.class 33 | 34 | # C extensions 35 | *.so 36 | 37 | # Distribution / packaging 38 | .Python 39 | build/ 40 | develop-eggs/ 41 | dist/ 42 | downloads/ 43 | eggs/ 44 | .eggs/ 45 | lib/ 46 | lib64/ 47 | parts/ 48 | sdist/ 49 | var/ 50 | wheels/ 51 | share/python-wheels/ 52 | *.egg-info/ 53 | .installed.cfg 54 | *.egg 55 | MANIFEST 56 | 57 | # PyInstaller 58 | # Usually these files are written by a python script from a template 59 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 60 | *.manifest 61 | *.spec 62 | 63 | # Installer logs 64 | pip-log.txt 65 | pip-delete-this-directory.txt 66 | 67 | # Unit test / coverage reports 68 | htmlcov/ 69 | .tox/ 70 | .nox/ 71 | .coverage 72 | .coverage.* 73 | .cache 74 | nosetests.xml 75 | coverage.xml 76 | *.cover 77 | *.py,cover 78 | .hypothesis/ 79 | .pytest_cache/ 80 | cover/ 81 | 82 | # Translations 83 | *.mo 84 | *.pot 85 | 86 | # Django stuff: 87 | *.log 88 | local_settings.py 89 | db.sqlite3 90 | db.sqlite3-journal 91 | 92 | # Flask stuff: 93 | instance/ 94 | .webassets-cache 95 | 96 | # Scrapy stuff: 97 | .scrapy 98 | 99 | # Sphinx documentation 100 | docs/_build/ 101 | 102 | # PyBuilder 103 | .pybuilder/ 104 | target/ 105 | 106 | # Jupyter Notebook 107 | .ipynb_checkpoints 108 | 109 | # IPython 110 | profile_default/ 111 | ipython_config.py 112 | 113 | # pyenv 114 | # For a library or package, you might want to ignore these files since the code is 115 | # intended to run in multiple environments; otherwise, check them in: 116 | # .python-version 117 | 118 | # pipenv 119 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 120 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 121 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 122 | # install all needed dependencies. 123 | #Pipfile.lock 124 | 125 | # poetry 126 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 127 | # This is especially recommended for binary packages to ensure reproducibility, and is more 128 | # commonly ignored for libraries. 129 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 130 | #poetry.lock 131 | 132 | # pdm 133 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 134 | #pdm.lock 135 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 136 | # in version control. 137 | # https://pdm.fming.dev/#use-with-ide 138 | .pdm.toml 139 | 140 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 141 | __pypackages__/ 142 | 143 | # Celery stuff 144 | celerybeat-schedule 145 | celerybeat.pid 146 | 147 | # SageMath parsed files 148 | *.sage.py 149 | 150 | # Environments 151 | .env 152 | .venv 153 | env/ 154 | venv/ 155 | ENV/ 156 | env.bak/ 157 | venv.bak/ 158 | 159 | # Spyder project settings 160 | .spyderproject 161 | .spyproject 162 | 163 | # Rope project settings 164 | .ropeproject 165 | 166 | # mkdocs documentation 167 | /site 168 | 169 | # mypy 170 | .mypy_cache/ 171 | .dmypy.json 172 | dmypy.json 173 | 174 | # Pyre type checker 175 | .pyre/ 176 | 177 | # pytype static type analyzer 178 | .pytype/ 179 | 180 | # Cython debug symbols 181 | cython_debug/ 182 | 183 | # PyCharm 184 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 185 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 186 | # and can be added to the global gitignore or merged into this file. For a more nuclear 187 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 188 | #.idea/ 189 | 190 | *~ 191 | 192 | # temporary files which can be created if a process still has a handle open of a deleted file 193 | .fuse_hidden* 194 | 195 | # KDE directory preferences 196 | .directory 197 | 198 | # Linux trash folder which might appear on any partition or disk 199 | .Trash-* 200 | 201 | # .nfs files are created when an open file is removed but is still being accessed 202 | .nfs* 203 | 204 | .vscode/* 205 | !.vscode/settings.json 206 | !.vscode/tasks.json 207 | !.vscode/launch.json 208 | !.vscode/extensions.json 209 | !.vscode/*.code-snippets 210 | 211 | # Local History for Visual Studio Code 212 | .history/ 213 | 214 | # Built Visual Studio Code Extensions 215 | *.vsix 216 | 217 | temp 218 | output 219 | secondary_video 220 | music 221 | youtube 222 | /test.py 223 | client_secrets.json 224 | upload_video.py-oauth2.json 225 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.1-bookworm 2 | 3 | RUN apt remove imagemagick -y 4 | 5 | # Download and execute a script 6 | RUN t=$(mktemp) && \ 7 | wget 'https://dist.1-2.dev/imei.sh' -qO "$t" && \ 8 | bash "$t" && \ 9 | rm "$t" 10 | 11 | # Set the working directory 12 | WORKDIR /app 13 | 14 | # Copy the current directory contents into the container at /app 15 | COPY . /app 16 | 17 | # Set up Python virtual environment 18 | RUN python3.12 -m venv /venv 19 | RUN /venv/bin/pip install --no-cache-dir -r requirements.txt 20 | 21 | # Use CMD to start cron in the foreground 22 | CMD /venv/bin/python3 /app/main.py 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2024] [Marvin von Rappard] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /auto-yt-shorts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marvinvr/auto-yt-shorts/a548922af8d12eb7046b3728cb7cdfce05e6eb15/auto-yt-shorts.png -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import dotenv 5 | 6 | dotenv.load_dotenv() 7 | 8 | POSSIBLE_TOPICS = [ 9 | # "NASA Facts", 10 | "Controversial Events", 11 | # "Bold Predictions", 12 | "Controversial Actions by Celebrities", 13 | "Controversial Actions by Well Known Companies", 14 | "Controversial Science Facts", 15 | "Controversial Historical Mysteries", 16 | "Controversial Tech Milestones", 17 | "Controversial Cultural Oddities", 18 | "Controversial Psychological Phenomena", 19 | "Controversial Space Exploration Events", 20 | "Controvercies around Cryptocurrency and Blockchain", 21 | "Controversial Laws", 22 | "Controversial Secrets of Successful People", 23 | "Controversial Eco-Friendly movements", 24 | "Controversial World Records", 25 | ] 26 | 27 | 28 | OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY_AUTO_YT_SHORTS") 29 | 30 | if not OPENAI_API_KEY: 31 | raise ValueError("OPENAI_API_KEY_AUTO_YT_SHORTS not set") 32 | 33 | 34 | OPENAI_MODEL = "gpt-4-turbo-preview" # "gpt-3.5-turbo-0125" 35 | 36 | MIN_SEARCH_TERMS = 3 37 | MAX_SEARCH_TERMS = 5 38 | 39 | PEXELS_API_KEY = os.environ.get("PEXELS_API_KEY") 40 | 41 | ASSEMBLY_AI_API_KEY = os.environ.get("ASSEMBLY_AI_API_KEY") 42 | 43 | NEWS_API_KEY = os.environ.get("NEWS_API_KEY") 44 | 45 | TEMP_PATH = Path("temp") 46 | 47 | os.makedirs(TEMP_PATH, exist_ok=True) 48 | 49 | OUTPUT_PATH = Path("output") 50 | 51 | os.makedirs(OUTPUT_PATH, exist_ok=True) 52 | 53 | BACKGROUND_SONGS_PATH = Path("music") 54 | 55 | SECONDARY_CONTENT_PATH = Path("secondary_video") 56 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | auto-yt-shorts: 4 | container_name: auto-yt-shorts 5 | build: . 6 | volumes: 7 | - ./youtube:/app/youtube 8 | - ./secondary_video:/app/secondary_video 9 | - ./music:/app/music 10 | env_file: 11 | - .env 12 | ports: 13 | - "8000:8000" 14 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: auto-yt-shorts 2 | channels: 3 | - defaults 4 | dependencies: 5 | - python=3.12 6 | - mypy 7 | - openai 8 | - python-dotenv 9 | - requests 10 | - pathlib 11 | - moviepy 12 | - pillow==10.0.1 13 | - scipy 14 | - tqdm 15 | prefix: /Users/mvr/miniconda3/envs/auto-yt-shorts 16 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY_AUTO_YT_SHORTS= 2 | PEXELS_API_KEY= 3 | ASSEMBLY_AI_API_KEY= -------------------------------------------------------------------------------- /fonts/bold_font.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marvinvr/auto-yt-shorts/a548922af8d12eb7046b3728cb7cdfce05e6eb15/fonts/bold_font.ttf -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from concurrent.futures import ThreadPoolExecutor, as_completed 3 | from multiprocessing import cpu_count 4 | 5 | import uvicorn 6 | from fastapi import FastAPI 7 | from tqdm import tqdm 8 | 9 | from utils.audio import generate_voiceover 10 | from utils.llm import ( 11 | get_description, 12 | get_most_engaging_titles, 13 | get_script, 14 | get_search_terms, 15 | get_titles, 16 | get_topic, 17 | ) 18 | from utils.metadata import save_metadata 19 | from utils.stock_videos import get_stock_videos 20 | from utils.video import generate_subtitles, generate_video 21 | from utils.yt import auto_upload, prep_for_manual_upload 22 | 23 | app = FastAPI() 24 | 25 | logging.basicConfig(level=logging.INFO) 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | def generate_video_data(title): 30 | logger.info("[Generated Title]") 31 | logger.info(title) 32 | 33 | script = get_script(title) 34 | logger.info("[Generated Script]") 35 | logger.info(script) 36 | 37 | description = get_description(title, script) 38 | logger.info("[Generated Description]") 39 | logger.info(description) 40 | 41 | search_terms = get_search_terms(title, script) 42 | logger.info("[Generated Search Terms]") 43 | logger.info(search_terms) 44 | 45 | stock_videos = get_stock_videos(search_terms) 46 | logger.info("[Generated Stock Videos]") 47 | 48 | voiceover = generate_voiceover(script) 49 | logger.info("[Generated Voiceover]") 50 | 51 | subtitles = generate_subtitles(voiceover) 52 | logger.info("[Generated Subtitles]") 53 | 54 | return title, description, script, search_terms, stock_videos, voiceover, subtitles 55 | 56 | 57 | @app.post("/generate_videos/") 58 | def generate_videos(n: int = 4) -> None: 59 | topic = get_topic() 60 | 61 | logger.info("[Generated Topic]") 62 | logger.info(topic) 63 | 64 | possible_titles = get_titles(topic) 65 | logger.info("[Generated Possible Titles]") 66 | logger.info(possible_titles) 67 | 68 | titles = get_most_engaging_titles(possible_titles, n) 69 | 70 | # Use ThreadPoolExecutor to execute the network-bound tasks in parallel 71 | with ThreadPoolExecutor(max_workers=min(cpu_count(), len(titles))) as executor: 72 | # Submit all tasks to the executor 73 | future_to_title = { 74 | executor.submit(generate_video_data, title): title for title in titles 75 | } 76 | 77 | for future in tqdm(as_completed(future_to_title), total=len(titles)): 78 | title, description, script, search_terms, stock_videos, voiceover, subtitles = ( 79 | future.result() 80 | ) 81 | 82 | video = generate_video(stock_videos, voiceover, subtitles) 83 | logger.info("[Generated Video]") 84 | 85 | new_video_file = save_metadata( 86 | title, description, None, script, search_terms, video 87 | ) 88 | logger.info("[Saved Video]") 89 | 90 | auto_upload(new_video_file, title, description) 91 | logger.info("[Uploaded Video]") 92 | 93 | 94 | @app.get("/health/") 95 | def health(): 96 | return {"status": "ok"} 97 | 98 | 99 | if __name__ == "__main__": 100 | uvicorn.run(app, host="0.0.0.0", port=8000) 101 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | project-logo 3 |

4 |

5 |

AUTO-YT-SHORTS

6 |

7 |

8 | license 9 | last-commit 10 | repo-top-language 11 | repo-language-count 12 |

13 |

14 | Developed with the software and tools below. 15 |

16 |

17 | tqdm 18 | YAML 19 | SciPy 20 | OpenAI 21 | Python 22 | Docker 23 | GitHub%20Actions 24 | FastAPI 25 |

26 | 27 |
28 | 29 |
30 | Table of Contents
31 | 32 | - [ Overview](#-overview) 33 | - [ Demo](#-demo) 34 | - [ Features](#-features) 35 | - [ Repository Structure](#-repository-structure) 36 | - [ Modules](#-modules) 37 | - [ Getting Started](#-getting-started) 38 | - [ Installation](#-installation) 39 | - [ Usage](#-usage) 40 | - [ Tests](#-tests) 41 | - [ Contributing](#-contributing) 42 | - [ License](#-license) 43 | - [ Acknowledgments](#-acknowledgments) 44 |
45 |
46 | 47 | ## Overview 48 | 49 | Auto-yt-shorts is an open-source project designed to automate the process of generating engaging short videos for platforms like YouTube and TikTok. Leveraging AI models and APIs, it enables the effortless creation of video content by suggesting topics, generating voiceovers, adding subtitles, and incorporating stock footage. With features like automatic uploads, background song selection, and parallel execution, auto-yt-shorts offers a valuable solution for content creators seeking a seamless video production workflow. 50 | 51 | **Note:** 52 | This project has been created out of curiosity and for educational purposes. It is not intended for commercial use or to infringe on any copyrights, and it is not actively being used to generate AI-generated videos. 53 | 54 | --- 55 | 56 | ## Demo 57 | 58 | The YouTube channel below showcases the auto-yt-shorts project in action, a few examples of the videos generated using the AI model, and the process of uploading them to the platform. 59 | 60 | ==> [QuickQuirks YouTube Channel](https://www.youtube.com/channel/UC4igt1OgsZGBs7PRqpxI9eQ) 61 | 62 | --- 63 | 64 | ## Features 65 | 66 | | | Feature | Description | 67 | | --- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 68 | | ⚙️ | **Architecture** | _The project utilizes a modular architecture with components for video generation, processing, and uploading. It leverages Python environment setup with Docker, FastAPI for web services, and OpenAI for content generation._ | 69 | | 🔌 | **Integrations** | _Key integrations include OAuth 2.0 for YouTube uploads, Pexels for stock videos, OpenAI for content generation, and AssemblyAI for audio processing. External dependencies like scipy and httplib2 enhance functionality._ | 70 | | 🧩 | **Modularity** | _The codebase is modular with distinct modules for metadata, video processing, AI content generation, and upload functionalities._ | 71 | | ⚡️ | **Performance** | _Efficiency is maintained through parallel execution for video processing, allowing for faster content generation. The use of Docker containers aids in resource management and scalability of the application._ | 72 | | 📦 | **Dependencies** | _Key dependencies include oauth2client, FastAPI, OpenAI, and Docker for environment setup and execution. External libraries like pillow and opencv-python enhance image and video processing capabilities._ | 73 | 74 | --- 75 | 76 | ## Repository Structure 77 | 78 | ```sh 79 | └── auto-yt-shorts/ 80 | ├── .github 81 | │ └── workflows 82 | ├── Dockerfile 83 | ├── config.py 84 | ├── docker-compose.yml 85 | ├── environment.yml 86 | ├── example.env 87 | ├── fonts 88 | │ └── bold_font.ttf 89 | ├── main.py 90 | ├── requirements.txt 91 | ├── test.http 92 | ├── upload_video.py 93 | └── utils 94 | ├── audio.py 95 | ├── llm.py 96 | ├── metadata.py 97 | ├── stock_videos.py 98 | ├── tiktok.py 99 | ├── video.py 100 | └── yt.py 101 | ``` 102 | 103 | --- 104 | 105 | ## Modules 106 | 107 |
. 108 | 109 | | File | Summary | 110 | | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 111 | | [config.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/config.py) | Defines settings and API keys for controversial topic generation, OpenAI model selection, minimum/maximum search terms, and API keys for Pexels, AssemblyAI, and News API. Creates directories for temporary and output files, background songs, and secondary video content. | 112 | | [requirements.txt](https://github.com/marvinvr/auto-yt-shorts/blob/master/requirements.txt) | Manages project dependencies such as OpenAI and FastAPI to facilitate AI video generation, processing, and uploading functionalities within the auto-yt-shorts repository's ecosystem. The file ensures seamless integration of critical libraries for efficient execution of tasks. | 113 | | [environment.yml](https://github.com/marvinvr/auto-yt-shorts/blob/master/environment.yml) | Defines dependencies and environment setup for Python project auto-yt-shorts. Specifies required packages and their versions, ensuring compatibility and consistent development environment for the repository. | 114 | | [Dockerfile](https://github.com/marvinvr/auto-yt-shorts/blob/master/Dockerfile) | Builds a Docker container for auto-yt-shorts project, setting up Python environment and executing necessary commands. Copies project files, installs dependencies, and initiates the main script within a Python virtual environment. | 115 | | [test.http](https://github.com/marvinvr/auto-yt-shorts/blob/master/test.http) | Generate_videos/. | 116 | | [upload_video.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/upload_video.py) | Enables uploading videos to YouTube with OAuth 2.0 authentication and customizable metadata. Implements retry logic and resumable uploads for reliability. Facilitates seamless integration with the parent repositorys video processing workflow. | 117 | | [docker-compose.yml](https://github.com/marvinvr/auto-yt-shorts/blob/master/docker-compose.yml) | Orchestrates Docker containers for the auto-yt-shorts service, configuring volumes and environment variables. Exposes port 8000 for external access. | 118 | | [main.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/main.py) | Generates and processes video content based on user input, leveraging parallel execution for efficiency. Handles video metadata, stock footage selection, voiceover generation, and automatic upload to the platform. | 119 | 120 |
121 | 122 |
utils 123 | 124 | | File | Summary | 125 | | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 126 | | [metadata.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/utils/metadata.py) | Creates and stores metadata for a video, moving the video file to designated output location and saving metadata in JSON format. | 127 | | [llm.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/utils/llm.py) | Generates engaging TikTok video ideas, titles, and descriptions based on user input topics. Utilizes OpenAIs language model to suggest captivating content elements in a conversational manner. Enhances creativity in content creation for short-form videos. | 128 | | [yt.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/utils/yt.py) | Enables automatic and manual video uploads to YouTube. Automatically uploads video with specified details. Prepares videos for manual upload by organizing into folders with relevant metadata. Organized and streamlined YouTube video upload functionalities in the repository architecture. | 129 | | [tiktok.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/utils/tiktok.py) | Enables TikTok video uploads using AuthBackend for authentication and upload_video function. Facilitates captioning and customizing upload settings. | 130 | | [audio.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/utils/audio.py) | Generates voiceovers and selects random background songs for videos using OpenAI API and local files. Provides functionalities for creating audio files and selecting music assets in the auto-yt-shorts repository structure. | 131 | | [stock_videos.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/utils/stock_videos.py) | Retrieves stock videos based on search terms using Pexels API. Integrates with the parent repositorys architecture via config and video utils. Implements video search and retrieval functionalities for further processing. | 132 | | [video.py](https://github.com/marvinvr/auto-yt-shorts/blob/master/utils/video.py) | Generates subtitles and combines videos into a single clip with burnt-in subtitles and background music. Includes functionality to save videos from URLs and add secondary content. | 133 | 134 |
135 | 136 | --- 137 | 138 | ## Getting Started 139 | 140 | ### Locally 141 | 142 | **System Requirements:** 143 | 144 | - **Python**: `version 3.12` 145 | 146 | #### Installation 147 | 148 |

From source

149 | 150 | > 1. Clone the auto-yt-shorts repository: 151 | > 152 | > ```console 153 | > $ git clone https://github.com/marvinvr/auto-yt-shorts 154 | > ``` 155 | > 156 | > 2. Change to the project directory: 157 | > 158 | > ```console 159 | > $ cd auto-yt-shorts 160 | > ``` 161 | > 162 | > 3. Update the environment file: 163 | > 164 | > ```console 165 | > $ cp example.env .env 166 | > ``` 167 | > 168 | > 4. Update the `.env` file with your API keys and other credentials. 169 | > 170 | > 5. Prefill the `music` and `secondary_video` directories with background music and secondary video content. The tool will automatically select and use these files. 171 | > 172 | > 6. Setup and Authenticate with YouTube using the OAuth 2.0 flow: 173 | > 174 | > Follow the instructions in the [YouTube Data API documentation](https://developers.google.com/youtube/v3/quickstart/python) to create a project and obtain OAuth 2.0 credentials. 175 | > 176 | > ```console 177 | > $ python upload_video.py 178 | > ``` 179 | > 180 | > 7. Install the dependencies: 181 | > 182 | > ```console 183 | > $ pip install -r requirements.txt 184 | > ``` 185 | 186 | #### Usage 187 | 188 |

From source

189 | 190 | > Run auto-yt-shorts using the command below: 191 | > 192 | > ```console 193 | > $ python main.py 194 | > ``` 195 | 196 | ### With Docker 197 | 198 | > Start the Docker container using the command below: 199 | > 200 | > ```console 201 | > $ docker compose up 202 | > ``` 203 | > 204 | > The application will be accessible at `http://localhost:8000`. You can send a POST request to the `/generate_videos` endpoint with the required parameters to generate a video. 205 | 206 | --- 207 | 208 | ## Contributing 209 | 210 | Contributions are welcome! Here are several ways you can contribute: 211 | 212 | - **[Report Issues](https://github.com/marvinvr/auto-yt-shorts/issues)**: Submit bugs found or log feature requests for the `auto-yt-shorts` project. 213 | - **[Join the Discussions](https://github.com/marvinvr/auto-yt-shorts/discussions)**: Share your insights, provide feedback, or ask questions. 214 | 215 |
216 | Contributing Guidelines 217 | 218 | 1. **Fork the Repository**: Start by forking the project repository to your github account. 219 | 2. **Clone Locally**: Clone the forked repository to your local machine using a git client. 220 | ```sh 221 | git clone https://github.com/marvinvr/auto-yt-shorts 222 | ``` 223 | 3. **Create a New Branch**: Always work on a new branch, giving it a descriptive name. 224 | ```sh 225 | git checkout -b new-feature-x 226 | ``` 227 | 4. **Make Your Changes**: Develop and test your changes locally. 228 | 5. **Commit Your Changes**: Commit with a clear message describing your updates. 229 | ```sh 230 | git commit -m 'Implemented new feature x.' 231 | ``` 232 | 6. **Push to github**: Push the changes to your forked repository. 233 | ```sh 234 | git push origin new-feature-x 235 | ``` 236 | 7. **Submit a Pull Request**: Create a PR against the original project repository. Clearly describe the changes and their motivations. 237 | 8. **Review**: Once your PR is reviewed and approved, it will be merged into the main branch. Congratulations on your contribution! 238 |
239 | 240 |
241 | Contributor Graph 242 |
243 |

244 | 245 | 246 | 247 |

248 |
249 | 250 | --- 251 | 252 | ## License 253 | 254 | This project is protected under the [MIT](https://choosealicense.com/licenses/mit) License. For more details, refer to the [LICENSE](https://choosealicense.com/licenses/mit) file. 255 | 256 | --- 257 | 258 | ## Acknowledgments 259 | 260 | - This project was inspired by the [MoneyPrinter](https://github.com/FujiwaraChoki/MoneyPrinter) repository. Some of the code and ideas were adapted from this project. 261 | 262 | --- 263 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | openai==1.11.0 2 | python-dotenv==0.21.0 3 | requests==2.31.0 4 | pathlib==1.0.1 5 | moviepy==1.0.3 6 | pillow==9.5.0 7 | scipy==1.12.0 8 | opencv-python==4.9.0.80 9 | assemblyai==0.20.2 10 | srt_equalizer==0.1.8 11 | tqdm==4.66.1 12 | fastapi==0.109.2 13 | uvicorn==0.27.1 14 | google-api-python-client==2.116.0 15 | httplib2==0.22.0 16 | oauth2client==4.1.3 -------------------------------------------------------------------------------- /test.http: -------------------------------------------------------------------------------- 1 | ##  2 | POST http://127.0.0.1:8000/generate_videos/ 3 | 4 | -------------------------------------------------------------------------------- /upload_video.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import random 5 | import sys 6 | import time 7 | 8 | import httplib2 9 | from apiclient.discovery import build 10 | from apiclient.errors import HttpError 11 | from apiclient.http import MediaFileUpload 12 | from oauth2client.client import flow_from_clientsecrets 13 | from oauth2client.file import Storage 14 | from oauth2client.tools import argparser, run_flow 15 | 16 | # Explicitly tell the underlying HTTP transport library not to retry, since 17 | # we are handling retry logic ourselves. 18 | httplib2.RETRIES = 1 19 | 20 | # Maximum number of times to retry before giving up. 21 | MAX_RETRIES = 10 22 | 23 | 24 | # Always retry when an apiclient.errors.HttpError with one of these status 25 | # codes is raised. 26 | RETRIABLE_STATUS_CODES = [500, 502, 503, 504] 27 | 28 | # The CLIENT_SECRETS_FILE variable specifies the name of a file that contains 29 | # the OAuth 2.0 information for this application, including its client_id and 30 | # client_secret. You can acquire an OAuth 2.0 client ID and client secret from 31 | # the Google API Console at 32 | # https://console.cloud.google.com/. 33 | # Please ensure that you have enabled the YouTube Data API for your project. 34 | # For more information about using OAuth2 to access the YouTube Data API, see: 35 | # https://developers.google.com/youtube/v3/guides/authentication 36 | # For more information about the client_secrets.json file format, see: 37 | # https://developers.google.com/api-client-library/python/guide/aaa_client_secrets 38 | CLIENT_SECRETS_FILE = "client_secrets.json" 39 | 40 | # This OAuth 2.0 access scope allows an application to upload files to the 41 | # authenticated user's YouTube channel, but doesn't allow other types of access. 42 | YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload" 43 | YOUTUBE_API_SERVICE_NAME = "youtube" 44 | YOUTUBE_API_VERSION = "v3" 45 | 46 | # This variable defines a message to display if the CLIENT_SECRETS_FILE is 47 | # missing. 48 | MISSING_CLIENT_SECRETS_MESSAGE = """ 49 | WARNING: Please configure OAuth 2.0 50 | 51 | To make this sample run you will need to populate the client_secrets.json file 52 | found at: 53 | 54 | %s 55 | 56 | with information from the API Console 57 | https://console.cloud.google.com/ 58 | 59 | For more information about the client_secrets.json file format, please visit: 60 | https://developers.google.com/api-client-library/python/guide/aaa_client_secrets 61 | """ % os.path.abspath( 62 | os.path.join(os.path.dirname(__file__), CLIENT_SECRETS_FILE) 63 | ) 64 | 65 | VALID_PRIVACY_STATUSES = ("public", "private", "unlisted") 66 | 67 | 68 | def get_authenticated_service(args): 69 | flow = flow_from_clientsecrets( 70 | CLIENT_SECRETS_FILE, 71 | scope=YOUTUBE_UPLOAD_SCOPE, 72 | message=MISSING_CLIENT_SECRETS_MESSAGE, 73 | ) 74 | 75 | storage = Storage("%s-oauth2.json" % sys.argv[0]) 76 | credentials = storage.get() 77 | 78 | if credentials is None or credentials.invalid: 79 | credentials = run_flow(flow, storage, args) 80 | 81 | return build( 82 | YOUTUBE_API_SERVICE_NAME, 83 | YOUTUBE_API_VERSION, 84 | http=credentials.authorize(httplib2.Http()), 85 | ) 86 | 87 | 88 | def initialize_upload(youtube, options): 89 | tags = None 90 | if options.keywords: 91 | tags = options.keywords.split(",") 92 | 93 | body = dict( 94 | snippet=dict( 95 | title=options.title, 96 | description=options.description, 97 | tags=tags, 98 | categoryId=options.category, 99 | ), 100 | status=dict(privacyStatus=options.privacyStatus), 101 | ) 102 | 103 | # Call the API's videos.insert method to create and upload the video. 104 | insert_request = youtube.videos().insert( 105 | part=",".join(body.keys()), 106 | body=body, 107 | # The chunksize parameter specifies the size of each chunk of data, in 108 | # bytes, that will be uploaded at a time. Set a higher value for 109 | # reliable connections as fewer chunks lead to faster uploads. Set a lower 110 | # value for better recovery on less reliable connections. 111 | # 112 | # Setting "chunksize" equal to -1 in the code below means that the entire 113 | # file will be uploaded in a single HTTP request. (If the upload fails, 114 | # it will still be retried where it left off.) This is usually a best 115 | # practice, but if you're using Python older than 2.6 or if you're 116 | # running on App Engine, you should set the chunksize to something like 117 | # 1024 * 1024 (1 megabyte). 118 | media_body=MediaFileUpload(options.file, chunksize=-1, resumable=True), 119 | ) 120 | 121 | resumable_upload(insert_request) 122 | 123 | 124 | # This method implements an exponential backoff strategy to resume a 125 | # failed upload. 126 | def resumable_upload(insert_request): 127 | response = None 128 | error = None 129 | retry = 0 130 | while response is None: 131 | try: 132 | print("Uploading file...") 133 | status, response = insert_request.next_chunk() 134 | if response is not None: 135 | if "id" in response: 136 | print("Video id '%s' was successfully uploaded." % response["id"]) 137 | else: 138 | exit("The upload failed with an unexpected response: %s" % response) 139 | except HttpError as e: 140 | error = "A retriable HTTP error %d occurred:\n%s" % ( 141 | e.resp.status, 142 | e.content, 143 | ) 144 | except HttpError as e: 145 | error = "A retriable error occurred: %s" % e 146 | 147 | if error is not None: 148 | print(error) 149 | retry += 1 150 | if retry > MAX_RETRIES: 151 | exit("No longer attempting to retry.") 152 | 153 | max_sleep = 2**retry 154 | sleep_seconds = random.random() * max_sleep 155 | print("Sleeping %f seconds and then retrying..." % sleep_seconds) 156 | time.sleep(sleep_seconds) 157 | 158 | 159 | if __name__ == "__main__": 160 | argparser.add_argument("--file", required=True, help="Video file to upload") 161 | argparser.add_argument("--title", help="Video title", default="Test Title") 162 | argparser.add_argument( 163 | "--description", help="Video description", default="Test Description" 164 | ) 165 | argparser.add_argument( 166 | "--category", 167 | default="22", 168 | help="Numeric video category. " 169 | + "See https://developers.google.com/youtube/v3/docs/videoCategories/list", 170 | ) 171 | argparser.add_argument( 172 | "--keywords", help="Video keywords, comma separated", default="" 173 | ) 174 | argparser.add_argument( 175 | "--privacyStatus", 176 | choices=VALID_PRIVACY_STATUSES, 177 | default=VALID_PRIVACY_STATUSES[0], 178 | help="Video privacy status.", 179 | ) 180 | args = argparser.parse_args() 181 | 182 | if not os.path.exists(args.file): 183 | exit("Please specify a valid file using the --file= parameter.") 184 | 185 | youtube = get_authenticated_service(args) 186 | try: 187 | initialize_upload(youtube, args) 188 | except HttpError as e: 189 | print("An HTTP error %d occurred:\n%s" % (e.resp.status, e.content)) 190 | -------------------------------------------------------------------------------- /utils/audio.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import uuid 4 | from pathlib import Path 5 | 6 | from openai import OpenAI 7 | 8 | from config import BACKGROUND_SONGS_PATH, OPENAI_API_KEY, TEMP_PATH 9 | 10 | client = OpenAI(api_key=OPENAI_API_KEY) 11 | 12 | 13 | def generate_voiceover(text: str) -> Path: 14 | audio_id = uuid.uuid4() 15 | audio_path = TEMP_PATH / f"{audio_id}.mp3" 16 | 17 | response = client.audio.speech.create( 18 | model="tts-1", voice="echo", input=text, speed=1 19 | ) 20 | 21 | response.stream_to_file(audio_path) 22 | 23 | return audio_path 24 | 25 | 26 | def get_random_background_song() -> Path: 27 | songs = list(BACKGROUND_SONGS_PATH.glob("*.mp3")) 28 | return random.choice(songs) 29 | -------------------------------------------------------------------------------- /utils/llm.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | from typing import List 4 | 5 | import requests 6 | from openai import OpenAI 7 | 8 | from config import NEWS_API_KEY, OPENAI_API_KEY, OPENAI_MODEL, POSSIBLE_TOPICS 9 | 10 | client = OpenAI(api_key=OPENAI_API_KEY) 11 | 12 | _base_prompt = """ 13 | You are a passionate Tiktok creator and you want to create more short form content. Your videos contain a voiceover, stock footage and subtitles. 14 | The content of the video is one informative, interesting or mind-blowing fact about the topic which people will find interesting. 15 | Your videos are not too complex, they should bring the message across in a simple and engaging way. 16 | """ 17 | 18 | 19 | def get_topic() -> str: 20 | return random.choice(POSSIBLE_TOPICS) 21 | 22 | 23 | def get_titles(topic: str) -> List[str]: 24 | _prompt = ( 25 | _base_prompt 26 | + """ 27 | The next message will contain the name of the topic, that you want to make a Tiktok about. 28 | The length of the video should be between 15 and 30 seconds. 29 | Generate some possible titles for your video. Use buzzwords and make them as engaging as possible. 30 | Use the name of a famous person, event, product or company to make it more engaging. 31 | The title should be about one specific fact or event related to the topic. 32 | 33 | Respond with JSON in the following format: 34 | { 35 | "titles": [ 36 | "Title 1", 37 | ... 38 | "Title n" 39 | ] 40 | } 41 | """ 42 | ) 43 | 44 | response = ( 45 | client.chat.completions.create( 46 | messages=[ 47 | {"role": "system", "content": _prompt}, 48 | {"role": "user", "content": topic}, 49 | ], 50 | response_format={"type": "json_object"}, 51 | model=OPENAI_MODEL, 52 | ) 53 | .choices[0] 54 | .message.content 55 | ) 56 | 57 | return json.loads(response)["titles"] 58 | 59 | ... 60 | 61 | 62 | def get_news_topics() -> List[str]: 63 | news = requests.get( 64 | f"https://newsapi.org/v2/top-headlines?country=us&apiKey={NEWS_API_KEY}" 65 | ).json() 66 | 67 | return [ 68 | article["description"] for article in news["articles"] if article["description"] 69 | ] 70 | 71 | 72 | def filter_by_spicyness(titles: List[str]) -> List[str]: 73 | _prompt = ( 74 | _base_prompt 75 | + """ 76 | You will be presented with a list of possible headline for your video and a corresponding number. 77 | Your goal is to make the most engaging video by using spicy news headlines that will attract the most attention. 78 | You are now a classification model to tell me whether the title is spicy or not. 79 | 80 | respond with JSON in the following format: 81 | { 82 | "spicy_titles": [n, m, ...] 83 | } 84 | """ 85 | ) 86 | 87 | response = ( 88 | client.chat.completions.create( 89 | messages=[ 90 | {"role": "system", "content": _prompt}, 91 | { 92 | "role": "user", 93 | "content": "\n".join( 94 | [f"{i+1}. {title}" for i, title in enumerate(titles)] 95 | ), 96 | }, 97 | ], 98 | response_format={"type": "json_object"}, 99 | model=OPENAI_MODEL, 100 | ) 101 | .choices[0] 102 | .message.content 103 | ) 104 | return [ 105 | titles[i] 106 | for i in json.loads(response)["spicy_titles"] 107 | if i < len(titles) and titles[i] 108 | ] 109 | 110 | 111 | def get_most_engaging_titles(titles: List[str], n: int = 1) -> List[str]: 112 | _prompt = ( 113 | _base_prompt 114 | + """ 115 | You will be presented with a list of possible title for your video and a corresponding number. 116 | Sort the titles by the most engaging one first and respond with a list of indices. 117 | They should have a name of a famous person, event, product or company, choose the one that is most engaging. 118 | 119 | Respond with JSON in the following format: 120 | { 121 | "most_engaging_titles": [n, m, ...] 122 | } 123 | """ 124 | ) 125 | 126 | response = ( 127 | client.chat.completions.create( 128 | messages=[ 129 | {"role": "system", "content": _prompt}, 130 | { 131 | "role": "user", 132 | "content": "\n".join( 133 | [f"{i+1}. {title}" for i, title in enumerate(titles)] 134 | ), 135 | }, 136 | ], 137 | response_format={"type": "json_object"}, 138 | model=OPENAI_MODEL, 139 | ) 140 | .choices[0] 141 | .message.content 142 | ) 143 | 144 | most_engaging_titles = json.loads(response)["most_engaging_titles"] 145 | 146 | sorted_titles = [ 147 | titles[i] for i in most_engaging_titles if i < len(titles) and titles[i] 148 | ] 149 | 150 | return sorted_titles[: n if n < len(sorted_titles) else len(sorted_titles)] 151 | 152 | 153 | def get_best_title(titles: List[str]) -> str: 154 | _prompt = ( 155 | _base_prompt 156 | + """ 157 | You will be presented with a list of possible title for your video and a corresponding number. 158 | Respond with the best, most engaging title for your video. 159 | It should have a name of a famous person, event, product or company, choose the one that is most engaging. 160 | 161 | Respond with JSON in the following format: 162 | { 163 | "best_title_index": n 164 | } 165 | 166 | """ 167 | ) 168 | 169 | response = ( 170 | client.chat.completions.create( 171 | messages=[ 172 | {"role": "system", "content": _prompt}, 173 | { 174 | "role": "user", 175 | "content": "\n".join( 176 | [f"{i+1}. {title}" for i, title in enumerate(titles)] 177 | ), 178 | }, 179 | ], 180 | response_format={"type": "json_object"}, 181 | model=OPENAI_MODEL, 182 | ) 183 | .choices[0] 184 | .message.content 185 | ) 186 | 187 | best_title_index = json.loads(response)["best_title_index"] 188 | return titles[best_title_index - 1] 189 | 190 | 191 | def get_description(title: str, script: str) -> str: 192 | _prompt = ( 193 | _base_prompt 194 | + f""" 195 | You have decided that your video is about {title}. 196 | The Script for your video is: 197 | {script} 198 | 199 | You will now generate a caption to be posted alognside your video. Keep it short and to the point. 200 | 201 | Do not under any circumstance refernce this prompt in your response. 202 | 203 | ONLY RETURN THE RAW DESCRIPTION. DO NOT RETURN ANYTHING ELSE. 204 | """ 205 | ) 206 | 207 | response = ( 208 | client.chat.completions.create( 209 | messages=[{"role": "user", "content": _prompt}], 210 | response_format={"type": "text"}, 211 | model=OPENAI_MODEL, 212 | ) 213 | .choices[0] 214 | .message.content.strip() 215 | ) 216 | 217 | return response 218 | 219 | 220 | def get_script(title: str) -> str: 221 | _prompt = ( 222 | _base_prompt 223 | + f""" 224 | You have decided on the title for your video: "{title}". 225 | You will now generate a script for your video. Keep it short and to the point. 226 | The video should be simple yet informative and engaging so that it can be easily understood by the audience and write like a real person would speak. 227 | 228 | Do not include any information about narration, music, cuts or similar. Only the text that will be narrated in the video. 229 | 230 | Do not under any circumstance refernce this prompt in your response. 231 | 232 | Get straight to the point, don't start with unnecessary things like, "welcome to this video". 233 | 234 | Obviously, the script should be related to the subject of the video. 235 | 236 | The voicover length of the video should be between 15 and 30 seconds. 237 | ONLY RETURN THE RAW SCRIPT. DO NOT RETURN ANYTHING ELSE. 238 | """ 239 | ) 240 | 241 | response = ( 242 | client.chat.completions.create( 243 | messages=[{"role": "user", "content": _prompt}], 244 | response_format={"type": "text"}, 245 | model=OPENAI_MODEL, 246 | ) 247 | .choices[0] 248 | .message.content.strip() 249 | ) 250 | 251 | return response 252 | 253 | 254 | def get_search_terms(title: str, script: str) -> list: 255 | _prompt = ( 256 | _base_prompt 257 | + f""" 258 | You have decided on the title for your video: "{title}". 259 | The Script for your video is: 260 | {script} 261 | 262 | You will now generate an appropriate amount search terms for the stock footage for your video. 263 | """ 264 | + """ 265 | 266 | The stock footage should be related to the content of the video and the the video. 267 | 268 | Make sure that the search terms are in the order that they appear in the script and that they are relevant to the content of the video. 269 | Also make sure the amount of search terms is appropriate for the length of the video. 270 | 271 | Respond with JSON in the following format: 272 | { 273 | "search_terms": [ 274 | "Search Term 1", 275 | ... 276 | "Search Term n" 277 | ] 278 | } 279 | """ 280 | ) 281 | 282 | response = ( 283 | client.chat.completions.create( 284 | messages=[{"role": "user", "content": _prompt}], 285 | response_format={"type": "json_object"}, 286 | model=OPENAI_MODEL, 287 | ) 288 | .choices[0] 289 | .message.content 290 | ) 291 | 292 | return json.loads(response)["search_terms"] 293 | -------------------------------------------------------------------------------- /utils/metadata.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from datetime import datetime 4 | from pathlib import Path 5 | from typing import List 6 | 7 | from config import OUTPUT_PATH 8 | 9 | 10 | def save_metadata( 11 | title: str, 12 | description: str, 13 | topic: str, 14 | script: str, 15 | search_terms: List[str], 16 | video_path: Path, 17 | ) -> Path: 18 | metadata = { 19 | "title": title, 20 | "description": description, 21 | "topic": topic, 22 | "script": script, 23 | "search_terms": search_terms, 24 | } 25 | 26 | today = (datetime.now()).strftime("%Y-%m-%d") 27 | 28 | os.system(f"mkdir -p {str(OUTPUT_PATH / today)}") 29 | 30 | new_video_file = OUTPUT_PATH / f"{today}" / f"{title}.mp4" 31 | 32 | os.system(f'mv {str(video_path)} "{new_video_file}"') 33 | 34 | with open(OUTPUT_PATH / f"{today}" / f"{title}.json", "w") as f: 35 | json.dump(metadata, f) 36 | 37 | return new_video_file 38 | -------------------------------------------------------------------------------- /utils/stock_videos.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from config import PEXELS_API_KEY 4 | import requests 5 | 6 | from utils.video import save_video 7 | 8 | 9 | def get_stock_videos(search_terms: List[str]) -> List[str]: 10 | urls = [search_pexels(term) for term in search_terms] 11 | 12 | return [save_video(url) for url in urls if url] 13 | 14 | def search_pexels(query: str) -> str: 15 | headers = { 16 | "Authorization": PEXELS_API_KEY 17 | } 18 | 19 | url = f"https://api.pexels.com/videos/search?query={query}&per_page=1" 20 | 21 | r = requests.get(url, headers=headers) 22 | 23 | response = r.json() 24 | 25 | try: 26 | video_urls = response["videos"][0]["video_files"] 27 | except: 28 | return "" 29 | video_url = "" 30 | 31 | for video in video_urls: 32 | if ".com/external" in video["link"]: 33 | video_url = video["link"] 34 | 35 | return video_url 36 | -------------------------------------------------------------------------------- /utils/tiktok.py: -------------------------------------------------------------------------------- 1 | from tiktok_uploader.auth import AuthBackend 2 | from tiktok_uploader.upload import upload_video, upload_videos 3 | 4 | 5 | def upload_tiktok(video_path: str, caption: str) -> str: 6 | 7 | upload_video( 8 | str(video_path), caption, "cookies.txt", browser="firefox", headless=False 9 | ) 10 | -------------------------------------------------------------------------------- /utils/video.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import uuid 4 | from pathlib import Path 5 | from typing import List 6 | 7 | import assemblyai as aai 8 | import requests 9 | import srt_equalizer 10 | from moviepy.audio.fx.all import volumex 11 | from moviepy.editor import ( 12 | AudioFileClip, 13 | CompositeAudioClip, 14 | CompositeVideoClip, 15 | TextClip, 16 | VideoFileClip, 17 | concatenate_videoclips, 18 | ) 19 | from moviepy.video.fx.all import crop 20 | from moviepy.video.tools.subtitles import SubtitlesClip 21 | 22 | from config import ASSEMBLY_AI_API_KEY, OUTPUT_PATH, SECONDARY_CONTENT_PATH, TEMP_PATH 23 | from utils.audio import get_random_background_song 24 | 25 | 26 | def generate_subtitles(audio_path: Path) -> Path: 27 | def equalize_subtitles(srt_path: str, max_chars: int = 10) -> None: 28 | srt_equalizer.equalize_srt_file(srt_path, srt_path, max_chars) 29 | 30 | aai.settings.api_key = ASSEMBLY_AI_API_KEY 31 | 32 | subtitles_id = uuid.uuid4() 33 | 34 | transcriber = aai.Transcriber() 35 | 36 | transcript = transcriber.transcribe(str(audio_path)) 37 | 38 | # Save subtitles 39 | subtitles_path = TEMP_PATH / f"{subtitles_id}.srt" 40 | 41 | subtitles = transcript.export_subtitles_srt() 42 | 43 | with open(subtitles_path, "w") as f: 44 | f.write(subtitles) 45 | 46 | # Equalize subtitles 47 | equalize_subtitles(subtitles_path) 48 | 49 | return subtitles_path 50 | 51 | 52 | def combine_videos(video_paths: List[Path], max_duration: int) -> Path: 53 | video_id = uuid.uuid4() 54 | combined_video_path = TEMP_PATH / f"{video_id}.mp4" 55 | 56 | clips = [] 57 | for video_path in video_paths: 58 | clip = VideoFileClip(str(video_path)) 59 | clip = clip.without_audio() 60 | # chain the clip to itself as many times as needed to be over max_duration / len(video_paths) 61 | clip = concatenate_videoclips([clip] * int(max_duration / len(video_paths))) 62 | 63 | clip = clip.subclip(0, max_duration / len(video_paths)) 64 | clip = clip.set_fps(30) 65 | 66 | # Not all videos are same size, 67 | # so we need to resize them 68 | clip = crop( 69 | clip, 70 | width=int(clip.h / 1920 * 1080), 71 | height=clip.h, 72 | x_center=clip.w / 2, 73 | y_center=clip.h / 2, 74 | ) 75 | clip = clip.resize((1080, 1920)) 76 | 77 | clips.append(clip) 78 | 79 | final_clip = concatenate_videoclips(clips) 80 | final_clip = final_clip.set_fps(30) 81 | final_clip.write_videofile( 82 | str(combined_video_path), 83 | threads=os.cpu_count(), 84 | temp_audiofile=str(TEMP_PATH / f"{video_id}.mp3"), 85 | ) 86 | 87 | return combined_video_path 88 | 89 | 90 | def generate_video( 91 | video_paths: List[Path], tts_path: Path, subtitles_path: Path 92 | ) -> Path: 93 | audio = AudioFileClip(str(tts_path)) 94 | 95 | combined_video_path = combine_videos(video_paths, audio.duration) 96 | 97 | generator = lambda txt: TextClip( 98 | txt, 99 | font=f"fonts/bold_font.ttf", 100 | fontsize=100, 101 | color="#FFFF00", 102 | stroke_color="black", 103 | stroke_width=5, 104 | ) 105 | 106 | # Burn the subtitles into the video 107 | subtitles = SubtitlesClip(str(subtitles_path), generator) 108 | result = CompositeVideoClip( 109 | [ 110 | VideoFileClip(str(combined_video_path)), 111 | subtitles.set_pos(("center", "center")), 112 | ] 113 | ) 114 | 115 | # Add the audio 116 | audio = AudioFileClip(str(tts_path)) 117 | music = AudioFileClip(str(get_random_background_song())) 118 | 119 | music = music.set_duration(audio.duration) 120 | 121 | audio = CompositeAudioClip([audio, volumex(music, 0.07)]) 122 | 123 | result = result.set_audio(audio) 124 | 125 | secondary_video = get_secondary_video_clip(result.duration) 126 | 127 | secondary_video = secondary_video.resize( 128 | (result.w, int(secondary_video.h / secondary_video.w * result.w)) 129 | ) 130 | 131 | secondary_video_position = ("center", result.h - secondary_video.h - 160) 132 | 133 | result = CompositeVideoClip( 134 | [result, secondary_video.set_pos(secondary_video_position)] 135 | ) 136 | 137 | video_id = uuid.uuid4() 138 | 139 | output_video_path = OUTPUT_PATH / f"{video_id}.mp4" 140 | 141 | result.write_videofile( 142 | str(output_video_path), 143 | threads=os.cpu_count(), 144 | temp_audiofile=str(TEMP_PATH / f"{video_id}.mp3"), 145 | ) 146 | 147 | return output_video_path 148 | 149 | 150 | def save_video(video_url: str) -> str: 151 | video_id = uuid.uuid4() 152 | video_path = TEMP_PATH / f"{video_id}.mp4" 153 | 154 | with open(video_path, "wb") as f: 155 | f.write(requests.get(video_url).content) 156 | 157 | return video_path 158 | 159 | 160 | def get_secondary_video_clip(duration) -> VideoFileClip: 161 | secondary_videos = list(SECONDARY_CONTENT_PATH.glob("*.mp4")) 162 | 163 | video_path = random.choice(secondary_videos) 164 | 165 | video = VideoFileClip(str(video_path)).without_audio() 166 | 167 | start_time = random.uniform(0, video.duration - duration) 168 | 169 | clip = video.subclip(start_time, start_time + duration) 170 | 171 | clip = clip.set_fps(30) 172 | 173 | return clip 174 | -------------------------------------------------------------------------------- /utils/yt.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from pathlib import Path 4 | 5 | 6 | def auto_upload( 7 | video: Path, 8 | title: str, 9 | description: str, 10 | privacy: str = "public", 11 | category: str = "24", 12 | ) -> None: 13 | os.system( 14 | f'/venv/bin/python3 upload_video.py --file="{str(video)}" --title="{title}" --description="{description}" --privacyStatus="{privacy}" --category="{category}"' 15 | ) 16 | 17 | 18 | def prep_for_manual_upload( 19 | file: Path, 20 | title: str, 21 | description: str, 22 | ) -> None: 23 | 24 | os.system("mkdir -p youtube") 25 | 26 | # create a folder with today's date 27 | today = (datetime.now()).strftime("%Y-%m-%d") 28 | 29 | os.system(f"mkdir -p youtube/{today}") 30 | 31 | # copy video file into youtube and name it title 32 | new_video_file = Path("youtube") / f"{today}" / f"{title}.mp4" 33 | if not new_video_file.exists(): 34 | # copy it 35 | os.system(f'cp {str(file)} "{new_video_file}"') 36 | 37 | # create a txt file with the description 38 | with open(new_video_file.with_suffix(".txt"), "w") as f: 39 | f.write(description) 40 | 41 | print("[Prepped for Manual Upload]") 42 | print(new_video_file) 43 | print(new_video_file.with_suffix(".txt")) 44 | --------------------------------------------------------------------------------