pullusb 37a9e0ca43 add template in create output path base node
templates for slot are in code but disabled, not useful in the end
2025-07-21 16:24:27 +02:00

489 lines
17 KiB
Python
Executable File

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 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