diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index 6d498d2..1cc59de 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -15,7 +15,7 @@ from utils.console import print_step, print_substep from utils.voice import sanitize_text DEFAULT_MAX_LENGTH: int = ( - 50 # Video length variable, edit this on your own risk. It should work, but it's not supported + 300 # Video length variable, edit this on your own risk. It should work, but it's not supported ) @@ -144,11 +144,20 @@ class TTSEngine: print("OSError") def call_tts(self, filename: str, text: str): - self.tts_module.run( - text, - filepath=f"{self.path}/{filename}.mp3", - random_voice=settings.config["settings"]["tts"]["random_voice"], - ) + # Check if the TTS module supports random_voice parameter + import inspect + run_signature = inspect.signature(self.tts_module.run) + if 'random_voice' in run_signature.parameters: + self.tts_module.run( + text, + filepath=f"{self.path}/{filename}.mp3", + random_voice=settings.config["settings"]["tts"]["random_voice"], + ) + else: + self.tts_module.run( + text, + filepath=f"{self.path}/{filename}.mp3", + ) # try: # self.length += MP3(f"{self.path}/{filename}.mp3").info.length # except (MutagenError, HeaderNotFoundError): diff --git a/main.py b/main.py index 849663d..b8d4ef1 100755 --- a/main.py +++ b/main.py @@ -79,9 +79,9 @@ def shutdown() -> NoReturn: if __name__ == "__main__": - if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11]: + if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12, 13]: 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." + "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." ) sys.exit() ffmpeg_install() diff --git a/requirements.txt b/requirements.txt index e6e2e7b..cbd4389 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ boto3==1.34.127 botocore==1.34.127 gTTS==2.5.1 moviepy==1.0.3 -playwright==1.44.0 +playwright>=1.45.0 praw==7.7.1 prawcore~=2.3.0 requests==2.32.3 @@ -10,15 +10,11 @@ rich==13.7.1 toml==0.10.2 translators==5.9.2 pyttsx3==2.90 -Pillow==10.3.0 +Pillow>=10.4.0 tomlkit==0.12.5 Flask==3.0.3 clean-text==0.6.0 unidecode==1.3.8 -spacy==3.7.5 -torch==2.3.1 -transformers==4.41.2 ffmpeg-python==0.2.0 elevenlabs==1.3.0 yt-dlp==2024.5.27 -numpy==1.26.4 diff --git a/target.py b/target.py new file mode 100644 index 0000000..dce91f1 --- /dev/null +++ b/target.py @@ -0,0 +1,291 @@ +import math +import re +import sys +from os import name +from pathlib import Path +from subprocess import Popen +from typing import NoReturn + +import praw +from prawcore.exceptions import ResponseException + +from reddit.subreddit import get_subreddit_threads +from utils import settings +from utils.cleanup import cleanup +from utils.console import print_markdown, print_step, print_substep +from utils.ffmpeg_install import ffmpeg_install +from utils.version import checkversion +from video_creation.background import download_background_audio, download_background_video +from video_creation.final_video import make_final_video +from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts +from video_creation.voices import save_text_to_mp3 +from video_creation.background import chop_background, get_background_config + +__VERSION__ = "3.3.0" + +print_markdown( + """# Reddit Video Maker Bot + +██████╗ ███████╗██████╗ ██████╗ ██╗████████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ ███╗ ███╗ █████╗ +██╔══██╗██╔════╝██╔══██╗██╔══██╗██║╚══██╔══╝ ██║ ██║██║██╔══██╗██╔════╝██╔═══██╗ ████╗ ████║██╔══██╗ +██║ ██╔╝██╔════╝██╔══██╗██████╔╝█████╗ ██║ ██║██║ ██║██║██║ ██║█████╗ ██║ ██║ ██╔████╔██║███████║ +█████╔╝ █████╗ ██████╔╝██████╔╝██╔══╝ ██║ ██║██║ ██║██║██║ ██║██╔══╝ ██║ ██║ ██║╚██╔╝██║██╔══██║ +██╔═██╗ ██╔══╝ ██╔══██╗██╔═██╗ ██╔══╝ ██║ ██║╚██╗ ██╔╝██║██║ ██║███████╗╚██████╔╝ ██║ ╚═╝ ██║██║ ██║ +██║ ██╗███████╗██║ ██║██║ ██╗███████╗██║ ██║ ╚████╔╝ ██║██████╔╝╚══════╝ ╚═════╝ ██║ ██║╚═╝ ██║ +╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ +╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝""" +) +print_markdown( + "### 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__) + + +def extract_post_id_from_url(url: str) -> str: + """ + Extract the post ID from a Reddit URL. + + Args: + url (str): Reddit post URL + + Returns: + str: Post ID + """ + # Handle different Reddit URL formats + patterns = [ + r'reddit\.com/r/\w+/comments/(\w+)/', # Standard format + r'reddit\.com/comments/(\w+)/', # Short format + r'reddit\.com/r/\w+/comments/(\w+)', # Without trailing slash + r'reddit\.com/comments/(\w+)', # Short format without trailing slash + ] + + for pattern in patterns: + match = re.search(pattern, url) + if match: + return match.group(1) + + raise ValueError("Invalid Reddit URL format. Please provide a valid Reddit post URL.") + + +def get_post_from_url(url: str) -> dict: + """ + Get Reddit post data from a specific URL. + + Args: + url (str): Reddit post URL + + Returns: + dict: Reddit post data + """ + print_substep("Extracting post ID from URL...") + + try: + post_id = extract_post_id_from_url(url) + print_substep(f"Post ID extracted: {post_id}") + except ValueError as e: + print_substep(f"Error: {e}", style="red") + sys.exit(1) + + print_substep("Logging into Reddit...") + + # Setup Reddit authentication + if settings.config["reddit"]["creds"]["2fa"]: + print("\nEnter your two-factor authentication code from your authenticator app.\n") + code = input("> ") + print() + pw = settings.config["reddit"]["creds"]["password"] + passkey = f"{pw}:{code}" + else: + passkey = settings.config["reddit"]["creds"]["password"] + + username = settings.config["reddit"]["creds"]["username"] + if str(username).casefold().startswith("u/"): + username = username[2:] + + try: + reddit = praw.Reddit( + client_id=settings.config["reddit"]["creds"]["client_id"], + client_secret=settings.config["reddit"]["creds"]["client_secret"], + user_agent="Accessing Reddit threads", + username=username, + passkey=passkey, + check_for_async=False, + ) + except ResponseException as e: + if e.response.status_code == 401: + print("Invalid credentials - please check them in config.toml") + sys.exit(1) + except Exception as e: + print(f"Something went wrong: {e}") + sys.exit(1) + + print_substep("Fetching post data...") + + try: + submission = reddit.submission(id=post_id) + # Force the submission to load all data + submission.title + submission.selftext + submission.score + submission.upvote_ratio + submission.num_comments + submission.permalink + submission.over_18 + submission.is_self + except Exception as e: + print_substep(f"Error fetching post: {e}", style="red") + sys.exit(1) + + # Check if post is NSFW + if submission.over_18 and not settings.config["settings"]["allow_nsfw"]: + print_substep("This post is NSFW and NSFW content is not allowed in your config.", style="red") + sys.exit(1) + + # Check if post has content for story mode + if settings.config["settings"]["storymode"]: + if not submission.selftext: + print_substep("This post has no text content for story mode.", style="red") + sys.exit(1) + + if len(submission.selftext) > settings.config["settings"]["storymode_max_length"]: + print_substep(f"Post is too long ({len(submission.selftext)} characters). Max allowed: {settings.config['settings']['storymode_max_length']}", style="red") + sys.exit(1) + + if len(submission.selftext) < 30: + print_substep("Post is too short (less than 30 characters).", style="red") + sys.exit(1) + + # Build content dictionary + content = { + "thread_url": f"https://new.reddit.com{submission.permalink}", + "thread_title": submission.title, + "thread_id": submission.id, + "is_nsfw": submission.over_18, + "comments": [] + } + + # Process content based on story mode + if settings.config["settings"]["storymode"]: + if settings.config["settings"]["storymodemethod"] == 1: + from utils.posttextparser import posttextparser + content["thread_post"] = posttextparser(submission.selftext) + else: + content["thread_post"] = submission.selftext + else: + # Process comments (not used in story mode) + for top_level_comment in submission.comments: + if hasattr(top_level_comment, 'body') and top_level_comment.body not in ["[removed]", "[deleted]"]: + if not top_level_comment.stickied: + from utils.voice import sanitize_text + sanitised = sanitize_text(top_level_comment.body) + if sanitised and sanitised != " ": + if len(top_level_comment.body) <= int(settings.config["reddit"]["thread"]["max_comment_length"]): + if len(top_level_comment.body) >= int(settings.config["reddit"]["thread"]["min_comment_length"]): + if top_level_comment.author is not None: + content["comments"].append({ + "comment_body": top_level_comment.body, + "comment_url": top_level_comment.permalink, + "comment_id": top_level_comment.id, + }) + + # Display post information + print_substep(f"Video will be: {submission.title} 👍", style="bold green") + print_substep(f"Thread url is: {content['thread_url']} 👍", style="bold green") + print_substep(f"Thread has {submission.score} upvotes", style="bold blue") + print_substep(f"Thread has a upvote ratio of {submission.upvote_ratio * 100}%", style="bold blue") + print_substep(f"Thread has {submission.num_comments} comments", style="bold blue") + + if settings.config["settings"]["storymode"]: + print_substep(f"Post content length: {len(submission.selftext)} characters", style="bold blue") + + print_substep("Post data fetched successfully.", style="bold green") + return content + + +def main() -> None: + """Main function to process a specific Reddit post URL.""" + global redditid, reddit_object + + print_step("Reddit Video Maker Bot - Target Mode") + print_substep("This mode allows you to create a video from a specific Reddit post URL.") + + # Get URL from user + while True: + url = input("\nEnter the Reddit post URL: ").strip() + if url: + break + print_substep("Please enter a valid URL.", style="red") + + # Get post data + reddit_object = get_post_from_url(url) + redditid = id(reddit_object) + + # Process the post + 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 = { + "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) + + +def shutdown() -> NoReturn: + """Cleanup and exit.""" + if "redditid" in globals(): + print_markdown("## Clearing temp files") + cleanup(redditid) + + print("Exiting...") + sys.exit() + + +if __name__ == "__main__": + if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12, 13]: + 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." + ) + sys.exit() + + ffmpeg_install() + directory = Path().absolute() + config = settings.check_toml( + f"{directory}/utils/.config.template.toml", f"{directory}/config.toml" + ) + config is False and sys.exit() + + if ( + not settings.config["settings"]["tts"]["tiktok_sessionid"] + or settings.config["settings"]["tts"]["tiktok_sessionid"] == "" + ) and config["settings"]["tts"]["voice_choice"] == "tiktok": + print_substep( + "TikTok voice requires a sessionid! Check our documentation on how to obtain one.", + "bold red", + ) + sys.exit() + + try: + main() + except KeyboardInterrupt: + shutdown() + except ResponseException: + 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" + f"Error: {err} \n" + f'Config: {config["settings"]}' + ) + raise err diff --git a/utils/imagenarator.py b/utils/imagenarator.py index 509882d..22830ba 100644 --- a/utils/imagenarator.py +++ b/utils/imagenarator.py @@ -7,6 +7,29 @@ from rich.progress import track from TTS.engine_wrapper import process_text from utils.fonts import getheight, getsize +from utils import settings + + +def calculate_text_dimensions(text, font, padding, wrap=50): + """ + Calculate the dimensions needed for text with given font and padding + """ + lines = textwrap.wrap(text, width=wrap) + max_line_width = 0 + total_height = 0 + + for line in lines: + line_width, line_height = getsize(font, line) + max_line_width = max(max_line_width, line_width) + total_height += line_height + + # Add padding between lines + if len(lines) > 1: + total_height += (len(lines) - 1) * padding + + # Add minimal padding around the text + padding_around = 10 + return max_line_width + (padding_around * 2), total_height + (padding_around * 2) def draw_multiple_line_text( @@ -53,6 +76,126 @@ def draw_multiple_line_text( y += line_height + padding +def draw_highlighted_text( + image, text, font, padding, wrap=50, highlighted_word_index=-1 +) -> None: + """ + Draw text with white fill, black outline, and yellow inner outline (layered effect) + """ + draw = ImageDraw.Draw(image) + image_width, image_height = image.size + + # Split text into words and wrap + words = text.split() + lines = [] + current_line = [] + current_line_width = 0 + + for word in words: + word_width, _ = getsize(font, word + " ") + if current_line_width + word_width <= image_width - 40: # 20px padding on each side + current_line.append(word) + current_line_width += word_width + else: + if current_line: + lines.append(" ".join(current_line)) + current_line = [word] + current_line_width = word_width + + if current_line: + lines.append(" ".join(current_line)) + + # Calculate total height + line_height = getheight(font, "A") + total_height = len(lines) * line_height + (len(lines) - 1) * padding + y_start = (image_height - total_height) // 2 + + # Draw each line + word_index = 0 + for line_idx, line in enumerate(lines): + line_words = line.split() + x = (image_width - getsize(font, line)[0]) // 2 + y = y_start + line_idx * (line_height + padding) + + # Draw each word in the line + for word in line_words: + word_width, _ = getsize(font, word + " ") + + # Determine color based on highlighting + if highlighted_word_index >= 0 and word_index == highlighted_word_index: + text_color = (255, 255, 0) # Bright yellow for highlighted word + inner_outline_color = (255, 255, 255) # White inner outline for highlighted word + else: + text_color = (255, 255, 255) # White for other words + inner_outline_color = (255, 255, 0) # Yellow inner outline for non-highlighted words + + # Draw black outer outline (thickest) + outer_stroke_width = 4 + for dx in range(-outer_stroke_width, outer_stroke_width + 1): + for dy in range(-outer_stroke_width, outer_stroke_width + 1): + if dx != 0 or dy != 0: # Skip the center pixel + draw.text( + (x + dx, y + dy), + word, + font=font, + fill=(0, 0, 0) # Black outer outline + ) + + # Draw yellow inner outline (medium thickness) + inner_stroke_width = 2 + for dx in range(-inner_stroke_width, inner_stroke_width + 1): + for dy in range(-inner_stroke_width, inner_stroke_width + 1): + if dx != 0 or dy != 0: # Skip the center pixel + draw.text( + (x + dx, y + dy), + word, + font=font, + fill=inner_outline_color # Yellow inner outline + ) + + # Draw the main text (white or yellow) + draw.text((x, y), word, font=font, fill=text_color) + + x += word_width + word_index += 1 + + +def create_highlighted_captions(theme, reddit_obj: dict, padding=5) -> None: + """ + Create captions with white text and black stroke (highlighted style) + """ + texts = reddit_obj["thread_post"] + id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"]) + + # Use the actual video resolution from config + W = int(settings.config["settings"]["resolution_w"]) + H = int(settings.config["settings"]["resolution_h"]) + size = (W, H) + + # Use larger, bolder font for better visibility like the screenshot + font_size = min(80, max(40, H // 25)) # Larger font size for better impact + font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_size) + + for idx, text in track(enumerate(texts), "Creating Highlighted Captions"): + text = process_text(text, False) + + # Create transparent background + image = Image.new("RGBA", size, (0, 0, 0, 0)) + + # Draw highlighted text with white text and black stroke + draw_highlighted_text( + image, + text, + font, + padding=padding, + wrap=25, # Tighter wrap for better text flow + highlighted_word_index=-1 # No specific word highlighted, just the style + ) + + # Save the image + image.save(f"assets/temp/{id}/png/img{idx}.png") + + def imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) -> None: """ Render Images for video @@ -60,16 +203,33 @@ def imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) -> texts = reddit_obj["thread_post"] id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"]) + # Use the actual video resolution from config instead of fixed landscape size + W = int(settings.config["settings"]["resolution_w"]) + H = int(settings.config["settings"]["resolution_h"]) + size = (W, H) + + # Adjust font size based on video resolution for better readability + # For 9:16 portrait videos, use smaller font size to fit better in the compact background if transparent: - font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 100) + font_size = min(50, max(25, H // 50)) # Smaller font size for compact background + font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_size) else: - font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), 100) - size = (1920, 1080) + font_size = min(50, max(25, H // 50)) # Smaller font size for compact background + font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), font_size) image = Image.new("RGBA", size, theme) for idx, text in track(enumerate(texts), "Rendering Image"): - image = Image.new("RGBA", size, theme) text = process_text(text, False) - draw_multiple_line_text(image, text, font, txtclr, padding, wrap=30, transparent=transparent) + # Adjust text wrapping based on video width for better fit + wrap_width = max(20, min(35, W // 60)) # More balanced wrap width to fill the overlay better + + # Calculate the dimensions needed for this text + text_width, text_height = calculate_text_dimensions(text, font, padding=2, wrap=wrap_width) + + # Create an image that's only as big as the text content + image = Image.new("RGBA", (text_width, text_height), theme) + + # Use smaller padding to make text lines closer together and fill more vertical space + draw_multiple_line_text(image, text, font, txtclr, padding=2, wrap=wrap_width, transparent=transparent) image.save(f"assets/temp/{id}/png/img{idx}.png") diff --git a/video_creation/background.py b/video_creation/background.py index 43be69a..a30e755 100644 --- a/video_creation/background.py +++ b/video_creation/background.py @@ -86,9 +86,10 @@ def download_background_video(background_config: Tuple[str, str, str, Any]): print_substep("Downloading the backgrounds videos... please be patient 🙏 ") print_substep(f"Downloading {filename} from {uri}") ydl_opts = { - "format": "bestvideo[height<=1080][ext=mp4]", + "format": "best[height<=1080]/best", "outtmpl": f"assets/backgrounds/video/{credit}-{filename}", "retries": 10, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", } with yt_dlp.YoutubeDL(ydl_opts) as ydl: diff --git a/video_creation/final_video.py b/video_creation/final_video.py index 101d0f7..67f07ad 100644 --- a/video_creation/final_video.py +++ b/video_creation/final_video.py @@ -254,7 +254,11 @@ def make_final_video( console.log(f"[bold green] Video Will Be: {length} Seconds Long") - screenshot_width = int((W * 45) // 100) + # For 9:16 portrait videos, use an even smaller width to completely prevent clipping + # Since W=1080 and H=1920, we need to be extremely conservative with the width + screenshot_width = int((W * 15) // 100) # Use only 15% of video width (162px) + # Ensure minimum and maximum bounds for portrait videos with extra padding + screenshot_width = max(150, min(screenshot_width, W - 300)) # Min 150px, Max W-300px for extra generous padding audio = ffmpeg.input(f"assets/temp/{reddit_id}/audio.mp3") final_audio = merge_background_audio(audio, reddit_id) diff --git a/video_creation/screenshot_downloader.py b/video_creation/screenshot_downloader.py index 6b56e99..423ed1e 100644 --- a/video_creation/screenshot_downloader.py +++ b/video_creation/screenshot_downloader.py @@ -9,7 +9,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.imagenarator import imagemaker, create_highlighted_captions from utils.playwright import clear_cookie_by_name from utils.videos import save_data @@ -61,12 +61,11 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): if storymode and settings.config["settings"]["storymodemethod"] == 1: # for idx,item in enumerate(reddit_object["thread_post"]): - print_substep("Generating images...") - return imagemaker( + print_substep("Generating highlighted captions...") + return create_highlighted_captions( theme=bgcolor, reddit_obj=reddit_object, - txtclr=txtcolor, - transparent=transparent, + padding=5, ) screenshot_num: int @@ -79,12 +78,13 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): # Device scale factor (or dsf for short) allows us to increase the resolution of the screenshots # When the dsf is 1, the width of the screenshot is 600 pixels # so we need a dsf such that the width of the screenshot is greater than the final resolution of the video - dsf = (W // 600) + 1 + # For better scaling, use a more conservative approach + dsf = max(1, min(2, (W // 800) + 1)) # Cap dsf between 1 and 2 for better compatibility context = browser.new_context( locale=lang or "en-us", color_scheme="dark", - viewport=ViewportSize(width=W, height=H), + viewport=ViewportSize(width=min(W, 1200), height=min(H, 1600)), # Cap viewport size device_scale_factor=dsf, user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", ) @@ -131,7 +131,7 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): page.reload() # Get the thread screenshot page.goto(reddit_object["thread_url"], timeout=0) - page.set_viewport_size(ViewportSize(width=W, height=H)) + page.set_viewport_size(ViewportSize(width=min(W, 1200), height=min(H, 1600))) page.wait_for_load_state() page.wait_for_timeout(5000)