From 0ea74e43f1b1c77e02bd49920eec5bc0be8762fa Mon Sep 17 00:00:00 2001 From: Simon <65854503+OpenSourceSimon@users.noreply.github.com> Date: Sun, 28 May 2023 16:05:41 +0200 Subject: [PATCH] Version 3.2 The RedditVideoMakerBot has been updated to version 3.2, bringing exciting new features and bug fixes. Introducing **ElevenLabs TTS** for high-quality audio. Enjoy **background audio** and **random voices per comment**. Run the bot with one click using the **bat file**. Use the **zoom setting** for bigger text. Bug fixes include video downloader improvements, random story mode fix, updated dependencies, code optimizations, text size adjustments, enhanced Reddit credentials validation, and translator fixes. Enjoy the new features and **thanks to all contributors**! --- .github/workflows/codesee-arch-diagram.yml | 23 --- .gitignore | 2 +- Dockerfile | 1 + GUI/layout.html | 15 +- README.md | 2 +- TTS/elevenlabs.py | 46 ++++++ TTS/engine_wrapper.py | 30 +++- main.py | 48 +++--- reddit/subreddit.py | 18 +-- requirements.txt | 35 +++-- run.bat | 15 ++ utils/.config.template.toml | 20 ++- utils/background_audios.json | 18 +++ ...ackgrounds.json => background_videos.json} | 0 utils/cleanup.py | 23 +-- utils/ffmpeg_install.py | 50 ++++-- utils/imagenarator.py | 12 +- utils/playwright.py | 7 + utils/posttextparser.py | 21 +-- utils/subreddit.py | 19 ++- video_creation/background.py | 147 +++++++++++++----- video_creation/final_video.py | 129 +++++++++++---- video_creation/screenshot_downloader.py | 78 ++++++++-- video_creation/voices.py | 2 + 24 files changed, 539 insertions(+), 222 deletions(-) delete mode 100644 .github/workflows/codesee-arch-diagram.yml create mode 100644 TTS/elevenlabs.py create mode 100644 run.bat create mode 100644 utils/background_audios.json rename utils/{backgrounds.json => background_videos.json} (100%) create mode 100644 utils/playwright.py diff --git a/.github/workflows/codesee-arch-diagram.yml b/.github/workflows/codesee-arch-diagram.yml deleted file mode 100644 index a72f58b..0000000 --- a/.github/workflows/codesee-arch-diagram.yml +++ /dev/null @@ -1,23 +0,0 @@ -# This workflow was added by CodeSee. Learn more at https://codesee.io/ -# This is v2.0 of this workflow file -on: - push: - branches: - - master - pull_request_target: - types: [opened, synchronize, reopened] - -name: CodeSee - -permissions: read-all - -jobs: - codesee: - runs-on: ubuntu-latest - continue-on-error: true - name: Analyze the repo with CodeSee - steps: - - uses: Codesee-io/codesee-action@v2 - with: - codesee-token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} - codesee-url: https://app.codesee.io diff --git a/.gitignore b/.gitignore index 5f4115f..41bdd5e 100644 --- a/.gitignore +++ b/.gitignore @@ -244,4 +244,4 @@ video_creation/data/videos.json video_creation/data/envvars.txt config.toml -video_creation/data/videos.json \ No newline at end of file +*.exe \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6d090c6..4cf2a71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.10.9-slim RUN apt update +RUN apt-get install -y ffmpeg RUN apt install python3-pip -y RUN mkdir /app diff --git a/GUI/layout.html b/GUI/layout.html index d56299e..59bd5ef 100644 --- a/GUI/layout.html +++ b/GUI/layout.html @@ -56,6 +56,13 @@ .tooltip-inner { max-width: 500px !important; } + #hard-reload { + cursor: pointer; + color: darkblue; + } + #hard-reload:hover { + color: blue; + } @@ -132,11 +139,17 @@ Theme by © Bootstrap. Developers and Maintainers

-

If your data is not refreshing, try to hard reload(Ctrl + F5) and visit your local +

If your data is not refreshing, try to hard reload(Ctrl + F5) or click this and visit your local + {{ file }} file.

