2 Commits

Author SHA1 Message Date
Brett Williams
0a69c184eb Fix pyinstaller spec files 2026-01-12 09:08:35 -06:00
8b3fdd14b5 Add Job Window Redesign (#128)
* Initial refactor of add_job_window

* Improved project naming and fixed Blender engine issue

* Improve time representation in main window

* Cleanup Blender job creation

* Send resolution / fps data in job submission

* More window improvements

* EngineManager renaming and refactoring

* FFMPEG path fixes for ffprobe

* More backend refactoring / improvements

* Performance improvements / API refactoring

* Show current job count in add window UI before submission

* Move some UI update code out of background thread

* Move some main window UI update code out of background thread
2026-01-12 09:06:53 -06:00
21 changed files with 937 additions and 546 deletions

View File

@@ -38,7 +38,8 @@ git clone https://github.com/blw1138/Zordon.git
pip3 install -r requirements.txt pip3 install -r requirements.txt
pip3 install pyinstaller pip3 install pyinstaller
pip3 install pyinstaller_versionfile pip3 install pyinstaller_versionfile
pyinstaller main.spec pyinstaller client.spec
pyinstaller server.spec
``` ```
## License ## License

View File

@@ -1,25 +1,53 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
# - get version from version file from PyInstaller.utils.hooks import collect_all
from pathlib import Path
import os import os
import sys import sys
import platform import platform
src_path = os.path.abspath("src")
sys.path.insert(0, src_path)
from version import APP_NAME, APP_VERSION, APP_AUTHOR
sys.path.insert(0, os.path.abspath('.'))
datas = [('resources', 'resources'), ('src/engines/blender/scripts/', 'src/engines/blender/scripts')] # ------------------------------------------------------------
# Project paths
# ------------------------------------------------------------
project_root = Path(SPECPATH).resolve()
src_dir = project_root / "src"
# Ensure `src.*` imports work during analysis
sys.path.insert(0, str(project_root))
# ------------------------------------------------------------
# Import version info
# ------------------------------------------------------------
from src.version import APP_NAME, APP_VERSION, APP_AUTHOR
APP_NAME = f"{APP_NAME}-client"
# ------------------------------------------------------------
# PyInstaller data / imports
# ------------------------------------------------------------
datas = [
("resources", "resources"),
("src/engines/blender/scripts", "src/engines/blender/scripts"),
]
binaries = [] binaries = []
hiddenimports = ['zeroconf'] hiddenimports = ["zeroconf", "src.version"]
tmp_ret = collect_all('zeroconf')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all("zeroconf")
datas += tmp_ret[0]
binaries += tmp_ret[1]
hiddenimports += tmp_ret[2]
# ------------------------------------------------------------
# Analysis
# ------------------------------------------------------------
a = Analysis( a = Analysis(
['client.py'], ["client.py"],
pathex=[], pathex=[str(project_root)],
binaries=binaries, binaries=binaries,
datas=datas, datas=datas,
hiddenimports=hiddenimports, hiddenimports=hiddenimports,
@@ -28,11 +56,16 @@ a = Analysis(
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[],
noarchive=False, noarchive=False,
optimize=1, # fyi: optim level 2 breaks on windows optimize=1, # optimize=2 breaks Windows builds
) )
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
if platform.system() == 'Darwin': # macOS # ------------------------------------------------------------
# Platform targets
# ------------------------------------------------------------
if platform.system() == "Darwin": # macOS
exe = EXE( exe = EXE(
pyz, pyz,
@@ -51,23 +84,26 @@ if platform.system() == 'Darwin': # macOS
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
) )
app = BUNDLE( app = BUNDLE(
exe, exe,
a.binaries, a.binaries,
a.datas, a.datas,
strip=True, strip=True,
name=f'{APP_NAME}.app', name=f"{APP_NAME}.app",
icon='resources/Server.png', icon="resources/Server.png",
bundle_identifier=None, bundle_identifier=None,
version=APP_VERSION version=APP_VERSION,
) )
elif platform.system() == 'Windows': elif platform.system() == "Windows":
import pyinstaller_versionfile import pyinstaller_versionfile
import tempfile import tempfile
version_file_path = os.path.join(tempfile.gettempdir(), 'versionfile.txt') version_file_path = os.path.join(
tempfile.gettempdir(), "versionfile.txt"
)
pyinstaller_versionfile.create_versionfile( pyinstaller_versionfile.create_versionfile(
output_file=version_file_path, output_file=version_file_path,
@@ -77,7 +113,7 @@ elif platform.system() == 'Windows':
internal_name=APP_NAME, internal_name=APP_NAME,
legal_copyright=f"© {APP_AUTHOR}", legal_copyright=f"© {APP_AUTHOR}",
original_filename=f"{APP_NAME}.exe", original_filename=f"{APP_NAME}.exe",
product_name=APP_NAME product_name=APP_NAME,
) )
exe = EXE( exe = EXE(
@@ -97,10 +133,11 @@ elif platform.system() == 'Windows':
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
version=version_file_path version=version_file_path,
) )
else: # linux else: # Linux
exe = EXE( exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
@@ -117,5 +154,5 @@ else: # linux
argv_emulation=False, argv_emulation=False,
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None entitlements_file=None,
) )

View File

@@ -1,24 +1,53 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
# - get version from version file from PyInstaller.utils.hooks import collect_all
from pathlib import Path
import os import os
import sys import sys
import platform import platform
sys.path.insert(0, os.path.abspath('.'))
from version import APP_NAME, APP_VERSION, APP_AUTHOR
APP_NAME = APP_NAME + " Server" # ------------------------------------------------------------
datas = [('resources', 'resources'), ('src/engines/blender/scripts/', 'src/engines/blender/scripts')] # Project paths
# ------------------------------------------------------------
project_root = Path(SPECPATH).resolve()
src_dir = project_root / "src"
# Ensure `src.*` imports work during analysis
sys.path.insert(0, str(project_root))
# ------------------------------------------------------------
# Import version info
# ------------------------------------------------------------
from src.version import APP_NAME, APP_VERSION, APP_AUTHOR
APP_NAME = f"{APP_NAME}-server"
# ------------------------------------------------------------
# PyInstaller data / imports
# ------------------------------------------------------------
datas = [
("resources", "resources"),
("src/engines/blender/scripts", "src/engines/blender/scripts"),
]
binaries = [] binaries = []
hiddenimports = ['zeroconf'] hiddenimports = ["zeroconf", "src.version"]
tmp_ret = collect_all('zeroconf')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all("zeroconf")
datas += tmp_ret[0]
binaries += tmp_ret[1]
hiddenimports += tmp_ret[2]
# ------------------------------------------------------------
# Analysis
# ------------------------------------------------------------
a = Analysis( a = Analysis(
['server.py'], ["server.py"],
pathex=[], pathex=[str(project_root)],
binaries=binaries, binaries=binaries,
datas=datas, datas=datas,
hiddenimports=hiddenimports, hiddenimports=hiddenimports,
@@ -27,16 +56,54 @@ a = Analysis(
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[],
noarchive=False, noarchive=False,
optimize=1, # fyi: optim level 2 breaks on windows optimize=1, # optimize=2 breaks Windows builds
) )
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
if platform.system() == 'Windows': # ------------------------------------------------------------
# Platform targets
# ------------------------------------------------------------
if platform.system() == "Darwin": # macOS
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name=APP_NAME,
debug=False,
bootloader_ignore_signals=False,
strip=True,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
app = BUNDLE(
exe,
a.binaries,
a.datas,
strip=True,
name=f"{APP_NAME}.app",
icon="resources/Server.png",
bundle_identifier=None,
version=APP_VERSION,
)
elif platform.system() == "Windows":
import pyinstaller_versionfile import pyinstaller_versionfile
import tempfile import tempfile
version_file_path = os.path.join(tempfile.gettempdir(), 'versionfile.txt') version_file_path = os.path.join(
tempfile.gettempdir(), "versionfile.txt"
)
pyinstaller_versionfile.create_versionfile( pyinstaller_versionfile.create_versionfile(
output_file=version_file_path, output_file=version_file_path,
@@ -46,30 +113,9 @@ if platform.system() == 'Windows':
internal_name=APP_NAME, internal_name=APP_NAME,
legal_copyright=f"© {APP_AUTHOR}", legal_copyright=f"© {APP_AUTHOR}",
original_filename=f"{APP_NAME}.exe", original_filename=f"{APP_NAME}.exe",
product_name=APP_NAME product_name=APP_NAME,
) )
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name=APP_NAME,
debug=False,
bootloader_ignore_signals=False,
strip=True,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
version=version_file_path
)
else: # linux / macOS
exe = EXE( exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
@@ -86,5 +132,27 @@ else: # linux / macOS
argv_emulation=False, argv_emulation=False,
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None entitlements_file=None,
version=version_file_path,
)
else: # Linux
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name=APP_NAME,
debug=False,
bootloader_ignore_signals=False,
strip=True,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
) )

View File

