├── .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 |
3 |
4 |
5 |
AUTO-YT-SHORTS
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | Developed with the software and tools below.
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
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 |
--------------------------------------------------------------------------------