├── tests ├── __init__.py ├── class_comment_test.py ├── make_submission_image_test.py ├── get_reddit_stories_test.py ├── class_submission_test.py ├── make_youtube_metadata_test.py └── utils_test.py ├── .python-version ├── reddit_shorts ├── __init__.py ├── tiktok_voice │ ├── __init__.py │ ├── __pycache__ │ │ └── __init__.cpython-311.pyc │ ├── src │ │ ├── __pycache__ │ │ │ ├── voice.cpython-311.pyc │ │ │ └── text_to_speech.cpython-311.pyc │ │ ├── voice.py │ │ └── text_to_speech.py │ ├── data │ │ └── config.json │ └── LICENSE ├── __pycache__ │ ├── config.cpython-311.pyc │ ├── main.cpython-311.pyc │ ├── utils.cpython-311.pyc │ ├── __init__.cpython-311.pyc │ ├── make_tts.cpython-311.pyc │ ├── tiktok_tts.cpython-311.pyc │ ├── create_short.cpython-311.pyc │ ├── get_reddit_stories.cpython-311.pyc │ └── make_submission_image.cpython-311.pyc ├── __main__.py ├── class_comment.py ├── make_youtube_metadata.py ├── query_db.py ├── utils.py ├── config.py ├── upload_to_youtube.py ├── main.py ├── get_reddit_stories.py ├── class_submission.py ├── make_submission_image.py ├── make_tts.py └── create_short.py ├── web_ui ├── static │ ├── thumbnails │ │ └── .gitkeep │ ├── music_assets │ │ └── .gitkeep │ └── index.html ├── __init__.py └── routes.py ├── Reddit_Shorts.egg-info ├── dependency_links.txt ├── top_level.txt ├── entry_points.txt ├── requires.txt ├── SOURCES.txt └── PKG-INFO ├── AUTHORS ├── .DS_Store ├── temp ├── .DS_Store ├── test_gtts_001 │ ├── title_test_gtts_001.mp3 │ └── content_test_gtts_001.mp3 └── images │ ├── 05faa5ba-cf11-4cd6-baf4-03911a5925c7.png │ ├── 09c5cad5-e973-4489-b6a2-085aa07c7e4a.png │ ├── 28d3b646-7a0b-418f-8800-1fe27fdc5fb9.png │ ├── 301b81bd-6e57-4152-9877-8d6f7d052d6c.png │ ├── 817c0bf2-b185-4177-b1c6-7003ee047890.png │ ├── 819efbc8-6546-4832-942d-b08d57e17bc8.png │ ├── d3afe8a8-5f34-4032-85bf-12041594e019.png │ └── ff53a682-fcda-4cce-b789-8fefc36ab776.png ├── setup.py ├── requirements-dev.txt ├── resources ├── .DS_Store └── images │ └── reddit_submission_template.png ├── run_web.py ├── requirements.txt ├── stories.txt ├── tox.ini ├── .gitignore ├── LICENSE ├── .pre-commit-config.yaml ├── setup.cfg ├── .cursor └── rules │ └── project-structure-and-script-running.mdc ├── testing ├── reddit_submission_identifier.py └── reddit_submission_example.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.9 2 | -------------------------------------------------------------------------------- /reddit_shorts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web_ui/static/thumbnails/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web_ui/static/music_assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Reddit_Shorts.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Reddit_Shorts.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | reddit_shorts 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Gavin Kondrath <78187175+gavink97@users.noreply.github.com> 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/.DS_Store -------------------------------------------------------------------------------- /temp/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/.DS_Store -------------------------------------------------------------------------------- /Reddit_Shorts.egg-info/entry_points.txt: -------------------------------------------------------------------------------- 1 | [console_scripts] 2 | shorts = reddit_shorts.main:main 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | setup() 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | flake8 4 | mypy 5 | pre-commit 6 | pytest 7 | tox 8 | -------------------------------------------------------------------------------- /resources/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/resources/.DS_Store -------------------------------------------------------------------------------- /reddit_shorts/tiktok_voice/__init__.py: -------------------------------------------------------------------------------- 1 | from .src.text_to_speech import tts 2 | from .src.voice import Voice -------------------------------------------------------------------------------- /run_web.py: -------------------------------------------------------------------------------- 1 | from web_ui import create_app 2 | 3 | if __name__ == '__main__': 4 | app = create_app() 5 | app.run(debug=True, port=5001) -------------------------------------------------------------------------------- /temp/test_gtts_001/title_test_gtts_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/test_gtts_001/title_test_gtts_001.mp3 -------------------------------------------------------------------------------- /temp/test_gtts_001/content_test_gtts_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/test_gtts_001/content_test_gtts_001.mp3 -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/config.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/config.cpython-311.pyc -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/main.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/main.cpython-311.pyc -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/utils.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/utils.cpython-311.pyc -------------------------------------------------------------------------------- /resources/images/reddit_submission_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/resources/images/reddit_submission_template.png -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/__init__.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/__init__.cpython-311.pyc -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/make_tts.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/make_tts.cpython-311.pyc -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/tiktok_tts.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/tiktok_tts.cpython-311.pyc -------------------------------------------------------------------------------- /temp/images/05faa5ba-cf11-4cd6-baf4-03911a5925c7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/images/05faa5ba-cf11-4cd6-baf4-03911a5925c7.png -------------------------------------------------------------------------------- /temp/images/09c5cad5-e973-4489-b6a2-085aa07c7e4a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/images/09c5cad5-e973-4489-b6a2-085aa07c7e4a.png -------------------------------------------------------------------------------- /temp/images/28d3b646-7a0b-418f-8800-1fe27fdc5fb9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/images/28d3b646-7a0b-418f-8800-1fe27fdc5fb9.png -------------------------------------------------------------------------------- /temp/images/301b81bd-6e57-4152-9877-8d6f7d052d6c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/images/301b81bd-6e57-4152-9877-8d6f7d052d6c.png -------------------------------------------------------------------------------- /temp/images/817c0bf2-b185-4177-b1c6-7003ee047890.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/images/817c0bf2-b185-4177-b1c6-7003ee047890.png -------------------------------------------------------------------------------- /temp/images/819efbc8-6546-4832-942d-b08d57e17bc8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/images/819efbc8-6546-4832-942d-b08d57e17bc8.png -------------------------------------------------------------------------------- /temp/images/d3afe8a8-5f34-4032-85bf-12041594e019.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/images/d3afe8a8-5f34-4032-85bf-12041594e019.png -------------------------------------------------------------------------------- /temp/images/ff53a682-fcda-4cce-b789-8fefc36ab776.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/temp/images/ff53a682-fcda-4cce-b789-8fefc36ab776.png -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/create_short.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/create_short.cpython-311.pyc -------------------------------------------------------------------------------- /reddit_shorts/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from reddit_shorts.main import main 4 | 5 | 6 | if __name__ == "__main__": 7 | raise SystemExit(main()) 8 | -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/get_reddit_stories.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/get_reddit_stories.cpython-311.pyc -------------------------------------------------------------------------------- /reddit_shorts/__pycache__/make_submission_image.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/__pycache__/make_submission_image.cpython-311.pyc -------------------------------------------------------------------------------- /reddit_shorts/tiktok_voice/__pycache__/__init__.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/tiktok_voice/__pycache__/__init__.cpython-311.pyc -------------------------------------------------------------------------------- /reddit_shorts/tiktok_voice/src/__pycache__/voice.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/tiktok_voice/src/__pycache__/voice.cpython-311.pyc -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask>=2.0.0 2 | flask-cors>=4.0.0 3 | requests>=2.31.0 4 | playsound==1.2.2 5 | moviepy>=1.0.3 6 | pillow>=10.0.0 7 | numpy>=1.24.0 8 | pyttsx3>=2.90 9 | python-dotenv>=1.0.0 -------------------------------------------------------------------------------- /reddit_shorts/tiktok_voice/src/__pycache__/text_to_speech.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egebese/brainrot-generator/HEAD/reddit_shorts/tiktok_voice/src/__pycache__/text_to_speech.cpython-311.pyc -------------------------------------------------------------------------------- /stories.txt: -------------------------------------------------------------------------------- 1 | Title: I pretended to be a whole agency for a pitch and actually got the job. 2 | Story: 3 | I’m just a solo creative, but a big brand posted they were looking for a “design agency with a proven ad portfolio.” I had none. But I had time, ambition… and a secret weapon. 4 | -------------------------------------------------------------------------------- /reddit_shorts/tiktok_voice/data/config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "https://tiktok-tts.weilnet.workers.dev/api/generation", 4 | "response": "data" 5 | }, 6 | { 7 | "url": "https://gesserit.co/api/tiktok-tts", 8 | "response": "base64" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /web_ui/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_cors import CORS 3 | 4 | def create_app(): 5 | app = Flask(__name__) 6 | CORS(app) # Enable CORS for all routes 7 | 8 | # Import and register blueprints 9 | from .routes import main_bp 10 | app.register_blueprint(main_bp) 11 | 12 | return app -------------------------------------------------------------------------------- /Reddit_Shorts.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | requests>=2 2 | praw>=7 3 | toml>=0.10 4 | python-dotenv>=1 5 | ffmpeg-python>=0.2 6 | openai-whisper>=20231117 7 | pillow>=10 8 | google-api-python-client>=2 9 | google-auth-oauthlib>=1 10 | google-auth-httplib2>=0.2 11 | playsound@ git+https://github.com/taconi/playsound 12 | gTTS>=2.2.3 13 | 14 | [testing] 15 | pytest>=6 16 | -------------------------------------------------------------------------------- /tests/class_comment_test.py: -------------------------------------------------------------------------------- 1 | from reddit_shorts.class_comment import Comment 2 | 3 | 4 | def test_process_comment(): 5 | author = 'submission_author' 6 | data = { 7 | 'author': 'gavin', 8 | 'body': 'Hey I hope you enjoy my shorts generator', 9 | 'id': 1 10 | } 11 | 12 | comment = Comment(data['author'], data['body'], data['id']) 13 | Comment.process_comment(comment, author) 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | requirements.txt 4 | envlist = py37,py38,pypy3,pre-commit 5 | 6 | [testenv] 7 | deps = requirements-dev.txt 8 | commands = 9 | coverage erase 10 | coverage run -m pytest {posargs:test} 11 | coverage report 12 | 13 | [testenv:pre-commit] 14 | skip_install = true 15 | deps = pre-commit 16 | commands = pre-commit run --all-files --show-diff-on-failure 17 | 18 | [pep8] 19 | ignore=E501 20 | -------------------------------------------------------------------------------- /tests/make_submission_image_test.py: -------------------------------------------------------------------------------- 1 | from reddit_shorts.make_submission_image import generate_reddit_story_image 2 | 3 | 4 | def test_generate_reddit_story_image(): 5 | submission_data = { 6 | 'subreddit': 'askreddit', 7 | 'author': 'gavink', 8 | 'title': 'What are some early signs of male pattern baldness?', 9 | 'timestamp': 1703679574, 10 | 'score': 100, 11 | 'num_comments': 50 12 | } 13 | 14 | kwargs = { 15 | 'platform': 'youtube', 16 | 'filter': True 17 | } 18 | 19 | generate_reddit_story_image(**{**submission_data, **kwargs}) 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project specific directories 2 | resources/ 3 | generated_shorts/ 4 | temp/ 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | dist/ 16 | build/ 17 | *.egg-info/ 18 | *.egg 19 | 20 | # Virtual environments 21 | venv/ 22 | env/ 23 | .env/ 24 | .venv/ 25 | 26 | # IDE specific files 27 | .idea/ 28 | .vscode/ 29 | *.swp 30 | *.swo 31 | 32 | # OS generated files 33 | .DS_Store 34 | .DS_Store? 35 | ._* 36 | .Spotlight-V100 37 | .Trashes 38 | ehthumbs.db 39 | Thumbs.db 40 | 41 | # Testing 42 | .coverage 43 | htmlcov/ 44 | .pytest_cache/ 45 | .tox/ 46 | 47 | # Logs 48 | *.log -------------------------------------------------------------------------------- /Reddit_Shorts.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | LICENSE 2 | README.md 3 | setup.cfg 4 | setup.py 5 | Reddit_Shorts.egg-info/PKG-INFO 6 | Reddit_Shorts.egg-info/SOURCES.txt 7 | Reddit_Shorts.egg-info/dependency_links.txt 8 | Reddit_Shorts.egg-info/entry_points.txt 9 | Reddit_Shorts.egg-info/requires.txt 10 | Reddit_Shorts.egg-info/top_level.txt 11 | reddit_shorts/__init__.py 12 | reddit_shorts/__main__.py 13 | reddit_shorts/class_comment.py 14 | reddit_shorts/class_submission.py 15 | reddit_shorts/config.py 16 | reddit_shorts/create_short.py 17 | reddit_shorts/get_reddit_stories.py 18 | reddit_shorts/main.py 19 | reddit_shorts/make_submission_image.py 20 | reddit_shorts/make_tts.py 21 | reddit_shorts/make_youtube_metadata.py 22 | reddit_shorts/query_db.py 23 | reddit_shorts/upload_to_youtube.py 24 | reddit_shorts/utils.py 25 | reddit_shorts/tiktok_voice/__init__.py -------------------------------------------------------------------------------- /tests/get_reddit_stories_test.py: -------------------------------------------------------------------------------- 1 | from reddit_shorts.get_reddit_stories import connect_to_reddit, get_story_from_reddit 2 | import praw 3 | import os 4 | from dotenv import load_dotenv 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def load_credentials(): 10 | load_dotenv() 11 | reddit_client_id = os.environ['REDDIT_CLIENT_ID'] 12 | reddit_client_secret = os.environ['REDDIT_CLIENT_SECRET'] 13 | return reddit_client_id, reddit_client_secret 14 | 15 | 16 | def test_connect_to_reddit(load_credentials): 17 | reddit_client_id, reddit_client_secret = load_credentials 18 | reddit_instance = connect_to_reddit(reddit_client_id, reddit_client_secret) 19 | 20 | assert isinstance(reddit_instance, praw.Reddit) 21 | 22 | 23 | def test_get_story_from_reddit(load_credentials): 24 | kwargs = { 25 | 'platform': 'youtube', 26 | 'filter': True 27 | } 28 | 29 | get_story_from_reddit(**kwargs) 30 | -------------------------------------------------------------------------------- /tests/class_submission_test.py: -------------------------------------------------------------------------------- 1 | from reddit_shorts.class_submission import identify_post_type, qualify_submission, Submission 2 | import pytest 3 | from reddit_shorts.config import subreddits 4 | from testing.reddit_submission_example import get_submission_from_reddit 5 | import random 6 | 7 | 8 | @pytest.fixture 9 | def kwargs(): 10 | kwargs = { 11 | 'platform': 'tiktok', 12 | 'filter': True 13 | } 14 | 15 | return kwargs 16 | 17 | 18 | @pytest.fixture 19 | def submission_sample(kwargs): 20 | data = get_submission_from_reddit(**kwargs) 21 | return data 22 | 23 | 24 | def test_identify_post_type(submission_sample): 25 | result = identify_post_type(submission_sample) 26 | assert result is not None 27 | 28 | 29 | def test_process_submission(submission_sample, kwargs): 30 | subreddit = random.choice(subreddits) 31 | result = Submission.process_submission(subreddit, submission_sample, **kwargs) 32 | assert result is not None 33 | 34 | 35 | def test_qualify_submission(submission_sample, kwargs): 36 | qualify_submission(submission_sample, **kwargs) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 gav.ink: Gavin Kondrath 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated 5 | documentation files (the “Software”), to deal in the 6 | Software without restriction, including without limitation 7 | the rights to use, copy, modify, merge, publish, distribute, 8 | sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall 13 | be included in all copies or substantial portions of the 14 | Software. 15 | 16 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY 17 | KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 18 | THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 19 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 20 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 21 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 22 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 23 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: pytest-check 5 | name: pytest-check 6 | entry: pytest 7 | args: [tests] 8 | language: system 9 | pass_filenames: false 10 | always_run: true 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v4.5.0 13 | hooks: 14 | - id: check-yaml 15 | - id: check-toml 16 | - id: end-of-file-fixer 17 | - id: trailing-whitespace 18 | - id: check-added-large-files 19 | - id: name-tests-test 20 | # - id: double-quote-string-fixer 21 | - id: requirements-txt-fixer 22 | - repo: https://github.com/PyCQA/flake8 23 | rev: 7.0.0 24 | hooks: 25 | - id: flake8 26 | - repo: https://github.com/hhatto/autopep8 27 | rev: v2.1.0 28 | hooks: 29 | - id: autopep8 30 | - repo: https://github.com/asottile/reorder-python-imports 31 | rev: v3.12.0 32 | hooks: 33 | - id: reorder-python-imports 34 | args: [--py37-plus] 35 | - repo: https://github.com/asottile/setup-cfg-fmt 36 | rev: v2.5.0 37 | hooks: 38 | - id: setup-cfg-fmt 39 | -------------------------------------------------------------------------------- /reddit_shorts/tiktok_voice/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mark Reznikov 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 | -------------------------------------------------------------------------------- /tests/make_youtube_metadata_test.py: -------------------------------------------------------------------------------- 1 | from reddit_shorts.make_youtube_metadata import create_video_title, create_video_keywords 2 | import pytest 3 | 4 | 5 | @pytest.fixture 6 | def metadata(): 7 | submission_title = "askreddit Craziest Coincidences: Redditors, what's the most mind-blowing coincidence you've ever experienced?" 8 | subreddit = "askreddit" 9 | 10 | return submission_title, subreddit 11 | 12 | 13 | def test_create_video_title(metadata): 14 | submission_title, subreddit = metadata 15 | 16 | kwargs = { 17 | 'platform': 'youtube', 18 | 'title': submission_title, 19 | 'subreddit': subreddit 20 | } 21 | 22 | assert create_video_title(**kwargs) == "askreddit Craziest Coincidences: Redditors, what's the most mind-blowing coinc... #reddit #minecraft" 23 | 24 | 25 | def test_create_video_keywords(metadata): 26 | submission_title, subreddit = metadata 27 | additional_keywords = "minecraft,mindblowing,askreddit" 28 | 29 | assert create_video_keywords(submission_title, subreddit, additional_keywords) == ['askreddit', 'craziest', 'coincidences', 'redditors', 'whats', 'the', 'most', 'mindblowing', 'coincidence', 'youve', 'ever', 'experienced', 'minecraft'] 30 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Reddit_Shorts 3 | version = 0.1 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown 6 | license = MIT 7 | license_files = LICENSE 8 | classifiers = 9 | License :: OSI Approved :: MIT License 10 | Programming Language :: Python :: 3 11 | Programming Language :: Python :: 3 :: Only 12 | Programming Language :: Python :: Implementation :: CPython 13 | Programming Language :: Python :: Implementation :: PyPy 14 | 15 | [options] 16 | packages = find: 17 | install_requires = 18 | requests>=2 19 | praw>=7 20 | toml>=0.10 21 | python-dotenv>=1 22 | ffmpeg-python>=0.2 23 | openai-whisper>=20231117 24 | pillow>=10 25 | google-api-python-client>=2 26 | google-auth-oauthlib>=1 27 | google-auth-httplib2>=0.2 28 | playsound@git+https://github.com/taconi/playsound 29 | gTTS>=2.2.3 30 | python_requires = >=3.8 31 | 32 | [options.packages.find] 33 | exclude = 34 | test* 35 | testing* 36 | 37 | [options.entry_points] 38 | console_scripts = 39 | shorts = reddit_shorts.main:main 40 | 41 | [options.extras_require] 42 | testing = 43 | pytest>=6 44 | 45 | [options.package_data] 46 | reddit_shorts = py.typed 47 | 48 | [coverage:run] 49 | plugins = covdefaults 50 | 51 | [flake8] 52 | max-line-length = 160 53 | 54 | -------------------------------------------------------------------------------- /reddit_shorts/class_comment.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import re 3 | 4 | from reddit_shorts.utils import contains_bad_words 5 | 6 | 7 | class Comment(): 8 | def __init__(self, 9 | author: str, 10 | body: str, 11 | id: int): 12 | self.author = author 13 | self.body = body 14 | self.id = id 15 | 16 | @classmethod 17 | def process_comment(cls, comment: Any, submission_author: str, **kwargs) -> 'Comment': 18 | author = str(comment.author) 19 | body = str(comment.body) 20 | filter = kwargs.get('filter') 21 | 22 | url_pattern = re.compile(r'http', flags=re.IGNORECASE) 23 | 24 | author = author.replace("-", "") 25 | 26 | if author == "AutoModerator": 27 | # print("Skipping bot comment") 28 | pass 29 | 30 | if author == submission_author: 31 | # print("Skipping Submission Authors comment") 32 | pass 33 | 34 | if url_pattern.search(body.lower()): 35 | # print("Skipping comment that contains a link") 36 | pass 37 | 38 | if filter is True: 39 | if contains_bad_words(body): 40 | pass 41 | 42 | return cls( 43 | author=author, 44 | body=body, 45 | id=comment.id 46 | ) 47 | -------------------------------------------------------------------------------- /reddit_shorts/make_youtube_metadata.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def create_video_title(**kwargs) -> str: 5 | submission_title = kwargs.get('title') 6 | platform = kwargs.get('platform') 7 | 8 | if platform == "youtube": 9 | title_character_limit = 100 10 | 11 | else: 12 | title_character_limit = 2200 13 | 14 | title_hashtags = "#reddit #minecraft" 15 | 16 | truncated_character_limit = title_character_limit - len(title_hashtags) - 4 17 | 18 | if len(submission_title) >= truncated_character_limit: 19 | truncated_title = submission_title[:truncated_character_limit] 20 | if truncated_title.endswith(' '): 21 | truncated_title = truncated_title[:-1] 22 | 23 | short_video_title = f"{truncated_title}... {title_hashtags}" 24 | else: 25 | short_video_title = f"{submission_title} {title_hashtags}" 26 | 27 | print(short_video_title) 28 | return short_video_title 29 | 30 | 31 | def create_video_keywords(submission_title: str, subreddit: str, additional_keywords: str = None) -> list: 32 | # character limit is 500 33 | cleaned_sentence = re.sub(r'[^\w\s]', '', submission_title.lower()) 34 | keywords = cleaned_sentence.split() 35 | unique_keywords = [] 36 | keywords.append(subreddit) 37 | if additional_keywords is not None: 38 | additional_keys = additional_keywords.split(',') 39 | keywords.extend(additional_keys) 40 | 41 | for word in keywords: 42 | if word not in unique_keywords: 43 | unique_keywords.append(word) 44 | 45 | return unique_keywords 46 | -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from reddit_shorts.utils import split_string_at_space, abbreviate_number, format_relative_time, tts_for_platform, random_choice_music, contains_bad_words 3 | from reddit_shorts.config import music, project_path 4 | 5 | 6 | def test_split_string_at_space(): 7 | text = "You've been kidnapped. The last person you saw on a tv series is coming to save you. Who is it?" 8 | index = 37 9 | assert split_string_at_space(text, index) == 31 10 | 11 | 12 | def test_abbreviate_number(): 13 | number = "9100" 14 | assert abbreviate_number(number) == "9.1k" 15 | 16 | 17 | def test_format_relative_time(): 18 | time = 1707595200.0 19 | formatted_time = datetime.datetime.fromtimestamp(time) 20 | format_relative_time(formatted_time) 21 | 22 | 23 | def test_tts_for_platform(): 24 | kwargs = { 25 | 'platform': 'youtube' 26 | } 27 | platform_tts_path = f'{project_path}/youtube_tts.txt' 28 | try: 29 | with open(platform_tts_path, 'r') as file: 30 | platform_tts = file.read() 31 | 32 | except FileNotFoundError: 33 | print(f"File {platform_tts_path} not found.") 34 | 35 | assert tts_for_platform(**kwargs) == (platform_tts_path, platform_tts) 36 | 37 | 38 | def test_random_choice_music(): 39 | subreddit_music_type = 'general' 40 | random_choice_music(music, subreddit_music_type) 41 | 42 | 43 | def test_contains_bad_words(): 44 | text = 'fuck' 45 | text2 = 'what do we have here?' 46 | assert contains_bad_words(text) is True 47 | assert contains_bad_words(text2) is False 48 | -------------------------------------------------------------------------------- /.cursor/rules/project-structure-and-script-running.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Project Structure and Script Running Guide 7 | 8 | This project generates short-form videos from local stories, combining TikTok TTS narration, background video, music, and a title overlay. 9 | 10 | ## Main Entry Points 11 | - The main script is [reddit_shorts/main.py](mdc:reddit_shorts/main.py). It is run via the console script `shorts` or with `python -m reddit_shorts.main`. 12 | - The console script is defined in [setup.cfg](mdc:setup.cfg) under `[options.entry_points]` as `shorts = reddit_shorts.main:main`. 13 | 14 | ## How Script Running Works 15 | - The main script loads a story from [stories.txt](mdc:stories.txt) using [reddit_shorts/get_reddit_stories.py](mdc:reddit_shorts/get_reddit_stories.py). 16 | - It generates TTS audio using the TikTok TTS library ([reddit_shorts/tiktok_voice/](mdc:reddit_shorts/tiktok_voice/)), called from [reddit_shorts/make_tts.py](mdc:reddit_shorts/make_tts.py). 17 | - A title image is generated with [reddit_shorts/make_submission_image.py](mdc:reddit_shorts/make_submission_image.py). 18 | - The final video is assembled in [reddit_shorts/create_short.py](mdc:reddit_shorts/create_short.py), combining narration, music, background video, and subtitles. 19 | - Output videos are saved to the [generated_shorts/](mdc:generated_shorts/) directory. 20 | 21 | ## Key Project Files and Folders 22 | - [reddit_shorts/](mdc:reddit_shorts/): Main Python package with all logic. 23 | - [resources/](mdc:resources/): Contains background videos, music, and images (ignored by git). 24 | - [generated_shorts/](mdc:generated_shorts/): Output directory for generated videos (ignored by git). 25 | - [temp/](mdc:temp/): Temporary files and intermediate assets (ignored by git). 26 | - [stories.txt](mdc:stories.txt): Input stories in a specific format. 27 | 28 | ## Running the Project 29 | - Activate your Python virtual environment. 30 | - Run `shorts` or `python -m reddit_shorts.main` from the project root. 31 | - Optionally use `--filter` to enable the profanity filter. 32 | 33 | See [README.md](mdc:README.md) for more details and setup instructions. 34 | -------------------------------------------------------------------------------- /reddit_shorts/query_db.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | 4 | def create_tables(): 5 | db = sqlite3.connect("shorts.db") 6 | cursor = db.cursor() 7 | 8 | create_uploads_table = """ 9 | CREATE TABLE IF NOT EXISTS uploads ( 10 | id INT AUTO_INCREMENT PRIMARY KEY, 11 | submission_id VARCHAR(30), 12 | top_comment_id VARCHAR(30) 13 | ); 14 | """ 15 | 16 | create_admin_table = """ 17 | CREATE TABLE IF NOT EXISTS admin ( 18 | id INT AUTO_INCREMENT PRIMARY KEY, 19 | submission_id VARCHAR(30) 20 | ); 21 | """ 22 | 23 | cursor.execute(create_uploads_table) 24 | cursor.execute(create_admin_table) 25 | 26 | db.commit() 27 | 28 | cursor.close() 29 | db.close() 30 | 31 | 32 | def check_if_video_exists(submission_id: str, top_comment_id: str) -> bool: 33 | db = sqlite3.connect("shorts.db") 34 | cursor = db.cursor() 35 | 36 | videos_query = """ 37 | SELECT * FROM uploads 38 | WHERE submission_id = ? 39 | AND top_comment_id = ?; 40 | """ 41 | 42 | cursor.execute(videos_query, (submission_id, top_comment_id)) 43 | rows = cursor.fetchall() 44 | 45 | if len(rows) > 0: 46 | video_exists = True 47 | print("Video found in DB!") 48 | 49 | else: 50 | video_exists = False 51 | print("Video not in DB!") 52 | 53 | db.commit() 54 | cursor.close() 55 | db.close() 56 | 57 | return video_exists 58 | 59 | 60 | def write_to_db(submission_id: str, top_comment_id: str) -> None: 61 | db = sqlite3.connect("shorts.db") 62 | cursor = db.cursor() 63 | 64 | write_to_videos = """ 65 | INSERT INTO uploads (submission_id, top_comment_id) 66 | VALUES (?, ?); 67 | """ 68 | 69 | cursor.execute(write_to_videos, (submission_id, top_comment_id)) 70 | db.commit() 71 | 72 | print("Updated DB") 73 | 74 | cursor.close() 75 | db.close() 76 | 77 | 78 | def check_for_admin_posts(submission_id: str) -> bool: 79 | db = sqlite3.connect("shorts.db") 80 | cursor = db.cursor() 81 | 82 | videos_query = """ 83 | SELECT * FROM admin 84 | WHERE submission_id = ?; 85 | """ 86 | 87 | cursor.execute(videos_query, (submission_id,)) 88 | rows = cursor.fetchall() 89 | 90 | if len(rows) > 0: 91 | admin_post = True 92 | print("This Submission was written by an Admin") 93 | 94 | else: 95 | admin_post = False 96 | # print("Video not in DB!") 97 | 98 | db.commit() 99 | cursor.close() 100 | db.close() 101 | 102 | return admin_post 103 | -------------------------------------------------------------------------------- /testing/reddit_submission_identifier.py: -------------------------------------------------------------------------------- 1 | import os 2 | import praw 3 | import random 4 | from dotenv import load_dotenv 5 | # from praw.models import MoreComments 6 | # from reddit_shorts.config import launcher_path 7 | from reddit_shorts.class_submission import identify_post_type 8 | 9 | load_dotenv() 10 | reddit_client_id = os.environ['REDDIT_CLIENT_ID'] 11 | reddit_client_secret = os.environ['REDDIT_CLIENT_SECRET'] 12 | 13 | firefox_user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0" 14 | 15 | reddit = praw.Reddit( 16 | client_id=f"{reddit_client_id}", 17 | client_secret=f"{reddit_client_secret}", 18 | user_agent=f"{firefox_user_agent}" 19 | ) 20 | 21 | subreddits_to_test = [ 22 | "casualconversation" 23 | ] 24 | 25 | 26 | def identify_submission_type(): 27 | subreddit = random.choice(subreddits_to_test) 28 | submission = reddit.subreddit(subreddit).random() 29 | 30 | if submission is None: 31 | print(f"This subreddit bans the use of .random: {subreddit}") 32 | for submission in reddit.subreddit(f"{subreddit}").hot(limit=1): 33 | submission_author = submission.author 34 | submission_title = submission.title 35 | submission_is_self = submission.is_self 36 | submission_text = submission.selftext 37 | submission_url = submission.url 38 | submission_id = submission.id 39 | submission_score = submission.score 40 | submission_comments_int = submission.num_comments 41 | submission_timestamp = submission.created_utc 42 | 43 | print(subreddit) 44 | print(submission_author) 45 | print(submission_id) 46 | print(submission_title) 47 | print(submission_score) 48 | print(submission_timestamp) 49 | print(submission_url) 50 | identified_post = identify_post_type(submission_is_self, submission_text, submission_url) 51 | print(identified_post.kind) 52 | print(submission_text) 53 | print(len(submission_text)) 54 | 55 | else: 56 | submission_author = submission.author 57 | submission_title = submission.title 58 | submission_is_self = submission.is_self 59 | submission_text = submission.selftext 60 | submission_url = submission.url 61 | submission_id = submission.id 62 | submission_score = submission.score 63 | submission_comments_int = submission.num_comments 64 | submission_timestamp = submission.created_utc 65 | 66 | print(subreddit) 67 | print(submission_author) 68 | print(submission_id) 69 | print(submission_title) 70 | print(submission_score) 71 | print(submission_timestamp) 72 | print(submission_url) 73 | identified_post = identify_post_type(submission_is_self, submission_text, submission_url) 74 | print(identified_post.kind) 75 | print(submission_text) 76 | print(len(submission_text)) 77 | 78 | 79 | identify_submission_type() 80 | -------------------------------------------------------------------------------- /testing/reddit_submission_example.py: -------------------------------------------------------------------------------- 1 | import praw 2 | import random 3 | import time 4 | from reddit_shorts.config import subreddits 5 | from reddit_shorts.get_reddit_stories import connect_to_reddit 6 | from praw.models import Submission 7 | import os 8 | import re 9 | from praw.models import MoreComments 10 | from dotenv import load_dotenv 11 | from reddit_shorts.utils import tts_for_platform 12 | from reddit_shorts.class_submission import qualify_submission 13 | 14 | load_dotenv() 15 | reddit_client_id = os.environ['REDDIT_CLIENT_ID'] 16 | reddit_client_secret = os.environ['REDDIT_CLIENT_SECRET'] 17 | 18 | 19 | def get_submission_from_reddit(reddit: praw.Reddit or None= None, **kwargs) -> Submission: 20 | print("Getting a story from Reddit...") 21 | min_character_len = 300 22 | max_character_len = 830 23 | (platform_tts_path, platform_tts) = tts_for_platform(**kwargs) 24 | url_pattern = re.compile(r'http', flags=re.IGNORECASE) 25 | 26 | while True: 27 | subreddit = random.choice(subreddits) 28 | subreddit_name = subreddit[0] 29 | 30 | try: 31 | if reddit is None: 32 | print("Connection failed. Attempting to reconnect...") 33 | reddit = connect_to_reddit(reddit_client_id, reddit_client_secret) 34 | time.sleep(5) 35 | 36 | except praw.exceptions.APIException as api_exception: 37 | print(f"PRAW API Exception: {api_exception}") 38 | reddit = None 39 | 40 | except Exception as e: 41 | print(f"Error: {e}") 42 | 43 | for submission in reddit.subreddit(f"{subreddit_name}").hot(limit=20): 44 | submission_author = submission.author 45 | submission_title = submission.title 46 | submission_text = submission.selftext 47 | 48 | submission_author = str(submission_author) 49 | submission_author = submission_author.replace("-", "") 50 | 51 | qualify_submission(submission, **kwargs) 52 | 53 | suitable_submission = False 54 | 55 | for top_level_comment in submission.comments: 56 | if isinstance(top_level_comment, MoreComments): 57 | continue 58 | 59 | top_comment_author = top_level_comment.author 60 | top_comment_body = top_level_comment.body 61 | 62 | top_comment_author = str(top_comment_author) 63 | top_comment_author = top_comment_author.replace("-", "") 64 | 65 | if top_comment_author == "AutoModerator": 66 | continue 67 | 68 | if top_comment_author == submission_author: 69 | continue 70 | 71 | if url_pattern.search(top_comment_body.lower()): 72 | continue 73 | 74 | total_length = len(submission_title) + len(submission_text) + len(top_comment_body) + len(platform_tts) 75 | print(f"{subreddit_name}:{submission_title} Total:{total_length}") 76 | 77 | if total_length < min_character_len: 78 | continue 79 | 80 | if total_length <= max_character_len: 81 | suitable_submission = True 82 | break 83 | 84 | if suitable_submission is True: 85 | return submission 86 | -------------------------------------------------------------------------------- /reddit_shorts/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import random 4 | import re 5 | 6 | from reddit_shorts.config import project_path, bad_words_list 7 | 8 | 9 | def split_string_at_space(text: str, index: int) -> int: 10 | while index >= 0 and text[index] != ' ': 11 | index -= 1 12 | return index 13 | 14 | 15 | def abbreviate_number(number: str) -> str: 16 | number = int(number) 17 | if number >= 10**10: 18 | return '{:.0f}B'.format(number / 10**9) 19 | elif number >= 10**9: 20 | return '{:.1f}B'.format(number / 10**9) 21 | elif number >= 10**7: 22 | return '{:.0f}M'.format(number / 10**6) 23 | elif number >= 10**6: 24 | return '{:.1f}M'.format(number / 10**6) 25 | elif number >= 10**4: 26 | return '{:.0f}k'.format(number / 10**3) 27 | elif number >= 10**3: 28 | return '{:.1f}k'.format(number / 10**3) 29 | else: 30 | return str(number) 31 | 32 | 33 | def format_relative_time(post_time: datetime.datetime) -> str: 34 | current_time = datetime.datetime.now() 35 | time_difference = current_time - post_time 36 | 37 | minutes = time_difference.total_seconds() / 60 38 | hours = minutes / 60 39 | days = hours / 24 40 | weeks = days / 7 41 | months = days / 30 42 | years = days / 365 43 | 44 | if years >= 1: 45 | return f"{int(years)} year{'s' if int(years) > 1 else ''} ago" 46 | elif months >= 1: 47 | return f"{int(months)} month{'s' if int(months) > 1 else ''} ago" 48 | elif weeks >= 1: 49 | return f"{int(weeks)} week{'s' if int(weeks) > 1 else ''} ago" 50 | elif days >= 1: 51 | return f"{int(days)} day{'s' if int(days) > 1 else ''} ago" 52 | elif hours >= 1: 53 | return f"{int(hours)} hr. ago" 54 | elif minutes >= 1: 55 | return f"{int(minutes)} mins ago" 56 | else: 57 | return "Just now" 58 | 59 | 60 | def tts_for_platform(**kwargs): 61 | platform = kwargs.get('platform') 62 | if platform == "youtube": 63 | platform_tts_path = os.path.join(project_path, "youtube_tts.txt") 64 | 65 | try: 66 | with open(platform_tts_path, 'r') as file: 67 | platform_tts = file.read() 68 | 69 | except FileNotFoundError: 70 | print(f"File {platform_tts_path} not found.") 71 | 72 | elif platform == "tiktok": 73 | platform_tts_path = os.path.join(project_path, "tiktok_tts.txt") 74 | 75 | try: 76 | with open(platform_tts_path, 'r') as file: 77 | platform_tts = file.read() 78 | 79 | except FileNotFoundError: 80 | print(f"File {platform_tts_path} not found.") 81 | 82 | return (platform_tts_path, platform_tts) 83 | 84 | 85 | def random_choice_music(music: list, subreddit_music_type: str): 86 | available_music = [(link, volume) for link, volume, music_type in music if music_type == subreddit_music_type] 87 | 88 | if available_music: 89 | random_song = random.choice(available_music) 90 | resource_music_link = random_song[0] 91 | resource_music_volume = random_song[1] 92 | return resource_music_link, resource_music_volume 93 | 94 | else: 95 | return None, None 96 | 97 | 98 | def contains_bad_words(text: str, bad_words: list = bad_words_list) -> bool: 99 | text = text.lower() 100 | bad_words_pattern = re.compile(r'\b(?:' + '|'.join(re.escape(word) for word in bad_words) + r')\b', re.IGNORECASE) 101 | 102 | if bad_words_pattern.search(text): 103 | return True 104 | return False 105 | -------------------------------------------------------------------------------- /reddit_shorts/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Determine the project root based on the location of this config file. 4 | # Assumes config.py is in 'reddit_shorts' and 'resources' is in the parent directory. 5 | launcher_path = os.path.abspath(os.path.dirname(__file__)) 6 | project_path = os.path.abspath(os.path.join(launcher_path, os.pardir)) 7 | 8 | # Path for the input stories text file 9 | stories_file_path = os.path.join(project_path, "stories.txt") 10 | 11 | # Path for saving output videos 12 | output_video_path = os.path.join(project_path, "generated_shorts") 13 | os.makedirs(output_video_path, exist_ok=True) # Ensure the directory exists 14 | 15 | video_resources_path = os.path.join(project_path, "resources", "footage") 16 | if not os.path.exists(video_resources_path): 17 | # Fallback for when installed as a package and resources are alongside modules 18 | video_resources_path = os.path.join(launcher_path, "resources", "footage") 19 | if not os.path.exists(video_resources_path): 20 | print(f"Warning: Footage directory not found at {os.path.join(project_path, 'resources', 'footage')} or {video_resources_path}") 21 | video_resources = [] 22 | else: 23 | video_resources = os.listdir(video_resources_path) 24 | else: 25 | video_resources = os.listdir(video_resources_path) 26 | 27 | 28 | music_resources_path = os.path.join(project_path, "resources", "music") 29 | if not os.path.exists(music_resources_path): 30 | # Fallback for when installed as a package and resources are alongside modules 31 | music_resources_path = os.path.join(launcher_path, "resources", "music") 32 | if not os.path.exists(music_resources_path): 33 | print(f"Warning: Music directory not found at {os.path.join(project_path, 'resources', 'music')} or {music_resources_path}") 34 | music_resources = [] 35 | else: 36 | music_resources = os.listdir(music_resources_path) 37 | else: 38 | music_resources = os.listdir(music_resources_path) 39 | 40 | footage = [] 41 | for file_name in video_resources: # Renamed 'file' to 'file_name' to avoid conflict 42 | file_path = os.path.join(video_resources_path, file_name) 43 | if not file_name.startswith('.DS_Store') and os.path.isfile(file_path): # Added check for file 44 | footage.append(file_path) 45 | 46 | # CHECK MUSIC FOR COPYRIGHT BEFORE USING 47 | # music_path str, volumex float float, music_type str 48 | music = [] 49 | for file_name in music_resources: # Renamed 'file' to 'file_name' 50 | music_file_path = os.path.join(music_resources_path, file_name) 51 | if not file_name.startswith('.DS_Store') and os.path.isfile(music_file_path): # Added check for file 52 | # Defaulting music type to general and volume, adjust as needed 53 | if file_name.endswith((".mp3", ".wav", ".ogg")): # Basic check for audio files 54 | # Assigning a default music_type and volume. 55 | # You might want to develop a more sophisticated way to categorize music if needed. 56 | music_type = "general" 57 | if "storytime" in file_name.lower(): 58 | music_type = "storytime" 59 | elif "creepy" in file_name.lower(): 60 | music_type = "creepy" 61 | 62 | # Example: Assign different volumes based on type or filename, or just a default 63 | volume = 0.2 64 | if music_type == "storytime": 65 | volume = 0.35 66 | elif music_type == "creepy": 67 | volume = 0.4 68 | 69 | music.append((music_file_path, volume, music_type)) 70 | 71 | if not footage: 72 | print("Warning: No footage files found. Please check 'resources/footage' directory.") 73 | if not music: 74 | print("Warning: No music files found. Please check 'resources/music' directory and ensure they have .mp3, .wav, or .ogg extensions.") 75 | # Add at least one default placeholder if empty, otherwise parts of the code might fail 76 | # music.append(("placeholder.mp3", 0.2, "general")) # This would require a placeholder file 77 | 78 | # This list is no longer needed as we are reading from a local file. 79 | # subreddits = [ 80 | # # Asking Questions 81 | # ("askreddit", "general", True), 82 | # ... (rest of the subreddits list was here) ... 83 | # ("thetruthishere", "creepy", False) 84 | # ] 85 | 86 | bad_words_list = [ 87 | "porn", 88 | "fuck", 89 | "fucking" 90 | ] 91 | 92 | # TikTok Session ID for TTS (No longer used by the new library) 93 | # TIKTOK_SESSION_ID_TTS = os.environ.get('TIKTOK_SESSION_ID_TTS', 'YOUR_SESSION_ID_HERE_OR_THE_ACTUAL_ONE') 94 | 95 | # List of words to check for in stories (optional) 96 | -------------------------------------------------------------------------------- /reddit_shorts/tiktok_voice/src/voice.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | # Enum to define available voices for text-to-speech conversion 4 | class Voice(Enum): 5 | # DISNEY VOICES 6 | GHOSTFACE = 'en_us_ghostface' 7 | CHEWBACCA = 'en_us_chewbacca' 8 | C3PO = 'en_us_c3po' 9 | STITCH = 'en_us_stitch' 10 | STORMTROOPER = 'en_us_stormtrooper' 11 | ROCKET = 'en_us_rocket' 12 | MADAME_LEOTA = 'en_female_madam_leota' 13 | GHOST_HOST = 'en_male_ghosthost' 14 | PIRATE = 'en_male_pirate' 15 | 16 | # ENGLISH VOICES 17 | AU_FEMALE_1 = 'en_au_001' 18 | AU_MALE_1 = 'en_au_002' 19 | UK_MALE_1 = 'en_uk_001' 20 | UK_MALE_2 = 'en_uk_003' 21 | US_FEMALE_1 = 'en_us_001' 22 | US_FEMALE_2 = 'en_us_002' 23 | US_MALE_1 = 'en_us_006' 24 | US_MALE_2 = 'en_us_007' 25 | US_MALE_3 = 'en_us_009' 26 | US_MALE_4 = 'en_us_010' 27 | MALE_JOMBOY = 'en_male_jomboy' 28 | MALE_CODY = 'en_male_cody' 29 | FEMALE_SAMC = 'en_female_samc' 30 | FEMALE_MAKEUP = 'en_female_makeup' 31 | FEMALE_RICHGIRL = 'en_female_richgirl' 32 | MALE_GRINCH = 'en_male_grinch' 33 | MALE_DEADPOOL = 'en_male_deadpool' 34 | MALE_JARVIS = 'en_male_jarvis' 35 | MALE_ASHMAGIC = 'en_male_ashmagic' 36 | MALE_OLANTERKKERS = 'en_male_olantekkers' 37 | MALE_UKNEIGHBOR = 'en_male_ukneighbor' 38 | MALE_UKBUTLER = 'en_male_ukbutler' 39 | FEMALE_SHENNA = 'en_female_shenna' 40 | FEMALE_PANSINO = 'en_female_pansino' 41 | MALE_TREVOR = 'en_male_trevor' 42 | FEMALE_BETTY = 'en_female_betty' 43 | MALE_CUPID = 'en_male_cupid' 44 | FEMALE_GRANDMA = 'en_female_grandma' 45 | MALE_XMXS_CHRISTMAS = 'en_male_m2_xhxs_m03_christmas' 46 | MALE_SANTA_NARRATION = 'en_male_santa_narration' 47 | MALE_SING_DEEP_JINGLE = 'en_male_sing_deep_jingle' 48 | MALE_SANTA_EFFECT = 'en_male_santa_effect' 49 | FEMALE_HT_NEYEAR = 'en_female_ht_f08_newyear' 50 | MALE_WIZARD = 'en_male_wizard' 51 | FEMALE_HT_HALLOWEEN = 'en_female_ht_f08_halloween' 52 | 53 | # EUROPE VOICES 54 | FR_MALE_1 = 'fr_001' 55 | FR_MALE_2 = 'fr_002' 56 | DE_FEMALE = 'de_001' 57 | DE_MALE = 'de_002' 58 | ES_MALE = 'es_002' 59 | 60 | # AMERICA VOICES 61 | ES_MX_MALE = 'es_mx_002' 62 | BR_FEMALE_1 = 'br_001' 63 | BR_FEMALE_2 = 'br_003' 64 | BR_FEMALE_3 = 'br_004' 65 | BR_MALE = 'br_005' 66 | BP_FEMALE_IVETE = 'bp_female_ivete' 67 | BP_FEMALE_LUDMILLA = 'bp_female_ludmilla' 68 | PT_FEMALE_LHAYS = 'pt_female_lhays' 69 | PT_FEMALE_LAIZZA = 'pt_female_laizza' 70 | PT_MALE_BUENO = 'pt_male_bueno' 71 | 72 | # ASIA VOICES 73 | ID_FEMALE = 'id_001' 74 | JP_FEMALE_1 = 'jp_001' 75 | JP_FEMALE_2 = 'jp_003' 76 | JP_FEMALE_3 = 'jp_005' 77 | JP_MALE = 'jp_006' 78 | KR_MALE_1 = 'kr_002' 79 | KR_FEMALE = 'kr_003' 80 | KR_MALE_2 = 'kr_004' 81 | JP_FEMALE_FUJICOCHAN = 'jp_female_fujicochan' 82 | JP_FEMALE_HASEGAWARIONA = 'jp_female_hasegawariona' 83 | JP_MALE_KEIICHINAKANO = 'jp_male_keiichinakano' 84 | JP_FEMALE_OOMAEAIIKA = 'jp_female_oomaeaika' 85 | JP_MALE_YUJINCHIGUSA = 'jp_male_yujinchigusa' 86 | JP_FEMALE_SHIROU = 'jp_female_shirou' 87 | JP_MALE_TAMAWAKAZUKI = 'jp_male_tamawakazuki' 88 | JP_FEMALE_KAORISHOJI = 'jp_female_kaorishoji' 89 | JP_FEMALE_YAGISHAKI = 'jp_female_yagishaki' 90 | JP_MALE_HIKAKIN = 'jp_male_hikakin' 91 | JP_FEMALE_REI = 'jp_female_rei' 92 | JP_MALE_SHUICHIRO = 'jp_male_shuichiro' 93 | JP_MALE_MATSUDAKE = 'jp_male_matsudake' 94 | JP_FEMALE_MACHIKORIIITA = 'jp_female_machikoriiita' 95 | JP_MALE_MATSUO = 'jp_male_matsuo' 96 | JP_MALE_OSADA = 'jp_male_osada' 97 | 98 | # SINGING VOICES 99 | SING_FEMALE_ALTO = 'en_female_f08_salut_damour' 100 | SING_MALE_TENOR = 'en_male_m03_lobby' 101 | SING_FEMALE_WARMY_BREEZE = 'en_female_f08_warmy_breeze' 102 | SING_MALE_SUNSHINE_SOON = 'en_male_m03_sunshine_soon' 103 | SING_FEMALE_GLORIOUS = 'en_female_ht_f08_glorious' 104 | SING_MALE_IT_GOES_UP = 'en_male_sing_funny_it_goes_up' 105 | SING_MALE_CHIPMUNK = 'en_male_m2_xhxs_m03_silly' 106 | SING_FEMALE_WONDERFUL_WORLD = 'en_female_ht_f08_wonderful_world' 107 | SING_MALE_FUNNY_THANKSGIVING = 'en_male_sing_funny_thanksgiving' 108 | 109 | # OTHER 110 | MALE_NARRATION = 'en_male_narration' 111 | MALE_FUNNY = 'en_male_funny' 112 | FEMALE_EMOTIONAL = 'en_female_emotional' 113 | 114 | # Function to check if a string matches any enum member name 115 | @staticmethod 116 | def from_string(input_string: str): 117 | # Iterate over all enum members 118 | for voice in Voice: 119 | if voice.name == input_string: 120 | return voice 121 | return None 122 | -------------------------------------------------------------------------------- /reddit_shorts/tiktok_voice/src/text_to_speech.py: -------------------------------------------------------------------------------- 1 | # Python standard modules 2 | import os 3 | import requests 4 | import base64 5 | import re 6 | from json import load 7 | from threading import Thread 8 | from typing import Dict, List, Optional 9 | 10 | # Downloaded modules 11 | from playsound import playsound 12 | 13 | # Local files 14 | from .voice import Voice 15 | 16 | def tts( 17 | text: str, 18 | voice: Voice, 19 | output_file_path: str = "output.mp3", 20 | play_sound: bool = False 21 | ): 22 | """Main function to convert text to speech and save to a file.""" 23 | 24 | # Validate input arguments 25 | _validate_args(text, voice) 26 | 27 | # Load endpoint data from the endpoints.json file 28 | endpoint_data: List[Dict[str, str]] = _load_endpoints() 29 | success: bool = False 30 | 31 | # Iterate over endpoints to find a working one 32 | for endpoint in endpoint_data: 33 | # Generate audio bytes from the current endpoint 34 | audio_bytes: bytes = _fetch_audio_bytes(endpoint, text, voice) 35 | 36 | if audio_bytes: 37 | # Save the generated audio to a file 38 | _save_audio_file(output_file_path, audio_bytes) 39 | 40 | # Optionally play the audio file 41 | if play_sound: 42 | playsound(output_file_path) 43 | 44 | success = True 45 | # Stop after processing a valid endpoint 46 | break 47 | 48 | if not success: 49 | raise Exception("failed to generate audio") 50 | 51 | def _save_audio_file(output_file_path: str, audio_bytes: bytes): 52 | """Write the audio bytes to a file.""" 53 | if os.path.exists(output_file_path): 54 | os.remove(output_file_path) 55 | 56 | with open(output_file_path, "wb") as file: 57 | file.write(audio_bytes) 58 | 59 | def _fetch_audio_bytes( 60 | endpoint: Dict[str, str], 61 | text: str, 62 | voice: Voice 63 | ) -> Optional[bytes]: 64 | """Fetch audio data from an endpoint and decode it.""" 65 | 66 | # Initialize variables for endpoint validity and audio data 67 | text_chunks: List[str] = _split_text(text) 68 | audio_chunks: List[str] = ["" for _ in range(len(text_chunks))] 69 | 70 | # Function to generate audio for each text chunk 71 | def generate_audio_chunk(index: int, text_chunk: str): 72 | try: 73 | response = requests.post(endpoint["url"], json={"text": text_chunk, "voice": voice.value}) 74 | response.raise_for_status() 75 | audio_chunks[index] = response.json()[endpoint["response"]] 76 | except (requests.RequestException, KeyError): 77 | return 78 | 79 | # Start threads for generating audio for each chunk 80 | threads = [Thread(target=generate_audio_chunk, args=(i, chunk)) for i, chunk in enumerate(text_chunks)] 81 | for thread in threads: 82 | thread.start() 83 | 84 | for thread in threads: 85 | thread.join() 86 | 87 | if any(not chunk for chunk in audio_chunks): 88 | return None 89 | 90 | # Concatenate and decode audio data from all chunks 91 | return base64.b64decode("".join(audio_chunks)) 92 | 93 | def _load_endpoints() -> List[Dict[str, str]]: 94 | """Load endpoint configurations from a JSON file.""" 95 | script_dir = os.path.dirname(__file__) 96 | json_file_path = os.path.join(script_dir, '../data', 'config.json') 97 | with open(json_file_path, 'r') as file: 98 | return load(file) 99 | 100 | def _validate_args(text: str, voice: Voice): 101 | """Validate the input arguments.""" 102 | 103 | # Check if the voice is of the correct type 104 | if not isinstance(voice, Voice): 105 | raise TypeError("'voice' must be of type Voice") 106 | 107 | # Check if the text is not empty 108 | if not text: 109 | raise ValueError("text must not be empty") 110 | 111 | def _split_text(text: str) -> List[str]: 112 | """Split text into chunks of 300 characters or less.""" 113 | 114 | # Split text into chunks based on punctuation marks 115 | merged_chunks: List[str] = [] 116 | separated_chunks: List[str] = re.findall(r'.*?[.,!?:;-]|.+', text) 117 | character_limit: int = 300 118 | # Further split any chunks longer than 300 characters 119 | for i, chunk in enumerate(separated_chunks): 120 | if len(chunk.encode("utf-8")) > character_limit: 121 | separated_chunks[i:i+1] = re.findall(r'.*?[ ]|.+', chunk) 122 | 123 | # Combine chunks into segments of 300 characters or less 124 | current_chunk: str = "" 125 | for separated_chunk in separated_chunks: 126 | if len(current_chunk.encode("utf-8")) + len(separated_chunk.encode("utf-8")) <= character_limit: 127 | current_chunk += separated_chunk 128 | else: 129 | merged_chunks.append(current_chunk) 130 | current_chunk = separated_chunk 131 | 132 | # Append the last chunk 133 | merged_chunks.append(current_chunk) 134 | return merged_chunks 135 | -------------------------------------------------------------------------------- /reddit_shorts/upload_to_youtube.py: -------------------------------------------------------------------------------- 1 | import httplib2 2 | import os 3 | import random 4 | import pickle 5 | import time 6 | import datetime 7 | 8 | from google_auth_oauthlib.flow import InstalledAppFlow 9 | from googleapiclient.discovery import build 10 | from googleapiclient.http import MediaFileUpload 11 | from google.auth.transport.requests import Request 12 | from apiclient.errors import HttpError 13 | 14 | from reddit_shorts.config import project_path 15 | 16 | httplib2.RETRIES = 1 17 | 18 | MAX_RETRIES = 10 19 | 20 | RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError) 21 | 22 | RETRIABLE_STATUS_CODES = [500, 502, 503, 504] 23 | 24 | CLIENT_SECRETS_FILE = f"{project_path}/client_secrets.json" 25 | 26 | YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload" 27 | YOUTUBE_API_SERVICE_NAME = "youtube" 28 | YOUTUBE_API_VERSION = "v3" 29 | 30 | MISSING_CLIENT_SECRETS_MESSAGE = """ 31 | WARNING: Please configure OAuth 2.0 32 | 33 | To make this sample run you will need to populate the client_secrets.json file 34 | found at: 35 | 36 | %s 37 | 38 | with information from the API Console 39 | https://console.cloud.google.com/ 40 | 41 | For more information about the client_secrets.json file format, please visit: 42 | https://developers.google.com/api-client-library/python/guide/aaa_client_secrets 43 | """ % os.path.abspath(os.path.join(os.path.dirname(__file__), CLIENT_SECRETS_FILE)) 44 | 45 | VALID_PRIVACY_STATUSES = ("public", "private", "unlisted") 46 | 47 | 48 | def get_authenticated_service(): 49 | cred = None 50 | pickle_file = f'{project_path}/token_{YOUTUBE_API_SERVICE_NAME}_{YOUTUBE_API_VERSION}.pickle' 51 | 52 | if os.path.exists(pickle_file): 53 | with open(pickle_file, 'rb') as token: 54 | cred = pickle.load(token) 55 | 56 | scopes = [f'{YOUTUBE_UPLOAD_SCOPE}'] 57 | 58 | if not cred or not cred.valid: 59 | if cred and cred.expired and cred.refresh_token: 60 | cred.refresh(Request()) 61 | else: 62 | flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRETS_FILE, scopes) 63 | cred = flow.run_local_server() 64 | 65 | with open(pickle_file, 'wb') as token: 66 | pickle.dump(cred, token) 67 | 68 | try: 69 | service = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, credentials=cred) 70 | print(YOUTUBE_API_SERVICE_NAME, 'service created successfully') 71 | return service 72 | 73 | except Exception as e: 74 | print('Unable to connect.') 75 | print(e) 76 | return None 77 | 78 | 79 | def convert_to_RFC_datetime(year=1900, month=1, day=1, hour=0, minute=0): 80 | dt = datetime.datetime(year, month, day, hour, minute, 0).isoformat() + 'Z' 81 | return dt 82 | 83 | 84 | def initialize_upload(youtube, youtube_short_file, short_video_title, video_description, video_category, video_keywords, video_privacy_status, notify_subs): 85 | 86 | if video_keywords: 87 | tags = video_keywords 88 | 89 | else: 90 | tags = None 91 | 92 | body = dict( 93 | snippet=dict( 94 | title=short_video_title, 95 | description=video_description, 96 | tags=tags, 97 | categoryId=video_category 98 | ), 99 | status=dict( 100 | privacyStatus=video_privacy_status 101 | ) 102 | ) 103 | 104 | insert_request = youtube.videos().insert( 105 | part=",".join(body.keys()), 106 | body=body, 107 | notifySubscribers=notify_subs, 108 | media_body=MediaFileUpload(youtube_short_file, chunksize=-1, resumable=True)) 109 | 110 | resumable_upload(insert_request) 111 | 112 | 113 | def resumable_upload(insert_request): 114 | response = None 115 | error = None 116 | retry = 0 117 | while response is None: 118 | try: 119 | print("Uploading file...") 120 | status, response = insert_request.next_chunk() 121 | if response is not None: 122 | if 'id' in response: 123 | print("Video id '%s' was successfully uploaded." % response['id']) 124 | else: 125 | exit("The upload failed with an unexpected response: %s" % response) 126 | except HttpError as e: 127 | if e.resp.status in RETRIABLE_STATUS_CODES: 128 | error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status, e.content) 129 | else: 130 | raise 131 | except RETRIABLE_EXCEPTIONS as e: 132 | error = "A retriable error occurred: %s" % e 133 | 134 | if error is not None: 135 | print(error) 136 | retry += 1 137 | if retry > MAX_RETRIES: 138 | exit("No longer attempting to retry.") 139 | 140 | max_sleep = 2 ** retry 141 | sleep_seconds = random.random() * max_sleep 142 | print("Sleeping %f seconds and then retrying..." % sleep_seconds) 143 | time.sleep(sleep_seconds) 144 | -------------------------------------------------------------------------------- /reddit_shorts/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import ssl 4 | 5 | from reddit_shorts.config import ( 6 | project_path, 7 | stories_file_path, 8 | output_video_path, 9 | # TIKTOK_SESSION_ID_TTS # No longer needed by the new library 10 | ) 11 | from reddit_shorts.get_reddit_stories import get_story_from_file 12 | from reddit_shorts.make_submission_image import generate_reddit_story_image 13 | from reddit_shorts.make_tts import generate_tiktok_tts_for_story # Uses the new library 14 | from reddit_shorts.create_short import create_short_video 15 | 16 | ssl._create_default_https_context = ssl._create_unverified_context 17 | 18 | def run_local_video_generation(**kwargs) -> str | None: 19 | """Generates a video locally from a story file.""" 20 | print("Starting local video generation process...") 21 | submission_data = get_story_from_file(**kwargs) 22 | 23 | if not submission_data: 24 | print("No story data received. Aborting video generation.") 25 | return None 26 | 27 | story_id = submission_data.get('id', "default_story_id") 28 | story_title = submission_data.get('title', "") 29 | story_selftext = submission_data.get('selftext', "") 30 | print(f"Processing story ID: {story_id}, Title: {story_title}") 31 | 32 | current_story_temp_dir = os.path.join(project_path, "temp", story_id) 33 | os.makedirs(current_story_temp_dir, exist_ok=True) 34 | 35 | print("Generating TTS audio for story...") 36 | tts_paths = generate_tiktok_tts_for_story( 37 | title=story_title, 38 | text_content=story_selftext, 39 | story_id=story_id, 40 | temp_dir=current_story_temp_dir, 41 | **kwargs # Pass through kwargs which should include the 'voice' parameter 42 | ) 43 | 44 | video_tts_path = tts_paths.get('video_tts_path') 45 | 46 | if not video_tts_path: 47 | print("TTS generation failed for video. Video might lack narration.") 48 | 49 | try: 50 | print("Generating story image...") 51 | image_generation_data = {**submission_data, 'subreddit': submission_data.get('music_type', 'local_story')} 52 | generate_reddit_story_image(**{**image_generation_data, **kwargs}) 53 | print("Story image generated.") 54 | except Exception as e: 55 | print(f"Error generating story image: {e}. Continuing without image specific to story text, or this step might need review.") 56 | 57 | print("Creating short video...") 58 | try: 59 | short_file_path = create_short_video( 60 | **tts_paths, 61 | **submission_data, 62 | **kwargs, 63 | output_dir=output_video_path 64 | ) 65 | if short_file_path and os.path.exists(short_file_path): 66 | print(f"Successfully created video: {short_file_path}") 67 | return short_file_path 68 | else: 69 | print(f"Video creation failed or file not found. Path returned: {short_file_path}") 70 | return None 71 | except Exception as e: 72 | print(f"Error during video creation: {e}") 73 | import traceback 74 | traceback.print_exc() 75 | return None 76 | 77 | def main(**kwargs) -> None: 78 | # Simplified main function to only generate video locally 79 | print(f"Received arguments for main: {kwargs}") 80 | 81 | # db_path = f'{project_path}/reddit-shorts/shorts.db' # DB not used 82 | # if not os.path.isfile(db_path): 83 | # create_tables() 84 | 85 | # No platform switching, just run the local generation 86 | video_file = run_local_video_generation(**kwargs) 87 | 88 | if video_file: 89 | print(f"Video generation complete. File saved at: {video_file}") 90 | else: 91 | print("Video generation failed.") 92 | 93 | # No uploading, no deleting of the local file 94 | 95 | 96 | def parse_my_args() -> dict: 97 | parser = argparse.ArgumentParser(description="Generate a short video from a local story file.") 98 | # Removed platform argument 99 | # parser.add_argument("-p", "--platform", choices=VALID_PLATFORM_CHOICES, default=VALID_PLATFORM_CHOICES[3], help="Choose what platform to upload to:") 100 | # parser.add_argument("-i", "--input", type=str.lower, action='store', default=False, help="Input your own text") 101 | # parser.add_argument("-v", "--video", type=str.lower, action='store', default=False, help="Input your own video path") 102 | # parser.add_argument("-m", "--music", type=str.lower, action='store', default=False, help="Input your own music") 103 | parser.add_argument("-pf", "--filter", action="store_true", default=False, help="Enable profanity filter for stories.") 104 | 105 | args = parser.parse_args() 106 | 107 | # platform = args.platform # No longer used 108 | profanity_filter = args.filter 109 | 110 | return { 111 | # 'platform': platform, # No longer used 112 | 'filter': profanity_filter, 113 | # Add other relevant args if create_short_video or other functions need them explicitly 114 | } 115 | 116 | 117 | def run() -> None: 118 | args = parse_my_args() 119 | main(**args) 120 | 121 | 122 | if __name__ == '__main__': 123 | run() 124 | -------------------------------------------------------------------------------- /Reddit_Shorts.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.4 2 | Name: Reddit_Shorts 3 | Version: 0.1 4 | License: MIT 5 | Classifier: License :: OSI Approved :: MIT License 6 | Classifier: Programming Language :: Python :: 3 7 | Classifier: Programming Language :: Python :: 3 :: Only 8 | Classifier: Programming Language :: Python :: Implementation :: CPython 9 | Classifier: Programming Language :: Python :: Implementation :: PyPy 10 | Requires-Python: >=3.8 11 | Description-Content-Type: text/markdown 12 | License-File: LICENSE 13 | Requires-Dist: requests>=2 14 | Requires-Dist: praw>=7 15 | Requires-Dist: toml>=0.10 16 | Requires-Dist: python-dotenv>=1 17 | Requires-Dist: ffmpeg-python>=0.2 18 | Requires-Dist: openai-whisper>=20231117 19 | Requires-Dist: pillow>=10 20 | Requires-Dist: google-api-python-client>=2 21 | Requires-Dist: google-auth-oauthlib>=1 22 | Requires-Dist: google-auth-httplib2>=0.2 23 | Requires-Dist: playsound@ git+https://github.com/taconi/playsound 24 | Requires-Dist: gTTS>=2.2.3 25 | Provides-Extra: testing 26 | Requires-Dist: pytest>=6; extra == "testing" 27 | Dynamic: license-file 28 | 29 | https://github.com/gavink97/reddit-shorts-generator/assets/78187175/d244555b-235b-4897-8c70-7009c6ba45ea 30 | 31 |

Reddit Shorts Generator

32 |

Generate Short-form media from UGC 33 | content from the worst website on the internet


34 | 35 | ## Table of contents 36 | - [Why](#why-this-project) 37 | - [Features](#features) 38 | - [Installation](#installation) 39 | - [Optional](#optional) 40 | - [Quick Start](#getting-started) 41 | - [Customization](#making-it-your-own) 42 | - [Contributing](#contributing) 43 | - [Roadmap](#roadmap) 44 | - [Star History](#star-history) 45 | 46 | 47 | ## Why this project 48 | I started this project after being inspired by this 49 | [video](https://youtu.be/_BsgckzDeRI?si=p18GIlR5urz-Pues). 50 | 51 | It was mid December and I had just gotten absolutely crushed 52 | in my first technical interview, so I wanted to build 53 | something simple to regain confidence. 54 | 55 | 56 | ## Features 57 | - Create Short-form media from UGC content from the worst website on the 58 | internet. 59 | - Upload videos directly to YouTube via the [YouTube 60 | API](https://developers.google.com/youtube/v3). 61 | - Automatically generate captions for your videos using Open AI 62 | [Whisper](https://github.com/openai/whisper). 63 | - Videos are highly customizable and render blazingly fast with FFMPEG. 64 | - Utilizes SQLite to avoid creating duplicate content. 65 | 66 | 67 | ## Installation 68 | Reddit Shorts can run independently but if you want to upload shorts 69 | automatically to YouTube or TikTok you must install the [TikTok 70 | Uploader](https://github.com/wkaisertexas/tiktok-uploader) and set up the 71 | [YouTube Data API](https://developers.google.com/youtube/v3) respectively. 72 | 73 | Additionally, install the reddit shorts repo in the root directory of the 74 | project as shown in the file tree. 75 | 76 | ### File tree 77 | 78 | ``` 79 | ├── client_secrets.json 80 | ├── cookies.txt 81 | ├── [reddit-shorts] 82 | ├── resources 83 | │   ├── footage 84 | │   └── music 85 | ├── tiktok-uploader 86 | ├── tiktok_tts.txt 87 | └── youtube_tts.txt 88 | ``` 89 | 90 | ### Optional 91 | - [TikTok Uploader](https://github.com/wkaisertexas/tiktok-uploader) is required to upload shorts to TikTok. 92 | - [YouTube Data API](https://developers.google.com/youtube/v3) is required to upload shorts to YouTube. 93 | 94 | ### Build source 95 | Because OpenAi's [Whisper](https://github.com/openai/whisper) is only compatible 96 | with Python 3.8 - 3.11 only use those versions of python. 97 | 98 | ``` 99 | mkdir reddit-shorts 100 | gh repo clone gavink97/reddit-shorts-generator reddit-shorts 101 | pip install -e reddit-shorts 102 | ``` 103 | 104 | ### Install dependencies 105 | This package requires `ffmpeg fonts-liberation` to be installed. 106 | 107 | *This repository utilizes pillow to create reddit images, so make sure to 108 | uninstall PIL if you have it installed* 109 | 110 | ### Config 111 | If your music / footage directory is different from the file tree configure the 112 | path inside `config.py`. 113 | 114 | If you are creating videos for TikTok or YouTube and wish to have some custom 115 | tts at the end of the video, make sure you 116 | create `tiktok_tts.txt` or `youtube_tts.txt`. 117 | 118 | 119 | ## Getting Started 120 | `shorts` is the command line interface for the project. 121 | 122 | Simply run `shorts -p platform` to generate a youtube short and automatically 123 | upload it. 124 | 125 | `shorts -p youtube` 126 | 127 | *it should be noted that the only supported platform is youtube at 128 | the moment* 129 | 130 | 131 | ## Making it your own 132 | Customize your shorts with FFMPG inside `create_short.py`. 133 | 134 | 135 | ## Contributing 136 | All contributions are welcome! 137 | 138 | ## Roadmap 139 | 140 | - [ ] Standalone video exports 141 | - [ ] TikTok Support 142 | 143 | ## Star History 144 | 145 | 146 | 147 | 148 | Star History Chart 149 | 150 | 151 | -------------------------------------------------------------------------------- /reddit_shorts/get_reddit_stories.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import uuid # For generating unique IDs for stories 4 | 5 | # Removed praw, dotenv, and time imports as they are no longer needed for local file processing. 6 | # from dotenv import load_dotenv 7 | # from reddit_shorts.class_submission import Submission # We will create a simpler structure for now 8 | from reddit_shorts.config import stories_file_path, bad_words_list # music list might be used for type later 9 | 10 | # load_dotenv() # Not needed 11 | # reddit_client_id = os.environ['REDDIT_CLIENT_ID'] # Not needed 12 | # reddit_client_secret = os.environ['REDDIT_CLIENT_SECRET'] # Not needed 13 | 14 | 15 | # The Submission class might be too complex for local files initially. 16 | # We'll aim for a dictionary structure that create_short.py can use. 17 | # The original Submission class might have methods for cleaning, word checking, etc. 18 | # which we might need to replicate or simplify. 19 | 20 | def parse_stories_from_file(file_path: str) -> list[dict]: 21 | """Parses stories from the local text file.""" 22 | stories = [] 23 | try: 24 | with open(file_path, 'r', encoding='utf-8') as f: 25 | current_story = {} 26 | lines = f.readlines() 27 | idx = 0 28 | while idx < len(lines): 29 | line = lines[idx].strip() 30 | if line.startswith("Title:"): 31 | if current_story: # Save previous story before starting a new one 32 | # Basic validation: ensure story has title and body 33 | if current_story.get("title") and current_story.get("selftext"): 34 | stories.append(current_story) 35 | current_story = {} # Reset for next story 36 | current_story["title"] = line.replace("Title:", "", 1).strip() 37 | current_story["id"] = str(uuid.uuid4()) # Generate a unique ID 38 | current_story["subreddit"] = "local_story" # Placeholder 39 | current_story["url"] = f"local_story_url_{current_story['id']}" # Placeholder 40 | current_story["music_type"] = "general" # Default, can be refined 41 | # Try to infer music_type from title or story content if desired 42 | # For example: 43 | if "creepy" in current_story["title"].lower() or \ 44 | (current_story.get("selftext") and "creepy" in current_story.get("selftext", "").lower()): 45 | current_story["music_type"] = "creepy" 46 | elif "story" in current_story["title"].lower() or \ 47 | (current_story.get("selftext") and "story" in current_story.get("selftext", "").lower()): 48 | current_story["music_type"] = "storytime" 49 | 50 | elif line == "Story:" and "title" in current_story: 51 | idx += 1 52 | story_body_lines = [] 53 | while idx < len(lines) and not lines[idx].strip().startswith("Title:"): 54 | story_body_lines.append(lines[idx].strip()) 55 | idx += 1 56 | current_story["selftext"] = "\n".join(story_body_lines).strip() 57 | continue # Already incremented idx in the inner loop 58 | 59 | idx += 1 60 | 61 | if current_story and current_story.get("title") and current_story.get("selftext"): # Add the last story 62 | stories.append(current_story) 63 | 64 | except FileNotFoundError: 65 | print(f"Error: Stories file not found at {file_path}") 66 | return [] 67 | except Exception as e: 68 | print(f"Error reading or parsing stories file: {e}") 69 | return [] 70 | return stories 71 | 72 | def check_bad_words(text: str) -> bool: 73 | """Checks if the text contains any bad words from the configured list.""" 74 | if not text: 75 | return False 76 | return any(bad_word in text.lower() for bad_word in bad_words_list) 77 | 78 | # Keep track of stories that have been processed in this session to avoid immediate reuse. 79 | # For persistent tracking, a database or file would be needed. 80 | _processed_story_ids_session = set() 81 | 82 | def get_story_from_file(**kwargs) -> dict | None: 83 | """Gets a single, unprocessed story from the local file.""" 84 | # print("Getting a story from local file...") # Optional: for debugging 85 | 86 | all_stories = parse_stories_from_file(stories_file_path) 87 | if not all_stories: 88 | print("No stories found in the file or an error occurred.") 89 | return None 90 | 91 | available_stories = [s for s in all_stories if s["id"] not in _processed_story_ids_session] 92 | 93 | if not available_stories: 94 | print("All stories from the file have been processed in this session.") 95 | # Optionally, reset if all are processed and we want to loop: 96 | # _processed_story_ids_session.clear() 97 | # available_stories = all_stories 98 | # if not available_stories: return None 99 | return None # Or handle re-processing if desired 100 | 101 | selected_story = random.choice(available_stories) 102 | 103 | # Perform bad word check (simplified from original Submission class) 104 | if check_bad_words(selected_story.get("title", "")) or \ 105 | check_bad_words(selected_story.get("selftext", "")): 106 | print(f"Story with title '{selected_story.get('title', '')}' contains bad words. Skipping.") 107 | _processed_story_ids_session.add(selected_story["id"]) # Mark as processed to avoid re-checking immediately 108 | return get_story_from_file(**kwargs) # Try to get another story 109 | 110 | _processed_story_ids_session.add(selected_story["id"]) 111 | 112 | # The original Submission class had more processing, e.g. character limits, 113 | # database interaction. We are simplifying this for now. 114 | # The dictionary returned should be compatible with what create_short.py expects. 115 | # Essential fields based on original Submission.as_dict() and create_short.py: 116 | # - title (str) 117 | # - selftext (str) 118 | # - id (str) 119 | # - subreddit (str) -> we use "music_type" derived from story, or a placeholder 120 | # - url (str) - placeholder 121 | # - music_type (str) - derived or default "general" 122 | 123 | print(f"Selected story: '{selected_story.get('title', 'Untitled')}'") 124 | return selected_story 125 | 126 | # Removed connect_to_reddit and get_story_from_reddit functions. 127 | # The main script will now call get_story_from_file. 128 | 129 | if __name__ == '__main__': 130 | # For testing purposes 131 | story = get_story_from_file() 132 | if story: 133 | print("\nSelected story for testing:") 134 | print(f" ID: {story.get('id')}") 135 | print(f" Title: {story.get('title')}") 136 | print(f" Music Type: {story.get('music_type')}") 137 | print(f" Story: {story.get('selftext')[:100]}...") # Print first 100 chars of story 138 | else: 139 | print("No story selected for testing.") 140 | 141 | # Test processing all stories 142 | print("\n--- All Parsed Stories ---") 143 | all_s = parse_stories_from_file(stories_file_path) 144 | for s in all_s: 145 | print(f"Title: {s.get('title')}, ID: {s.get('id')}, Music: {s.get('music_type')}") 146 | print(f"Total stories parsed: {len(all_s)}") 147 | if not all_s and os.path.exists(stories_file_path): 148 | print(f"Make sure '{stories_file_path}' is not empty and stories are formatted correctly.") 149 | -------------------------------------------------------------------------------- /web_ui/routes.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Blueprint, request, jsonify, send_file, send_from_directory 3 | from reddit_shorts.tiktok_voice.src.voice import Voice 4 | from reddit_shorts.main import run_local_video_generation 5 | from reddit_shorts.config import footage, music 6 | 7 | # Set the static folder when creating the blueprint 8 | # It should be relative to the blueprint's root path. 9 | # Since routes.py is in web_ui/, and static is also in web_ui/, 10 | # the path will be 'static'. 11 | main_bp = Blueprint('main', __name__, static_folder='static') 12 | 13 | # Voice name mapping based on user's provided table 14 | VOICE_NAME_MAP = { 15 | 'en_male_jomboy': 'Game On', 16 | 'en_us_002': 'Jessie', 17 | 'es_mx_002': 'Warm', # Note: Non-English, will be filtered by current logic unless changed 18 | 'en_male_funny': 'Wacky', 19 | 'en_us_ghostface': 'Scream', # Also 'Ghost Face' in user list, using 'Scream' as it's first 20 | 'en_female_samc': 'Empathetic', 21 | 'en_male_cody': 'Serious', 22 | 'en_female_makeup': 'Beauty Guru', 23 | 'en_female_richgirl': 'Bestie', 24 | 'en_male_grinch': 'Trickster', 25 | 'en_us_006': 'Joey', 26 | 'en_male_narration': 'Story Teller', 27 | 'en_male_deadpool': 'Mr. GoodGuy', 28 | 'en_uk_001': 'Narrator', 29 | 'en_uk_003': 'Male English UK', 30 | 'en_au_001': 'Metro', 31 | 'en_male_jarvis': 'Alfred', 32 | 'en_male_ashmagic': 'ashmagic', 33 | 'en_male_olantekkers': 'olantekkers', 34 | 'en_male_ukneighbor': 'Lord Cringe', 35 | 'en_male_ukbutler': 'Mr. Meticulous', 36 | 'en_female_shenna': 'Debutante', 37 | 'en_female_pansino': 'Varsity', 38 | 'en_male_trevor': 'Marty', 39 | 'en_female_f08_twinkle': 'Pop Lullaby', 40 | 'en_male_m03_classical': 'Classic Electric', 41 | 'en_female_betty': 'Bae', 42 | 'en_male_cupid': 'Cupid', 43 | 'en_female_grandma': 'Granny', 44 | 'en_male_m2_xhxs_m03_christmas': 'Cozy', 45 | 'en_male_santa_narration': 'Author', 46 | 'en_male_sing_deep_jingle': 'Caroler', 47 | 'en_male_santa_effect': 'Santa', 48 | 'en_female_ht_f08_newyear': 'NYE 2023', 49 | 'en_male_wizard': 'Magician', 50 | 'en_female_ht_f08_halloween': 'Opera', 51 | 'en_female_ht_f08_glorious': 'Euphoric', 52 | 'en_male_sing_funny_it_goes_up': 'Hypetrain', 53 | 'en_female_ht_f08_wonderful_world': 'Melodrama', 54 | 'en_male_m2_xhxs_m03_silly': 'Quirky Time', 55 | 'en_female_emotional': 'Peaceful', 56 | 'en_male_m03_sunshine_soon': 'Toon Beat', 57 | 'en_female_f08_warmy_breeze': 'Open Mic', 58 | 'en_male_sing_funny_thanksgiving': 'Thanksgiving', 59 | 'en_female_f08_salut_damour': 'Cottagecore', 60 | 'en_us_007': 'Professor', 61 | 'en_us_009': 'Scientist', 62 | 'en_us_010': 'Confidence', 63 | 'en_au_002': 'Smooth', 64 | # Duplicates from user table already covered: en_us_ghostface, en_us_chewbacca, etc. 65 | # Assuming codes from Voice enum like 'en_us_chewbacca' are preferred if not in mapping. 66 | 'fr_001': 'French - Male 1' # Added from user's list 67 | } 68 | 69 | # Prioritized voice codes from user 70 | PRIORITIZED_VOICE_CODES = [ 71 | 'en_male_jomboy', 72 | 'en_us_002', 73 | 'es_mx_002', 74 | 'en_male_funny', 75 | 'en_us_ghostface', 76 | 'en_female_samc', 77 | 'en_male_cody', 78 | 'en_female_makeup', 79 | 'en_female_richgirl', 80 | 'en_male_grinch', 81 | 'en_us_006', 82 | 'en_male_narration', 83 | 'en_male_deadpool' 84 | ] 85 | 86 | @main_bp.route('/') 87 | def index(): 88 | # Serves the index.html file from the 'static' directory 89 | # The directory is relative to the blueprint's static folder, 90 | # or app's static_folder if blueprint has no static_folder. 91 | # For our setup, Flask should find it in web_ui/static/ 92 | return send_from_directory(main_bp.static_folder, 'index.html') 93 | 94 | @main_bp.route('/api/voices', methods=['GET']) 95 | def get_voices(): 96 | """Return list of available TikTok voices, with prioritized voices first.""" 97 | prioritized_list = [] 98 | other_voices_list = [] 99 | 100 | # Ensure all prioritized codes are valid and map them to their display info 101 | valid_prioritized_voice_objects = [] 102 | for code in PRIORITIZED_VOICE_CODES: 103 | voice_enum_member = Voice((code)) # Get enum member by value (code) 104 | if voice_enum_member: 105 | # Apply filter (e.g. English only, or specific other languages) 106 | if voice_enum_member.value.startswith('en_') or voice_enum_member.value in ['fr_001', 'es_mx_002']: 107 | voice_name = VOICE_NAME_MAP.get(code, voice_enum_member.name.replace('_', ' ').title()) 108 | prioritized_list.append({ 109 | "id": code, 110 | "name": voice_name, 111 | "category": "TikTok" 112 | }) 113 | valid_prioritized_voice_objects.append(voice_enum_member) 114 | 115 | # Process the rest of the voices from the Enum 116 | for voice_enum_member in Voice: 117 | if voice_enum_member not in valid_prioritized_voice_objects: # Avoid duplicates 118 | # Apply filter (e.g. English only, or specific other languages) 119 | if voice_enum_member.value.startswith('en_') or voice_enum_member.value in ['fr_001', 'es_mx_002']: 120 | voice_id = voice_enum_member.value 121 | voice_name = VOICE_NAME_MAP.get(voice_id, voice_enum_member.name.replace('_', ' ').title()) 122 | other_voices_list.append({ 123 | "id": voice_id, 124 | "name": voice_name, 125 | "category": "TikTok" 126 | }) 127 | 128 | return jsonify(prioritized_list + other_voices_list) 129 | 130 | @main_bp.route('/api/backgrounds', methods=['GET']) 131 | def get_backgrounds(): 132 | """Return list of available background videos""" 133 | if not footage: 134 | return jsonify([]) 135 | 136 | backgrounds = [] 137 | for video_path in footage: 138 | base_name = os.path.basename(video_path) 139 | video_name_without_ext = os.path.splitext(base_name)[0] 140 | thumbnail_url = f"/static/thumbnails/{video_name_without_ext}.jpg" 141 | backgrounds.append({ 142 | "id": base_name, 143 | "name": video_name_without_ext, 144 | "path": video_path, 145 | "thumbnail": thumbnail_url # Add the thumbnail URL 146 | }) 147 | return jsonify(backgrounds) 148 | 149 | @main_bp.route('/api/music', methods=['GET']) 150 | def get_music(): 151 | """Return list of available music tracks""" 152 | if not music: 153 | return jsonify([]) 154 | 155 | tracks = [] 156 | for music_file_path, volume, music_type_from_config in music: 157 | base_name = os.path.basename(music_file_path) 158 | track_name = os.path.splitext(base_name)[0] 159 | # Construct a URL that the frontend can use to fetch the music for preview 160 | # Assumes music files are copied to 'web_ui/static/music_assets/' 161 | servable_url = f"/static/music_assets/{base_name}" 162 | 163 | tracks.append({ 164 | "id": base_name, # Keep original ID for selection consistency if needed 165 | "name": track_name, 166 | "path": servable_url, # Send the servable URL 167 | "type": music_type_from_config # Use the type from config 168 | }) 169 | return jsonify(tracks) 170 | 171 | @main_bp.route('/api/generate', methods=['POST']) 172 | def generate_video(): 173 | """Generate a video from the provided script and settings""" 174 | data = request.json 175 | 176 | # Create a story entry in stories.txt format 177 | story_content = f"""Title: {data['title']} 178 | Story: 179 | {data['story']} 180 | """ 181 | 182 | # Temporarily save the story 183 | with open('stories.txt', 'w', encoding='utf-8') as f: 184 | f.write(story_content) 185 | 186 | # Set up generation parameters 187 | params = { 188 | 'filter': data.get('filter', False), 189 | 'voice': data.get('voice', 'en_us_002'), # Default TikTok voice 190 | 'background_video': data.get('background_video', None), 191 | 'background_music': data.get('background_music', None) 192 | } 193 | 194 | try: 195 | # Generate the video 196 | video_path = run_local_video_generation(**params) 197 | 198 | if video_path and os.path.exists(video_path): 199 | # Return the video file 200 | return send_file( 201 | video_path, 202 | mimetype='video/mp4', 203 | as_attachment=True, 204 | download_name=os.path.basename(video_path) 205 | ) 206 | else: 207 | return jsonify({"error": "Video generation failed"}), 500 208 | 209 | except Exception as e: 210 | return jsonify({"error": str(e)}), 500 -------------------------------------------------------------------------------- /reddit_shorts/class_submission.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Optional 3 | import re 4 | import os 5 | from praw.models import MoreComments 6 | from praw.models.comment_forest import CommentForest 7 | from reddit_shorts.config import project_path 8 | from reddit_shorts.query_db import check_if_video_exists, write_to_db, check_for_admin_posts 9 | from reddit_shorts.class_comment import Comment 10 | from reddit_shorts.utils import tts_for_platform, contains_bad_words 11 | 12 | 13 | class Submission: 14 | def __init__(self, 15 | subreddit: str, 16 | author: str, 17 | title: str, 18 | is_self: bool, 19 | text: str, 20 | url: str, 21 | score: int, 22 | num_comments: int, 23 | timestamp: datetime, 24 | id: int, 25 | comments: CommentForest, 26 | music_type: Optional[str], 27 | top_comment_body: Optional[str], 28 | top_comment_author: Optional[str], 29 | kind: str = None): 30 | self.subreddit = subreddit 31 | self.author = author 32 | self.title = title 33 | self.is_self = is_self 34 | self.text = text 35 | self.url = url 36 | self.score = score 37 | self.num_comments = num_comments 38 | self.timestamp = timestamp 39 | self.id = id 40 | self.comments = comments 41 | self.music_type = music_type 42 | self.top_comment_body = top_comment_body 43 | self.top_comment_author = top_comment_author 44 | self.kind = kind 45 | 46 | def as_dict(self): 47 | return { 48 | "subreddit": self.subreddit, 49 | "author": self.author, 50 | "title": self.title, 51 | "is_self": self.is_self, 52 | "text": self.text, 53 | "url": self.url, 54 | "score": self.score, 55 | "num_comments": self.num_comments, 56 | "timestamp": self.timestamp, 57 | "id": self.id, 58 | "comments": self.comments, 59 | "music_type": self.music_type, 60 | "top_comment_body": self.top_comment_body, 61 | "top_comment_author": self.top_comment_author, 62 | "kind": self.kind 63 | } 64 | 65 | @classmethod 66 | def process_submission(cls, subreddit: list, submission: Any, **kwargs) -> 'Submission': 67 | platform = kwargs.get('platform') 68 | (platform_tts_path, platform_tts) = tts_for_platform(**kwargs) 69 | 70 | subreddit_name = subreddit[0] 71 | subreddit_music_type = subreddit[1] 72 | 73 | tts_character_limit = 200 74 | min_character_len = 300 75 | 76 | if platform == 'youtube': 77 | max_character_len = 830 78 | 79 | elif platform == 'tiktok': 80 | max_character_len = 2400 81 | 82 | submission_author = str(submission.author) 83 | submission_title = str(submission.title) 84 | submission_text = str(submission.selftext) 85 | submission_id = str(submission.id) 86 | submission_kind = identify_post_type(submission) 87 | 88 | submission_author = submission_author.replace("-", "") 89 | 90 | qualify_submission(submission, **kwargs) 91 | 92 | suitable_submission = False 93 | comments = submission.comments 94 | 95 | for index, comment in enumerate(comments): 96 | if isinstance(comment, MoreComments): 97 | continue 98 | 99 | comment_data = Comment.process_comment(comment, submission_author) 100 | top_comment_body = comment_data.body 101 | top_comment_author = comment_data.author 102 | top_comment_id = comment_data.id 103 | 104 | total_length = len(submission_title) + len(submission_text) + len(top_comment_body) + len(platform_tts) 105 | print(f"{subreddit_name}:{submission_title} Total:{total_length}") 106 | 107 | if total_length < min_character_len: 108 | continue 109 | 110 | if total_length <= max_character_len: 111 | video_exists = check_if_video_exists(submission_id, top_comment_id) 112 | if video_exists is False: 113 | suitable_submission = True 114 | break 115 | 116 | else: 117 | continue 118 | 119 | if index == len(comments) - 1: 120 | print('reached the end of the comments') 121 | return None 122 | 123 | if suitable_submission is True: 124 | print("Found a suitable submission!") 125 | print(f"Suitable Submission:{subreddit}:{submission_title} Total:{total_length}") 126 | 127 | if not os.path.exists(f"{project_path}/temp/ttsoutput/texts/"): 128 | os.makedirs(f"{project_path}/temp/ttsoutput/texts/") 129 | 130 | if len(submission_title) >= tts_character_limit: 131 | with open(f'{project_path}/temp/ttsoutput/texts/{submission_author}_title.txt', 'w', encoding='utf-8') as file: 132 | file.write(submission_title) 133 | 134 | if len(submission_text) >= tts_character_limit: 135 | with open(f'{project_path}/temp/ttsoutput/texts/{submission_author}_content.txt', 'w', encoding='utf-8') as file: 136 | file.write(submission_text) 137 | 138 | if len(top_comment_body) >= tts_character_limit: 139 | with open(f'{project_path}/temp/ttsoutput/texts/{top_comment_author}.txt', 'w', encoding='utf-8') as file: 140 | file.write(top_comment_body) 141 | 142 | write_to_db(submission_id, top_comment_id) 143 | 144 | return cls( 145 | subreddit=subreddit_name, 146 | author=submission_author, 147 | title=submission_title, 148 | is_self=submission.is_self, 149 | text=submission_text, 150 | url=submission.url, 151 | score=submission.score, 152 | num_comments=submission.num_comments, 153 | timestamp=submission.created_utc, 154 | id=submission_id, 155 | comments=submission.comments, 156 | music_type=subreddit_music_type, 157 | top_comment_body=top_comment_body, 158 | top_comment_author=top_comment_author, 159 | kind=submission_kind 160 | ) 161 | 162 | 163 | class Title(Submission): 164 | kind = "title" 165 | 166 | 167 | class Text(Submission): 168 | kind = "text" 169 | 170 | 171 | class Link(Submission): 172 | kind = "link" 173 | 174 | 175 | class Image(Submission): 176 | kind = "image" 177 | 178 | 179 | class Video(Submission): 180 | kind = "video" 181 | 182 | 183 | def identify_post_type(submission: Any) -> str: 184 | image_extensions = ['.jpg', '.jpeg', '.png', '.gif'] 185 | video_extensions = ['.mp4', '.avi', '.mov'] 186 | reddit_image_extensions = [ 187 | 'https://www.reddit.com/gallery/', 188 | 'https://i.redd.it/' 189 | ] 190 | reddit_video_extensions = [ 191 | 'https://v.redd.it/', 192 | 'https://youtube.com', 193 | 'https://youtu.be' 194 | ] 195 | 196 | if submission.is_self is True: 197 | 198 | if submission.selftext == "": 199 | return Title.kind 200 | 201 | else: 202 | return Text.kind 203 | 204 | if submission.is_self is False: 205 | url = submission.url 206 | 207 | if any(url.endswith(ext) for ext in image_extensions): 208 | return Image.kind 209 | 210 | elif any(url.startswith(ext) for ext in reddit_image_extensions): 211 | return Image.kind 212 | 213 | elif any(url.endswith(ext) for ext in video_extensions): 214 | return Video.kind 215 | 216 | elif any(url.startswith(ext) for ext in reddit_video_extensions): 217 | return Video.kind 218 | 219 | else: 220 | return Link.kind 221 | 222 | 223 | def qualify_submission(submission: Any, **kwargs) -> None: 224 | filter = kwargs.get('filter') 225 | url_pattern = re.compile(r'http', flags=re.IGNORECASE) 226 | 227 | submission_title = str(submission.title) 228 | submission_text = str(submission.selftext) 229 | submission_id = str(submission.id) 230 | submission_kind = identify_post_type(submission) 231 | 232 | if submission_kind == "image" or submission_kind == "video": 233 | # print(f"This submission is not in text {submission_title}") 234 | pass 235 | 236 | if url_pattern.search(submission_text.lower()): 237 | # print("Skipping post that contains a link") 238 | pass 239 | 240 | admin_post = check_for_admin_posts(submission_id) 241 | if admin_post is True: 242 | pass 243 | 244 | if filter is True: 245 | if contains_bad_words(submission_title): 246 | pass 247 | 248 | if contains_bad_words(submission_text): 249 | pass 250 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TikTok Brainrot Generator 2 | 3 | This project generates engaging short videos from text stories. It features a web-based UI for easy video creation, allowing users to input stories, select background videos, choose background music, and pick from a variety of TikTok-style Text-to-Speech (TTS) voices. The final videos are saved directly to your local machine. 4 | 5 | Created by [egebese](https://x.com/egebese). 6 | 7 | **Acknowledgements:** This project is a significantly modified version of the original [reddit-shorts-generator by gavink97](https://github.com/gavink97/reddit-shorts-generator.git). While the core functionality has been adapted for local use with different TTS and story input methods, and now includes a Web UI, much of the foundational video processing logic and project structure is derived from this original work. 8 | 9 | ## Key Features 10 | 11 | * **Web-Based User Interface:** Easily create videos through a user-friendly web page. 12 | * **Flexible Input:** 13 | * Enter story titles and content directly in the UI. 14 | * Select background videos from your `resources/footage/` directory, with thumbnail previews. 15 | * Choose background music from your `resources/music/` directory, with audio previews. 16 | * Select from a wide range of TikTok TTS voices. 17 | * **Local Video Output:** Saves generated videos to the `generated_shorts/` directory. 18 | * **TikTok TTS Narration:** Utilizes the [mark-rez/TikTok-Voice-TTS](https://github.com/mark-rez/TikTok-Voice-TTS) library for dynamic and natural-sounding text-to-speech. 19 | * **Customizable Aesthetics:** 20 | * **Fonts:** Uses "Montserrat ExtraBold" for the title image text and video subtitles by default. 21 | * **Background Video Looping:** Background videos will loop if their duration is shorter than the narration. 22 | * **Custom Title Image:** Allows for a user-provided template (`resources/images/reddit_submission_template.png`) where the story title is overlaid. 23 | * **Platform Independent:** Core functionality does not rely on direct integration with external platforms like Reddit or YouTube APIs for content fetching or uploading. 24 | 25 | ## Setup and Installation 26 | 27 | ### Prerequisites 28 | 29 | 1. **Python:** Python 3.11+ is recommended. You can use tools like `pyenv` to manage Python versions. 30 | ```bash 31 | # Example using pyenv (if you have it installed) 32 | # pyenv install 3.11.9 33 | # pyenv local 3.11.9 34 | ``` 35 | 2. **FFmpeg:** Essential for video processing. It must be installed on your system and accessible via your system's PATH. 36 | * **macOS (using Homebrew):** `brew install ffmpeg` 37 | * **Linux (using apt):** `sudo apt update && sudo apt install ffmpeg` 38 | * **Windows:** Download from the [FFmpeg website](https://ffmpeg.org/download.html) and add the `bin` directory to your PATH. 39 | 3. **Fonts:** 40 | * **Montserrat ExtraBold:** Used for title images and subtitles. Ensure this font is installed on your system. You can typically find and install `.ttf` or `.otf` files. If the font is not found by its name, you may need to modify the font paths/names directly in `reddit_shorts/make_submission_image.py` and `reddit_shorts/create_short.py`. 41 | 42 | ### Installation Steps 43 | 44 | 1. **Clone the Repository:** 45 | ```bash 46 | git clone https://github.com/egebese/tiktok-brainrot-generator.git # Or your fork 47 | cd tiktok-brainrot-generator 48 | ``` 49 | 50 | 2. **Create and Activate a Python Virtual Environment:** 51 | ```bash 52 | python3 -m venv venv 53 | source venv/bin/activate # On Windows: venv\Scripts\activate 54 | ``` 55 | 56 | 3. **Install Dependencies:** 57 | ```bash 58 | pip install -r requirements.txt 59 | ``` 60 | If you intend to modify the core `reddit_shorts` package itself and want those changes reflected immediately (developer install): 61 | ```bash 62 | pip install -e . 63 | ``` 64 | 65 | ## Project Structure & Required Assets 66 | 67 | Before running, ensure the following files and directories are set up: 68 | 69 | * **`resources/footage/`**: 70 | * Place your background MP4 video files in this directory. These will be available for selection in the Web UI. Thumbnails will be automatically generated and cached in `web_ui/static/thumbnails/`. 71 | * **`resources/music/`**: 72 | * Place your background music files (MP3, WAV, OGG) here. These will be available for selection in the Web UI. For preview functionality, ensure assets are accessible (e.g., copied to `web_ui/static/music_assets/` by the application or during setup). 73 | * **`resources/images/reddit_submission_template.png`**: 74 | * This is your custom background image for the title screen overlay. The story title will be drawn onto this image. 75 | * The current title placement is configured in `reddit_shorts/make_submission_image.py`. You may need to adjust these if you change the template significantly. 76 | * **`generated_shorts/`**: 77 | * This directory will be created automatically if it doesn't exist. All videos generated via the Web UI will be saved here. 78 | * **`web_ui/`**: Contains the Flask web application. 79 | * `static/`: Holds static assets for the UI (HTML, CSS, JS, and initially empty `thumbnails` and `music_assets` folders). 80 | * `routes.py`: Defines the API endpoints and serves the UI. 81 | * `__init__.py`: Initializes the Flask app. 82 | * **`run_web.py`**: Script to start the Flask development server for the Web UI. 83 | * **`reddit_shorts/`**: Main Python package containing the video generation logic. 84 | * `config.py`: Contains paths to resources, bad words list, and other configurations. 85 | * `main.py`: Core logic for video generation, called by the Web UI. 86 | * `make_tts.py`: Handles Text-to-Speech using the integrated TikTok TTS library. 87 | * `create_short.py`: Manages the FFmpeg video assembly process. 88 | * `make_submission_image.py`: Generates the title image overlay. 89 | * `tiktok_voice/`: The integrated [mark-rez/TikTok-Voice-TTS](https://github.com/mark-rez/TikTok-Voice-TTS) library. 90 | * **`requirements.txt`**: Lists Python dependencies for the project, including Flask for the Web UI. 91 | 92 | ## Usage 93 | 94 | ### Running the Web UI 95 | 96 | 1. **Prepare Assets:** 97 | * Add your MP4 background videos to the `resources/footage/` directory. 98 | * Add your MP3/WAV/OGG music files to the `resources/music/` directory. (The UI will copy `music.mp3` to `web_ui/static/music_assets/` for preview if it exists at the expected location). 99 | * Ensure your `resources/images/reddit_submission_template.png` is in place. 100 | 2. **Start the Flask Server:** 101 | Open your terminal, navigate to the project's root directory, ensure your virtual environment is activated, and run: 102 | ```bash 103 | python run_web.py 104 | ``` 105 | 3. **Access the UI:** 106 | Open your web browser and go to `http://127.0.0.1:5001` (or the port specified in the terminal output). 107 | 4. **Generate Videos:** 108 | * Fill in the "Title" and "Story" fields. 109 | * Select a background video from the displayed thumbnails. 110 | * Select background music and preview it. 111 | * Choose a TTS voice from the paginated table. 112 | * Click "Generate Video". The video will be processed and then downloaded by your browser. It will also be saved in the `generated_shorts/` directory. 113 | 114 | ### (Alternative) Original CLI Usage (Limited Functionality) 115 | 116 | The project previously supported a CLI-based generation using `stories.txt`. While the Web UI is now the primary method, the underlying CLI entry point `brainrot-gen` (or `python -m reddit_shorts.main`) might still work for basic generation if `stories.txt` is populated, but it will not use the UI's selection features for voice, specific background video, or music. 117 | 118 | For CLI usage with `stories.txt`: 119 | 1. **Prepare `stories.txt`**: (Create this file in the project root if using CLI) 120 | * Format: Each story should have a title and the story body: 121 | ``` 122 | Title: [Your Story Title Here] 123 | Story: 124 | [First paragraph of your story] 125 | ... 126 | ``` 127 | Separate multiple stories with at least one blank line. 128 | 2. **Run:** 129 | ```bash 130 | brainrot-gen 131 | # or 132 | # python -m reddit_shorts.main 133 | ``` 134 | * `--filter`: Enables a basic profanity filter. 135 | 136 | ## Customization 137 | 138 | * **Video Editing Logic:** Modify `reddit_shorts/create_short.py` to change FFmpeg parameters, subtitle styles, or video composition. 139 | * **Title Image Generation:** Adjust title placement, font, or text wrapping in `reddit_shorts/make_submission_image.py`. 140 | * **TTS Voice Management:** Voices are sourced from the `tiktok_voice` library and managed in `web_ui/routes.py` for the UI. 141 | * **Configuration:** Edit `reddit_shorts/config.py` for resource paths, etc. 142 | * **Web UI Frontend:** Modify `web_ui/static/index.html` for UI layout, styling (Tailwind CSS), and Vue.js app logic. 143 | * **Web UI Backend:** Modify `web_ui/routes.py` for API endpoint behavior. 144 | 145 | ## Troubleshooting 146 | 147 | * **`ModuleNotFoundError: No module named 'flask'` (or other dependencies):** Ensure you've installed dependencies using `pip install -r requirements.txt`. 148 | * **`Address already in use` for port 5001:** Another application is using the port. You can change the port in `run_web.py`. 149 | * **404 Errors in UI / API calls not working:** Check terminal logs from `python run_web.py` for errors. Ensure Flask routes are correctly defined. 150 | * **`ffmpeg: command not found`**: Ensure FFmpeg is installed and in your system's PATH. 151 | * **Font errors (e.g., "Font not found")**: Make sure "Montserrat ExtraBold" (or your chosen font) is installed correctly. 152 | * **TTS Issues**: 153 | * Check your internet connection. 154 | * The underlying API used by the TTS library can sometimes be unreliable. 155 | * Look for error messages in the console output from `python run_web.py`. 156 | * **No videos generated / Video generation fails**: 157 | * Check `resources/footage/` has at least one `.mp4` file. 158 | * Check `resources/music/` has music files. 159 | * Ensure `resources/images/reddit_submission_template.png` exists. 160 | * Examine console output from `python run_web.py` for detailed FFmpeg or Python errors during generation. 161 | 162 | ## Acknowledgements 163 | 164 | * This project is a heavily modified fork of [gavink97/reddit-shorts-generator](https://github.com/gavink97/reddit-shorts-generator). 165 | * Uses the [mark-rez/TikTok-Voice-TTS](https://github.com/mark-rez/TikTok-Voice-TTS) library. 166 | * Web UI uses Vue.js and Tailwind CSS. 167 | 168 | ## License 169 | 170 | This project is currently unlicensed, inheriting the original [MIT License](https://github.com/gavink97/reddit-shorts-generator/blob/main/LICENSE) from the upstream repository where applicable to original code sections. New modifications by egebese are also effectively under MIT-style permissions unless otherwise specified. 171 | 172 | --- 173 | 174 | *This project builds upon the foundational structure and concepts from the [Reddit Shorts Generator by gavink97](https://github.com/gavink97/reddit-shorts-generator.git).* 175 | 176 | ## Star History 177 | 178 | [![Star History Chart](https://api.star-history.com/svg?repos=egebese/brainrot-generator&type=Date)](https://www.star-history.com/#egebese/brainrot-generator&Date) -------------------------------------------------------------------------------- /reddit_shorts/make_submission_image.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import random # For placeholder data 4 | 5 | from PIL import Image, ImageFont, ImageDraw, ImageOps # ImageOps for potential padding 6 | 7 | from reddit_shorts.config import project_path # This should be default_project_path now or ensure it resolves correctly 8 | # Removed unused utils: split_string_at_space, abbreviate_number, format_relative_time 9 | # as we will simplify the image to mostly title, or use the template's existing elements. 10 | 11 | # Helper function for text wrapping (can be improved) 12 | def wrap_text(text, font, max_width): 13 | lines = [] 14 | if not text: 15 | return lines 16 | 17 | words = text.split() 18 | current_line = '' 19 | for word in words: 20 | # Test if adding word exceeds max_width 21 | # For multiline_textbbox, we'd need a draw object, but font.getlength is for single line 22 | # A simple approximation: test bbox of current_line + word 23 | # More accurately, build line by line and measure with draw.multiline_textbbox 24 | if current_line: # if line is not empty 25 | test_line = f'{current_line} {word}' 26 | else: 27 | test_line = word 28 | 29 | # Use textbbox for more accurate width, considering font specifics 30 | # Requires a ImageDraw object, so we pass it or create a dummy one if needed for helper 31 | # For simplicity here, let's assume a draw object would be available or use getlength as an approximation 32 | # Note: getlength is for single line. For multiline, textbbox is better. 33 | # Pillow versions differ: older getsize, newer getlength, textbbox, multiline_textbbox 34 | try: 35 | # Try to get width using textlength if available (newer Pillow) 36 | line_width = font.getlength(test_line) 37 | except AttributeError: 38 | # Fallback for older Pillow or if no draw object for textbbox 39 | # This is a rough estimate based on average char width, not ideal. 40 | # A better fallback for older Pillow is draw.textsize(test_line, font=font)[0] 41 | # but this helper doesn't have `draw` yet. 42 | # For a robust solution, this helper would need `draw` or be part of a class. 43 | # For now, we keep it simple and expect it might need refinement. 44 | # A very basic character count based splitting if font metrics fail badly: 45 | # if len(test_line) * (font.size * 0.6) > max_width: # Rough estimate 46 | # For now, let's stick to what original code implies (word splitting by count) 47 | # The original code had `characters_to_linebreak` which is what we are trying to replace 48 | # with a width-based approach. 49 | pass # Placeholder for more robust width check if font.getlength fails 50 | 51 | if font.getlength(test_line) <= max_width: 52 | current_line = test_line 53 | else: 54 | if current_line: # Avoid adding empty lines if a single word is too long 55 | lines.append(current_line) 56 | current_line = word 57 | # Handle case where a single word is longer than max_width (optional: break word) 58 | if font.getlength(current_line) > max_width: 59 | # For now, just add it if it's the only word, it will overflow. 60 | # Proper handling would be char-level splitting. 61 | lines.append(current_line) 62 | current_line = '' # Reset if word itself was added 63 | 64 | if current_line: # Add the last line 65 | lines.append(current_line) 66 | return lines 67 | 68 | def generate_reddit_story_image(**kwargs) -> None: 69 | story_id = str(kwargs.get('id', 'unknown_story')) # Use ID for filename, provide fallback 70 | # subreddit will be music_type or 'local_story' passed from main.py 71 | subreddit = str(kwargs.get('subreddit', 'local_story')) 72 | submission_title = str(kwargs.get('title', 'Untitled Story')) 73 | 74 | # Provide placeholders for Reddit-specific data not available from local files 75 | submission_author = str(kwargs.get('author', story_id[:20])) # Use truncated story_id or a generic name 76 | submission_timestamp = kwargs.get('timestamp', datetime.datetime.now().timestamp()) 77 | submission_score = int(kwargs.get('score', random.randint(20, 300))) 78 | submission_comments_int = int(kwargs.get('num_comments', random.randint(5, 50))) 79 | 80 | subreddit_lowercase = subreddit.lower() 81 | 82 | # Ensure temp/images directory exists 83 | temp_images_dir = os.path.join(project_path, "temp", "images") 84 | os.makedirs(temp_images_dir, exist_ok=True) 85 | 86 | story_template_path = os.path.join(project_path, "resources", "images", "reddit_submission_template.png") 87 | if not os.path.exists(story_template_path): 88 | print(f"Error: Story template not found at {story_template_path}") 89 | return 90 | story_template = Image.open(story_template_path).convert("RGBA") # Ensure RGBA for transparency handling 91 | template_width, template_height = story_template.size 92 | 93 | # Define offsets for the title box based on user specification 94 | offset_left = 148 95 | offset_top = 198 96 | offset_right = 148 97 | offset_bottom = 134 98 | 99 | # Calculate title box coordinates 100 | title_box_left = offset_left 101 | title_box_top = offset_top 102 | title_box_right = template_width - offset_right 103 | title_box_bottom = template_height - offset_bottom 104 | title_box_width = title_box_right - title_box_left 105 | title_box_height = title_box_bottom - title_box_top # Calculated for completeness 106 | 107 | if title_box_width <= 0 or title_box_height <= 0: 108 | print(f"Error: Calculated title box dimensions are invalid (width: {title_box_width}, height: {title_box_height}). Check template size and offsets.") 109 | return 110 | 111 | # Output filename uses story_id 112 | submission_image_path = os.path.join(temp_images_dir, f"{story_id}.png") 113 | 114 | community_logo_path = os.path.join(project_path, "resources", "images", "subreddits", f"{subreddit_lowercase}.png") 115 | default_community_logo_path = os.path.join(project_path, "resources", "images", "subreddits", "default.png") 116 | 117 | if len(submission_author) > 22: 118 | submission_author_formatted = submission_author[:22] 119 | # return submission_author_formatted # This was an early return, should not be here 120 | else: 121 | submission_author_formatted = submission_author 122 | 123 | font_name = "Montserrat-ExtraBold" # Changed from LiberationSans-Bold 124 | # Start with a reasonable font size; this might need to be dynamic later. 125 | # For dynamic font sizing, you would loop, check fit, and adjust size. 126 | title_font_size = 60 127 | try: 128 | title_font = ImageFont.truetype(font_name, title_font_size) 129 | except IOError: 130 | print(f"Error: Font '{font_name}' at size {title_font_size} not found. Please ensure Montserrat ExtraBold is installed and accessible.") 131 | # Attempt a very basic fallback if the specified font isn't found 132 | try: 133 | title_font = ImageFont.load_default() # Very basic, might not look good 134 | print("Warning: Using default Pillow font due to error with Montserrat-ExtraBold.") 135 | except Exception as e_font_fallback: 136 | print(f"Error loading default font: {e_font_fallback}. Cannot draw title.") 137 | return 138 | 139 | draw = ImageDraw.Draw(story_template) 140 | 141 | if os.path.exists(community_logo_path): 142 | community_logo_img = Image.open(community_logo_path) 143 | elif os.path.exists(default_community_logo_path): 144 | community_logo_img = Image.open(default_community_logo_path) 145 | else: 146 | print(f"Warning: Default community logo not found at {default_community_logo_path}. Skipping logo.") 147 | community_logo_img = None 148 | 149 | if community_logo_img: 150 | community_logo_img = community_logo_img.resize((244, 244)) 151 | story_template.paste(community_logo_img, (222, 368), mask=community_logo_img if community_logo_img.mode == 'RGBA' else None) 152 | 153 | # --- Text Wrapping and Drawing for Title --- 154 | # Using a simpler wrap_text logic for now. Pillow's TextWrapper is not in older versions. 155 | # For complex cases, a more sophisticated text engine or manual line breaking based on draw.textbbox is better. 156 | 157 | # Simple line wrapper (based on character count per line, then join - from original code) 158 | # This is less accurate than width-based wrapping. We'll try to improve. 159 | # characters_to_linebreak = int(title_box_width / (title_font_size * 0.45)) # Rough estimate of chars per line 160 | # if characters_to_linebreak == 0: characters_to_linebreak = 10 # Avoid zero div / too small 161 | # chunks = [] 162 | # current_line_start = 0 163 | # while current_line_start < len(submission_title): 164 | # line_end = current_line_start + characters_to_linebreak 165 | # if line_end < len(submission_title): 166 | # # Try to break at a space 167 | # actual_line_end = submission_title.rfind(' ', current_line_start, line_end + 1) 168 | # if actual_line_end == -1 or actual_line_end < current_line_start: # No space found, or space is before start 169 | # actual_line_end = line_end # Break mid-word if no space 170 | # else: 171 | # actual_line_end = len(submission_title) 172 | # chunks.append(submission_title[current_line_start:actual_line_end].strip()) 173 | # current_line_start = actual_line_end + 1 # Skip the space if broken at space 174 | # wrapped_title_text = '\n'.join(chunks) 175 | 176 | # Improved wrapping using the helper (still basic, assumes `font.getlength` works) 177 | wrapped_lines = wrap_text(submission_title, title_font, title_box_width) 178 | wrapped_title_text = '\n'.join(wrapped_lines) 179 | 180 | # If you have Pillow 9.2.0+ you can use multiline_textbbox for better vertical centering and fit checking. 181 | # For now, we draw at top of box and rely on fixed line spacing. 182 | # Anchor 'lt' means top-left for the text block. 183 | # To center within the box, more calculations are needed (get text block height, then offset top). 184 | 185 | # Get the bounding box of the wrapped text to help with centering (optional, but good for alignment) 186 | try: 187 | # text_bbox for Pillow 9.2.0+ 188 | # For older versions, this specific call might not exist or behave differently. 189 | # draw.multiline_textbbox((0,0), wrapped_title_text, font=title_font, spacing=4) # spacing is line spacing 190 | # If using an older Pillow, one might have to render to a dummy image to get size or sum line heights. 191 | # For now, let's use a default spacing and align top-left in the box. 192 | text_x = title_box_left 193 | text_y = title_box_top 194 | line_spacing = 10 # Adjust as needed for the chosen font size 195 | 196 | # Vertical centering: (needs text block height) 197 | #_, _, _, text_block_bottom = draw.multiline_textbbox((text_x, text_y), wrapped_title_text, font=title_font, spacing=line_spacing) 198 | #text_block_height = text_block_bottom - text_y # This is not quite right, text_y is not 0 for bbox calc relative to (0,0) 199 | # A better way: sum heights or use bbox with (0,0) as origin 200 | # For simplicity, let's just draw from the top of the box for now and adjust line spacing. 201 | # True vertical centering within the box title_box_height would involve: 202 | # 1. Get total height of wrapped_title_text with chosen font and spacing. 203 | # 2. text_y = title_box_top + (title_box_height - total_text_height) / 2 204 | 205 | draw.multiline_text( 206 | (text_x, text_y), 207 | wrapped_title_text, 208 | fill=(35, 31, 32, 255), # Black, fully opaque 209 | font=title_font, 210 | spacing=line_spacing, # Line spacing 211 | align='left' # 'left', 'center', or 'right' (horizontal alignment of lines) 212 | ) 213 | print(f"Title drawn in box ({title_box_left},{title_box_top})-({title_box_right},{title_box_bottom}) with font size {title_font_size}.") 214 | 215 | except Exception as e_draw: 216 | print(f"Error drawing multiline text for title: {e_draw}") 217 | 218 | # Since the template now provides all other UI elements (r/AITA, scores, etc.), 219 | # we remove the script's logic for drawing those. 220 | # The original code for drawing author, timestamp, scores, community logo is now removed. 221 | 222 | try: 223 | story_template.save(submission_image_path) 224 | print(f"Submission image saved to: {submission_image_path}") 225 | except Exception as e: 226 | print(f"Error saving submission image: {e}") 227 | 228 | # story_template.show() # Keep commented out unless debugging 229 | -------------------------------------------------------------------------------- /reddit_shorts/make_tts.py: -------------------------------------------------------------------------------- 1 | import os 2 | # import ssl # No longer needed if not using tiktok_tts which might have its own ssl context needs 3 | 4 | # from dotenv import load_dotenv # Not needed for gTTS 5 | from gtts import gTTS 6 | import shutil # For ensuring temp directories exist 7 | 8 | # from reddit_shorts.config import project_path # We'll use a passed-in temp_dir 9 | # from reddit_shorts.tiktok_tts import tts # Replacing this 10 | # from reddit_shorts.utils import tts_for_platform # Replacing this logic 11 | 12 | # ssl._create_default_https_context = ssl._create_unverified_context # Likely not needed for gTTS 13 | 14 | # load_dotenv() # Not needed 15 | # tiktok_session_id = os.environ['TIKTOK_SESSION_ID_TTS'] # Not needed 16 | 17 | # Import the tts function from the existing tiktok_tts module 18 | # from reddit_shorts.tiktok_tts import tts as tiktok_tts_api 19 | 20 | # Import from the new library copied into reddit_shorts/tiktok_voice 21 | # The actual tts function and Voice enum are in tiktok_voice.src 22 | from reddit_shorts.tiktok_voice.src.text_to_speech import tts as tiktok_library_tts 23 | from reddit_shorts.tiktok_voice.src.voice import Voice 24 | 25 | # Default voice mapping to the new library's enum 26 | # Voice.US_FEMALE_2 maps to 'en_us_002' 27 | DEFAULT_TIKTOK_VOICE_ENUM = Voice.US_FEMALE_2 28 | 29 | def generate_gtts_for_story(title: str, text_content: str, story_id: str, temp_dir: str) -> dict: 30 | """ 31 | Generates TTS audio for title and content using gTTS and saves them to the specified temp_dir. 32 | Returns a dictionary with paths to the generated TTS files. 33 | """ 34 | if not title and not text_content: 35 | print("Error: Both title and text content are empty. Cannot generate TTS.") 36 | return {'title_tts_path': None, 'content_tts_path': None} 37 | 38 | # Ensure the specific temporary directory for this story's TTS exists 39 | # temp_dir is expected to be something like /path/to/project/temp// 40 | os.makedirs(temp_dir, exist_ok=True) 41 | 42 | title_tts_path = None 43 | content_tts_path = None 44 | lang = 'en' # Language for TTS 45 | 46 | try: 47 | # TTS for Title 48 | if title: 49 | title_tts_filename = f"title_{story_id}.mp3" 50 | title_tts_path = os.path.join(temp_dir, title_tts_filename) 51 | tts_obj = gTTS(text=title, lang=lang, slow=False) 52 | tts_obj.save(title_tts_path) 53 | print(f"Title TTS generated: {title_tts_path}") 54 | else: 55 | print("Title is empty, skipping title TTS generation.") 56 | 57 | # TTS for Content 58 | if text_content: 59 | # gTTS might have issues with very long texts in one go. 60 | # It's better to split if necessary, but for now, let's try direct. 61 | # Max length for gTTS is not strictly defined but practically, very long texts can fail or be slow. 62 | # Consider splitting text_content into chunks if issues arise. 63 | if len(text_content) > 4000: # Arbitrary limit, gTTS docs don't specify hard limit 64 | print(f"Warning: Content text is very long ({len(text_content)} chars). TTS might be slow or fail.") 65 | 66 | content_tts_filename = f"content_{story_id}.mp3" 67 | content_tts_path = os.path.join(temp_dir, content_tts_filename) 68 | tts_obj = gTTS(text=text_content, lang=lang, slow=False) 69 | tts_obj.save(content_tts_path) 70 | print(f"Content TTS generated: {content_tts_path}") 71 | else: 72 | print("Content text is empty, skipping content TTS generation.") 73 | 74 | except Exception as e: 75 | print(f"Error during gTTS generation: {e}") 76 | # Return None for paths if an error occurs to prevent downstream issues 77 | # It's possible one succeeded and the other failed. 78 | if title_tts_path and not os.path.exists(title_tts_path): 79 | title_tts_path = None 80 | if content_tts_path and not os.path.exists(content_tts_path): 81 | content_tts_path = None 82 | # No need to return partial success, create_short can handle missing files by skipping them. 83 | 84 | return { 85 | 'video_tts_path': title_tts_path, # Matching key expected by create_short_video (originally narrator_title_track) 86 | 'content_tts_path': content_tts_path # Matching key expected by create_short_video (originally narrator_content_track) 87 | } 88 | 89 | def generate_tiktok_tts_for_story(title: str, text_content: str, story_id: str, temp_dir: str, **kwargs) -> dict: 90 | """ 91 | Generates TTS audio for title and content using the mark-rez/TikTok-Voice-TTS library 92 | and saves them to the specified temp_dir. 93 | Accepts an optional 'voice' kwarg for the voice code (e.g., 'en_us_002'). 94 | Returns a dictionary with paths to the generated TTS files. 95 | """ 96 | generated_paths = {'video_tts_path': None, 'content_tts_path': None} 97 | selected_voice_code = kwargs.get('voice', None) 98 | 99 | active_voice_enum = DEFAULT_TIKTOK_VOICE_ENUM 100 | if selected_voice_code: 101 | try: 102 | # Attempt to get the Voice enum member by its value (the voice code string) 103 | active_voice_enum = Voice(selected_voice_code) 104 | print(f"Using selected voice: {selected_voice_code} ({active_voice_enum.name})") 105 | except ValueError: 106 | print(f"Warning: Invalid voice code '{selected_voice_code}' provided. Falling back to default voice {DEFAULT_TIKTOK_VOICE_ENUM.value}.") 107 | # active_voice_enum remains DEFAULT_TIKTOK_VOICE_ENUM 108 | 109 | if not title and not text_content: 110 | print("Error: Both title and text content are empty. Cannot generate TTS.") 111 | return generated_paths 112 | 113 | # Ensure the temporary directory for this story exists 114 | os.makedirs(temp_dir, exist_ok=True) 115 | 116 | # --- Generate TTS for Title --- 117 | if title: 118 | title_tts_filename = f"title_{story_id}.mp3" 119 | title_tts_path = os.path.join(temp_dir, title_tts_filename) 120 | print(f"Generating TikTok TTS for title using new library: {title[:50]}...") 121 | try: 122 | tiktok_library_tts( 123 | text=title, 124 | voice=active_voice_enum, 125 | output_file_path=title_tts_path, 126 | play_sound=False 127 | ) 128 | if os.path.exists(title_tts_path) and os.path.getsize(title_tts_path) > 0: 129 | generated_paths['video_tts_path'] = title_tts_path 130 | print(f"Title TTS successfully generated: {title_tts_path}") 131 | else: 132 | print(f"Error: Title TTS file not generated or empty by new library. Path: {title_tts_path}") 133 | except Exception as e: 134 | print(f"Error during title TTS generation with new library: {e}") 135 | import traceback 136 | traceback.print_exc() 137 | else: 138 | print("Title is empty, skipping title TTS generation.") 139 | 140 | # --- Generate TTS for Content --- 141 | if text_content: 142 | content_tts_filename = f"content_{story_id}.mp3" 143 | content_tts_path = os.path.join(temp_dir, content_tts_filename) 144 | print(f"Generating TikTok TTS for content using new library (first 50 chars): {text_content[:50]}...") 145 | try: 146 | # For content, the library handles splitting long text internally 147 | tiktok_library_tts( 148 | text=text_content, 149 | voice=active_voice_enum, 150 | output_file_path=content_tts_path, 151 | play_sound=False 152 | ) 153 | if os.path.exists(content_tts_path) and os.path.getsize(content_tts_path) > 0: 154 | generated_paths['content_tts_path'] = content_tts_path 155 | print(f"Content TTS successfully generated: {content_tts_path}") 156 | else: 157 | print(f"Error: Content TTS file not generated or empty by new library. Path: {content_tts_path}") 158 | except Exception as e: 159 | print(f"Error during content TTS generation with new library: {e}") 160 | import traceback 161 | traceback.print_exc() 162 | else: 163 | print("Text content is empty, skipping content TTS generation.") 164 | 165 | return generated_paths 166 | 167 | # Example usage (for testing this file directly) 168 | if __name__ == '__main__': 169 | print("Testing gTTS generation...") 170 | # Create a dummy temp directory for testing 171 | test_story_id = "test_gtts_001" 172 | # project_root_for_test = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) 173 | # test_temp_dir = os.path.join(project_root_for_test, "temp", test_story_id, "tts") 174 | 175 | # Simpler temp dir for direct script test relative to script location 176 | script_dir = os.path.dirname(os.path.abspath(__file__)) 177 | test_temp_root = os.path.join(script_dir, "..", "temp") # e.g., project_root/temp/ 178 | test_temp_story_dir = os.path.join(test_temp_root, test_story_id) # e.g., project_root/temp/test_gtts_001/ 179 | 180 | # Ensure base temp directory exists (temp/) 181 | # os.makedirs(os.path.dirname(test_temp_dir), exist_ok=True) 182 | # os.makedirs(test_temp_dir, exist_ok=True) 183 | # This generate_gtts_for_story will create the final subdir if needed 184 | 185 | print(f"TTS files will be saved in a subdirectory of: {test_temp_story_dir}") 186 | 187 | sample_title = "Hello World from gTTS" 188 | sample_content = "This is a test of the Google Text-to-Speech library. It should generate an MP3 file with this text spoken." 189 | 190 | tts_paths = generate_gtts_for_story(sample_title, sample_content, test_story_id, test_temp_story_dir) 191 | 192 | if tts_paths.get('video_tts_path') and os.path.exists(tts_paths['video_tts_path']): 193 | print(f"Title TTS successfully created at: {tts_paths['video_tts_path']}") 194 | else: 195 | print("Title TTS creation failed or file not found.") 196 | 197 | if tts_paths.get('content_tts_path') and os.path.exists(tts_paths['content_tts_path']): 198 | print(f"Content TTS successfully created at: {tts_paths['content_tts_path']}") 199 | else: 200 | print("Content TTS creation failed or file not found.") 201 | 202 | # Clean up dummy test directory if you want 203 | # if os.path.exists(test_temp_dir): 204 | # shutil.rmtree(test_temp_dir) 205 | # print(f"Cleaned up test directory: {test_temp_dir}") 206 | # if os.path.exists(os.path.dirname(test_temp_dir)) and not os.listdir(os.path.dirname(test_temp_dir)): 207 | # os.rmdir(os.path.dirname(test_temp_dir)) 208 | # if os.path.exists(os.path.dirname(os.path.dirname(test_temp_dir))) and not os.listdir(os.path.dirname(os.path.dirname(test_temp_dir))): 209 | # os.rmdir(os.path.dirname(os.path.dirname(test_temp_dir))) # clean up temp/ if empty 210 | print(f"If successful, check the subdirectory within {test_temp_story_dir} for MP3 files.") 211 | 212 | print("Testing new generate_tiktok_tts_for_story function...") 213 | 214 | # Create a dummy temp directory for testing 215 | test_story_id = "test_story_001" 216 | test_temp_dir_base = "temp" # Assuming script is run from project root where 'temp' would be 217 | test_temp_dir_story = os.path.join(test_temp_dir_base, test_story_id, "tts_test_output") # More specific path 218 | 219 | # Ensure the base temp directory exists for the test 220 | if not os.path.exists(test_temp_dir_base): 221 | os.makedirs(test_temp_dir_base) 222 | if not os.path.exists(os.path.join(test_temp_dir_base, test_story_id)): 223 | os.makedirs(os.path.join(test_temp_dir_base, test_story_id)) 224 | 225 | 226 | print(f"Test temporary directory will be: {test_temp_dir_story}") 227 | # Clean up previous test directory if it exists 228 | if os.path.exists(test_temp_dir_story): 229 | shutil.rmtree(test_temp_dir_story) 230 | os.makedirs(test_temp_dir_story, exist_ok=True) 231 | 232 | sample_title = "Hello World from New Library" 233 | sample_content = "This is a test of the newly integrated TikTok TTS library. It should handle this text and save it as an MP3 audio file." 234 | 235 | results = generate_tiktok_tts_for_story( 236 | title=sample_title, 237 | text_content=sample_content, 238 | story_id=test_story_id, 239 | temp_dir=test_temp_dir_story 240 | ) 241 | 242 | print("\n--- Test Results ---") 243 | if results.get('video_tts_path') and os.path.exists(results['video_tts_path']): 244 | print(f"Title TTS Path: {results['video_tts_path']} (Exists: True, Size: {os.path.getsize(results['video_tts_path'])})") 245 | else: 246 | print(f"Title TTS generation FAILED. Path: {results.get('video_tts_path')}") 247 | 248 | if results.get('content_tts_path') and os.path.exists(results['content_tts_path']): 249 | print(f"Content TTS Path: {results['content_tts_path']} (Exists: True, Size: {os.path.getsize(results['content_tts_path'])})") 250 | else: 251 | print(f"Content TTS generation FAILED. Path: {results.get('content_tts_path')}") 252 | 253 | # Suggest cleanup 254 | print(f"\nTest files were saved in: {test_temp_dir_story}") 255 | print("You may want to manually delete this directory after inspection.") 256 | # For automated cleanup, you could add: 257 | # if os.path.exists(test_temp_dir_story): 258 | # shutil.rmtree(test_temp_dir_story) 259 | # print(f"Cleaned up test directory: {test_temp_dir_story}") 260 | -------------------------------------------------------------------------------- /web_ui/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Video Generator 7 | 8 | 9 | 10 | 11 | 12 |
13 |

Let's configure your video

14 | 15 | 16 |
17 |

Your Video Script

18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 | 26 |
27 |
28 | Number of words: {{ wordCount }} 29 |
30 | Estimated video duration: {{ estimatedDuration }} seconds 31 |
32 |
33 |
34 | 35 | 36 |
37 |

Select a Background Video

38 |
39 |
44 | 45 |
46 | 48 |
49 |

{{ video.name }}

50 |
51 |
52 |
53 |
54 |
55 | 56 | 57 |
58 |

Select Voice

59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {/* Slightly different bg for selected row */} 73 | 74 | 75 | 76 | 82 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 |
NameCodePreviewAction
{{ voice.name }}{{ voice.id }} 77 | 81 | 83 | 87 |
No voices on this page.
No voices loaded.
97 |
98 | 99 |
100 |
101 |

102 | Page {{ currentPage }} of {{ totalPages }} 103 | (Total: {{ allVoices.length }} voices) 104 |

105 |
106 |
107 | 111 | 115 |
116 |
117 |
118 | 119 | 120 |
121 |

Select Background Music

122 |
123 |
128 |
129 |

{{ track.name }}

130 |

Type: {{ track.type }}

131 |
132 | 136 |
137 |
138 |
139 | 140 | 141 |
142 |

Advanced options

143 |
144 | 148 |
149 |
150 | 151 | 152 | 157 | 158 |

159 | {{ usageCount }} people used this tool in the last 24h 160 |

161 |
162 | 163 | 303 | 304 | -------------------------------------------------------------------------------- /reddit_shorts/create_short.py: -------------------------------------------------------------------------------- 1 | import os 2 | import whisper 3 | import math 4 | import random 5 | import ffmpeg 6 | import shutil # For cleaning up temp directories 7 | 8 | from whisper.utils import get_writer 9 | 10 | # project_path is derived in config.py and used for temp paths if not overridden 11 | from reddit_shorts.config import project_path as default_project_path, footage, music, output_video_path as global_output_video_path 12 | from reddit_shorts.utils import random_choice_music 13 | 14 | 15 | def get_audio_duration(track: str) -> float: # Changed to accept path directly 16 | if not track or not os.path.exists(track): 17 | # print(f"Warning: Audio track {track} not found or is None. Returning 0 duration.") 18 | return 0.0 19 | try: 20 | probe = ffmpeg.probe(track) 21 | audio_info = next(s for s in probe['streams'] if s['codec_type'] == 'audio') 22 | duration = float(audio_info['duration']) 23 | return duration 24 | except Exception as e: 25 | print(f"Error probing audio duration for {track}: {e}") 26 | return 0.0 27 | 28 | 29 | def get_video_duration(track: str) -> float: # Changed to accept path directly 30 | if not track or not os.path.exists(track): 31 | print(f"Warning: Video track {track} not found. Returning 0 duration.") 32 | return 0.0 33 | try: 34 | probe = ffmpeg.probe(track) 35 | video_info = next(stream for stream in probe['streams'] if stream['codec_type'] == 'video') 36 | duration = float(video_info['duration']) 37 | return duration 38 | except Exception as e: 39 | print(f"Error probing video duration for {track}: {e}") 40 | return 0.0 41 | 42 | 43 | def create_short_video(**kwargs) -> str | None: 44 | story_id = kwargs.get('id') 45 | story_title = kwargs.get('title') # Expecting title from submission_data 46 | # submission_text = kwargs.get('selftext') # Now passed as narrator_content_track audio 47 | subreddit_music_type = kwargs.get('music_type', 'general') # Default to general 48 | 49 | # TTS tracks for title and content - provided by a preceding step (e.g. modified make_tts.py) 50 | narrator_title_track_path = kwargs.get('video_tts_path') # Path to title TTS audio file 51 | narrator_content_track_path = kwargs.get('content_tts_path') # Path to content (selftext) TTS audio file 52 | 53 | # Commentor and platform TTS are removed as per our simplification 54 | # commentor_track_path = kwargs.get('commentor_track') 55 | # platform_tts_track_path = kwargs.get('platform_track') 56 | 57 | output_dir = kwargs.get('output_dir', global_output_video_path) # Get from kwargs or global config 58 | os.makedirs(output_dir, exist_ok=True) 59 | 60 | # Define a temporary directory for this specific video's processing files 61 | # This helps in organization and cleanup. 62 | # Use default_project_path (imported from config) for the root of temp, or make it relative to output_dir 63 | temp_processing_dir = os.path.join(default_project_path, "temp", story_id if story_id else "temp_video") 64 | os.makedirs(temp_processing_dir, exist_ok=True) 65 | 66 | # submission_image path needs to use story_id (or title if id is not reliable yet from image gen) 67 | # Assuming make_submission_image.py saves based on ID now. 68 | submission_image_filename = f"{story_id}.png" if story_id else "story_image.png" 69 | submission_image_path = os.path.join(default_project_path, "temp", "images", submission_image_filename) 70 | 71 | short_file_path = os.path.join(output_dir, f"{story_id if story_id else 'short'}.mp4") 72 | tts_combined_path = os.path.join(temp_processing_dir, "combined.mp3") 73 | music_looped_path = os.path.join(temp_processing_dir, "music_looped.mp3") 74 | processed_music_path = os.path.join(temp_processing_dir, "music_processed.mp3") 75 | mixed_audio_file_path = os.path.join(temp_processing_dir, 'mixed_audio.mp3') 76 | # srt_filename = f"{story_id if story_id else 'combined'}.srt" 77 | # tts_combined_srt_path = os.path.join(temp_processing_dir, srt_filename) 78 | # Using a fixed name for SRT within its temp dir for Whisper, will be specific to this run. 79 | tts_combined_srt_path = os.path.join(temp_processing_dir, "subtitles.srt") 80 | 81 | # --- Background Video Selection --- 82 | selected_video_path_from_kwargs = kwargs.get('background_video') # Get the path from UI 83 | video_to_use = None 84 | 85 | if selected_video_path_from_kwargs and os.path.exists(selected_video_path_from_kwargs): 86 | video_to_use = selected_video_path_from_kwargs 87 | print(f"Using selected background video: {video_to_use}") 88 | elif footage: # Fallback to random if selection is invalid or not provided 89 | print(f"Warning: Selected background video '{selected_video_path_from_kwargs}' not found, not provided, or invalid. Using a random video from config.") 90 | video_to_use = random.choice(footage) 91 | print(f"Randomly selected background video: {video_to_use}") 92 | else: 93 | print("Error: No background footage available (neither selected nor in config). Please check config and resources directory.") 94 | shutil.rmtree(temp_processing_dir, ignore_errors=True) 95 | return None 96 | # --- End Background Video Selection --- 97 | 98 | if not music: 99 | print("Warning: No music available. Video will be created without music.") 100 | resource_music_link = None 101 | resource_music_volume = 0.0 102 | else: 103 | resource_music_link, resource_music_volume = random_choice_music(music, subreddit_music_type) 104 | 105 | # --- Start Audio Processing --- 106 | audio_segments = [] 107 | space_between_tts = 0.5 # seconds 108 | current_total_duration = 0.0 109 | 110 | if narrator_title_track_path and os.path.exists(narrator_title_track_path): 111 | audio_segments.append(ffmpeg.input(narrator_title_track_path)) 112 | current_total_duration += get_audio_duration(narrator_title_track_path) 113 | # Add silence after title if content follows 114 | if narrator_content_track_path and os.path.exists(narrator_content_track_path): 115 | audio_segments.append(ffmpeg.input(f'aevalsrc=0:d={space_between_tts}', f='lavfi')) 116 | current_total_duration += space_between_tts 117 | else: 118 | print("Warning: Narrator title track not found or not provided.") 119 | 120 | if narrator_content_track_path and os.path.exists(narrator_content_track_path): 121 | audio_segments.append(ffmpeg.input(narrator_content_track_path)) 122 | current_total_duration += get_audio_duration(narrator_content_track_path) 123 | else: 124 | print("Warning: Narrator content track not found or not provided.") 125 | 126 | if not audio_segments: 127 | print("Error: No valid TTS audio tracks provided for title or content. Cannot create video.") 128 | shutil.rmtree(temp_processing_dir, ignore_errors=True) # Clean up temp dir 129 | return None 130 | 131 | # Concatenate available TTS audio segments 132 | try: 133 | concat_filter = ffmpeg.concat(*audio_segments, v=0, a=1).node 134 | (ffmpeg 135 | .output(concat_filter[0], tts_combined_path) 136 | .run(overwrite_output=True, quiet=True) 137 | ) 138 | except Exception as e: 139 | print(f"Error concatenating TTS audio: {e}") 140 | shutil.rmtree(temp_processing_dir, ignore_errors=True) 141 | return None 142 | # --- End Audio Processing --- 143 | 144 | soundduration = get_audio_duration(tts_combined_path) # This is the duration of combined narration 145 | if soundduration == 0.0: 146 | print("Error: Combined TTS audio has zero duration. Cannot proceed.") 147 | shutil.rmtree(temp_processing_dir, ignore_errors=True) 148 | return None 149 | 150 | # Whisper transcription for subtitles 151 | try: 152 | print("Starting Whisper transcription for subtitles...") 153 | writer_options = {"max_line_count": 1, "max_words_per_line": 1} 154 | whisper_model = whisper.load_model("tiny.en", device="cpu") # Consider base or small for better accuracy if tiny is too basic 155 | tts_combined_transcribed = whisper_model.transcribe(tts_combined_path, language="en", fp16=False, word_timestamps=True, task="transcribe") 156 | srt_writer = get_writer("srt", os.path.dirname(tts_combined_srt_path)) # Pass directory to writer 157 | srt_writer(tts_combined_transcribed, os.path.basename(tts_combined_srt_path), writer_options) 158 | print(f"Subtitles generated: {tts_combined_srt_path}") 159 | if not os.path.exists(tts_combined_srt_path): 160 | print("Warning: SRT file was not created by Whisper.") 161 | # Fallback: create an empty SRT file to prevent ffmpeg error if subtitles are mandatory in filter graph 162 | with open(tts_combined_srt_path, 'w') as f: 163 | f.write("") 164 | except Exception as e: 165 | print(f"Error during Whisper transcription: {e}. Subtitles might be missing.") 166 | # Fallback: create an empty SRT file 167 | with open(tts_combined_srt_path, 'w') as f: 168 | f.write("") 169 | 170 | # Background Music Processing 171 | if resource_music_link and os.path.exists(resource_music_link): 172 | music_track_input = ffmpeg.input(resource_music_link) 173 | music_duration = get_audio_duration(resource_music_link) 174 | 175 | if music_duration > 0 and soundduration > 0: 176 | if music_duration < soundduration: 177 | loops = int(soundduration / music_duration) + 1 178 | # crossfade_duration = min(10, music_duration / 2) # Ensure crossfade is not too long for short music 179 | crossfade_duration = 5 180 | 181 | looped_inputs = [ffmpeg.input(resource_music_link) for _ in range(loops)] 182 | try: 183 | (ffmpeg 184 | .filter(looped_inputs, 'acrossfade', d=str(crossfade_duration), c1='tri', c2='tri') 185 | .output(music_looped_path) 186 | .run(overwrite_output=True, quiet=True) 187 | ) 188 | music_source_for_processing = music_looped_path 189 | except Exception as e: 190 | print(f"Error looping music with acrossfade: {e}. Using original music track.") 191 | music_source_for_processing = resource_music_link # Fallback to original if looping fails 192 | else: 193 | music_source_for_processing = resource_music_link 194 | 195 | try: 196 | (ffmpeg 197 | .input(music_source_for_processing) 198 | .filter('atrim', start=0, end=soundduration) 199 | .filter('volume', resource_music_volume) 200 | .filter('afade', t='out', st=max(0, soundduration-5), d=5) 201 | .output(processed_music_path) 202 | .run(overwrite_output=True, quiet=True) 203 | ) 204 | except Exception as e: 205 | print(f"Error processing music: {e}. Continuing without music.") 206 | processed_music_path = None # No music if processing fails 207 | else: 208 | print("Music duration or sound duration is zero. Skipping music processing.") 209 | processed_music_path = None 210 | else: 211 | print("No music link provided or file does not exist. Skipping music processing.") 212 | processed_music_path = None 213 | 214 | # Mixing TTS with Background Music (if music exists) 215 | narration_audio_stream = ffmpeg.input(tts_combined_path) 216 | if processed_music_path and os.path.exists(processed_music_path): 217 | background_music_stream = ffmpeg.input(processed_music_path) 218 | try: 219 | (ffmpeg 220 | .filter([narration_audio_stream, background_music_stream], 'amix', inputs=2, duration='first', dropout_transition=str(soundduration)) 221 | .output(mixed_audio_file_path) 222 | .run(overwrite_output=True, quiet=True) 223 | ) 224 | final_audio_stream_for_video = ffmpeg.input(mixed_audio_file_path) 225 | except Exception as e: 226 | print(f"Error mixing narration and music: {e}. Using narration only.") 227 | final_audio_stream_for_video = narration_audio_stream # Fallback to narration only 228 | else: 229 | print("Processed music path not available. Using narration audio only for video.") 230 | # shutil.copy(tts_combined_path, mixed_audio_file_path) # If mixed_audio_file_path is expected later 231 | final_audio_stream_for_video = narration_audio_stream 232 | 233 | # Video Processing 234 | if not os.path.exists(video_to_use): # Check video_to_use instead of random_video_path 235 | print(f"Error: Selected background video {video_to_use} not found.") 236 | shutil.rmtree(temp_processing_dir, ignore_errors=True) 237 | return None 238 | 239 | video_duration = get_video_duration(video_to_use) # Use video_to_use 240 | video_input_options = {} 241 | 242 | if video_duration == 0.0: 243 | print(f"Error: Background video {video_to_use} has zero duration. Cannot use this video.") # Use video_to_use 244 | shutil.rmtree(temp_processing_dir, ignore_errors=True) 245 | return None 246 | 247 | if soundduration > video_duration: 248 | print(f"Narration duration ({soundduration:.2f}s) is longer than background video ({video_duration:.2f}s). Looping video.") 249 | # Pick a random start point within the original video's duration for the first segment 250 | start_ss = random.uniform(0, video_duration) if video_duration > 0 else 0 251 | video_input_options['ss'] = f"{start_ss:.4f}" # Format to string with precision 252 | video_input_options['stream_loop'] = -1 # Loop indefinitely 253 | video_input_options['t'] = f"{soundduration:.4f}" # Trim the looped stream to soundduration 254 | else: 255 | # Narration is shorter or equal to video duration, pick a random segment 256 | max_start_point = video_duration - soundduration 257 | start_ss = random.uniform(0, max_start_point) if max_start_point > 0 else 0 258 | video_input_options['ss'] = f"{start_ss:.4f}" 259 | video_input_options['t'] = f"{soundduration:.4f}" 260 | 261 | resource_video_clipped = ( 262 | ffmpeg 263 | .input(video_to_use, **video_input_options) # Use video_to_use 264 | .crop(x='(iw-ow)/2', y='(ih-oh)/2', width='ih*9/16', height='ih') 265 | .filter('scale', width=1080, height=1920) 266 | .filter('setpts', 'PTS-STARTPTS') # Reset timestamps after trimming/looping 267 | ) 268 | 269 | # Image Overlay 270 | # Determine title display duration - should be duration of title TTS if available 271 | title_tts_duration = get_audio_duration(narrator_title_track_path) if narrator_title_track_path else 0 272 | # If no title TTS, maybe show for a fixed short duration, or not at all. 273 | # For now, if title_tts_duration is 0, overlay won't show based on 'between(t,0,{title_tts_duration})' 274 | # which is fine. Or set a minimum (e.g. 2-3s) if title_track_path is None but image exists. 275 | if not os.path.exists(submission_image_path): 276 | print(f"Warning: Submission image {submission_image_path} not found. Video will not have image overlay.") 277 | overlay_stream = None 278 | else: 279 | overlay_stream = ( 280 | ffmpeg 281 | .input(submission_image_path) 282 | .filter('scale', w='min(1000,iw)', h='-1') # Scale image, limit width to 1000px 283 | ) 284 | 285 | # Main video and audio assembly 286 | output_options = { 287 | 'c:v': 'libx264', 288 | 'preset': 'medium', # Slower for better compression/quality, or 'fast'/'ultrafast' for speed 289 | 'crf': '23', # Constant Rate Factor (18-28 is typical, lower is better quality) 290 | 'c:a': 'aac', 291 | 'b:a': '192k', 292 | 'movflags': '+faststart' # Good for web video 293 | } 294 | 295 | # Filter graph construction 296 | video_with_audio = ffmpeg.concat(resource_video_clipped, final_audio_stream_for_video, v=1, a=1).node 297 | main_stream = video_with_audio[0] 298 | audio_stream_node = video_with_audio[1] 299 | 300 | # Apply fade out to video and audio if possible 301 | main_stream = ffmpeg.filter(main_stream, 'fade', type='out', start_time=max(0, soundduration-3), duration=3) 302 | # audio_stream_node = ffmpeg.filter(audio_stream_node, 'afade', type='out', start_time=max(0, soundduration-3), duration=3) 303 | # Note: Fading audio here might be redundant if already faded in music processing and narration ends cleanly. 304 | # If final_audio_stream_for_video is directly from narration_audio_stream (no music), an afade here would be good. 305 | if not (processed_music_path and os.path.exists(processed_music_path)): 306 | audio_stream_node = ffmpeg.filter(audio_stream_node, 'afade', type='out', start_time=max(0, soundduration-3), duration=3) 307 | 308 | if overlay_stream and title_tts_duration > 0: 309 | main_stream = ffmpeg.overlay(main_stream, overlay_stream, x='(W-w)/2', y='(H-h)/3', enable=f'between(t,0,{title_tts_duration})') 310 | elif overlay_stream: # If title TTS is 0 but image exists, maybe show for a fixed short time? For now, it won't show. 311 | print("Title TTS duration is 0 or title track not found, image overlay based on title duration might not show.") 312 | # Example: Show for first 3 seconds if no title TTS: enable='between(t,0,3)' 313 | # main_stream = ffmpeg.overlay(main_stream, overlay_stream, x='(W-w)/2', y='(H-h)/3', enable='between(t,0,3)') 314 | 315 | if os.path.exists(tts_combined_srt_path) and os.path.getsize(tts_combined_srt_path) > 0: 316 | main_stream = ffmpeg.filter( 317 | main_stream, 318 | 'subtitles', 319 | filename=tts_combined_srt_path, 320 | force_style=f'''MarginV=60,Bold=-1,Fontname=Montserrat ExtraBold,Fontsize=36,OutlineColour=&HFF000000,BorderStyle=1,Outline=2,Shadow=2,ShadowColour=&HAA000000''' 321 | ) 322 | else: 323 | print("SRT file is missing or empty. Skipping subtitles filter.") 324 | 325 | try: 326 | (ffmpeg 327 | .output(main_stream, audio_stream_node, short_file_path, **output_options) 328 | .run(overwrite_output=True, quiet=False) # Set quiet=False for more ffmpeg output if debugging 329 | ) 330 | print(f"Video processing complete. Output: {short_file_path}") 331 | except ffmpeg.Error as e: 332 | print(f"FFmpeg Error during final video assembly: {e.stderr.decode('utf8') if e.stderr else 'Unknown FFmpeg error'}") 333 | shutil.rmtree(temp_processing_dir, ignore_errors=True) # Clean up 334 | return None 335 | except Exception as e: 336 | print(f"Generic error during final video assembly: {e}") 337 | shutil.rmtree(temp_processing_dir, ignore_errors=True) # Clean up 338 | return None 339 | finally: 340 | # Clean up temporary processing directory 341 | shutil.rmtree(temp_processing_dir, ignore_errors=True) 342 | print(f"Temporary processing directory {temp_processing_dir} cleaned up.") 343 | 344 | return short_file_path 345 | 346 | 347 | # Example of how it might be called if you had TTS paths: 348 | # if __name__ == '__main__': 349 | # # Create dummy TTS files for testing 350 | # os.makedirs("temp_tts", exist_ok=True) 351 | # dummy_title_tts = "temp_tts/title.mp3" 352 | # dummy_content_tts = "temp_tts/content.mp3" 353 | # # You'd use a real TTS engine to create these from text 354 | # # For now, just creating silent placeholders if you run this directly 355 | # try: 356 | # ffmpeg.input('aevalsrc=0:d=2', f='lavfi').output(dummy_title_tts).run(overwrite_output=True, quiet=True) 357 | # ffmpeg.input('aevalsrc=0:d=10', f='lavfi').output(dummy_content_tts).run(overwrite_output=True, quiet=True) 358 | # except Exception as e: 359 | # print(f"Could not create dummy tts files: {e}") 360 | 361 | # test_kwargs = { 362 | # 'id': 'test_story_001', 363 | # 'title': 'My Test Story Title', 364 | # 'music_type': 'general', 365 | # 'video_tts_path': dummy_title_tts, # Corresponds to narrator_title_track in original 366 | # 'content_tts_path': dummy_content_tts, # Corresponds to narrator_content_track in original 367 | # 'output_dir': 'generated_videos_test' # Test output directory 368 | # # 'selftext' is not directly used if content_tts_path is provided 369 | # } 370 | # print(f"Running test with kwargs: {test_kwargs}") 371 | # # Need to ensure make_submission_image.py has run and created temp/images/test_story_001.png 372 | # # os.makedirs("temp/images", exist_ok=True) 373 | # # with open("temp/images/test_story_001.png", "w") as f: f.write("dummy png") # DUMMY, use real image 374 | 375 | # video_path = create_short_video(**test_kwargs) 376 | # if video_path: 377 | # print(f"Test video created: {video_path}") 378 | # else: 379 | # print("Test video creation failed.") 380 | # # Cleanup dummy files 381 | # if os.path.exists(dummy_title_tts): os.remove(dummy_title_tts) 382 | # if os.path.exists(dummy_content_tts): os.remove(dummy_content_tts) 383 | # if os.path.exists("temp/images/test_story_001.png"): os.remove("temp/images/test_story_001.png") 384 | --------------------------------------------------------------------------------