diff --git a/README.md b/README.md index a2735a3..8883724 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ -# git_template -A Template from which to create new repository from. +# Render Toolbox + +Blender addon for rendering setup and checks ## Development ### Cloning diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9699d2c --- /dev/null +++ b/__init__.py @@ -0,0 +1,50 @@ +bl_info = { + "name": "Render Toolbox", + "description": "Perform checks and setup outputs", + "author": "Samuel Bernou", + "version": (0, 1, 0), + "blender": (3, 0, 0), + "location": "View3D", + "warning": "", + "doc_url": "https://git.autourdeminuit.com/autour_de_minuit/render_toolbox", + "tracker_url": "https://git.autourdeminuit.com/autour_de_minuit/render_toolbox/issues", + "category": "Object" +} + +## TODO: +## Transfer useful and generic render function from gp_render: +# - mute/unmute all file output nodes +# - export crop infos to json (hardcoded path in gp_render. make an export dialog instead, with eventually an environnement variable for the path) +# - Viewlayer list view + +## Transfer useful and genereric render stuff from gp_toolbox: +# - ? Conflict visibility checker ? This also fit other tasks, but more often use to debug at precomp/rendering step. + +## Improve existing: +# - Connect to file output node should have the option to add scene name as output prefix +# - Connect to file output node should have search and replace options for output names in window +# - Connect to file output node should not remove unlinked output by defaut (should be an option in window) + + +from . import setup_outputs +from . import ui + +bl_modules = ( + setup_outputs, + ui, + # prefs, +) + +import bpy + +def register(): + for mod in bl_modules: + mod.register() + + +def unregister(): + for mod in reversed(bl_modules): + mod.unregister() + +if __name__ == "__main__": + register() \ No newline at end of file diff --git a/fn.py b/fn.py new file mode 100644 index 0000000..18c8919 --- /dev/null +++ b/fn.py @@ -0,0 +1,237 @@ +import bpy +import os +import re +import json + +### --- 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 + + +def connect_to_file_output(node_list, file_out=None, base_path='', excludes=None, remap_names=None, file_format=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}}. + + frame (bpy.types.CompositorNode, optional): If given, create nodes into a frame. + Defaults to None. + + Returns: + list[bpy.types.CompositorNode]: All nodes created. + """ + + scene = bpy.context.scene + nodes = scene.node_tree.nodes + links = scene.node_tree.links + 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 + + excludes = excludes or {} + + for node in node_list: + exclusions = excludes.get(node.name) or [] + ## create one output facing node and connect all + outs = [o for o in node.outputs if not o.is_unavailable and not 'crypto' in o.name.lower() and o.name not in exclusions] + cryptout = [o for o in node.outputs if not o.is_unavailable and 'crypto' in o.name.lower() and o.name not in exclusions] + + if node.type == 'R_LAYERS': + out_base = node.layer + elif node.label: + out_base = node.label + else: + out_base = node.name + out_base = bpy.path.clean_name(out_base) + out_name = f'OUT_{out_base}' + + if outs: + fo = file_out + if not fo: + fo = nodes.get(out_name) + if not fo: + # color = (0.2,0.3,0.5) + fo = create_node('CompositorNodeOutputFile', tree=scene.node_tree, location=(real_loc(node)[0]+500, real_loc(node)[1]+50), 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) + + fo.name = out_name + if node.parent: + fo.parent = node.parent + + if base_path: + fo.base_path = base_path + else: + if fo.format.file_format == 'OPEN_EXR_MULTILAYER': + fo.base_path = f'//render/{out_base}/{out_base}_' + else: + fo.base_path = f'//render/{out_base}' + + for o in outs: + 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) # clean name ? + else: + slot_name = bpy.path.clean_name(o.name) + + # if fo.format.file_format == 'OPEN_EXR_MULTILAYER': + # slot_name = slot_name + # else: + # slot_name = f'{slot_name}/{slot_name}_' + # fo.file_slots.new(slot_name) + fs = fo.file_slots.new('tmp') # slot_name) + ls = fo.layer_slots.new('tmp') # slot_name + 'layer') + + ls = fo.layer_slots[-1] + ls.name = slot_name + + fs = fo.file_slots[-1] + fs.path = f'{slot_name}/{slot_name}_' # Error 'NodeSocketColor' object has no attribute 'path' + + + out_input = fo.inputs[-1] + links.new(o, out_input) + + clear_disconnected(fo) + fo.update() + + ## Create separate file out for cryptos + if cryptout: + out_name += '_cryptos' + fo = file_out + if not fo: + fo = nodes.get(out_name) + if not fo: + # color = (0.2,0.3,0.5) + fo = create_node('CompositorNodeOutputFile', tree=scene.node_tree, location=(real_loc(node)[0]+400, real_loc(node)[1]-200), width=220) + 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) # OPEN_EXR_MULTILAYER, RGBA, ZIP + fo.format.color_depth = '32' # For crypto force 32bit + + fo.name = out_name + if node.parent: + fo.parent = node.parent + + if base_path: + fo.base_path = base_path + else: + if fo.format.file_format == 'OPEN_EXR_MULTILAYER': + ## FIXME: find a better organization for separated crypto pass + fo.base_path = f'//render/{out_base}/cryptos/cryptos_' + else: + fo.base_path = f'//render/{out_base}' + + for o in cryptout: + ## Skip already connected + ## TODO Test recusively to find fo (some have interconnected sockets) + # if next((l for l in o.links if l.to_node == fo), None): + if next((l for l in o.links if recursive_node_connect_check(l, fo)), None): + continue + + # if remap_names and (custom_name := remap_names.get(o.name)): + if (socket_remaps := remap_names.get(node.name)) and (custom_name := socket_remaps.get(o.name)): + slot_name = bpy.path.clean_name(custom_name) # clean name ? + else: + slot_name = bpy.path.clean_name(o.name) # directly use name in multi layer exr + + # if fo.format.file_format == 'OPEN_EXR_MULTILAYER': + # slot_name = slot_name + # else: + # slot_name = f'{slot_name}/{slot_name}_' + # fo.file_slots.new(slot_name) + + # Setting both file_slots and layer_slots... + 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}_' # Error 'NodeSocketColor' object has no attribute 'path' + + + out_input = fo.inputs[-1] + links.new(o, out_input) + clear_disconnected(fo) + fo.update() diff --git a/setup_outputs.py b/setup_outputs.py new file mode 100644 index 0000000..fd5a355 --- /dev/null +++ b/setup_outputs.py @@ -0,0 +1,259 @@ +import bpy +import os +from . import fn + +class RT_PG_selectable_prop(bpy.types.PropertyGroup): + node_name: bpy.props.StringProperty(name="Node Name") + node_label: bpy.props.StringProperty(name="Node Label") + name: bpy.props.StringProperty(name="Name or Path") + socket_name: bpy.props.StringProperty(name="Source socket Name") # Source socket name as reference + select: bpy.props.BoolProperty(name="Selected", default=True) + is_linked: bpy.props.BoolProperty(name="Linked", default=False) + # is_valid: bpy.props.BoolProperty(name="Valid", default=True) + + ## extra output naming options + name_from_node: bpy.props.StringProperty(name="Name From Node") + name_from_node_and_socket: bpy.props.StringProperty(name="Name From Node And Socket") + +class RT_OT_connect_selected_to_file_out(bpy.types.Operator): + bl_idname = "rt.connect_selected_to_file_out" + bl_label = "Connect Selected To File Output" + bl_description = "Connect Selected Nodes to a new fileoutput node\ + \nIf a fileoutput node is selected, socket are added to it" + bl_options = {"REGISTER", "UNDO"} + + socket_collection : bpy.props.CollectionProperty(type=RT_PG_selectable_prop) + + show_custom_settings : bpy.props.BoolProperty( + name='Settings', + default=False) + + ## enum choice for naming: socket_name, node_name, node_and_socket_name, + name_type : bpy.props.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), + ('socket_name', 'Socket Name', 'Use the socket name as output name', 1), + ('node_name', 'Node Name', 'Use the node name as output name', 2), + ) + ) + + # prefix_with_node_name : bpy.props.BoolProperty( + # name='Prefix With Node Name', + # description='Add the node name as prefix to the output name', + # default=False) + + base_path : bpy.props.StringProperty( + name='Custom base path', + default='', + description='Set the base path of created file_output (not if already exists)') + + file_format : bpy.props.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 : bpy.props.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 : bpy.props.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): + 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 + + ## 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}' + # node_name = f'{n.scene.name}_{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 + else: + item.name_from_node_and_socket = f'{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) + + ## 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' + 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='') + + 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 + + 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) + return {"FINISHED"} + +classes=( +RT_PG_selectable_prop, +RT_OT_connect_selected_to_file_out, +) + +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 diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..9761448 --- /dev/null +++ b/ui.py @@ -0,0 +1,27 @@ +import bpy + +from bpy.types import Panel + + +class RT_PT_gp_node_ui(Panel): + bl_space_type = "NODE_EDITOR" + bl_region_type = "UI" + bl_category = "Render" + bl_label = "Render Toolbox" + + def draw(self, context): + layout = self.layout + layout.operator("rt.connect_selected_to_file_out", icon="NODE") + + +classes = ( +RT_PT_gp_node_ui, +) + +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