3 Commits

Author SHA1 Message Date
f6ee57fb55 FFMPEG path fixes for ffprobe 2026-01-06 23:49:22 -06:00
2ba99cee31 EngineManager renaming and refactoring 2026-01-06 23:48:49 -06:00
Brett Williams
13a82a540a More window improvements 2026-01-06 21:50:41 -06:00
4 changed files with 95 additions and 69 deletions

View File

@@ -389,7 +389,7 @@ def engine_info():
def process_engine(engine): def process_engine(engine):
try: try:
# Get all installed versions of the engine # Get all installed versions of the engine
installed_versions = EngineManager.all_versions_for_engine(engine.name()) installed_versions = EngineManager.all_version_data_for_engine(engine.name())
if not installed_versions: if not installed_versions:
return None return None
@@ -420,7 +420,7 @@ def engine_info():
except Exception as e: except Exception as e:
logger.error(f"Error fetching details for engine '{engine.name()}': {e}") logger.error(f"Error fetching details for engine '{engine.name()}': {e}")
raise e return {}
engine_data = {} engine_data = {}
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
@@ -438,7 +438,7 @@ def engine_info():
def is_engine_available(engine_name): def is_engine_available(engine_name):
return {'engine': engine_name, 'available': RenderQueue.is_available_for_job(engine_name), return {'engine': engine_name, 'available': RenderQueue.is_available_for_job(engine_name),
'cpu_count': int(psutil.cpu_count(logical=False)), 'cpu_count': int(psutil.cpu_count(logical=False)),
'versions': EngineManager.all_versions_for_engine(engine_name), 'versions': EngineManager.all_version_data_for_engine(engine_name),
'hostname': server.config['HOSTNAME']} 'hostname': server.config['HOSTNAME']}

View File

