diff --git a/.gitignore b/.gitignore index 3dbfbf8..b5241ef 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ /.github/ *.idea .DS_Store +/venv/ +.env venv/ diff --git a/add_job.py b/add_job.py new file mode 100644 index 0000000..4bd034b --- /dev/null +++ b/add_job.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import argparse +import logging +import os +import socket +import sys +import threading +import time + +from server import start_server +from src.api.serverproxy_manager import ServerProxyManager + +logger = logging.getLogger() + +def main(): + parser = argparse.ArgumentParser( + description="Zordon CLI tool for preparing/submitting a render job", + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + + # Required arguments + parser.add_argument("scene_file", help="Path to the scene file (e.g., .blend, .max, .mp4)") + parser.add_argument("engine", help="Desired render engine", choices=['blender', 'ffmpeg']) + + # Frame range + parser.add_argument("--start", type=int, default=1, help="Start frame") + parser.add_argument("--end", type=int, default=1, help="End frame") + + # Job metadata + parser.add_argument("--name", default=None, help="Job name") + + # Output + parser.add_argument("--output", default="", help="Output path/pattern (e.g., /renders/frame_####.exr)") + + # Target OS and Engine Version + parser.add_argument( + "--os", + choices=["any", "windows", "linux", "macos"], + default="any", + help="Target operating system for render workers" + ) + parser.add_argument( + "--engine-version", + default="latest", + help="Required renderer/engine version number (e.g., '4.2', '5.0')" + ) + + # Optional flags + parser.add_argument("--dry-run", action="store_true", help="Print job details without submitting") + + args = parser.parse_args() + + # Basic validation + if not os.path.exists(args.scene_file): + print(f"Error: Scene file '{args.scene_file}' not found!", file=sys.stderr) + sys.exit(1) + + if args.start > args.end: + print("Error: Start frame cannot be greater than end frame!", file=sys.stderr) + sys.exit(1) + + # Calculate total frames + total_frames = len(range(args.start, args.end + 1)) + job_name = args.name or os.path.basename(args.scene_file) + file_path = os.path.abspath(args.scene_file) + + # Print job summary + print("Render Job Summary:") + print(f" Job Name : {job_name}") + print(f" Scene File : {file_path}") + print(f" Engine : {args.engine}") + print(f" Frames : {args.start}-{args.end} → {total_frames} frames") + print(f" Output Path : {args.output or '(default from scene)'}") + print(f" Target OS : {args.os}") + print(f" Engine Version : {args.engine_version}") + + if args.dry_run: + print("\nDry run complete (no submission performed).") + return + + local_hostname = socket.gethostname() + local_hostname = local_hostname + (".local" if not local_hostname.endswith(".local") else "") + found_proxy = ServerProxyManager.get_proxy_for_hostname(local_hostname) + + is_connected = found_proxy.check_connection() + if not is_connected: + local_server_thread = threading.Thread(target=start_server, args=[True], daemon=True) + local_server_thread.start() + while not is_connected: + # todo: add timeout + # is_connected = found_proxy.check_connection() + time.sleep(1) + + new_job = {"name": job_name, "engine": args.engine} + response = found_proxy.post_job_to_server(file_path, [new_job]) + if response and response.ok: + print(f"Uploaded to {found_proxy.hostname} successfully!") + running_job_data = response.json()[0] + job_id = running_job_data.get('id') + print(f"Job {job_id} Summary:") + print(f" Status : {running_job_data.get('status')}") + print(f" Engine : {running_job_data.get('engine')}-{running_job_data.get('engine_version')}") + + print("\nWaiting for render to complete...") + percent_complete = 0.0 + while percent_complete < 1.0: + # add checks for errors + time.sleep(1) + running_job_data = found_proxy.get_job_info(job_id) + percent_complete = running_job_data['percent_complete'] + sys.stdout.write("\x1b[1A") # Move up 1 + sys.stdout.write("\x1b[0J") # Clear from cursor to end of screen (optional) + print(f"Percent Complete: {percent_complete:.2%}") + sys.stdout.flush() + print("Finished rendering successfully!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/client.spec b/client.spec index f351144..82b6a14 100644 --- a/client.spec +++ b/client.spec @@ -18,7 +18,7 @@ datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] a = Analysis( - ['main.py'], + ['client.py'], pathex=[], binaries=binaries, datas=datas, diff --git a/server.spec b/server.spec new file mode 100644 index 0000000..595138a --- /dev/null +++ b/server.spec @@ -0,0 +1,90 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +# - get version from version file +import os +import sys +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')] +binaries = [] +hiddenimports = ['zeroconf'] +tmp_ret = collect_all('zeroconf') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['server.py'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=1, # fyi: optim level 2 breaks on windows +) +pyz = PYZ(a.pure) + +if platform.system() == 'Windows': + + import pyinstaller_versionfile + import tempfile + + version_file_path = os.path.join(tempfile.gettempdir(), 'versionfile.txt') + + pyinstaller_versionfile.create_versionfile( + output_file=version_file_path, + version=APP_VERSION, + company_name=APP_AUTHOR, + file_description=APP_NAME, + internal_name=APP_NAME, + legal_copyright=f"© {APP_AUTHOR}", + original_filename=f"{APP_NAME}.exe", + 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( + 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 + ) diff --git a/src/api/add_job_helpers.py b/src/api/add_job_helpers.py index 6c42016..84ae2f5 100644 --- a/src/api/add_job_helpers.py +++ b/src/api/add_job_helpers.py @@ -55,7 +55,7 @@ def handle_uploaded_project_files(request, jobs_list, upload_directory): # Prepare the local filepath cleaned_path_name = jobs_list[0].get('name', os.path.splitext(referred_name)[0]).replace(' ', '-') job_dir = os.path.join(upload_directory, '-'.join( - [datetime.now().strftime("%Y.%m.%d_%H.%M.%S"), renderer, cleaned_path_name])) + [datetime.now().strftime("%Y.%m.%d_%H.%M.%S"), engine_name, cleaned_path_name])) os.makedirs(job_dir, exist_ok=True) project_source_dir = os.path.join(job_dir, 'source') os.makedirs(project_source_dir, exist_ok=True) @@ -133,7 +133,7 @@ def process_zipped_project(zip_path): logger.debug(f"Zip files: {project_files}") - # supported_exts = RenderWorkerFactory.class_for_name(renderer).engine.supported_extensions + # supported_exts = RenderWorkerFactory.class_for_name(engine).engine.supported_extensions # if supported_exts: # project_files = [file for file in project_files if any(file.endswith(ext) for ext in supported_exts)] diff --git a/src/api/api_server.py b/src/api/api_server.py index 9ca8bc4..6e09120 100755 --- a/src/api/api_server.py +++ b/src/api/api_server.py @@ -340,8 +340,8 @@ def delete_job(job_id): # Engine Info and Management: # -------------------------------------------- -@server.get('/api/renderer_info') -def renderer_info(): +@server.get('/api/engine_info') +def engine_info(): response_type = request.args.get('response_type', 'standard') if response_type not in ['full', 'standard']: raise ValueError(f"Invalid response_type: {response_type}") @@ -379,19 +379,19 @@ def renderer_info(): return result except Exception as e: - logger.error(f'Error fetching details for {engine.name()} renderer: {e}') + logger.error(f"Error fetching details for engine '{engine.name()}': {e}") raise e - renderer_data = {} + engine_data = {} with concurrent.futures.ThreadPoolExecutor() as executor: futures = {executor.submit(process_engine, engine): engine.name() for engine in EngineManager.supported_engines()} for future in concurrent.futures.as_completed(futures): result = future.result() if result: - renderer_data.update(result) + engine_data.update(result) - return renderer_data + return engine_data @server.get('/api//is_available') @@ -442,22 +442,22 @@ def delete_engine_download(): (f"Error deleting {json_data.get('engine')} {json_data.get('version')}", 500) -@server.get('/api/renderer//args') -def get_renderer_args(renderer): +@server.get('/api/engine//args') +def get_engine_args(engine_name): try: - renderer_engine_class = EngineManager.engine_with_name(renderer) - return renderer_engine_class().get_arguments() + engine_class = EngineManager.engine_with_name(engine_name) + return engine_class().get_arguments() except LookupError: - return f"Cannot find renderer '{renderer}'", 400 + return f"Cannot find engine '{engine_name}'", 400 -@server.get('/api/renderer//help') -def get_renderer_help(renderer): +@server.get('/api/engine//help') +def get_engine_help(engine_name): try: - renderer_engine_class = EngineManager.engine_with_name(renderer) - return renderer_engine_class().get_help() + engine_class = EngineManager.engine_with_name(engine_name) + return engine_class().get_help() except LookupError: - return f"Cannot find renderer '{renderer}'", 400 + return f"Cannot find engine '{engine_name}'", 400 # -------------------------------------------- diff --git a/src/distributed_job_manager.py b/src/distributed_job_manager.py index 342c263..1bfab6e 100644 --- a/src/distributed_job_manager.py +++ b/src/distributed_job_manager.py @@ -156,7 +156,7 @@ class DistributedJobManager: logger.debug(f"New job output path: {output_path}") # create & configure jobs - worker = EngineManager.create_worker(renderer=new_job_attributes['renderer'], + worker = EngineManager.create_worker(engine_name=new_job_attributes['engine'], input_path=loaded_project_local_path, output_path=output_path, engine_version=new_job_attributes.get('engine_version'), @@ -303,14 +303,14 @@ class DistributedJobManager: Args: parent_worker (Worker): The parent job what we're creating the subjobs for. - new_job_attributes (dict): Dict of desired attributes for new job (frame count, renderer, output path, etc) + new_job_attributes (dict): Dict of desired attributes for new job (frame count, engine, output path, etc) project_path (str): The path to the project. system_os (str, optional): Required OS. Default is any. specific_servers (list, optional): List of specific servers to split work between. Defaults to all found. """ # Check availability - available_servers = specific_servers if specific_servers else cls.find_available_servers(parent_worker.renderer, + available_servers = specific_servers if specific_servers else cls.find_available_servers(parent_worker.engine_name, system_os) # skip if theres no external servers found external_servers = [x for x in available_servers if x['hostname'] != parent_worker.hostname] @@ -354,7 +354,7 @@ class DistributedJobManager: subjob['parent'] = f"{parent_worker.id}@{parent_worker.hostname}" subjob['start_frame'] = server_data['frame_range'][0] subjob['end_frame'] = server_data['frame_range'][-1] - subjob['engine_version'] = parent_worker.renderer_version + subjob['engine_version'] = parent_worker.engine_version logger.debug(f"Posting subjob with frames {subjob['start_frame']}-" f"{subjob['end_frame']} to {server_hostname}") post_results = RenderServerProxy(server_hostname).post_job_to_server( diff --git a/src/engines/aerender/aerender_engine.py b/src/engines/aerender/aerender_engine.py index 7e55664..5a8b60f 100644 --- a/src/engines/aerender/aerender_engine.py +++ b/src/engines/aerender/aerender_engine.py @@ -8,7 +8,7 @@ class AERender(BaseRenderEngine): def version(self): version = None try: - render_path = self.renderer_path() + render_path = self.engine_path() if render_path: ver_out = subprocess.check_output([render_path, '-version'], timeout=SUBPROCESS_TIMEOUT) version = ver_out.decode('utf-8').split(" ")[-1].strip() diff --git a/src/engines/blender/blender_engine.py b/src/engines/blender/blender_engine.py index 568bd1a..88e9334 100644 --- a/src/engines/blender/blender_engine.py +++ b/src/engines/blender/blender_engine.py @@ -35,7 +35,7 @@ class Blender(BaseRenderEngine): def version(self): version = None try: - render_path = self.renderer_path() + render_path = self.engine_path() if render_path: ver_out = subprocess.check_output([render_path, '-v'], timeout=SUBPROCESS_TIMEOUT, creationflags=_creationflags) @@ -52,7 +52,7 @@ class Blender(BaseRenderEngine): def run_python_expression(self, project_path, python_expression, timeout=None): if os.path.exists(project_path): try: - return subprocess.run([self.renderer_path(), '-b', project_path, '--python-expr', python_expression], + return subprocess.run([self.engine_path(), '-b', project_path, '--python-expr', python_expression], capture_output=True, timeout=timeout, creationflags=_creationflags) except Exception as e: err_msg = f"Error running python expression in blender: {e}" @@ -69,7 +69,7 @@ class Blender(BaseRenderEngine): raise FileNotFoundError(f'Python script not found: {script_path}') try: - command = [self.renderer_path(), '-b', '--python', script_path] + command = [self.engine_path(), '-b', '--python', script_path] if project_path: command.insert(2, project_path) result = subprocess.run(command, capture_output=True, timeout=timeout, creationflags=_creationflags) @@ -132,7 +132,7 @@ class Blender(BaseRenderEngine): return None def get_arguments(self): - help_text = subprocess.check_output([self.renderer_path(), '-h'], creationflags=_creationflags).decode('utf-8') + help_text = subprocess.check_output([self.engine_path(), '-h'], creationflags=_creationflags).decode('utf-8') lines = help_text.splitlines() options = {} @@ -179,7 +179,7 @@ class Blender(BaseRenderEngine): logger.error("GPU data not found in the output.") def supported_render_engines(self): - engine_output = subprocess.run([self.renderer_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT, + engine_output = subprocess.run([self.engine_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT, 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()] return render_engines diff --git a/src/engines/blender/blender_worker.py b/src/engines/blender/blender_worker.py index e8414f0..2903541 100644 --- a/src/engines/blender/blender_worker.py +++ b/src/engines/blender/blender_worker.py @@ -26,7 +26,7 @@ class BlenderRenderWorker(BaseRenderWorker): def generate_worker_subprocess(self): - cmd = [self.renderer_path] + cmd = [self.engine_path] if self.args.get('background', True): # optionally run render not in background cmd.append('-b') cmd.append(self.input_path) diff --git a/src/engines/core/base_engine.py b/src/engines/core/base_engine.py index ec09133..313a63e 100644 --- a/src/engines/core/base_engine.py +++ b/src/engines/core/base_engine.py @@ -9,7 +9,7 @@ SUBPROCESS_TIMEOUT = 5 class BaseRenderEngine(object): """Base class for render engines. This class provides common functionality and structure for various rendering - engines. Create subclasses and override the methods marked below to add additional renderers + engines. Create subclasses and override the methods marked below to add additional engines Attributes: install_paths (list): A list of default installation paths where the render engine @@ -24,13 +24,13 @@ class BaseRenderEngine(object): # -------------------------------------------- def __init__(self, custom_path=None): - self.custom_renderer_path = custom_path - if not self.renderer_path() or not os.path.exists(self.renderer_path()): - raise FileNotFoundError(f"Cannot find path to renderer for {self.name()} instance: {self.renderer_path()}") + self.custom_engine_path = custom_path + if not self.engine_path() or not os.path.exists(self.engine_path()): + raise FileNotFoundError(f"Cannot find path to engine for {self.name()} instance: {self.engine_path()}") - if not os.access(self.renderer_path(), os.X_OK): - logger.warning(f"Path is not executable. Setting permissions to 755 for {self.renderer_path()}") - os.chmod(self.renderer_path(), 0o755) + if not os.access(self.engine_path(), os.X_OK): + logger.warning(f"Path is not executable. Setting permissions to 755 for {self.engine_path()}") + os.chmod(self.engine_path(), 0o755) def version(self): """Return the version number as a string. @@ -60,7 +60,7 @@ class BaseRenderEngine(object): @classmethod def get_output_formats(cls): - """Returns a list of available output formats supported by the renderer. + """Returns a list of available output formats supported by the engine. Returns: list[str]: A list of strings representing the available output formats. @@ -83,20 +83,20 @@ class BaseRenderEngine(object): return [] def get_help(self): - """Retrieves the help documentation for the renderer. + """Retrieves the help documentation for the engine. - This method runs the renderer's help command (default: '-h') and captures the output. - Override this method if the renderer uses a different help flag. + This method runs the engine's help command (default: '-h') and captures the output. + Override this method if the engine uses a different help flag. Returns: str: The help documentation as a string. Raises: - FileNotFoundError: If the renderer path is not found. + FileNotFoundError: If the engine path is not found. """ - path = self.renderer_path() + path = self.engine_path() if not path: - raise FileNotFoundError("renderer path not found") + raise FileNotFoundError(f"Engine path not found: {path}") creationflags = subprocess.CREATE_NO_WINDOW if platform.system() == 'Windows' else 0 help_doc = subprocess.check_output([path, '-h'], stderr=subprocess.STDOUT, timeout=SUBPROCESS_TIMEOUT, creationflags=creationflags).decode('utf-8') @@ -141,15 +141,15 @@ class BaseRenderEngine(object): # Do Not Override These Methods: # -------------------------------------------- - def renderer_path(self): - return self.custom_renderer_path or self.default_renderer_path() + def engine_path(self): + return self.custom_engine_path or self.default_engine_path() @classmethod def name(cls): return str(cls.__name__).lower() @classmethod - def default_renderer_path(cls): + def default_engine_path(cls): path = None try: # Linux and macOS path = subprocess.check_output(['which', cls.name()], timeout=SUBPROCESS_TIMEOUT).decode('utf-8').strip() diff --git a/src/engines/core/base_worker.py b/src/engines/core/base_worker.py index b2da574..0e00f79 100644 --- a/src/engines/core/base_worker.py +++ b/src/engines/core/base_worker.py @@ -32,9 +32,9 @@ class BaseRenderWorker(Base): date_created = Column(DateTime) start_time = Column(DateTime, nullable=True) end_time = Column(DateTime, nullable=True) - renderer = Column(String) - renderer_version = Column(String) - renderer_path = Column(String) + engine_name = Column(String) + engine_version = Column(String) + engine_path = Column(String) priority = Column(Integer) project_length = Column(Integer) start_frame = Column(Integer) @@ -46,8 +46,6 @@ class BaseRenderWorker(Base): file_hash = Column(String) _status = Column(String) - engine = None - # -------------------------------------------- # Required Overrides for Subclasses: # -------------------------------------------- @@ -57,7 +55,7 @@ class BaseRenderWorker(Base): if not ignore_extensions: if not any(ext in input_path for ext in self.engine.supported_extensions()): - err_meg = f'Cannot find valid project with supported file extension for {self.engine.name()} renderer' + err_meg = f"Cannot find valid project with supported file extension for '{self.engine.name()}'" logger.error(err_meg) raise ValueError(err_meg) if not self.engine: @@ -74,10 +72,10 @@ class BaseRenderWorker(Base): self.output_path = output_path self.args = args or {} self.date_created = datetime.now() - self.renderer = self.engine.name() - self.renderer_path = engine_path - self.renderer_version = self.engine(engine_path).version() - self.custom_renderer_path = None + self.engine_name = self.engine.name() + self.engine_path = engine_path + self.engine_version = self.engine(engine_path).version() + self.custom_engine_path = None self.priority = priority self.parent = parent self.children = {} @@ -116,17 +114,17 @@ class BaseRenderWorker(Base): raise NotImplementedError("generate_worker_subprocess not implemented") def _parse_stdout(self, line): - """Parses a line of standard output from the renderer. + """Parses a line of standard output from the engine. This method should be overridden in a subclass to implement the logic for processing - and interpreting a single line of output from the renderer's standard output stream. + and interpreting a single line of output from the engine's standard output stream. On frame completion, the subclass should: 1. Update value of self.current_frame 2. Call self._send_frame_complete_notification() Args: - line (str): A line of text from the renderer's standard output. + line (str): A line of text from the engine's standard output. Raises: NotImplementedError: If the method is not overridden in a subclass. @@ -152,7 +150,7 @@ class BaseRenderWorker(Base): # -------------------------------------------- def __repr__(self): - return f"" + return f"" @property def total_frames(self): @@ -215,7 +213,7 @@ class BaseRenderWorker(Base): self.errors.append(msg) return - if not os.path.exists(self.renderer_path): + if not os.path.exists(self.engine_path): self.status = RenderStatus.ERROR msg = f'Cannot find render engine path for {self.engine.name()}' logger.error(msg) @@ -269,14 +267,14 @@ class BaseRenderWorker(Base): logger.error(err_msg) self.errors.append(err_msg) - # handle instances where renderer exits ok but doesnt generate files + # handle instances where engine exits ok but doesnt generate files if not return_code and not file_count_has_increased: err_msg = (f"{self.engine.name()} render exited ok, but file count has not increased. " f"Count is still {len(self.file_list())}") log_file.write(f'Error: {err_msg}\n\n') self.errors.append(err_msg) - # only count the attempt as failed if renderer creates no output - reset counter on successful output + # only count the attempt as failed if engine creates no output - reset counter on successful output failed_attempts = 0 if file_count_has_increased else failed_attempts + 1 def __run__wait_for_subjobs(self, logfile): @@ -302,7 +300,7 @@ class BaseRenderWorker(Base): with open(self.log_path(), "a") as log_file: self.log_and_print(f"{self.start_time.isoformat()} - Starting " - f"{self.engine.name()} {self.renderer_version} render job for {self.name} " + f"{self.engine.name()} {self.engine_version} render job for {self.name} " f"({self.input_path})", log_file) log_file.write(f"\n") if not self.children: @@ -493,8 +491,8 @@ class BaseRenderWorker(Base): 'file_hash': self.file_hash, 'percent_complete': self.percent_complete(), 'file_count': len(self.file_list()), - 'renderer': self.renderer, - 'renderer_version': self.renderer_version, + 'engine': self.engine_name, + 'engine_version': self.engine_version, 'errors': getattr(self, 'errors', None), 'start_frame': self.start_frame, 'end_frame': self.end_frame, diff --git a/src/engines/engine_manager.py b/src/engines/engine_manager.py index a5afb35..d59f1e9 100644 --- a/src/engines/engine_manager.py +++ b/src/engines/engine_manager.py @@ -12,7 +12,7 @@ logger = logging.getLogger() class EngineManager: - """Class that manages different versions of installed renderers and handles fetching and downloading new versions, + """Class that manages different versions of installed render engines and handles fetching and downloading new versions, if possible. """ @@ -88,7 +88,7 @@ class EngineManager: 'version': version or 'error', 'system_os': current_system_os(), 'cpu': current_system_cpu(), - 'path': eng.default_renderer_path(), + 'path': eng.default_engine_path(), 'type': 'system' } @@ -96,7 +96,7 @@ class EngineManager: futures = { executor.submit(fetch_engine_details, eng, include_corrupt): eng.name() for eng in cls.supported_engines() - if eng.default_renderer_path() and (not filter_name or filter_name == eng.name()) + if eng.default_engine_path() and (not filter_name or filter_name == eng.name()) } for future in concurrent.futures.as_completed(futures): @@ -240,14 +240,14 @@ class EngineManager: thread.start() @classmethod - def create_worker(cls, renderer, 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(renderer).worker_class() + worker_class = cls.engine_with_name(engine_name).worker_class() # check to make sure we have versions installed - all_versions = cls.all_versions_for_engine(renderer) + all_versions = cls.all_versions_for_engine(engine_name) if not all_versions: - raise FileNotFoundError(f"Cannot find any installed {renderer} engines") + raise FileNotFoundError(f"Cannot find any installed '{engine_name}' engines") # Find the path to the requested engine version or use default engine_path = None @@ -259,9 +259,9 @@ class EngineManager: # Download the required engine if not found locally if not engine_path: - download_result = cls.download_engine(renderer, engine_version) + download_result = cls.download_engine(engine_name, engine_version) if not download_result: - raise FileNotFoundError(f"Cannot download requested version: {renderer} {engine_version}") + raise FileNotFoundError(f"Cannot download requested version: {engine_name} {engine_version}") engine_path = download_result['path'] logger.info("Engine downloaded. Creating worker.") else: diff --git a/src/engines/ffmpeg/ffmpeg_engine.py b/src/engines/ffmpeg/ffmpeg_engine.py index 70e456f..2f9fd2d 100644 --- a/src/engines/ffmpeg/ffmpeg_engine.py +++ b/src/engines/ffmpeg/ffmpeg_engine.py @@ -24,7 +24,7 @@ class FFMPEG(BaseRenderEngine): return FFMPEGUI.get_options(self) def supported_extensions(self): - help_text = (subprocess.check_output([self.renderer_path(), '-h', 'full'], stderr=subprocess.STDOUT, + help_text = (subprocess.check_output([self.engine_path(), '-h', 'full'], stderr=subprocess.STDOUT, creationflags=_creationflags).decode('utf-8')) found = re.findall(r'extensions that .* is allowed to access \(default "(.*)"', help_text) found_extensions = set() @@ -35,7 +35,7 @@ class FFMPEG(BaseRenderEngine): def version(self): version = None try: - ver_out = subprocess.check_output([self.renderer_path(), '-version'], timeout=SUBPROCESS_TIMEOUT, + ver_out = subprocess.check_output([self.engine_path(), '-version'], timeout=SUBPROCESS_TIMEOUT, creationflags=_creationflags).decode('utf-8') match = re.match(r".*version\s*([\w.*]+)\W*", ver_out) if match: @@ -82,7 +82,7 @@ class FFMPEG(BaseRenderEngine): return None def get_encoders(self): - raw_stdout = subprocess.check_output([self.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL, + raw_stdout = subprocess.check_output([self.engine_path(), '-encoders'], stderr=subprocess.DEVNULL, timeout=SUBPROCESS_TIMEOUT, creationflags=_creationflags).decode('utf-8') pattern = r'(?P[VASFXBD.]{6})\s+(?P\S{2,})\s+(?P.*)' encoders = [m.groupdict() for m in re.finditer(pattern, raw_stdout)] @@ -94,7 +94,7 @@ class FFMPEG(BaseRenderEngine): def get_all_formats(self): try: - formats_raw = subprocess.check_output([self.renderer_path(), '-formats'], stderr=subprocess.DEVNULL, + formats_raw = subprocess.check_output([self.engine_path(), '-formats'], stderr=subprocess.DEVNULL, timeout=SUBPROCESS_TIMEOUT, creationflags=_creationflags).decode('utf-8') pattern = r'(?P[DE]{1,2})\s+(?P\S{2,})\s+(?P.*)' @@ -108,7 +108,7 @@ class FFMPEG(BaseRenderEngine): # Extract the common extension using regex muxer_flag = 'muxer' if 'E' in ffmpeg_format['type'] else 'demuxer' format_detail_raw = subprocess.check_output( - [self.renderer_path(), '-hide_banner', '-h', f"{muxer_flag}={ffmpeg_format['id']}"], + [self.engine_path(), '-hide_banner', '-h', f"{muxer_flag}={ffmpeg_format['id']}"], creationflags=_creationflags).decode('utf-8') pattern = r"Common extensions: (\w+)" common_extensions = re.findall(pattern, format_detail_raw) @@ -121,7 +121,7 @@ class FFMPEG(BaseRenderEngine): return [x['id'] for x in self.get_all_formats() if 'E' in x['type'].upper()] def get_frame_count(self, path_to_file): - raw_stdout = subprocess.check_output([self.renderer_path(), '-i', path_to_file, '-map', '0:v:0', '-c', 'copy', + raw_stdout = subprocess.check_output([self.engine_path(), '-i', path_to_file, '-map', '0:v:0', '-c', 'copy', '-f', 'null', '-'], stderr=subprocess.STDOUT, timeout=SUBPROCESS_TIMEOUT, creationflags=_creationflags).decode('utf-8') match = re.findall(r'frame=\s*(\d+)', raw_stdout) @@ -131,7 +131,7 @@ class FFMPEG(BaseRenderEngine): return -1 def get_arguments(self): - help_text = (subprocess.check_output([self.renderer_path(), '-h', 'long'], stderr=subprocess.STDOUT, + help_text = (subprocess.check_output([self.engine_path(), '-h', 'long'], stderr=subprocess.STDOUT, creationflags=_creationflags).decode('utf-8')) lines = help_text.splitlines() diff --git a/src/engines/ffmpeg/ffmpeg_worker.py b/src/engines/ffmpeg/ffmpeg_worker.py index 2d6ed47..089fc58 100644 --- a/src/engines/ffmpeg/ffmpeg_worker.py +++ b/src/engines/ffmpeg/ffmpeg_worker.py @@ -16,7 +16,7 @@ class FFMPEGRenderWorker(BaseRenderWorker): def generate_worker_subprocess(self): - cmd = [self.renderer_path, '-y', '-stats', '-i', self.input_path] + cmd = [self.engine_path, '-y', '-stats', '-i', self.input_path] # Resize frame if self.args.get('x_resolution', None) and self.args.get('y_resolution', None): diff --git a/src/render_queue.py b/src/render_queue.py index c6df222..be99553 100755 --- a/src/render_queue.py +++ b/src/render_queue.py @@ -46,7 +46,7 @@ class RenderQueue: try: not_started = cls.jobs_with_status(RenderStatus.NOT_STARTED, priority_sorted=True) for job in not_started: - if cls.is_available_for_job(job.renderer, job.priority): + if cls.is_available_for_job(job.engine_name, job.priority): cls.start_job(job) scheduled = cls.jobs_with_status(RenderStatus.SCHEDULED, priority_sorted=True) @@ -145,7 +145,7 @@ class RenderQueue: @classmethod def renderer_instances(cls): from collections import Counter - all_instances = [x.renderer for x in cls.running_jobs()] + all_instances = [x.engine_name for x in cls.running_jobs()] return Counter(all_instances) @classmethod diff --git a/src/ui/add_job.py b/src/ui/add_job_window.py similarity index 85% rename from src/ui/add_job.py rename to src/ui/add_job_window.py index ea9eb23..745a9a4 100644 --- a/src/ui/add_job.py +++ b/src/ui/add_job_window.py @@ -14,7 +14,7 @@ from requests import Response from src.api.server_proxy import RenderServerProxy from src.engines.engine_manager import EngineManager -from src.ui.engine_help_viewer import EngineHelpViewer +from src.ui.engine_help_window import EngineHelpViewer from src.utilities.zeroconf_server import ZeroconfServer @@ -24,7 +24,7 @@ class NewRenderJobForm(QWidget): self.notes_group = None self.frame_rate_input = None self.resolution_x_input = None - self.renderer_group = None + self.engine_group = None self.output_settings_group = None self.resolution_y_input = None self.project_path = project_path @@ -34,17 +34,17 @@ class NewRenderJobForm(QWidget): self.load_file_group = None self.current_engine_options = None self.file_format_combo = None - self.renderer_options_layout = None + self.engine_options_layout = None self.cameras_list = None self.cameras_group = None - self.renderer_version_combo = None + self.engine_version_combo = None self.worker_thread = None self.msg_box = None self.engine_help_viewer = None self.raw_args = None self.submit_progress_label = None self.submit_progress = None - self.renderer_type = None + self.engine_type = None self.process_label = None self.process_progress_bar = None self.splitjobs_same_os = None @@ -62,13 +62,13 @@ class NewRenderJobForm(QWidget): # Job / Server Data self.server_proxy = RenderServerProxy(socket.gethostname()) - self.renderer_info = None + self.engine_info = None self.project_info = None # Setup self.setWindowTitle("New Job") self.setup_ui() - self.update_renderer_info() + self.update_engine_info() self.setup_project() # get renderer info in bg thread @@ -182,33 +182,33 @@ class NewRenderJobForm(QWidget): # add group to layout main_layout.addWidget(self.output_settings_group) - # Renderer Group - self.renderer_group = QGroupBox("Renderer Settings") - renderer_group_layout = QVBoxLayout(self.renderer_group) - renderer_layout = QHBoxLayout() - renderer_layout.addWidget(QLabel("Renderer:")) - self.renderer_type = QComboBox() - self.renderer_type.currentIndexChanged.connect(self.renderer_changed) - renderer_layout.addWidget(self.renderer_type) + # Engine Group + self.engine_group = QGroupBox("Engine Settings") + engine_group_layout = QVBoxLayout(self.engine_group) + engine_layout = QHBoxLayout() + engine_layout.addWidget(QLabel("Engine:")) + self.engine_type = QComboBox() + self.engine_type.currentIndexChanged.connect(self.engine_changed) + engine_layout.addWidget(self.engine_type) # Version - renderer_layout.addWidget(QLabel("Version:")) - self.renderer_version_combo = QComboBox() - self.renderer_version_combo.addItem('latest') - renderer_layout.addWidget(self.renderer_version_combo) - renderer_group_layout.addLayout(renderer_layout) + engine_layout.addWidget(QLabel("Version:")) + self.engine_version_combo = QComboBox() + self.engine_version_combo.addItem('latest') + engine_layout.addWidget(self.engine_version_combo) + engine_group_layout.addLayout(engine_layout) # dynamic options - self.renderer_options_layout = QVBoxLayout() - renderer_group_layout.addLayout(self.renderer_options_layout) + self.engine_options_layout = QVBoxLayout() + engine_group_layout.addLayout(self.engine_options_layout) # Raw Args - raw_args_layout = QHBoxLayout(self.renderer_group) + raw_args_layout = QHBoxLayout(self.engine_group) raw_args_layout.addWidget(QLabel("Raw Args:")) self.raw_args = QLineEdit() raw_args_layout.addWidget(self.raw_args) args_help_button = QPushButton("?") args_help_button.clicked.connect(self.args_help_button_clicked) raw_args_layout.addWidget(args_help_button) - renderer_group_layout.addLayout(raw_args_layout) - main_layout.addWidget(self.renderer_group) + engine_group_layout.addLayout(raw_args_layout) + main_layout.addWidget(self.engine_group) # Cameras Group self.cameras_group = QGroupBox("Cameras") @@ -240,28 +240,28 @@ class NewRenderJobForm(QWidget): self.submit_progress_label.setHidden(True) main_layout.addWidget(self.submit_progress_label) - self.toggle_renderer_enablement(False) + self.toggle_engine_enablement(False) - def update_renderer_info(self): - # get the renderer info and add them all to the ui - self.renderer_info = self.server_proxy.get_renderer_info(response_type='full') - self.renderer_type.addItems(self.renderer_info.keys()) - # select the best renderer for the file type + def update_engine_info(self): + # get the engine info and add them all to the ui + self.engine_info = self.server_proxy.get_engine_info(response_type='full') + self.engine_type.addItems(self.engine_info.keys()) + # select the best engine for the file type engine = EngineManager.engine_for_project_path(self.project_path) - self.renderer_type.setCurrentText(engine.name().lower()) + self.engine_type.setCurrentText(engine.name().lower()) # refresh ui - self.renderer_changed() + self.engine_changed() - def renderer_changed(self): + def engine_changed(self): # load the version numbers - current_renderer = self.renderer_type.currentText().lower() or self.renderer_type.itemText(0) - self.renderer_version_combo.clear() - self.renderer_version_combo.addItem('latest') + current_engine = self.engine_type.currentText().lower() or self.engine_type.itemText(0) + self.engine_version_combo.clear() + self.engine_version_combo.addItem('latest') self.file_format_combo.clear() - if current_renderer: - renderer_vers = [version_info['version'] for version_info in self.renderer_info[current_renderer]['versions']] - self.renderer_version_combo.addItems(renderer_vers) - self.file_format_combo.addItems(self.renderer_info[current_renderer]['supported_export_formats']) + if current_engine: + engine_vers = [version_info['version'] for version_info in self.engine_info[current_engine]['versions']] + self.engine_version_combo.addItems(engine_vers) + self.file_format_combo.addItems(self.engine_info[current_engine]['supported_export_formats']) def update_server_list(self): clients = ZeroconfServer.found_hostnames() @@ -278,7 +278,7 @@ class NewRenderJobForm(QWidget): # UI stuff on main thread self.process_progress_bar.setHidden(False) self.process_label.setHidden(False) - self.toggle_renderer_enablement(False) + self.toggle_engine_enablement(False) output_name, _ = os.path.splitext(os.path.basename(self.scene_file_input.text())) output_name = output_name.replace(' ', '_') @@ -296,8 +296,8 @@ class NewRenderJobForm(QWidget): self.render_name_input.setText(directory) def args_help_button_clicked(self): - url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/renderer/' - f'{self.renderer_type.currentText()}/help') + url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/engine/' + f'{self.engine_type.currentText()}/help') self.engine_help_viewer = EngineHelpViewer(url) self.engine_help_viewer.show() @@ -306,20 +306,20 @@ class NewRenderJobForm(QWidget): def post_get_project_info_update(self): """Called by the GetProjectInfoWorker - Do not call directly.""" try: - # Set the best renderer we can find + # Set the best engine we can find input_path = self.scene_file_input.text() engine = EngineManager.engine_for_project_path(input_path) - engine_index = self.renderer_type.findText(engine.name().lower()) + engine_index = self.engine_type.findText(engine.name().lower()) if engine_index >= 0: - self.renderer_type.setCurrentIndex(engine_index) + self.engine_type.setCurrentIndex(engine_index) else: - self.renderer_type.setCurrentIndex(0) #todo: find out why we don't have renderer info yet - # not ideal but if we don't have the renderer info we have to pick something + self.engine_type.setCurrentIndex(0) #todo: find out why we don't have engine info yet + # not ideal but if we don't have the engine info we have to pick something # cleanup progress UI self.load_file_group.setHidden(True) - self.toggle_renderer_enablement(True) + self.toggle_engine_enablement(True) # Load scene data self.start_frame_input.setValue(self.project_info.get('frame_start')) @@ -347,9 +347,9 @@ class NewRenderJobForm(QWidget): self.cameras_group.setHidden(True) # Dynamic Engine Options - clear_layout(self.renderer_options_layout) # clear old options + clear_layout(self.engine_options_layout) # clear old options # dynamically populate option list - system_info = self.renderer_info.get(engine.name(), {}).get('system_info', {}) + 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: h_layout = QHBoxLayout() @@ -363,15 +363,15 @@ class NewRenderJobForm(QWidget): else: text_box = QLineEdit() h_layout.addWidget(text_box) - self.renderer_options_layout.addLayout(h_layout) + self.engine_options_layout.addLayout(h_layout) except AttributeError: pass - def toggle_renderer_enablement(self, enabled=False): + def toggle_engine_enablement(self, enabled=False): """Toggle on/off all the render settings""" self.project_group.setHidden(not enabled) self.output_settings_group.setHidden(not enabled) - self.renderer_group.setHidden(not enabled) + self.engine_group.setHidden(not enabled) self.notes_group.setHidden(not enabled) if not enabled: self.cameras_group.setHidden(True) @@ -386,7 +386,7 @@ class NewRenderJobForm(QWidget): self.submit_progress_label.setHidden(True) self.process_progress_bar.setHidden(True) self.process_label.setHidden(True) - self.toggle_renderer_enablement(True) + self.toggle_engine_enablement(True) self.msg_box = QMessageBox() if not error_string: @@ -450,8 +450,8 @@ class SubmitWorker(QThread): try: hostname = self.window.server_input.currentText() job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(), - 'renderer': self.window.renderer_type.currentText().lower(), - 'engine_version': self.window.renderer_version_combo.currentText(), + 'engine': self.window.engine_type.currentText().lower(), + 'engine_version': self.window.engine_version_combo.currentText(), 'args': {'raw': self.window.raw_args.text(), 'export_format': self.window.file_format_combo.currentText()}, 'output_path': self.window.render_name_input.text(), @@ -464,8 +464,8 @@ class SubmitWorker(QThread): 'name': self.window.render_name_input.text()} # get the dynamic args - for i in range(self.window.renderer_options_layout.count()): - item = self.window.renderer_options_layout.itemAt(i) + for i in range(self.window.engine_options_layout.count()): + item = self.window.engine_options_layout.itemAt(i) layout = item.layout() # get the layout for x in range(layout.count()): z = layout.itemAt(x) @@ -497,7 +497,7 @@ class SubmitWorker(QThread): job_list = [job_json] # presubmission tasks - engine = EngineManager.engine_with_name(self.window.renderer_type.currentText().lower()) + engine = EngineManager.engine_with_name(self.window.engine_type.currentText().lower()) input_path = engine().perform_presubmission_tasks(input_path) # submit err_msg = "" diff --git a/src/ui/console.py b/src/ui/console_window.py similarity index 100% rename from src/ui/console.py rename to src/ui/console_window.py diff --git a/src/ui/engine_browser.py b/src/ui/engine_browser.py index 7e23a29..aba78ed 100644 --- a/src/ui/engine_browser.py +++ b/src/ui/engine_browser.py @@ -93,7 +93,7 @@ class EngineBrowserWindow(QMainWindow): def update_table(self): def update_table_worker(): - raw_server_data = RenderServerProxy(self.hostname).get_renderer_info() + raw_server_data = RenderServerProxy(self.hostname).get_engine_info() if not raw_server_data: return diff --git a/src/ui/engine_help_viewer.py b/src/ui/engine_help_window.py similarity index 100% rename from src/ui/engine_help_viewer.py rename to src/ui/engine_help_window.py diff --git a/src/ui/log_viewer.py b/src/ui/log_window.py similarity index 100% rename from src/ui/log_viewer.py rename to src/ui/log_window.py diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 53415a5..aad2e93 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -22,10 +22,10 @@ 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 import NewRenderJobForm -from src.ui.console import ConsoleWindow +from src.ui.add_job_window import NewRenderJobForm +from src.ui.console_window import ConsoleWindow from src.ui.engine_browser import EngineBrowserWindow -from src.ui.log_viewer import LogViewer +from src.ui.log_window import LogViewer from src.ui.widgets.menubar import MenuBar from src.ui.widgets.proportional_image_label import ProportionalImageLabel from src.ui.widgets.statusbar import StatusBar @@ -306,12 +306,12 @@ class MainWindow(QMainWindow): get_time_elapsed(start_time, end_time) name = job.get('name') or os.path.basename(job.get('input_path', '')) - renderer = f"{job.get('renderer', '')}-{job.get('renderer_version')}" + engine_name = f"{job.get('renderer', '')}-{job.get('renderer_version')}" priority = str(job.get('priority', '')) total_frames = str(job.get('total_frames', '')) date_created_string = iso_datestring_to_formatted_datestring(job['date_created']) - items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(renderer), + items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(engine_name), QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed), QTableWidgetItem(total_frames), QTableWidgetItem(date_created_string)] @@ -395,7 +395,7 @@ class MainWindow(QMainWindow): return [] def refresh_job_headers(self): - self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Renderer", "Priority", "Status", + self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Engine", "Priority", "Status", "Time Elapsed", "Frames", "Date Created"]) self.job_list_view.setColumnHidden(0, True) diff --git a/src/utilities/ffmpeg_helper.py b/src/utilities/ffmpeg_helper.py index cb55f19..ceb52bb 100644 --- a/src/utilities/ffmpeg_helper.py +++ b/src/utilities/ffmpeg_helper.py @@ -4,18 +4,18 @@ from src.engines.ffmpeg.ffmpeg_engine import FFMPEG def image_sequence_to_video(source_glob_pattern, output_path, framerate=24, encoder="prores_ks", profile=4, start_frame=1): - subprocess.run([FFMPEG.default_renderer_path(), "-framerate", str(framerate), "-start_number", + subprocess.run([FFMPEG.default_engine_path(), "-framerate", str(framerate), "-start_number", str(start_frame), "-i", f"{source_glob_pattern}", "-c:v", encoder, "-profile:v", str(profile), '-pix_fmt', 'yuva444p10le', output_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) def save_first_frame(source_path, dest_path, max_width=1280): - subprocess.run([FFMPEG.default_renderer_path(), '-i', source_path, '-vf', f'scale={max_width}:-1', + subprocess.run([FFMPEG.default_engine_path(), '-i', source_path, '-vf', f'scale={max_width}:-1', '-vframes', '1', dest_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) def generate_thumbnail(source_path, dest_path, max_width=240, fps=12): - subprocess.run([FFMPEG.default_renderer_path(), '-i', source_path, '-vf', + subprocess.run([FFMPEG.default_engine_path(), '-i', source_path, '-vf', f"scale={max_width}:trunc(ow/a/2)*2,format=yuv420p", '-r', str(fps), '-c:v', 'libx264', '-preset', 'ultrafast', '-an', dest_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)