Path: blob/master/video_creation/final_video.py
493 views
import multiprocessing1import os2import re3import tempfile4import textwrap5import threading6import time7from os.path import exists # Needs to be imported specifically8from pathlib import Path9from typing import Dict, Final, Tuple1011import ffmpeg12import translators13from PIL import Image, ImageDraw, ImageFont14from rich.console import Console15from rich.progress import track1617from utils import settings18from utils.cleanup import cleanup19from utils.console import print_step, print_substep20from utils.fonts import getheight21from utils.id import extract_id22from utils.thumbnail import create_thumbnail23from utils.videos import save_data2425console = Console()262728class ProgressFfmpeg(threading.Thread):29def __init__(self, vid_duration_seconds, progress_update_callback):30threading.Thread.__init__(self, name="ProgressFfmpeg")31self.stop_event = threading.Event()32self.output_file = tempfile.NamedTemporaryFile(mode="w+", delete=False)33self.vid_duration_seconds = vid_duration_seconds34self.progress_update_callback = progress_update_callback3536def run(self):37while not self.stop_event.is_set():38latest_progress = self.get_latest_ms_progress()39if latest_progress is not None:40completed_percent = latest_progress / self.vid_duration_seconds41self.progress_update_callback(completed_percent)42time.sleep(1)4344def get_latest_ms_progress(self):45lines = self.output_file.readlines()4647if lines:48for line in lines:49if "out_time_ms" in line:50out_time_ms_str = line.split("=")[1].strip()51if out_time_ms_str.isnumeric():52return float(out_time_ms_str) / 1000000.053else:54# Handle the case when "N/A" is encountered55return None56return None5758def stop(self):59self.stop_event.set()6061def __enter__(self):62self.start()63return self6465def __exit__(self, *args, **kwargs):66self.stop()676869def name_normalize(name: str) -> str:70name = re.sub(r'[?\\"%*:|<>]', "", name)71name = re.sub(r"( [w,W]\s?\/\s?[o,O,0])", r" without", name)72name = re.sub(r"( [w,W]\s?\/)", r" with", name)73name = re.sub(r"(\d+)\s?\/\s?(\d+)", r"\1 of \2", name)74name = re.sub(r"(\w+)\s?\/\s?(\w+)", r"\1 or \2", name)75name = re.sub(r"\/", r"", name)7677lang = settings.config["reddit"]["thread"]["post_lang"]78if lang:79print_substep("Translating filename...")80translated_name = translators.translate_text(name, translator="google", to_language=lang)81return translated_name82else:83return name848586def prepare_background(reddit_id: str, W: int, H: int) -> str:87output_path = f"assets/temp/{reddit_id}/background_noaudio.mp4"88output = (89ffmpeg.input(f"assets/temp/{reddit_id}/background.mp4")90.filter("crop", f"ih*({W}/{H})", "ih")91.output(92output_path,93an=None,94**{95"c:v": "h264_nvenc",96"b:v": "20M",97"b:a": "192k",98"threads": multiprocessing.cpu_count(),99},100)101.overwrite_output()102)103try:104output.run(quiet=True)105except ffmpeg.Error as e:106print(e.stderr.decode("utf8"))107exit(1)108return output_path109110111def get_text_height(draw, text, font, max_width):112lines = textwrap.wrap(text, width=max_width)113total_height = 0114for line in lines:115_, _, _, height = draw.textbbox((0, 0), line, font=font)116total_height += height117return total_height118119120def create_fancy_thumbnail(image, text, text_color, padding, wrap=35):121"""122It will take the 1px from the middle of the template and will be resized (stretched) vertically to accommodate the extra height needed for the title.123"""124print_step(f"Creating fancy thumbnail for: {text}")125font_title_size = 47126font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)127image_width, image_height = image.size128129# Calculate text height to determine new image height130draw = ImageDraw.Draw(image)131text_height = get_text_height(draw, text, font, wrap)132lines = textwrap.wrap(text, width=wrap)133# This is -50 to reduce the empty space at the bottom of the image,134# change it as per your requirement if needed otherwise leave it.135new_image_height = image_height + text_height + padding * (len(lines) - 1) - 50136137# Separate the image into top, middle (1px), and bottom parts138top_part_height = image_height // 2139middle_part_height = 1 # 1px height middle section140bottom_part_height = image_height - top_part_height - middle_part_height141142top_part = image.crop((0, 0, image_width, top_part_height))143middle_part = image.crop((0, top_part_height, image_width, top_part_height + middle_part_height))144bottom_part = image.crop((0, top_part_height + middle_part_height, image_width, image_height))145146# Stretch the middle part147new_middle_height = new_image_height - top_part_height - bottom_part_height148middle_part = middle_part.resize((image_width, new_middle_height))149150# Create new image with the calculated height151new_image = Image.new("RGBA", (image_width, new_image_height))152153# Paste the top, stretched middle, and bottom parts into the new image154new_image.paste(top_part, (0, 0))155new_image.paste(middle_part, (0, top_part_height))156new_image.paste(bottom_part, (0, top_part_height + new_middle_height))157158# Draw the title text on the new image159draw = ImageDraw.Draw(new_image)160y = top_part_height + padding161for line in lines:162draw.text((120, y), line, font=font, fill=text_color, align="left")163y += get_text_height(draw, line, font, wrap) + padding164165# Draw the username "PlotPulse" at the specific position166username_font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 30)167draw.text(168(205, 825),169settings.config["settings"]["channel_name"],170font=username_font,171fill=text_color,172align="left",173)174175return new_image176177178def merge_background_audio(audio: ffmpeg, reddit_id: str):179"""Gather an audio and merge with assets/backgrounds/background.mp3180Args:181audio (ffmpeg): The TTS final audio but without background.182reddit_id (str): The ID of subreddit183"""184background_audio_volume = settings.config["settings"]["background"]["background_audio_volume"]185if background_audio_volume == 0:186return audio # Return the original audio187else:188# sets volume to config189bg_audio = ffmpeg.input(f"assets/temp/{reddit_id}/background.mp3").filter(190"volume",191background_audio_volume,192)193# Merges audio and background_audio194merged_audio = ffmpeg.filter([audio, bg_audio], "amix", duration="longest")195return merged_audio # Return merged audio196197198def make_final_video(199number_of_clips: int,200length: int,201reddit_obj: dict,202background_config: Dict[str, Tuple],203):204"""Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp205Args:206number_of_clips (int): Index to end at when going through the screenshots'207length (int): Length of the video208reddit_obj (dict): The reddit object that contains the posts to read.209background_config (Tuple[str, str, str, Any]): The background config to use.210"""211# settings values212W: Final[int] = int(settings.config["settings"]["resolution_w"])213H: Final[int] = int(settings.config["settings"]["resolution_h"])214215opacity = settings.config["settings"]["opacity"]216217reddit_id = extract_id(reddit_obj)218219allowOnlyTTSFolder: bool = (220settings.config["settings"]["background"]["enable_extra_audio"]221and settings.config["settings"]["background"]["background_audio_volume"] != 0222)223224print_step("Creating the final video 🎥")225226background_clip = ffmpeg.input(prepare_background(reddit_id, W=W, H=H))227228# Gather all audio clips229audio_clips = list()230if number_of_clips == 0 and settings.config["settings"]["storymode"] == "false":231print(232"No audio clips to gather. Please use a different TTS or post."233) # This is to fix the TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'234exit()235if settings.config["settings"]["storymode"]:236if settings.config["settings"]["storymodemethod"] == 0:237audio_clips = [ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3")]238audio_clips.insert(1, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/postaudio.mp3"))239elif settings.config["settings"]["storymodemethod"] == 1:240audio_clips = [241ffmpeg.input(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")242for i in track(range(number_of_clips + 1), "Collecting the audio files...")243]244audio_clips.insert(0, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3"))245246else:247audio_clips = [248ffmpeg.input(f"assets/temp/{reddit_id}/mp3/{i}.mp3") for i in range(number_of_clips)249]250audio_clips.insert(0, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3"))251252audio_clips_durations = [253float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/{i}.mp3")["format"]["duration"])254for i in range(number_of_clips)255]256audio_clips_durations.insert(2570,258float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/title.mp3")["format"]["duration"]),259)260audio_concat = ffmpeg.concat(*audio_clips, a=1, v=0)261ffmpeg.output(262audio_concat, f"assets/temp/{reddit_id}/audio.mp3", **{"b:a": "192k"}263).overwrite_output().run(quiet=True)264265console.log(f"[bold green] Video Will Be: {length} Seconds Long")266267screenshot_width = int((W * 45) // 100)268audio = ffmpeg.input(f"assets/temp/{reddit_id}/audio.mp3")269final_audio = merge_background_audio(audio, reddit_id)270271image_clips = list()272273Path(f"assets/temp/{reddit_id}/png").mkdir(parents=True, exist_ok=True)274275# Credits to tim (beingbored)276# get the title_template image and draw a text in the middle part of it with the title of the thread277title_template = Image.open("assets/title_template.png")278279title = reddit_obj["thread_title"]280281title = name_normalize(title)282283font_color = "#000000"284padding = 5285286# create_fancy_thumbnail(image, text, text_color, padding287title_img = create_fancy_thumbnail(title_template, title, font_color, padding)288289title_img.save(f"assets/temp/{reddit_id}/png/title.png")290image_clips.insert(2910,292ffmpeg.input(f"assets/temp/{reddit_id}/png/title.png")["v"].filter(293"scale", screenshot_width, -1294),295)296297current_time = 0298if settings.config["settings"]["storymode"]:299audio_clips_durations = [300float(301ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")["format"]["duration"]302)303for i in range(number_of_clips)304]305audio_clips_durations.insert(3060,307float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/title.mp3")["format"]["duration"]),308)309if settings.config["settings"]["storymodemethod"] == 0:310image_clips.insert(3111,312ffmpeg.input(f"assets/temp/{reddit_id}/png/story_content.png").filter(313"scale", screenshot_width, -1314),315)316background_clip = background_clip.overlay(317image_clips[0],318enable=f"between(t,{current_time},{current_time + audio_clips_durations[0]})",319x="(main_w-overlay_w)/2",320y="(main_h-overlay_h)/2",321)322current_time += audio_clips_durations[0]323elif settings.config["settings"]["storymodemethod"] == 1:324for i in track(range(0, number_of_clips + 1), "Collecting the image files..."):325image_clips.append(326ffmpeg.input(f"assets/temp/{reddit_id}/png/img{i}.png")["v"].filter(327"scale", screenshot_width, -1328)329)330background_clip = background_clip.overlay(331image_clips[i],332enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})",333x="(main_w-overlay_w)/2",334y="(main_h-overlay_h)/2",335)336current_time += audio_clips_durations[i]337else:338for i in range(0, number_of_clips + 1):339image_clips.append(340ffmpeg.input(f"assets/temp/{reddit_id}/png/comment_{i}.png")["v"].filter(341"scale", screenshot_width, -1342)343)344image_overlay = image_clips[i].filter("colorchannelmixer", aa=opacity)345assert (346audio_clips_durations is not None347), "Please make a GitHub issue if you see this. Ping @JasonLovesDoggo on GitHub."348background_clip = background_clip.overlay(349image_overlay,350enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})",351x="(main_w-overlay_w)/2",352y="(main_h-overlay_h)/2",353)354current_time += audio_clips_durations[i]355356title = extract_id(reddit_obj, "thread_title")357idx = extract_id(reddit_obj)358title_thumb = reddit_obj["thread_title"]359360filename = f"{name_normalize(title)[:251]}"361subreddit = settings.config["reddit"]["thread"]["subreddit"]362363if not exists(f"./results/{subreddit}"):364print_substep("The 'results' folder could not be found so it was automatically created.")365os.makedirs(f"./results/{subreddit}")366367if not exists(f"./results/{subreddit}/OnlyTTS") and allowOnlyTTSFolder:368print_substep("The 'OnlyTTS' folder could not be found so it was automatically created.")369os.makedirs(f"./results/{subreddit}/OnlyTTS")370371# create a thumbnail for the video372settingsbackground = settings.config["settings"]["background"]373374if settingsbackground["background_thumbnail"]:375if not exists(f"./results/{subreddit}/thumbnails"):376print_substep(377"The 'results/thumbnails' folder could not be found so it was automatically created."378)379os.makedirs(f"./results/{subreddit}/thumbnails")380# get the first file with the .png extension from assets/backgrounds and use it as a background for the thumbnail381first_image = next(382(file for file in os.listdir("assets/backgrounds") if file.endswith(".png")),383None,384)385if first_image is None:386print_substep("No png files found in assets/backgrounds", "red")387388else:389font_family = settingsbackground["background_thumbnail_font_family"]390font_size = settingsbackground["background_thumbnail_font_size"]391font_color = settingsbackground["background_thumbnail_font_color"]392thumbnail = Image.open(f"assets/backgrounds/{first_image}")393width, height = thumbnail.size394thumbnailSave = create_thumbnail(395thumbnail,396font_family,397font_size,398font_color,399width,400height,401title_thumb,402)403thumbnailSave.save(f"./assets/temp/{reddit_id}/thumbnail.png")404print_substep(f"Thumbnail - Building Thumbnail in assets/temp/{reddit_id}/thumbnail.png")405406text = f"Background by {background_config['video'][2]}"407background_clip = ffmpeg.drawtext(408background_clip,409text=text,410x=f"(w-text_w)",411y=f"(h-text_h)",412fontsize=5,413fontcolor="White",414fontfile=os.path.join("fonts", "Roboto-Regular.ttf"),415)416background_clip = background_clip.filter("scale", W, H)417print_step("Rendering the video 🎥")418from tqdm import tqdm419420pbar = tqdm(total=100, desc="Progress: ", bar_format="{l_bar}{bar}", unit=" %")421422def on_update_example(progress) -> None:423status = round(progress * 100, 2)424old_percentage = pbar.n425pbar.update(status - old_percentage)426427defaultPath = f"results/{subreddit}"428with ProgressFfmpeg(length, on_update_example) as progress:429path = defaultPath + f"/{filename}"430path = (431path[:251] + ".mp4"432) # Prevent a error by limiting the path length, do not change this.433try:434ffmpeg.output(435background_clip,436final_audio,437path,438f="mp4",439**{440"c:v": "h264_nvenc",441"b:v": "20M",442"b:a": "192k",443"threads": multiprocessing.cpu_count(),444},445).overwrite_output().global_args("-progress", progress.output_file.name).run(446quiet=True,447overwrite_output=True,448capture_stdout=False,449capture_stderr=False,450)451except ffmpeg.Error as e:452print(e.stderr.decode("utf8"))453exit(1)454old_percentage = pbar.n455pbar.update(100 - old_percentage)456if allowOnlyTTSFolder:457path = defaultPath + f"/OnlyTTS/{filename}"458path = (459path[:251] + ".mp4"460) # Prevent a error by limiting the path length, do not change this.461print_step("Rendering the Only TTS Video 🎥")462with ProgressFfmpeg(length, on_update_example) as progress:463try:464ffmpeg.output(465background_clip,466audio,467path,468f="mp4",469**{470"c:v": "h264_nvenc",471"b:v": "20M",472"b:a": "192k",473"threads": multiprocessing.cpu_count(),474},475).overwrite_output().global_args("-progress", progress.output_file.name).run(476quiet=True,477overwrite_output=True,478capture_stdout=False,479capture_stderr=False,480)481except ffmpeg.Error as e:482print(e.stderr.decode("utf8"))483exit(1)484485old_percentage = pbar.n486pbar.update(100 - old_percentage)487pbar.close()488save_data(subreddit, filename + ".mp4", title, idx, background_config["video"][2])489print_step("Removing temporary files 🗑")490cleanups = cleanup(reddit_id)491print_substep(f"Removed {cleanups} temporary files 🗑")492print_step("Done! 🎉 The video is in the results folder 📁")493494495