@@ -309,6 +309,12 @@ def add_job_handler():
new_job = DistributedJobManager.create_render_job(processed_job_data, loaded_project_local_path) new_job = DistributedJobManager.create_render_job(processed_job_data, loaded_project_local_path)
created_jobs.append(new_job) created_jobs.append(new_job)
# Save notes to .txt
if processed_job_data.get("notes"):
parent_dir = os.path.dirname(os.path.dirname(loaded_project_local_path))
notes_name = processed_job_data['name'] + "-notes.txt"
with open(os.path.join(parent_dir, notes_name), "w") as f:
f.write(processed_job_data["notes"])
return [x.json() for x in created_jobs] return [x.json() for x in created_jobs]
except Exception as e: except Exception as e:
logger.exception(f"Error creating render job: {e}") logger.exception(f"Error creating render job: {e}")
@@ -374,6 +380,26 @@ def delete_job(job_id):
# Engine Info and Management: # Engine Info and Management:
# -------------------------------------------- # --------------------------------------------
@server.get('/api/engine_for_filename')
def get_engine_for_filename():
filename = request.args.get("filename")
if not filename:
return "Error: filename is required", 400
found_engine = EngineManager.engine_class_for_project_path(filename)
if not found_engine:
return f"Error: cannot find a suitable engine for '{filename}'", 400
return found_engine.name()
@server.get('/api/installed_engines')
def get_installed_engines():
result = {}
for engine_class in EngineManager.supported_engines():
data = EngineManager.all_version_data_for_engine(engine_class.name())
if data:
result[engine_class.name()] = data
return result
@server.get('/api/engine_info') @server.get('/api/engine_info')
def engine_info(): def engine_info():
response_type = request.args.get('response_type', 'standard') response_type = request.args.get('response_type', 'standard')
@@ -383,7 +409,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
@@ -414,7 +440,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:
@@ -428,14 +454,69 @@ def engine_info():
return engine_data return engine_data
@server.get('/api/<engine_name>/info')
def get_engine_info(engine_name):
try:
response_type = request.args.get('response_type', 'standard')
# Get all installed versions of the engine
installed_versions = EngineManager.all_version_data_for_engine(engine_name)
if not installed_versions:
return {}
result = { 'is_available': RenderQueue.is_available_for_job(engine_name),
'versions': installed_versions
}
if response_type == 'full':
with concurrent.futures.ThreadPoolExecutor() as executor:
engine_class = EngineManager.engine_class_with_name(engine_name)
en = EngineManager.get_latest_engine_instance(engine_class)
future_results = {
'supported_extensions': executor.submit(en.supported_extensions),
'supported_export_formats': executor.submit(en.get_output_formats),
'system_info': executor.submit(en.system_info),
'options': executor.submit(en.ui_options)
}
for key, future in future_results.items():
result[key] = future.result()
return result
except Exception as e:
logger.error(f"Error fetching details for engine '{engine_name}': {e}")
return {}
@server.get('/api/<engine_name>/is_available') @server.get('/api/<engine_name>/is_available')
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']}
@server.get('/api/engine/<engine_name>/args')
def get_engine_args(engine_name):
try:
engine_class = EngineManager.engine_class_with_name(engine_name)
return engine_class().get_arguments()
except LookupError:
return f"Cannot find engine '{engine_name}'", 400
@server.get('/api/engine/<engine_name>/help')
def get_engine_help(engine_name):
try:
engine_class = EngineManager.engine_class_with_name(engine_name)
return engine_class().get_help()
except LookupError:
return f"Cannot find engine '{engine_name}'", 400
# --------------------------------------------
# Engine Downloads and Updates:
# --------------------------------------------
@server.get('/api/is_engine_available_to_download') @server.get('/api/is_engine_available_to_download')
def is_engine_available_to_download(): def is_engine_available_to_download():
available_result = EngineManager.version_is_available_to_download(request.args.get('engine'), available_result = EngineManager.version_is_available_to_download(request.args.get('engine'),
@@ -476,24 +557,6 @@ def delete_engine_download():
(f"Error deleting {json_data.get('engine')} {json_data.get('version')}", 500) (f"Error deleting {json_data.get('engine')} {json_data.get('version')}", 500)
@server.get('/api/engine/<engine_name>/args')
def get_engine_args(engine_name):
try:
engine_class = EngineManager.engine_with_name(engine_name)
return engine_class().get_arguments()
except LookupError:
return f"Cannot find engine '{engine_name}'", 400
@server.get('/api/engine/<engine_name>/help')
def get_engine_help(engine_name):
try:
engine_class = EngineManager.engine_with_name(engine_name)
return engine_class().get_help()
except LookupError:
return f"Cannot find engine '{engine_name}'", 400
# -------------------------------------------- # --------------------------------------------
# Miscellaneous: # Miscellaneous:
# -------------------------------------------- # --------------------------------------------
@@ -568,6 +631,15 @@ def handle_detached_instance(_):
return "Unavailable", 503 return "Unavailable", 503
@server.errorhandler(404)
def handle_404(error):
url = request.url
err_msg = f"404 Not Found: {url}"
if 'favicon' not in url:
logger.warning(err_msg)
return err_msg, 404
@server.errorhandler(Exception) @server.errorhandler(Exception)
def handle_general_error(general_error): def handle_general_error(general_error):
err_msg = f"Server error: {general_error}" err_msg = f"Server error: {general_error}"

View File

@@ -43,9 +43,9 @@ class JobImportHandler:
raise FileNotFoundError("Cannot find any valid project paths") raise FileNotFoundError("Cannot find any valid project paths")
# Prepare the local filepath # Prepare the local filepath
cleaned_path_name = os.path.splitext(referred_name)[0].replace(' ', '-') cleaned_path_name = job_name.replace(' ', '-')
job_dir = os.path.join(upload_directory, '-'.join( job_dir = os.path.join(upload_directory, '-'.join(
[datetime.now().strftime("%Y.%m.%d_%H.%M.%S"), engine_name, cleaned_path_name])) [cleaned_path_name, engine_name, datetime.now().strftime("%Y.%m.%d_%H.%M.%S")]))
os.makedirs(job_dir, exist_ok=True) os.makedirs(job_dir, exist_ok=True)
project_source_dir = os.path.join(job_dir, 'source') project_source_dir = os.path.join(job_dir, 'source')
os.makedirs(project_source_dir, exist_ok=True) os.makedirs(project_source_dir, exist_ok=True)

View File

@@ -247,16 +247,19 @@ class RenderServerProxy:
# Engines: # Engines:
# -------------------------------------------- # --------------------------------------------
def is_engine_available(self, engine_name): def get_engine_for_filename(self, filename, timeout=5):
return self.request_data(f'{engine_name}/is_available') response = self.request(f'engine_for_filename?filename={os.path.basename(filename)}', timeout)
return response.text
def get_all_engines(self): def get_installed_engines(self, timeout=5):
# todo: this doesnt work return self.request_data(f'installed_engines', timeout)
return self.request_data('all_engines')
def get_engine_info(self, response_type='standard', timeout=5): def is_engine_available(self, engine_name:str, timeout=5):
return self.request_data(f'{engine_name}/is_available', timeout)
def get_all_engine_info(self, response_type='standard', timeout=5):
""" """
Fetches engine information from the server. Fetches all engine information from the server.
Args: Args:
response_type (str, optional): Returns standard or full version of engine info response_type (str, optional): Returns standard or full version of engine info
@@ -268,19 +271,33 @@ class RenderServerProxy:
all_data = self.request_data(f"engine_info?response_type={response_type}", timeout=timeout) all_data = self.request_data(f"engine_info?response_type={response_type}", timeout=timeout)
return all_data return all_data
def delete_engine(self, engine, version, system_cpu=None): def get_engine_info(self, engine_name:str, response_type='standard', timeout=5):
"""
Fetches specific engine information from the server.
Args:
engine_name (str): Name of the engine
response_type (str, optional): Returns standard or full version of engine info
timeout (int, optional): The number of seconds to wait for a response from the server. Defaults to 5.
Returns:
dict: A dictionary containing the engine information.
"""
return self.request_data(f'{engine_name}/info?response_type={response_type}', timeout)
def delete_engine(self, engine_name:str, version:str, system_cpu=None):
""" """
Sends a request to the server to delete a specific engine. Sends a request to the server to delete a specific engine.
Args: Args:
engine (str): The name of the engine to delete. engine_name (str): The name of the engine to delete.
version (str): The version of the engine to delete. version (str): The version of the engine to delete.
system_cpu (str, optional): The system CPU type. Defaults to None. system_cpu (str, optional): The system CPU type. Defaults to None.
Returns: Returns:
Response: The response from the server. Response: The response from the server.
""" """
form_data = {'engine': engine, 'version': version, 'system_cpu': system_cpu} form_data = {'engine': engine_name, 'version': version, 'system_cpu': system_cpu}
return requests.post(f'http://{self.hostname}:{self.port}/api/delete_engine', json=form_data) return requests.post(f'http://{self.hostname}:{self.port}/api/delete_engine', json=form_data)
# -------------------------------------------- # --------------------------------------------

View File

@@ -24,10 +24,12 @@ class Blender(BaseRenderEngine):
from src.engines.blender.blender_worker import BlenderRenderWorker from src.engines.blender.blender_worker import BlenderRenderWorker
return BlenderRenderWorker return BlenderRenderWorker
@staticmethod def ui_options(self):
def ui_options(system_info): options = [
from src.engines.blender.blender_ui import BlenderUI {'name': 'engine', 'options': self.supported_render_engines()},
return BlenderUI.get_options(system_info) {'name': 'render_device', 'options': ['Any', 'GPU', 'CPU']},
]
return options
def supported_extensions(self): def supported_extensions(self):
return ['blend'] return ['blend']
@@ -117,7 +119,7 @@ class Blender(BaseRenderEngine):
# report any missing textures # report any missing textures
not_found = re.findall("(Unable to pack file, source path .*)\n", result_text) not_found = re.findall("(Unable to pack file, source path .*)\n", result_text)
for err in not_found: for err in not_found:
logger.error(err) raise ChildProcessError(err)
p = re.compile('Saved to: (.*)\n') p = re.compile('Saved to: (.*)\n')
match = p.search(result_text) match = p.search(result_text)
@@ -125,6 +127,7 @@ class Blender(BaseRenderEngine):
new_path = os.path.join(dir_name, match.group(1).strip()) new_path = os.path.join(dir_name, match.group(1).strip())
logger.info(f'Blender file packed successfully to {new_path}') logger.info(f'Blender file packed successfully to {new_path}')
return new_path return new_path
return project_path
except Exception as e: except Exception as e:
msg = f'Error packing .blend file: {e}' msg = f'Error packing .blend file: {e}'
logger.error(msg) logger.error(msg)
@@ -164,7 +167,7 @@ class Blender(BaseRenderEngine):
return options return options
def system_info(self): def system_info(self):
return {'render_devices': self.get_render_devices()} return {'render_devices': self.get_render_devices(), 'engines': self.supported_render_engines()}
def get_render_devices(self): def get_render_devices(self):
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_system_info.py') script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_system_info.py')
@@ -179,7 +182,7 @@ class Blender(BaseRenderEngine):
logger.error("GPU data not found in the output.") logger.error("GPU data not found in the output.")
def supported_render_engines(self): def supported_render_engines(self):
engine_output = subprocess.run([self.engine_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT, engine_output = subprocess.run([self.engine_path(), '-b', '-E', 'help'], timeout=SUBPROCESS_TIMEOUT,
capture_output=True, creationflags=_creationflags).stdout.decode('utf-8').strip() capture_output=True, creationflags=_creationflags).stdout.decode('utf-8').strip()
render_engines = [x.strip() for x in engine_output.split('Blender Engine Listing:')[-1].strip().splitlines()] render_engines = [x.strip() for x in engine_output.split('Blender Engine Listing:')[-1].strip().splitlines()]
return render_engines return render_engines

View File

@@ -1,9 +0,0 @@
class BlenderUI:
@staticmethod
def get_options(system_info):
options = [
{'name': 'engine', 'options': system_info.get('engines', [])},
{'name': 'render_device', 'options': ['Any', 'GPU', 'CPU']},
]
return options

View File

@@ -18,14 +18,13 @@ class BlenderRenderWorker(BaseRenderWorker):
self.__frame_percent_complete = 0.0 self.__frame_percent_complete = 0.0
# Scene Info # Scene Info
self.scene_info = Blender(engine_path).get_project_info(input_path) self.scene_info = {}
self.start_frame = int(self.scene_info.get('start_frame', 1))
self.end_frame = int(self.scene_info.get('end_frame', self.start_frame))
self.project_length = (self.end_frame - self.start_frame) + 1
self.current_frame = -1 self.current_frame = -1
def generate_worker_subprocess(self): def generate_worker_subprocess(self):
self.scene_info = Blender(self.engine_path).get_project_info(self.input_path)
cmd = [self.engine_path] cmd = [self.engine_path]
if self.args.get('background', True): # optionally run render not in background if self.args.get('background', True): # optionally run render not in background
cmd.append('-b') cmd.append('-b')
@@ -41,10 +40,16 @@ class BlenderRenderWorker(BaseRenderWorker):
cmd.append('--python-expr') cmd.append('--python-expr')
python_exp = 'import bpy; bpy.context.scene.render.use_overwrite = False;' python_exp = 'import bpy; bpy.context.scene.render.use_overwrite = False;'
# Setup Custom Resolution
if self.args.get('resolution'):
res = self.args.get('resolution')
python_exp += 'bpy.context.scene.render.resolution_percentage = 100;'
python_exp += f'bpy.context.scene.render.resolution_x={res[0]}; bpy.context.scene.render.resolution_y={res[1]};'
# Setup Custom Camera # Setup Custom Camera
custom_camera = self.args.get('camera', None) custom_camera = self.args.get('camera', None)
if custom_camera: if custom_camera:
python_exp = python_exp + f"bpy.context.scene.camera = bpy.data.objects['{custom_camera}'];" python_exp += f"bpy.context.scene.camera = bpy.data.objects['{custom_camera}'];"
# Set Render Device for Cycles (gpu/cpu/any) # Set Render Device for Cycles (gpu/cpu/any)
if blender_engine == 'CYCLES': if blender_engine == 'CYCLES':
@@ -85,11 +90,15 @@ class BlenderRenderWorker(BaseRenderWorker):
def _parse_stdout(self, line): def _parse_stdout(self, line):
pattern = re.compile( cycles_pattern = re.compile(
r'Fra:(?P<frame>\d*).*Mem:(?P<memory>\S+).*Time:(?P<time>\S+)(?:.*Remaining:)?(?P<remaining>\S*)') r'Fra:(?P<frame>\d*).*Mem:(?P<memory>\S+).*Time:(?P<time>\S+)(?:.*Remaining:)?(?P<remaining>\S*)')
found = pattern.search(line) cycles_match = cycles_pattern.search(line)
if found: eevee_pattern = re.compile(
stats = found.groupdict() r"Rendering\s+(?P<current>\d+)\s*/\s*(?P<total>\d+)\s+samples"
)
eevee_match = eevee_pattern.search(line)
if cycles_match:
stats = cycles_match.groupdict()
memory_use = stats['memory'] memory_use = stats['memory']
time_elapsed = stats['time'] time_elapsed = stats['time']
time_remaining = stats['remaining'] or 'Unknown' time_remaining = stats['remaining'] or 'Unknown'
@@ -104,6 +113,11 @@ class BlenderRenderWorker(BaseRenderWorker):
logger.debug( logger.debug(
'Frame:{0} | Mem:{1} | Time:{2} | Remaining:{3}'.format(self.current_frame, memory_use, 'Frame:{0} | Mem:{1} | Time:{2} | Remaining:{3}'.format(self.current_frame, memory_use,
time_elapsed, time_remaining)) time_elapsed, time_remaining))
elif eevee_match:
self.__frame_percent_complete = int(eevee_match.groups()[0]) / int(eevee_match.groups()[1])
logger.debug(f'Frame:{self.current_frame} | Samples:{eevee_match.groups()[0]}/{eevee_match.groups()[1]}')
elif "Rendering frame" in line: # used for eevee
self.current_frame = int(line.split("Rendering frame")[-1].strip())
elif "file doesn't exist" in line.lower(): elif "file doesn't exist" in line.lower():
self.log_error(line, halt_render=True) self.log_error(line, halt_render=True)
elif line.lower().startswith('error'): elif line.lower().startswith('error'):

View File

@@ -1,6 +1,10 @@
import bpy import bpy
import json import json
# Force CPU rendering
bpy.context.preferences.addons["cycles"].preferences.compute_device_type = "NONE"
bpy.context.scene.cycles.device = "CPU"
# Ensure Cycles is available # Ensure Cycles is available
bpy.context.preferences.addons['cycles'].preferences.get_devices() bpy.context.preferences.addons['cycles'].preferences.get_devices()

View File

@@ -4,7 +4,7 @@ import platform
import subprocess import subprocess
logger = logging.getLogger() logger = logging.getLogger()
SUBPROCESS_TIMEOUT = 5 SUBPROCESS_TIMEOUT = 10
class BaseRenderEngine(object): class BaseRenderEngine(object):
@@ -133,8 +133,7 @@ class BaseRenderEngine(object):
def downloader(): # override when subclassing if using a downloader class def downloader(): # override when subclassing if using a downloader class
return None return None
@staticmethod def ui_options(self): # override to return options for ui
def ui_options(system_info): # override to return options for ui
return {} return {}
# -------------------------------------------- # --------------------------------------------

View File

@@ -36,7 +36,6 @@ class BaseRenderWorker(Base):
engine_version = Column(String) engine_version = Column(String)
engine_path = Column(String) engine_path = Column(String)
priority = Column(Integer) priority = Column(Integer)
project_length = Column(Integer)
start_frame = Column(Integer) start_frame = Column(Integer)
end_frame = Column(Integer, nullable=True) end_frame = Column(Integer, nullable=True)
parent = Column(String, nullable=True) parent = Column(String, nullable=True)
@@ -83,7 +82,6 @@ class BaseRenderWorker(Base):
self.maximum_attempts = 3 self.maximum_attempts = 3
# Frame Ranges # Frame Ranges
self.project_length = 0 # is this necessary?
self.current_frame = 0 self.current_frame = 0
self.start_frame = 0 self.start_frame = 0
self.end_frame = None self.end_frame = None
@@ -154,7 +152,7 @@ class BaseRenderWorker(Base):
@property @property
def total_frames(self): def total_frames(self):
return (self.end_frame or self.project_length) - self.start_frame + 1 return max(self.end_frame, 1) - self.start_frame + 1
@property @property
def status(self): def status(self):

View File

@@ -3,13 +3,16 @@ import os
import shutil import shutil
import threading import threading
import concurrent.futures import concurrent.futures
from typing import Type
from engines.core.base_engine import BaseRenderEngine
from src.engines.blender.blender_engine import Blender from src.engines.blender.blender_engine import Blender
from src.engines.ffmpeg.ffmpeg_engine import FFMPEG from src.engines.ffmpeg.ffmpeg_engine import FFMPEG
from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu
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,
@@ -20,33 +23,37 @@ class EngineManager:
download_tasks = [] download_tasks = []
@staticmethod @staticmethod
def supported_engines(): def supported_engines() -> list[type[BaseRenderEngine]]:
return [Blender, FFMPEG] return ENGINE_CLASSES
# --- Installed Engines ---
@classmethod @classmethod
def downloadable_engines(cls): def engine_class_for_project_path(cls, path) -> type[BaseRenderEngine]:
return [engine for engine in cls.supported_engines() if hasattr(engine, "downloader") and engine.downloader()] _, extension = os.path.splitext(path)
extension = extension.lower().strip('.')
for engine_class in cls.supported_engines():
engine = cls.get_latest_engine_instance(engine_class)
if extension in engine.supported_extensions():
return engine_class
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 active_downloads(cls) -> list: def engine_class_with_name(cls, engine_name: str) -> type[BaseRenderEngine] | None:
return [x for x in cls.download_tasks if x.is_alive()]
@classmethod
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: Type[BaseRenderEngine]) -> BaseRenderEngine:
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) -> list:
if not cls.engines_path: if not cls.engines_path:
raise FileNotFoundError("Engine path is not set") raise FileNotFoundError("Engine path is not set")
@@ -68,7 +75,7 @@ class EngineManager:
# Initialize binary_name with engine name # Initialize binary_name with engine name
binary_name = result_dict['engine'].lower() binary_name = result_dict['engine'].lower()
# Determine the correct binary name based on the engine and system_os # Determine the correct binary name based on the engine and system_os
eng = cls.engine_with_name(result_dict['engine']) eng = cls.engine_class_with_name(result_dict['engine'])
binary_name = eng.binary_names.get(result_dict['system_os'], binary_name) binary_name = eng.binary_names.get(result_dict['system_os'], binary_name)
# Find the path to the binary file # Find the path to the binary file
@@ -123,70 +130,102 @@ 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:str, include_corrupt=False, ignore_system=False) -> list:
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:str, system_os=None, cpu=None, ignore_system=None) -> list:
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 []
@classmethod @classmethod
def is_version_downloaded(cls, engine, version, system_os=None, cpu=None, ignore_system=False): def is_version_installed(cls, engine_name:str, 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_name, 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
@classmethod @classmethod
def version_is_available_to_download(cls, engine, version, system_os=None, cpu=None): def version_is_available_to_download(cls, engine_name:str, version, system_os=None, cpu=None):
try: try:
downloader = cls.engine_with_name(engine).downloader() downloader = cls.engine_class_with_name(engine_name).downloader()
return downloader.version_is_available_to_download(version=version, system_os=system_os, cpu=cpu) return downloader.version_is_available_to_download(version=version, system_os=system_os, cpu=cpu)
except Exception as e: except Exception as e:
logger.debug(f"Exception in version_is_available_to_download: {e}") logger.debug(f"Exception in version_is_available_to_download: {e}")
return None return None
@classmethod @classmethod
def find_most_recent_version(cls, engine=None, system_os=None, cpu=None, lts_only=False): def find_most_recent_version(cls, engine_name:str, system_os=None, cpu=None, lts_only=False) -> dict:
try: try:
downloader = cls.engine_with_name(engine).downloader() downloader = cls.engine_class_with_name(engine_name).downloader()
return downloader.find_most_recent_version(system_os=system_os, cpu=cpu) return downloader.find_most_recent_version(system_os=system_os, cpu=cpu)
except Exception as e: except Exception as e:
logger.debug(f"Exception in find_most_recent_version: {e}") logger.debug(f"Exception in find_most_recent_version: {e}")
return None return {}
@classmethod @classmethod
def get_existing_download_task(cls, engine, version, system_os=None, cpu=None): def is_engine_update_available(cls, engine_class: Type[BaseRenderEngine], 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
def get_existing_download_task(cls, engine_name, version, system_os=None, cpu=None):
for task in cls.download_tasks: for task in cls.download_tasks:
task_parts = task.name.split('-') task_parts = task.name.split('-')
task_engine, task_version, task_system_os, task_cpu = task_parts[:4] task_engine, task_version, task_system_os, task_cpu = task_parts[:4]
if engine == task_engine and version == task_version: if engine_name == task_engine and version == task_version:
if system_os in (task_system_os, None) and cpu in (task_cpu, None): if system_os in (task_system_os, None) and cpu in (task_cpu, None):
return task return task
return None return None
@classmethod @classmethod
def download_engine(cls, engine, version, system_os=None, cpu=None, background=False, ignore_system=False): def download_engine(cls, engine_name, version, system_os=None, cpu=None, background=False, ignore_system=False):
engine_to_download = cls.engine_with_name(engine) engine_to_download = cls.engine_class_with_name(engine_name)
existing_task = cls.get_existing_download_task(engine, version, system_os, cpu) existing_task = cls.get_existing_download_task(engine_name, version, system_os, cpu)
if existing_task: if existing_task:
logger.debug(f"Already downloading {engine} {version}") logger.debug(f"Already downloading {engine_name} {version}")
if not background: if not background:
existing_task.join() # If download task exists, wait until it's done downloading existing_task.join() # If download task exists, wait until it's done downloading
return None return None
@@ -196,7 +235,7 @@ class EngineManager:
elif not cls.engines_path: elif not cls.engines_path:
raise FileNotFoundError("Engines path must be set before requesting downloads") raise FileNotFoundError("Engines path must be set before requesting downloads")
thread = EngineDownloadWorker(engine, version, system_os, cpu) thread = EngineDownloadWorker(engine_name, version, system_os, cpu)
cls.download_tasks.append(thread) cls.download_tasks.append(thread)
thread.start() thread.start()
@@ -204,55 +243,44 @@ 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_name, 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_name}")
return found_engine return found_engine
@classmethod @classmethod
def delete_engine_download(cls, engine, version, system_os=None, cpu=None): def delete_engine_download(cls, engine_name, version, system_os=None, cpu=None):
logger.info(f"Requested deletion of engine: {engine}-{version}") logger.info(f"Requested deletion of engine: {engine_name}-{version}")
found = cls.is_version_downloaded(engine, version, system_os, cpu) found = cls.is_version_installed(engine_name, 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_name, version, found['system_os'], found['cpu']])
remove_path = os.path.join(found['path'].split(root_dir_name)[0], root_dir_name) remove_path = os.path.join(found['path'].split(root_dir_name)[0], root_dir_name)
# delete the file path # delete the file path
logger.info(f"Deleting engine at path: {remove_path}") logger.info(f"Deleting engine at path: {remove_path}")
shutil.rmtree(remove_path, ignore_errors=False) shutil.rmtree(remove_path, ignore_errors=False)
logger.info(f"Engine {engine}-{version}-{found['system_os']}-{found['cpu']} successfully deleted") logger.info(f"Engine {engine_name}-{version}-{found['system_os']}-{found['cpu']} successfully deleted")
return True return True
elif found: # these are managed by the system / user. Don't delete these. elif found: # these are managed by the system / user. Don't delete these.
logger.error(f'Cannot delete requested {engine} {version}. Managed externally.') logger.error(f'Cannot delete requested {engine_name} {version}. Managed externally.')
else: else:
logger.error(f"Cannot find engine: {engine}-{version}") logger.error(f"Cannot find engine: {engine_name}-{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):
worker_class = cls.engine_with_name(engine_name).worker_class() worker_class = cls.engine_class_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,14 +335,14 @@ 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
# Get the appropriate downloader class based on the engine type # Get the appropriate downloader class based on the engine type
downloader = EngineManager.engine_with_name(self.engine).downloader() downloader = EngineManager.engine_class_with_name(self.engine).downloader()
downloader.download_engine( self.version, download_location=EngineManager.engines_path, downloader.download_engine( self.version, download_location=EngineManager.engines_path,
system_os=self.system_os, cpu=self.cpu, timeout=300, progress_callback=self._update_progress) system_os=self.system_os, cpu=self.cpu, timeout=300, progress_callback=self._update_progress)
except Exception as e: except Exception as e:
@@ -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 *
@@ -20,8 +21,7 @@ class FFMPEG(BaseRenderEngine):
return FFMPEGRenderWorker return FFMPEGRenderWorker
def ui_options(self): def ui_options(self):
from src.engines.ffmpeg.ffmpeg_ui import FFMPEGUI return []
return FFMPEGUI.get_options(self)
def supported_extensions(self): def supported_extensions(self):
help_text = (subprocess.check_output([self.engine_path(), '-h', 'full'], stderr=subprocess.STDOUT, help_text = (subprocess.check_output([self.engine_path(), '-h', 'full'], stderr=subprocess.STDOUT,
@@ -45,10 +45,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,
@@ -78,7 +87,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

@@ -1,5 +0,0 @@
class FFMPEGUI:
@staticmethod
def get_options(system_info):
options = []
return options

View File

@@ -19,8 +19,8 @@ class FFMPEGRenderWorker(BaseRenderWorker):
cmd = [self.engine_path, '-y', '-stats', '-i', self.input_path] cmd = [self.engine_path, '-y', '-stats', '-i', self.input_path]
# Resize frame # Resize frame
if self.args.get('x_resolution', None) and self.args.get('y_resolution', None): if self.args.get('resolution', None):
cmd.extend(['-vf', f"scale={self.args['x_resolution']}:{self.args['y_resolution']}"]) cmd.extend(['-vf', f"scale={self.args['resolution'][0]}:{self.args['resolution'][1]}"])
# Convert raw args from string if available # Convert raw args from string if available
raw_args = self.args.get('raw', None) raw_args = self.args.get('raw', None)

View File

@@ -1,32 +1,33 @@
import copy
import os.path import os.path
import pathlib
import socket import socket
import threading
import psutil import psutil
from PyQt6.QtCore import QThread, pyqtSignal, Qt, pyqtSlot from PyQt6.QtCore import QThread, pyqtSignal, Qt, pyqtSlot
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox, QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox,
QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem,
QTabWidget
) )
from requests import Response
from src.api.server_proxy import RenderServerProxy from src.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager from src.engines.engine_manager import EngineManager
from src.ui.engine_help_window import EngineHelpViewer from src.ui.engine_help_window import EngineHelpViewer
from src.utilities.zeroconf_server import ZeroconfServer from src.utilities.zeroconf_server import ZeroconfServer
from src.utilities.misc_helper import COMMON_RESOLUTIONS
from utilities.misc_helper import COMMON_FRAME_RATES
class NewRenderJobForm(QWidget): class NewRenderJobForm(QWidget):
def __init__(self, project_path=None): def __init__(self, project_path=None):
super().__init__() super().__init__()
self.notes_group = None self.resolution_options_list = None
self.frame_rate_input = None
self.resolution_x_input = None self.resolution_x_input = None
self.engine_group = None
self.output_settings_group = None
self.resolution_y_input = None self.resolution_y_input = None
self.fps_options_list = None
self.fps_input = None
self.engine_group = None
self.notes_group = None
self.output_settings_group = None
self.project_path = project_path self.project_path = project_path
# UI # UI
@@ -55,152 +56,202 @@ class NewRenderJobForm(QWidget):
self.priority_input = None self.priority_input = None
self.end_frame_input = None self.end_frame_input = None
self.start_frame_input = None self.start_frame_input = None
self.render_name_input = None self.job_name_input = None
self.scene_file_input = None self.scene_file_input = None
self.scene_file_browse_button = None self.scene_file_browse_button = None
self.job_name_input = None self.tabs = None
# Job / Server Data # Job / Server Data
self.server_proxy = RenderServerProxy(socket.gethostname()) self.server_proxy = RenderServerProxy(socket.gethostname())
self.engine_info = None
self.project_info = None self.project_info = None
self.installed_engines = {}
self.preferred_engine = None
# Setup # Setup
self.setWindowTitle("New Job") self.setWindowTitle("New Job")
self.setup_ui() self.setup_ui()
self.update_engine_info()
self.setup_project() self.setup_project()
# get renderer info in bg thread
# t = threading.Thread(target=self.update_renderer_info)
# t.start()
self.show() self.show()
def setup_ui(self): def setup_ui(self):
# Main Layout # Main widget layout
main_layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
# Loading File Group # Tabs
self.tabs = QTabWidget()
# ==================== Loading Section (outside tabs) ====================
self.load_file_group = QGroupBox("Loading") self.load_file_group = QGroupBox("Loading")
load_file_layout = QVBoxLayout(self.load_file_group) load_file_layout = QVBoxLayout(self.load_file_group)
# progress bar
progress_layout = QHBoxLayout() progress_layout = QHBoxLayout()
self.process_label = QLabel("Processing")
self.process_progress_bar = QProgressBar() self.process_progress_bar = QProgressBar()
self.process_progress_bar.setMinimum(0) self.process_progress_bar.setMinimum(0)
self.process_progress_bar.setMaximum(0) self.process_progress_bar.setMaximum(0) # Indeterminate
self.process_label = QLabel("Processing")
progress_layout.addWidget(self.process_label) progress_layout.addWidget(self.process_label)
progress_layout.addWidget(self.process_progress_bar) progress_layout.addWidget(self.process_progress_bar)
load_file_layout.addLayout(progress_layout) load_file_layout.addLayout(progress_layout)
main_layout.addWidget(self.load_file_group)
# Project Group # Scene File
self.project_group = QGroupBox("Project") job_overview_group = QGroupBox("Project File")
server_layout = QVBoxLayout(self.project_group) file_group_layout = QVBoxLayout(job_overview_group)
# File Path
# Job Name
job_name_layout = QHBoxLayout()
job_name_layout.addWidget(QLabel("Job name:"))
self.job_name_input = QLineEdit()
job_name_layout.addWidget(self.job_name_input)
self.engine_type = QComboBox()
job_name_layout.addWidget(self.engine_type)
file_group_layout.addLayout(job_name_layout)
# Job File
scene_file_picker_layout = QHBoxLayout() scene_file_picker_layout = QHBoxLayout()
scene_file_picker_layout.addWidget(QLabel("File:"))
self.scene_file_input = QLineEdit() self.scene_file_input = QLineEdit()
self.scene_file_input.setText(self.project_path) self.scene_file_input.setText(self.project_path)
self.scene_file_browse_button = QPushButton("Browse...") self.scene_file_browse_button = QPushButton("Browse...")
self.scene_file_browse_button.clicked.connect(self.browse_scene_file) self.scene_file_browse_button.clicked.connect(self.browse_scene_file)
scene_file_picker_layout.addWidget(QLabel("File:"))
scene_file_picker_layout.addWidget(self.scene_file_input) scene_file_picker_layout.addWidget(self.scene_file_input)
scene_file_picker_layout.addWidget(self.scene_file_browse_button) scene_file_picker_layout.addWidget(self.scene_file_browse_button)
server_layout.addLayout(scene_file_picker_layout) file_group_layout.addLayout(scene_file_picker_layout)
# Server List
main_layout.addWidget(job_overview_group)
main_layout.addWidget(self.load_file_group)
main_layout.addWidget(self.tabs)
# ==================== Tab 1: Job Settings ====================
self.project_group = QWidget()
project_layout = QVBoxLayout(self.project_group) # Fixed: proper parent
# Server / Hostname
server_list_layout = QHBoxLayout() server_list_layout = QHBoxLayout()
server_list_layout.setSpacing(0) server_list_layout.addWidget(QLabel("Render Target:"))
self.server_input = QComboBox() self.server_input = QComboBox()
server_list_layout.addWidget(QLabel("Hostname:"), 1) server_list_layout.addWidget(self.server_input)
server_list_layout.addWidget(self.server_input, 3) project_layout.addLayout(server_list_layout)
server_layout.addLayout(server_list_layout)
main_layout.addWidget(self.project_group)
self.update_server_list()
# Priority # Priority
priority_layout = QHBoxLayout() priority_layout = QHBoxLayout()
priority_layout.addWidget(QLabel("Priority:"), 1) priority_layout.addWidget(QLabel("Priority:"))
self.priority_input = QComboBox() self.priority_input = QComboBox()
self.priority_input.addItems(["High", "Medium", "Low"]) self.priority_input.addItems(["High", "Medium", "Low"])
self.priority_input.setCurrentIndex(1) self.priority_input.setCurrentIndex(1)
priority_layout.addWidget(self.priority_input, 3) priority_layout.addWidget(self.priority_input)
server_layout.addLayout(priority_layout) project_layout.addLayout(priority_layout)
# Splitjobs
self.enable_splitjobs = QCheckBox("Automatically split render across multiple servers")
self.enable_splitjobs.setEnabled(True)
server_layout.addWidget(self.enable_splitjobs)
self.splitjobs_same_os = QCheckBox("Only render on same OS")
self.splitjobs_same_os.setEnabled(True)
server_layout.addWidget(self.splitjobs_same_os)
# Output Settings Group # Split Jobs Options
self.output_settings_group = QGroupBox("Output Settings") self.enable_splitjobs = QCheckBox("Automatically split render across multiple servers")
project_layout.addWidget(self.enable_splitjobs)
self.splitjobs_same_os = QCheckBox("Only render on same OS")
project_layout.addWidget(self.splitjobs_same_os)
project_layout.addStretch() # Push everything up
# ==================== Tab 2: Output Settings ====================
self.output_settings_group = QWidget()
output_settings_layout = QVBoxLayout(self.output_settings_group) output_settings_layout = QVBoxLayout(self.output_settings_group)
# output path
render_name_layout = QHBoxLayout() # File Format
render_name_layout.addWidget(QLabel("Render name:")) format_group = QGroupBox("Format / Range")
self.render_name_input = QLineEdit() output_settings_layout.addWidget(format_group)
render_name_layout.addWidget(self.render_name_input) format_group_layout = QVBoxLayout()
output_settings_layout.addLayout(render_name_layout) format_group.setLayout(format_group_layout)
# file format
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()
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()
# frame range format_group_layout.addLayout(file_format_layout)
frame_range_layout = QHBoxLayout(self.output_settings_group)
# Frame Range
frame_range_layout = QHBoxLayout()
frame_range_layout.addWidget(QLabel("Frames:"))
self.start_frame_input = QSpinBox() self.start_frame_input = QSpinBox()
self.start_frame_input.setRange(1, 99999) self.start_frame_input.setRange(1, 99999)
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)
frame_range_layout.addWidget(QLabel("Frames:")) 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)
output_settings_layout.addLayout(frame_range_layout) frame_range_layout.addStretch()
# resolution format_group_layout.addLayout(frame_range_layout)
resolution_layout = QHBoxLayout(self.output_settings_group)
# --- Resolution & FPS Group ---
resolution_group = QGroupBox("Resolution / Frame Rate")
output_settings_layout.addWidget(resolution_group)
resolution_group_layout = QVBoxLayout()
resolution_group.setLayout(resolution_group_layout)
# Resolution
resolution_layout = QHBoxLayout()
self.resolution_options_list = QComboBox()
self.resolution_options_list.setFixedWidth(200)
self.resolution_options_list.addItem("Original Size")
for res in COMMON_RESOLUTIONS:
self.resolution_options_list.addItem(res)
self.resolution_options_list.currentIndexChanged.connect(self._resolution_preset_changed)
resolution_layout.addWidget(self.resolution_options_list)
resolution_group_layout.addLayout(resolution_layout)
self.resolution_x_input = QSpinBox() self.resolution_x_input = QSpinBox()
self.resolution_x_input.setRange(1, 9999) # Assuming max resolution width 9999 self.resolution_x_input.setRange(1, 9999)
self.resolution_x_input.setValue(1920) self.resolution_x_input.setValue(1920)
self.resolution_y_input = QSpinBox() self.resolution_x_input.setFixedWidth(80)
self.resolution_y_input.setRange(1, 9999) # Assuming max resolution height 9999
self.resolution_y_input.setValue(1080)
self.frame_rate_input = QDoubleSpinBox()
self.frame_rate_input.setRange(1, 9999) # Assuming max resolution width 9999
self.frame_rate_input.setDecimals(3)
self.frame_rate_input.setValue(23.976)
resolution_layout.addWidget(QLabel("Resolution:"))
resolution_layout.addWidget(self.resolution_x_input) resolution_layout.addWidget(self.resolution_x_input)
self.resolution_y_input = QSpinBox()
self.resolution_y_input.setRange(1, 9999)
self.resolution_y_input.setValue(1080)
self.resolution_y_input.setFixedWidth(80)
resolution_layout.addWidget(QLabel("x")) resolution_layout.addWidget(QLabel("x"))
resolution_layout.addWidget(self.resolution_y_input) resolution_layout.addWidget(self.resolution_y_input)
resolution_layout.addWidget(QLabel("@")) resolution_layout.addStretch()
resolution_layout.addWidget(self.frame_rate_input)
resolution_layout.addWidget(QLabel("fps"))
output_settings_layout.addLayout(resolution_layout)
# add group to layout
main_layout.addWidget(self.output_settings_group)
# Engine Group fps_layout = QHBoxLayout()
self.engine_group = QGroupBox("Engine Settings") self.fps_options_list = QComboBox()
self.fps_options_list.setFixedWidth(200)
self.fps_options_list.addItem("Original FPS")
for fps_option in COMMON_FRAME_RATES:
self.fps_options_list.addItem(fps_option)
self.fps_options_list.currentIndexChanged.connect(self._fps_preset_changed)
fps_layout.addWidget(self.fps_options_list)
self.fps_input = QDoubleSpinBox()
self.fps_input.setDecimals(3)
self.fps_input.setRange(1.0, 999.0)
self.fps_input.setValue(23.976)
self.fps_input.setFixedWidth(80)
fps_layout.addWidget(self.fps_input)
fps_layout.addWidget(QLabel("fps"))
fps_layout.addStretch()
resolution_group_layout.addLayout(fps_layout)
output_settings_layout.addStretch()
# ==================== Tab 3: Engine Settings ====================
self.engine_group = QWidget()
engine_group_layout = QVBoxLayout(self.engine_group) engine_group_layout = QVBoxLayout(self.engine_group)
engine_layout = QHBoxLayout() engine_layout = QHBoxLayout()
engine_layout.addWidget(QLabel("Engine:")) engine_layout.addWidget(QLabel("Engine Version:"))
self.engine_type = QComboBox()
self.engine_type.currentIndexChanged.connect(self.engine_changed)
engine_layout.addWidget(self.engine_type)
# Version
engine_layout.addWidget(QLabel("Version:"))
self.engine_version_combo = QComboBox() self.engine_version_combo = QComboBox()
self.engine_version_combo.addItem('latest') self.engine_version_combo.addItem('latest')
engine_layout.addWidget(self.engine_version_combo) engine_layout.addWidget(self.engine_version_combo)
engine_group_layout.addLayout(engine_layout) engine_group_layout.addLayout(engine_layout)
# dynamic options
# Dynamic engine options
self.engine_options_layout = QVBoxLayout() self.engine_options_layout = QVBoxLayout()
engine_group_layout.addLayout(self.engine_options_layout) engine_group_layout.addLayout(self.engine_options_layout)
# Raw Args # Raw Args
raw_args_layout = QHBoxLayout(self.engine_group) raw_args_layout = QHBoxLayout()
raw_args_layout.addWidget(QLabel("Raw Args:")) raw_args_layout.addWidget(QLabel("Raw Args:"))
self.raw_args = QLineEdit() self.raw_args = QLineEdit()
raw_args_layout.addWidget(self.raw_args) raw_args_layout.addWidget(self.raw_args)
@@ -208,24 +259,34 @@ class NewRenderJobForm(QWidget):
args_help_button.clicked.connect(self.args_help_button_clicked) args_help_button.clicked.connect(self.args_help_button_clicked)
raw_args_layout.addWidget(args_help_button) raw_args_layout.addWidget(args_help_button)
engine_group_layout.addLayout(raw_args_layout) engine_group_layout.addLayout(raw_args_layout)
main_layout.addWidget(self.engine_group) engine_group_layout.addStretch()
# Cameras Group # ==================== Tab 4: Cameras ====================
self.cameras_group = QGroupBox("Cameras") self.cameras_group = QWidget()
cameras_layout = QVBoxLayout(self.cameras_group) cameras_layout = QVBoxLayout(self.cameras_group)
self.cameras_list = QListWidget() self.cameras_list = QListWidget()
self.cameras_group.setHidden(True) self.cameras_list.itemChanged.connect(self.update_job_count)
cameras_layout.addWidget(self.cameras_list) cameras_layout.addWidget(self.cameras_list)
main_layout.addWidget(self.cameras_group)
# Notes Group # ==================== Tab 5: Misc / Notes ====================
self.notes_group = QGroupBox("Additional Notes") self.notes_group = QWidget()
notes_layout = QVBoxLayout(self.notes_group) notes_layout = QVBoxLayout(self.notes_group)
self.notes_input = QPlainTextEdit() self.notes_input = QPlainTextEdit()
notes_layout.addWidget(self.notes_input) notes_layout.addWidget(self.notes_input)
main_layout.addWidget(self.notes_group)
# Submit Button # == Create Tabs
self.tabs.addTab(self.project_group, "Job Settings")
self.tabs.addTab(self.output_settings_group, "Output Settings")
self.tabs.addTab(self.engine_group, "Engine Settings")
self.tabs.addTab(self.cameras_group, "Cameras")
self.tabs.addTab(self.notes_group, "Notes")
self.update_server_list()
index = self.tabs.indexOf(self.cameras_group)
if index != -1:
self.tabs.setTabEnabled(index, False)
# ==================== Submit Section (outside tabs) ====================
self.submit_button = QPushButton("Submit Job") self.submit_button = QPushButton("Submit Job")
self.submit_button.clicked.connect(self.submit_job) self.submit_button.clicked.connect(self.submit_job)
main_layout.addWidget(self.submit_button) main_layout.addWidget(self.submit_button)
@@ -240,17 +301,38 @@ class NewRenderJobForm(QWidget):
self.submit_progress_label.setHidden(True) self.submit_progress_label.setHidden(True)
main_layout.addWidget(self.submit_progress_label) main_layout.addWidget(self.submit_progress_label)
# Initial engine state
self.toggle_engine_enablement(False) self.toggle_engine_enablement(False)
self.tabs.setCurrentIndex(0)
def update_engine_info(self): def update_job_count(self, changed_item=None):
# get the engine info and add them all to the ui checked = 0
self.engine_info = self.server_proxy.get_engine_info(response_type='full') total = self.cameras_list.count()
self.engine_type.addItems(self.engine_info.keys())
# select the best engine for the file type for i in range(total):
engine = EngineManager.engine_for_project_path(self.project_path) item = self.cameras_list.item(i)
self.engine_type.setCurrentText(engine.name().lower()) if item.checkState() == Qt.CheckState.Checked:
# refresh ui checked += 1
self.engine_changed()
message = f"Submit {checked} Jobs" if checked > 1 else "Submit Job"
self.submit_button.setText(message)
self.submit_button.setEnabled(bool(checked))
def _resolution_preset_changed(self, index):
selected_res = COMMON_RESOLUTIONS.get(self.resolution_options_list.currentText())
if selected_res:
self.resolution_x_input.setValue(selected_res[0])
self.resolution_y_input.setValue(selected_res[1])
elif index == 0:
self.resolution_x_input.setValue(self.project_info.get('resolution_x'))
self.resolution_y_input.setValue(self.project_info.get('resolution_y'))
def _fps_preset_changed(self, index):
selected_fps = COMMON_FRAME_RATES.get(self.fps_options_list.currentText())
if selected_fps:
self.fps_input.setValue(selected_fps)
elif index == 0:
self.fps_input.setValue(self.project_info.get('fps'))
def engine_changed(self): def engine_changed(self):
# load the version numbers # load the version numbers
@@ -259,9 +341,13 @@ class NewRenderJobForm(QWidget):
self.engine_version_combo.addItem('latest') self.engine_version_combo.addItem('latest')
self.file_format_combo.clear() self.file_format_combo.clear()
if current_engine: if current_engine:
engine_vers = [version_info['version'] for version_info in self.engine_info[current_engine]['versions']] engine_info = self.server_proxy.get_engine_info(current_engine, 'full', timeout=10)
self.current_engine_options = engine_info.get('options', [])
if not engine_info:
raise FileNotFoundError(f"Cannot get information about engine '{current_engine}'")
engine_vers = [v['version'] for v in engine_info['versions']]
self.engine_version_combo.addItems(engine_vers) self.engine_version_combo.addItems(engine_vers)
self.file_format_combo.addItems(self.engine_info[current_engine]['supported_export_formats']) self.file_format_combo.addItems(engine_info.get('supported_export_formats'))
def update_server_list(self): def update_server_list(self):
clients = ZeroconfServer.found_hostnames() clients = ZeroconfServer.found_hostnames()
@@ -282,18 +368,19 @@ class NewRenderJobForm(QWidget):
output_name, _ = os.path.splitext(os.path.basename(self.scene_file_input.text())) output_name, _ = os.path.splitext(os.path.basename(self.scene_file_input.text()))
output_name = output_name.replace(' ', '_') output_name = output_name.replace(' ', '_')
self.render_name_input.setText(output_name) self.job_name_input.setText(output_name)
file_name = self.scene_file_input.text() file_name = self.scene_file_input.text()
# setup bg worker # setup bg worker
self.worker_thread = GetProjectInfoWorker(window=self, project_path=file_name) self.worker_thread = GetProjectInfoWorker(window=self, project_path=file_name)
self.worker_thread.message_signal.connect(self.post_get_project_info_update) self.worker_thread.message_signal.connect(self.post_get_project_info_update)
self.worker_thread.error_signal.connect(self.show_error_message)
self.worker_thread.start() self.worker_thread.start()
def browse_output_path(self): def browse_output_path(self):
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory") directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
if directory: if directory:
self.render_name_input.setText(directory) self.job_name_input.setText(directory)
def args_help_button_clicked(self): def args_help_button_clicked(self):
url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/engine/' url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/engine/'
@@ -301,14 +388,26 @@ class NewRenderJobForm(QWidget):
self.engine_help_viewer = EngineHelpViewer(url) self.engine_help_viewer = EngineHelpViewer(url)
self.engine_help_viewer.show() self.engine_help_viewer.show()
def show_error_message(self, message):
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Icon.Critical)
msg.setWindowTitle("Error")
msg.setText(message)
msg.exec()
# -------- Update -------- # -------- Update --------
def post_get_project_info_update(self): def post_get_project_info_update(self):
"""Called by the GetProjectInfoWorker - Do not call directly.""" """Called by the GetProjectInfoWorker - Do not call directly."""
try: try:
self.engine_type.addItems(self.installed_engines.keys())
self.engine_type.setCurrentText(self.preferred_engine)
self.engine_changed()
# Set the best engine we can find # Set the best engine we can find
input_path = self.scene_file_input.text() input_path = self.scene_file_input.text()
engine = EngineManager.engine_for_project_path(input_path) engine = EngineManager.engine_class_for_project_path(input_path)
engine_index = self.engine_type.findText(engine.name().lower()) engine_index = self.engine_type.findText(engine.name().lower())
if engine_index >= 0: if engine_index >= 0:
@@ -326,12 +425,13 @@ class NewRenderJobForm(QWidget):
self.end_frame_input.setValue(self.project_info.get('frame_end')) self.end_frame_input.setValue(self.project_info.get('frame_end'))
self.resolution_x_input.setValue(self.project_info.get('resolution_x')) self.resolution_x_input.setValue(self.project_info.get('resolution_x'))
self.resolution_y_input.setValue(self.project_info.get('resolution_y')) self.resolution_y_input.setValue(self.project_info.get('resolution_y'))
self.frame_rate_input.setValue(self.project_info.get('fps')) self.fps_input.setValue(self.project_info.get('fps'))
# Cameras # Cameras
self.cameras_list.clear() self.cameras_list.clear()
index = self.tabs.indexOf(self.cameras_group)
if self.project_info.get('cameras'): if self.project_info.get('cameras'):
self.cameras_group.setHidden(False) self.tabs.setTabEnabled(index, True)
found_active = False found_active = False
for camera in self.project_info['cameras']: for camera in self.project_info['cameras']:
# create the list items and make them checkable # create the list items and make them checkable
@@ -344,13 +444,12 @@ class NewRenderJobForm(QWidget):
if not found_active: if not found_active:
self.cameras_list.item(0).setCheckState(Qt.CheckState.Checked) self.cameras_list.item(0).setCheckState(Qt.CheckState.Checked)
else: else:
self.cameras_group.setHidden(True) self.tabs.setTabEnabled(index, False)
self.update_job_count()
# Dynamic Engine Options # Dynamic Engine Options
clear_layout(self.engine_options_layout) # clear old options clear_layout(self.engine_options_layout) # clear old options
# dynamically populate option list # dynamically populate option list
system_info = self.engine_info.get(engine.name(), {}).get('system_info', {})
self.current_engine_options = engine.ui_options(system_info=system_info)
for option in self.current_engine_options: for option in self.current_engine_options:
h_layout = QHBoxLayout() h_layout = QHBoxLayout()
label = QLabel(option['name'].replace('_', ' ').capitalize() + ':') label = QLabel(option['name'].replace('_', ' ').capitalize() + ':')
@@ -369,12 +468,13 @@ class NewRenderJobForm(QWidget):
def toggle_engine_enablement(self, enabled=False): def toggle_engine_enablement(self, enabled=False):
"""Toggle on/off all the render settings""" """Toggle on/off all the render settings"""
self.project_group.setHidden(not enabled) indexes = [self.tabs.indexOf(self.project_group),
self.output_settings_group.setHidden(not enabled) self.tabs.indexOf(self.output_settings_group),
self.engine_group.setHidden(not enabled) self.tabs.indexOf(self.engine_group),
self.notes_group.setHidden(not enabled) self.tabs.indexOf(self.cameras_group),
if not enabled: self.tabs.indexOf(self.notes_group)]
self.cameras_group.setHidden(True) for idx in indexes:
self.tabs.setTabEnabled(idx, enabled)
self.submit_button.setEnabled(enabled) self.submit_button.setEnabled(enabled)
def after_job_submission(self, error_string): def after_job_submission(self, error_string):
@@ -449,19 +549,22 @@ class SubmitWorker(QThread):
try: try:
hostname = self.window.server_input.currentText() hostname = self.window.server_input.currentText()
resolution = (self.window.resolution_x_input.text(), self.window.resolution_y_input.text())
job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(), job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(),
'engine_name': self.window.engine_type.currentText().lower(), 'engine_name': self.window.engine_type.currentText().lower(),
'engine_version': self.window.engine_version_combo.currentText(), 'engine_version': self.window.engine_version_combo.currentText(),
'args': {'raw': self.window.raw_args.text(), 'args': {'raw': self.window.raw_args.text(),
'export_format': self.window.file_format_combo.currentText()}, 'export_format': self.window.file_format_combo.currentText(),
'output_path': self.window.render_name_input.text(), 'resolution': resolution,
'fps': self.window.fps_input.text(),},
'output_path': self.window.job_name_input.text(),
'start_frame': self.window.start_frame_input.value(), 'start_frame': self.window.start_frame_input.value(),
'end_frame': self.window.end_frame_input.value(), 'end_frame': self.window.end_frame_input.value(),
'priority': self.window.priority_input.currentIndex() + 1, 'priority': self.window.priority_input.currentIndex() + 1,
'notes': self.window.notes_input.toPlainText(), 'notes': self.window.notes_input.toPlainText(),
'enable_split_jobs': self.window.enable_splitjobs.isChecked(), 'enable_split_jobs': self.window.enable_splitjobs.isChecked(),
'split_jobs_same_os': self.window.splitjobs_same_os.isChecked(), 'split_jobs_same_os': self.window.splitjobs_same_os.isChecked(),
'name': self.window.render_name_input.text()} 'name': self.window.job_name_input.text()}
# get the dynamic args # get the dynamic args
for i in range(self.window.engine_options_layout.count()): for i in range(self.window.engine_options_layout.count()):
@@ -496,9 +599,10 @@ class SubmitWorker(QThread):
children_jobs.append(child_job_data) children_jobs.append(child_job_data)
job_json['child_jobs'] = children_jobs job_json['child_jobs'] = children_jobs
# presubmission tasks # presubmission tasks - use local installs
engine = EngineManager.engine_with_name(self.window.engine_type.currentText().lower()) engine_class = EngineManager.engine_class_with_name(self.window.engine_type.currentText().lower())
input_path = engine().perform_presubmission_tasks(input_path) latest_engine = EngineManager.get_latest_engine_instance(engine_class)
input_path = latest_engine.perform_presubmission_tasks(input_path)
# submit # submit
err_msg = "" err_msg = ""
result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_data=job_json, result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_data=job_json,
@@ -516,6 +620,7 @@ class GetProjectInfoWorker(QThread):
"""Worker class called to retrieve information about a project file on a background thread and update the UI""" """Worker class called to retrieve information about a project file on a background thread and update the UI"""
message_signal = pyqtSignal() message_signal = pyqtSignal()
error_signal = pyqtSignal(str)
def __init__(self, window, project_path): def __init__(self, window, project_path):
super().__init__() super().__init__()
@@ -523,9 +628,19 @@ class GetProjectInfoWorker(QThread):
self.project_path = project_path self.project_path = project_path
def run(self): def run(self):
engine = EngineManager.engine_for_project_path(self.project_path) try:
self.window.project_info = engine().get_project_info(self.project_path) # get the engine info and add them all to the ui
self.window.installed_engines = self.window.server_proxy.get_installed_engines()
# select the best engine for the file type
self.window.preferred_engine = self.window.server_proxy.get_engine_for_filename(self.project_path)
# this should be the only time we use a local engine instead of using the proxy besides submitting
engine_class = EngineManager.engine_class_for_project_path(self.project_path)
engine = EngineManager.get_latest_engine_instance(engine_class)
self.window.project_info = engine.get_project_info(self.project_path)
self.message_signal.emit() self.message_signal.emit()
except Exception as e:
self.error_signal.emit(str(e))
def clear_layout(layout): def clear_layout(layout):

