diff --git a/.gitignore b/.gitignore index 65d700d..d4d844f 100644 --- a/.gitignore +++ b/.gitignore @@ -145,11 +145,83 @@ dmypy.json cython_debug/ # PyCharm -# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser output/ local/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..c007ce1 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:C:\Users\jerem\D\09._AI_projects\viralautomator\local\database\db.db + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..5753022 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..568ed59 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/viralautomator.iml b/.idea/viralautomator.iml new file mode 100644 index 0000000..1a19fd3 --- /dev/null +++ b/.idea/viralautomator.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/chore/GenerationContext.py b/src/chore/GenerationContext.py index f8ab19d..6ae208a 100644 --- a/src/chore/GenerationContext.py +++ b/src/chore/GenerationContext.py @@ -2,6 +2,7 @@ import os import time from datetime import datetime +import gradio import moviepy.editor as mp from .. import engines @@ -35,8 +36,16 @@ class GenerationContext: backgroundengine, metadataengine, uploadengine, + audiobackgroundengine, progress, ) -> None: + self.captions = [] + self.dir = None + self.script = None + self.description = None + self.title = None + self.credits = None + self.duration = None self.progress = progress self.powerfulllmengine: engines.LLMEngine.BaseLLMEngine = powerfulllmengine[0] @@ -79,17 +88,13 @@ class GenerationContext: for eng in self.uploadengine: eng.ctx = self - def setup_dir(self): - self.dir = f"output/{time.time()}" - os.makedirs(self.dir) + self.audiobackgroundengine: engines.AudioBackgroundEngine.BaseAudioBackgroundEngine = ( + audiobackgroundengine[0] + ) + self.audiobackgroundengine.ctx = self - def get_file_path(self, name: str) -> str: - return os.path.join(self.dir, name) - - def process(self): - # ⚠️ IMPORTANT NOTE: All methods called here are expected to be defined as abstract methods in the base classes, if not there is an issue with the engine implementation. - - # Kinda like in css, we have a z-index of moviepy clips (any). Then the engines append some clips to this, and we render it all with index 0 below, and index 9 at the top. + # Kinda like in css, we have a z-index of moviepy clips (any). Then the engines append some clips to this, + # and we render it all with index 0 below, and index 9 at the top. self.index_0 = [] self.index_1 = [] self.index_2 = [] @@ -100,23 +105,45 @@ class GenerationContext: self.index_7 = [] self.index_8 = [] self.index_9 = [] + + self.audio = [] + self.credits = "Generated by AI" + def setup_dir(self): + self.dir = f"output/{time.time()}" + os.makedirs(self.dir) + + def get_file_path(self, name: str) -> str: + return os.path.join(self.dir, name) + + def process(self): + # ⚠️ IMPORTANT NOTE: All methods called here are expected to be defined as abstract methods in the base + # classes, if not there is an issue with the engine implementation. + self.progress(0.1, "Loading settings...") self.setup_dir() - self.settingsengine.load() + if not isinstance(self.settingsengine, engines.NoneEngine): + self.settingsengine.load() self.progress(0.2, "Generating script...") - self.scriptengine.generate() + if not isinstance(self.powerfulllmengine, engines.NoneEngine): + self.scriptengine.generate() - self.progress(0.3, "Generating synthtetizing voice...") - self.ttsengine.synthesize(self.script, self.get_file_path("tts.wav")) - self.duration: float # for type hinting + self.progress(0.3, "Synthesizing voice...") + if not isinstance(self.ttsengine, engines.NoneEngine): + self.ttsengine.synthesize(self.script, self.get_file_path("tts.wav")) + self.duration: float # for type hinting + self.audio.append(mp.AudioFileClip(self.get_file_path("tts.wav"))) if not isinstance(self.backgroundengine, engines.NoneEngine): self.progress(0.4, "Generating background...") self.backgroundengine.get_background() + if not isinstance(self.audiobackgroundengine, engines.NoneEngine): + self.progress(0.45, "Generating audio background...") + self.audiobackgroundengine.get_background() + self.assetsengine = [ engine for engine in self.assetsengine @@ -129,10 +156,6 @@ class GenerationContext: if not isinstance(self.captioningengine, engines.NoneEngine): self.progress(0.6, "Generating captions...") self.captioningengine.get_captions() - else: - self.captions = [] - - # add any other processing steps here # we render to a file called final.mp4 self.progress(0.7, "Rendering video...") @@ -149,18 +172,22 @@ class GenerationContext: *self.index_9, ] clip = mp.CompositeVideoClip(clips, size=(self.width, self.height)) + audio = mp.CompositeAudioClip(self.audio) clip.set_duration(self.duration) - audio = mp.AudioFileClip(self.get_file_path("tts.wav")) clip: mp.CompositeVideoClip = clip.set_audio(audio) - clip.write_videofile(self.get_file_path("final.mp4"), fps=60, threads=4) + clip.write_videofile(self.get_file_path("final.mp4"), fps=60, threads=4, codec="h264_nvenc") self.progress(0.8, "Generating metadata...") self.metadataengine.get_metadata() + self.description = self.description + "\n" + self.credits self.progress(0.9, "Uploading video...") for engine in self.uploadengine: - engine.upload() - + try: + engine.upload() + except Exception as e: + print(e) + gradio.Warning(f"{engine.name} failed to upload the video.") self.progress(0.99, "Storing in database...") self.store_in_db() self.progress(1, "Done!") diff --git a/src/engines/AssetsEngine/GoogleAssetsEngine.py b/src/engines/AssetsEngine/GoogleAssetsEngine.py index 41b55f5..48cf04e 100644 --- a/src/engines/AssetsEngine/GoogleAssetsEngine.py +++ b/src/engines/AssetsEngine/GoogleAssetsEngine.py @@ -49,16 +49,18 @@ class GoogleAssetsEngine(BaseAssetsEngine): "num": 1, } os.makedirs("temp", exist_ok=True) - self.google.search( - search_params=_search_params, - path_to_dir="./temp/", - custom_image_name="temp", - ) - # we find the file called temp. extension - filename = [f for f in os.listdir("./temp/") if f.startswith("temp.")][0] - img = mp.ImageClip(f"./temp/{filename}") - # delete the temp folder - shutil.rmtree("temp") + try: + self.google.search( + search_params=_search_params, + path_to_dir="./temp/", + custom_image_name="temp", + ) + # we find the file called temp. extension + filename = [f for f in os.listdir("./temp/") if f.startswith("temp.")][0] + img = mp.ImageClip(f"./temp/{filename}") + # delete the temp folder + finally: + shutil.rmtree("temp") img: mp.ImageClip = img.set_duration(end - start) img: mp.ImageClip = img.set_start(start) diff --git a/src/engines/AudioBackgroundEngine/BaseAudioBackgroundEngine.py b/src/engines/AudioBackgroundEngine/BaseAudioBackgroundEngine.py new file mode 100644 index 0000000..ef26ef7 --- /dev/null +++ b/src/engines/AudioBackgroundEngine/BaseAudioBackgroundEngine.py @@ -0,0 +1,9 @@ +from abc import abstractmethod + +from ..BaseEngine import BaseEngine + + +class BaseAudioBackgroundEngine(BaseEngine): + @abstractmethod + def get_background(self) -> None: + ... diff --git a/src/engines/AudioBackgroundEngine/MusicAudioBackgroundEngine.py b/src/engines/AudioBackgroundEngine/MusicAudioBackgroundEngine.py new file mode 100644 index 0000000..d3b305c --- /dev/null +++ b/src/engines/AudioBackgroundEngine/MusicAudioBackgroundEngine.py @@ -0,0 +1,88 @@ +import os +import random +import shutil +import time + +import gradio as gr +import moviepy.editor as mp +from moviepy.audio.fx.audio_fadein import audio_fadein +from moviepy.audio.fx.audio_fadeout import audio_fadeout + +from . import BaseAudioBackgroundEngine + + +class MusicAudioBackgroundEngine(BaseAudioBackgroundEngine): + name = "Music Audio Background Engine" + description = "A basic background engine to set the background audio to a music track." + num_options = 1 + + def __init__(self, options: list[str]): + assets = self.get_assets(type="bcg_music") + self.background_audio = [asset for asset in assets if asset.data["name"] == options[0]][0] + super().__init__() + + @classmethod + def get_options(cls) -> list: + assets = cls.get_assets(type="bcg_music") + choices = ( + [asset.data["name"] for asset in assets] + if len(assets) > 0 + else ["No audios available"] + ) + + return [ + gr.Dropdown( + choices=choices, + label="Background Music", + value=choices[0] if len(assets) > 0 else "No audios available", + type="value", + ) + ] + + def get_background(self): + background = mp.AudioFileClip(f"{self.background_audio.path}") + self.ctx.credits += f"\n{self.background_audio.data['credits']}" + # we add fade in and fade out to the audio + background: mp.AudioFileClip = background.fx(audio_fadein, 1).fx(audio_fadeout, 1) + # loop the audio to match the duration of the video + audio_clips = [] + while sum([clip.duration for clip in audio_clips]) < self.ctx.duration: + audio_clips.append(background) + # now we cut the audio to match the duration of the video exactly + audio: mp.AudioFileClip = mp.concatenate_audioclips(audio_clips) + audio: mp.AudioFileClip = audio.subclip(0, self.ctx.duration) + # finally we add a new fade OUT only to the audio + audio: mp.AudioFileClip = audio.fx(audio_fadeout, 1) + # change volume to 0.5 + audio: mp.AudioFileClip = audio.volumex(0.5) + self.ctx.audio.append(audio) + + @classmethod + def get_settings(cls): + def add_file(fp: str, name: str, credits: str): + if name == "": + raise ValueError("Name cannot be empty.") + new_fp = f"local/assets/audios/{time.time()}{os.path.splitext(fp)[1]}" + shutil.move(fp, new_fp) + cls.add_asset( + path=new_fp, + metadata={"name": name, "credits": credits}, + type="bcg_music", + ) + gr.Info("Video added successfully.") + + with gr.Column() as add_asset_inputs: + add_asset_name = gr.Textbox(label="Name of the audio", value="") + add_asset_credits = gr.Textbox(label="Credits", value="") + add_asset_input = gr.File( + file_count="single", + file_types=["audio"], + type="filepath", + ) + with gr.Column() as add_asset_button: + add_asset_button = gr.Button(value="Add Audio") + add_asset_button.click( + add_file, + inputs=[add_asset_input, add_asset_name, add_asset_credits], + outputs=[], + ) diff --git a/src/engines/AudioBackgroundEngine/__init__.py b/src/engines/AudioBackgroundEngine/__init__.py new file mode 100644 index 0000000..85949e6 --- /dev/null +++ b/src/engines/AudioBackgroundEngine/__init__.py @@ -0,0 +1,2 @@ +from .BaseAudioBackgroundEngine import BaseAudioBackgroundEngine +from .MusicAudioBackgroundEngine import MusicAudioBackgroundEngine diff --git a/src/engines/BackgroundEngine/BaseBackgroundEngine.py b/src/engines/BackgroundEngine/BaseBackgroundEngine.py index 6ef6a74..3e51891 100644 --- a/src/engines/BackgroundEngine/BaseBackgroundEngine.py +++ b/src/engines/BackgroundEngine/BaseBackgroundEngine.py @@ -1,6 +1,6 @@ from abc import abstractmethod -from ..BaseEngine import BaseEngine +from src.engines.BaseEngine import BaseEngine class BaseBackgroundEngine(BaseEngine): diff --git a/src/engines/BaseEngine.py b/src/engines/BaseEngine.py index 7275312..586758c 100644 --- a/src/engines/BaseEngine.py +++ b/src/engines/BaseEngine.py @@ -7,7 +7,6 @@ from ..chore import GenerationContext from ..models import SessionLocal, File, Setting -# noinspection PyTypeChecker class BaseEngine(ABC): num_options: int name: str @@ -15,7 +14,6 @@ class BaseEngine(ABC): def __init__(self): self.ctx: GenerationContext # This is for type hinting only - pass @classmethod @abstractmethod diff --git a/src/engines/CaptioningEngine/SimpleCaptioningEngine.py b/src/engines/CaptioningEngine/SimpleCaptioningEngine.py index 2d0cf65..104df2e 100644 --- a/src/engines/CaptioningEngine/SimpleCaptioningEngine.py +++ b/src/engines/CaptioningEngine/SimpleCaptioningEngine.py @@ -1,4 +1,6 @@ import gradio as gr +import os +import shutil from moviepy.editor import TextClip from . import BaseCaptioningEngine @@ -80,6 +82,13 @@ class SimpleCaptioningEngine(BaseCaptioningEngine): self.ctx.index_7.extend(clips) + @classmethod + def get_settings(cls): + gr.Markdown( + "To add a custom font, simply install the font on your system, restart the server, and input the exact " + "file name (without the path) in the dropdown." + ) + @classmethod def get_options(cls) -> list: with gr.Column() as font_options: @@ -87,7 +96,8 @@ class SimpleCaptioningEngine(BaseCaptioningEngine): font = gr.Dropdown( label="Font", choices=TextClip.list("font"), - value="Comic-Sans-MS", + value="Comic-Sans-MS-Bold", + allow_custom_value=True, # Allow custom font ) font_size = gr.Number( label="Font Size", diff --git a/src/engines/NoneEngine.py b/src/engines/NoneEngine.py index 1f304da..b7fa53e 100644 --- a/src/engines/NoneEngine.py +++ b/src/engines/NoneEngine.py @@ -2,13 +2,28 @@ from . import BaseEngine class NoneEngine(BaseEngine): - num_options = 0 - name = "None" - description = "No engine selected" + """ + This class represents a NoneEngine which is a subclass of BaseEngine. + It is used when no engine is selected. It does not have any options. + """ - def __init__(self): + num_options = 0 # The number of options available for this engine. + name = "None" # The name of the engine. + description = "No engine selected" # A brief description of the engine. + + def __init__(self, *args, **kwargs): + """ + Constructor method for the NoneEngine class. It calls the constructor of the BaseEngine class. + """ super().__init__() @classmethod def get_options(cls): - return [] + """ + This class method returns the options available for the NoneEngine. + Since NoneEngine does not have any options, it returns an empty list. + + Returns: + list: An empty list as there are no options for NoneEngine. + """ + return [] \ No newline at end of file diff --git a/src/engines/ScriptEngine/ScientificFactsScriptEngine.py b/src/engines/ScriptEngine/ScientificFactsScriptEngine.py new file mode 100644 index 0000000..0446258 --- /dev/null +++ b/src/engines/ScriptEngine/ScientificFactsScriptEngine.py @@ -0,0 +1,37 @@ +import os + +import gradio as gr + +from .BaseScriptEngine import BaseScriptEngine +from ...utils.prompting import get_prompt + + +class ScientificFactsScriptEngine(BaseScriptEngine): + name = "Scientific facts Thoughts" + description = "Generate a scientific facts script." + num_options = 1 + + def __init__(self, options: list[list | tuple | str | int | float | bool | None]): + self.n_sentences = options[0] + super().__init__() + + def generate(self): + sys_prompt, chat_prompt = get_prompt( + "scientific_facts", + location=os.path.join( + os.path.dirname(os.path.abspath(__file__)), "prompts" + ), + ) + sys_prompt = sys_prompt.format(n_sentences=self.n_sentences) + chat_prompt = chat_prompt.format(n_sentences=self.n_sentences) + self.ctx.script = self.ctx.powerfulllmengine.generate( + system_prompt=sys_prompt, + chat_prompt=chat_prompt, + max_tokens=20 * self.n_sentences, + temperature=1.3, + json_mode=False, + ).strip() + + @classmethod + def get_options(cls) -> list: + return [gr.Number(label="Number of sentences", value=5, minimum=1)] diff --git a/src/engines/ScriptEngine/prompts/scientific_facts.yaml b/src/engines/ScriptEngine/prompts/scientific_facts.yaml new file mode 100644 index 0000000..c7dfee2 --- /dev/null +++ b/src/engines/ScriptEngine/prompts/scientific_facts.yaml @@ -0,0 +1,19 @@ +system: |- + You are an expert content writer of a YouTube shorts channel. You specialize in scientific facts shorts. + Your shorts are {n_sentences} sentences long. This is VERY IMPORTANT, MAKE SURE TO RESPECT THIS LENGTH. + They are extremely captivating, and original. + You need to follow the following guidelines: + - **Hook the Viewer:** Start with a compelling question, fact, or scenario to grab attention immediately. Your fact can also be a bit wierd or shocking (not really shocking, but you get the point), so that the viewer wants to know the actual truth. + Specifically, you can start with something that isn't completely correct, then when you continue the actual explanation unfolds, in order to make the first few words more attractive. + - **Keep it Short and Sweet:** Deliver your content concisely and rapidly to match the platform's fast-paced nature. + - **Tap into Relatability or Curiosity:** Make your content relatable or introduce surprising elements to spark curiosity. + - **Maintain a Conversational Tone:** Use conversational language to make your content more accessible and engaging. + - **Use Visual Imagery:** Describe concepts in a way that invokes visual imagery, enhancing engagement. + - **Include a Call to Action:** End with a direct call to action to encourage viewer interaction if applicable. + You are now tasked to produce the greatest short script for the user. + Start with a compelling information, fact, or scenario to grab attention IMMEDIATELY. + Keep it short, EXTREMELY interesting and original. + If it is appropriate, at the end, ask a question to the user, and end point blank. + YOU never respond with anything else that the video script, not even a hello. +chat: | + Please give me a script. Make sure to keep it {n_sentences} sentences long, including any questions or calls to action. \ No newline at end of file diff --git a/src/engines/TTSEngine/BaseTTSEngine.py b/src/engines/TTSEngine/BaseTTSEngine.py index f4cff54..e3955c4 100644 --- a/src/engines/TTSEngine/BaseTTSEngine.py +++ b/src/engines/TTSEngine/BaseTTSEngine.py @@ -49,7 +49,7 @@ class BaseTTSEngine(BaseEngine): """ device = "cuda" if is_available() else "cpu" audio = wt.load_audio(path) - model = wt.load_model("small", device=device) + model = wt.load_model("large-v3", device=device) result = wt.transcribe(model=model, audio=audio) results = [word for chunk in result["segments"] for word in chunk["words"]] diff --git a/src/engines/UploadEngine/TikTokUploadEngine.py b/src/engines/UploadEngine/TikTokUploadEngine.py index 8975312..6c72a16 100644 --- a/src/engines/UploadEngine/TikTokUploadEngine.py +++ b/src/engines/UploadEngine/TikTokUploadEngine.py @@ -36,11 +36,11 @@ class TikTokUploadEngine(BaseUploadEngine): description = description.replace(word, "") title = title.strip() - description = description.strip().replace("\n", " ") # Newlines are not supported by this uploader + description = description.strip() hashtags_str = " ".join(hashtags) + " " if hashtags else "" failed = upload_video( filename=self.ctx.get_file_path("final.mp4"), - description=f"{title} {description} {hashtags_str}", + description=f"{title}\n{description} {hashtags_str}", cookies_str=cookies, browser="chrome", comment=True, stitch=False, duet=False diff --git a/src/engines/__init__.py b/src/engines/__init__.py index ea2e006..262b522 100644 --- a/src/engines/__init__.py +++ b/src/engines/__init__.py @@ -7,6 +7,7 @@ from . import ScriptEngine from . import SettingsEngine from . import TTSEngine from . import UploadEngine +from . import AudioBackgroundEngine from .BaseEngine import BaseEngine from .NoneEngine import NoneEngine @@ -49,7 +50,11 @@ ENGINES: dict[str, dict[str, bool | list[BaseEngine]]] = { "multiple": True, }, "BackgroundEngine": { - "classes": [BackgroundEngine.VideoBackgroundEngine, NoneEngine], + "classes": [NoneEngine, BackgroundEngine.VideoBackgroundEngine], + "multiple": False, + }, + "AudioBackgroundEngine": { + "classes": [NoneEngine, AudioBackgroundEngine.MusicAudioBackgroundEngine], "multiple": False, }, "MetadataEngine": { diff --git a/ui/gradio_ui.py b/ui/gradio_ui.py index 0f0439a..254ba5a 100644 --- a/ui/gradio_ui.py +++ b/ui/gradio_ui.py @@ -186,13 +186,13 @@ class GenerateUI: output_title.render() output_description.render() open_folder.render() - open_folder.click(lambda x: os.system(f"open {os.path.abspath(x)}") if os.name == "posix" else os.system(f"explorer {os.abspath(x)}"), inputs=output_path) + open_folder.click(lambda x: os.system(f"open {os.path.abspath(x)}") if os.name == "posix" else os.system(f"explorer {os.path.abspath(x)}"), inputs=output_path) with gr.Column(): output_video.render() return interface - def run_generate_interface(self, progress=gr.Progress(), *args) -> list[gr.update]: + def run_generate_interface(self, progress=gr.Progress(track_tqdm=True), *args) -> list[gr.update]: progress(0, desc="Loading engines... 🚀") options = self.repack_options(*args) arguments = {name.lower(): options[name] for name in ENGINES.keys()}