+ \ No newline at end of file diff --git a/README.md b/README.md index e6f61a6..81e37be 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ In its current state, this bot does exactly what it needs to do. However, improv I have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute! - [ ] Creating better documentation and adding a command line interface. -- [ ] Allowing the user to choose background music for their videos. +- [x] Allowing the user to choose background music for their videos. - [x] Allowing users to choose a reddit thread instead of being randomized. - [x] Allowing users to choose a background that is picked instead of the Minecraft one. - [x] Allowing users to choose between any subreddit. diff --git a/TTS/elevenlabs.py b/TTS/elevenlabs.py new file mode 100644 index 0000000..ab6dbb0 --- /dev/null +++ b/TTS/elevenlabs.py @@ -0,0 +1,46 @@ +import random + +from elevenlabs import generate, save + +from utils import settings + +voices = [ + "Adam", + "Antoni", + "Arnold", + "Bella", + "Domi", + "Elli", + "Josh", + "Rachel", + "Sam", +] + + +class elevenlabs: + def __init__(self): + self.max_chars = 2500 + self.voices = voices + + def run(self, text, filepath, random_voice: bool = False): + if random_voice: + voice = self.randomvoice() + else: + voice = str( + settings.config["settings"]["tts"]["elevenlabs_voice_name"] + ).capitalize() + + if settings.config["settings"]["tts"]["elevenlabs_api_key"]: + api_key = settings.config["settings"]["tts"]["elevenlabs_api_key"] + else: + raise ValueError( + "You didn't set an Elevenlabs API key! Please set the config variable ELEVENLABS_API_KEY to a valid API key." + ) + + audio = generate( + api_key=api_key, text=text, voice=voice, model="eleven_multilingual_v1" + ) + save(audio=audio, filename=filepath) + + def randomvoice(self): + return random.choice(self.voices) diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index e6b92d8..90fe45f 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -3,11 +3,8 @@ import re from pathlib import Path from typing import Tuple -# import sox -# from mutagen import MutagenError -# from mutagen.mp3 import MP3, HeaderNotFoundError import numpy as np -import translators as ts +import translators from moviepy.audio.AudioClip import AudioClip from moviepy.audio.fx.volumex import volumex from moviepy.editor import AudioFileClip @@ -17,7 +14,9 @@ from utils import settings from utils.console import print_step, print_substep from utils.voice import sanitize_text -DEFAULT_MAX_LENGTH: int = 50 # video length variable + +DEFAULT_MAX_LENGTH: int = 50 # Video length variable, edit this on your own risk. It should work, but it's not supported + class TTSEngine: @@ -55,9 +54,20 @@ class TTSEngine: self, ): # adds periods to the end of paragraphs (where people often forget to put them) so tts doesn't blend sentences for comment in self.reddit_object["comments"]: + # remove links + regex_urls = r"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*" + comment["comment_body"] = re.sub(regex_urls, " ", comment["comment_body"]) comment["comment_body"] = comment["comment_body"].replace("\n", ". ") + comment["comment_body"] = re.sub(r"\bAI\b", "A.I", comment["comment_body"]) + comment["comment_body"] = re.sub( + r"\bAGI\b", "A.G.I", comment["comment_body"] + ) if comment["comment_body"][-1] != ".": comment["comment_body"] += "." + comment["comment_body"] = comment["comment_body"].replace(". . .", ".") + comment["comment_body"] = comment["comment_body"].replace(".. . ", ".") + comment["comment_body"] = comment["comment_body"].replace(". . ", ".") + comment["comment_body"] = re.sub(r'\."\.', '".', comment["comment_body"]) def run(self) -> Tuple[int, int]: Path(self.path).mkdir(parents=True, exist_ok=True) @@ -66,7 +76,7 @@ class TTSEngine: self.add_periods() self.call_tts("title", process_text(self.reddit_object["thread_title"])) # processed_text = ##self.reddit_object["thread_post"] != "" - idx = None + idx = 0 if settings.config["settings"]["storymode"]: if settings.config["settings"]["storymodemethod"] == 0: @@ -141,7 +151,11 @@ class TTSEngine: print("OSError") def call_tts(self, filename: str, text: str): - self.tts_module.run(text, filepath=f"{self.path}/{filename}.mp3") + self.tts_module.run( + text, + filepath=f"{self.path}/{filename}.mp3", + random_voice=settings.config["settings"]["tts"]["random_voice"], + ) # try: # self.length += MP3(f"{self.path}/{filename}.mp3").info.length # except (MutagenError, HeaderNotFoundError): @@ -172,6 +186,6 @@ def process_text(text: str, clean: bool = True): new_text = sanitize_text(text) if clean else text if lang: print_substep("Translating Text...") - translated_text = ts.google(text, to_language=lang) + translated_text = translators.google(text, to_language=lang) new_text = sanitize_text(translated_text) return new_text diff --git a/main.py b/main.py index b7a1b7f..b8692c1 100755 --- a/main.py +++ b/main.py @@ -1,10 +1,10 @@ #!/usr/bin/env python import math import sys -from logging import error from os import name from pathlib import Path from subprocess import Popen +from typing import NoReturn from prawcore import ResponseException from utils.console import print_substep @@ -15,8 +15,9 @@ from utils.console import print_markdown, print_step from utils.id import id from utils.version import checkversion from video_creation.background import ( - download_background, - chop_background_video, + download_background_video, + download_background_audio, + chop_background, get_background_config, ) from video_creation.final_video import make_final_video @@ -24,7 +25,7 @@ from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts from video_creation.voices import save_text_to_mp3 from utils.ffmpeg_install import ffmpeg_install -__VERSION__ = "3.1" +__VERSION__ = "3.2" print( """ @@ -38,7 +39,7 @@ print( ) # Modified by JasonLovesDoggo print_markdown( - "### Thanks for using this tool! [Feel free to contribute to this project on GitHub!](https://lewismenelaws.com) If you have any questions, feel free to reach out to me on Twitter or submit a GitHub issue. You can find solutions to many common problems in the [Documentation](): https://reddit-video-maker-bot.netlify.app/" + "### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/" ) checkversion(__VERSION__) @@ -50,9 +51,13 @@ def main(POST_ID=None) -> None: length, number_of_comments = save_text_to_mp3(reddit_object) length = math.ceil(length) get_screenshots_of_reddit_posts(reddit_object, number_of_comments) - bg_config = get_background_config() - download_background(bg_config) - chop_background_video(bg_config, length, reddit_object) + bg_config = { + "video": get_background_config("video"), + "audio": get_background_config("audio"), + } + download_background_video(bg_config["video"]) + download_background_audio(bg_config["audio"]) + chop_background(bg_config, length, reddit_object) make_final_video(number_of_comments, length, reddit_object, bg_config) @@ -65,28 +70,26 @@ def run_many(times) -> None: Popen("cls" if name == "nt" else "clear", shell=True).wait() -def shutdown(): - try: - redditid - except NameError: - print("Exiting...") - exit() - else: +def shutdown() -> NoReturn: + if "redditid" in globals(): print_markdown("## Clearing temp files") cleanup(redditid) - print("Exiting...") - exit() + + print("Exiting...") + sys.exit() if __name__ == "__main__": if sys.version_info.major != 3 or sys.version_info.minor != 10: print("Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again.") - ffmpeg_install() # install ffmpeg if not installed + sys.exit() + ffmpeg_install() directory = Path().absolute() config = settings.check_toml( - f"{directory}/utils/.config.template.toml", "config.toml" + f"{directory}/utils/.config.template.toml", f"{directory}/config.toml" ) - config is False and exit() + config is False and sys.exit() + if ( not settings.config["settings"]["tts"]["tiktok_sessionid"] or settings.config["settings"]["tts"]["tiktok_sessionid"] == "" @@ -95,7 +98,7 @@ if __name__ == "__main__": "TikTok voice requires a sessionid! Check our documentation on how to obtain one.", "bold red", ) - exit() + sys.exit() try: if config["reddit"]["thread"]["post_id"]: for index, post_id in enumerate( @@ -114,13 +117,12 @@ if __name__ == "__main__": except KeyboardInterrupt: shutdown() except ResponseException: - # error for invalid credentials print_markdown("## Invalid credentials") print_markdown("Please check your credentials in the config.toml file") - shutdown() except Exception as err: config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED" + config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED" print_step( f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n" f"Version: {__VERSION__} \n" diff --git a/reddit/subreddit.py b/reddit/subreddit.py index 6251610..aea510b 100644 --- a/reddit/subreddit.py +++ b/reddit/subreddit.py @@ -107,20 +107,10 @@ def get_subreddit_threads(POST_ID: str): if submission is None: return get_subreddit_threads(POST_ID) # submission already done. rerun - if settings.config["settings"]["storymode"]: - if not submission.selftext: - print_substep("You are trying to use story mode on post with no post text") - exit() - else: - # Check for the length of the post text - if len(submission.selftext) > ( - settings.config["settings"]["storymode_max_length"] or 2000 - ): - print_substep( - f"Post is too long ({len(submission.selftext)}), try with a different post. ({settings.config['settings']['storymode_max_length']} character limit)" - ) - exit() - elif not submission.num_comments: + elif ( + not submission.num_comments + and settings.config["settings"]["storymode"] == "false" + ): print_substep("No comments found. Skipping.") exit() diff --git a/requirements.txt b/requirements.txt index 22d3d7f..e06ff0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,23 @@ -boto3==1.24.24 -botocore==1.27.24 -gTTS==2.2.4 +boto3==1.26.142 +botocore==1.29.142 +gTTS==2.3.2 moviepy==1.0.3 -playwright==1.23.0 -praw==7.6.1 +playwright==1.34.0 +praw==7.7.0 prawcore~=2.3.0 -pytube==12.1.0 -requests==2.28.1 -rich==13.3.1 +requests==2.31.0 +rich==13.3.5 toml==0.10.2 -translators==5.3.1 +translators==5.7.6 pyttsx3==2.90 -Pillow~=9.4.0 -tomlkit==0.11.4 -Flask==2.2.2 +Pillow==9.5.0 +tomlkit==0.11.8 +Flask==2.3.2 clean-text==0.6.0 -unidecode==1.3.2 -spacy==3.4.1 -torch==1.12.1 -transformers==4.25.1 -ffmpeg-python==0.2.0 \ No newline at end of file +unidecode==1.3.6 +spacy==3.5.3 +torch==2.0.1 +transformers==4.29.2 +ffmpeg-python==0.2.0 +elevenlabs==0.2.16 +yt-dlp==2023.3.4 \ No newline at end of file diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..38bbeb7 --- /dev/null +++ b/run.bat @@ -0,0 +1,15 @@ +@echo off +set VENV_DIR=.venv + +if exist "%VENV_DIR%" ( + echo Activating virtual environment... + call "%VENV_DIR%\Scripts\activate.bat" +) + +echo Running Python script... +python main.py + +if errorlevel 1 ( + echo An error occurred. Press any key to exit. + pause >nul +) diff --git a/utils/.config.template.toml b/utils/.config.template.toml index b2fa1d4..90e511f 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -14,7 +14,6 @@ max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "min_comment_length number of characters a comment can have. default is 0", example = 50, oob_error = "the max comment length should be between 1 and 100" } post_lang = { default = "", optional = true, explanation = "The language you would like to translate to.", example = "es-cr", options = ['','af', 'ak', 'am', 'ar', 'as', 'ay', 'az', 'be', 'bg', 'bho', 'bm', 'bn', 'bs', 'ca', 'ceb', 'ckb', 'co', 'cs', 'cy', 'da', 'de', 'doi', 'dv', 'ee', 'el', 'en', 'en-US', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gom', 'gu', 'ha', 'haw', 'hi', 'hmn', 'hr', 'ht', 'hu', 'hy', 'id', 'ig', 'ilo', 'is', 'it', 'iw', 'ja', 'jw', 'ka', 'kk', 'km', 'kn', 'ko', 'kri', 'ku', 'ky', 'la', 'lb', 'lg', 'ln', 'lo', 'lt', 'lus', 'lv', 'mai', 'mg', 'mi', 'mk', 'ml', 'mn', 'mni-Mtei', 'mr', 'ms', 'mt', 'my', 'ne', 'nl', 'no', 'nso', 'ny', 'om', 'or', 'pa', 'pl', 'ps', 'pt', 'qu', 'ro', 'ru', 'rw', 'sa', 'sd', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vi', 'xh', 'yi', 'yo', 'zh-CN', 'zh-TW', 'zu'] } min_comments = { default = 20, optional = false, nmin = 10, type = "int", explanation = "The minimum number of comments a post should have to be included. default is 20", example = 29, oob_error = "the minimum number of comments should be between 15 and 999999" } -#post_url = { optional = true, default = "", regex = "^https:\\/\\/www\\.reddit\\.com\\/r\\/[a-zA-Z0-9]+\\/comments\\/[a-zA-Z0-9]+\\/[a-zA-Z0-9_]+\\/$", explanation = "Not working currently Use if you want to use a specific post.", example = "https://www.reddit.com/r/buildapc/comments/yzh07p/have_you_switched_to_windows_11/" } [ai] ai_similarity_enabled = {optional = true, option = [true, false], default = false, type = "bool", explanation = "Threads read from Reddit are sorted based on their similarity to the keywords given below"} @@ -25,29 +24,34 @@ allow_nsfw = { optional = false, type = "bool", default = false, example = false theme = { optional = false, default = "dark", example = "light", options = ["dark", "light", "transparent", ], explanation = "Sets the Reddit theme, either LIGHT or DARK. For story mode you can also use a transparent background." } times_to_run = { optional = false, default = 1, example = 2, explanation = "Used if you want to run multiple times. Set to an int e.g. 4 or 29 or 1", type = "int", nmin = 1, oob_error = "It's very hard to run something less than once." } opacity = { optional = false, default = 0.9, example = 0.8, explanation = "Sets the opacity of the comments when overlayed over the background", type = "float", nmin = 0, nmax = 1, oob_error = "The opacity HAS to be between 0 and 1", input_error = "The opacity HAS to be a decimal number between 0 and 1" } -transition = { optional = true, default = 0.2, example = 0.2, explanation = "Sets the transition time (in seconds) between the comments. Set to 0 if you want to disable it.", type = "float", nmin = 0, nmax = 2, oob_error = "The transition HAS to be between 0 and 2", input_error = "The opacity HAS to be a decimal number between 0 and 2" } +#transition = { optional = true, default = 0.2, example = 0.2, explanation = "Sets the transition time (in seconds) between the comments. Set to 0 if you want to disable it.", type = "float", nmin = 0, nmax = 2, oob_error = "The transition HAS to be between 0 and 2", input_error = "The opacity HAS to be a decimal number between 0 and 2" } storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Only read out title and post content, great for subreddits with stories" } storymodemethod= { optional = true, default = 1, example = 1, explanation = "Style that's used for the storymode. Set to 0 for single picture display in whole video, set to 1 for fancy looking video ", type = "int", nmin = 0, oob_error = "It's very hard to run something less than once.", options = [0, 1] } storymode_max_length = { optional = true, default = 1000, example = 1000, explanation = "Max length of the storymode video in characters. 200 characters are approximately 50 seconds.", type = "int", nmin = 1, oob_error = "It's very hard to make a video under a second." } resolution_w = { optional = false, default = 1080, example = 1440, explantation = "Sets the width in pixels of the final video" } resolution_h = { optional = false, default = 1920, example = 2560, explantation = "Sets the height in pixels of the final video" } +zoom = { optional = true, default = 1, example = 1.1, explanation = "Sets the browser zoom level. Useful if you want the text larger.", type = "float", nmin = 0.1, nmax = 2, oob_error = "The text is really difficult to read at a zoom level higher than 2" } [settings.background] -background_choice = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2","multiversus","fall-guys","steep", ""], explanation = "Sets the background for the video based on game name" } -#background_audio = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Sets a audio to play in the background (put a background.mp3 file in the assets/backgrounds directory for it to be used.)" } -#background_audio_volume = { optional = true, type = "float", default = 0.3, example = 0.1, explanation="Sets the volume of the background audio. only used if the background_audio is also set to true" } +background_video = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2","multiversus","fall-guys","steep", ""], explanation = "Sets the background for the video based on game name" } +background_audio = { optional = true, default = "lofi", example = "chill-summer", options = ["lofi","lofi-2","chill-summer",""], explanation = "Sets the background audio for the video" } +background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation="Sets the volume of the background audio. If you don't want background audio, set it to 0.", oob_error = "The volume HAS to be between 0 and 1", input_error = "The volume HAS to be a float number between 0 and 1"} +enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation="Used if you want to render another video without background audio in a separate folder", input_error = "The value HAS to be true or false"} background_thumbnail = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Generate a thumbnail for the video (put a thumbnail.png file in the assets/backgrounds directory.)" } background_thumbnail_font_family = { optional = true, default = "arial", example = "arial", explanation = "Font family for the thumbnail text" } background_thumbnail_font_size = { optional = true, type = "int", default = 96, example = 96, explanation = "Font size in pixels for the thumbnail text" } background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Font color in RGB format for the thumbnail text" } [settings.tts] -voice_choice = { optional = false, default = "tiktok", options = ["streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", ], example = "tiktok", explanation = "The voice platform used for TTS generation. This can be left blank and you will be prompted to choose at runtime." } +voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", ], example = "tiktok", explanation = "The voice platform used for TTS generation. " } +random_voice = { optional = false, default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" } +elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] } +elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" } aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for AWS Polly" } streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" } tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "The voice used for TikTok TTS" } -tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok sessionid needed for the TTS API request. Check documentation if you don't know how to obtain it." } +tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok sessionid needed if you're using the TikTok TTS. Check documentation if you don't know how to obtain it." } python_voice = { optional = false, default = "1", example = "1", explanation = "The index of the system tts voices (can be downloaded externally, run ptt.py to find value, start from zero)" } py_voice_num = { optional = false, default = "2", example = "2", explanation = "The number of system voices (2 are pre-installed in Windows)" } -silence_duration = { optional = true, example = "0.1", explanation = "Time in seconds between TTS comments", default = 0.3, type = "float" } +#silence_duration = { optional = true, example = "0.1", explanation = "Time in seconds between TTS comments", default = 0.3, type = "float" } no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false,], explanation = "Whether to remove emojis from the comments" } \ No newline at end of file diff --git a/utils/background_audios.json b/utils/background_audios.json new file mode 100644 index 0000000..752436d --- /dev/null +++ b/utils/background_audios.json @@ -0,0 +1,18 @@ +{ + "__comment": "Supported Backgrounds Audio. Can add/remove background audio here...", + "lofi": [ + "https://www.youtube.com/watch?v=LTphVIore3A", + "lofi.mp3", + "Super Lofi World" + ], + "lofi-2":[ + "https://www.youtube.com/watch?v=BEXL80LS0-I", + "lofi-2.mp3", + "stompsPlaylist" + ], + "chill-summer":[ + "https://www.youtube.com/watch?v=EZE8JagnBI8", + "chill-summer.mp3", + "Mellow Vibes Radio" + ] +} diff --git a/utils/backgrounds.json b/utils/background_videos.json similarity index 100% rename from utils/backgrounds.json rename to utils/background_videos.json diff --git a/utils/cleanup.py b/utils/cleanup.py index e035980..6e00d4c 100644 --- a/utils/cleanup.py +++ b/utils/cleanup.py @@ -1,29 +1,20 @@ import os from os.path import exists +import shutil def _listdir(d): # listdir with full path return [os.path.join(d, f) for f in os.listdir(d)] -def cleanup(id) -> int: +def cleanup(reddit_id) -> int: """Deletes all temporary assets in assets/temp Returns: int: How many files were deleted """ - if exists(f"../assets/temp/{id}/"): - count = 0 - files = [f for f in os.listdir(f"../assets/temp/{id}/") if f.endswith(".mp4")] - count += len(files) - for f in files: - os.remove(f"../assets/temp/{id}/{f}") - REMOVE_DIRS = [f"../assets/temp/{id}/mp3/", f"../assets/temp/{id}/png/"] - for d in REMOVE_DIRS: - if exists(d): - count += len(_listdir(d)) - for f in _listdir(d): - os.remove(f) - os.rmdir(d) - os.rmdir(f"../assets/temp/{id}/") - return count + directory = f"../assets/temp/{reddit_id}/" + if exists(directory): + shutil.rmtree(directory) + + return 1 diff --git a/utils/ffmpeg_install.py b/utils/ffmpeg_install.py index 5522028..1171d96 100644 --- a/utils/ffmpeg_install.py +++ b/utils/ffmpeg_install.py @@ -17,28 +17,36 @@ def ffmpeg_install_windows(): os.rename("ffmpeg-master-latest-win64-gpl", "ffmpeg") # Move the files inside bin to the root for file in os.listdir("ffmpeg/bin"): - os.rename(f"ffmpeg/bin/{file}", f"ffmpeg/{file}") + os.rename(f"ffmpeg/bin/{file}", f"./{file}") os.rmdir("ffmpeg/bin") for file in os.listdir("ffmpeg/doc"): os.remove(f"ffmpeg/doc/{file}") os.rmdir("ffmpeg/doc") - # Add to the path - subprocess.run("setx /M PATH \"%PATH%;%CD%\\ffmpeg\"", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + os.remove("ffmpeg/LICENSE.txt") + os.rmdir("ffmpeg/") + print("FFmpeg installed successfully! Please restart your computer and then re-run the program.") - exit() + except Exception as e: print( - "An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again.") + "An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again." + ) print(e) exit() def ffmpeg_install_linux(): try: - subprocess.run("sudo apt install ffmpeg", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subprocess.run( + "sudo apt install ffmpeg", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) except Exception as e: print( - "An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again.") + "An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again." + ) print(e) exit() print("FFmpeg installed successfully! Please re-run the program.") @@ -47,10 +55,16 @@ def ffmpeg_install_linux(): def ffmpeg_install_mac(): try: - subprocess.run("brew install ffmpeg", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subprocess.run( + "brew install ffmpeg", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) except FileNotFoundError: print( - "Homebrew is not installed. Please install it and try again. Otherwise, please install FFmpeg manually and try again.") + "Homebrew is not installed. Please install it and try again. Otherwise, please install FFmpeg manually and try again." + ) exit() print("FFmpeg installed successfully! Please re-run the program.") exit() @@ -60,10 +74,14 @@ def ffmpeg_install(): try: # Try to run the FFmpeg command subprocess.run(['ffmpeg', '-version'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - print('FFmpeg is installed on this system! If you are seeing this error for the second time, restart your computer.') except FileNotFoundError as e: + # Check if there's ffmpeg.exe in the current directory + if os.path.exists("./ffmpeg.exe"): + print('FFmpeg is installed on this system! If you are seeing this error for the second time, restart your computer.') print('FFmpeg is not installed on this system.') - resp = input("We can try to automatically install it for you. Would you like to do that? (y/n): ") + resp = input( + "We can try to automatically install it for you. Would you like to do that? (y/n): " + ) if resp.lower() == "y": print("Installing FFmpeg...") if os.name == "nt": @@ -73,12 +91,16 @@ def ffmpeg_install(): elif os.name == "mac": ffmpeg_install_mac() else: - print("Your OS is not supported. Please install FFmpeg manually and try again.") + print( + "Your OS is not supported. Please install FFmpeg manually and try again." + ) exit() else: print("Please install FFmpeg manually and try again.") exit() except Exception as e: - print("Welcome fellow traveler! You're one of the few who have made it this far. We have no idea how you got at this error, but we're glad you're here. Please report this error to the developer, and we'll try to fix it as soon as possible. Thank you for your patience!") + print( + "Welcome fellow traveler! You're one of the few who have made it this far. We have no idea how you got at this error, but we're glad you're here. Please report this error to the developer, and we'll try to fix it as soon as possible. Thank you for your patience!" + ) print(e) - return None \ No newline at end of file + return None diff --git a/utils/imagenarator.py b/utils/imagenarator.py index a3883b6..9427a42 100644 --- a/utils/imagenarator.py +++ b/utils/imagenarator.py @@ -57,20 +57,18 @@ def imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) -> """ Render Images for video """ - title = process_text( - reddit_obj["thread_title"], False - ) + title = process_text(reddit_obj["thread_title"], False) texts = reddit_obj["thread_post"] id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"]) if transparent: - font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 50) - tfont = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 50) + font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 100) + tfont = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 100) else: tfont = ImageFont.truetype( - os.path.join("fonts", "Roboto-Bold.ttf"), 35 + os.path.join("fonts", "Roboto-Bold.ttf"), 100 ) # for title - font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), 30) + font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), 100) size = (1920, 1080) image = Image.new("RGBA", size, theme) diff --git a/utils/playwright.py b/utils/playwright.py new file mode 100644 index 0000000..be046e6 --- /dev/null +++ b/utils/playwright.py @@ -0,0 +1,7 @@ +def clear_cookie_by_name(context, cookie_cleared_name): + cookies = context.cookies() + filtered_cookies = [ + cookie for cookie in cookies if cookie["name"] != cookie_cleared_name + ] + context.clear_cookies() + context.add_cookies(filtered_cookies) diff --git a/utils/posttextparser.py b/utils/posttextparser.py index 9b1e306..120d4aa 100644 --- a/utils/posttextparser.py +++ b/utils/posttextparser.py @@ -1,4 +1,7 @@ +import os import re +import time +from typing import List import spacy @@ -7,25 +10,25 @@ from utils.voice import sanitize_text # working good -def posttextparser(obj): - text = re.sub("\n", "", obj) - +def posttextparser(obj, *, tried: bool = False) -> List[str]: + text: str = re.sub("\n", " ", obj) try: nlp = spacy.load("en_core_web_sm") - except OSError: + except OSError as e: + if not tried: + os.system("python -m spacy download en_core_web_sm") + time.sleep(5) + return posttextparser(obj, tried=True) print_step( - "The spacy model can't load. You need to install it with the command \npython -m spacy download en_core_web_sm" - ) - exit() + "The spacy model can't load. You need to install it with the command \npython -m spacy download en_core_web_sm ") + raise e doc = nlp(text) newtext: list = [] - # to check for space str for line in doc.sents: if sanitize_text(line.text): newtext.append(line.text) - # print(line) return newtext diff --git a/utils/subreddit.py b/utils/subreddit.py index a883e78..ac4a604 100644 --- a/utils/subreddit.py +++ b/utils/subreddit.py @@ -57,6 +57,23 @@ def get_subreddit_undone( f'This post has under the specified minimum of comments ({settings.config["reddit"]["thread"]["min_comments"]}). Skipping...' ) continue + if settings.config["settings"]["storymode"]: + if not submission.selftext: + print_substep( + "You are trying to use story mode on post with no post text" + ) + continue + else: + # Check for the length of the post text + if len(submission.selftext) > ( + settings.config["settings"]["storymode_max_length"] or 2000 + ): + print_substep( + f"Post is too long ({len(submission.selftext)}), try with a different post. ({settings.config['settings']['storymode_max_length']} character limit)" + ) + continue + elif len(submission.selftext) < 30: + continue if settings.config["settings"]["storymode"] and not submission.is_self: continue if similarity_scores is not None: @@ -73,7 +90,7 @@ def get_subreddit_undone( ] # set doesn't have __getitem__ index = times_checked + 1 if index == len(VALID_TIME_FILTERS): - print("all time filters have been checked you absolute madlad ") + print("All submissions have been done.") return get_subreddit_undone( subreddit.top( diff --git a/video_creation/background.py b/video_creation/background.py index 0458ce6..68c255a 100644 --- a/video_creation/background.py +++ b/video_creation/background.py @@ -3,29 +3,36 @@ import random import re from pathlib import Path from random import randrange -from typing import Any, Tuple +from typing import Any, Tuple, Dict -from moviepy.editor import VideoFileClip +from moviepy.editor import VideoFileClip, AudioFileClip from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip -from pytube import YouTube -from pytube.cli import on_progress from utils import settings from utils.console import print_step, print_substep +import yt_dlp -# Load background videos -with open("./utils/backgrounds.json") as json_file: - background_options = json.load(json_file) -# Remove "__comment" from backgrounds -background_options.pop("__comment", None) +def load_background_options(): + background_options = {} + # Load background videos + with open("./utils/background_videos.json") as json_file: + background_options["video"] = json.load(json_file) -# Add position lambda function -# (https://zulko.github.io/moviepy/ref/VideoClip/VideoClip.html#moviepy.video.VideoClip.VideoClip.set_position) -for name in list(background_options.keys()): - pos = background_options[name][3] + # Load background audios + with open("./utils/background_audios.json") as json_file: + background_options["audio"] = json.load(json_file) - if pos != "center": - background_options[name][3] = lambda t: ("center", pos + t) + # Remove "__comment" from backgrounds + del background_options["video"]["__comment"] + del background_options["audio"]["__comment"] + + for name in list(background_options["video"].keys()): + pos = background_options["video"][name][3] + + if pos != "center": + background_options["video"][name][3] = lambda t: ("center", pos + t) + + return background_options def get_start_and_end_times(video_length: int, length_of_clip: int) -> Tuple[int, int]: @@ -38,15 +45,22 @@ def get_start_and_end_times(video_length: int, length_of_clip: int) -> Tuple[int Returns: tuple[int,int]: Start and end time of the randomized interval """ - random_time = randrange(180, int(length_of_clip) - int(video_length)) + initialValue = 180 + # Issue #1649 - Ensures that will be a valid interval in the video + while int(length_of_clip) <= int(video_length + initialValue): + if initialValue == initialValue // 2: + raise Exception("Your background is too short for this video length") + else: + initialValue //= 2 # Divides the initial value by 2 until reach 0 + random_time = randrange(initialValue, int(length_of_clip) - int(video_length)) return random_time, random_time + video_length -def get_background_config(): +def get_background_config(mode: str): """Fetch the background/s configuration""" try: choice = str( - settings.config["settings"]["background"]["background_choice"] + settings.config["settings"]["background"][f"background_{mode}"] ).casefold() except AttributeError: print_substep("No background selected. Picking random background'") @@ -54,57 +68,106 @@ def get_background_config(): # Handle default / not supported background using default option. # Default : pick random from supported background. - if not choice or choice not in background_options: - choice = random.choice(list(background_options.keys())) + if not choice or choice not in background_options[mode]: + choice = random.choice(list(background_options[mode].keys())) - return background_options[choice] + return background_options[mode][choice] -def download_background(background_config: Tuple[str, str, str, Any]): +def download_background_video(background_config: Tuple[str, str, str, Any]): """Downloads the background/s video from YouTube.""" - Path("./assets/backgrounds/").mkdir(parents=True, exist_ok=True) + Path("./assets/backgrounds/video/").mkdir(parents=True, exist_ok=True) # note: make sure the file name doesn't include an - in it uri, filename, credit, _ = background_config - if Path(f"assets/backgrounds/{credit}-{filename}").is_file(): + if Path(f"assets/backgrounds/video/{credit}-{filename}").is_file(): return print_step( "We need to download the backgrounds videos. they are fairly large but it's only done once. 😎" ) print_substep("Downloading the backgrounds videos... please be patient 🙏 ") print_substep(f"Downloading {filename} from {uri}") - YouTube(uri, on_progress_callback=on_progress).streams.filter( - res="1080p" - ).first().download("assets/backgrounds", filename=f"{credit}-{filename}") + ydl_opts = { + "format": "bestvideo[height<=1080][ext=mp4]", + "outtmpl": f"assets/backgrounds/video/{credit}-{filename}", + "retries": 10, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download(uri) print_substep("Background video downloaded successfully! 🎉", style="bold green") -def chop_background_video( - background_config: Tuple[str, str, str, Any], video_length: int, reddit_object: dict +def download_background_audio(background_config: Tuple[str, str, str]): + """Downloads the background/s audio from YouTube.""" + Path("./assets/backgrounds/audio/").mkdir(parents=True, exist_ok=True) + # note: make sure the file name doesn't include an - in it + uri, filename, credit = background_config + if Path(f"assets/backgrounds/audio/{credit}-{filename}").is_file(): + return + print_step( + "We need to download the backgrounds audio. they are fairly large but it's only done once. 😎" + ) + print_substep("Downloading the backgrounds audio... please be patient 🙏 ") + print_substep(f"Downloading {filename} from {uri}") + ydl_opts = { + "outtmpl": f"./assets/backgrounds/audio/{credit}-{filename}", + "format": "bestaudio/best", + "extract_audio": True, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([uri]) + + print_substep("Background audio downloaded successfully! 🎉", style="bold green") + + +def chop_background( + background_config: Dict[str, Tuple], video_length: int, reddit_object: dict ): - """Generates the background footage to be used in the video and writes it to assets/temp/background.mp4 + """Generates the background audio and footage to be used in the video and writes it to assets/temp/background.mp3 and assets/temp/background.mp4 Args: - background_config (Tuple[str, str, str, Any]) : Current background configuration + background_config (Dict[str,Tuple]]) : Current background configuration video_length (int): Length of the clip where the background footage is to be taken out of """ - - print_step("Finding a spot in the backgrounds video to chop...✂️") - choice = f"{background_config[2]}-{background_config[1]}" id = re.sub(r"[^\w\s-]", "", reddit_object["thread_id"]) - background = VideoFileClip(f"assets/backgrounds/{choice}") - start_time, end_time = get_start_and_end_times(video_length, background.duration) + if settings.config["settings"]["background"][f"background_audio_volume"] == 0: + print_step("Volume was set to 0. Skipping background audio creation . . .") + else: + print_step("Finding a spot in the backgrounds audio to chop...✂️") + audio_choice = ( + f"{background_config['audio'][2]}-{background_config['audio'][1]}" + ) + background_audio = AudioFileClip(f"assets/backgrounds/audio/{audio_choice}") + start_time_audio, end_time_audio = get_start_and_end_times( + video_length, background_audio.duration + ) + background_audio = background_audio.subclip(start_time_audio, end_time_audio) + background_audio.write_audiofile(f"assets/temp/{id}/background.mp3") + + print_step("Finding a spot in the backgrounds video to chop...✂️") + video_choice = f"{background_config['video'][2]}-{background_config['video'][1]}" + background_video = VideoFileClip(f"assets/backgrounds/video/{video_choice}") + start_time_video, end_time_video = get_start_and_end_times( + video_length, background_video.duration + ) + # Extract video subclip try: ffmpeg_extract_subclip( - f"assets/backgrounds/{choice}", - start_time, - end_time, + f"assets/backgrounds/video/{video_choice}", + start_time_video, + end_time_video, targetname=f"assets/temp/{id}/background.mp4", ) except (OSError, IOError): # ffmpeg issue see #348 print_substep("FFMPEG issue. Trying again...") - with VideoFileClip(f"assets/backgrounds/{choice}") as video: - new = video.subclip(start_time, end_time) + with VideoFileClip(f"assets/backgrounds/video/{video_choice}") as video: + new = video.subclip(start_time_video, end_time_video) new.write_videofile(f"assets/temp/{id}/background.mp4") print_substep("Background video chopped successfully!", style="bold green") - return background_config[2] + return background_config["video"][2] + + +# Create a tuple for downloads background (background_audio_options, background_video_options) +background_options = load_background_options() diff --git a/video_creation/final_video.py b/video_creation/final_video.py index 4838574..52ebe04 100644 --- a/video_creation/final_video.py +++ b/video_creation/final_video.py @@ -1,29 +1,28 @@ import multiprocessing import os import re -import shutil -from os.path import exists # Needs to be imported specifically +from os.path import exists # Needs to be imported specifically from typing import Final -from typing import Tuple, Any +from typing import Tuple, Any, Dict import ffmpeg -import translators as ts +import translators from PIL import Image from rich.console import Console from rich.progress import track -from utils import settings from utils.cleanup import cleanup from utils.console import print_step, print_substep from utils.thumbnail import create_thumbnail from utils.videos import save_data - -console = Console() +from utils import settings import tempfile import threading import time +console = Console() + class ProgressFfmpeg(threading.Thread): def __init__(self, vid_duration_seconds, progress_update_callback): @@ -73,7 +72,7 @@ def name_normalize(name: str) -> str: lang = settings.config["reddit"]["thread"]["post_lang"] if lang: print_substep("Translating filename...") - translated_name = ts.google(name, to_language=lang) + translated_name = translators.google(name, to_language=lang) return translated_name else: return name @@ -98,17 +97,39 @@ def prepare_background(reddit_id: str, W: int, H: int) -> str: ) try: output.run(quiet=True) - except Exception as e: - print(e) - exit() + except ffmpeg.Error as e: + print(e.stderr.decode("utf8")) + exit(1) return output_path +def merge_background_audio(audio: ffmpeg, reddit_id: str): + """Gather an audio and merge with assets/backgrounds/background.mp3 + Args: + audio (ffmpeg): The TTS final audio but without background. + reddit_id (str): The ID of subreddit + """ + background_audio_volume = settings.config["settings"]["background"][ + "background_audio_volume" + ] + if background_audio_volume == 0: + return audio # Return the original audio + else: + # sets volume to config + bg_audio = ffmpeg.input(f"assets/temp/{reddit_id}/background.mp3").filter( + "volume", + background_audio_volume, + ) + # Merges audio and background_audio + merged_audio = ffmpeg.filter([audio, bg_audio], "amix", duration="longest") + return merged_audio # Return merged audio + + def make_final_video( number_of_clips: int, length: int, reddit_obj: dict, - background_config: Tuple[str, str, str, Any], + background_config: Dict[str, Tuple], ): """Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp Args: @@ -121,13 +142,26 @@ def make_final_video( W: Final[int] = int(settings.config["settings"]["resolution_w"]) H: Final[int] = int(settings.config["settings"]["resolution_h"]) + opacity = settings.config["settings"]["opacity"] + reddit_id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"]) + + allowOnlyTTSFolder: bool = ( + settings.config["settings"]["background"]["enable_extra_audio"] + and settings.config["settings"]["background"]["background_audio_volume"] != 0 + ) + print_step("Creating the final video 🎥") background_clip = ffmpeg.input(prepare_background(reddit_id, W=W, H=H)) # Gather all audio clips audio_clips = list() + if number_of_clips == 0 and settings.config["settings"]["storymode"] == "false": + print( + "No audio clips to gather. Please use a different TTS or post." + ) # This is to fix the TypeError: unsupported operand type(s) for +: 'int' and 'NoneType' + exit() if settings.config["settings"]["storymode"]: if settings.config["settings"]["storymodemethod"] == 0: audio_clips = [ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3")] @@ -177,6 +211,7 @@ def make_final_video( screenshot_width = int((W * 45) // 100) audio = ffmpeg.input(f"assets/temp/{reddit_id}/audio.mp3") + final_audio = merge_background_audio(audio, reddit_id) image_clips = list() @@ -242,8 +277,9 @@ def make_final_video( "v" ].filter("scale", screenshot_width, -1) ) + image_overlay = image_clips[i].filter("colorchannelmixer", aa=opacity) background_clip = background_clip.overlay( - image_clips[i], + image_overlay, enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})", x="(main_w-overlay_w)/2", y="(main_h-overlay_h)/2", @@ -258,15 +294,25 @@ def make_final_video( subreddit = settings.config["reddit"]["thread"]["subreddit"] if not exists(f"./results/{subreddit}"): - print_substep("The results folder didn't exist so I made it") + print_substep( + "The 'results' folder could not be found so it was automatically created." + ) os.makedirs(f"./results/{subreddit}") + if not exists(f"./results/{subreddit}/OnlyTTS") and allowOnlyTTSFolder: + print_substep( + "The 'OnlyTTS' folder could not be found so it was automatically created." + ) + os.makedirs(f"./results/{subreddit}/OnlyTTS") + # create a thumbnail for the video settingsbackground = settings.config["settings"]["background"] if settingsbackground["background_thumbnail"]: if not exists(f"./results/{subreddit}/thumbnails"): - print_substep("The results/thumbnails folder didn't exist so I made it") + print_substep( + "The 'results/thumbnails' folder could not be found so it was automatically created." + ) os.makedirs(f"./results/{subreddit}/thumbnails") # get the first file with the .png extension from assets/backgrounds and use it as a background for the thumbnail first_image = next( @@ -300,34 +346,36 @@ def make_final_video( f"Thumbnail - Building Thumbnail in assets/temp/{reddit_id}/thumbnail.png" ) - text = f"Background by {background_config[2]}" + text = f"Background by {background_config['video'][2]}" background_clip = ffmpeg.drawtext( background_clip, text=text, x=f"(w-text_w)", y=f"(h-text_h)", - fontsize=12, + fontsize=5, fontcolor="White", fontfile=os.path.join("fonts", "Roboto-Regular.ttf"), ) + background_clip = background_clip.filter("scale", W, H) print_step("Rendering the video 🎥") from tqdm import tqdm pbar = tqdm(total=100, desc="Progress: ", bar_format="{l_bar}{bar}", unit=" %") - def on_update_example(progress): + def on_update_example(progress) -> None: status = round(progress * 100, 2) old_percentage = pbar.n pbar.update(status - old_percentage) - path = f"results/{subreddit}/{filename}" - path = path[:251] - path = path + ".mp4" - + defaultPath = f"results/{subreddit}" with ProgressFfmpeg(length, on_update_example) as progress: + path = defaultPath + f"/{filename}" + path = ( + path[:251] + ".mp4" + ) # Prevent a error by limiting the path length, do not change this. ffmpeg.output( background_clip, - audio, + final_audio, path, f="mp4", **{ @@ -342,12 +390,41 @@ def make_final_video( capture_stdout=False, capture_stderr=False, ) - old_percentage = pbar.n pbar.update(100 - old_percentage) - pbar.close() + if allowOnlyTTSFolder: + path = defaultPath + f"/OnlyTTS/{filename}" + path = ( + path[:251] + ".mp4" + ) # Prevent a error by limiting the path length, do not change this. + print_step("Rendering the Only TTS Video 🎥") + with ProgressFfmpeg(length, on_update_example) as progress: + try: + ffmpeg.output( + background_clip, + audio, + path, + f="mp4", + **{ + "c:v": "h264", + "b:v": "20M", + "b:a": "192k", + "threads": multiprocessing.cpu_count(), + }, + ).overwrite_output().global_args("-progress", progress.output_file.name).run( + quiet=True, + overwrite_output=True, + capture_stdout=False, + capture_stderr=False, + ) + except ffmpeg.Error as e: + print(e.stderr.decode("utf8")) + exit(1) - save_data(subreddit, filename + ".mp4", title, idx, background_config[2]) + old_percentage = pbar.n + pbar.update(100 - old_percentage) + pbar.close() + save_data(subreddit, filename + ".mp4", title, idx, background_config["video"][2]) print_step("Removing temporary files 🗑") cleanups = cleanup(reddit_id) print_substep(f"Removed {cleanups} temporary files 🗑") diff --git a/video_creation/screenshot_downloader.py b/video_creation/screenshot_downloader.py index 1c3ab5e..f7510f6 100644 --- a/video_creation/screenshot_downloader.py +++ b/video_creation/screenshot_downloader.py @@ -3,7 +3,7 @@ import re from pathlib import Path from typing import Dict, Final -import translators as ts +import translators from playwright.async_api import async_playwright # pylint: disable=unused-import from playwright.sync_api import ViewportSize, sync_playwright from rich.progress import track @@ -11,6 +11,7 @@ from rich.progress import track from utils import settings from utils.console import print_step, print_substep from utils.imagenarator import imagemaker +from utils.playwright import clear_cookie_by_name from utils.videos import save_data @@ -116,7 +117,30 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): page.locator("button[class$='m-full-width']").click() page.wait_for_timeout(5000) + login_error_div = page.locator(".AnimatedForm__errorMessage").first + if login_error_div.is_visible(): + login_error_message = login_error_div.inner_text() + if login_error_message.strip() == "": + # The div element is empty, no error + pass + else: + # The div contains an error message + print_substep( + "Your reddit credentials are incorrect! Please modify them accordingly in the config.toml file.", + style="red", + ) + exit() + else: + pass + page.wait_for_load_state() + # Handle the redesign + # Check if the redesign optout cookie is set + if page.locator("#redesign-beta-optin-btn").is_visible(): + # Clear the redesign optout cookie + clear_cookie_by_name(context, "redesign_optout") + # Reload the page for the redesign to take effect + page.reload() # Get the thread screenshot page.goto(reddit_object["thread_url"], timeout=0) page.set_viewport_size(ViewportSize(width=W, height=H)) @@ -144,13 +168,13 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): if lang: print_substep("Translating post...") - texts_in_tl = ts.google( + texts_in_tl = translators.google( reddit_object["thread_title"], to_language=lang, ) page.evaluate( - "tl_content => document.querySelector('[data-test-id=\"post-content\"] > div:nth-child(3) > div > div').textContent = tl_content", + "tl_content => document.querySelector('[data-adclicklocation=\"title\"] > div > div > h1').textContent = tl_content", texts_in_tl, ) else: @@ -158,9 +182,20 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): postcontentpath = f"assets/temp/{reddit_id}/png/title.png" try: - page.locator('[data-test-id="post-content"]').screenshot( - path=postcontentpath - ) + if settings.config["settings"]["zoom"] != 1: + # store zoom settings + zoom = settings.config["settings"]["zoom"] + # zoom the body of the page + page.evaluate("document.body.style.zoom=" + str(zoom)) + # as zooming the body doesn't change the properties of the divs, we need to adjust for the zoom + location = page.locator('[data-test-id="post-content"]').bounding_box() + for i in location: + location[i] = float("{:.2f}".format(location[i] * zoom)) + page.screenshot(clip=location, path=postcontentpath) + else: + page.locator('[data-test-id="post-content"]').screenshot( + path=postcontentpath + ) except Exception as e: print_substep("Something went wrong!", style="red") resp = input( @@ -202,10 +237,10 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): page.goto(f'https://reddit.com{comment["comment_url"]}', timeout=0) - # translate code + # translate code if settings.config["reddit"]["thread"]["post_lang"]: - comment_tl = ts.google( + comment_tl = translators.google( comment["comment_body"], to_language=settings.config["reddit"]["thread"]["post_lang"], ) @@ -214,9 +249,29 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): [comment_tl, comment["comment_id"]], ) try: - page.locator(f"#t1_{comment['comment_id']}").screenshot( - path=f"assets/temp/{reddit_id}/png/comment_{idx}.png" - ) + if settings.config["settings"]["zoom"] != 1: + # store zoom settings + zoom = settings.config["settings"]["zoom"] + # zoom the body of the page + page.evaluate("document.body.style.zoom=" + str(zoom)) + # scroll comment into view + page.locator( + f"#t1_{comment['comment_id']}" + ).scroll_into_view_if_needed() + # as zooming the body doesn't change the properties of the divs, we need to adjust for the zoom + location = page.locator( + f"#t1_{comment['comment_id']}" + ).bounding_box() + for i in location: + location[i] = float("{:.2f}".format(location[i] * zoom)) + page.screenshot( + clip=location, + path=f"assets/temp/{reddit_id}/png/comment_{idx}.png", + ) + else: + page.locator(f"#t1_{comment['comment_id']}").screenshot( + path=f"assets/temp/{reddit_id}/png/comment_{idx}.png" + ) except TimeoutError: del reddit_object["comments"] screenshot_num += 1 @@ -227,3 +282,4 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): browser.close() print_substep("Screenshots downloaded Successfully.", style="bold green") + diff --git a/video_creation/voices.py b/video_creation/voices.py index 425f589..0dcc87d 100644 --- a/video_creation/voices.py +++ b/video_creation/voices.py @@ -7,6 +7,7 @@ from TTS.TikTok import TikTok from TTS.aws_polly import AWSPolly from TTS.engine_wrapper import TTSEngine from TTS.pyttsx import pyttsx +from TTS.elevenlabs import elevenlabs from TTS.streamlabs_polly import StreamlabsPolly from utils import settings from utils.console import print_table, print_step @@ -19,6 +20,7 @@ TTSProviders = { "StreamlabsPolly": StreamlabsPolly, "TikTok": TikTok, "pyttsx": pyttsx, + "ElevenLabs": elevenlabs, }