diff --git a/src/api/api_server.py b/src/api/api_server.py index 34568e5..d4d96aa 100755 --- a/src/api/api_server.py +++ b/src/api/api_server.py @@ -309,6 +309,12 @@ def add_job_handler(): new_job = DistributedJobManager.create_render_job(processed_job_data, loaded_project_local_path) 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] except Exception as e: logger.exception(f"Error creating render job: {e}") @@ -374,6 +380,26 @@ def delete_job(job_id): # 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') def engine_info(): response_type = request.args.get('response_type', 'standard') @@ -383,7 +409,7 @@ def engine_info(): def process_engine(engine): try: # 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: return None @@ -414,7 +440,7 @@ def engine_info(): except Exception as e: logger.error(f"Error fetching details for engine '{engine.name()}': {e}") - raise e + return {} engine_data = {} with concurrent.futures.ThreadPoolExecutor() as executor: @@ -428,14 +454,69 @@ def engine_info(): return engine_data +@server.get('/api//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//is_available') def is_engine_available(engine_name): return {'engine': engine_name, 'available': RenderQueue.is_available_for_job(engine_name), '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']} +@server.get('/api/engine//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//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') def is_engine_available_to_download(): 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) -@server.get('/api/engine//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//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: # -------------------------------------------- @@ -568,6 +631,15 @@ def handle_detached_instance(_): 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) def handle_general_error(general_error): err_msg = f"Server error: {general_error}" diff --git a/src/api/job_import_handler.py b/src/api/job_import_handler.py index f8dc606..446e088 100644 --- a/src/api/job_import_handler.py +++ b/src/api/job_import_handler.py @@ -43,9 +43,9 @@ class JobImportHandler: raise FileNotFoundError("Cannot find any valid project paths") # 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( - [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) project_source_dir = os.path.join(job_dir, 'source') os.makedirs(project_source_dir, exist_ok=True) diff --git a/src/api/server_proxy.py b/src/api/server_proxy.py index bbf2d7a..b3d7e0b 100644 --- a/src/api/server_proxy.py +++ b/src/api/server_proxy.py @@ -247,16 +247,19 @@ class RenderServerProxy: # Engines: # -------------------------------------------- - def is_engine_available(self, engine_name): - return self.request_data(f'{engine_name}/is_available') + def get_engine_for_filename(self, filename, timeout=5): + response = self.request(f'engine_for_filename?filename={os.path.basename(filename)}', timeout) + return response.text - def get_all_engines(self): - # todo: this doesnt work - return self.request_data('all_engines') + def get_installed_engines(self, timeout=5): + return self.request_data(f'installed_engines', timeout) - 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: 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) 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. 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. system_cpu (str, optional): The system CPU type. Defaults to None. Returns: 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) # -------------------------------------------- diff --git a/src/engines/blender/blender_engine.py b/src/engines/blender/blender_engine.py index 88e9334..303f6c7 100644 --- a/src/engines/blender/blender_engine.py +++ b/src/engines/blender/blender_engine.py @@ -24,10 +24,12 @@ class Blender(BaseRenderEngine): from src.engines.blender.blender_worker import BlenderRenderWorker return BlenderRenderWorker - @staticmethod - def ui_options(system_info): - from src.engines.blender.blender_ui import BlenderUI - return BlenderUI.get_options(system_info) + def ui_options(self): + options = [ + {'name': 'engine', 'options': self.supported_render_engines()}, + {'name': 'render_device', 'options': ['Any', 'GPU', 'CPU']}, + ] + return options def supported_extensions(self): return ['blend'] @@ -117,7 +119,7 @@ class Blender(BaseRenderEngine): # report any missing textures not_found = re.findall("(Unable to pack file, source path .*)\n", result_text) for err in not_found: - logger.error(err) + raise ChildProcessError(err) p = re.compile('Saved to: (.*)\n') match = p.search(result_text) @@ -125,6 +127,7 @@ class Blender(BaseRenderEngine): new_path = os.path.join(dir_name, match.group(1).strip()) logger.info(f'Blender file packed successfully to {new_path}') return new_path + return project_path except Exception as e: msg = f'Error packing .blend file: {e}' logger.error(msg) @@ -164,7 +167,7 @@ class Blender(BaseRenderEngine): return options 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): 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.") 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() 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_ui.py b/src/engines/blender/blender_ui.py deleted file mode 100644 index 100df2a..0000000 --- a/src/engines/blender/blender_ui.py +++ /dev/null @@ -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 diff --git a/src/engines/blender/blender_worker.py b/src/engines/blender/blender_worker.py index 2903541..ecd6374 100644 --- a/src/engines/blender/blender_worker.py +++ b/src/engines/blender/blender_worker.py @@ -18,14 +18,13 @@ class BlenderRenderWorker(BaseRenderWorker): self.__frame_percent_complete = 0.0 # Scene Info - self.scene_info = Blender(engine_path).get_project_info(input_path) - 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.scene_info = {} self.current_frame = -1 def generate_worker_subprocess(self): + self.scene_info = Blender(self.engine_path).get_project_info(self.input_path) + cmd = [self.engine_path] if self.args.get('background', True): # optionally run render not in background cmd.append('-b') @@ -41,10 +40,16 @@ class BlenderRenderWorker(BaseRenderWorker): cmd.append('--python-expr') 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 custom_camera = self.args.get('camera', None) 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) if blender_engine == 'CYCLES': @@ -85,11 +90,15 @@ class BlenderRenderWorker(BaseRenderWorker): def _parse_stdout(self, line): - pattern = re.compile( + cycles_pattern = re.compile( r'Fra:(?P\d*).*Mem:(?P\S+).*Time:(?P