@@ -11,6 +11,9 @@ from src.utilities.misc_helper import system_safe_path, current_system_os, curre
logger = logging.getLogger() logger = logging.getLogger()
ENGINE_CLASSES = [Blender, FFMPEG]
class EngineManager: class EngineManager:
"""Class that manages different versions of installed render engines and handles fetching and downloading new versions, """Class that manages different versions of installed render engines and handles fetching and downloading new versions,
if possible. if possible.
@@ -21,32 +24,36 @@ class EngineManager:
@staticmethod @staticmethod
def supported_engines(): def supported_engines():
return [Blender, FFMPEG] return ENGINE_CLASSES
# --- Installed Engines ---
@classmethod @classmethod
def downloadable_engines(cls): def engine_for_project_path(cls, path):
return [engine for engine in cls.supported_engines() if hasattr(engine, "downloader") and engine.downloader()] _, extension = os.path.splitext(path)
extension = extension.lower().strip('.')
@classmethod for engine_class in cls.supported_engines():
def active_downloads(cls) -> list: engine = cls.get_latest_engine_instance(engine_class)
return [x for x in cls.download_tasks if x.is_alive()] if extension in engine.supported_extensions():
return engine
undefined_renderer_support = [x for x in cls.supported_engines() if not cls.get_latest_engine_instance(x).supported_extensions()]
return undefined_renderer_support[0]
@classmethod @classmethod
def engine_with_name(cls, engine_name): def engine_with_name(cls, engine_name):
for obj in cls.supported_engines(): for obj in cls.supported_engines():
if obj.name().lower() == engine_name.lower(): if obj.name().lower() == engine_name.lower():
return obj return obj
return None
@classmethod @classmethod
def update_all_engines(cls): def get_latest_engine_instance(cls, engine_class):
for engine in cls.downloadable_engines(): newest = cls.newest_installed_engine_data(engine_class.name())
update_available = cls.is_engine_update_available(engine) engine = engine_class(newest["path"])
if update_available: return engine
update_available['name'] = engine.name()
cls.download_engine(engine.name(), update_available['version'], background=True)
@classmethod @classmethod
def get_engines(cls, filter_name=None, include_corrupt=False, ignore_system=False): def get_installed_engine_data(cls, filter_name=None, include_corrupt=False, ignore_system=False):
if not cls.engines_path: if not cls.engines_path:
raise FileNotFoundError("Engine path is not set") raise FileNotFoundError("Engine path is not set")
@@ -123,31 +130,41 @@ class EngineManager:
return results return results
# --- Check for Updates ---
@classmethod @classmethod
def all_versions_for_engine(cls, engine_name, include_corrupt=False, ignore_system=False): def update_all_engines(cls):
versions = cls.get_engines(filter_name=engine_name, include_corrupt=include_corrupt, ignore_system=ignore_system) for engine in cls.downloadable_engines():
update_available = cls.is_engine_update_available(engine)
if update_available:
update_available['name'] = engine.name()
cls.download_engine(engine.name(), update_available['version'], background=True)
@classmethod
def all_version_data_for_engine(cls, engine_name, include_corrupt=False, ignore_system=False):
versions = cls.get_installed_engine_data(filter_name=engine_name, include_corrupt=include_corrupt, ignore_system=ignore_system)
sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True) sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True)
return sorted_versions return sorted_versions
@classmethod @classmethod
def newest_engine_version(cls, engine, system_os=None, cpu=None, ignore_system=None): def newest_installed_engine_data(cls, engine_name, system_os=None, cpu=None, ignore_system=None):
system_os = system_os or current_system_os() system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu() cpu = cpu or current_system_cpu()
try: try:
filtered = [x for x in cls.all_versions_for_engine(engine, ignore_system=ignore_system) filtered = [x for x in cls.all_version_data_for_engine(engine_name, ignore_system=ignore_system)
if x['system_os'] == system_os and x['cpu'] == cpu] if x['system_os'] == system_os and x['cpu'] == cpu]
return filtered[0] return filtered[0]
except IndexError: except IndexError:
logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}") logger.error(f"Cannot find newest engine version for {engine_name}-{system_os}-{cpu}")
return None return None
@classmethod @classmethod
def is_version_downloaded(cls, engine, version, system_os=None, cpu=None, ignore_system=False): def is_version_installed(cls, engine, version, system_os=None, cpu=None, ignore_system=False):
system_os = system_os or current_system_os() system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu() cpu = cpu or current_system_cpu()
filtered = [x for x in cls.get_engines(filter_name=engine, ignore_system=ignore_system) if filtered = [x for x in cls.get_installed_engine_data(filter_name=engine, ignore_system=ignore_system) if
x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version] x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version]
return filtered[0] if filtered else False return filtered[0] if filtered else False
@@ -169,6 +186,28 @@ class EngineManager:
logger.debug(f"Exception in find_most_recent_version: {e}") logger.debug(f"Exception in find_most_recent_version: {e}")
return None return None
@classmethod
def is_engine_update_available(cls, engine_class, ignore_system_installs=False):
logger.debug(f"Checking for updates to {engine_class.name()}")
latest_version = engine_class.downloader().find_most_recent_version()
if not latest_version:
logger.warning(f"Could not find most recent version of {engine_class.name()} to download")
return None
version_num = latest_version.get('version')
if cls.is_version_installed(engine_class.name(), version_num, ignore_system=ignore_system_installs):
logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded")
return None
return latest_version
# --- Downloads ---
@classmethod
def downloadable_engines(cls):
return [engine for engine in cls.supported_engines() if hasattr(engine, "downloader") and engine.downloader()]
@classmethod @classmethod
def get_existing_download_task(cls, engine, version, system_os=None, cpu=None): def get_existing_download_task(cls, engine, version, system_os=None, cpu=None):
for task in cls.download_tasks: for task in cls.download_tasks:
@@ -204,7 +243,7 @@ class EngineManager:
return thread return thread
thread.join() thread.join()
found_engine = cls.is_version_downloaded(engine, version, system_os, cpu, ignore_system) # Check that engine downloaded found_engine = cls.is_version_installed(engine, version, system_os, cpu, ignore_system) # Check that engine downloaded
if not found_engine: if not found_engine:
logger.error(f"Error downloading {engine}") logger.error(f"Error downloading {engine}")
return found_engine return found_engine
@@ -213,7 +252,7 @@ class EngineManager:
def delete_engine_download(cls, engine, version, system_os=None, cpu=None): def delete_engine_download(cls, engine, version, system_os=None, cpu=None):
logger.info(f"Requested deletion of engine: {engine}-{version}") logger.info(f"Requested deletion of engine: {engine}-{version}")
found = cls.is_version_downloaded(engine, version, system_os, cpu) found = cls.is_version_installed(engine, version, system_os, cpu)
if found and found['type'] == 'managed': # don't delete system installs if found and found['type'] == 'managed': # don't delete system installs
# find the root directory of the engine executable # find the root directory of the engine executable
root_dir_name = '-'.join([engine, version, found['system_os'], found['cpu']]) root_dir_name = '-'.join([engine, version, found['system_os'], found['cpu']])
@@ -229,22 +268,11 @@ class EngineManager:
logger.error(f"Cannot find engine: {engine}-{version}") logger.error(f"Cannot find engine: {engine}-{version}")
return False return False
# --- Background Tasks ---
@classmethod @classmethod
def is_engine_update_available(cls, engine_class, ignore_system_installs=False): def active_downloads(cls) -> list:
logger.debug(f"Checking for updates to {engine_class.name()}") return [x for x in cls.download_tasks if x.is_alive()]
latest_version = engine_class.downloader().find_most_recent_version()
if not latest_version:
logger.warning(f"Could not find most recent version of {engine_class.name()} to download")
return None
version_num = latest_version.get('version')
if cls.is_version_downloaded(engine_class.name(), version_num, ignore_system=ignore_system_installs):
logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded")
return None
return latest_version
@classmethod @classmethod
def create_worker(cls, engine_name, input_path, output_path, engine_version=None, args=None, parent=None, name=None): def create_worker(cls, engine_name, input_path, output_path, engine_version=None, args=None, parent=None, name=None):
@@ -252,7 +280,7 @@ class EngineManager:
worker_class = cls.engine_with_name(engine_name).worker_class() worker_class = cls.engine_with_name(engine_name).worker_class()
# check to make sure we have versions installed # check to make sure we have versions installed
all_versions = cls.all_versions_for_engine(engine_name) all_versions = cls.all_version_data_for_engine(engine_name)
if not all_versions: if not all_versions:
raise FileNotFoundError(f"Cannot find any installed '{engine_name}' engines") raise FileNotFoundError(f"Cannot find any installed '{engine_name}' engines")
@@ -281,16 +309,6 @@ class EngineManager:
return worker_class(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args, return worker_class(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args,
parent=parent, name=name) parent=parent, name=name)
@classmethod
def engine_for_project_path(cls, path):
_, extension = os.path.splitext(path)
extension = extension.lower().strip('.')
for engine in cls.supported_engines():
if extension in engine().supported_extensions():
return engine
undefined_renderer_support = [x for x in cls.supported_engines() if not x().supported_extensions()]
return undefined_renderer_support[0]
class EngineDownloadWorker(threading.Thread): class EngineDownloadWorker(threading.Thread):
"""A thread worker for downloading a specific version of a rendering engine. """A thread worker for downloading a specific version of a rendering engine.
@@ -317,8 +335,8 @@ class EngineDownloadWorker(threading.Thread):
def run(self): def run(self):
try: try:
existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu, existing_download = EngineManager.is_version_installed(self.engine, self.version, self.system_os, self.cpu,
ignore_system=True) ignore_system=True)
if existing_download: if existing_download:
logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists") logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists")
return existing_download return existing_download
@@ -341,4 +359,4 @@ if __name__ == '__main__':
# EngineManager.delete_engine_download('blender', '3.2.1', 'macos', 'a') # EngineManager.delete_engine_download('blender', '3.2.1', 'macos', 'a')
EngineManager.engines_path = "/Users/brettwilliams/zordon-uploads/engines" EngineManager.engines_path = "/Users/brettwilliams/zordon-uploads/engines"
# print(EngineManager.is_version_downloaded("ffmpeg", "6.0")) # print(EngineManager.is_version_downloaded("ffmpeg", "6.0"))
print(EngineManager.get_engines()) print(EngineManager.get_installed_engine_data())

