From ef4fc0e42ee3479bc8c6809e931185c32fba4fa3 Mon Sep 17 00:00:00 2001 From: Brett Date: Sat, 3 Aug 2024 18:26:56 -0500 Subject: [PATCH] Blender GPU / CPU Render (#81) * Add script to get GPU information from Blender * Change run_python_script to allow it to run without a project file * Simplify run_python_script code * Fix mistake * Add system_info to engine classes and api_server. /api/renderer_info now supports standard and full response modes. * Get full renderer_info response for add job UI * Enable setting specific Blender render_device using args * Add Blender render device options to UI --- src/api/api_server.py | 29 ++++++++--- src/api/server_proxy.py | 5 +- src/engines/blender/blender_engine.py | 49 ++++++++++++------- src/engines/blender/blender_ui.py | 1 + src/engines/blender/blender_worker.py | 38 ++++++++++---- .../blender/scripts/get_system_info.py | 17 +++++++ src/engines/core/base_engine.py | 6 ++- src/ui/add_job.py | 4 +- 8 files changed, 107 insertions(+), 42 deletions(-) create mode 100644 src/engines/blender/scripts/get_system_info.py diff --git a/src/api/api_server.py b/src/api/api_server.py index 1b86530..b9c58d1 100755 --- a/src/api/api_server.py +++ b/src/api/api_server.py @@ -363,6 +363,8 @@ def status(): @server.get('/api/renderer_info') def renderer_info(): + response_type = request.args.get('response_type', 'standard') + def process_engine(engine): try: # Get all installed versions of the engine @@ -373,14 +375,27 @@ def renderer_info(): install_path = system_installed_versions[0]['path'] if system_installed_versions else \ installed_versions[0]['path'] - return { - engine.name(): { - 'is_available': RenderQueue.is_available_for_job(engine.name()), - 'versions': installed_versions, - 'supported_extensions': engine.supported_extensions(), - 'supported_export_formats': engine(install_path).get_output_formats() + en = engine(install_path) + + if response_type == 'full': # Full dataset - Can be slow + return { + en.name(): { + 'is_available': RenderQueue.is_available_for_job(en.name()), + 'versions': installed_versions, + 'supported_extensions': engine.supported_extensions(), + 'supported_export_formats': en.get_output_formats(), + 'system_info': en.system_info() + } } - } + elif response_type == 'standard': # Simpler dataset to reduce response times + return { + en.name(): { + 'is_available': RenderQueue.is_available_for_job(en.name()), + 'versions': installed_versions, + } + } + else: + raise AttributeError(f"Invalid response_type: {response_type}") except Exception as e: logger.error(f'Error fetching details for {engine.name()} renderer: {e}') return {} diff --git a/src/api/server_proxy.py b/src/api/server_proxy.py index 4160dd9..42cff23 100644 --- a/src/api/server_proxy.py +++ b/src/api/server_proxy.py @@ -248,17 +248,18 @@ class RenderServerProxy: # --- Renderer --- # - def get_renderer_info(self, timeout=5): + def get_renderer_info(self, response_type='standard', timeout=5): """ Fetches renderer information from the server. Args: + response_type (str, optional): Returns standard or full version of renderer 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 renderer information. """ - all_data = self.request_data(f'renderer_info', timeout=timeout) + all_data = self.request_data(f"renderer_info?response_type={response_type}", timeout=timeout) return all_data def delete_engine(self, engine, version, system_cpu=None): diff --git a/src/engines/blender/blender_engine.py b/src/engines/blender/blender_engine.py index 018125f..b086c33 100644 --- a/src/engines/blender/blender_engine.py +++ b/src/engines/blender/blender_engine.py @@ -56,25 +56,27 @@ class Blender(BaseRenderEngine): else: raise FileNotFoundError(f'Project file not found: {project_path}') - def run_python_script(self, project_path, script_path, timeout=None): - if os.path.exists(project_path) and os.path.exists(script_path): - try: - return subprocess.run([self.renderer_path(), '-b', project_path, '--python', script_path], - capture_output=True, timeout=timeout) - except Exception as e: - logger.warning(f"Error running python script in blender: {e}") - pass - elif not os.path.exists(project_path): + def run_python_script(self, script_path, project_path=None, timeout=None): + + if project_path and not os.path.exists(project_path): raise FileNotFoundError(f'Project file not found: {project_path}') elif not os.path.exists(script_path): raise FileNotFoundError(f'Python script not found: {script_path}') - raise Exception("Uncaught exception") + + try: + command = [self.renderer_path(), '-b', '--python', script_path] + if project_path: + command.insert(2, project_path) + return subprocess.run(command, capture_output=True, timeout=timeout) + except Exception as e: + logger.exception(f"Error running python script in blender: {e}") def get_project_info(self, project_path, timeout=10): scene_info = {} try: script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_file_info.py') - results = self.run_python_script(project_path, system_safe_path(script_path), timeout=timeout) + results = self.run_python_script(project_path=project_path, script_path=system_safe_path(script_path), + timeout=timeout) result_text = results.stdout.decode() for line in result_text.splitlines(): if line.startswith('SCENE_DATA:'): @@ -92,7 +94,8 @@ class Blender(BaseRenderEngine): try: logger.info(f"Starting to pack Blender file: {project_path}") script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'pack_project.py') - results = self.run_python_script(project_path, system_safe_path(script_path), timeout=timeout) + results = self.run_python_script(project_path=project_path, script_path=system_safe_path(script_path), + timeout=timeout) result_text = results.stdout.decode() dir_name = os.path.dirname(project_path) @@ -144,12 +147,20 @@ class Blender(BaseRenderEngine): return options - def get_detected_gpus(self): - # no longer works on 4.0 - engine_output = subprocess.run([self.renderer_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT, - capture_output=True).stdout.decode('utf-8') - gpu_names = re.findall(r"DETECTED GPU: (.+)", engine_output) - return gpu_names + def system_info(self): + return {'render_devices': self.get_render_devices()} + + def get_render_devices(self): + script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_system_info.py') + results = self.run_python_script(script_path=script_path) + output = results.stdout.decode() + match = re.search(r"GPU DATA:(\[[\s\S]*\])", output) + if match: + gpu_data_json = match.group(1) + gpus_info = json.loads(gpu_data_json) + return gpus_info + else: + 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, @@ -163,5 +174,5 @@ class Blender(BaseRenderEngine): if __name__ == "__main__": - x = Blender.get_detected_gpus() + x = Blender().get_render_devices() print(x) diff --git a/src/engines/blender/blender_ui.py b/src/engines/blender/blender_ui.py index f63a2ec..fd28b70 100644 --- a/src/engines/blender/blender_ui.py +++ b/src/engines/blender/blender_ui.py @@ -4,5 +4,6 @@ class BlenderUI: def get_options(instance): options = [ {'name': 'engine', 'options': instance.supported_render_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 88258a5..1a5db5e 100644 --- a/src/engines/blender/blender_worker.py +++ b/src/engines/blender/blender_worker.py @@ -14,11 +14,6 @@ class BlenderRenderWorker(BaseRenderWorker): def __init__(self, input_path, output_path, engine_path, args=None, parent=None, name=None): super(BlenderRenderWorker, self).__init__(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args, parent=parent, name=name) - # Args - self.blender_engine = self.args.get('engine', 'BLENDER_EEVEE').upper() - self.export_format = self.args.get('export_format', None) or 'JPEG' - self.camera = self.args.get('camera', None) - # Stats self.__frame_percent_complete = 0.0 @@ -36,16 +31,39 @@ class BlenderRenderWorker(BaseRenderWorker): cmd.append('-b') cmd.append(self.input_path) - # Python expressions + # Start Python expressions - # todo: investigate splitting into separate 'setup' script cmd.append('--python-expr') python_exp = 'import bpy; bpy.context.scene.render.use_overwrite = False;' - if self.camera: - python_exp = python_exp + f"bpy.context.scene.camera = bpy.data.objects['{self.camera}'];" - # insert any other python exp checks here + + # 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}'];" + + # Set Render Device (gpu/cpu/any) + blender_engine = self.args.get('engine', 'BLENDER_EEVEE').upper() + if blender_engine == 'CYCLES': + render_device = self.args.get('render_device', 'any').lower() + if render_device not in {'any', 'gpu', 'cpu'}: + raise AttributeError(f"Invalid Cycles render device: {render_device}") + + use_gpu = render_device in {'any', 'gpu'} + use_cpu = render_device in {'any', 'cpu'} + + python_exp = python_exp + ("exec(\"for device in bpy.context.preferences.addons[" + f"'cycles'].preferences.devices: device.use = {use_cpu} if device.type == 'CPU'" + f" else {use_gpu}\")") + + # -- insert any other python exp checks / generators here -- + + # End Python expressions here cmd.append(python_exp) + # Export format + export_format = self.args.get('export_format', None) or 'JPEG' + path_without_ext = os.path.splitext(self.output_path)[0] + "_" - cmd.extend(['-E', self.blender_engine, '-o', path_without_ext, '-F', self.export_format]) + cmd.extend(['-E', blender_engine, '-o', path_without_ext, '-F', export_format]) # set frame range cmd.extend(['-s', self.start_frame, '-e', self.end_frame, '-a']) diff --git a/src/engines/blender/scripts/get_system_info.py b/src/engines/blender/scripts/get_system_info.py new file mode 100644 index 0000000..14bd90d --- /dev/null +++ b/src/engines/blender/scripts/get_system_info.py @@ -0,0 +1,17 @@ +import bpy +import json + +# Ensure Cycles is available +bpy.context.preferences.addons['cycles'].preferences.get_devices() + +# Collect the devices information +devices_info = [] +for device in bpy.context.preferences.addons['cycles'].preferences.devices: + devices_info.append({ + "name": device.name, + "type": device.type, + "use": device.use + }) + +# Print the devices information in JSON format +print("GPU DATA:" + json.dumps(devices_info)) diff --git a/src/engines/core/base_engine.py b/src/engines/core/base_engine.py index c92c593..469e87f 100644 --- a/src/engines/core/base_engine.py +++ b/src/engines/core/base_engine.py @@ -69,8 +69,10 @@ class BaseRenderEngine(object): def get_output_formats(cls): raise NotImplementedError(f"get_output_formats not implemented for {cls.__name__}") - @classmethod - def get_arguments(cls): + def get_arguments(self): + pass + + def system_info(self): pass def perform_presubmission_tasks(self, project_path): diff --git a/src/ui/add_job.py b/src/ui/add_job.py index cf8c566..a2ba65e 100644 --- a/src/ui/add_job.py +++ b/src/ui/add_job.py @@ -243,7 +243,7 @@ class NewRenderJobForm(QWidget): 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() + 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 engine = EngineManager.engine_for_project_path(self.project_path) @@ -353,7 +353,7 @@ class NewRenderJobForm(QWidget): self.current_engine_options = engine().ui_options() for option in self.current_engine_options: h_layout = QHBoxLayout() - label = QLabel(option['name'].capitalize() + ':') + label = QLabel(option['name'].replace('_', ' ').capitalize() + ':') h_layout.addWidget(label) if option.get('options'): combo_box = QComboBox()