diff --git a/client/ayon_blender/api/lib.py b/client/ayon_blender/api/lib.py index 701881a1..3008f621 100644 --- a/client/ayon_blender/api/lib.py +++ b/client/ayon_blender/api/lib.py @@ -26,6 +26,11 @@ def load_scripts(paths): It is possible that this function will be changed in future and usage will be based on Blender version. + + This does not work in Blender 5+ due to `bpy_types` being unavailable. But + usually this is not needed for Blender 5+ anyway, because it does allow + better user scripts management through environment variables than older + releases of Blender. """ import bpy_types @@ -131,6 +136,16 @@ def new_paths(): def append_user_scripts(): + """Apply user scripts to Blender. + + This was originally used for early Blender 4 versions due to requiring + AYON to be sources from `BLENDER_USER_SCRIPTS` paths which unfortunately + allowed only a single path, *and* it had the side effect of not loading the + default user scripts anymore. + + In Blender 5+ this is irrelevant and instead additional Script Directories + can be configured and used instead. + """ default_user_prefs = os.path.join( bpy.utils.resource_path('USER'), "scripts", @@ -743,7 +758,7 @@ def search_replace_render_paths(src: str, dest: str) -> bool: changes = True # Base paths for Compositor File Output Nodes - node_tree = bpy.context.scene.node_tree + node_tree = get_scene_node_tree() if node_tree: for node in node_tree.nodes: if node.bl_idname != "CompositorNodeOutputFile": @@ -764,28 +779,27 @@ def search_replace_render_paths(src: str, dest: str) -> bool: return changes -def map_colorspace_name(colorspace: str) -> str: - """ - Map ACES or other colorspace names to Blender's expected colorspace names. - DEPRECATED: This function is deprecated and will be removed in future - versions. Blender 5.0 natively supports ACES colorspaces. - - Args: - colorspace: The original colorspace name +def get_scene_node_tree(ensure_exists=False): + """Return the node tree - Returns: - str: The mapped colorspace name that Blender expects + Arguments: + ensure_exists (bool): When enabled, make sure a compositor node tree is + enabled and set. """ - colorspace_mapping = { - "ACES - ACEScg": "ACEScg", - "ACES - ACES2065-1": "ACES2065-1", - "ACES - sRGB": "sRGB", - "ACES - Rec.709": "Linear Rec.709", - "ACES - Rec.2020": "Linear Rec.2020", - "Linear": "Linear Rec.709", - "sRGB": "sRGB", - "Rec.709": "Rec.1886", - "Rec.2020": "Rec.2020", - } + if get_blender_version() >= (5, 0, 0): + # Blender 5.0+ + if not bpy.context.scene.compositing_node_group and ensure_exists: + # In Blender 5 if no comp node tree is set, create one + tree = bpy.data.node_groups.new("Compositor Nodes", + "CompositorNodeTree") + bpy.context.scene.compositing_node_group = tree + return tree + + return bpy.context.scene.compositing_node_group + else: + # Blender 4.0 and below + if not bpy.context.scene.node_tree and ensure_exists: + # Force enable compositor in Blender 4 + bpy.context.scene.use_nodes = True - return colorspace_mapping.get(colorspace, colorspace) + return bpy.context.scene.node_tree diff --git a/client/ayon_blender/api/pipeline.py b/client/ayon_blender/api/pipeline.py index 54d76dca..1910fc4f 100644 --- a/client/ayon_blender/api/pipeline.py +++ b/client/ayon_blender/api/pipeline.py @@ -186,7 +186,11 @@ def install(): register_loader_plugin_path(str(LOAD_PATH)) register_creator_plugin_path(str(CREATE_PATH)) - lib.append_user_scripts() + if lib.get_blender_version() < (5, 0, 0): + # User script directories had issues in custom management in older + # Blender releases - appending user scripts within AYON was a + # workaround only in-place to solve that issue. + lib.append_user_scripts() lib.set_app_templates_path() register_event_callback("new", on_new) @@ -751,7 +755,7 @@ def ls() -> Iterator: yield parse_container(container) # Compositor nodes are not in `bpy.data` that `lib.lsattr` looks in. - node_tree = bpy.context.scene.node_tree + node_tree = lib.get_scene_node_tree() if node_tree: for node in node_tree.nodes: ayon_prop = node.get(AYON_PROPERTY) diff --git a/client/ayon_blender/api/plugin.py b/client/ayon_blender/api/plugin.py index 43f3cf1c..a3efdb88 100644 --- a/client/ayon_blender/api/plugin.py +++ b/client/ayon_blender/api/plugin.py @@ -32,7 +32,8 @@ ) from .lib import ( imprint, - get_blender_version + get_blender_version, + get_scene_node_tree ) @@ -212,8 +213,9 @@ def cache_instance_data(shared_data): # Consider any node tree objects as well node_tree_objects = [] - if bpy.context.scene.node_tree: - node_tree_objects = bpy.context.scene.node_tree.nodes + node_tree = get_scene_node_tree() + if node_tree: + node_tree_objects = node_tree.nodes for obj_or_col in itertools.chain( ayon_instance_objs, @@ -377,7 +379,8 @@ def remove_instances(self, instances: List[CreatedInstance]): # Remove compositor node elif isinstance(node, bpy.types.CompositorNode): - bpy.context.scene.node_tree.nodes.remove(node) + node_tree = get_scene_node_tree() + node_tree.nodes.remove(node) self._remove_instance_from_context(instance) diff --git a/client/ayon_blender/api/render_lib.py b/client/ayon_blender/api/render_lib.py index 2704f069..8f73c16e 100644 --- a/client/ayon_blender/api/render_lib.py +++ b/client/ayon_blender/api/render_lib.py @@ -46,6 +46,10 @@ def get_renderer(project_settings) -> str: def get_compositing(project_settings) -> bool: """Get whether 'Composite' render is enabled from blender settings.""" + # Blender 5+ does not have the "Composite" node, so it's always False + if lib.get_blender_version() >= (5, 0, 0): + return False + return project_settings["blender"]["RenderSettings"]["compositing"] @@ -54,9 +58,12 @@ def set_render_format(ext: str, multilayer: bool): bpy.context.scene.render.use_file_extension = True image_settings = bpy.context.scene.render.image_settings + if multilayer and lib.get_blender_version() >= (5, 0, 0): + image_settings.media_type = "MULTI_LAYER_IMAGE" + if ext == "exr": - image_settings.file_format = ( - "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") + file_format = "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR" + image_settings.file_format = file_format elif ext == "bmp": image_settings.file_format = "BMP" elif ext == "rgb": @@ -90,10 +97,19 @@ def get_file_format_extension(file_format: str) -> str: return "jpeg" elif file_format == "JPEG2000": return "jp2" - elif file_format == "TARGA": + elif file_format == "TARGA" or file_format == "TARGA_RAW": return "tga" elif file_format == "TIFF": return "tif" + # Blender 5+ + elif file_format == "CINEON": + return "cin" + elif file_format == "DPX": + return "dpx" + elif file_format == "WEBP": + return "webp" + elif file_format == "HDR": + return "hdr" else: raise ValueError(f"Unsupported file format: {file_format}") @@ -230,32 +246,6 @@ def existing_aov_options( return aov_list -def _create_aov_slot( - slots: "bpy.types.RenderSlots", - variant_name: str, - aov_sep: str, - renderpass_name: str, - is_multi_exr: bool, - render_layer: str, -) -> "bpy.types.RenderSlot": - """Add a new render output slot to the slots. - - The slots usually are the file slots of the compositor output node. - The filepath is based on the render layer, variant name and render pass. - - If it's multi-exr, the slot will be named after the render pass only. - - Returns: - The created slot - - """ - filename = ( - f"{render_layer}/" - f"{variant_name}_{render_layer}{aov_sep}{renderpass_name}.####" - ) - return slots.new(renderpass_name if is_multi_exr else filename) - - def get_base_render_output_path( variant_name: str, multi_exr: Optional[bool] = None, @@ -303,16 +293,12 @@ def create_render_node_tree( create render layer nodes for. project_settings (dict): The project settings dictionary. """ - # Set the scene to use the compositor node tree to render - if not bpy.context.scene.use_nodes: - bpy.context.scene.use_nodes = True - aov_sep = get_aov_separator(project_settings) ext = get_image_format(project_settings) multilayer = get_multilayer(project_settings) compositing = get_compositing(project_settings) - tree = bpy.context.scene.node_tree + tree = lib.get_scene_node_tree(ensure_exists=True) comp_composite_type = "CompositorNodeComposite" @@ -330,26 +316,68 @@ def create_render_node_tree( output.name = variant_name output.label = variant_name + # Multi-exr + multi_exr: bool = ext == "exr" and multilayer + blender_version = lib.get_blender_version() + if blender_version >= (5, 0, 0): + output.format.media_type = ( + "MULTI_LAYER_IMAGE" if multi_exr else "IMAGE" + ) # By default, match output format from scene file format image_settings = bpy.context.scene.render.image_settings output.format.file_format = image_settings.file_format - multi_exr: bool = ext == "exr" and multilayer # Define the base path for the File Output node. - output.base_path = get_base_render_output_path( + base_path = get_base_render_output_path( variant_name, project_settings=project_settings ) + if blender_version >= (5, 0, 0): + base_path_dir, base_path_filename = os.path.split(base_path) + if not multi_exr: + base_path_filename += aov_sep + + output.directory = base_path_dir + output.file_name = base_path_filename + slots = output.file_output_items + else: + output.base_path = base_path + slots = output.layer_slots if multi_exr else output.file_slots + + def _create_aov_slot( + renderpass_name: str, + render_layer: str, + socket_type: str = "FLOAT", + ) -> "bpy.types.RenderSlot": + """Add a new render output slot to the slots. + + The slots usually are the file slots of the compositor output node. + The filepath is based on the render layer, variant name and render pass. + + If it's multi-exr, the slot will be named after the render pass only. + + Returns: + The created slot + + """ + if lib.get_blender_version() >= (5, 0, 0): + new_output_item = output.file_output_items.new( + socket_type, renderpass_name + ) + return output.inputs[new_output_item.name] + + filename: str = ( + f"{render_layer}/" + f"{variant_name}_{render_layer}{aov_sep}{renderpass_name}.####" + ) + return slots.new(renderpass_name if multi_exr else filename) - slots = output.layer_slots if multi_exr else output.file_slots slots.clear() # Create a new socket for the Beauty output pass_name = "Beauty" for render_layer_node in render_layer_nodes: render_layer = render_layer_node.layer - slot = _create_aov_slot( - slots, variant_name, aov_sep, pass_name, multi_exr, render_layer - ) + slot = _create_aov_slot(pass_name, render_layer, socket_type="RGBA") tree.links.new(render_layer_node.outputs["Image"], slot) last_found_renderlayer_node = next( @@ -360,9 +388,7 @@ def create_render_node_tree( # with only the one view layer pass_name = "Composite" render_layer = last_found_renderlayer_node.layer - slot = _create_aov_slot( - slots, variant_name, aov_sep, pass_name, multi_exr, render_layer - ) + slot = _create_aov_slot(pass_name, render_layer) # If there's a composite node, we connect its 'Image' input with the # new slot on the output if composite_node: @@ -384,15 +410,17 @@ def create_render_node_tree( if not output_socket.enabled: continue + socket_type: str = "FLOAT" # Only relevant for Blender 5+ + if lib.get_blender_version() >= (5, 0, 0): + socket_type = output_socket.type + if socket_type == "VALUE": + socket_type = "FLOAT" + slot = _create_aov_slot( - slots, - variant_name, - aov_sep, output_socket.name, - multi_exr, render_layer, + socket_type=socket_type ) - tree.links.new(output_socket, slot) return output @@ -423,13 +451,9 @@ def prepare_rendering( view_layers = bpy.context.scene.view_layers set_render_passes(project_settings, renderer, view_layers) - # Ensure compositor nodes are enabled before accessing node tree - if not bpy.context.scene.use_nodes: - bpy.context.scene.use_nodes = True - # Use selected renderlayer nodes, or assume we want a renderlayer node for # each view layer so we retrieve all of them. - node_tree = bpy.context.scene.node_tree + node_tree = lib.get_scene_node_tree(ensure_exists=True) selected_renderlayer_nodes = [] # Check if node_tree is available before accessing nodes @@ -501,7 +525,8 @@ def get_or_create_render_layer_nodes( view_layers: list["bpy.types.ViewLayer"], ) -> set[bpy.types.CompositorNodeRLayers]: """Get existing render layer nodes or create new ones.""" - tree = bpy.context.scene.node_tree + tree = lib.get_scene_node_tree(ensure_exists=True) + view_layer_names: set[str] = { view_layer.name for view_layer in view_layers } diff --git a/client/ayon_blender/hooks/pre_pyside_install.py b/client/ayon_blender/hooks/pre_pyside_install.py index 87a4f5cf..5579802d 100644 --- a/client/ayon_blender/hooks/pre_pyside_install.py +++ b/client/ayon_blender/hooks/pre_pyside_install.py @@ -31,7 +31,7 @@ def execute(self): def inner_execute(self): # Get blender's python directory - version_regex = re.compile(r"^([2-4])\.[0-9]+$") + version_regex = re.compile(r"^([2-5])\.[0-9]+$") platform = system().lower() executable = self.launch_context.executable.executable_path diff --git a/client/ayon_blender/plugins/create/create_render.py b/client/ayon_blender/plugins/create/create_render.py index 0de8cfa1..2fcf0994 100644 --- a/client/ayon_blender/plugins/create/create_render.py +++ b/client/ayon_blender/plugins/create/create_render.py @@ -1,4 +1,5 @@ """Create render.""" +import os import re import bpy @@ -8,6 +9,8 @@ from ayon_core.pipeline.create import CreatedInstance from ayon_blender.api import plugin, lib, render_lib +BLENDER_VERSION = lib.get_blender_version() + def clean_name(name: str) -> str: """Ensure variant name is valid, e.g. strip spaces from name""" @@ -42,7 +45,7 @@ class CreateRender(plugin.BlenderCreator): icon = "eye" def _find_compositor_node_from_create_render_setup(self) -> Optional["bpy.types.CompositorNodeOutputFile"]: - tree = bpy.context.scene.node_tree + tree = lib.get_scene_node_tree() for node in tree.nodes: if ( node.bl_idname == "CompositorNodeOutputFile" @@ -54,9 +57,7 @@ def _find_compositor_node_from_create_render_setup(self) -> Optional["bpy.types. def create( self, product_name: str, instance_data: dict, pre_create_data: dict ): - # Force enable compositor - if not bpy.context.scene.use_nodes: - bpy.context.scene.use_nodes = True + tree = lib.get_scene_node_tree(ensure_exists=True) variant: str = instance_data.get("variant", self.default_variant) @@ -67,21 +68,26 @@ def create( node = render_lib.prepare_rendering(variant_name=variant) else: # Create a Compositor node - tree = bpy.context.scene.node_tree node: bpy.types.CompositorNodeOutputFile = tree.nodes.new( "CompositorNodeOutputFile" ) project_settings = ( self.create_context.get_current_project_settings() ) - node.format.file_format = "OPEN_EXR_MULTILAYER" - node.base_path = render_lib.get_base_render_output_path( + base_path = render_lib.get_base_render_output_path( variant_name=variant, # For now enforce multi-exr here since we are not connecting # any inputs and it at least ensures a full path is set. multi_exr=True, project_settings=project_settings, ) + node.format.file_format = "OPEN_EXR_MULTILAYER" + if BLENDER_VERSION >= (5, 0, 0): + directory, filename = os.path.split(base_path) + node.directory = directory + node.file_name = filename + else: + node.base_path = base_path node.name = variant node.label = variant @@ -98,8 +104,9 @@ def create( return instance def collect_instances(self): - if not bpy.context.scene.use_nodes: - # Compositor is not enabled, so no render instances should be found + node_tree = lib.get_scene_node_tree() + if not node_tree: + # Blender 5.0 may not have created and set a compositor group return super().collect_instances() @@ -144,7 +151,7 @@ def collect_instances(self): # Collect all remaining compositor output nodes unregistered_output_nodes = [ - node for node in bpy.context.scene.node_tree.nodes + node for node in node_tree.nodes if node.bl_idname == "CompositorNodeOutputFile" and node not in collected_nodes ] diff --git a/client/ayon_blender/plugins/load/load_blendscene.py b/client/ayon_blender/plugins/load/load_blendscene.py index 0476222c..d9c0d756 100644 --- a/client/ayon_blender/plugins/load/load_blendscene.py +++ b/client/ayon_blender/plugins/load/load_blendscene.py @@ -57,7 +57,15 @@ def _process_data(self, libpath, group_name, product_type): members = [] for attr in dir(data_to): from_names: list[str] = names_by_attr[attr] - for from_name, data in zip(from_names, getattr(data_to, attr)): + values = getattr(data_to, attr) + + # Blender 5.0 also has `version` (tuple) and `Done` (bool) + # attributes on the data that we do not want to touch + # TODO: Find a more reliable way to find the right attributes + if not isinstance(values, list): + continue + + for from_name, data in zip(from_names, values): data.name = f"{group_name}:{from_name}" members.append(data) diff --git a/client/ayon_blender/plugins/load/load_image_compositor.py b/client/ayon_blender/plugins/load/load_image_compositor.py index 669a2e7b..3c8f985a 100644 --- a/client/ayon_blender/plugins/load/load_image_compositor.py +++ b/client/ayon_blender/plugins/load/load_image_compositor.py @@ -29,19 +29,13 @@ def process_asset( context: Full parenthood of representation to load options: Additional settings dictionary """ - path = self.filepath_from_context(context) - - # Enable nodes to ensure they can be loaded - if not bpy.context.scene.use_nodes: - self.log.info("Enabling 'use nodes' for Compositor") - bpy.context.scene.use_nodes = True + # Get the scene's compositor node tree + node_tree = lib.get_scene_node_tree(ensure_exists=True) # Load the image in data + path = self.filepath_from_context(context) image = bpy.data.images.load(path, check_existing=True) - # Get the current scene's compositor node tree - node_tree = bpy.context.scene.node_tree - # Create a new image node img_comp_node = node_tree.nodes.new(type='CompositorNodeImage') img_comp_node.image = image @@ -66,7 +60,8 @@ def exec_remove(self, container: Dict) -> bool: image: Optional[bpy.types.Image] = img_comp_node.image # Delete the compositor node - bpy.context.scene.node_tree.nodes.remove(img_comp_node) + node_tree = lib.get_scene_node_tree() + node_tree.nodes.remove(img_comp_node) # Delete the image if it remains unused self.remove_image_if_unused(image) @@ -142,7 +137,13 @@ def set_source_and_colorspace( if colorspace_data: colorspace: str = colorspace_data["colorspace"] if colorspace: - image.colorspace_settings.name = colorspace + try: + image.colorspace_settings.name = colorspace + except TypeError as exc: + self.log.warning( + f"Colorspace '{colorspace}' not found in " + f"current color management. See:\n{exc}" + ) def remove_image_if_unused(self, image: bpy.types.Image): if image and not image.users: diff --git a/client/ayon_blender/plugins/publish/collect_render.py b/client/ayon_blender/plugins/publish/collect_render.py index cbeb49ef..0de89ce7 100644 --- a/client/ayon_blender/plugins/publish/collect_render.py +++ b/client/ayon_blender/plugins/publish/collect_render.py @@ -8,7 +8,7 @@ import bpy -from ayon_blender.api import colorspace, plugin, render_lib +from ayon_blender.api import colorspace, plugin, lib, render_lib def files_as_sequence(files) -> list[str]: @@ -84,24 +84,11 @@ def process(self, instance: pyblish.api.Instance): review: bool = instance.data["creator_attributes"].get("review", False) expected_files: dict[str, list[str]] = {} - output_paths = self.get_expected_outputs(comp_output_node) - is_multilayer = self.is_multilayer_exr(comp_output_node) - - for output_path in output_paths: - if is_multilayer: - # Only ever a single output - we enforce the identifier to an - # empty string to have it considered to not split into a - # subname for the product - aov_identifier = "" - else: - aov_identifier = self.get_aov_identifier( - output_path, - instance - ) - + outputs = self.get_expected_outputs(comp_output_node, instance) + for aov_identifier, output_path in outputs.items(): aov_label = aov_identifier or "" self.log.debug( - f"Expecting outputs for AOV {aov_label}: " + f"AOV '{aov_label}': " f"{output_path}" ) @@ -122,7 +109,7 @@ def process(self, instance: pyblish.api.Instance): "fps": context.data["fps"], "byFrameStep": frame_step, "review": review, - "multipartExr": is_multilayer, + "multipartExr": self.is_multilayer_exr(comp_output_node), "farm": True, "expectedFiles": [expected_files], "renderProducts": colorspace.ARenderProduct( @@ -178,8 +165,9 @@ def is_multilayer_exr( def get_expected_outputs( self, - node: "bpy.types.CompositorNodeOutputFile" - ) -> list[str]: + node: "bpy.types.CompositorNodeOutputFile", + instance: pyblish.api.Instance + ) -> dict[str, str]: """Return the expected output files from a compositor node output file. The output paths are **not** converted to individual frames and will @@ -191,9 +179,72 @@ def get_expected_outputs( paths do and qualify as a full path with `#` as padding frame tokens. Returns: - list[str]: The full output image or sequence paths. + dict[str]: The full output image or sequence paths per identifier. """ + # Blender 5 + if lib.get_blender_version() >= (5, 0, 0): + return self._get_expected_outputs_blender_5(node) + + # Blender 4 + output_paths = self._get_expected_outputs_blender_4(node) + is_multilayer = self.is_multilayer_exr(node) + outputs_per_aov = {} + for output_path in output_paths: + if is_multilayer: + # Only ever a single output - we enforce the identifier to an + # empty string to have it considered to not split into a + # subname for the product + aov_identifier = "" + else: + aov_identifier = self.get_aov_identifier( + output_path, + instance + ) + outputs_per_aov[aov_identifier] = output_path + return outputs_per_aov + + def _get_expected_outputs_blender_5( + self, + node: "bpy.types.CompositorNodeOutputFile" + ) -> dict[str, str]: + """Return output filepaths for CompositorNodeOutputFile in Blender 5""" + directory: str = node.directory + file_name: str = node.file_name + outputs: dict[str, str] = {} + base_path: str = os.path.join(directory, file_name) + + if self.is_multilayer_exr(node): + file_path = self._resolve_full_render_path( + path=base_path, + file_format=node.format.file_format + ) + outputs[""] = file_path # beauty only + else: + # Separate images + for output_item in node.file_output_items: + if output_item.override_node_format: + output_format = output_item.format.file_format + else: + output_format = node.format.file_format + + # Resolve the full render path for the output path + file_path = self._resolve_full_render_path( + path=f"{base_path}{output_item.name}", + file_format=output_format + ) + + # Use the output item name as AOV identifier but remove any + # special characters like `#`, `_`, `.` and spaces. + aov_identifier: str = re.sub("[#_. ]", "", output_item.name) + outputs[aov_identifier] = file_path + return outputs + + def _get_expected_outputs_blender_4( + self, + node: "bpy.types.CompositorNodeOutputFile" + ) -> list[str]: + """Return output filepaths for CompositorNodeOutputFile in Blender 4""" outputs: list[str] = [] base_path: str = node.base_path @@ -232,7 +283,6 @@ def get_expected_outputs( ) outputs.append(file_path) - return outputs def _resolve_full_render_path( @@ -332,6 +382,4 @@ def get_aov_identifier( f"{aov_identifier}" ) aov_identifier = aov_identifier.removeprefix(variant_prefix) - - self.log.info(f"'{aov_identifier}' AOV from filepath: {path}") return aov_identifier diff --git a/client/ayon_blender/plugins/publish/extract_usd.py b/client/ayon_blender/plugins/publish/extract_usd.py index 58d4c550..fcff5eec 100644 --- a/client/ayon_blender/plugins/publish/extract_usd.py +++ b/client/ayon_blender/plugins/publish/extract_usd.py @@ -64,7 +64,9 @@ def process(self, instance): "export_global_forward_selection": attribute_values.get("forward_axis", "Z"), "export_global_up_selection": attribute_values.get("up_axis", "Y"), } - if lib.get_blender_version() < (4, 2, 1): + + blender_version = lib.get_blender_version() + if blender_version < (4, 2, 1): kwargs = {} if convert_orientation: self.log.warning( @@ -73,6 +75,12 @@ def process(self, instance): "4.2.1 to support it." ) + # See: https://docs.blender.org/api/current/bpy.ops.wm.html#bpy.ops.wm.usd_export # noqa + if blender_version >= (5, 0, 0): + kwargs["export_textures_mode"] = "KEEP" + else: + kwargs["export_textures"] = False + # Export USD with bpy.context.temp_override(**context): bpy.ops.wm.usd_export( @@ -81,7 +89,6 @@ def process(self, instance): filepath=filepath, root_prim_path="", selected_objects_only=True, - export_textures=False, relative_paths=False, export_animation=False, export_hair=False, diff --git a/client/ayon_blender/plugins/publish/validate_render_output_paths.py b/client/ayon_blender/plugins/publish/validate_render_output_paths.py index ecc4e479..93afa338 100644 --- a/client/ayon_blender/plugins/publish/validate_render_output_paths.py +++ b/client/ayon_blender/plugins/publish/validate_render_output_paths.py @@ -13,7 +13,7 @@ PublishValidationError, OptionalPyblishPluginMixin ) -from ayon_blender.api import plugin, render_lib +from ayon_blender.api import plugin, lib, render_lib def fix_filename(path: str, extension: Optional[str] = None) -> str: @@ -241,6 +241,19 @@ def get_invalid(cls, instance) -> Optional[str]: for _aov, output_files in expected_files.items(): first_file = output_files[0] + if workfile_filename_no_ext not in first_file: + return ( + "Render output does not include workfile name: " + f"{workfile_filename_no_ext}.\n\n" + "Use Repair action to fix the render base filepath." + ) + + # Requirements below are only valid for Blender 4 and below + # because Blender 5+ does not have a decent place to put the + # frame indicator and extensions for non-multilayer outputs + if lib.get_blender_version() >= (5, 0, 0): + continue + # Ensure filename ends with `.{frame}.{ext}` by checking whether file_no_ext = os.path.splitext(first_file)[0] if not file_no_ext[-1].isdigit(): @@ -265,13 +278,6 @@ def get_invalid(cls, instance) -> Optional[str]: "frame number." ) - if workfile_filename_no_ext not in first_file: - return ( - "Render output does not include workfile name: " - f"{workfile_filename_no_ext}.\n\n" - "Use Repair action to fix the render base filepath." - ) - return None @classmethod @@ -280,7 +286,30 @@ def repair(cls, instance): output_node: "bpy.types.CompositorNodeOutputFile" = ( instance.data["transientData"]["instance_node"] ) + # See: https://developer.blender.org/docs/release_notes/5.0/python_api/#nodes # noqa + if lib.get_blender_version() >= (5, 0, 0): + cls._repair_blender_5(output_node) + else: + cls._repair_blender_4(output_node) + + @classmethod + def _repair_blender_5( + cls, + output_node: "bpy.types.CompositorNodeOutputFile" + ): + # Ensure a directory is included that matches the current filename + blend_file: str = os.path.basename(bpy.data.filepath) + blend_file = os.path.splitext(blend_file)[0] + orig_output_path = output_node.directory + output_node_dir = os.path.dirname(orig_output_path) + new_output_dir = os.path.join(output_node_dir, blend_file) + output_node.directory = new_output_dir + @classmethod + def _repair_blender_4( + cls, + output_node: "bpy.types.CompositorNodeOutputFile" + ): # Check whether CompositorNodeOutputFile is rendering to multilayer EXR file_format: str = output_node.format.file_format is_multilayer: bool = file_format == "OPEN_EXR_MULTILAYER" @@ -325,7 +354,7 @@ def repair(cls, instance): @staticmethod def get_description(): - return inspect.cleandoc(""" + doc = inspect.cleandoc(""" ### Compositor Output Filepaths Invalid The Output File node in the Compositor has invalid output paths. @@ -334,9 +363,15 @@ def get_description(): - Include the workfile name in the output path, this is to ensure unique render paths for each workfile version. - - - End with `.####.{ext}`. It is allowed to specify no extension and - frame tokens at all. As such, `filename.` is valid, because if frame - number and extension are missing Blender will automatically append - them. """) + + if lib.get_blender_version() < (5, 0, 0): + doc += "\n" + inspect.cleandoc(""" + - End with `.####.{ext}`. It is allowed to specify no extension + and frame tokens at all. As such, `filename.` is valid, because + if frame number and extension are missing Blender will + automatically append them. + """) + + return doc + diff --git a/server/settings/render_settings.py b/server/settings/render_settings.py index 4fdda173..c027df3a 100644 --- a/server/settings/render_settings.py +++ b/server/settings/render_settings.py @@ -132,7 +132,11 @@ class RenderSettingsModel(BaseSettingsModel): ) compositing: bool = SettingsField( title="Enable Compositing", - description="When enabled AYON will output composite AOV beside regular rgba beauty output.", + description=( + "When enabled AYON will output composite AOV beside regular rgba " + "beauty output. This is only supported for Blender 4 and lower " + "due to removal of the 'Composite' node in Blender 5." + ), ) aov_list: list[str] = SettingsField( default_factory=list,