diff --git a/GUI.py b/GUI.py index bc41838..47dfc25 100644 --- a/GUI.py +++ b/GUI.py @@ -1,32 +1,118 @@ -# Import the server module -import http.server import webbrowser +from pathlib import Path + +# Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump" +import tomlkit +from flask import ( + Flask, + redirect, + render_template, + request, + send_from_directory, + url_for, +) + +import utils.gui_utils as gui # Set the hostname HOST = "localhost" # Set the port number PORT = 4000 -# Define class to display the index page of the web server -class PythonServer(http.server.SimpleHTTPRequestHandler): - def do_GET(self): - if self.path == "/GUI": - self.path = "index.html" - return http.server.SimpleHTTPRequestHandler.do_GET(self) - - -# Declare object of the class -webServer = http.server.HTTPServer((HOST, PORT), PythonServer) -# Print the URL of the webserver, new =2 opens in a new tab -print(f"Server started at http://{HOST}:{PORT}/GUI/") -webbrowser.open(f"http://{HOST}:{PORT}/GUI/", new=2) -print("Website opened in new tab") -print("Press Ctrl+C to quit") -try: - # Run the web server - webServer.serve_forever() -except KeyboardInterrupt: - # Stop the web server - webServer.server_close() - print("The server is stopped.") - exit() +# Configure application +app = Flask(__name__, template_folder="GUI") + +# Configure secret key only to use 'flash' +app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' + + +# Ensure responses aren't cached +@app.after_request +def after_request(response): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Expires"] = 0 + response.headers["Pragma"] = "no-cache" + return response + + +# Display index.html +@app.route("/") +def index(): + return render_template("index.html", file="videos.json") + + +@app.route("/backgrounds", methods=["GET"]) +def backgrounds(): + return render_template("backgrounds.html", file="backgrounds.json") + + +@app.route("/background/add", methods=["POST"]) +def background_add(): + # Get form values + youtube_uri = request.form.get("youtube_uri").strip() + filename = request.form.get("filename").strip() + citation = request.form.get("citation").strip() + position = request.form.get("position").strip() + + gui.add_background(youtube_uri, filename, citation, position) + + return redirect(url_for("backgrounds")) + + +@app.route("/background/delete", methods=["POST"]) +def background_delete(): + key = request.form.get("background-key") + gui.delete_background(key) + + return redirect(url_for("backgrounds")) + + +@app.route("/settings", methods=["GET", "POST"]) +def settings(): + config_load = tomlkit.loads(Path("config.toml").read_text()) + config = gui.get_config(config_load) + + # Get checks for all values + checks = gui.get_checks() + + if request.method == "POST": + # Get data from form as dict + data = request.form.to_dict() + + # Change settings + config = gui.modify_settings(data, config_load, checks) + + return render_template( + "settings.html", file="config.toml", data=config, checks=checks + ) + + +# Make videos.json accessible +@app.route("/videos.json") +def videos_json(): + return send_from_directory("video_creation/data", "videos.json") + + +# Make backgrounds.json accessible +@app.route("/backgrounds.json") +def backgrounds_json(): + return send_from_directory("utils", "backgrounds.json") + + +# Make videos in results folder accessible +@app.route("/results/") +def results(name): + return send_from_directory("results", name, as_attachment=True) + + +# Make voices samples in voices folder accessible +@app.route("/voices/") +def voices(name): + return send_from_directory("GUI/voices", name, as_attachment=True) + + +# Run browser and start the app +if __name__ == "__main__": + webbrowser.open(f"http://{HOST}:{PORT}", new=2) + print("Website opened in new tab. Refresh if it didn't load.") + app.run(port=PORT) diff --git a/GUI/backgrounds.html b/GUI/backgrounds.html new file mode 100644 index 0000000..541e39f --- /dev/null +++ b/GUI/backgrounds.html @@ -0,0 +1,263 @@ +{% extends "layout.html" %} +{% block main %} + + + + + + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/GUI/index.html b/GUI/index.html index c771adc..fe64e36 100644 --- a/GUI/index.html +++ b/GUI/index.html @@ -1,229 +1,131 @@ - - - - - - RedditVideoMakerBot - - - +{% extends "layout.html" %} +{% block main %} - - - - - -
- -
- -
-
+
+
-
-
- +
+
+ +
-
-
+
-
+
-
-
- - +
+
- - - - - - - - - + var searchFilter = () => { + const input = document.querySelector(".searchFilter"); + const cards = document.getElementsByClassName("col"); + console.log(cards[1]) + let filter = input.value + for (let i = 0; i < cards.length; i++) { + let title = cards[i].querySelector(".card-text"); + if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) { + cards[i].classList.remove("d-none") + } else { + cards[i].classList.add("d-none") + } + } + } + +{% endblock %} \ No newline at end of file diff --git a/GUI/layout.html b/GUI/layout.html new file mode 100644 index 0000000..d56299e --- /dev/null +++ b/GUI/layout.html @@ -0,0 +1,142 @@ + + + + + + + RedditVideoMakerBot + + + + + + + + + + + + + + +
+ {% if get_flashed_messages() %} + {% for category, message in get_flashed_messages(with_categories=true) %} + + {% if category == "error" %} + + + {% else %} + + {% endif %} + {% endfor %} + {% endif %} + +
+ + {% block main %}{% endblock %} + + + + + \ No newline at end of file diff --git a/GUI/settings.html b/GUI/settings.html new file mode 100644 index 0000000..2b6b014 --- /dev/null +++ b/GUI/settings.html @@ -0,0 +1,576 @@ +{% extends "layout.html" %} +{% block main %} + +
+
+
+
+ + +