View File

@@ -93,7 +93,7 @@ class EngineBrowserWindow(QMainWindow):
def update_table(self): def update_table(self):
def update_table_worker(): def update_table_worker():
raw_server_data = RenderServerProxy(self.hostname).get_engine_info() raw_server_data = RenderServerProxy(self.hostname).get_all_engine_info()
if not raw_server_data: if not raw_server_data:
return return

View File

@@ -2,7 +2,6 @@
import ast import ast
import datetime import datetime
import io import io
import json
import logging import logging
import os import os
import sys import sys
@@ -10,18 +9,17 @@ import threading
import time import time
import PIL import PIL
import humanize
from PIL import Image from PIL import Image
from PyQt6.QtCore import Qt, QByteArray, QBuffer, QIODevice, QThread from PyQt6.QtCore import Qt, QByteArray, QBuffer, QIODevice, QThread, pyqtSignal
from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon
from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QListWidget, QTableWidget, QAbstractItemView, \ from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QListWidget, QTableWidget, QAbstractItemView, \
QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem, \ QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem, \
QFileDialog QFileDialog
from src.api.api_server import API_VERSION from src.api.api_server import API_VERSION
from src.api.serverproxy_manager import ServerProxyManager
from src.render_queue import RenderQueue from src.render_queue import RenderQueue
from src.utilities.misc_helper import get_time_elapsed, resources_dir, is_localhost
from src.utilities.status_utils import RenderStatus
from src.utilities.zeroconf_server import ZeroconfServer
from src.ui.add_job_window import NewRenderJobForm from src.ui.add_job_window import NewRenderJobForm
from src.ui.console_window import ConsoleWindow from src.ui.console_window import ConsoleWindow
from src.ui.engine_browser import EngineBrowserWindow from src.ui.engine_browser import EngineBrowserWindow
@@ -30,8 +28,10 @@ from src.ui.widgets.menubar import MenuBar
from src.ui.widgets.proportional_image_label import ProportionalImageLabel from src.ui.widgets.proportional_image_label import ProportionalImageLabel
from src.ui.widgets.statusbar import StatusBar from src.ui.widgets.statusbar import StatusBar
from src.ui.widgets.toolbar import ToolBar from src.ui.widgets.toolbar import ToolBar
from src.api.serverproxy_manager import ServerProxyManager from src.utilities.misc_helper import get_time_elapsed, resources_dir, is_localhost
from src.utilities.misc_helper import launch_url, iso_datestring_to_formatted_datestring from src.utilities.misc_helper import launch_url
from src.utilities.status_utils import RenderStatus
from src.utilities.zeroconf_server import ZeroconfServer
from src.version import APP_NAME from src.version import APP_NAME
logger = logging.getLogger() logger = logging.getLogger()
@@ -99,8 +99,10 @@ class MainWindow(QMainWindow):
self.create_toolbars() self.create_toolbars()
# start background update # start background update
self.bg_update_thread = QThread() self.found_servers = []
self.bg_update_thread.run = self.__background_update self.job_data = {}
self.bg_update_thread = BackgroundUpdater(window=self)
self.bg_update_thread.updated_signal.connect(self.update_ui_data)
self.bg_update_thread.start() self.bg_update_thread.start()
# Setup other windows # Setup other windows
@@ -161,44 +163,35 @@ class MainWindow(QMainWindow):
self.job_list_view.verticalHeader().setVisible(False) self.job_list_view.verticalHeader().setVisible(False)
self.job_list_view.itemSelectionChanged.connect(self.job_picked) self.job_list_view.itemSelectionChanged.connect(self.job_picked)
self.job_list_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.job_list_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.refresh_job_headers()
# Image Layout # Setup Job Headers
image_group = QGroupBox("Job Preview") self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Engine", "Priority", "Status",
image_layout = QVBoxLayout(image_group) "Time Elapsed", "Frames", "Date Created"])
image_layout.setContentsMargins(0, 0, 0, 0) self.job_list_view.setColumnHidden(0, True)
image_center_layout = QHBoxLayout()
image_center_layout.addWidget(self.image_label)
image_layout.addWidget(self.image_label)
# image_layout.addLayout(image_center_layout)
# Job Layout self.job_list_view.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
job_list_group = QGroupBox("Render Jobs") self.job_list_view.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents)
# Job List Layout
job_list_group = QGroupBox("Job Preview")
job_list_layout = QVBoxLayout(job_list_group) job_list_layout = QVBoxLayout(job_list_group)
job_list_layout.setContentsMargins(0, 0, 0, 0) job_list_layout.setContentsMargins(0, 0, 0, 0)
image_layout.addWidget(self.job_list_view, stretch=True) job_list_layout.addWidget(self.image_label)
image_layout.addLayout(job_list_layout) job_list_layout.addWidget(self.job_list_view, stretch=True)
# Add them all to the window # Add them all to the window
main_layout.addLayout(info_layout) main_layout.addLayout(info_layout)
right_layout = QVBoxLayout() right_layout = QVBoxLayout()
right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.addWidget(image_group) right_layout.addWidget(job_list_group)
# right_layout.addWidget(job_list_group)
main_layout.addLayout(right_layout) main_layout.addLayout(right_layout)
def __background_update(self):
while True:
try:
self.update_servers()
self.fetch_jobs()
except RuntimeError:
pass
except Exception as e:
logger.error(f"Uncaught exception in background update: {e}")
time.sleep(0.5)
def closeEvent(self, event): def closeEvent(self, event):
running_jobs = len(RenderQueue.running_jobs()) running_jobs = len(RenderQueue.running_jobs())
if running_jobs: if running_jobs:
@@ -214,6 +207,10 @@ class MainWindow(QMainWindow):
# -- Server Code -- # # -- Server Code -- #
def refresh_job_list(self):
self.job_list_view.clearContents()
self.bg_update_thread.needs_update = True
@property @property
def current_server_proxy(self): def current_server_proxy(self):
return ServerProxyManager.get_proxy_for_hostname(self.current_hostname) return ServerProxyManager.get_proxy_for_hostname(self.current_hostname)
@@ -229,7 +226,7 @@ class MainWindow(QMainWindow):
# Update the current hostname and clear the job list # Update the current hostname and clear the job list
self.current_hostname = new_hostname self.current_hostname = new_hostname
self.job_list_view.setRowCount(0) self.job_list_view.setRowCount(0)
self.fetch_jobs(clear_table=True) self.refresh_job_list()
# Select the first row if there are jobs listed # Select the first row if there are jobs listed
if self.job_list_view.rowCount(): if self.job_list_view.rowCount():
@@ -281,21 +278,19 @@ class MainWindow(QMainWindow):
self.server_info_ram.setText(memory_info) self.server_info_ram.setText(memory_info)
self.server_info_gpu.setText(gpu_info) self.server_info_gpu.setText(gpu_info)
def fetch_jobs(self, clear_table=False): def update_ui_data(self):
self.update_servers()
if not self.current_server_proxy: if not self.current_server_proxy:
return return
if clear_table: server_job_data = self.job_data.get(self.current_server_proxy.hostname)
self.job_list_view.clear() if server_job_data:
self.refresh_job_headers() num_jobs = len(server_job_data)
job_fetch = self.current_server_proxy.get_all_jobs(ignore_token=False)
if job_fetch:
num_jobs = len(job_fetch)
self.job_list_view.setRowCount(num_jobs) self.job_list_view.setRowCount(num_jobs)
for row, job in enumerate(job_fetch): for row, job in enumerate(server_job_data):
display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \ display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \
('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status ('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status
@@ -307,14 +302,15 @@ class MainWindow(QMainWindow):
get_time_elapsed(start_time, end_time) get_time_elapsed(start_time, end_time)
name = job.get('name') or os.path.basename(job.get('input_path', '')) name = job.get('name') or os.path.basename(job.get('input_path', ''))
engine_name = f"{job.get('renderer', '')}-{job.get('renderer_version')}" engine_name = f"{job.get('engine', '')}-{job.get('engine_version')}"
priority = str(job.get('priority', '')) priority = str(job.get('priority', ''))
total_frames = str(job.get('total_frames', '')) total_frames = str(job.get('total_frames', ''))
date_created_string = iso_datestring_to_formatted_datestring(job['date_created']) converted_time = datetime.datetime.fromisoformat(job['date_created'])
humanized_time = humanize.naturaltime(converted_time)
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(engine_name), items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(engine_name),
QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed), QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed),
QTableWidgetItem(total_frames), QTableWidgetItem(date_created_string)] QTableWidgetItem(total_frames), QTableWidgetItem(humanized_time)]
for col, item in enumerate(items): for col, item in enumerate(items):
self.job_list_view.setItem(row, col, item) self.job_list_view.setItem(row, col, item)
@@ -395,18 +391,6 @@ class MainWindow(QMainWindow):
except AttributeError: except AttributeError:
return [] return []
def refresh_job_headers(self):
self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Engine", "Priority", "Status",
"Time Elapsed", "Frames", "Date Created"])
self.job_list_view.setColumnHidden(0, True)
self.job_list_view.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.job_list_view.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents)
# -- Image Code -- # # -- Image Code -- #
@@ -444,28 +428,25 @@ class MainWindow(QMainWindow):
logger.error(f"Error loading image data: {e}") logger.error(f"Error loading image data: {e}")
def update_servers(self): def update_servers(self):
found_servers = list(set(ZeroconfServer.found_hostnames() + self.added_hostnames))
found_servers = [x for x in found_servers if ZeroconfServer.get_hostname_properties(x)['api_version'] == API_VERSION]
# Always make sure local hostname is first # Always make sure local hostname is first
if found_servers and not is_localhost(found_servers[0]): if self.found_servers and not is_localhost(self.found_servers[0]):
for hostname in found_servers: for hostname in self.found_servers:
if is_localhost(hostname): if is_localhost(hostname):
found_servers.remove(hostname) self.found_servers.remove(hostname)
found_servers.insert(0, hostname) self.found_servers.insert(0, hostname)
break break
old_count = self.server_list_view.count() old_count = self.server_list_view.count()
# Update proxys # Update proxys
for hostname in found_servers: for hostname in self.found_servers:
ServerProxyManager.get_proxy_for_hostname(hostname) # setup background updates ServerProxyManager.get_proxy_for_hostname(hostname) # setup background updates
# Add in all the missing servers # Add in all the missing servers
current_server_list = [] current_server_list = []
for i in range(self.server_list_view.count()): for i in range(self.server_list_view.count()):
current_server_list.append(self.server_list_view.item(i).text()) current_server_list.append(self.server_list_view.item(i).text())
for hostname in found_servers: for hostname in self.found_servers:
if hostname not in current_server_list: if hostname not in current_server_list:
properties = ZeroconfServer.get_hostname_properties(hostname) properties = ZeroconfServer.get_hostname_properties(hostname)
image_path = os.path.join(resources_dir(), f"{properties.get('system_os', 'Monitor')}.png") image_path = os.path.join(resources_dir(), f"{properties.get('system_os', 'Monitor')}.png")
@@ -476,7 +457,7 @@ class MainWindow(QMainWindow):
servers_to_remove = [] servers_to_remove = []
for i in range(self.server_list_view.count()): for i in range(self.server_list_view.count()):
name = self.server_list_view.item(i).text() name = self.server_list_view.item(i).text()
if name not in found_servers: if name not in self.found_servers:
servers_to_remove.append(name) servers_to_remove.append(name)
# remove any servers that shouldn't be shown any longer # remove any servers that shouldn't be shown any longer
@@ -572,7 +553,7 @@ class MainWindow(QMainWindow):
if msg_box.exec() == QMessageBox.StandardButton.Yes: if msg_box.exec() == QMessageBox.StandardButton.Yes:
for job_id in job_ids: for job_id in job_ids:
self.current_server_proxy.cancel_job(job_id, confirm=True) self.current_server_proxy.cancel_job(job_id, confirm=True)
self.fetch_jobs(clear_table=True) self.refresh_job_list()
def delete_job(self, event): def delete_job(self, event):
""" """
@@ -599,7 +580,7 @@ class MainWindow(QMainWindow):
if msg_box.exec() == QMessageBox.StandardButton.Yes: if msg_box.exec() == QMessageBox.StandardButton.Yes:
for job_id in job_ids: for job_id in job_ids:
self.current_server_proxy.delete_job(job_id, confirm=True) self.current_server_proxy.delete_job(job_id, confirm=True)
self.fetch_jobs(clear_table=True) self.refresh_job_list()
def download_files(self, event): def download_files(self, event):
@@ -630,6 +611,36 @@ class MainWindow(QMainWindow):
self.new_job_window.show() self.new_job_window.show()
class BackgroundUpdater(QThread):
"""Worker class to fetch job and server information and update the UI"""
updated_signal = pyqtSignal()
error_signal = pyqtSignal(str)
def __init__(self, window):
super().__init__()
self.window = window
self.needs_update = True
def run(self):
try:
last_run = 0
while True:
now = time.monotonic()
if now - last_run >= 1.0 or self.needs_update:
self.window.found_servers = list(set(ZeroconfServer.found_hostnames() + self.window.added_hostnames))
self.window.found_servers = [x for x in self.window.found_servers if
ZeroconfServer.get_hostname_properties(x)['api_version'] == API_VERSION]
if self.window.current_server_proxy:
self.window.job_data[self.window.current_server_proxy.hostname] =\
self.window.current_server_proxy.get_all_jobs(ignore_token=False)
self.needs_update = False
self.updated_signal.emit()
time.sleep(0.05)
except Exception as e:
print(f"ERROR: {e}")
self.error_signal.emit(str(e))
if __name__ == "__main__": if __name__ == "__main__":
# lazy load GUI frameworks # lazy load GUI frameworks
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication

