Path: blob/master/video_creation/final_video.py
327 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.thumbnail import create_thumbnail22from utils.videos import save_data2324console = Console()252627class ProgressFfmpeg(threading.Thread):28def __init__(self, vid_duration_seconds, progress_update_callback):29threading.Thread.__init__(self, name="ProgressFfmpeg")30self.stop_event = threading.Event()31self.output_file = tempfile.NamedTemporaryFile(mode="w+", delete=False)32self.vid_duration_seconds = vid_duration_seconds33self.progress_update_callback = progress_update_callback3435def run(self):36while not self.stop_event.is_set():37latest_progress = self.get_latest_ms_progress()38if latest_progress is not None:39completed_percent = latest_progress / self.vid_duration_seconds40self.progress_update_callback(completed_percent)41time.sleep(1)4243def get_latest_ms_progress(self):44lines = self.output_file.readlines()4546if lines:47for line in lines:48if "out_time_ms" in line:49out_time_ms_str = line.split("=")[1].strip()50if out_time_ms_str.isnumeric():51return float(out_time_ms_str) / 1000000.052else:53# Handle the case when "N/A" is encountered54return None55return None5657def stop(self):58self.stop_event.set()5960def __enter__(self):61self.start()62return self6364def __exit__(self, *args, **kwargs):65self.stop()666768def name_normalize(name: str) -> str:69name = re.sub(r'[?\\"%*:|<>]', "", name)70name = re.sub(r"( [w,W]\s?\/\s?[o,O,0])", r" without", name)71name = re.sub(r"( [w,W]\s?\/)", r" with", name)72name = re.sub(r"(\d+)\s?\/\s?(\d+)", r"\1 of \2", name)73name = re.sub(r"(\w+)\s?\/\s?(\w+)", r"\1 or \2", name)74name = re.sub(r"\/", r"", name)7576lang = settings.config["reddit"]["thread"]["post_lang"]77if lang:78print_substep("Translating filename...")79translated_name = translators.translate_text(name, translator="google", to_language=lang)80return translated_name81else:82return name838485def prepare_background(reddit_id: str, W: int, H: int) -> str:86output_path = f"assets/temp/{reddit_id}/background_noaudio.mp4"87output = (88ffmpeg.input(f"assets/temp/{reddit_id}/background.mp4")89.filter("crop", f"ih*({W}/{H})", "ih")90.output(91output_path,92an=None,93**{94"c:v": "h264",95"b:v": "20M",96"b:a": "192k",97"threads": multiprocessing.cpu_count(),98},99)100.overwrite_output()101)102try:103output.run(quiet=True)104except ffmpeg.Error as e:105print(e.stderr.decode("utf8"))106exit(1)107return output_path108109110def create_fancy_thumbnail(image, text, text_color, padding, wrap=35):111print_step(f"Creating fancy thumbnail for: {text}")112font_title_size = 47113font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)114image_width, image_height = image.size115lines = textwrap.wrap(text, width=wrap)116y = (117(image_height / 2)118- (((getheight(font, text) + (len(lines) * padding) / len(lines)) * len(lines)) / 2)119+ 30120)121draw = ImageDraw.Draw(image)122123username_font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 30)124draw.text(125(205, 825),126settings.config["settings"]["channel_name"],127font=username_font,128fill=text_color,129align="left",130)131132if len(lines) == 3:133lines = textwrap.wrap(text, width=wrap + 10)134font_title_size = 40135font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)136y = (137(image_height / 2)138- (((getheight(font, text) + (len(lines) * padding) / len(lines)) * len(lines)) / 2)139+ 35140)141elif len(lines) == 4:142lines = textwrap.wrap(text, width=wrap + 10)143font_title_size = 35144font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)145y = (146(image_height / 2)147- (((getheight(font, text) + (len(lines) * padding) / len(lines)) * len(lines)) / 2)148+ 40149)150elif len(lines) > 4:151lines = textwrap.wrap(text, width=wrap + 10)152font_title_size = 30153font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)154y = (155(image_height / 2)156- (((getheight(font, text) + (len(lines) * padding) / len(lines)) * len(lines)) / 2)157+ 30158)159160for line in lines:161draw.text((120, y), line, font=font, fill=text_color, align="left")162y += getheight(font, line) + padding163164return image165166167def merge_background_audio(audio: ffmpeg, reddit_id: str):168"""Gather an audio and merge with assets/backgrounds/background.mp3169Args:170audio (ffmpeg): The TTS final audio but without background.171reddit_id (str): The ID of subreddit172"""173background_audio_volume = settings.config["settings"]["background"]["background_audio_volume"]174if background_audio_volume == 0:175return audio # Return the original audio176else:177# sets volume to config178bg_audio = ffmpeg.input(f"assets/temp/{reddit_id}/background.mp3").filter(179"volume",180background_audio_volume,181)182# Merges audio and background_audio183merged_audio = ffmpeg.filter([audio, bg_audio], "amix", duration="longest")184return merged_audio # Return merged audio185186187def make_final_video(188number_of_clips: int,189length: int,190reddit_obj: dict,191background_config: Dict[str, Tuple],192):193"""Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp194Args:195number_of_clips (int): Index to end at when going through the screenshots'196length (int): Length of the video197reddit_obj (dict): The reddit object that contains the posts to read.198background_config (Tuple[str, str, str, Any]): The background config to use.199"""200# settings values201W: Final[int] = int(settings.config["settings"]["resolution_w"])202H: Final[int] = int(settings.config["settings"]["resolution_h"])203204opacity = settings.config["settings"]["opacity"]205206reddit_id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"])207208allowOnlyTTSFolder: bool = (209settings.config["settings"]["background"]["enable_extra_audio"]210and settings.config["settings"]["background"]["background_audio_volume"] != 0211)212213print_step("Creating the final video 🎥")214215background_clip = ffmpeg.input(prepare_background(reddit_id, W=W, H=H))216217# Gather all audio clips218audio_clips = list()219if number_of_clips == 0 and settings.config["settings"]["storymode"] == "false":220print(221"No audio clips to gather. Please use a different TTS or post."222) # This is to fix the TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'223exit()224if settings.config["settings"]["storymode"]:225if settings.config["settings"]["storymodemethod"] == 0:226audio_clips = [ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3")]227audio_clips.insert(1, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/postaudio.mp3"))228elif settings.config["settings"]["storymodemethod"] == 1:229audio_clips = [230ffmpeg.input(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")231for i in track(range(number_of_clips + 1), "Collecting the audio files...")232]233audio_clips.insert(0, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3"))234235else:236audio_clips = [237ffmpeg.input(f"assets/temp/{reddit_id}/mp3/{i}.mp3") for i in range(number_of_clips)238]239audio_clips.insert(0, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3"))240241audio_clips_durations = [242float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/{i}.mp3")["format"]["duration"])243for i in range(number_of_clips)244]245audio_clips_durations.insert(2460,247float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/title.mp3")["format"]["duration"]),248)249audio_concat = ffmpeg.concat(*audio_clips, a=1, v=0)250ffmpeg.output(251audio_concat, f"assets/temp/{reddit_id}/audio.mp3", **{"b:a": "192k"}252).overwrite_output().run(quiet=True)253254console.log(f"[bold green] Video Will Be: {length} Seconds Long")255256screenshot_width = int((W * 45) // 100)257audio = ffmpeg.input(f"assets/temp/{reddit_id}/audio.mp3")258final_audio = merge_background_audio(audio, reddit_id)259260image_clips = list()261262Path(f"assets/temp/{reddit_id}/png").mkdir(parents=True, exist_ok=True)263264# Credits to tim (beingbored)265# get the title_template image and draw a text in the middle part of it with the title of the thread266title_template = Image.open("assets/title_template.png")267268title = reddit_obj["thread_title"]269270title = name_normalize(title)271272font_color = "#000000"273padding = 5274275# create_fancy_thumbnail(image, text, text_color, padding276title_img = create_fancy_thumbnail(title_template, title, font_color, padding)277278title_img.save(f"assets/temp/{reddit_id}/png/title.png")279image_clips.insert(2800,281ffmpeg.input(f"assets/temp/{reddit_id}/png/title.png")["v"].filter(282"scale", screenshot_width, -1283),284)285286current_time = 0287if settings.config["settings"]["storymode"]:288audio_clips_durations = [289float(290ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")["format"]["duration"]291)292for i in range(number_of_clips)293]294audio_clips_durations.insert(2950,296float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/title.mp3")["format"]["duration"]),297)298if settings.config["settings"]["storymodemethod"] == 0:299image_clips.insert(3001,301ffmpeg.input(f"assets/temp/{reddit_id}/png/story_content.png").filter(302"scale", screenshot_width, -1303),304)305background_clip = background_clip.overlay(306image_clips[0],307enable=f"between(t,{current_time},{current_time + audio_clips_durations[0]})",308x="(main_w-overlay_w)/2",309y="(main_h-overlay_h)/2",310)311current_time += audio_clips_durations[0]312elif settings.config["settings"]["storymodemethod"] == 1:313for i in track(range(0, number_of_clips + 1), "Collecting the image files..."):314image_clips.append(315ffmpeg.input(f"assets/temp/{reddit_id}/png/img{i}.png")["v"].filter(316"scale", screenshot_width, -1317)318)319background_clip = background_clip.overlay(320image_clips[i],321enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})",322x="(main_w-overlay_w)/2",323y="(main_h-overlay_h)/2",324)325current_time += audio_clips_durations[i]326else:327for i in range(0, number_of_clips + 1):328image_clips.append(329ffmpeg.input(f"assets/temp/{reddit_id}/png/comment_{i}.png")["v"].filter(330"scale", screenshot_width, -1331)332)333image_overlay = image_clips[i].filter("colorchannelmixer", aa=opacity)334assert (335audio_clips_durations is not None336), "Please make a GitHub issue if you see this. Ping @JasonLovesDoggo on GitHub."337background_clip = background_clip.overlay(338image_overlay,339enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})",340x="(main_w-overlay_w)/2",341y="(main_h-overlay_h)/2",342)343current_time += audio_clips_durations[i]344345title = re.sub(r"[^\w\s-]", "", reddit_obj["thread_title"])346idx = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"])347title_thumb = reddit_obj["thread_title"]348349filename = f"{name_normalize(title)[:251]}"350subreddit = settings.config["reddit"]["thread"]["subreddit"]351352if not exists(f"./results/{subreddit}"):353print_substep("The 'results' folder could not be found so it was automatically created.")354os.makedirs(f"./results/{subreddit}")355356if not exists(f"./results/{subreddit}/OnlyTTS") and allowOnlyTTSFolder:357print_substep("The 'OnlyTTS' folder could not be found so it was automatically created.")358os.makedirs(f"./results/{subreddit}/OnlyTTS")359360# create a thumbnail for the video361settingsbackground = settings.config["settings"]["background"]362363if settingsbackground["background_thumbnail"]:364if not exists(f"./results/{subreddit}/thumbnails"):365print_substep(366"The 'results/thumbnails' folder could not be found so it was automatically created."367)368os.makedirs(f"./results/{subreddit}/thumbnails")369# get the first file with the .png extension from assets/backgrounds and use it as a background for the thumbnail370first_image = next(371(file for file in os.listdir("assets/backgrounds") if file.endswith(".png")),372None,373)374if first_image is None:375print_substep("No png files found in assets/backgrounds", "red")376377else:378font_family = settingsbackground["background_thumbnail_font_family"]379font_size = settingsbackground["background_thumbnail_font_size"]380font_color = settingsbackground["background_thumbnail_font_color"]381thumbnail = Image.open(f"assets/backgrounds/{first_image}")382width, height = thumbnail.size383thumbnailSave = create_thumbnail(384thumbnail,385font_family,386font_size,387font_color,388width,389height,390title_thumb,391)392thumbnailSave.save(f"./assets/temp/{reddit_id}/thumbnail.png")393print_substep(f"Thumbnail - Building Thumbnail in assets/temp/{reddit_id}/thumbnail.png")394395text = f"Background by {background_config['video'][2]}"396background_clip = ffmpeg.drawtext(397background_clip,398text=text,399x=f"(w-text_w)",400y=f"(h-text_h)",401fontsize=5,402fontcolor="White",403fontfile=os.path.join("fonts", "Roboto-Regular.ttf"),404)405background_clip = background_clip.filter("scale", W, H)406print_step("Rendering the video 🎥")407from tqdm import tqdm408409pbar = tqdm(total=100, desc="Progress: ", bar_format="{l_bar}{bar}", unit=" %")410411def on_update_example(progress) -> None:412status = round(progress * 100, 2)413old_percentage = pbar.n414pbar.update(status - old_percentage)415416defaultPath = f"results/{subreddit}"417with ProgressFfmpeg(length, on_update_example) as progress:418path = defaultPath + f"/{filename}"419path = (420path[:251] + ".mp4"421) # Prevent a error by limiting the path length, do not change this.422try:423ffmpeg.output(424background_clip,425final_audio,426path,427f="mp4",428**{429"c:v": "h264",430"b:v": "20M",431"b:a": "192k",432"threads": multiprocessing.cpu_count(),433},434).overwrite_output().global_args("-progress", progress.output_file.name).run(435quiet=True,436overwrite_output=True,437capture_stdout=False,438capture_stderr=False,439)440except ffmpeg.Error as e:441print(e.stderr.decode("utf8"))442exit(1)443old_percentage = pbar.n444pbar.update(100 - old_percentage)445if allowOnlyTTSFolder:446path = defaultPath + f"/OnlyTTS/{filename}"447path = (448path[:251] + ".mp4"449) # Prevent a error by limiting the path length, do not change this.450print_step("Rendering the Only TTS Video 🎥")451with ProgressFfmpeg(length, on_update_example) as progress:452try:453ffmpeg.output(454background_clip,455audio,456path,457f="mp4",458**{459"c:v": "h264",460"b:v": "20M",461"b:a": "192k",462"threads": multiprocessing.cpu_count(),463},464).overwrite_output().global_args("-progress", progress.output_file.name).run(465quiet=True,466overwrite_output=True,467capture_stdout=False,468capture_stderr=False,469)470except ffmpeg.Error as e:471print(e.stderr.decode("utf8"))472exit(1)473474old_percentage = pbar.n475pbar.update(100 - old_percentage)476pbar.close()477save_data(subreddit, filename + ".mp4", title, idx, background_config["video"][2])478print_step("Removing temporary files 🗑")479cleanups = cleanup(reddit_id)480print_substep(f"Removed {cleanups} temporary files 🗑")481print_step("Done! 🎉 The video is in the results folder 📁")482483484