├── shorts ├── __init__.py ├── __main__.py ├── main.py ├── class_comment.py ├── config.py ├── get_reddit_stories.py ├── metadata.py ├── audio.py ├── query_db.py ├── utils.py ├── platforms.py ├── args.py ├── make_tts.py ├── youtube.py ├── make_submission_image.py ├── class_submission.py ├── create_short.py └── tiktok_tts.py ├── 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 ├── AUTHORS ├── commitlint.config.js ├── setup.py ├── requirements-dev.txt ├── tox.ini ├── package.json ├── LICENSE ├── setup.cfg ├── .pre-commit-config.yaml ├── testing ├── reddit_submission_identifier.py └── reddit_submission_example.py └── README.adoc /shorts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Gavin Kondrath <78187175+gavink97@users.noreply.github.com> 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /shorts/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from shorts.main import _run 4 | 5 | 6 | if __name__ == "__main__": 7 | raise SystemExit(_run()) 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reddit-shorts", 3 | "version": "1.0.0", 4 | "author": "Gavin Kondrath", 5 | "email": "78187175+gavink97@users.noreply.github.com", 6 | "description": "", 7 | "github": "https://github.com/gavink97", 8 | "homepage": "https://github.com/gavink97/reddit-video-automation#readme", 9 | "bugs": { 10 | "url": "https://github.com/gavink97/reddit-video-automation/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/gavink97/reddit-video-automation.git" 15 | }, 16 | "license": "MIT", 17 | "type": "module", 18 | "devDependencies": { 19 | "@commitlint/cli": "^19.8.1", 20 | "@commitlint/config-conventional": "^19.8.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /shorts/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | from shorts.args import _parse_args 6 | from shorts.config import _project_path 7 | from shorts.query_db import create_tables 8 | from shorts.platforms import _tiktok, _youtube, _create_short 9 | 10 | 11 | def _main(**kwargs) -> None: 12 | if not os.path.isfile(f'{_project_path}/reddit-shorts/shorts.db'): 13 | create_tables() 14 | 15 | platform = kwargs.get("platform") 16 | 17 | match platform: 18 | case "tiktok": 19 | _tiktok(**kwargs) 20 | 21 | case "youtube": 22 | _youtube(**kwargs) 23 | case "all": 24 | kwargs.__setitem__('platform', 'tiktok') 25 | _tiktok(**kwargs) 26 | 27 | kwargs.__setitem__('platform', 'youtube') 28 | _youtube(**kwargs) 29 | 30 | case "video" | _: 31 | _create_short(**kwargs) 32 | 33 | 34 | def _run_main() -> None: 35 | load_dotenv() 36 | args = _parse_args() 37 | _main(**args) 38 | 39 | 40 | if __name__ == "__main__": 41 | _run_main() 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = Shorts_Generator 3 | version = 1.0 4 | long_description = file: README.adoc 5 | long_description_content_type = text/plain 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 | ffmpeg-python>=0.2 19 | google-api-python-client>=2 20 | google-auth-httplib2>=0.2 21 | google-auth-oauthlib>=1 22 | openai-whisper>=v20231117 23 | pillow>=10 24 | playsound@git+https://github.com/taconi/playsound 25 | praw>=7 26 | python-dotenv>=1 27 | requests>=2 28 | toml>=0.10 29 | python_requires = >=3.8 30 | 31 | [options.packages.find] 32 | exclude = 33 | test* 34 | testing* 35 | 36 | [options.entry_points] 37 | console_scripts = 38 | shorts = shorts.main:_run_main 39 | 40 | [options.extras_require] 41 | testing = 42 | pytest>=6 43 | 44 | [options.package_data] 45 | shorts = py.typed 46 | 47 | [coverage:run] 48 | plugins = covdefaults 49 | 50 | [flake8] 51 | max-line-length = 160 52 | -------------------------------------------------------------------------------- /shorts/class_comment.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import re 3 | 4 | from 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 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-yaml 6 | - id: check-toml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - id: check-added-large-files 10 | - id: name-tests-test 11 | # - id: double-quote-string-fixer 12 | - id: requirements-txt-fixer 13 | 14 | - repo: https://gitlab.com/bmares/check-json5 15 | rev: v1.0.0 16 | hooks: 17 | - id: check-json5 18 | 19 | - repo: https://github.com/PyCQA/flake8 20 | rev: 7.0.0 21 | hooks: 22 | - id: flake8 23 | 24 | - repo: https://github.com/hhatto/autopep8 25 | rev: v2.1.0 26 | hooks: 27 | - id: autopep8 28 | 29 | - repo: https://github.com/asottile/reorder-python-imports 30 | rev: v3.12.0 31 | hooks: 32 | - id: reorder-python-imports 33 | args: [--py37-plus] 34 | 35 | - repo: https://github.com/asottile/setup-cfg-fmt 36 | rev: v2.5.0 37 | hooks: 38 | - id: setup-cfg-fmt 39 | 40 | #- repo: local 41 | # hooks: 42 | # - id: pytest-check 43 | # name: pytest-check 44 | # entry: pytest 45 | # args: [tests] 46 | # language: system 47 | # pass_filenames: false 48 | # always_run: true 49 | 50 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 51 | rev: v9.16.0 52 | hooks: 53 | - id: commitlint 54 | stages: [commit-msg] 55 | additional_dependencies: ['@commitlint/config-conventional'] 56 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /shorts/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | _project_path = os.path.expanduser('~/projects/reddit-shorts/pyapp') 4 | if not os.path.isdir(_project_path): 5 | _project_path = '/pyapp' 6 | 7 | _temp_path = os.path.join(_project_path, 'temp') 8 | _resources = os.path.join(_project_path, 'resources') 9 | 10 | _video_resources = os.path.join(_resources, 'footage') 11 | _game_resources = os.path.join(_resources, 'gaming') 12 | _music_resources = os.path.join(_resources, 'music') 13 | 14 | _CHANNEL_NAME = "@yourchannel" 15 | 16 | _footage = [] 17 | for file in os.listdir(_video_resources): 18 | file_path = os.path.join(_video_resources, file) 19 | if not file.startswith('.DS_Store'): 20 | _footage.append(file_path) 21 | 22 | _gaming_footage = [] 23 | for file in os.listdir(_game_resources): 24 | file_path = os.path.join(_game_resources, file) 25 | if not file.startswith('.DS_Store'): 26 | _gaming_footage.append(file_path) 27 | 28 | _music = [] 29 | for file in os.listdir(_music_resources): 30 | file_path = os.path.join(_music_resources, file) 31 | if not file.startswith('.DS_Store'): 32 | _music.append(file_path) 33 | 34 | # subreddit allows_random 35 | _subreddits = [ 36 | # Asking Questions 37 | ("askreddit", True), 38 | ("askmen", True), 39 | ("askuk", True), 40 | ("nostupidquestions", True), 41 | 42 | # Opinions 43 | ("unpopularopinion", True), 44 | ("legaladvice", False), 45 | 46 | # Random thoughts 47 | ("showerthoughts", True), 48 | 49 | # Today I learned 50 | ("todayilearned", True), 51 | ("explainlikeimfive", True), 52 | 53 | # Stories 54 | ("stories", True), 55 | ("talesfromtechsupport", True), 56 | ("talesfromretail", True), 57 | ("offmychest", False), 58 | ("relationships", False), 59 | ("casualconversation", True), 60 | 61 | # Creepy stuff 62 | ("letsnotmeet", True), 63 | ("shortscarystories", True), 64 | ("truescarystories", True), 65 | ("creepyencounters", True), 66 | ("thetruthishere", False) 67 | ] 68 | 69 | bad_words_list = [ 70 | "porn", 71 | "pornography", 72 | "fuck", 73 | "fucking" 74 | ] 75 | -------------------------------------------------------------------------------- /shorts/get_reddit_stories.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import time 4 | 5 | import praw 6 | 7 | from shorts.class_submission import Submission 8 | from shorts.config import _subreddits 9 | 10 | 11 | def _connect_to_reddit(**kwargs) -> praw.Reddit: 12 | client_id = os.environ['REDDIT_CLIENT_ID'] 13 | client_secret = os.environ['REDDIT_CLIENT_SECRET'] 14 | user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0" 15 | 16 | try: 17 | reddit = praw.Reddit( 18 | client_id=client_id, 19 | client_secret=client_secret, 20 | user_agent=user_agent 21 | ) 22 | return reddit 23 | 24 | except Exception as e: 25 | raise Exception(f"Error connecting to Reddit API: {e}") 26 | 27 | 28 | def _get_content(reddit: praw.Reddit or None = None, **kwargs) -> dict: 29 | print("Getting a story from Reddit...") 30 | strategy = kwargs.get('strategy') 31 | 32 | while True: 33 | subreddit = random.choice(_subreddits) 34 | subreddit_name = subreddit[0] 35 | 36 | try: 37 | if reddit is None: 38 | print("Connecting to reddit api...") 39 | reddit = _connect_to_reddit() 40 | time.sleep(5) 41 | 42 | except Exception as e: 43 | raise Exception(e) 44 | 45 | match strategy: 46 | case "random": 47 | submission = reddit.subreddit(subreddit_name).random() 48 | if submission is None: 49 | print( 50 | f"This subreddit bans the use of .random: {subreddit}" 51 | ) 52 | 53 | continue 54 | 55 | submission_data = Submission.process_submission( 56 | subreddit, 57 | submission, 58 | **kwargs 59 | ) 60 | 61 | if submission_data: 62 | return submission_data.as_dict() 63 | 64 | case "hot" | _: 65 | for submission in reddit.subreddit(subreddit_name).hot(limit=20): 66 | submission_data = Submission.process_submission( 67 | subreddit, 68 | submission, 69 | **kwargs 70 | ) 71 | 72 | if submission_data: 73 | return submission_data.as_dict() 74 | -------------------------------------------------------------------------------- /shorts/metadata.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def _title_video(submission, **kwargs) -> str: 5 | submission_title = submission.get('title') 6 | platform = kwargs.get('platform') 7 | 8 | if platform == "youtube": 9 | character_limit = 100 10 | 11 | else: 12 | character_limit = 2200 13 | 14 | title_hashtags = "#reddit #minecraft" 15 | 16 | truncated_limit = character_limit - len(title_hashtags) - 4 17 | 18 | if len(submission_title) >= truncated_limit: 19 | truncated_title = submission_title[:truncated_limit] 20 | if truncated_title.endswith(' '): 21 | truncated_title = truncated_title[:-1] 22 | 23 | video_title = f"{truncated_title}... {title_hashtags}" 24 | else: 25 | video_title = f"{submission_title} {title_hashtags}" 26 | 27 | print(video_title) 28 | return video_title 29 | 30 | 31 | def _video_keywords( 32 | title: str, 33 | subreddit: str, 34 | additional_keywords: str = None 35 | ) -> list: 36 | # character limit is 500 on youtube 37 | cleaned_sentence = re.sub(r'[^\w\s]', '', title.lower()) 38 | keywords = cleaned_sentence.split() 39 | unique_keywords = [] 40 | keywords.append(subreddit) 41 | if additional_keywords is not None: 42 | additional_keys = additional_keywords.split(',') 43 | keywords.extend(additional_keys) 44 | 45 | for word in keywords: 46 | if word not in unique_keywords: 47 | unique_keywords.append(word) 48 | 49 | return unique_keywords 50 | 51 | 52 | # start here 53 | def _ai_title() -> str: 54 | try: 55 | from transformers import pipeline 56 | 57 | except ImportError: 58 | print("You do not have the required imports") 59 | 60 | model = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B" 61 | pipe = pipeline("text-generation", model=model, device="mps") 62 | 63 | content = "this is a test just to see if its working :) hello world, how do you do?" 64 | 65 | messages = [ 66 | {"role": "system", "content": "You are writing click bait video titles for a youtube video based on the content of the video", }, 67 | {"role": "user", "content": f"{content}"}, 68 | ] 69 | 70 | prompt = pipe.tokenizer.apply_chat_template( 71 | messages, 72 | tokenize=False, 73 | add_generation_prompt=True 74 | ) 75 | 76 | resp = pipe(prompt, max_new_tokens=128) 77 | # print(resp[0]['generated_text']) 78 | print(resp) 79 | 80 | 81 | if __name__ == "__main__": 82 | _ai_title() 83 | -------------------------------------------------------------------------------- /shorts/audio.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import ExitStack 3 | 4 | import ffmpeg 5 | 6 | from shorts.config import _temp_path 7 | from shorts.utils import _clean_up 8 | from shorts.utils import _normalize 9 | 10 | 11 | def _audio(submission: dict, tts_tracks: dict, **kwargs) -> None: 12 | title_track = tts_tracks.get('title_track', '') 13 | comment_track = tts_tracks.get('comment_track', '') 14 | content_track = tts_tracks.get('content_track', '') 15 | input_track = tts_tracks.get('input_track', '') 16 | platform_track = tts_tracks.get('platform_track', '') 17 | 18 | tts_combined = os.path.join(_temp_path, 'ttsoutput', 'combined.mp3') 19 | 20 | tracks = [] 21 | 22 | for key, path in tts_tracks.items(): 23 | if path == '' or path is None: 24 | continue 25 | 26 | if not os.path.exists(path): 27 | raise ValueError(f"track does not exist: {path}") 28 | 29 | with ExitStack() as stack: 30 | stack.callback(_clean_up, [ 31 | input_track, 32 | comment_track, 33 | content_track, 34 | platform_track 35 | ]) 36 | 37 | silent_audio = ffmpeg.input( 38 | 'aevalsrc=0:d={}'.format(.5), f='lavfi' 39 | ) 40 | 41 | if kwargs.get('input') != '': 42 | custom_input = ffmpeg.input(input_track) 43 | custom_input_normalized = _normalize(custom_input, -16) 44 | 45 | tracks.append(custom_input_normalized) 46 | 47 | else: 48 | title_input = ffmpeg.input(title_track) 49 | title_input_normalized = _normalize(title_input, -16) 50 | 51 | tracks.append(title_input_normalized) 52 | 53 | comment_input = ffmpeg.input(comment_track) 54 | comment_input_normalized = _normalize(comment_input, -16) 55 | 56 | if submission.get('text') != '': 57 | content_input = ffmpeg.input(content_track) 58 | content_input_normalized = _normalize(content_input, -16) 59 | 60 | tracks.append(silent_audio) 61 | tracks.append(content_input_normalized) 62 | 63 | tracks.append(silent_audio) 64 | tracks.append(comment_input_normalized) 65 | 66 | if kwargs.get('platform') != 'video': 67 | platform_input = ffmpeg.input(platform_track) 68 | platform_input_normalized = _normalize(platform_input, -16) 69 | 70 | tracks.append(silent_audio) 71 | tracks.append(platform_input_normalized) 72 | 73 | ( 74 | ffmpeg 75 | .concat( 76 | *tracks, 77 | n=len(tracks), 78 | v=0, 79 | a=1 80 | ) 81 | .output(tts_combined) 82 | .run(overwrite_output=True) 83 | ) 84 | 85 | return 86 | -------------------------------------------------------------------------------- /shorts/query_db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | 4 | from shorts.config import _project_path 5 | 6 | 7 | def create_tables(): 8 | database_path = os.path.join(_project_path, "shorts.db") 9 | db = sqlite3.connect(database_path) 10 | cursor = db.cursor() 11 | 12 | create_uploads_table = """ 13 | CREATE TABLE IF NOT EXISTS uploads ( 14 | id INT AUTO_INCREMENT PRIMARY KEY, 15 | submission_id VARCHAR(30), 16 | top_comment_id VARCHAR(30) 17 | ); 18 | """ 19 | 20 | create_admin_table = """ 21 | CREATE TABLE IF NOT EXISTS admin ( 22 | id INT AUTO_INCREMENT PRIMARY KEY, 23 | submission_id VARCHAR(30) 24 | ); 25 | """ 26 | 27 | cursor.execute(create_uploads_table) 28 | cursor.execute(create_admin_table) 29 | 30 | db.commit() 31 | 32 | cursor.close() 33 | db.close() 34 | 35 | 36 | def check_if_video_exists(submission_id: str, top_comment_id: str) -> bool: 37 | database_path = os.path.join(_project_path, "shorts.db") 38 | db = sqlite3.connect(database_path) 39 | cursor = db.cursor() 40 | 41 | videos_query = """ 42 | SELECT * FROM uploads 43 | WHERE submission_id = ? 44 | AND top_comment_id = ?; 45 | """ 46 | 47 | cursor.execute(videos_query, (submission_id, top_comment_id)) 48 | rows = cursor.fetchall() 49 | 50 | if len(rows) > 0: 51 | video_exists = True 52 | print("Video found in DB!") 53 | 54 | else: 55 | video_exists = False 56 | print("Video not in DB!") 57 | 58 | db.commit() 59 | cursor.close() 60 | db.close() 61 | 62 | return video_exists 63 | 64 | 65 | def write_to_db(submission_id: str, top_comment_id: str) -> None: 66 | database_path = os.path.join(_project_path, "shorts.db") 67 | db = sqlite3.connect(database_path) 68 | cursor = db.cursor() 69 | 70 | write_to_videos = """ 71 | INSERT INTO uploads (submission_id, top_comment_id) 72 | VALUES (?, ?); 73 | """ 74 | 75 | cursor.execute(write_to_videos, (submission_id, top_comment_id)) 76 | db.commit() 77 | 78 | print("Updated DB") 79 | 80 | cursor.close() 81 | db.close() 82 | 83 | 84 | def check_for_admin_posts(submission_id: str) -> bool: 85 | database_path = os.path.join(_project_path, "shorts.db") 86 | db = sqlite3.connect(database_path) 87 | cursor = db.cursor() 88 | 89 | videos_query = """ 90 | SELECT * FROM admin 91 | WHERE submission_id = ?; 92 | """ 93 | 94 | cursor.execute(videos_query, (submission_id,)) 95 | rows = cursor.fetchall() 96 | 97 | if len(rows) > 0: 98 | admin_post = True 99 | print("This Submission was written by an Admin") 100 | 101 | else: 102 | admin_post = False 103 | # print("Video not in DB!") 104 | 105 | db.commit() 106 | cursor.close() 107 | db.close() 108 | 109 | return admin_post 110 | -------------------------------------------------------------------------------- /shorts/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import ffmpeg 5 | 6 | from shorts.config import bad_words_list 7 | 8 | 9 | def contains_bad_words(text: str, bad_words: list = bad_words_list) -> bool: 10 | text = text.lower() 11 | pattern = re.compile(r'\b(?:' + '|'.join( 12 | re.escape(word) for word in bad_words 13 | ) + r')\b', re.IGNORECASE) 14 | 15 | if pattern.search(text): 16 | return True 17 | return False 18 | 19 | 20 | def _get_audio_duration(track: ffmpeg.Stream) -> float: 21 | probe = ffmpeg.probe(track) 22 | info = next(s for s in probe['streams'] if s['codec_type'] == 'audio') 23 | duration = float(info['duration']) 24 | return duration 25 | 26 | 27 | def _get_video_duration(track: ffmpeg.Stream) -> float: 28 | probe = ffmpeg.probe(track) 29 | info = next(s for s in probe['streams'] if s['codec_type'] == 'video') 30 | duration = float(info['duration']) 31 | return duration 32 | 33 | 34 | def _clean_up(files: list | str) -> None: 35 | if isinstance(files, str): 36 | try: 37 | os.remove(files) 38 | except Exception: 39 | return 40 | 41 | else: 42 | for path in files: 43 | try: 44 | os.remove(path) 45 | except Exception: 46 | pass 47 | 48 | 49 | def _measure_loudness(track: ffmpeg.Stream) -> dict: 50 | try: 51 | _, err = ( 52 | ffmpeg 53 | .filter( 54 | track, 55 | 'loudnorm', 56 | I=-16, 57 | dual_mono=True, 58 | TP=-1.5, 59 | LRA=11, 60 | print_format='summary') 61 | .output('pipe:', format="null") 62 | .run(capture_stdout=True, capture_stderr=True) 63 | ) 64 | except ffmpeg.Error as e: 65 | raise Exception(e.stderr.decode()) 66 | 67 | out = err.decode().split('\n') 68 | 69 | loudness = { 70 | 'input_integrated': None, 71 | 'input_true_peak': None, 72 | 'input_lra': None, 73 | 'input_threshold': None, 74 | } 75 | 76 | for line in out: 77 | if 'Input Integrated' in line: 78 | loudness['input_integrated'] = line.split()[-2] 79 | elif 'Input True Peak' in line: 80 | loudness['input_true_peak'] = line.split()[-2] 81 | elif 'Input LRA' in line: 82 | loudness['input_lra'] = line.split()[-2] 83 | elif 'Input Threshold' in line: 84 | loudness['input_threshold'] = line.split()[-2] 85 | 86 | return loudness 87 | 88 | 89 | def _normalize(track: ffmpeg.Stream, target: int = -16) -> ffmpeg.Stream: 90 | if target > 0: 91 | raise Exception("target must be a negative number") 92 | 93 | return track.filter( 94 | 'loudnorm', 95 | I=target, 96 | dual_mono=True, 97 | TP=-1.5, 98 | LRA=11 99 | ) 100 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /shorts/platforms.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import ExitStack 3 | 4 | from apiclient.errors import HttpError 5 | 6 | from shorts.config import _project_path 7 | from shorts.create_short import _create_video 8 | from shorts.get_reddit_stories import _get_content 9 | from shorts.make_submission_image import _generate_reddit_image 10 | from shorts.make_tts import _tts 11 | from shorts.metadata import _title_video 12 | from shorts.metadata import _video_keywords 13 | from shorts.utils import _clean_up 14 | from shorts.youtube import get_authenticated_service 15 | from shorts.youtube import initialize_upload 16 | 17 | 18 | def _create_short(**kwargs) -> None or list: 19 | submission = None 20 | 21 | if kwargs.get('input') == '': 22 | submission = _get_content(**kwargs) 23 | _generate_reddit_image(submission, **kwargs) 24 | 25 | tts_tracks = _tts(submission, **kwargs) 26 | file_path = _create_video(submission, tts_tracks, **kwargs) 27 | 28 | if kwargs.get('platform') == 'video': 29 | print(f"Video complete at {file_path}") 30 | return 31 | 32 | else: 33 | return file_path, submission 34 | 35 | 36 | def _tiktok(**kwargs) -> None: 37 | try: 38 | from tiktok_uploader.upload import upload_video 39 | 40 | except ImportError: 41 | raise Exception("Tiktok Uploader is unavailable") 42 | 43 | file_path, submission = _create_short(**kwargs) 44 | 45 | if kwargs.get('title') != '': 46 | video_title = kwargs.get('title') 47 | else: 48 | video_title = _title_video(submission, **kwargs) 49 | 50 | max_retrys = 5 51 | retry_count = 0 52 | 53 | with ExitStack() as stack: 54 | stack.callback(_clean_up, file_path) 55 | 56 | while retry_count < max_retrys: 57 | try: 58 | upload_video( 59 | file_path, 60 | description=video_title, 61 | cookies=os.path.join(_project_path, 'cookies.txt'), 62 | browser='firefox', 63 | headless=False 64 | ) 65 | 66 | break 67 | 68 | except Exception as e: 69 | print(f"Upload failed: {e}. Retrying... Attempt {retry_count + 1} of {max_retrys}") 70 | retry_count += 1 71 | 72 | if retry_count == max_retrys: 73 | print("Failed to upload a video to tiktok.") 74 | break 75 | 76 | 77 | def _youtube(**kwargs) -> None: 78 | file_path, submission = _create_short(**kwargs) 79 | keys = kwargs.get('keywords') 80 | 81 | if kwargs.get('title') != '': 82 | video_title = kwargs.get('title') 83 | else: 84 | video_title = _title_video(submission, **kwargs) 85 | 86 | if keys != '': 87 | video_keywords = str(keys).split(' ') 88 | 89 | else: 90 | title = submission.get('title') 91 | subreddit = submission.get('subreddit') 92 | 93 | keywords = ','.join([ 94 | 'hidden reddit', 95 | 'hidden', 96 | 'reddit', 97 | 'minecraft', 98 | 'gaming' 99 | ]) 100 | 101 | video_keywords = _video_keywords(title, subreddit, keywords) 102 | 103 | video_description = "" 104 | video_category = "20" # Gaming 105 | video_privacy_status = "public" 106 | notify_subs = False 107 | 108 | with ExitStack() as stack: 109 | stack.callback(_clean_up, file_path) 110 | 111 | youtube = get_authenticated_service() 112 | 113 | try: 114 | initialize_upload( 115 | youtube, 116 | file_path, 117 | video_title, 118 | video_description, 119 | video_category, 120 | video_keywords, 121 | video_privacy_status, 122 | notify_subs 123 | ) 124 | 125 | except HttpError as e: 126 | raise Exception( 127 | "An HTTP error %d occurred:\n%s" % (e.resp.status, e.content) 128 | ) 129 | -------------------------------------------------------------------------------- /shorts/args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | _PLATFORM_CHOICES = ("tiktok", "youtube", "all", "video") 5 | _STRATEGIES = ("hot", "random") 6 | 7 | 8 | def _parse_args() -> dict: 9 | parser = argparse.ArgumentParser() 10 | 11 | parser.add_argument( 12 | "-p", 13 | "--platform", 14 | choices=_PLATFORM_CHOICES, 15 | default=_PLATFORM_CHOICES[3], 16 | help="Choose a platform:" 17 | ) 18 | 19 | parser.add_argument( 20 | "-i", 21 | "--input", 22 | type=str.lower, 23 | action='store', 24 | default='', 25 | help="text or text file path" 26 | ) 27 | 28 | parser.add_argument( 29 | "-t", 30 | "--title", 31 | type=str.lower, 32 | action='store', 33 | default='', 34 | help="video title" 35 | ) 36 | 37 | parser.add_argument( 38 | "-k", 39 | "--keywords", 40 | type=str.lower, 41 | action='store', 42 | default='', 43 | help="keywords or hashtags", 44 | dest="keywords", 45 | ) 46 | 47 | parser.add_argument( 48 | "-v", 49 | "--video", 50 | type=str.lower, 51 | action='append', 52 | default=[], 53 | help="video path or 2 paths for split screen" 54 | ) 55 | 56 | parser.add_argument( 57 | "-m", 58 | "--music", 59 | type=str.lower, 60 | action='store', 61 | default='', 62 | help="music file path" 63 | ) 64 | 65 | parser.add_argument( 66 | "-pf", 67 | "--filter", 68 | action="store_true", 69 | default=False, 70 | help="Profanity filter" 71 | ) 72 | 73 | parser.add_argument( 74 | "-s", 75 | "--split", 76 | action="store_true", 77 | default=False, 78 | help="split screen video" 79 | ) 80 | 81 | parser.add_argument( 82 | "--strategy", 83 | choices=_STRATEGIES, 84 | default=_STRATEGIES[0], 85 | help="Choose a content gathering strategy" 86 | ) 87 | 88 | args = parser.parse_args() 89 | 90 | split = args.split 91 | keywords = args.keywords 92 | 93 | input_value = "" 94 | 95 | title = args.title 96 | 97 | if args.input != '': 98 | if os.path.isfile(args.input): 99 | with open(args.input, 'r', encoding='utf-8') as file: 100 | input_value = str(file.read()) 101 | 102 | else: 103 | input_value = args.input 104 | 105 | if args.platform != 'video': 106 | while not title.strip(): 107 | title = input( 108 | "A title is required to upload. Please enter a title:\n" 109 | ) 110 | 111 | if keywords == '': 112 | keywords = input( 113 | "Would you like to add some keywords?\n" 114 | ) 115 | 116 | if args.video != []: 117 | if len(args.video) > 2: 118 | raise Exception( 119 | "A maximum of two video inputs are allowed." 120 | f" {len(args.video)} are provided." 121 | ) 122 | 123 | if len(args.video) == 2: 124 | split = True 125 | 126 | for v in args.video: 127 | if not os.path.isfile(v): 128 | raise Exception(f"Invalid video path provided: {v}") 129 | 130 | if len(args.video) == 1 & split: 131 | raise Exception( 132 | "Invalid number of video inputs." 133 | f" {len(args.video)} are provided." 134 | ) 135 | 136 | if args.music != '': 137 | if not os.path.isfile(args.music): 138 | raise Exception(f"Invalid music file path provided: {args.music}") 139 | 140 | return { 141 | 'platform': args.platform, 142 | 'filter': args.filter, 143 | 'input': input_value, 144 | 'video': args.video, 145 | 'music': args.music, 146 | 'strategy': args.strategy, 147 | 'title': title, 148 | 'keywords': keywords, 149 | 'split': split 150 | } 151 | -------------------------------------------------------------------------------- /shorts/make_tts.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from shorts.config import _project_path, _temp_path 4 | from shorts.tiktok_tts import tts 5 | from shorts.utils import _clean_up 6 | from contextlib import ExitStack 7 | 8 | 9 | def _tts(submission, **kwargs) -> dict: 10 | if kwargs.get('input') != '': 11 | return _input_tts(**kwargs) 12 | 13 | else: 14 | return _submission_tts(submission, **kwargs) 15 | 16 | 17 | def _input_tts(**kwargs) -> dict: 18 | session_id = os.environ['TIKTOK_SESSION_ID_TTS'] 19 | platform = kwargs.get("platform") 20 | user_input = kwargs.get("input") 21 | 22 | # check tiktok_tts.py for a full list of tts voice options 23 | input_tts = "en_male_narration" 24 | platform_tts = "en_us_007" 25 | custom_user_input = False 26 | 27 | ttsout = os.path.join(_temp_path, 'ttsoutput') 28 | tts_texts = os.path.join(ttsout, 'texts') 29 | 30 | input_track = os.path.join(ttsout, 'custom_input.mp3') 31 | platform_track = os.path.join(ttsout, 'platform.mp3') 32 | 33 | if os.path.isfile(user_input): 34 | input_text = user_input 35 | 36 | else: 37 | input_text = os.path.join(tts_texts, 'custom_input.txt') 38 | custom_user_input = True 39 | 40 | with open(input_text, 'w', encoding='utf-8') as file: 41 | file.write(user_input) 42 | 43 | match platform: 44 | case "tiktok": 45 | platform_text = os.path.join(_project_path, "tiktok_tts.txt") 46 | 47 | case "youtube": 48 | platform_text = os.path.join(_project_path, "youtube_tts.txt") 49 | 50 | case "video" | _: 51 | platform_track = '' 52 | 53 | with ExitStack() as stack: 54 | if custom_user_input: 55 | stack.callback(_clean_up, input_text) 56 | 57 | tts(session_id=session_id, text_speaker=input_tts, 58 | file=input_text, filename=input_track) 59 | 60 | if platform != 'video': 61 | tts(session_id=session_id, text_speaker=platform_tts, 62 | file=platform_text, filename=platform_track) 63 | 64 | return { 65 | 'input_track': input_track, 66 | 'platform_track': platform_track 67 | } 68 | 69 | 70 | def _submission_tts(submission, **kwargs) -> dict: 71 | session_id = os.environ['TIKTOK_SESSION_ID_TTS'] 72 | 73 | platform = kwargs.get("platform") 74 | 75 | author = submission.get('author') 76 | content = submission.get('text') 77 | comment_author = submission.get('top_comment_author') 78 | 79 | # check tiktok_tts.py for a full list of tts voice options 80 | tiktok_narrator = "en_male_narration" 81 | tiktok_commentor = "en_us_009" 82 | my_tts = "en_us_007" 83 | 84 | ttsout = os.path.join(_temp_path, 'ttsoutput') 85 | tts_texts = os.path.join(ttsout, 'texts') 86 | 87 | title_track = os.path.join(ttsout, f'{author}_title.mp3') 88 | content_track = os.path.join(ttsout, f'{author}_content.mp3') 89 | comment_track = os.path.join(ttsout, f'{comment_author}.mp3') 90 | platform_track = os.path.join(ttsout, 'platform.mp3') 91 | 92 | title_text = os.path.join(tts_texts, f'{author}_title.txt') 93 | content_text = os.path.join(tts_texts, f'{author}_content.txt') 94 | comment_text = os.path.join(tts_texts, f'{comment_author}.txt') 95 | 96 | match platform: 97 | case "tiktok": 98 | platform_text = os.path.join(_project_path, "tiktok_tts.txt") 99 | 100 | case "youtube": 101 | platform_text = os.path.join(_project_path, "youtube_tts.txt") 102 | 103 | case "video" | _: 104 | platform_track = '' 105 | 106 | with ExitStack() as stack: 107 | stack.callback(_clean_up, [ 108 | title_text, 109 | content_text, 110 | comment_text 111 | ]) 112 | 113 | tts(session_id=session_id, text_speaker=tiktok_narrator, 114 | file=title_text, filename=title_track) 115 | 116 | tts(session_id=session_id, text_speaker=tiktok_commentor, 117 | file=comment_text, filename=comment_track) 118 | 119 | if content != "": 120 | tts(session_id=session_id, text_speaker=tiktok_narrator, 121 | file=content_text, filename=content_track) 122 | else: 123 | content_track = '' 124 | 125 | if platform != 'video': 126 | tts(session_id=session_id, text_speaker=my_tts, 127 | file=platform_text, filename=platform_track) 128 | 129 | return { 130 | 'title_track': title_track, 131 | 'content_track': content_track, 132 | 'comment_track': comment_track, 133 | 'platform_track': platform_track 134 | } 135 | -------------------------------------------------------------------------------- /shorts/youtube.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import pickle 4 | import random 5 | import time 6 | 7 | import httplib2 8 | from apiclient.errors import HttpError 9 | from google.auth.transport.requests import Request 10 | from google_auth_oauthlib.flow import InstalledAppFlow 11 | from googleapiclient.discovery import build 12 | from googleapiclient.http import MediaFileUpload 13 | 14 | from 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 | SECRETS = f"{_project_path}/client_secrets.json" 25 | 26 | YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload" 27 | _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__), SECRETS)) 44 | 45 | VALID_PRIVACY_STATUSES = ("public", "private", "unlisted") 46 | 47 | 48 | def get_authenticated_service(): 49 | cred = None 50 | pickle_file = f'token_{_SERVICE_NAME}_{YOUTUBE_API_VERSION}.pickle' 51 | 52 | api_token = os.path.join(_project_path, pickle_file) 53 | if os.path.exists(api_token): 54 | with open(api_token, 'rb') as token: 55 | cred = pickle.load(token) 56 | 57 | scopes = [f'{YOUTUBE_UPLOAD_SCOPE}'] 58 | 59 | if not cred or not cred.valid: 60 | if cred and cred.expired and cred.refresh_token: 61 | cred.refresh(Request()) 62 | else: 63 | flow = InstalledAppFlow.from_client_secrets_file(SECRETS, scopes) 64 | cred = flow.run_local_server() 65 | 66 | with open(api_token, 'wb') as token: 67 | pickle.dump(cred, token) 68 | 69 | try: 70 | service = build(_SERVICE_NAME, YOUTUBE_API_VERSION, credentials=cred) 71 | print(_SERVICE_NAME, 'service created successfully') 72 | return service 73 | 74 | except Exception as e: 75 | print('Unable to connect.') 76 | print(e) 77 | return None 78 | 79 | 80 | def convert_to_RFC_datetime(year=1900, month=1, day=1, hour=0, minute=0): 81 | dt = datetime.datetime(year, month, day, hour, minute, 0).isoformat() + 'Z' 82 | return dt 83 | 84 | 85 | def initialize_upload( 86 | youtube, 87 | youtube_short_file, 88 | short_video_title, 89 | video_description, 90 | video_category, 91 | video_keywords, 92 | video_privacy_status, 93 | notify_subs 94 | ): 95 | 96 | if video_keywords: 97 | tags = video_keywords 98 | 99 | else: 100 | tags = None 101 | 102 | body = dict( 103 | snippet=dict( 104 | title=short_video_title, 105 | description=video_description, 106 | tags=tags, 107 | categoryId=video_category 108 | ), 109 | status=dict( 110 | privacyStatus=video_privacy_status 111 | ) 112 | ) 113 | 114 | insert_request = youtube.videos().insert( 115 | part=",".join(body.keys()), 116 | body=body, 117 | notifySubscribers=notify_subs, 118 | media_body=MediaFileUpload( 119 | youtube_short_file, 120 | chunksize=-1, 121 | resumable=True 122 | )) 123 | 124 | resumable_upload(insert_request) 125 | 126 | 127 | def resumable_upload(insert_request): 128 | response = None 129 | error = None 130 | retry = 0 131 | while response is None: 132 | try: 133 | print("Uploading file...") 134 | status, response = insert_request.next_chunk() 135 | if response is not None: 136 | if 'id' in response: 137 | print("Video id '%s' was uploaded." % response['id']) 138 | else: 139 | exit("Upload failed. Unexpected response: %s" % response) 140 | 141 | except HttpError as e: 142 | if e.resp.status in RETRIABLE_STATUS_CODES: 143 | error = "A retriable HTTP error %d occurred:\n%s" % ( 144 | e.resp.status, 145 | e.content 146 | ) 147 | 148 | else: 149 | raise 150 | except RETRIABLE_EXCEPTIONS as e: 151 | error = "A retriable error occurred: %s" % e 152 | 153 | if error is not None: 154 | print(error) 155 | retry += 1 156 | if retry > MAX_RETRIES: 157 | exit("No longer attempting to retry.") 158 | 159 | max_sleep = 2 ** retry 160 | sleep_seconds = random.random() * max_sleep 161 | print("Sleeping %f seconds and then retrying..." % sleep_seconds) 162 | time.sleep(sleep_seconds) 163 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Reddit Shorts Generator - Generate Short-form media from UGC content 2 | ifdef::env-github[] 3 | Gavin Kondrath <78187175+gavink97@users.noreply.github.com> 4 | v1.0.0, 2025-01-31 5 | :homepage: https://github.com/gavink97/gridt 6 | endif::[] 7 | :toc: 8 | :toc-placement!: 9 | :icons: font 10 | 11 | Reddit Shorts Generator - Generate Short-form media from UGC content from reddit or anywhere else on the internet 12 | 13 | toc::[] 14 | 15 | == Features 16 | - Upload videos directly to YouTube via the https://developers.google.com/youtube/v3[YouTube API] 17 | - Automatically generate captions for your videos using Open AI https://github.com/openai/whisper[Whisper] 18 | - Videos are highly customizable and render blazingly fast with FFMPEG 19 | - Utilizes SQLite to avoid creating duplicate content 20 | 21 | == Getting Started 22 | RSG (Reddit Shorts Generator) is designed to be modular, with extensions built 23 | in to upload to YouTube or TikTok. 24 | 25 | === Installation 26 | Install the reddit shorts repo in the root directory of the 27 | project as shown in the file tree. 28 | 29 | === Build from source 30 | Because OpenAi's https://github.com/openai/whisper[Whisper] is only compatible 31 | with Python 3.8 - 3.11 only use those versions of python. 32 | 33 | ``` 34 | mkdir reddit-shorts 35 | gh repo clone gavink97/reddit-shorts-generator reddit-shorts 36 | pip install -e reddit-shorts 37 | ``` 38 | 39 | === File Tree 40 | ``` 41 | ├── client_secrets.json 42 | ├── cookies.txt 43 | ├── [reddit-shorts] 44 | ├── resources 45 | │   ├── footage 46 | │   └── music 47 | ├── tiktok-uploader 48 | ├── tiktok_tts.txt 49 | └── youtube_tts.txt 50 | ``` 51 | 52 | === Extensions 53 | - https://github.com/wkaisertexas/tiktok-uploader[TikTok Uploader] is required to upload shorts to TikTok. 54 | - https://developers.google.com/youtube/v3[YouTube Data API] is required to upload shorts to YouTube. 55 | 56 | === Dependencies 57 | This package requires `ffmpeg fonts-liberation` to be installed. 58 | 59 | *This repository utilizes pillow to create reddit images, so make sure to 60 | uninstall PIL if you have it installed* 61 | 62 | === Getting your TikTok Session ID 63 | You will need your tiktok session id in order to use tiktok generated text to speech 64 | 65 | ==== Google Chrome 66 | For Google Chrome: open the inspector, Application tab, in the side bar under 67 | Storage click the Cookies drop down and select tiktok. Then filter for sessionid 68 | 69 | ==== Firefox 70 | For Firefox: open the inspector, Storage tab then click the Cookies drop down 71 | and select tiktok. Then filter for sessionid 72 | 73 | == Commands 74 | 75 | === Platform 76 | Upload to a platform via `-p` or `--platform` 77 | 78 | shorts -p platform 79 | 80 | options are: `youtube` `tiktok` `video` (stand alone video) `all` (except standalone) 81 | 82 | === Input 83 | Custom input text or path to custom input text via `-i` or `--input` 84 | 85 | shorts -i "hello world" 86 | 87 | === Video 88 | Custom video input via `-v` or `--video` 89 | 90 | shorts -v path/to/your/video 91 | 92 | 93 | === Split 94 | Split video inputs via `-s` or `--split` 95 | 96 | shorts --split 97 | 98 | *Split will automatically take effect if two or more videos are inputed via --video* 99 | 100 | shorts --video path/to/video/one --video path/to/video/two 101 | 102 | === Music 103 | Custom music input via `-m` or `--music` 104 | 105 | shorts -v path/to/your/music 106 | 107 | === Profanity Filter 108 | Keep your content clean via `-pf` or `--filter` 109 | 110 | shorts -pf 111 | 112 | === Strategy 113 | Choose a strategy for gathering content via `--strategy` 114 | 115 | shorts --strategy hot 116 | 117 | options are: `hot` `random` 118 | 119 | === Title 120 | Custom title for video via `-t` or `--title` 121 | 122 | shorts -p youtube -t "Hello Youtube!" 123 | 124 | *only an option when uploading to a platform* 125 | 126 | === Keywords 127 | Custom keywords or hashtags for video via `-k or `--keywords` 128 | 129 | shorts -p youtube -k "funny fyp reddit" 130 | 131 | *only an option when uploading to a platform* 132 | 133 | == Contributing 134 | 135 | I'm open to contributions. 136 | 137 | == Support 138 | 139 | If you're feeling generous you can support this project and others I make on 140 | https://ko-fi.com/E1E119NG8M[Ko-fi] :) 141 | 142 | == Roadmap 143 | 144 | === Planned additions 145 | * [x] Split Screen Video 146 | 147 | === Feature ideas 148 | * [ ] AI Generated Titles / Tags 149 | * [x] Automating volumes for music / tts 150 | 151 | == Star 152 | 153 | If you've found this useful please give it a star ⭐️ as it helps other developers 154 | find my repos. 155 | 156 | ++++ 157 | 158 | 159 | 160 | 161 | Star History Chart 162 | 163 | 164 | ++++ 165 | -------------------------------------------------------------------------------- /shorts/make_submission_image.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | from PIL import Image, ImageFont, ImageDraw 5 | 6 | from shorts.config import _resources, _temp_path 7 | 8 | 9 | def _split_string(text: str, index: int) -> int: 10 | while index >= 0 and text[index] != ' ': 11 | index -= 1 12 | return index 13 | 14 | 15 | def _abbr_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 _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 _generate_reddit_image(submission: dict, **kwargs) -> None: 61 | subreddit = str(submission.get('subreddit')) 62 | author = str(submission.get('author')) 63 | title = str(submission.get('title')) 64 | timestamp = submission.get('timestamp') 65 | score = int(submission.get('score')) 66 | comments = int(submission.get('num_comments')) 67 | 68 | template = Image.open(f"{_resources}/images/reddit_submission_template.png") 69 | submission_image = f"{_temp_path}/images/{author}.png" 70 | community_logo = f"{_resources}/images/subreddits/{subreddit.lower()}.png" 71 | default_community_logo = f"{_resources}/images/subreddits/default.png" 72 | 73 | if len(author) > 22: 74 | submission_author_formatted = author[:22] 75 | return submission_author_formatted 76 | 77 | else: 78 | author_formatted = author 79 | 80 | characters_to_linebreak = 37 81 | line_start = 0 82 | chunks = [] 83 | 84 | while line_start < len(title): 85 | line_end = line_start + characters_to_linebreak 86 | 87 | if line_end < len(title): 88 | line_end = _split_string(title, line_end) 89 | chunks.append(title[line_start:line_end].strip()) 90 | line_start = line_end + 1 91 | 92 | title_formatted = '\n'.join(chunks) 93 | line_break_count = title_formatted.count('\n') 94 | 95 | if line_break_count >= 3: 96 | third_line_break_index = title_formatted.find( 97 | '\n', 98 | title_formatted.find( 99 | '\n', 100 | title_formatted.find( 101 | '\n' 102 | ) 103 | + 1 104 | ) 105 | + 1 106 | ) 107 | 108 | title_formatted = title_formatted[:third_line_break_index] 109 | 110 | time_formatted = _relative_time(datetime.datetime.fromtimestamp(timestamp)) 111 | 112 | score_formatted = _abbr_number(score) 113 | comments_formatted = _abbr_number(comments) 114 | 115 | font = "LiberationSans" 116 | draw = ImageDraw.Draw(template) 117 | twenty_pt_bold = ImageFont.truetype(f'{font}-Bold', 82) 118 | twenty_pt_reg = ImageFont.truetype(f'{font}-Regular', 82) 119 | twenty_six_pt_bold = ImageFont.truetype(f'{font}-Bold', 110) 120 | 121 | if os.path.exists(community_logo): 122 | community_image = Image.open(community_logo) 123 | community_image = community_image.resize((244, 244)) 124 | 125 | else: 126 | community_image = Image.open(default_community_logo) 127 | community_image = community_image.resize((244, 244)) 128 | 129 | template.paste(community_image, (222, 368), mask=community_image) 130 | 131 | draw.text((569, 459), author_formatted, (35, 31, 32,), font=twenty_pt_bold) 132 | 133 | author_length = draw.textlength(author_formatted, font=twenty_pt_bold) 134 | 135 | author_length_offset = 569 + author_length 136 | 137 | draw.ellipse(((author_length_offset + 36), 504, 138 | (author_length_offset + 50), 518), 139 | (35, 31, 32,)) 140 | 141 | draw.text(((author_length_offset + 95), 459), 142 | time_formatted, (35, 31, 32,), 143 | font=twenty_pt_reg) 144 | 145 | draw.multiline_text((236, 672), title_formatted, 146 | (35, 31, 32,), font=twenty_six_pt_bold, spacing=43.5 147 | ) 148 | 149 | if len(score_formatted) < 4: 150 | offset_needed = 4 - len(score_formatted) 151 | score_offset = offset_needed * 22 152 | 153 | draw.text((460+score_offset, 1253), 154 | score_formatted, (35, 31, 32,), 155 | font=twenty_pt_bold) 156 | 157 | else: 158 | draw.text((460, 1253), score_formatted, 159 | (35, 31, 32,), font=twenty_pt_bold) 160 | 161 | if len(comments_formatted) < 4: 162 | offset_needed = 4 - len(comments_formatted) 163 | comments_offset = offset_needed * 24 164 | 165 | draw.text((1172+comments_offset, 1253), 166 | comments_formatted, (35, 31, 32,), 167 | font=twenty_pt_bold) 168 | 169 | else: 170 | draw.text((1172, 1253), comments_formatted, 171 | (35, 31, 32,), font=twenty_pt_bold) 172 | 173 | if __name__ == "__main__": 174 | template.show() 175 | else: 176 | template.save(submission_image) 177 | 178 | 179 | if __name__ == "__main__": 180 | _generate_reddit_image({ 181 | 'subreddit': '', 182 | 'author': '', 183 | 'title': '', 184 | 'timestamp': '', 185 | 'score': '', 186 | 'num_comments': '' 187 | }) 188 | -------------------------------------------------------------------------------- /shorts/class_submission.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from datetime import datetime 4 | from typing import Any 5 | from typing import Optional 6 | 7 | from praw.models import MoreComments 8 | from praw.models.comment_forest import CommentForest 9 | 10 | from shorts.class_comment import Comment 11 | from shorts.config import _project_path 12 | from shorts.config import _temp_path 13 | from shorts.query_db import check_for_admin_posts 14 | from shorts.query_db import check_if_video_exists 15 | from shorts.query_db import write_to_db 16 | from shorts.utils import contains_bad_words 17 | 18 | 19 | class Submission: 20 | def __init__( 21 | self, 22 | subreddit: str, 23 | author: str, 24 | title: str, 25 | is_self: bool, 26 | text: str, 27 | url: str, 28 | score: int, 29 | num_comments: int, 30 | timestamp: datetime, 31 | id: int, 32 | comments: CommentForest, 33 | top_comment_body: Optional[str], 34 | top_comment_author: Optional[str], 35 | kind: str = None 36 | ): 37 | self.subreddit = subreddit 38 | self.author = author 39 | self.title = title 40 | self.is_self = is_self 41 | self.text = text 42 | self.url = url 43 | self.score = score 44 | self.num_comments = num_comments 45 | self.timestamp = timestamp 46 | self.id = id 47 | self.comments = comments 48 | self.top_comment_body = top_comment_body 49 | self.top_comment_author = top_comment_author 50 | self.kind = kind 51 | 52 | def as_dict(self): 53 | return { 54 | "subreddit": self.subreddit, 55 | "author": self.author, 56 | "title": self.title, 57 | "is_self": self.is_self, 58 | "text": self.text, 59 | "url": self.url, 60 | "score": self.score, 61 | "num_comments": self.num_comments, 62 | "timestamp": self.timestamp, 63 | "id": self.id, 64 | "comments": self.comments, 65 | "top_comment_body": self.top_comment_body, 66 | "top_comment_author": self.top_comment_author, 67 | "kind": self.kind 68 | } 69 | 70 | @classmethod 71 | def process_submission( 72 | cls, 73 | subreddit: list, 74 | submission: Any, 75 | **kwargs 76 | ) -> 'Submission': 77 | platform = kwargs.get('platform') 78 | 79 | match platform: 80 | case "tiktok": 81 | max_character_len = 2400 82 | platform_tts_path = os.path.join( 83 | _project_path, "tiktok_tts.txt") 84 | 85 | with open(platform_tts_path, 'r', encoding='utf-8') as file: 86 | platform_tts = str(file.read()) 87 | 88 | case "youtube": 89 | max_character_len = 830 90 | platform_tts_path = os.path.join( 91 | _project_path, "youtube_tts.txt") 92 | 93 | with open(platform_tts_path, 'r', encoding='utf-8') as file: 94 | platform_tts = str(file.read()) 95 | 96 | case "video" | _: 97 | max_character_len = 10000 98 | platform_tts = '' 99 | 100 | subreddit_name = subreddit[0] 101 | 102 | min_character_len = 300 103 | 104 | submission_author = str(submission.author) 105 | submission_title = str(submission.title) 106 | submission_text = str(submission.selftext) 107 | submission_id = str(submission.id) 108 | submission_kind = identify_post_type(submission) 109 | 110 | if submission_text == ' ': 111 | submission_text = '' 112 | 113 | submission_author = submission_author.replace("-", "") 114 | 115 | qualify_submission(submission, **kwargs) 116 | 117 | suitable_submission = False 118 | comments = submission.comments 119 | 120 | for index, comment in enumerate(comments): 121 | if isinstance(comment, MoreComments): 122 | continue 123 | 124 | comment_data = Comment.process_comment(comment, submission_author) 125 | top_comment_body = comment_data.body 126 | top_comment_author = comment_data.author 127 | top_comment_id = comment_data.id 128 | 129 | total_length = len(submission_title) + len(submission_text) + len(top_comment_body) + len(platform_tts) 130 | print(f"{subreddit_name}:{submission_title} Total:{total_length}") 131 | 132 | if total_length < min_character_len: 133 | continue 134 | 135 | if total_length <= max_character_len: 136 | video_exists = check_if_video_exists( 137 | submission_id, top_comment_id) 138 | if video_exists is False: 139 | suitable_submission = True 140 | break 141 | 142 | else: 143 | continue 144 | 145 | if index == len(comments) - 1: 146 | print('reached the end of the comments') 147 | return None 148 | 149 | if suitable_submission is True: 150 | print("Found a suitable submission!") 151 | print(f"Suitable Submission:{subreddit}:{submission_title}") 152 | 153 | tts_texts = os.path.join(_temp_path, 'ttsoutput', 'texts') 154 | 155 | if not os.path.exists(tts_texts): 156 | os.makedirs(tts_texts) 157 | 158 | with open( 159 | os.path.join(tts_texts, f'{submission_author}_title.txt'), 160 | 'w', 161 | encoding='utf-8' 162 | ) as file: 163 | file.write(submission_title) 164 | 165 | with open( 166 | os.path.join(tts_texts, f'{top_comment_author}.txt'), 167 | 'w', 168 | encoding='utf-8' 169 | ) as file: 170 | file.write(top_comment_body) 171 | 172 | if submission_text != '': 173 | with open( 174 | os.path.join( 175 | tts_texts, 176 | f'{submission_author}_content.txt' 177 | ), 178 | 'w', 179 | encoding='utf-8' 180 | ) as file: 181 | file.write(submission_text) 182 | 183 | write_to_db(submission_id, top_comment_id) 184 | 185 | return cls( 186 | subreddit=subreddit_name, 187 | author=submission_author, 188 | title=submission_title, 189 | is_self=submission.is_self, 190 | text=submission_text, 191 | url=submission.url, 192 | score=submission.score, 193 | num_comments=submission.num_comments, 194 | timestamp=submission.created_utc, 195 | id=submission_id, 196 | comments=submission.comments, 197 | top_comment_body=top_comment_body, 198 | top_comment_author=top_comment_author, 199 | kind=submission_kind 200 | ) 201 | 202 | 203 | class Title(Submission): 204 | kind = "title" 205 | 206 | 207 | class Text(Submission): 208 | kind = "text" 209 | 210 | 211 | class Link(Submission): 212 | kind = "link" 213 | 214 | 215 | class Image(Submission): 216 | kind = "image" 217 | 218 | 219 | class Video(Submission): 220 | kind = "video" 221 | 222 | 223 | def identify_post_type(submission: Any) -> str: 224 | image_extensions = ['.jpg', '.jpeg', '.png', '.gif'] 225 | video_extensions = ['.mp4', '.avi', '.mov'] 226 | reddit_image_extensions = [ 227 | 'https://www.reddit.com/gallery/', 228 | 'https://i.redd.it/' 229 | ] 230 | reddit_video_extensions = [ 231 | 'https://v.redd.it/', 232 | 'https://youtube.com', 233 | 'https://youtu.be' 234 | ] 235 | 236 | if submission.is_self is True: 237 | 238 | if submission.selftext == "": 239 | return Title.kind 240 | 241 | else: 242 | return Text.kind 243 | 244 | if submission.is_self is False: 245 | url = submission.url 246 | 247 | if any(url.endswith(ext) for ext in image_extensions): 248 | return Image.kind 249 | 250 | elif any(url.startswith(ext) for ext in reddit_image_extensions): 251 | return Image.kind 252 | 253 | elif any(url.endswith(ext) for ext in video_extensions): 254 | return Video.kind 255 | 256 | elif any(url.startswith(ext) for ext in reddit_video_extensions): 257 | return Video.kind 258 | 259 | else: 260 | return Link.kind 261 | 262 | 263 | def qualify_submission(submission: Any, **kwargs) -> None: 264 | filter = kwargs.get('filter') 265 | url_pattern = re.compile(r'http', flags=re.IGNORECASE) 266 | 267 | submission_title = str(submission.title) 268 | submission_text = str(submission.selftext) 269 | submission_id = str(submission.id) 270 | submission_kind = identify_post_type(submission) 271 | 272 | if submission_kind == "image" or submission_kind == "video": 273 | # print(f"This submission is not in text {submission_title}") 274 | pass 275 | 276 | if url_pattern.search(submission_text.lower()): 277 | # print("Skipping post that contains a link") 278 | pass 279 | 280 | admin_post = check_for_admin_posts(submission_id) 281 | if admin_post is True: 282 | pass 283 | 284 | if filter is True: 285 | if contains_bad_words(submission_title): 286 | pass 287 | 288 | if contains_bad_words(submission_text): 289 | pass 290 | -------------------------------------------------------------------------------- /shorts/create_short.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | import random 4 | from contextlib import ExitStack 5 | 6 | import ffmpeg 7 | import whisper 8 | from whisper.utils import get_writer 9 | 10 | from shorts.audio import _audio 11 | from shorts.config import _CHANNEL_NAME 12 | from shorts.config import _footage 13 | from shorts.config import _gaming_footage 14 | from shorts.config import _music 15 | from shorts.config import _temp_path 16 | from shorts.utils import _clean_up 17 | from shorts.utils import _get_audio_duration 18 | from shorts.utils import _get_video_duration 19 | from shorts.utils import _normalize 20 | 21 | 22 | def _create_video(submission: dict, tts_tracks: dict, **kwargs) -> str: 23 | FONT = "Liberation Sans" 24 | 25 | _uploads = os.path.join(_temp_path, 'uploads') 26 | 27 | split = kwargs.get('split') 28 | user_video = kwargs.get('video') 29 | user_input = kwargs.get('input') 30 | if user_input == '': 31 | author = submission.get('author') 32 | title_track = tts_tracks.get('title_track') 33 | title_duration = float(_get_audio_duration(title_track)) 34 | image = os.path.join(_temp_path, 'images', f'{author}.png') 35 | short_file_path = os.path.join(_uploads, f'{author}.mp4') 36 | 37 | else: 38 | short_file_path = os.path.join(_uploads, 'custom_input.mp4') 39 | title_track = '' 40 | image = '' 41 | 42 | music_looped = os.path.join(_temp_path, 'music_looped.mp3') 43 | processed_music = os.path.join(_temp_path, 'music_processed.mp3') 44 | music_normalized = os.path.join(_temp_path, 'music_normalized.mp3') 45 | mixed_track = os.path.join(_temp_path, 'mixed_audio.mp3') 46 | 47 | ttsoutput_dir = os.path.join(_temp_path, 'ttsoutput') 48 | tts_combined = os.path.join(ttsoutput_dir, 'combined.mp3') 49 | combined_srt = os.path.join(ttsoutput_dir, 'combined.srt') 50 | 51 | temp_files = [ 52 | title_track, 53 | image, 54 | tts_combined, 55 | music_looped, 56 | processed_music, 57 | combined_srt, 58 | mixed_track, 59 | music_normalized, 60 | ] 61 | 62 | _audio(submission, tts_tracks, **kwargs) 63 | if not os.path.exists(tts_combined): 64 | raise ValueError(f"Missing combined track: {tts_combined}") 65 | 66 | tts_duration = math.floor(_get_audio_duration(tts_combined)) + 1 67 | 68 | if kwargs.get('music') == '': 69 | music = random.choice(_music) 70 | else: 71 | music = kwargs.get('music') 72 | 73 | playhead = 0 74 | 75 | match f"{split}-{user_video != []}": 76 | # split screen + customer user video 77 | case 'True-True': 78 | source_1 = user_video[0] 79 | source_2 = user_video[1] 80 | 81 | playhead_1 = 0 82 | playhead_2 = 0 83 | 84 | for index, video in enumerate(user_video): 85 | duration = math.floor(_get_video_duration(video)) + 1 86 | if duration < tts_duration: 87 | raise Exception( 88 | f'{video} is too short.\ 89 | n\tts: {tts_duration} video: {duration}' 90 | ) 91 | 92 | if index == 0: 93 | playhead_1 = random.randint(0, duration - tts_duration) 94 | 95 | else: 96 | playhead_2 = random.randint(0, duration - tts_duration) 97 | 98 | # split screen default video 99 | case 'True-False': 100 | # should never be the same video tho 101 | source_1 = random.choice(_footage) 102 | source_2 = random.choice(_gaming_footage) 103 | 104 | playhead_1 = 0 105 | playhead_2 = 0 106 | 107 | for index, video in enumerate([source_1, source_2]): 108 | if not os.path.exists(video): 109 | raise ValueError(f"video source does not exist: {video}") 110 | 111 | duration = math.floor(_get_video_duration(video)) + 1 112 | if duration < tts_duration: 113 | while duration < tts_duration: 114 | footage = random.choice(_footage) 115 | duration = math.floor(_get_video_duration(footage)) + 1 116 | 117 | if index == 0: 118 | playhead_1 = random.randint(0, duration - tts_duration) 119 | 120 | else: 121 | playhead_2 = random.randint(0, duration - tts_duration) 122 | 123 | # single screen custom user video 124 | case 'False-True': 125 | footage = user_video[0] 126 | duration = math.floor(_get_video_duration(footage)) + 1 127 | if duration < tts_duration: 128 | # this should probably do something else 129 | raise Exception(f"Video is too short. tss: {tts_duration}") 130 | 131 | playhead = random.randint(0, duration - tts_duration) 132 | 133 | # single screen default video 134 | case 'False-False' | _: 135 | footage = random.choice(_gaming_footage) 136 | if not os.path.exists(footage): 137 | raise ValueError(f"video source does not exist: {footage}") 138 | 139 | duration = math.floor(_get_video_duration(footage)) + 1 140 | if duration < tts_duration: 141 | while duration < tts_duration: 142 | footage = random.choice(_footage) 143 | duration = math.floor(_get_video_duration(footage)) + 1 144 | 145 | playhead = random.randint(0, duration - tts_duration) 146 | 147 | writer_options = { 148 | "max_line_count": 1, 149 | "max_words_per_line": 1 150 | } 151 | 152 | whisper_model = whisper.load_model("tiny.en", device="cpu") 153 | tts_transcribed = whisper_model.transcribe( 154 | tts_combined, 155 | language="en", 156 | fp16=False, 157 | word_timestamps=True, 158 | task="transcribe" 159 | ) 160 | 161 | fade = 5 162 | 163 | music_duration = math.floor(_get_audio_duration(music)) + 1 164 | if tts_duration < fade: 165 | tts_duration = tts_duration + (fade - tts_duration) 166 | 167 | with ExitStack() as stack: 168 | stack.callback(_clean_up, temp_files) 169 | 170 | writer = get_writer("srt", ttsoutput_dir) 171 | writer(tts_transcribed, tts_combined, writer_options) 172 | 173 | music_track = ffmpeg.input(music) 174 | 175 | if music_duration < tts_duration: 176 | loops = int(tts_duration / music_duration) + 1 177 | crossfade_duration = 10 178 | 179 | inputs = [ffmpeg.input(music) for _ in range(loops)] 180 | 181 | chain = inputs[0] 182 | for next_input in inputs[1:]: 183 | chain = ffmpeg.filter( 184 | [chain, next_input], 185 | 'acrossfade', 186 | d=crossfade_duration, 187 | c1='tri', 188 | c2='tri' 189 | ) 190 | ( 191 | ffmpeg 192 | .output(chain, music_looped) 193 | .run(overwrite_output=True) 194 | ) 195 | 196 | music_track = ffmpeg.input(music_looped) 197 | 198 | music_track = _normalize(music_track, -28) 199 | 200 | ( 201 | music_track 202 | .filter('atrim', start=0, end=tts_duration) 203 | .filter('afade', t='out', st=tts_duration - fade, d=fade) 204 | .output(processed_music) 205 | .run(overwrite_output=True) 206 | ) 207 | 208 | if not os.path.exists(processed_music): 209 | raise ValueError(f"Missing music track: {processed_music}") 210 | 211 | background_music = ffmpeg.input(processed_music) 212 | tts = ffmpeg.input(tts_combined) 213 | 214 | ( 215 | ffmpeg 216 | .filter([tts, background_music], 'amix', inputs=2) 217 | .output(mixed_track) 218 | .run(overwrite_output=True) 219 | ) 220 | 221 | if not os.path.exists(mixed_track): 222 | raise ValueError(f"Missing mixed_track: {mixed_track}") 223 | 224 | mixed_audio = ffmpeg.input(mixed_track) 225 | 226 | if not split: 227 | clipped_footage = ( 228 | ffmpeg 229 | .input(footage) 230 | .filter('crop', 'ih*9/16', 'ih', '(iw-ih*9/16)/2', 0) 231 | .filter('scale', '1080', '1920') 232 | .trim(start=playhead, end=playhead + tts_duration) 233 | .filter('setpts', 'PTS-STARTPTS') 234 | ) 235 | 236 | else: 237 | top = ( 238 | ffmpeg 239 | .input(source_1) 240 | .filter( 241 | 'scale', 242 | '(960/1080)*1920', 243 | 960, 244 | force_original_aspect_ratio='decrease' 245 | ) 246 | .filter('crop', 1080, 960, '(in_w-out_w)/2', '(in_h-out_h)/2') 247 | .trim(start=playhead_1, end=playhead_1 + tts_duration) 248 | .filter('setpts', 'PTS-STARTPTS') 249 | ) 250 | 251 | bottom = ( 252 | ffmpeg 253 | .input(source_2) 254 | .filter( 255 | 'scale', 256 | '(960/1080)*1920', 257 | 960, 258 | force_original_aspect_ratio='decrease' 259 | ) 260 | .filter('crop', 1080, 960, '(in_w-out_w)/2', '(in_h-out_h)/2') 261 | .trim(start=playhead_2, end=playhead_2 + tts_duration) 262 | .filter('setpts', 'PTS-STARTPTS') 263 | ) 264 | 265 | clipped_footage = ffmpeg.filter( 266 | [top, bottom], 267 | 'vstack', 268 | ) 269 | 270 | master = ffmpeg.concat( 271 | clipped_footage, 272 | mixed_audio, 273 | v=1, 274 | a=1 275 | ) 276 | 277 | master = master.filter( 278 | 'fade', 279 | type='out', 280 | start_time=tts_duration - fade, 281 | duration=fade 282 | ) 283 | 284 | if user_input == '': 285 | reddit_image = ( 286 | ffmpeg 287 | .input(image) 288 | .filter('scale', '960', '-1') 289 | ) 290 | 291 | master = master.overlay( 292 | reddit_image, 293 | x='(W-w)/2', 294 | y='(H-h)/2', 295 | enable=f'between(t,0,{title_duration})' 296 | ) 297 | 298 | master = master.drawtext( 299 | text=_CHANNEL_NAME, 300 | fontfile=FONT, 301 | fontsize=22, 302 | fontcolor="white", 303 | x=700, 304 | y=180, 305 | borderw=2, 306 | bordercolor="black" 307 | ) 308 | master = master.filter( 309 | 'subtitles', 310 | combined_srt, 311 | force_style=f''' 312 | MarginV=40, 313 | Bold=-1, 314 | Fontname={FONT}, 315 | Fontsize=50, 316 | OutlineColour=&H100000000, 317 | BorderStyle=1, 318 | Outline=3, 319 | Shadow=3 320 | ''' 321 | ) 322 | 323 | master.output( 324 | short_file_path, 325 | t=tts_duration 326 | ).run(overwrite_output=True) 327 | 328 | return short_file_path 329 | -------------------------------------------------------------------------------- /shorts/tiktok_tts.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import base64 3 | import os 4 | import random 5 | import re 6 | import textwrap 7 | 8 | import playsound 9 | import requests 10 | 11 | from shorts.config import _project_path 12 | 13 | # Source: https://github.com/oscie57/tiktok-voice 14 | 15 | voices = [ 16 | # DISNEY VOICES 17 | 'en_us_ghostface', # Ghost Face 18 | 'en_us_chewbacca', # Chewbacca 19 | 'en_us_c3po', # C3PO 20 | 'en_us_stitch', # Stitch 21 | 'en_us_stormtrooper', # Stormtrooper 22 | 'en_us_rocket', # Rocket 23 | 24 | # ENGLISH VOICES 25 | 'en_au_001', # English AU - Female 26 | 'en_au_002', # English AU - Male 27 | 'en_uk_001', # English UK - Male 1 28 | 'en_uk_003', # English UK - Male 2 29 | 'en_us_001', # English US - Female (Int. 1) 30 | 'en_us_002', # English US - Female (Int. 2) 31 | 'en_us_006', # English US - Male 1 32 | 'en_us_007', # English US - Male 2 33 | 'en_us_009', # English US - Male 3 34 | 'en_us_010', # English US - Male 4 35 | 36 | # EUROPE VOICES 37 | 'fr_001', # French - Male 1 38 | 'fr_002', # French - Male 2 39 | 'de_001', # German - Female 40 | 'de_002', # German - Male 41 | 'es_002', # Spanish - Male 42 | 43 | # AMERICA VOICES 44 | 'es_mx_002', # Spanish MX - Male 45 | 'br_001', # Portuguese BR - Female 1 46 | 'br_003', # Portuguese BR - Female 2 47 | 'br_004', # Portuguese BR - Female 3 48 | 'br_005', # Portuguese BR - Male 49 | 50 | # ASIA VOICES 51 | 'id_001', # Indonesian - Female 52 | 'jp_001', # Japanese - Female 1 53 | 'jp_003', # Japanese - Female 2 54 | 'jp_005', # Japanese - Female 3 55 | 'jp_006', # Japanese - Male 56 | 'kr_002', # Korean - Male 1 57 | 'kr_003', # Korean - Female 58 | 'kr_004', # Korean - Male 2 59 | 60 | # SINGING VOICES 61 | 'en_female_f08_salut_damour' # Alto 62 | 'en_male_m03_lobby' # Tenor 63 | 'en_female_f08_warmy_breeze' # Warmy Breeze 64 | 'en_male_m03_sunshine_soon' # Sunshine Soon 65 | 66 | # OTHER 67 | 'en_male_narration' # narrator 68 | 'en_male_funny' # wacky 69 | 'en_female_emotional' # peaceful 70 | ] 71 | 72 | # this may be different based on your tiktok session id 73 | # please check the link for instructions on how to get your uri. 74 | # https://github.com/oscie57/tiktok-voice/issues/60#issuecomment-2565037435 75 | 76 | uri = "https://api22-normal-c-alisg.tiktokv.com" 77 | endpoint = '/media/api/text/speech/invoke/' 78 | api_uri = uri + endpoint 79 | _batch = os.path.join(_project_path, 'batch') 80 | 81 | 82 | def tts(session_id: str, text_speaker: str = "en_us_002", req_text: str = None, 83 | filename: str = 'voice.mp3', play: bool = False, file: str = None): 84 | 85 | if file is not None: 86 | req_text = open(file, 'r', errors='ignore', encoding='utf-8').read() 87 | chunk_size = 200 88 | 89 | textlist = textwrap.wrap( 90 | req_text, 91 | width=chunk_size, 92 | break_long_words=True, 93 | break_on_hyphens=False 94 | ) 95 | 96 | if os.path.exists(_batch): 97 | for item in os.listdir(_batch): 98 | os.remove(os.path.join(_batch, item)) 99 | 100 | os.removedirs(_batch) 101 | 102 | os.makedirs(_batch) 103 | 104 | for i, item in enumerate(textlist): 105 | batch = os.path.join(_batch, f'{i}.mp3') 106 | tts_batch(session_id, text_speaker, item, batch) 107 | 108 | batch_create(filename) 109 | 110 | for item in os.listdir(_batch): 111 | os.remove(os.path.join(_batch, item)) 112 | 113 | os.removedirs(_batch) 114 | 115 | return 116 | 117 | req_text = str(req_text) 118 | req_text = req_text.replace("+", "plus") 119 | req_text = req_text.replace(" ", "+") 120 | req_text = req_text.replace("&", "and") 121 | 122 | headers = { 123 | 'User-Agent': 'com.zhiliaoapp.musically/2022600030 (Linux; U; Android 7.1.2; es_ES; SM-G988N; Build/NRD90M;tt-ok/3.12.13.1)', 124 | 'Cookie': f'sessionid={session_id}' 125 | } 126 | url = f"{api_uri}?text_speaker={text_speaker}&req_text={req_text}&speaker_map_type=0&aid=1233" 127 | 128 | try: 129 | r = requests.post(url, headers=headers) 130 | print(r.json()["message"]) 131 | 132 | if r.json()["message"] == "Couldn't load speech. Try again.": 133 | output_data = { 134 | "status": "Session ID is invalid", 135 | "status_code": 5 136 | } 137 | 138 | print(output_data) 139 | return output_data 140 | 141 | vstr = [r.json()["data"]["v_str"]][0] 142 | msg = [r.json()["message"]][0] 143 | scode = [r.json()["status_code"]][0] 144 | log = [r.json()["extra"]["log_id"]][0] 145 | dur = [r.json()["data"]["duration"]][0] 146 | spkr = [r.json()["data"]["speaker"]][0] 147 | 148 | b64d = base64.b64decode(vstr) 149 | 150 | with open(filename, "wb") as out: 151 | out.write(b64d) 152 | 153 | output_data = { 154 | "status": msg.capitalize(), 155 | "status_code": scode, 156 | "duration": dur, 157 | "speaker": spkr, 158 | "log": log 159 | } 160 | 161 | print(output_data) 162 | 163 | if play is True: 164 | playsound.playsound(filename) 165 | os.remove(filename) 166 | 167 | return output_data 168 | 169 | except Exception as e: 170 | raise Exception(f"An error occurred during the request: {e}") 171 | 172 | 173 | def tts_batch( 174 | session_id: str, 175 | text_speaker: str = 'en_us_002', 176 | req_text: str = 'TikTok Text to Speech', 177 | filename: str = 'voice.mp3' 178 | ): 179 | req_text = req_text.replace("+", "plus") 180 | req_text = req_text.replace(" ", "+") 181 | req_text = req_text.replace("&", "and") 182 | 183 | headers = { 184 | 'User-Agent': 'com.zhiliaoapp.musically/2022600030 (Linux; U; Android 7.1.2; es_ES; SM-G988N; Build/NRD90M;tt-ok/3.12.13.1)', 185 | 'Cookie': f'sessionid={session_id}' 186 | } 187 | 188 | url = f"{api_uri}?text_speaker={text_speaker}&req_text={req_text}&speaker_map_type=0&aid=1233" 189 | 190 | try: 191 | r = requests.post(url, headers=headers) 192 | 193 | if r.json()["message"] == "Couldn't load speech. Try again.": 194 | output_data = {"status": "Session ID is invalid", "status_code": 5} 195 | print(output_data) 196 | return output_data 197 | 198 | vstr = [r.json()["data"]["v_str"]][0] 199 | msg = [r.json()["message"]][0] 200 | scode = [r.json()["status_code"]][0] 201 | log = [r.json()["extra"]["log_id"]][0] 202 | dur = [r.json()["data"]["duration"]][0] 203 | spkr = [r.json()["data"]["speaker"]][0] 204 | 205 | b64d = base64.b64decode(vstr) 206 | 207 | with open(filename, "wb") as out: 208 | out.write(b64d) 209 | 210 | output_data = { 211 | "status": msg.capitalize(), 212 | "status_code": scode, 213 | "duration": dur, 214 | "speaker": spkr, 215 | "log": log 216 | } 217 | 218 | print(output_data) 219 | 220 | return output_data 221 | 222 | except Exception as e: 223 | raise Exception(f"An error occurred during the request: {e}") 224 | 225 | 226 | def batch_create(filename: str = 'voice.mp3'): 227 | out = open(filename, 'wb') 228 | 229 | def sorted_alphanumeric(data): 230 | def convert(text): 231 | return int(text) if text.isdigit() else text.lower() 232 | 233 | def alphanum_key(key): 234 | return [convert(c) for c in re.split('([0-9]+)', key)] 235 | 236 | return sorted(data, key=alphanum_key) 237 | 238 | for item in sorted_alphanumeric(os.listdir(_batch)): 239 | file = open(os.path.join(_batch, item), 'rb').read() 240 | out.write(file) 241 | 242 | out.close() 243 | 244 | 245 | def main(): 246 | parser = argparse.ArgumentParser( 247 | description="Simple Python script to interact with the TikTok TTS API" 248 | ) 249 | 250 | parser.add_argument( 251 | "-v", "--voice", help="the code of the desired voice" 252 | ) 253 | 254 | parser.add_argument( 255 | "-t", "--text", help="the text to be read" 256 | ) 257 | 258 | parser.add_argument( 259 | "-s", "--session", help="account session id" 260 | ) 261 | 262 | parser.add_argument( 263 | "-f", "--file", help="use this if you wanna use 'text.txt'" 264 | ) 265 | 266 | parser.add_argument( 267 | "-n", "--name", help="The name for the output file (.mp3)" 268 | ) 269 | 270 | parser.add_argument( 271 | "-p", 272 | "--play", 273 | action='store_true', 274 | help="use this if you want to play your output" 275 | ) 276 | 277 | args = parser.parse_args() 278 | 279 | text_speaker = args.voice 280 | 281 | if args.file is not None: 282 | req_text = open( 283 | args.file, 284 | 'r', 285 | errors='ignore', 286 | encoding='utf-8' 287 | ).read() 288 | 289 | else: 290 | if args.text is None: 291 | req_text = 'TikTok Text To Speech' 292 | print('You need to have one form of text! (See README.md)') 293 | else: 294 | req_text = args.text 295 | 296 | if args.play is not None: 297 | play = args.play 298 | 299 | if args.voice is None: 300 | text_speaker = 'en_us_002' 301 | print('You need to have a voice! (See README.md)') 302 | 303 | if text_speaker == "random": 304 | text_speaker = randomvoice() 305 | 306 | if args.name is not None: 307 | filename = args.name 308 | else: 309 | filename = 'voice.mp3' 310 | 311 | if args.session is None: 312 | print('FATAL: You need to have a TikTok session ID!') 313 | exit(1) 314 | 315 | if args.file is not None: 316 | chunk_size = 200 317 | 318 | textlist = textwrap.wrap( 319 | req_text, 320 | width=chunk_size, 321 | break_long_words=True, 322 | break_on_hyphens=False 323 | ) 324 | 325 | os.makedirs(_batch) 326 | 327 | for i, item in enumerate(textlist): 328 | batch = os.path.join(_batch, f'{i}.mp3') 329 | tts_batch(args.session, text_speaker, item, batch) 330 | 331 | batch_create(filename) 332 | 333 | for item in os.listdir(_batch): 334 | os.remove(os.path.join(_batch, item)) 335 | 336 | os.removedirs(_batch) 337 | 338 | return 339 | 340 | tts(args.session, text_speaker, req_text, filename, play) 341 | 342 | 343 | def randomvoice(): 344 | count = random.randint(0, len(voices)) 345 | text_speaker = voices[count] 346 | 347 | return text_speaker 348 | 349 | 350 | def sampler(): 351 | for item in voices: 352 | text_speaker = item 353 | filename = item 354 | print(item) 355 | req_text = 'TikTok Text To Speech Sample' 356 | tts(text_speaker, req_text, filename) 357 | 358 | 359 | if __name__ == "__main__": 360 | main() 361 | --------------------------------------------------------------------------------