View File

@@ -37,7 +37,7 @@ class GetEngineInfoWorker(QThread):
self.parent = parent self.parent = parent
def run(self): def run(self):
data = RenderServerProxy(socket.gethostname()).get_engine_info() data = RenderServerProxy(socket.gethostname()).get_all_engine_info()
self.done.emit(data) self.done.emit(data)
class SettingsWindow(QMainWindow): class SettingsWindow(QMainWindow):
@@ -413,7 +413,7 @@ class SettingsWindow(QMainWindow):
msg_result = msg_box.exec() msg_result = msg_box.exec()
messagebox_shown = True messagebox_shown = True
if msg_result == QMessageBox.StandardButton.Yes: if msg_result == QMessageBox.StandardButton.Yes:
EngineManager.download_engine(engine=engine.name(), version=result['version'], background=True, EngineManager.download_engine(engine_name=engine.name(), version=result['version'], background=True,
ignore_system=ignore_system) ignore_system=ignore_system)
self.engine_download_progress_bar.setHidden(False) self.engine_download_progress_bar.setHidden(False)
self.engine_download_progress_bar.setValue(0) self.engine_download_progress_bar.setValue(0)

View File

@@ -249,20 +249,6 @@ def num_to_alphanumeric(num):
return result[::-1] # Reverse the result to get the correct alphanumeric string return result[::-1] # Reverse the result to get the correct alphanumeric string
def iso_datestring_to_formatted_datestring(iso_date_string):
from dateutil import parser
import pytz
# Parse the ISO date string into a datetime object and convert timezones
date = parser.isoparse(iso_date_string).astimezone(pytz.UTC)
local_timezone = datetime.now().astimezone().tzinfo
date_local = date.astimezone(local_timezone)
# Format the date to the desired readable yet sortable format with 12-hour time
formatted_date = date_local.strftime('%Y-%m-%d %I:%M %p')
return formatted_date
def get_gpu_info(): def get_gpu_info():
"""Cross-platform GPU information retrieval""" """Cross-platform GPU information retrieval"""
@@ -384,3 +370,56 @@ def get_gpu_info():
return get_windows_gpu_info() return get_windows_gpu_info()
else: # Assume Linux or other else: # Assume Linux or other
return get_linux_gpu_info() return get_linux_gpu_info()
COMMON_RESOLUTIONS = {
# SD
"SD_480p": (640, 480),
"NTSC_DVD": (720, 480),
"PAL_DVD": (720, 576),
# HD
"HD_720p": (1280, 720),
"HD_900p": (1600, 900),
"HD_1080p": (1920, 1080),
# Cinema / Film
"2K_DCI": (2048, 1080),
"4K_DCI": (4096, 2160),
# UHD / Consumer
"UHD_4K": (3840, 2160),
"UHD_5K": (5120, 2880),
"UHD_8K": (7680, 4320),
# Ultrawide / Aspect Variants
"UW_1080p": (2560, 1080),
"UW_1440p": (3440, 1440),
"UW_5K": (5120, 2160),
# Mobile / Social
"VERTICAL_1080x1920": (1080, 1920),
"SQUARE_1080": (1080, 1080),
# Classic / Legacy
"VGA": (640, 480),
"SVGA": (800, 600),
"XGA": (1024, 768),
"WXGA": (1280, 800),
}
COMMON_FRAME_RATES = {
"23.976 (NTSC Film)": 23.976,
"24 (Cinema)": 24.0,
"25 (PAL)": 25.0,
"29.97 (NTSC)": 29.97,
"30": 30.0,
"48 (HFR Film)": 48.0,
"50 (PAL HFR)": 50.0,
"59.94": 59.94,
"60": 60.0,
"72": 72.0,
"90 (VR)": 90.0,
"120": 120.0,
"144 (Gaming)": 144.0,
"240 (HFR)": 240.0,
}