View File

@@ -1,4 +1,5 @@
import json import json
import os
import re import re
from src.engines.core.base_engine import * from src.engines.core.base_engine import *
@@ -46,10 +47,19 @@ class FFMPEG(BaseRenderEngine):
return version return version
def get_project_info(self, project_path, timeout=10): def get_project_info(self, project_path, timeout=10):
"""Run ffprobe and parse the output as JSON"""
try: try:
# Run ffprobe and parse the output as JSON # resolve ffprobe path
engine_dir = os.path.dirname(self.engine_path())
ffprobe_path = os.path.join(engine_dir, 'ffprobe')
if self.engine_path().endswith('.exe'):
ffprobe_path += '.exe'
if not os.path.exists(ffprobe_path): # fallback to system install (if available)
ffprobe_path = 'ffprobe'
# run ffprobe
cmd = [ cmd = [
'ffprobe', '-v', 'quiet', '-print_format', 'json', ffprobe_path, '-v', 'quiet', '-print_format', 'json',
'-show_streams', '-select_streams', 'v', project_path '-show_streams', '-select_streams', 'v', project_path
] ]
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True, output = subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True,
@@ -79,7 +89,7 @@ class FFMPEG(BaseRenderEngine):
} }
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") print(f"Failed to get FFMPEG project info: {e}")
return None return None
def get_encoders(self): def get_encoders(self):

