From 3ed6605523b3921835f6c046779c430e2cf07b6f Mon Sep 17 00:00:00 2001 From: pullusb Date: Wed, 16 Jul 2025 14:54:10 +0200 Subject: [PATCH] refactor to prepare for more operator files --- __init__.py | 15 +- operators/__init__.py | 17 + .../outputs_search_and_replace.py | 4 +- operators/outputs_setup.py | 421 ++++++++++++++++++ 4 files changed, 445 insertions(+), 12 deletions(-) create mode 100755 operators/__init__.py rename outputs_search_and_replace.py => operators/outputs_search_and_replace.py (99%) create mode 100755 operators/outputs_setup.py diff --git a/__init__.py b/__init__.py index 9dcc6d8..5e94031 100755 --- a/__init__.py +++ b/__init__.py @@ -11,26 +11,21 @@ bl_info = { "category": "Render" } -from . import setup_outputs -from . import outputs_search_and_replace +from . import operators from . import ui -bl_modules = ( - setup_outputs, - outputs_search_and_replace, +modules = ( + operators, ui, # prefs, ) -import bpy - def register(): - for mod in bl_modules: + for mod in modules: mod.register() - def unregister(): - for mod in reversed(bl_modules): + for mod in reversed(modules): mod.unregister() if __name__ == "__main__": diff --git a/operators/__init__.py b/operators/__init__.py new file mode 100755 index 0000000..cd864f4 --- /dev/null +++ b/operators/__init__.py @@ -0,0 +1,17 @@ +from . import ( + outputs_setup, + outputs_search_and_replace, +) + +mods = ( + outputs_setup, + outputs_search_and_replace, +) + +def register(): + for mod in mods: + mod.register() + +def unregister(): + for mod in reversed(mods): + mod.unregister() diff --git a/outputs_search_and_replace.py b/operators/outputs_search_and_replace.py similarity index 99% rename from outputs_search_and_replace.py rename to operators/outputs_search_and_replace.py index 980e970..28ebd61 100644 --- a/outputs_search_and_replace.py +++ b/operators/outputs_search_and_replace.py @@ -1,13 +1,13 @@ import bpy import os import re -from . import fn +from .. import fn from bpy.props import (StringProperty, BoolProperty, EnumProperty, CollectionProperty) -from .constant import TECH_PASS_KEYWORDS +from ..constant import TECH_PASS_KEYWORDS class RT_OT_outputs_search_and_replace(bpy.types.Operator): diff --git a/operators/outputs_setup.py b/operators/outputs_setup.py new file mode 100755 index 0000000..789e0eb --- /dev/null +++ b/operators/outputs_setup.py @@ -0,0 +1,421 @@ +import bpy +import os +import re +from .. import fn +from bpy.props import (StringProperty, + BoolProperty, + EnumProperty, + CollectionProperty) + +from ..constant import TECH_PASS_KEYWORDS + + +# region Search and replace +## -- Search and replace to batch rename item in collection property + +class RT_OT_colprop_search_and_replace(bpy.types.Operator): + bl_idname = "rt.colprop_search_and_replace" + bl_label = "Search And Replace" + bl_description = "Search/Replace texts" + bl_options = {"REGISTER", "INTERNAL"} + + ## target to affect + data_path: StringProperty(name="Data Path", description="Path to collection prop to affect", default="") + target_prop: StringProperty(name="Target Prop", description="Name of the property to affect in whole collection", default="") + + ## Search and replace options + find: StringProperty(name="Find", description="Name to replace", default="", maxlen=0, options={'HIDDEN'}, subtype='NONE') + replace: StringProperty(name="Repl", description="New name placed", default="", maxlen=0, options={'HIDDEN'}, subtype='NONE') + prefix: BoolProperty(name="Prefix Only", description="Affect only prefix of name (skipping names without separator)", default=False) + use_regex: BoolProperty(name="Regex", description="Use regular expression (advanced), equivalent to python re.sub()", default=False) + + separator: StringProperty(name="Separator", description="Separator to get prefix", default='_') + # selected: BoolProperty(name="Selected Only", description="Affect only selection", default=False) + + def rename(self, source): + if not self.find: + return + + old = source + if self.use_regex: + new = re.sub(self.find, self.replace, source) + if old != new: + return new + return + + if self.prefix: + if not self.separator in source: + # Only if separator exists + return + splited = source.split(self.separator) + prefix = splited[0] + new_prefix = prefix.replace(self.find, self.replace) + if prefix != new_prefix: + splited[0] = new_prefix + return self.separator.join(splited) + + else: + new = source.replace(self.find, self.replace) + if old != new: + return new + + def execute(self, context): + ## Get the collection prop from data path + collection_prop = eval(self.data_path) + + count = 0 + for item in collection_prop: + ## Get the target prop + name = getattr(item, self.target_prop) + # prop = prop.replace(self.find, self.replace) + new = self.rename(name) + + if new is None or name == new: + continue + + ## Rename in case of difference + print(f'rename: {name} --> {new}') + setattr(item, self.target_prop, new) + count += 1 + + if count: + mess = str(count) + ' rename(s)' + self.report({'INFO'}, mess) + else: + self.report({'WARNING'}, 'Nothing changed') + return{'FINISHED'} + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + row = layout.row() + # row_a= row.row() + # row_a.prop(self, "separator") + # # row_a.prop(self, "selected") + + row_b= row.row() + row_b.prop(self, "prefix") + row_c= row.row() + + row_c.prop(self, "use_regex") + # row_a.active = not self.use_regex + row_b.active = not self.use_regex + + layout.prop(self, "find") + layout.prop(self, "replace") + +# endregion + +# region Create file output + +## -- properties and operator for file output connect + +class RT_PG_selectable_prop(bpy.types.PropertyGroup): + node_name: StringProperty(name="Node Name") + node_label: StringProperty(name="Node Label") + name: StringProperty(name="Name or Path") + socket_name: StringProperty(name="Source socket Name") # Source socket name as reference + select: BoolProperty(name="Selected", default=True) + is_linked: BoolProperty(name="Linked", default=False) + # is_valid: BoolProperty(name="Valid", default=True) + + ## extra output naming options + name_from_node: StringProperty(name="Name From Node") + name_from_node_and_socket: StringProperty(name="Name From Node And Socket") + name_from_node_and_socket_with_scene: StringProperty(name="Name From Node And Socket With Scene prefixed") + +class RT_OT_create_output_layers(bpy.types.Operator): + bl_idname = "rt.create_output_layers" + bl_label = "Create Output Layers" + bl_description = "Connect Selected Nodes to a new file output node\ + \nIf an existing file output node is selected, socket are added to it" + bl_options = {"REGISTER", "UNDO"} + + ## ! collection prop -> Now stored on window manager at invoke + # socket_collection : CollectionProperty(type=RT_PG_selectable_prop) + + show_custom_settings : BoolProperty( + name='Settings', + default=False) + + split_tech_passes : BoolProperty( + name='Separate Tech Passes', + default=True, + description='Create a separate file output for technical passes (32 bit)') + + ## enum choice for naming: socket_name, node_name, node_and_socket_name, + name_type : EnumProperty( + name='Output Name From', + description='Choose the output name\ + \nNode name use Label (Use node name when there is no Label)', + default='node_and_socket_name', + items=( + ('node_and_socket_name', 'Node Socket Name', 'Use the node name prefix and socket name', 0), + ('node_and_socket_name_with_scene', 'Scene + Node Socket Name', 'Use the node name prefix and socket name, prefix scene with render layer nodes', 1), + ('socket_name', 'Socket Name', 'Use the socket name as output name', 2), + ('node_name', 'Node Name', 'Use the node name as output name', 3), + ) + ) + + # prefix_with_node_name : BoolProperty( + # name='Prefix With Node Name', + # description='Add the node name as prefix to the output name', + # default=False) + + base_path : StringProperty( + name='Custom base path', + default='', + description='Set the base path of created file_output (not if already exists)') + + file_format : EnumProperty( + name='Output Format', + default='NONE', + items=( + ('NONE', 'Default', 'Use default settings'), + ('OPEN_EXR_MULTILAYER', 'OpenEXR MultiLayer', 'Output image in multilayer OpenEXR format'), + ('OPEN_EXR', 'OpenEXR', 'Output image in OpenEXR format'), + ) + ) + + exr_codec : EnumProperty( + name='Codec', + default='PIZ', + description='Codec settings for OpenEXR', + items=( + ('PIZ', 'PIZ (lossless)', ''), + ('ZIP', 'ZIP (lossless)', ''), + ('RLE', 'RLE (lossless)', ''), + ('ZIPS', 'ZIPS (lossless)', ''), + ('PXR24', 'Pxr24 (lossy)', ''), + ('B44', 'B44 (lossy)', ''), + ('B44A', 'B44A (lossy)', ''), + ('DWAA', 'DWAA (lossy)', ''), + ('DWAB', 'DWAB (lossy)', ''), + ), + ) + + color_depth : EnumProperty( + name='Color Depth', + default='16', + description='Bit depth per channel', + items=( + # ('8', '8', '8-bit color channels'), + # ('10', '10', '10-bit color channels'), + # ('12', '12', '12-bit color channels'), + ('16', '16 (Half)', '16-bit color channels'), + ('32', '32 (Full)', '32-bit color channels'), + ), + ) + + def invoke(self, context, event): + if not hasattr(context.window_manager, 'rt_socket_collection'): + ## Create collection prop on window manager if not existing + ## (not stored in self anymore, so that other operators can access it, ex: search and replace) + bpy.types.WindowManager.rt_socket_collection = CollectionProperty(type=RT_PG_selectable_prop) + self.socket_collection = context.window_manager.rt_socket_collection + + self.socket_collection.clear() + if event.ctrl: + # Direct connect, do not use any options + self.base_path = '' + return self.execute(context) + + selected = [n for n in context.scene.node_tree.nodes if n.select and n.type != 'OUTPUT_FILE'] + if not selected: + self.report({'ERROR'}, 'No render layer nodes selected') + return {'CANCELLED'} + for n in selected: + for o in n.outputs: + if o.is_unavailable: + continue + item = self.socket_collection.add() + item.node_name = n.name + item.node_label = n.label.strip() + item.socket_name = o.name + + ## Set editable names + item.name = o.name + + ## Store other naming options (cleaned at exec with bpy.path.clean_name) + node_name = n.label.strip() if n.label.strip() else n.name + scene_node_name = node_name # same by default + + if n.type == 'R_LAYERS': + scene_node_name = f'{n.scene.name}_{n.layer}' + + ## Change node_name for render layers: scene_viewlayer_name + if n.type == 'R_LAYERS' and node_name != n.label: # skip if a label is set + node_name = f'{n.layer}' + + item.name_from_node = node_name + + if len(n.outputs) == 1: + ## Only one output, just pick node name, no need to add socket name + item.name_from_node_and_socket = node_name + item.name_from_node_and_socket_with_scene = scene_node_name + else: + print(f'node_name: {node_name} VS {scene_node_name}') + item.name_from_node_and_socket = f'{node_name}_{o.name}' + item.name_from_node_and_socket_with_scene = f'{scene_node_name}_{o.name}' + + ## TODO: rename item.name according to template pairs in preferences (to add later) + if o.is_linked: + item.is_linked = True + item.select = False + + return context.window_manager.invoke_props_dialog(self, width=500) + + def draw(self, context): + layout = self.layout + box = layout.box() + expand_icon = 'DISCLOSURE_TRI_DOWN' if self.show_custom_settings else 'DISCLOSURE_TRI_RIGHT' + box.prop(self, 'show_custom_settings', emboss=False, icon=expand_icon) + + ## Settings + if self.show_custom_settings: + box.use_property_split = True + box.row().prop(self, 'name_type', expand=False) + box.prop(self, 'base_path') + col = box.column() + col.prop(self, 'file_format') + col.prop(self, 'exr_codec') + col.row().prop(self, 'color_depth', expand=True) + col.prop(self, 'split_tech_passes') + + search_row = layout.row() + op = search_row.operator("rt.colprop_search_and_replace", icon='BORDERMOVE') + op.data_path = 'bpy.context.window_manager.rt_socket_collection' + if self.name_type == 'socket_name': + op.target_prop = 'name' + elif self.name_type == 'node_name': + op.target_prop = 'name_from_node' + elif self.name_type == 'node_and_socket_name': + op.target_prop = 'name_from_node_and_socket' + elif self.name_type == 'node_and_socket_name_with_scene': + op.target_prop = 'name_from_node_and_socket_with_scene' + + ## Node Sockets + layout.use_property_split = False + col = layout.column() + current_node_name = '' + for item in self.socket_collection: + if item.node_name != current_node_name: + current_node_name = item.node_name + col.separator() + ## Display node label + name or name only + if item.node_label: + display_node_name = f'{item.node_label} ({item.node_name})' + else: + display_node_name = item.node_name + ## A bit dirty: For render layer node, show render layer-node name. + if display_node_name.startswith('Render Layer'): + rln = context.scene.node_tree.nodes.get(item.node_name) + display_node_name = f'{rln.scene.name}/{rln.layer} ({display_node_name})' + col.label(text=display_node_name, icon='NODE_SEL') + + row = col.row() + if item.is_linked: + row.label(text='', icon='LINKED') # NODETREE + else: + row.label(text='', icon='BLANK1') + row.prop(item, 'select', text='') + + display_name = item.socket_name + if 'crypto' in display_name.lower(): + display_name = f'{display_name} -> 32bit output node' + elif self.split_tech_passes and display_name.lower() in TECH_PASS_KEYWORDS: + display_name = f'{display_name} -> Tech pass' + + row.label(text=display_name) + row.label(text='', icon='RIGHTARROW') + + if self.name_type == 'socket_name': + row.prop(item, 'name', text='') + elif self.name_type == 'node_name': + row.prop(item, 'name_from_node', text='') + elif self.name_type == 'node_and_socket_name': + row.prop(item, 'name_from_node_and_socket', text='') + elif self.name_type == 'node_and_socket_name_with_scene': + row.prop(item, 'name_from_node_and_socket_with_scene', text='') + + def execute(self, context): + + # Build exclude dict from selection + excludes = {} + remap_names = {} + ## Old system + # if len(self.socket_collection): + # for item in self.socket_collection: + # if not item.select: + # # All deselected goes to exclude with {node_name: [socket1, ...]} + # excludes.setdefault(item.node_name, []).append(item.name) + # elif item.socket_name != item.name: + # remap_names[item.socket_name] = item.name + + if len(self.socket_collection): + for item in self.socket_collection: + final_name = item.name + ## change name if other options were used + if self.name_type == 'node_name': + final_name = item.name_from_node + elif self.name_type == 'node_and_socket_name': + final_name = item.name_from_node_and_socket + elif self.name_type == 'node_and_socket_name_with_scene': + final_name = item.name_from_node_and_socket_with_scene + + if not item.select: + # All deselected goes to exclude with {node_name: [socket1, ...]} + excludes.setdefault(item.node_name, []).append(item.socket_name) + elif item.socket_name != final_name: + remap_names.setdefault(item.node_name, {})[item.socket_name] = final_name + # remap_names[item.socket_name] = final_name + + ## Handle default file format + file_ext = self.file_format + if self.file_format == 'NONE': + env_file_format = os.environ.get('FILE_FORMAT') + file_ext = env_file_format if env_file_format else 'OPEN_EXR_MULTILAYER' + + file_format = { + 'file_format' : file_ext, + 'exr_codec' : self.exr_codec, + 'color_depth' : self.color_depth, + } + + scn = context.scene + nodes = scn.node_tree.nodes + selected = [n for n in nodes if n.select] + outfile = next((n for n in selected if n.type == 'OUTPUT_FILE'), None) + # Exclude output file from + selected = [n for n in selected if n.type != 'OUTPUT_FILE'] + + # fn.connect_to_file_output(selected, outfile) + for n in selected: + fn.connect_to_file_output( + n, + outfile, + base_path=self.base_path, + excludes=excludes, + remap_names=remap_names, + file_format=file_format, + split_tech_passes=self.split_tech_passes) + + return {"FINISHED"} + +# endregion + +classes=( +RT_OT_colprop_search_and_replace, +RT_PG_selectable_prop, +RT_OT_create_output_layers, +) + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) \ No newline at end of file