import bpy import os import re import json from .constant import TECH_PASS_KEYWORDS from pathlib import Path # region Manage nodes def real_loc(n): if not n.parent: return n.location return n.location + real_loc(n.parent) def set_file_output_format(fo): """Default fileout format for output file node Get from OUTPUT_RENDER_FILE_FORMAT environment variable else fallback to multilayer EXR """ env_file_format = json.loads(os.environ.get('OUTPUT_RENDER_FILE_FORMAT', '{}')) if not env_file_format: env_file_format = { 'file_format': 'OPEN_EXR_MULTILAYER', 'exr_codec': 'ZIP', 'color_depth': '16', 'color_mode': 'RGBA' } for k, v in env_file_format.items(): setattr(fo.format, k, v) def clear_disconnected(fo): '''Remove unlinked inputs from file output node''' for inp in reversed(fo.inputs): if not inp.is_linked: print(f'Deleting unlinked fileout slot: {inp.name}') fo.inputs.remove(inp) def create_node(type, tree=None, **kargs): '''Get a type, a tree to add in, and optionnaly multiple attribute to set return created node ''' tree = tree or bpy.context.scene.node_tree node = tree.nodes.new(type) for k,v in kargs.items(): setattr(node, k, v) return node def recursive_node_connect_check(l, target_node): '''Get a link and a node return True if link is connected to passed target_node further in the node tree ''' if l.to_node == target_node: return True for o in l.to_node.outputs: for sl in o.links: if recursive_node_connect_check(sl, target_node): return True return False # region create and connect file output def create_and_connect_file_output(node, outputs, file_out, out_name, base_path, out_base, scene, remap_names, file_format, suffix='', color_depth=None, location_offset=(500, 50), base_path_template=None, use_slot_template=False): """Helper function to create file output node and connect it to outputs Args: node: Source node outputs: List of outputs to connect file_out: Existing file output node or None out_name: Name for the file output node base_path: Base path for output files out_base: Base name for output files scene: Blender scene remap_names: Dictionary for remapping output names file_format: Format settings suffix: Optional suffix for paths color_depth: Optional override for color depth location_offset: Offset for the node position Returns: bpy.types.CompositorNode: Created or used file output node """ if not outputs: return None nodes = scene.node_tree.nodes links = scene.node_tree.links fo = file_out if not fo: fo = nodes.get(out_name) if not fo: fo = create_node('CompositorNodeOutputFile', tree=scene.node_tree, location=(real_loc(node)[0] + location_offset[0], real_loc(node)[1] + location_offset[1]), width=600) fo.inputs.remove(fo.inputs[0]) # Remove default image input if file_format: for k, v in file_format.items(): setattr(fo.format, k, v) else: set_file_output_format(fo) if color_depth: fo.format.color_depth = color_depth fo.name = out_name if node.parent: fo.parent = node.parent if base_path_template is not None: fo.base_path = build_path_from_template(base_path_template, node_name=node.name.strip(), node_label=node.label.strip(), scene_name=node.scene.name if node.type == 'R_LAYERS' else None, viewlayer_name=node.layer if node.type == 'R_LAYERS' else None, ) if suffix: if fo.format.file_format == 'OPEN_EXR_MULTILAYER': ## insert suffix right after last slash as folder path_list = re.split('(\\/)', fo.base_path) path_list.insert(-1, suffix) fo.base_path = ''.join(path_list) else: ## Ensure slash separator then add suffix fo.base_path = fo.base_path.rstrip('\\/') + '/' + suffix else: # default behavior without template if base_path: fo.base_path = base_path else: if fo.format.file_format == 'OPEN_EXR_MULTILAYER': fo.base_path = f'//render/{out_base}/{suffix}{out_base}_' else: fo.base_path = f'//render/{out_base}/{suffix}' for o in outputs: if next((l for l in o.links if recursive_node_connect_check(l, fo)), None): continue if (socket_remaps := remap_names.get(node.name)) and (custom_name := socket_remaps.get(o.name)): slot_name = bpy.path.clean_name(custom_name) else: slot_name = bpy.path.clean_name(o.name) fs = fo.file_slots.new('tmp') ls = fo.layer_slots.new('tmp') ls = fo.layer_slots[-1] ls.name = slot_name fs = fo.file_slots[-1] fs.path = f'{slot_name}/{slot_name}_' if use_slot_template: ## Always False (currently never use slot template) fs.path = slot_name out_input = fo.inputs[-1] links.new(o, out_input) clear_disconnected(fo) fo.update() return fo def connect_to_file_output(node_list, file_out=None, base_path='', excludes=None, remap_names=None, file_format=None, split_tech_passes=False, template=None): """Connect selected nodes output to file output(s) if a file output is selected, add intputs on it Args: node_list (list[bpy.types.Nodes,]): Nodes to connect file_out (bpy.types.CompositorNode, optional): File output node to connect to instead of new Defaults to None base_path (str, optional): Directory of images to render. if not passed, will use source node layer name Defaults to ''. file_format (dict, optionnal): converts each dictionary key into a file output format attribute and assigns the corresponding value. Defaults to None. excludes (dict, optionnal): List of output names to exclude {node_name: [outputs,]}. Defaults toNone. remap_names (dict, optionnal): List of output names to remap {node_name: {output_name: new_name}}. split_tech_passes (bool, optional): When True, create a separate file output for technical passes Defaults to False. Returns: list[bpy.types.CompositorNode]: All nodes created. """ scene = bpy.context.scene if not isinstance(node_list, list): node_list = [node_list] node_list = [n for n in node_list if n.type != 'OUTPUT_FILE'] if not node_list: return [] created_nodes = [] excludes = excludes or {} remap_names = remap_names or {} tech_file_format = file_format.copy() if file_format else None if tech_file_format: ## TODO: Set to EXR if provioded is not ERX or EXR multilayer ? # ->> if tech_file_format.get('file_format')) is not in ('OPEN_EXR', 'OPEN_EXR_MULTILAYER'): ... ## Force 32 bit tech_file_format['color_depth'] = '32' ## Enforce a lossless format if provided is not if (codec := tech_file_format.get('exr_codec')) and codec not in ['ZIP','PIZ','RLE','ZIPS']: tech_file_format['exr_codec'] = 'PIZ' for node in node_list: exclusions = excludes.get(node.name) or [] # Get all available outputs excluding those in exclusions all_outputs = [o for o in node.outputs if not o.is_unavailable and o.name not in exclusions] # Base name for output nodes if node.label: out_base = node.label else: out_base = node.name out_base = bpy.path.clean_name(out_base) # Categorize outputs crypto_outputs = [o for o in all_outputs if 'crypto' in o.name.lower()] if split_tech_passes: # Filter tech passes tech_outputs = [o for o in all_outputs if o.name.lower() in TECH_PASS_KEYWORDS] # any(keyword in o.name.lower() for keyword in TECH_PASS_KEYWORDS)] # Regular outputs (excluding crypto and tech passes) regular_outputs = [o for o in all_outputs if o not in crypto_outputs and o not in tech_outputs] else: # If not splitting tech passes, include them with regular outputs regular_outputs = [o for o in all_outputs if o not in crypto_outputs] tech_outputs = [] y_offset = 50 node_margin = 100 # Create and connect regular outputs if regular_outputs: out_name = f'OUT_{out_base}' fo_regular = create_and_connect_file_output( node, regular_outputs, file_out, out_name, base_path, out_base, scene, remap_names, file_format, location_offset=(500, y_offset), base_path_template=template ) if fo_regular and fo_regular not in created_nodes: created_nodes.append(fo_regular) y_offset += -22 * len(regular_outputs) - node_margin # Create and connect tech outputs with 32-bit depth if split_tech_passes is True if tech_outputs: out_name = f'OUT_{out_base}_tech' fo_tech = create_and_connect_file_output( node, tech_outputs, None, out_name, base_path, out_base, scene, remap_names, tech_file_format, suffix='tech/', color_depth='32', location_offset=(500, y_offset), base_path_template=template ) if fo_tech and fo_tech not in created_nodes: created_nodes.append(fo_tech) y_offset -= node_margin y_offset += -22 * len(tech_outputs) # Create and connect crypto outputs with 32-bit depth if crypto_outputs: out_name = f'OUT_{out_base}_cryptos' fo_crypto = create_and_connect_file_output( node, crypto_outputs, None, out_name, base_path, out_base, scene, remap_names, tech_file_format, suffix='cryptos/', color_depth='32', location_offset=(500, y_offset), base_path_template=template ) if fo_crypto and fo_crypto not in created_nodes: created_nodes.append(fo_crypto) return created_nodes # endregion # endregion # region Utilities def get_collection_children_recursive(col, cols=None) -> list: '''return a list of all the child collections and their subcollections in the passed collection''' cols = cols if cols is not None else [] for sub in col.children: if sub not in cols: cols.append(sub) if len(sub.children): cols = get_collection_children_recursive(sub, cols) return cols def set_properties_editor_tab(tab, skip_if_exists=True): '''Take a tab name and apply it to properties editor tab: identifier of the tab, possible name in: ['TOOL', 'SCENE', 'RENDER', 'OUTPUT', 'VIEW_LAYER', 'WORLD', 'COLLECTION', 'OBJECT', 'CONSTRAINT', 'MODIFIER', 'DATA', 'BONE', 'BONE_CONSTRAINT', 'MATERIAL', 'TEXTURE', 'PARTICLES', 'PHYSICS', 'SHADERFX'] skip_if_exists: do nothing if a properties editor is alrteady on this tab ''' if bpy.context.area.type == 'PROPERTIES': bpy.context.area.spaces.active.context = tab return prop_space = None for area in bpy.context.screen.areas: if area.type == 'PROPERTIES': for space in area.spaces: if space.type == 'PROPERTIES': if skip_if_exists and space.context == tab: return if prop_space is None: prop_space = space if prop_space is not None: prop_space.context = tab return 1 def show_and_active_object(obj, make_active=True, select=True, unhide=True): ''' Show the object Disable exclude parent collection collection and select with options Activate and show all parents collections active : Make the object active select : Select the object (independent of active state) unhide : show object in viewport (activate both visibility status) ''' # Activate parents collections # activate_parent_collections(obj, ensure_visible=ensure_collection_visible) if unhide: # Show obj object if obj.hide_viewport: obj.hide_viewport = False if obj.hide_get(): obj.hide_set(False) # Make object Active if make_active: bpy.context.view_layer.objects.active = obj # Select object if select: obj.select_set(True) def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'): '''Show message box with element passed as string or list if _message if a list of lists: if sublist have 2 element: considered a label [text, icon] if sublist have 3 element: considered as an operator [ops_id_name, text, icon] if sublist have 4 element: considered as a property [object, propname, text, icon] ''' def draw(self, context): layout = self.layout for l in _message: if isinstance(l, str): layout.label(text=l) elif len(l) == 2: # label with icon layout.label(text=l[0], icon=l[1]) elif len(l) == 3: # ops layout.operator_context = "INVOKE_DEFAULT" layout.operator(l[0], text=l[1], icon=l[2], emboss=False) # <- highligh the entry elif len(l) == 4: # prop row = layout.row(align=True) row.label(text=l[2], icon=l[3]) row.prop(l[0], l[1], text='') if isinstance(_message, str): _message = [_message] bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon) def get_rightmost_number_in_string(string) -> str: """Get the rightmost number passed string return the number as string, empty string if no number found """ res = re.search(r'(\d+)(?!.*\d)', string) if not res: return '' return res.group(1) def build_path_from_template(template: str, node_name: str=None, node_label: str=None, scene_name: str=None, viewlayer_name: str=None, socket_name: str=None, socket_index: str=None, ) -> str: """Get a name from a template string Possible keyword: {node_name} : name of the node {node_label} : label of the node (fallback to node_name if not set) {scene_name} : name of the source render layer node scene {viewlayer_name} : name of the source render layer viewlayer {socket_name} : name of source the node socket {socket_index} : index of the node in the node tree (010, 020, etc.) ## Info from Blend {blend_name} : name of the blend file {blend_version} : version of the blend file {date} : current date (YYYYMMDD format) {time} : current time (HHMMSS format) """ ## note: socket index is a bad idea because the final order is not decided before execute if not template: return '' # Get blend file info blend_path = bpy.data.filepath blend_name = '' blend_version = '' if blend_path: blend_name = Path(blend_path).stem blend_version = get_rightmost_number_in_string(blend_name) # Get current date and time import datetime now = datetime.datetime.now() current_date = now.strftime("%Y%m%d") current_time = now.strftime("%H%M%S") # Handle node_label to name fallback if "{node_label}" in template: if node_label is None or not node_label: node_label = node_name if socket_index: # Ensure socket_index is formatted as a zero-padded string and increment on base 10 (allow to insert inbetween renders) socket_index = f"{int(socket_index)*10:03d}" def clean_name(name): # Clean names to be file-system safe if name is None: return '' return bpy.path.clean_name(name) # Create a dictionary of all available variables template_vars = { 'node_name': clean_name(node_name), 'node_label': clean_name(node_label), 'socket_name': clean_name(socket_name), 'scene_name': clean_name(scene_name), 'viewlayer_name': clean_name(viewlayer_name), 'socket_index': socket_index or '', 'blend_name': clean_name(blend_name), 'blend_version': blend_version, 'date': current_date, 'time': current_time, } # Apply template using format_map to handle missing keys gracefully try: applied_template = template.format_map(template_vars) except KeyError as e: # If a key is missing, return template with error message # print(f"Template error: Unknown variable {e}") return f"Template error: Unknown {e}" return applied_template # endregion