View File

@@ -158,20 +158,18 @@ class NewRenderJobForm(QWidget):
self.output_settings_group = QWidget() self.output_settings_group = QWidget()
output_settings_layout = QVBoxLayout(self.output_settings_group) output_settings_layout = QVBoxLayout(self.output_settings_group)
# # Render Name
# render_name_layout = QHBoxLayout()
# render_name_layout.addWidget(QLabel("Render name:"))
# self.job_name_input = QLineEdit()
# render_name_layout.addWidget(self.job_name_input)
# output_settings_layout.addLayout(render_name_layout)
# File Format # File Format
format_group = QGroupBox("Format / Range")
output_settings_layout.addWidget(format_group)
format_group_layout = QVBoxLayout()
format_group.setLayout(format_group_layout)
file_format_layout = QHBoxLayout() file_format_layout = QHBoxLayout()
file_format_layout.addWidget(QLabel("Format:")) file_format_layout.addWidget(QLabel("Format:"))
self.file_format_combo = QComboBox() self.file_format_combo = QComboBox()
# You can populate this later based on engine self.file_format_combo.setFixedWidth(200)
file_format_layout.addWidget(self.file_format_combo) file_format_layout.addWidget(self.file_format_combo)
output_settings_layout.addLayout(file_format_layout) file_format_layout.addStretch()
format_group_layout.addLayout(file_format_layout)
# Frame Range # Frame Range
frame_range_layout = QHBoxLayout() frame_range_layout = QHBoxLayout()
@@ -181,12 +179,12 @@ class NewRenderJobForm(QWidget):
self.start_frame_input.setFixedWidth(80) self.start_frame_input.setFixedWidth(80)
self.end_frame_input = QSpinBox() self.end_frame_input = QSpinBox()
self.end_frame_input.setRange(1, 99999) self.end_frame_input.setRange(1, 99999)
self.start_frame_input.setFixedWidth(80) self.end_frame_input.setFixedWidth(80)
frame_range_layout.addWidget(self.start_frame_input) frame_range_layout.addWidget(self.start_frame_input)
frame_range_layout.addWidget(QLabel("to")) frame_range_layout.addWidget(QLabel("to"))
frame_range_layout.addWidget(self.end_frame_input) frame_range_layout.addWidget(self.end_frame_input)
frame_range_layout.addStretch() frame_range_layout.addStretch()
output_settings_layout.addLayout(frame_range_layout) format_group_layout.addLayout(frame_range_layout)
# --- Resolution & FPS Group --- # --- Resolution & FPS Group ---
resolution_group = QGroupBox("Resolution / Frame Rate") resolution_group = QGroupBox("Resolution / Frame Rate")