Reddit Credentials

+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ + +
+ + +

Reddit Thread

+
+ +
+
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+ +
+ Max number of characters a comment can have. +
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+ +
+ The minimum number of comments a post should have to be + included. +
+
+ + +

General Settings

+
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+ +
+ Used if you want to create multiple videos. +
+
+
+ +
+
+ +
+ Sets the opacity of the comments when overlayed over the + background. +
+
+
+ +
+
+ +
+ Sets the transition time (in seconds) between the + comments. Set to 0 if you want to disable it. +
+
+
+ +
+ + See all available + backgrounds +
+
+ + +

TTS Settings

+
+ +
+ +
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+ +
+ Time in seconds between TTS comments. +
+
+
+
+ + +
+
+
+ +
+ + + +{% endblock %} \ No newline at end of file diff --git a/GUI/voices/amy.mp3 b/GUI/voices/amy.mp3 new file mode 100644 index 0000000..29ea64b Binary files /dev/null and b/GUI/voices/amy.mp3 differ diff --git a/GUI/voices/br_001.mp3 b/GUI/voices/br_001.mp3 new file mode 100644 index 0000000..3b343dc Binary files /dev/null and b/GUI/voices/br_001.mp3 differ diff --git a/GUI/voices/br_003.mp3 b/GUI/voices/br_003.mp3 new file mode 100644 index 0000000..9a6b845 Binary files /dev/null and b/GUI/voices/br_003.mp3 differ diff --git a/GUI/voices/br_004.mp3 b/GUI/voices/br_004.mp3 new file mode 100644 index 0000000..47d0010 Binary files /dev/null and b/GUI/voices/br_004.mp3 differ diff --git a/GUI/voices/br_005.mp3 b/GUI/voices/br_005.mp3 new file mode 100644 index 0000000..104cc3a Binary files /dev/null and b/GUI/voices/br_005.mp3 differ diff --git a/GUI/voices/brian.mp3 b/GUI/voices/brian.mp3 new file mode 100644 index 0000000..9304703 Binary files /dev/null and b/GUI/voices/brian.mp3 differ diff --git a/GUI/voices/de_001.mp3 b/GUI/voices/de_001.mp3 new file mode 100644 index 0000000..ef4a1b5 Binary files /dev/null and b/GUI/voices/de_001.mp3 differ diff --git a/GUI/voices/de_002.mp3 b/GUI/voices/de_002.mp3 new file mode 100644 index 0000000..4d7f89f Binary files /dev/null and b/GUI/voices/de_002.mp3 differ diff --git a/GUI/voices/emma.mp3 b/GUI/voices/emma.mp3 new file mode 100644 index 0000000..07895f0 Binary files /dev/null and b/GUI/voices/emma.mp3 differ diff --git a/GUI/voices/en_au_001.mp3 b/GUI/voices/en_au_001.mp3 new file mode 100644 index 0000000..b940e5c Binary files /dev/null and b/GUI/voices/en_au_001.mp3 differ diff --git a/GUI/voices/en_au_002.mp3 b/GUI/voices/en_au_002.mp3 new file mode 100644 index 0000000..a6421bb Binary files /dev/null and b/GUI/voices/en_au_002.mp3 differ diff --git a/GUI/voices/en_uk_001.mp3 b/GUI/voices/en_uk_001.mp3 new file mode 100644 index 0000000..666cac6 Binary files /dev/null and b/GUI/voices/en_uk_001.mp3 differ diff --git a/GUI/voices/en_uk_003.mp3 b/GUI/voices/en_uk_003.mp3 new file mode 100644 index 0000000..eecf6f6 Binary files /dev/null and b/GUI/voices/en_uk_003.mp3 differ diff --git a/GUI/voices/en_us_001.mp3 b/GUI/voices/en_us_001.mp3 new file mode 100644 index 0000000..56f1e01 Binary files /dev/null and b/GUI/voices/en_us_001.mp3 differ diff --git a/GUI/voices/en_us_002.mp3 b/GUI/voices/en_us_002.mp3 new file mode 100644 index 0000000..56f1e01 Binary files /dev/null and b/GUI/voices/en_us_002.mp3 differ diff --git a/GUI/voices/en_us_006.mp3 b/GUI/voices/en_us_006.mp3 new file mode 100644 index 0000000..f980665 Binary files /dev/null and b/GUI/voices/en_us_006.mp3 differ diff --git a/GUI/voices/en_us_007.mp3 b/GUI/voices/en_us_007.mp3 new file mode 100644 index 0000000..446a911 Binary files /dev/null and b/GUI/voices/en_us_007.mp3 differ diff --git a/GUI/voices/en_us_009.mp3 b/GUI/voices/en_us_009.mp3 new file mode 100644 index 0000000..2dd624d Binary files /dev/null and b/GUI/voices/en_us_009.mp3 differ diff --git a/GUI/voices/en_us_010.mp3 b/GUI/voices/en_us_010.mp3 new file mode 100644 index 0000000..34a37fe Binary files /dev/null and b/GUI/voices/en_us_010.mp3 differ diff --git a/GUI/voices/en_us_c3po.mp3 b/GUI/voices/en_us_c3po.mp3 new file mode 100644 index 0000000..eeabfac Binary files /dev/null and b/GUI/voices/en_us_c3po.mp3 differ diff --git a/GUI/voices/en_us_chewbacca.mp3 b/GUI/voices/en_us_chewbacca.mp3 new file mode 100644 index 0000000..6257d10 Binary files /dev/null and b/GUI/voices/en_us_chewbacca.mp3 differ diff --git a/GUI/voices/en_us_ghostface.mp3 b/GUI/voices/en_us_ghostface.mp3 new file mode 100644 index 0000000..0ad75be Binary files /dev/null and b/GUI/voices/en_us_ghostface.mp3 differ diff --git a/GUI/voices/en_us_rocket.mp3 b/GUI/voices/en_us_rocket.mp3 new file mode 100644 index 0000000..66a0356 Binary files /dev/null and b/GUI/voices/en_us_rocket.mp3 differ diff --git a/GUI/voices/en_us_stitch.mp3 b/GUI/voices/en_us_stitch.mp3 new file mode 100644 index 0000000..3c2fcc0 Binary files /dev/null and b/GUI/voices/en_us_stitch.mp3 differ diff --git a/GUI/voices/en_us_stormtrooper.mp3 b/GUI/voices/en_us_stormtrooper.mp3 new file mode 100644 index 0000000..8773ee4 Binary files /dev/null and b/GUI/voices/en_us_stormtrooper.mp3 differ diff --git a/GUI/voices/es_002.mp3 b/GUI/voices/es_002.mp3 new file mode 100644 index 0000000..4fb1d01 Binary files /dev/null and b/GUI/voices/es_002.mp3 differ diff --git a/GUI/voices/es_mx_002.mp3 b/GUI/voices/es_mx_002.mp3 new file mode 100644 index 0000000..18d0dfe Binary files /dev/null and b/GUI/voices/es_mx_002.mp3 differ diff --git a/GUI/voices/fr_001.mp3 b/GUI/voices/fr_001.mp3 new file mode 100644 index 0000000..deec4c8 Binary files /dev/null and b/GUI/voices/fr_001.mp3 differ diff --git a/GUI/voices/fr_002.mp3 b/GUI/voices/fr_002.mp3 new file mode 100644 index 0000000..c2b7512 Binary files /dev/null and b/GUI/voices/fr_002.mp3 differ diff --git a/GUI/voices/geraint.mp3 b/GUI/voices/geraint.mp3 new file mode 100644 index 0000000..403e0b6 Binary files /dev/null and b/GUI/voices/geraint.mp3 differ diff --git a/GUI/voices/id_001.mp3 b/GUI/voices/id_001.mp3 new file mode 100644 index 0000000..19febf9 Binary files /dev/null and b/GUI/voices/id_001.mp3 differ diff --git a/GUI/voices/ivy.mp3 b/GUI/voices/ivy.mp3 new file mode 100644 index 0000000..0d649c3 Binary files /dev/null and b/GUI/voices/ivy.mp3 differ diff --git a/GUI/voices/joanna.mp3 b/GUI/voices/joanna.mp3 new file mode 100644 index 0000000..f53b393 Binary files /dev/null and b/GUI/voices/joanna.mp3 differ diff --git a/GUI/voices/joey.mp3 b/GUI/voices/joey.mp3 new file mode 100644 index 0000000..d83c9d2 Binary files /dev/null and b/GUI/voices/joey.mp3 differ diff --git a/GUI/voices/jp_001.mp3 b/GUI/voices/jp_001.mp3 new file mode 100644 index 0000000..b6505cd Binary files /dev/null and b/GUI/voices/jp_001.mp3 differ diff --git a/GUI/voices/jp_003.mp3 b/GUI/voices/jp_003.mp3 new file mode 100644 index 0000000..1ef5782 Binary files /dev/null and b/GUI/voices/jp_003.mp3 differ diff --git a/GUI/voices/jp_005.mp3 b/GUI/voices/jp_005.mp3 new file mode 100644 index 0000000..5d3d5bf Binary files /dev/null and b/GUI/voices/jp_005.mp3 differ diff --git a/GUI/voices/jp_006.mp3 b/GUI/voices/jp_006.mp3 new file mode 100644 index 0000000..3e493fb Binary files /dev/null and b/GUI/voices/jp_006.mp3 differ diff --git a/GUI/voices/justin.mp3 b/GUI/voices/justin.mp3 new file mode 100644 index 0000000..1275f14 Binary files /dev/null and b/GUI/voices/justin.mp3 differ diff --git a/GUI/voices/kendra.mp3 b/GUI/voices/kendra.mp3 new file mode 100644 index 0000000..2bb35a8 Binary files /dev/null and b/GUI/voices/kendra.mp3 differ diff --git a/GUI/voices/kimberly.mp3 b/GUI/voices/kimberly.mp3 new file mode 100644 index 0000000..e51652c Binary files /dev/null and b/GUI/voices/kimberly.mp3 differ diff --git a/GUI/voices/kr_002.mp3 b/GUI/voices/kr_002.mp3 new file mode 100644 index 0000000..e9ef657 Binary files /dev/null and b/GUI/voices/kr_002.mp3 differ diff --git a/GUI/voices/kr_003.mp3 b/GUI/voices/kr_003.mp3 new file mode 100644 index 0000000..8411cd2 Binary files /dev/null and b/GUI/voices/kr_003.mp3 differ diff --git a/GUI/voices/kr_004.mp3 b/GUI/voices/kr_004.mp3 new file mode 100644 index 0000000..182b407 Binary files /dev/null and b/GUI/voices/kr_004.mp3 differ diff --git a/GUI/voices/matthew.mp3 b/GUI/voices/matthew.mp3 new file mode 100644 index 0000000..cce7967 Binary files /dev/null and b/GUI/voices/matthew.mp3 differ diff --git a/GUI/voices/nicole.mp3 b/GUI/voices/nicole.mp3 new file mode 100644 index 0000000..5e972e2 Binary files /dev/null and b/GUI/voices/nicole.mp3 differ diff --git a/GUI/voices/raveena.mp3 b/GUI/voices/raveena.mp3 new file mode 100644 index 0000000..558403f Binary files /dev/null and b/GUI/voices/raveena.mp3 differ diff --git a/GUI/voices/russell.mp3 b/GUI/voices/russell.mp3 new file mode 100644 index 0000000..b8d8e7a Binary files /dev/null and b/GUI/voices/russell.mp3 differ diff --git a/GUI/voices/salli.mp3 b/GUI/voices/salli.mp3 new file mode 100644 index 0000000..467f98d Binary files /dev/null and b/GUI/voices/salli.mp3 differ diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index 00485b3..1324118 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import os import re from pathlib import Path from typing import Tuple @@ -6,8 +7,11 @@ from typing import Tuple # import sox # from mutagen import MutagenError # from mutagen.mp3 import MP3, HeaderNotFoundError +import numpy as np import translators as ts -from moviepy.editor import AudioFileClip, CompositeAudioClip, concatenate_audioclips +from moviepy.audio.AudioClip import AudioClip +from moviepy.audio.fx.volumex import volumex +from moviepy.editor import AudioFileClip from rich.progress import track from utils import settings @@ -22,7 +26,7 @@ class TTSEngine: """Calls the given TTS engine to reduce code duplication and allow multiple TTS engines. Args: - tts_module : The TTS module. Your module should handle the TTS itself and saving to the given path under the run method. + tts_module : The TTS module. Your module should handle the TTS itself and saving to the given path under the run method. reddit_object : The reddit object that contains the posts to read. path (Optional) : The unix style path to save the mp3 files to. This must not have leading or trailing slashes. max_length (Optional) : The maximum length of the mp3 files in total. @@ -84,45 +88,69 @@ class TTSEngine: split_text = [ x.group().strip() for x in re.finditer(r" *(((.|\n){0," + str(self.tts_module.max_chars) + "})(\.|.$))", text) ] - offset = 0 - for idy, text_cut in enumerate(split_text): - # print(f"{idx}-{idy}: {text_cut}\n") - new_text = process_text(text_cut) - if not new_text or new_text.isspace(): - offset += 1 - continue - - self.call_tts(f"{idx}-{idy - offset}.part", new_text) - split_files.append(AudioFileClip(f"{self.path}/{idx}-{idy - offset}.part.mp3")) - - CompositeAudioClip([concatenate_audioclips(split_files)]).write_audiofile( - f"{self.path}/{idx}.mp3", fps=44100, verbose=False, logger=None - ) + self.create_silence_mp3() - for i in split_files: - name = i.filename - i.close() - Path(name).unlink() - - # for i in range(0, idy + 1): - # print(f"Cleaning up {self.path}/{idx}-{i}.part.mp3") + idy = None + for idy, text_cut in enumerate(split_text): + newtext = process_text(text_cut) + # print(f"{idx}-{idy}: {newtext}\n") - # Path(f"{self.path}/{idx}-{i}.part.mp3").unlink() + if not newtext or newtext.isspace(): + print("newtext was blank because sanitized split text resulted in none") + continue + else: + self.call_tts(f"{idx}-{idy}.part", newtext) + with open(f"{self.path}/list.txt", 'w') as f: + for idz in range(0, len(split_text)): + f.write("file " + f"'{idx}-{idz}.part.mp3'" + "\n") + split_files.append(str(f"{self.path}/{idx}-{idy}.part.mp3")) + f.write("file " + f"'silence.mp3'" + "\n") + + os.system("ffmpeg -f concat -y -hide_banner -loglevel panic -safe 0 " + + "-i " + f"{self.path}/list.txt " + + "-c copy " + f"{self.path}/{idx}.mp3") + try: + for i in range(0, len(split_files)): + os.unlink(split_files[i]) + except FileNotFoundError as e: + print("File not found: " + e.filename) + except OSError: + print("OSError") def call_tts(self, filename: str, text: str): - 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): - # self.length += sox.file_info.duration(f"{self.path}/{filename}.mp3") + try: + self.tts_module.run(text, filepath=f"{self.path}/{filename}_no_silence.mp3") + self.create_silence_mp3() + + with open(f"{self.path}/{filename}.txt", 'w') as f: + f.write("file " + f"'{filename}_no_silence.mp3'" + "\n") + f.write("file " + f"'silence.mp3'" + "\n") + f.close() + os.system("ffmpeg -f concat -y -hide_banner -loglevel panic -safe 0 " + + "-i " + f"{self.path}/{filename}.txt " + + "-c copy " + f"{self.path}/{filename}.mp3") clip = AudioFileClip(f"{self.path}/{filename}.mp3") - self.last_clip_length = clip.duration self.length += clip.duration + self.last_clip_length = clip.duration clip.close() + try: + name = [f"{filename}_no_silence.mp3", "silence.mp3", f"{filename}.txt"] + for i in range(0, len(name)): + os.unlink(str(rf"{self.path}/" + name[i])) + except FileNotFoundError as e: + print("File not found: " + e.filename) + except OSError: + print("OSError") except: self.length = 0 + def create_silence_mp3(self): + silence_duration = settings.config["settings"]["tts"]["silence_duration"] + silence = AudioClip(make_frame=lambda t: np.sin(440 * 2 * np.pi * t), duration=silence_duration, fps=44100) + silence = volumex(silence, 0) + silence.write_audiofile(f"{self.path}/silence.mp3", fps=44100, verbose=False, logger=None) + def process_text(text: str): lang = settings.config["reddit"]["thread"]["post_lang"] diff --git a/main.py b/main.py index 81d755e..8887d1a 100755 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ from subprocess import Popen from os import name from sys import platform +from pathlib import Path from prawcore import ResponseException from reddit.subreddit import get_subreddit_threads @@ -86,7 +87,9 @@ def shutdown(): exit() if __name__ == "__main__": - config = settings.check_toml("utils/.config.template.toml", "config.toml") + assert sys.version_info >= (3, 9), "Python 3.10 or higher is required" + directory = Path().absolute() + config = settings.check_toml(f"{directory}/utils/.config.template.toml", "config.toml") config is False and exit() try: if config["settings"]["times_to_run"]: diff --git a/requirements.txt b/requirements.txt index a954c20..084ab29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ toml==0.10.2 translators==5.3.1 pyttsx3==2.90 Pillow~=9.1.1 +tomlkit==0.11.4 +Flask==2.2.2 diff --git a/utils/.config.template.toml b/utils/.config.template.toml index adbaed0..50cf413 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -3,11 +3,11 @@ client_id = { optional = false, nmin = 12, nmax = 30, explanation = "The ID of y client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "The SECRET of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The secret should be over 20 and under 40 characters, double check your input." } username = { optional = false, nmin = 3, nmax = 20, explanation = "The username of your reddit account", example = "JasonLovesDoggo", regex = "^[-_0-9a-zA-Z]+$", oob_error = "A username HAS to be between 3 and 20 characters" } password = { optional = false, nmin = 8, explanation = "The password of your reddit account", example = "fFAGRNJru1FTz70BzhT3Zg", oob_error = "Password too short" } -2fa = { optional = true, type = "bool", options = [true, false,], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true } +2fa = { optional = true, type = "bool", options = [true, false, ], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true } [reddit.thread] -random = { optional = true, options = [true, false,], default = false, type = "bool", explanation = "If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'", example = "True" } +random = { optional = true, options = [true, false, ], default = false, type = "bool", explanation = "If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'", example = "True" } subreddit = { optional = false, regex = "[_0-9a-zA-Z]+$", nmin = 3, explanation = "What subreddit to pull posts from, the name of the sub, not the URL. You can have multiple subreddits, add an + with no spaces.", example = "AskReddit+Redditdev", oob_error = "A subreddit name HAS to be between 3 and 20 characters" } post_id = { optional = true, default = "", regex = "^((?!://|://)[+a-zA-Z0-9])*$", explanation = "Used if you want to use a specific post.", example = "urdtfx" } max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "max number of characters a comment can have. default is 500", example = 500, oob_error = "the max comment length should be between 10 and 10000" } @@ -16,24 +16,25 @@ min_comments = { default = 20, optional = false, nmin = 15, type = "int", explan [settings] -allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false,], explanation = "Whether to allow NSFW content, True or False" } -theme = { optional = false, default = "dark", example = "light", options = ["dark", "light",], explanation = "Sets the Reddit theme, either LIGHT or DARK" } +allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false, ], explanation = "Whether to allow NSFW content, True or False" } +theme = { optional = false, default = "dark", example = "light", options = ["dark", "light", ], explanation = "Sets the Reddit theme, either LIGHT or DARK" } 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" } -storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Only read out title and post content, not yet implemented" } +storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false, ], explanation = "Only read out title and post content, not yet implemented" } [settings.background] -background_choice = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", ""], explanation = "Sets the background for the video based on game name" } +background_choice = { optional = true, default = "minecraft", example = "rocket-league", options = ["", "minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck"], 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" } [settings.tts] -voice_choice = { optional = false, default = "", 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 = ["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." } 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 = false, default = "en_us_006", example = "en_us_006", explanation = "The voice used for TikTok TTS" } -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)"} +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" } diff --git a/utils/CONSTANTS.py b/utils/CONSTANTS.py deleted file mode 100644 index e46ebbc..0000000 --- a/utils/CONSTANTS.py +++ /dev/null @@ -1,45 +0,0 @@ -# Supported Background. Can add/remove background video here.... -# - : key -> used as keyword for TOML file. value -> background configuration -# Format (value): -# 1. Youtube URI -# 2. filename -# 3. Citation (owner of the video) -# 4. Position of image clips in the background. See moviepy reference for more information. (https://zulko.github.io/moviepy/ref/VideoClip/VideoClip.html#moviepy.video.VideoClip.VideoClip.set_position) -background_options = { - "motor-gta": ( # Motor-GTA Racing - "https://www.youtube.com/watch?v=vw5L4xCPy9Q", - "bike-parkour-gta.mp4", - "Achy Gaming", - lambda t: ("center", 480 + t), - ), - "rocket-league": ( # Rocket League - "https://www.youtube.com/watch?v=2X9QGY__0II", - "rocket_league.mp4", - "Orbital Gameplay", - lambda t: ("center", 200 + t), - ), - "minecraft": ( # Minecraft parkour - "https://www.youtube.com/watch?v=n_Dv4JMiwK8", - "parkour.mp4", - "bbswitzer", - "center", - ), - "gta": ( # GTA Stunt Race - "https://www.youtube.com/watch?v=qGa9kWREOnE", - "gta-stunt-race.mp4", - "Achy Gaming", - lambda t: ("center", 480 + t), - ), - "csgo-surf": ( # CSGO Surf - "https://www.youtube.com/watch?v=E-8JlyO59Io", - "csgo-surf.mp4", - "Aki", - "center", - ), - "cluster-truck": ( # Cluster Truck Gameplay - "https://www.youtube.com/watch?v=uVKxtdMgJVU", - "cluster_truck.mp4", - "No Copyright Gameplay", - lambda t: ("center", 480 + t), - ), -} diff --git a/utils/backgrounds.json b/utils/backgrounds.json new file mode 100644 index 0000000..af800c5 --- /dev/null +++ b/utils/backgrounds.json @@ -0,0 +1,39 @@ +{ + "__comment": "Supported Backgrounds. Can add/remove background video here...", + "motor-gta": [ + "https://www.youtube.com/watch?v=vw5L4xCPy9Q", + "bike-parkour-gta.mp4", + "Achy Gaming", + 480 + ], + "rocket-league": [ + "https://www.youtube.com/watch?v=2X9QGY__0II", + "rocket_league.mp4", + "Orbital Gameplay", + 200 + ], + "minecraft": [ + "https://www.youtube.com/watch?v=n_Dv4JMiwK8", + "parkour.mp4", + "bbswitzer", + "center" + ], + "gta": [ + "https://www.youtube.com/watch?v=qGa9kWREOnE", + "gta-stunt-race.mp4", + "Achy Gaming", + 480 + ], + "csgo-surf": [ + "https://www.youtube.com/watch?v=E-8JlyO59Io", + "csgo-surf.mp4", + "Aki", + "center" + ], + "cluster-truck": [ + "https://www.youtube.com/watch?v=uVKxtdMgJVU", + "cluster_truck.mp4", + "No Copyright Gameplay", + 480 + ] +} \ No newline at end of file diff --git a/utils/gui_utils.py b/utils/gui_utils.py new file mode 100644 index 0000000..b539ff7 --- /dev/null +++ b/utils/gui_utils.py @@ -0,0 +1,226 @@ +import json +import re +from pathlib import Path + +import toml +import tomlkit +from flask import flash + + +# Get validation checks from template +def get_checks(): + template = toml.load("utils/.config.template.toml") + checks = {} + + def unpack_checks(obj: dict): + for key in obj.keys(): + if "optional" in obj[key].keys(): + checks[key] = obj[key] + else: + unpack_checks(obj[key]) + + unpack_checks(template) + + return checks + + +# Get current config (from config.toml) as dict +def get_config(obj: dict, done={}): + for key in obj.keys(): + if not isinstance(obj[key], dict): + done[key] = obj[key] + else: + get_config(obj[key], done) + + return done + + +# Checks if value is valid +def check(value, checks): + incorrect = False + + if value == "False": + value = "" + + if not incorrect and "type" in checks: + try: + value = eval(checks["type"])(value) + except Exception: + incorrect = True + + if ( + not incorrect and "options" in checks and value not in checks["options"] + ): # FAILSTATE Value is not one of the options + incorrect = True + if ( + not incorrect + and "regex" in checks + and ( + (isinstance(value, str) and re.match(checks["regex"], value) is None) + or not isinstance(value, str) + ) + ): # FAILSTATE Value doesn't match regex, or has regex but is not a string. + incorrect = True + + if ( + not incorrect + and not hasattr(value, "__iter__") + and ( + ("nmin" in checks and checks["nmin"] is not None and value < checks["nmin"]) + or ( + "nmax" in checks + and checks["nmax"] is not None + and value > checks["nmax"] + ) + ) + ): + incorrect = True + + if ( + not incorrect + and hasattr(value, "__iter__") + and ( + ( + "nmin" in checks + and checks["nmin"] is not None + and len(value) < checks["nmin"] + ) + or ( + "nmax" in checks + and checks["nmax"] is not None + and len(value) > checks["nmax"] + ) + ) + ): + incorrect = True + + if incorrect: + return "Error" + + return value + + +# Modify settings (after form is submitted) +def modify_settings(data: dict, config_load, checks: dict): + # Modify config settings + def modify_config(obj: dict, name: str, value: any): + for key in obj.keys(): + if name == key: + obj[key] = value + elif not isinstance(obj[key], dict): + continue + else: + modify_config(obj[key], name, value) + + # Remove empty/incorrect key-value pairs + data = {key: value for key, value in data.items() if value and key in checks.keys()} + + # Validate values + for name in data.keys(): + value = check(data[name], checks[name]) + + # Value is invalid + if value == "Error": + flash("Some values were incorrect and didn't save!", "error") + else: + # Value is valid + modify_config(config_load, name, value) + + # Save changes in config.toml + with Path("config.toml").open("w") as toml_file: + toml_file.write(tomlkit.dumps(config_load)) + + flash("Settings saved!") + + return get_config(config_load) + + +# Delete background video +def delete_background(key): + # Read backgrounds.json + with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds: + data = json.load(backgrounds) + + # Remove background from backgrounds.json + with open("utils/backgrounds.json", "w", encoding="utf-8") as backgrounds: + if data.pop(key, None): + json.dump(data, backgrounds, ensure_ascii=False, indent=4) + else: + flash("Couldn't find this background. Try refreshing the page.", "error") + return + + # Remove background video from ".config.template.toml" + config = tomlkit.loads(Path("utils/.config.template.toml").read_text()) + config["settings"]["background"]["background_choice"]["options"].remove(key) + + with Path("utils/.config.template.toml").open("w") as toml_file: + toml_file.write(tomlkit.dumps(config)) + + flash(f'Successfully removed "{key}" background!') + + +# Add background video +def add_background(youtube_uri, filename, citation, position): + # Validate YouTube URI + regex = re.compile(r"(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)").search( + youtube_uri + ) + + if not regex: + flash("YouTube URI is invalid!", "error") + return + + youtube_uri = f"https://www.youtube.com/watch?v={regex.group(1)}" + + # Check if position is valid + if position == "" or position == "center": + position = "center" + + elif position.isdecimal(): + position = int(position) + + else: + flash('Position is invalid! It can be "center" or decimal number.', "error") + return + + # Sanitize filename + regex = re.compile(r"^([a-zA-Z0-9\s_-]{1,100})$").match(filename) + + if not regex: + flash("Filename is invalid!", "error") + return + + filename = filename.replace(" ", "_") + + # Check if background doesn't already exist + with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds: + data = json.load(backgrounds) + + # Check if key isn't already taken + if filename in list(data.keys()): + flash("Background video with this name already exist!", "error") + return + + # Check if the YouTube URI isn't already used under different name + if youtube_uri in [data[i][0] for i in list(data.keys())]: + flash("Background video with this YouTube URI is already added!", "error") + return + + # Add background video to json file + with open("utils/backgrounds.json", "r+", encoding="utf-8") as backgrounds: + data = json.load(backgrounds) + + data[filename] = [youtube_uri, filename + ".mp4", citation, position] + backgrounds.seek(0) + json.dump(data, backgrounds, ensure_ascii=False, indent=4) + + # Add background video to ".config.template.toml" + config = tomlkit.loads(Path("utils/.config.template.toml").read_text()) + config["settings"]["background"]["background_choice"]["options"].append(filename) + + with Path("utils/.config.template.toml").open("w") as toml_file: + toml_file.write(tomlkit.dumps(config)) + + flash(f'Added "{citation}-{filename}.mp4" as a new background video!') + + return diff --git a/utils/settings.py b/utils/settings.py index 9f1f78b..d2974d5 100755 --- a/utils/settings.py +++ b/utils/settings.py @@ -1,7 +1,7 @@ #!/usr/bin/env python import re from typing import Tuple, Dict - +from pathlib import Path import toml from rich.console import Console @@ -159,4 +159,5 @@ If you see any prompts, that means that you have unset/incorrectly set variables if __name__ == "__main__": - check_toml("utils/.config.template.toml", "config.toml") + directory = Path().absolute() + check_toml(f"{directory}/utils/.config.template.toml", "config.toml") diff --git a/video_creation/background.py b/video_creation/background.py index f0f38d5..c451025 100644 --- a/video_creation/background.py +++ b/video_creation/background.py @@ -1,3 +1,4 @@ +import json import random import re from pathlib import Path @@ -8,11 +9,24 @@ from moviepy.editor import VideoFileClip 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.CONSTANTS import background_options from utils.console import print_step, print_substep +# 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) + +# 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] + + if pos != "center": + background_options[name][3] = lambda t: ("center", pos + t) + def get_start_and_end_times(video_length: int, length_of_clip: int) -> Tuple[int, int]: """Generates a random interval of time to be used as the background of the video.