import bpy import os import re import json from mathutils import Vector from pathlib import Path from itertools import groupby from math import isclose from collections import defaultdict from time import time from . constant import RD_SCENE_NAME ### -- rules def is_valid_name(name): '''return True if name correspond to a valid object Don't start with a dot '.' Is not "note" ''' if name.startswith('.'): return False ## FIXME: /!\ "note" as an exclude word is not good practice, temporary fix if name.lower() == 'note': return False return True ### -- node basic 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 new_aa_node(tree, **kargs): '''create AA node''' aa = create_node('CompositorNodeAntiAliasing', tree) # type = ANTIALIASING aa.threshold = 1.0 # 0.5 aa.contrast_limit = 0.25 # 0.5 aa.corner_rounding = 0.25 aa.hide = True for k,v in kargs.items(): setattr(aa, k, v) return aa def create_aa_nodegroup(tree): ngroup = bpy.data.node_groups.get('AA') if not ngroup: ngroup = bpy.data.node_groups.new('AA', 'CompositorNodeTree') ng_in = create_node('NodeGroupInput', tree=ngroup, location=(-600,0)) ng_out = create_node('NodeGroupOutput', tree=ngroup, location=(600,0)) sep = create_node('CompositorNodeSepRGBA', tree=ngroup, location=(-150,0)) comb = create_node('CompositorNodeCombRGBA', tree=ngroup, location=(350,25)) if bpy.app.version < (4,0,0): ngroup.inputs.new('NodeSocketColor', 'Image') ngroup.outputs.new('NodeSocketColor', 'Image') else: ngroup.interface.new_socket('Image', in_out='INPUT', socket_type='NodeSocketColor') ngroup.interface.new_socket('Image', in_out='OUTPUT', socket_type='NodeSocketColor') # in AA # ngroup.links.new(comb.outputs[0], ng_out.inputs[0]) # <- connect without out AA aa = new_aa_node(ngroup, location=(-400, 0)) # ngroup.links.new(ng_in.outputs[0], sep.inputs[0]) ngroup.links.new(ng_in.outputs[0], aa.inputs[0]) ngroup.links.new(aa.outputs[0], sep.inputs[0]) # ngroup.links.new(ng_in.outputs[0], sep.inputs[0]) for i in range(3): ngroup.links.new(sep.outputs[i], comb.inputs[i]) # alpha AA alpha_aa = new_aa_node(ngroup, location=(100,-150)) ngroup.links.new(sep.outputs[3], alpha_aa.inputs[0]) ngroup.links.new(alpha_aa.outputs[0], comb.inputs[3]) ngroup.links.new(comb.outputs[0], ng_out.inputs[0]) ng = create_node('CompositorNodeGroup', tree=tree) ng.node_tree = ngroup ng.name = ngroup.name ng.hide=True return ng ## -- object and scene settings def activate_workspace(name='', context=None): if not name: return if context is None: context = bpy.context if context.window.workspace.name == name: print(f'Already in {name} workspace') return if (wkspace := bpy.data.workspaces.get(name)): context.window.workspace = wkspace return True # Same name with spaces as underscore dir_name = name.replace(' ', '_') filepath = Path(__file__).parent / 'app_templates' / dir_name / 'startup.blend' ret = bpy.ops.workspace.append_activate(idname=name, filepath=str(filepath)) if ret != {'FINISHED'}: print(f'Could not found {name} at {filepath}') return False return context.window.workspace def copy_settings(obj_a, obj_b): exclusion = ['bl_rna', 'id_data', 'identifier','name_property','rna_type','properties', 'stamp_note_text','use_stamp_note', 'settingsFilePath', 'settingsStamp', 'select', 'matrix_local', 'matrix_parent_inverse', 'matrix_basis','location','rotation_euler', 'rotation_quaternion', 'rotation_axis_angle', 'scale'] for attr in dir(obj_a): if attr.startswith('__'): continue if attr in exclusion: continue # print('attr: ', attr) # if obj_a.is_property_readonly(attr): # block when things aren't attribute # continue try: val = getattr(obj_a, attr) except AttributeError: # print(f'cant get {attr}') pass try: setattr(obj_b, attr, val) except: # print(f"can't set {attr}") pass def set_file_output_format(fo): env_file_format = json.loads(os.environ.get('GP_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 set_scene_aa_settings(scene=None, aa=True): '''aa == using native AA, else disable scene AA''' if not scene: scene = bpy.context.scene if aa: scene.eevee.taa_render_samples = 32 scene.grease_pencil_settings.antialias_threshold = 1 else: scene.eevee.taa_render_samples = 1 scene.grease_pencil_settings.antialias_threshold = 0 def set_settings(scene=None, aa=True): '''aa == using native AA, else disable scene AA''' if not scene: scene = bpy.context.scene # specify scene settings for these kind of render set_scene_aa_settings(scene=scene, aa=aa) scene.render.film_transparent = True scene.render.use_compositing = True scene.render.use_sequencer = False scene.view_settings.view_transform = 'Standard' scene.render.resolution_percentage = 100 # output (fast write settings since this is just to delete afterwards...) scene.render.filepath = f'//render/preview/{scene.name}/preview_' im_settings = scene.render.image_settings # im_settings.file_format = 'JPEG' # im_settings.color_mode = 'RGB' # im_settings.quality = 0 ## Same as output nodes im_settings.file_format = 'OPEN_EXR' im_settings.color_mode = 'RGBA' im_settings.color_depth = '16' im_settings.exr_codec = 'ZIP' def scene_aa(scene=None, toggle=True): '''Change scene AA settings and commute AA nodes according to toggle''' if not scene: scene=bpy.context.scene # Enable/disable native anti-alias on active scene set_scene_aa_settings(scene=scene, aa=toggle) ## Set AA on scene where object and viewlayers exists local_nodes = scene.node_tree.nodes if (group_node := next((n for n in local_nodes if n.name.startswith('NG_')), None)): # Get a viewlayer connected to a NG_ and check which scene is referred if (rlayer := next((i.links[0].from_node for i in group_node.inputs if i.links and i.links[0].from_node.type == 'R_LAYERS'), None)): if rlayer.scene and rlayer.scene != scene: print(f'Set AA to {toggle} on scene "{rlayer.scene.name}"') set_scene_aa_settings(scene=rlayer.scene, aa=toggle) ## Mute/Unmute AA nodegroups for n in local_nodes: if n.type == 'GROUP' and n.name.startswith('NG_'): # n.mute = False # mute whole nodegroup ? for gn in n.node_tree.nodes: if gn.type == 'GROUP' and gn.node_tree.name == 'AA': gn.mute = toggle def new_scene_from(name, src_scn=None, regen=True, crop=True, link_cam=True, link_light=True): '''Get / Create a scene from name and source scene to get settings from''' scn = bpy.data.scenes.get(name) if scn and not regen: return scn elif scn and regen: bpy.data.scenes.remove(scn) src_scn = src_scn or bpy.context.scene # given scene, or active scene scn = bpy.data.scenes.new(name) ## copy original settings over to new scene # copy_settings(current, scn) # BAD for attr in ['frame_start', 'frame_end', 'frame_current', 'camera', 'world']: setattr(scn, attr, getattr(src_scn, attr)) copy_settings(src_scn.render, scn.render) ## link cameras (and lights ?) for ob in src_scn.objects: if link_cam and ob.type == 'CAMERA': scn.collection.objects.link(ob) if link_light and ob.type == 'LIGHT': scn.collection.objects.link(ob) # set adapted render settings set_settings(scn) if crop: scn.render.use_border = True scn.render.use_crop_to_border = True scn.use_nodes = True return scn def get_compo_scene(scene_name=None, create=True): '''Get / Create a dedicated compositing scene to link GP use passed scene name, if no name is passed fall back to compo_scene propertie return None if field is empty''' scene_name = scene_name or bpy.context.scene.gp_render_settings.node_scene if not scene_name: # return None or render scene return scn = bpy.data.scenes.get(scene_name) if scn: return scn if not create: return ## -- Create render scene current = bpy.context.scene ## With data scn = bpy.data.scenes.new(scene_name) ## copy original settings over to new scene for attr in ['frame_start', 'frame_end', 'frame_current']: # , 'camera', 'world' setattr(scn, attr, getattr(current, attr)) copy_settings(current.render, scn.render) ## Copy markers # for marker in current.timeline_markers: # new_marker = scn.timeline_markers.new(marker.name, frame=marker.frame) # new_marker.camera = marker.camera scn.use_nodes = True ## Clear node tree scn.node_tree.nodes.clear() set_settings(scn) scn.gp_render_settings['use_aa'] = True # Set compo scene target in it's own property as well scn.gp_render_settings.node_scene = scene_name return scn def get_render_scene(scene_name=None, create=True): '''Get / Create a dedicated render scene to link GP''' scene_name = scene_name or RD_SCENE_NAME render_scn = bpy.data.scenes.get(scene_name) if render_scn: return render_scn if not create: return ## -- Create render scene current = bpy.context.scene ## With data render_scn = bpy.data.scenes.new(scene_name) ## copy original settings over to new scene # copy_settings(current, render_scn) # BAD for attr in ['frame_start', 'frame_end', 'frame_current', 'camera', 'world']: setattr(render_scn, attr, getattr(current, attr)) copy_settings(current.render, render_scn.render) ## link cameras (and lights ?) for ob in current.objects: if ob.type in ('CAMERA', 'LIGHT'): render_scn.collection.objects.link(ob) ## Copy markers for marker in current.timeline_markers: new_marker = render_scn.timeline_markers.new(marker.name, frame=marker.frame) new_marker.camera = marker.camera render_scn.use_nodes = True ## Clear node tree (initial view layer stuff) render_scn.node_tree.nodes.clear() # for n in reversed(render_scn.node_tree.nodes): # render_scn.node_tree.nodes.remove(n) set_settings(render_scn) render_scn.gp_render_settings['use_aa'] = True return render_scn def get_view_layer(name, scene=None): '''get viewlayer name return existing/created viewlayer ''' scene = scene or get_render_scene() ### pass double letter prefix as suffix ## pass_name = re.sub(r'^([A-Z]{2})(_)(.*)', r'\3\2\1', 'name') ## pass_name = f'{name}_{passe}' pass_vl = scene.view_layers.get(name) if not pass_vl: pass_vl = scene.view_layers.new(name) pass_vl.use_pass_z = True return pass_vl def set_resolution_from_cam_prop(cam=None, scene=None): if scene is None: scene = bpy.context.scene if not cam: cam = scene.camera if not cam: return ('ERROR', 'No active camera') res = cam.get('resolution') if not res: return ('ERROR', 'Cam has no resolution attribute') rd = scene.render if rd.resolution_x == res[0] and rd.resolution_y == res[1]: return ('INFO', f'Resolution already at {res[0]}x{res[1]}') else: rd.resolution_x, rd.resolution_y = res[0], res[1] return ('INFO', f'Resolution to {res[0]}x{res[1]}') ## -- node location tweaks def real_loc(n): if not n.parent: return n.location return n.location + real_loc(n.parent) def get_frame_transform(f, node_tree=None): '''Return real transform location of a frame node only works with one level of nesting (not recursive) ''' if not node_tree: node_tree = f.id_data if f.type != 'FRAME': return # return real_loc(f), f.dimensions childs = [n for n in node_tree.nodes if n.parent == f] if not childs: return f.location, f.dimensions # real_locs = [f.location + n.location for n in childs] xs = [n.location.x for n in childs] + [n.location.x + n.dimensions.x for n in childs] ys = [n.location.y for n in childs] + [n.location.y - n.dimensions.y for n in childs] xs.sort(key=lambda loc: loc) # x val : ascending ys.sort(key=lambda loc: loc) # ascending # , reversed=True) # y val : descending loc = Vector((min(xs), max(ys))) dim = Vector((max(xs) - min(xs) + 60, max(ys) - min(ys) + 60)) return loc, dim ## -- get all frames with their real transform. def bbox(f, frames): xs=[] ys=[] for n in frames[f]: # nodes of passed frame # Better as Vectors ? if n.type == 'FRAME': if n not in frames.keys(): # print(f'frame {n.name} not in frame list') continue all_xs, all_ys = bbox(n, frames) # frames[n] xs += all_xs ys += all_ys else: loc = real_loc(n) xs += [loc.x, loc.x + n.dimensions.x] # + (n.dimensions.x/get_dpi_factor()) ys += [loc.y, loc.y - n.dimensions.y] # - (n.dimensions.y/get_dpi_factor()) # margin ~= 30 # return xs and ys return [min(xs)-30, max(xs)+30], [min(ys)-30, max(ys)+30] def get_frames_bbox(node_tree): '''Return a dic with all frames ex: {frame_node: (location, dimension), ...} ''' # create dic of frame object with his direct child nodes nodes frames = defaultdict(list) frames_bbox = {} for n in node_tree.nodes: if not n.parent: continue # also contains frames frames[n.parent].append(n) # Dic for bbox coord for f, nodes in frames.items(): if f.parent: continue xs, ys = bbox(f, frames) # xs, ys = bbox(nodes, frames) ## returning: list of corner coords # coords = [ # Vector((xs[0], ys[1])), # Vector((xs[1], ys[1])), # Vector((xs[1], ys[0])), # Vector((xs[0], ys[0])), # ] # frames_bbox[f] = coords ## returning: (loc vector, dimensions vector) frames_bbox[f] = Vector((xs[0], ys[1])), Vector((xs[1] - xs[0], ys[1] - ys[0])) return frames_bbox ## -- nodes helper functions def remove_nodes_by_viewlayer(viewlayer_list, scene=None): '''Take a list of viewlayer and optionaly a scene to target nodetree remove nodes related to this viewlayer in nodetree ''' scene = scene or bpy.context.scene vl_names = [v.name for v in viewlayer_list] for n in reversed(scene.node_tree.nodes): if n.type == 'R_LAYERS' and n.layer in vl_names: for lnk in n.outputs[0].links: grp = lnk.to_node if grp.type != 'GROUP': continue if not grp.name.startswith('NG'): continue sockin = lnk.to_socket sockout = grp.outputs.get(sockin.name) if not sockout: continue for grplink in sockout.links: if grplink.to_node.type != 'OUTPUT_FILE': continue fo_socket = grplink.to_socket fo = grplink.to_node fo.file_slots.remove(fo_socket) # remove input and output from group # grp.inputs.remove(sockin) # do not clear inside !! # grp.outputs.remove(sockout) # do not clear inside !! ngroup = grp.node_tree if bpy.app.version < (4,0,0): for i in range(len(grp.inputs))[::-1]: if grp.inputs[i].name == sockin.name: ngroup.inputs.remove(ngroup.inputs[i]) break for i in range(len(grp.outputs))[::-1]: if grp.outputs[i].name == sockout.name: ngroup.outputs.remove(ngroup.outputs[i]) break else: g_inputs = [s for s in ngroup.interface.items_tree if s.in_out == 'INPUT'] g_outputs = [s for s in ngroup.interface.items_tree if s.in_out == 'OUTPUT'] for i in range(len(grp.inputs))[::-1]: if grp.inputs[i].name == sockin.name: ngroup.interface.remove(g_inputs[i]) break for i in range(len(grp.outputs))[::-1]: if grp.outputs[i].name == sockout.name: ngroup.interface.remove(g_outputs[i]) break # Remove render_layer node scene.node_tree.nodes.remove(n) def merge_gplayer_viewlayers(ob=None, act=None, layers=None, scene=None): '''ob is not needed if active and layers are passed''' if ob is None: ob = bpy.context.object if act is None: act = ob.data.layers.active if layers is None: layers = [l for l in ob.data.layers if l.select and l != act] if act is None: return ({'ERROR'}, 'Active layer not found. Should be active layer on active object!') rd_scn = scene or get_render_scene(create=False) # bpy.context.scene node_scene = get_compo_scene(create=False) or rd_scn if not act.viewlayer_render: return ({'ERROR'}, f'Active layer {act.info} has no viewlayer assigned') # List layers and viewlayers vls = [rd_scn.view_layers.get(l.viewlayer_render) for l in layers if l.viewlayer_render and l.viewlayer_render != act.viewlayer_render and rd_scn.view_layers.get(l.viewlayer_render)] # Remove duplication vls = list(set(vls)) # Remove viewlayer related nodes # FIXME it's possible nodes are nodes searched in the right scene if launched from RenderGP remove_nodes_by_viewlayer(vls, node_scene) # send compositing scene # Assign view layer from active to selected for l in layers: l.viewlayer_render = act.viewlayer_render ## Delete unused viewlayers () used_vl_name = [n.layer for n in rd_scn.node_tree.nodes if n.type == 'R_LAYERS' and n.layer] used_vl_name = list(set(used_vl_name)) for vl in vls: # rd_scn.view_layers.remove(vl) if vl.name == 'exclude': # keep exclude continue if not vl.name in used_vl_name: rd_scn.view_layers.remove(vl) def group_adjacent_layer_prefix_rlayer(ob, excluded_prefix=[], first_name=True): '''Set viewlayer and renderlayers by Gp layer adjacent prefix Call merge_gplayer_viewlayers with grouped prefix :excluded_prefix: List of prefixes to exclude from merge or str with comma separated values :first_name: Keep the viewlayer of the bottom layer in group, else last ''' pattern = os.environ.get('GP_RENDER_MERGE_PATTERN', r'^([A-Z]{2})_') re_prefix = re.compile(pattern, flags=re.IGNORECASE) if isinstance(excluded_prefix, str): excluded_prefix = [p.strip() for p in excluded_prefix.split(',')] ## Create adjacent grp list: [('CO', [layer1, layer2]), ('LN', [layer3, layer4])] adjacent_prefix_groups = [ (g[0], list(g[1])) for g in groupby([l for l in ob.data.layers], key=lambda l: re_prefix.search(l.info).group(1) if re_prefix.search(l.info) else '') ] for prefix, layer_grp in adjacent_prefix_groups: ## Remove layer that are in excluded viewlayer ## Else None/exclusion vl can expand rest of the adjacent layers for l in reversed(layer_grp): if not l.viewlayer_render or l.viewlayer_render == 'exclude': print(f'prefix "{prefix}": remove "{l.info}" from grouping adjacent layers') layer_grp.remove(l) # remove traget the layer directly if len(layer_grp) < 2: continue if not prefix or prefix in excluded_prefix: continue ref = layer_grp[0] if first_name else layer_grp[-1] merge_gplayer_viewlayers(ob, act=ref, layers=layer_grp) def clear_nodegroup(name, full_clear=False): '''remove duplication of a nodegroup (.???) also remove the base one if full_clear True ''' for ng in reversed(bpy.data.node_groups): pattern = name + r'\.\d{3}' if not full_clear and ng.users: continue if re.search(pattern, ng.name): bpy.data.node_groups.remove(ng) if full_clear and ng.name == name: # if full clear bpy.data.node_groups.remove(ng) def rearrange_rlayers_in_frames(node_tree): '''rearrange RL nodes in all frames in nodetree''' frames_l = [n for n in node_tree.nodes if n.type == 'FRAME'] for f in frames_l: all_in_frames = [n for n in node_tree.nodes if n.parent == f] rlayers = [n for n in all_in_frames if n.type == 'R_LAYERS'] if not rlayers: continue all_in_frames.sort(key=lambda x: x.location.y, reverse=True) # descending rlayers.sort(key=lambda x: x.location.y, reverse=True) # descending top = all_in_frames[0].location.y for rl in rlayers: # move to top with equal size rl.location.y = top if rl.dimensions.y == 0: # Newly created nodes top -= 180 + 20 # down by probable size + gap of 20 else: top -= rl.dimensions.y + 20 # place next down by height + gap of 20 def rearrange_frames(node_tree): frame_d = get_frames_bbox(node_tree) # dic : {frame_node:(loc vector, dimensions vector), ...} if not frame_d: print('no frame found') return # print([f.name for f in frame_d.keys()]) ## order the dict by frame.y location frame_d = {key: value for key, value in sorted(frame_d.items(), key=lambda pair: pair[1][0].y - pair[1][1].y, reverse=True)} frames = [[f, v[0], v[1].y] for f, v in frame_d.items()] # [frame_node, real_loc, real dimensions] top = frames[0][1].y # upper node location.y # top = 0 #always start a 0 offset = 0 for f in frames: ## f[1] : real loc Vector ## f[0] : frame ## move frame by offset needed (delta between real_loc and "fake" loc , minus offset) f[0].location.y = (f[1].y - f[0].location.y) - offset # avoid offset when recalculating from 0 top # f[0].location.y = f[1].y - top - offset offset += f[2] + 200 # gap f[0].update() def reorder_inputs(ng): rl_nodes = [s.links[0].from_node for s in ng.inputs if s.is_linked and s.links and s.links[0].from_node.type == 'R_LAYERS'] rl_nodes.sort(key=lambda x: x.location.y, reverse=True) names = [n.layer for n in rl_nodes] inputs_names = [s.name for s in ng.inputs] filtered_names = [n for n in names if n in inputs_names] if bpy.app.version < (4,0,0): for dest, name in enumerate(filtered_names): ## rebuild list at each iteration so index are good inputs_names = [s.name for s in ng.inputs] src = inputs_names.index(name) # reorder on node_tree not directly on node! ng.node_tree.inputs.move(src, dest) else: n_inputs = [s for s in ng.node_tree.interface.items_tree if s.in_out == 'INPUT'] for dest, name in enumerate(filtered_names): item = next((s for s in ng.node_tree.interface.items_tree if s.in_out == 'INPUT' and s.name == name), None) if not item: # Dbg print(f'!PROBLEM with input "{name}"') continue # Need to offset index (inputs are listed after output in item_tree list) dest = dest + n_inputs[0].position ng.node_tree.interface.move(item, dest) ng.node_tree.interface_update(bpy.context) def reorder_outputs(ng): ordered_out_name = [nis.name for nis in ng.inputs if nis.name in [o.name for o in ng.outputs]] for s_name in ordered_out_name: all_outnames = [o.name for o in ng.outputs] # reorder on nodetree, not on node ! if bpy.app.version < (4,0,0): ng.node_tree.outputs.move(all_outnames.index(s_name), ordered_out_name.index(s_name)) else: item = next((s for s in ng.node_tree.interface.items_tree if s.in_out == 'OUTPUT' and s.name == s_name), None) if not item: # Dbg print(f'!PROBLEM with output "{s_name}"') continue ng.node_tree.interface.move(item, ordered_out_name.index(s_name)) ng.node_tree.interface_update(bpy.context) def clear_disconnected(fo): for inp in reversed(fo.inputs): if not inp.is_linked: print(f'Deleting unlinked fileout slot: {inp.name}') fo.inputs.remove(inp) def reorder_fileout(fo, ng=None): if not ng: # get connected nodegroup for s in fo.inputs: if s.is_linked and s.links and s.links[0].from_node.type == 'GROUP': ng = s.links[0].from_node break if not ng: print(f'No nodegroup to refer to filter {fo.name}') return ordered = [o.links[0].to_socket.name for o in ng.outputs if o.is_linked and o.is_linked and o.links[0].to_node == fo] for s_name in ordered: all_outnames = [s.name for s in fo.inputs] # same as [fs.path for fs in fo.file_slots] fo.inputs.move(all_outnames.index(s_name), ordered.index(s_name)) def reorganise_NG_nodegroup(ng): '''refit node content to avoid overlap''' ngroup = ng.node_tree ng_in = ngroup.nodes.get('Group Input') offset = 35 y = 0 for s in ng_in.outputs: if s.is_linked: s.links[0].to_node.location.y = y y -= offset def connect_to_group_output(n): for o in n.outputs: if o.is_linked: if o.links[0].to_node.type == 'GROUP_OUTPUT': return o.links[0].to_socket val = connect_to_group_output(o.links[0].to_node) if val: return val return False def connect_to_group_input(n): for i in n.inputs: if i.is_linked: if i.links[0].from_node.type == 'GROUP_INPUT': return i.links[0].from_socket val = connect_to_group_input(i.links[0].from_node) if val: return val return False def all_connected_forward(n, nlist=[]): '''return list of all forward connected nodes recursively (include passed nodes)''' for o in n.outputs: if o.is_linked: for lnk in o.links: if lnk.to_node.type == 'GROUP_OUTPUT': if n not in nlist: return nlist + [n] else: return nlist else: nlist = all_connected_forward(lnk.to_node, nlist) if n in nlist: return nlist return nlist + [n] def all_connected_forward_from_socket(socket): '''return a list of all nodes connected forward after socket''' node_list = [] for ln in socket.links: for n in all_connected_forward(ln.to_node): if n not in node_list: node_list.append(n) # node_list = list(set(node_list)) return node_list def node_height(n): return n.height if not n.hide else 30 def reorder_nodegroup_content(ngroup): if isinstance(ngroup, bpy.types.Node): ngroup = ngroup.node_tree grp_in = None for n in ngroup.nodes: if n.type == 'GROUP_INPUT': grp_in = n break if not grp_in: return n_threads = [] for out in grp_in.outputs: n_thread = all_connected_forward_from_socket(out) if n_thread: n_threads.append(n_thread) level = grp_in.location.y for thread in n_threads: top = max([n.location.y for n in thread]) bottom = min([n.location.y - node_height(n) for n in thread]) thread_h = top - bottom # move all nodes to adjust to level diff_to_add = level - top for n in thread: n.location.y += diff_to_add # move level to bottom level -= thread_h + 2 # add a gap of two def clear_nodegroup_content_if_disconnected(ngroup): '''Get a nodegroup.node_tree delete orphan nodes that are not connected from group input node ''' if isinstance(ngroup, bpy.types.Node): # case where a node is sent instead of the group ngroup = ngroup.node_tree for n in reversed(ngroup.nodes): if n.type in ('GROUP_INPUT', 'GROUP_OUTPUT'): continue if not connect_to_group_input(n) and not connect_to_group_output(n): # is disconnected from both side ngroup.nodes.remove(n) reorder_nodegroup_content(ngroup) def clean_nodegroup_inputs(ng, skip_existing_pass=True): '''Clear inputs to output of passed nodegroup if not connected''' ngroup = ng.node_tree rl_nodes = [n.layer for n in ng.id_data.nodes if n.type == 'R_LAYERS'] for i in range(len(ng.inputs))[::-1]: if not ng.inputs[i].is_linked: if skip_existing_pass and any(ng.inputs[i].name == x for x in rl_nodes): # a render layer of this name still exists continue ngroup.inputs.remove(ngroup.inputs[i]) # clear_nodegroup_content_if_disconnected(ngroup) def bridge_reconnect_nodegroup(ng, socket_name=None): ''' Reconnect group_in and group out that have been disconnected :socket: only use this specific socket type ''' ngroup = ng.node_tree ng_in = ngroup.nodes.get('Group Input') ng_out = ngroup.nodes.get('Group Output') for sockin in ng_in.outputs: if socket_name and sockin.name != socket_name: continue if not sockin.name: # last empty output is listed continue sockout = ng_out.inputs.get(sockin.name) if not sockout: continue if len(sockin.links) and connect_to_group_output(sockin.links[0].to_node): continue ## need reconnect aa = create_aa_nodegroup(ngroup) ngroup.links.new(sockin, aa.inputs[0]) ngroup.links.new(aa.outputs[0], sockout) print(f'{ng.name}: Bridged {sockin.name}') def random_color(alpha=False): import random if alpha: return (random.uniform(0,1), random.uniform(0,1), random.uniform(0,1), 1) return (random.uniform(0,1), random.uniform(0,1), random.uniform(0,1)) def nodegroup_merge_inputs(ngroup, aa=True): '''Get a nodegroup merge every group inputs with alpha over then connect to antialias and a new output ''' ng_in = ngroup.nodes.get('Group Input') ng_out = ngroup.nodes.get('Group Output') x, y = ng_in.location.x + 200, 0 offset_x, offset_y = 150, -100 # merge all inputs in alphaover nodes prev = None for i in range(len(ng_in.outputs)-1): # skip waiting point inp = ng_in.outputs[i] if not prev: prev = ng_in continue # live connect ao = create_node('CompositorNodeAlphaOver', tree=ngroup, location=(x,y), hide=True) ngroup.links.new(prev.outputs[0], ao.inputs[1]) ngroup.links.new(inp, ao.inputs[2]) x += offset_x y += offset_y prev = ao # Create one output and link out_name = 'preview' if bpy.app.version < (4,0,0): # out_name = ngroup.inputs[0].name # name like first inputout_name out = ngroup.outputs.new('NodeSocketColor', out_name) else: # out_name = next((s for s in ngroup.interface.items_tree if s.in_out == 'INPUT')).name # name like first input out = ngroup.interface.new_socket(out_name, in_out='OUTPUT', socket_type='NodeSocketColor') ## create a merged name as output ?? if aa: # create AA and link aa = create_aa_nodegroup(ngroup) # new_aa_node(ngroup) aa.location = (ao.location.x + 200, ao.location.y) ngroup.links.new(ao.outputs[0], aa.inputs[0]) # node_tree ngroup.links.new(aa.outputs[0], ng_out.inputs[0]) else: # link directly ngroup.links.new(ao.outputs[0], ng_out.inputs[0]) ## -- renumbering funcs def get_numbered_output(out, slot_name): '''Return output slot name without looking for numbering ???_ ''' pattern = r'^(?:\d{3}_)?' # optional non capture group of 3 digits + _ pattern = f'{pattern}{slot_name}' for inp in out.inputs: if re.match(pattern, inp.name): return inp def add_fileslot_number(fs, number): field_attr = 'name' if hasattr(fs, 'name') else 'path' elems = getattr(fs, field_attr).split('/') for i, e in enumerate(elems): if re.match(r'^\d{3}_', e): elems[i] = re.sub(r'^(\d{3})', lambda x: str(number).zfill(3), e) else: elems[i] = f'{str(number).zfill(3)}_{e}' new = '/'.join(elems) setattr(fs, field_attr, new) return new def renumber(fo, offset=10): '''Force renumber all the slots with a 3''' if fo.type != 'OUTPUT_FILE': return ct = 10 # start at 10 slots = fo.layer_slots if fo.format.file_format == 'OPEN_EXR_MULTILAYER' else fo.file_slots for fs in slots: add_fileslot_number(fs, ct) ct += offset def get_num(string) -> int: '''get a tring or a file_slot object return leading number or None ''' if not isinstance(string, str): if hasattr(string, 'path'): string = string.path else: string = string.name num = re.search(r'^(\d{3})_', string) if num: return int(num.group(1)) def delete_numbering(fo): # padding=3 '''Delete prefix numbering on all slots on passed file output''' if fo.type != 'OUTPUT_FILE': return if fo.format.file_format == 'OPEN_EXR_MULTILAYER': slots = fo.layer_slots field_attr = 'name' else: slots = fo.file_slots field_attr = 'path' for fs in slots: elems = getattr(fs, field_attr).split('/') for i, e in enumerate(elems): elems[i] = re.sub(r'^\d{3}_', '', e) new = '/'.join(elems) setattr(fs, field_attr, new) def reverse_fileout_inputs(fo): count = len(fo.inputs) for i in range(count): fo.inputs.move(count-1, i) def renumber_keep_existing(fo, offset=10, invert=True): '''Renumber by keeping existing numbers and inserting new one whenever possible Big and ugly function that do the trick nonetheless... ''' if fo.type != 'OUTPUT_FILE': return ct = 10 if invert: reverse_fileout_inputs(fo) fsl = fo.layer_slots if fo.format.file_format == 'OPEN_EXR_MULTILAYER' else fo.file_slots last_idx = len(fsl) - 1 prev = None prev_num = None for idx, fs in enumerate(fsl): # print('-->', idx, fs.path) if idx == last_idx: # handle last if get_num(fs) is not None: break if idx > 0: prev = fsl[idx-1] num = get_num(prev) if num is not None: add_fileslot_number(fs, num + offset) else: add_fileslot_number(fs, ct) else: add_fileslot_number(fs, 10) # there is only one slot (maybe don't number ?) break # update the ct with the current taken number if any number = get_num(fs) if number is not None: prev = fs ct = number + offset continue # skip already numbered # analyse all next slots until there is numbered divider = 0 # print(f'range(1, {len(fsl) - idx}') for i in range(1, len(fsl) - idx): next_num = get_num(fsl[idx + i]) if next_num is not None: divider = i+1 break if idx == 0: # handle first prev_num = 0 prev = None if next_num is None: add_fileslot_number(fs, 0) elif next_num == 0: print(f'Cannot insert value before 0 to {fsl.path}') continue else: add_fileslot_number(fs, int(next_num / 2)) else: prev = fsl[idx-1] test_prev = get_num(prev) if test_prev is not None: prev_num = test_prev if not divider: if prev_num is not None: add_fileslot_number(fs, prev_num + offset) else: add_fileslot_number(fs, ct) else: if prev_num is not None: # iterate rename gap_inc = int((next_num - prev_num) / divider) if gap_inc < 1: # same values ! print(f'cannot insert a median value at {fs.path} between {prev_num} and {next_num}') continue ct = prev_num for temp_id in range(idx, idx+i): ct += gap_inc add_fileslot_number(fsl[temp_id], ct) else: print("what's going on ?\n") # first check if it has a number (if not bas) prev = fs ct += offset if invert: reverse_fileout_inputs(fo) def has_channel_color(layer): '''Return True if gp_layer.channel_color is different than the default (0.2, 0.2, 0.2) ''' if not any(isclose(i, 0.2, abs_tol=0.001) for i in layer.channel_color): return True def normalize(text): return text.lower().replace('-', '_') PATTERN = r'^(?P-\s)?(?P[A-Z]{2}_)?(?P.*?)(?P_[A-Z]{2})?(?P\.\d{3})?$' # numering def normalize_layer_name(layer, prefix='', desc='', suffix='', lower=True, dash_to_underscore=True, point_to_underscore=True, get_only=False): '''GET a layer and argument to build and assign name''' import re name = layer.info pattern = PATTERN sep = '_' res = re.search(pattern, name.strip()) grp = '' if res.group('grp') is None else res.group('grp') tag = '' if res.group('tag') is None else res.group('tag') # tag2 = '' if res.group('tag2') is None else res.group('tag2') name = '' if res.group('name') is None else res.group('name') sfix = '' if res.group('sfix') is None else res.group('sfix') inc = '' if res.group('inc') is None else res.group('inc') if grp: grp = ' ' + grp # name is strip(), so grp first spaces are gones. if prefix: if prefix == 'prefixkillcode': tag = '' else: tag = prefix.upper().strip() + sep # if prefix2: # tag2 = prefix2.upper().strip() + sep if desc: name = desc if suffix: if suffix == 'suffixkillcode': sfix = '' else: sfix = sep + suffix.upper().strip() # check if name is available without the increment ending if lower: name = name.lower() if dash_to_underscore: name = name.replace('-', '_') if point_to_underscore: name = name.replace('.', '_') new = f'{grp}{tag}{name}{sfix}' # lower suffix ? if get_only: return new if new != layer.info: old = layer.info print(f'{old} >> {new}') layer.info = new # Also change name string in modifier target ! for ob in [o for o in bpy.data.objects if o.type == 'GPENCIL' and o.data == layer.id_data]: for m in ob.grease_pencil_modifiers: if hasattr(m, 'layer') and m.layer: if m.layer == old: print(f' - updated in {ob.name} modifier {m.name}') m.layer = new # unused currently def build_dope_gp_list(layer_list): '''Take a list of GP layers return a dict with pairs {gp data : own layer list}''' from collections import defaultdict gps = defaultdict(list) for l in layer_list: gps[l.id_data].append(l) return gps def build_layers_targets_from_dopesheet(context): '''Return all selected layers on context GP dopesheet according to seelction and filters''' ob = context.object gpl = context.object.data.layers act = gpl.active dopeset = context.space_data.dopesheet if dopeset.show_only_selected: pool = [o for o in context.selected_objects if o.type == 'GPENCIL'] else: pool = [o for o in context.scene.objects if o.type == 'GPENCIL'] if not dopeset.show_hidden: pool = [o for o in pool if o.visible_get()] layer_pool = [l for o in pool for l in o.data.layers] layer_pool = list(set(layer_pool)) # remove dupli-layers from same data source with # apply search filter if dopeset.filter_text: layer_pool = [l for l in layer_pool if (dopeset.filter_text.lower() in l.info.lower()) ^ dopeset.use_filter_invert] return layer_pool """ # old show message gox without operator support def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'): '''get a str to display or a list of [str, str] can have an icon [[str, icon], str, [str, icon]] ''' def draw(self, context): for l in _message: if isinstance(l, str): self.layout.label(text=l) else: self.layout.label(text=l[0], icon=l[1]) if isinstance(_message, str): _message = [_message] bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon) """ 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] ''' def draw(self, context): for l in _message: if isinstance(l, str): self.layout.label(text=l) else: if len(l) == 2: # label with icon self.layout.label(text=l[0], icon=l[1]) elif len(l) == 3: # ops self.layout.operator_context = "INVOKE_DEFAULT" self.layout.operator(l[0], text=l[1], icon=l[2], emboss=False) # <- highligh the entry if isinstance(_message, str): _message = [_message] bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon) ## -- camera framing and object anim checks def get_bbox_3d(ob) -> list: bbox_coords = ob.bound_box return [ob.matrix_world @ Vector(b) for b in bbox_coords] def is_render_included(o, scn) -> bool: '''return True if object is in at least one non-excluded collection in all passed scene viewlayer ''' if o.hide_render: return False for vl in scn.view_layers: all_cols = get_collection_childs_recursive(vl.layer_collection) for c in all_cols: print(c.name) if o in c.collection.objects[:]: if not c.exclude: return True return False def get_crop_pixel_coord(scn) -> dict: # width height probably not needed. might need px_width = (scn.render.border_max_x - scn.render.border_min_x) * scn.render.resolution_x px_height = (scn.render.border_max_y - scn.render.border_min_y) * scn.render.resolution_y pos_x = (scn.render.border_min_x + ((scn.render.border_max_x - scn.render.border_min_x) / 2)) * scn.render.resolution_x ## coord y > image center coord from bottom-left (Blender) # pos_y = (scn.render.border_min_y + ((scn.render.border_max_y - scn.render.border_min_y) / 2)) * scn.render.resolution_y, ## image center coord from top-left (AE) pos_y = ((1 - scn.render.border_max_y) + ((scn.render.border_max_y - scn.render.border_min_y) / 2)) * scn.render.resolution_y coord = { 'position_x' : round(pos_x), 'position_y' : round(pos_y), 'width' : round(px_width), 'height' : round(px_height), 'scene_res_x': scn.render.resolution_x, 'scene_res_y': scn.render.resolution_y, } return coord def export_crop_to_json() -> dict: '''Export crop to json coords for AE''' blend = Path(bpy.data.filepath) ## Use a fixed name (easier to load from After effects) json_path = blend.parent / 'render' / 'crop_infos.json' ## Use blend name (to support version) # json_path = blend.parent / 'render' / f'{blend.stem}.json' ## per scene : json_path = Path(bpy.data.filepath).parent / 'render' / f'{scn.name}.json' # json_path = Path(bpy.data.filepath).parent / 'render' / f'{scn.name}.json' # f'{ob.name}.json' coord_dic = {} for scn in bpy.data.scenes: if scn.render.use_border and scn.render.use_crop_to_border: # Only usefull if cropped scn_border = get_crop_pixel_coord(scn) ## Only scn name (meaning only one name to refer if multiple GP) # coord_dic[scn.name] = scn_border ## use name of first found visible GP (scene name if no visible GP) gps = [o for o in scn.objects if o.type == 'GPENCIL' if is_render_included(o, scn)] # o.visible_get() < only work on active window if gps and scn.name != 'Scene': # always export Scene with Scene name... for ob in gps: coord_dic[ob.name] = scn_border print(f'Added gp {ob.name} crop info') else: coord_dic[scn.name] = scn_border print(f'Added scene {scn.name} crop info') if coord_dic: json_path.parent.mkdir(parents=False, exist_ok=True) # save bbox with json_path.open('w') as fd: json.dump(coord_dic, fd, indent='\t') print(f'Coords saved at: {json_path}') return coord_dic def set_border_region_from_coord(coords, scn=None, margin=30, export_json=True): '''Get a list of point coord in worldcamera view space (0 to 1) on each axis set border (of passed scene :scn: ) with given coordinate return the coords list as pixel coordinate ''' scn = scn or bpy.context.scene coords2d_x = sorted([c[0] for c in coords]) coords2d_y = sorted([c[1] for c in coords]) margin_width = margin / scn.render.resolution_x margin_height = margin / scn.render.resolution_y # set crop scn.render.border_min_x = coords2d_x[0] - margin_width scn.render.border_max_x = coords2d_x[-1] + margin_width scn.render.border_min_y = coords2d_y[0] - margin_height scn.render.border_max_y = coords2d_y[-1] + margin_height ## get clamped relative value # relative_bbox2d_coords = [ # (scn.render.border_min_x, scn.render.border_min_y), # (scn.render.border_min_x, scn.render.border_max_y), # (scn.render.border_max_x, scn.render.border_max_y), # (scn.render.border_max_x, scn.render.border_min_y), # ] pixel_bbox2d_coords = [ (scn.render.border_min_x*scn.render.resolution_x, scn.render.border_min_y*scn.render.resolution_y), (scn.render.border_min_x*scn.render.resolution_x, scn.render.border_max_y*scn.render.resolution_y), (scn.render.border_max_x*scn.render.resolution_x, scn.render.border_max_y*scn.render.resolution_y), (scn.render.border_max_x*scn.render.resolution_x, scn.render.border_min_y*scn.render.resolution_y), ] # if export_json: # export_crop_to_json(scn) return pixel_bbox2d_coords def get_gp_box_all_frame(ob, cam=None): '''set crop to object bounding box considering whole animation. Cam should not be animated (render in bg_cam) return 2d bbox in pixels ''' from bpy_extras.object_utils import world_to_camera_view coords_cam_list = [] scn = bpy.context.scene cam = cam or scn.camera start = time() if ob.animation_data and ob.animation_data.action: # use frame set on all frames print(f'{ob.name} has anim') # frame_nums = sorted(list(set([f.frame_number for l in ob.data.layers if len(l.frames) for f in l.frames if len(f.strokes) and scn.frame_start <= f.frame_number <= scn.frame_end]))) for num in range(scn.frame_start, scn.frame_end+1): scn.frame_set(num) for l in ob.data.layers: if l.hide or l.opacity == 0.0: continue if l.active_frame: for s in l.active_frame.strokes: if len(s.points) == 1: # skip isolated points continue coords_cam_list += [world_to_camera_view(scn, cam, ob.matrix_world @ p.co) for p in s.points] else: # if object is not animated no need to frame_set to update object position print(f'{ob.name} no anim') for l in ob.data.layers: if l.hide or l.opacity == 0.0: continue for f in l.frames: if not (scn.frame_start <= f.frame_number <= scn.frame_end): continue for s in f.strokes: if len(s.points) == 1: # skip isolated points continue coords_cam_list += [world_to_camera_view(scn, cam, ob.matrix_world @ p.co) for p in s.points] print(time() - start) # Dbg-time return coords_cam_list def has_anim(ob): # TODO make a better check (check if there is only one key in each channel, count as not animated) return ob.animation_data and ob.animation_data.action def has_keyframe(ob, attr): anim = ob.animation_data if anim is not None and anim.action is not None: for fcu in anim.action.fcurves: if fcu.data_path == attr: return len(fcu.keyframe_points) > 0 return False def get_gp_box_all_frame_selection(oblist=None, scn=None, cam=None, timeout=40): ''' get points of all selection return 2d bbox in pixels return None if timeout (too long to process, better to do it visually) ''' from bpy_extras.object_utils import world_to_camera_view t0 = time() coords_cam_list = [] scn = scn or bpy.context.scene oblist = oblist or [o for o in scn.objects if o.select_get()] cam = cam or scn.camera start = time() if any(has_anim(ob) for ob in oblist): print(f'at least one is animated: {oblist}') for num in range(scn.frame_start, scn.frame_end+1): scn.frame_set(num) for ob in oblist: for l in ob.data.layers: if l.hide or l.opacity == 0.0 or l.info.startswith('MA_'): # hardcoded Mask exclusion ! continue if not l.active_frame: continue for s in l.active_frame.strokes: if len(s.points) == 1: # skip isolated points continue coords_cam_list += [world_to_camera_view(scn, cam, ob.matrix_world @ p.co) for p in s.points] if time() - t0 > timeout: print(f'timeout (more than {timeout}s to calculate) evaluating frame position of objects {oblist}') return else: print(f'No anim') for ob in oblist: # if object is not animated no need to frame_set to update object position for l in ob.data.layers: if l.hide or l.opacity == 0.0 or l.info.startswith('MA_'): # hardcoded Mask exclusion ! continue for f in l.frames: if time() - t0 > timeout: print(f'timeout (more than {timeout}s to calculate) evaluating frame position of objects {oblist}') return if not (scn.frame_start <= f.frame_number <= scn.frame_end): continue for s in f.strokes: if len(s.points) == 1: # skip isolated points continue coords_cam_list += [world_to_camera_view(scn, cam, ob.matrix_world @ p.co) for p in s.points] print(f'{len(coords_cam_list)} gp points listed {time() - start:.1f}s') return coords_cam_list def get_bbox_2d(ob, cam=None): from bpy_extras.object_utils import world_to_camera_view scn = bpy.context.scene cam = cam or scn.camera coords2d = [world_to_camera_view(scn, cam, p) for p in get_bbox_3d(ob)] coords2d_x = sorted([c[0] for c in coords2d]) coords2d_y = sorted([c[1] for c in coords2d]) bbox2d_coords = [ (coords2d_x[0], coords2d_y[0]), (coords2d_x[0], coords2d_y[-1]), (coords2d_x[-1], coords2d_y[-1]), (coords2d_x[-1], coords2d_y[0]), ] return [Vector(b) for b in bbox2d_coords] def set_box_from_selected_objects(scn=None, cam=None, export_json=False): scn = scn or bpy.context.scene cam = cam or scn.camera selection = [o for o in scn.objects if o.select_get()] # selected_objects coords = get_gp_box_all_frame_selection(oblist=selection, scn=scn, cam=cam) if not coords: return f'Border not set: Timeout during analysis of {len(selection)} objects' _bbox_px = set_border_region_from_coord(coords, margin=30, scn=scn, export_json=export_json) def get_cam_frame_center_world(cam): '''get camera frame center world position in 3d space''' ## ortho cam note: scale must be 1,1,1 (parent too) to fit right in cam-frame rectangle import numpy as np frame = cam.data.view_frame() mat = cam.matrix_world frame = [mat @ v for v in frame] # return np.add.reduce(frame) / 4 return Vector(np.sum(frame, axis=0) / 4) def get_coord_in_cam_space(scene, cam_ob, co, ae=False): '''Get 2d coordinate of vector in cam space :scene: scene where camera is used (needed to get resolution) :cam_ob: camera object :co: the Vector3 coordinate to find in cam space :ae: if True, Return after effects coord, top-left corner origin (blender is bottom-left) ''' import bpy_extras co_2d = bpy_extras.object_utils.world_to_camera_view(scene, cam_ob, co) if ae: # y coordinate from top co_2d = Vector((co_2d.x, 1 - co_2d.y)) ## Convert to pixel values based on scene resolution and percentage render_scale = scene.render.resolution_percentage / 100 render_size = ( int(scene.render.resolution_x * render_scale), int(scene.render.resolution_y * render_scale), ) return ( round(co_2d.x * render_size[0]), # x round(co_2d.y * render_size[1]), # y ) ## -- After effects exports def get_ae_keyframe_clipboard_header(scn): '''Need to use tabs for AE''' ## multiline version, but space instead of tabs break syntax for AE # import textwrap # t = f'''\ # Adobe After Effects 8.0 Keyframe Data # Units Per Second {scn.render.fps} # Source Width {scn.render.resolution_x} # Source Height {scn.render.resolution_y} # Source Pixel Aspect Ratio 1 # Comp Pixel Aspect Ratio 1 # Transform Position # Frame X pixels Y pixels Z pixels # ''' # t = textwrap.dedent(t) # ## spaces to tab # t = re.sub(r'\s{3-4}', u'\t', t) # ! Still spaces ## Direct use of tabs is safer t = 'Adobe After Effects 8.0 Keyframe Data\n\n' t += '\tUnits Per Second\t%s\n'%scn.render.fps t += '\tSource Width\t%s\n'%scn.render.resolution_x t += '\tSource Height\t%s\n'%scn.render.resolution_y t += '\tSource Pixel Aspect Ratio\t1\n' t += '\tComp Pixel Aspect Ratio\t1\n\n' t += 'Transform\tPosition\n' t += '\tFrame\tX pixels\tY pixels\tZ pixels\t\n' return t ## -- Collection handle def get_collection_childs_recursive(col, cols=[], include_root=True): '''return a list of all the sub-collections in passed col''' # force start from fresh list (otherwise same cols list is used at next call) cols = cols or [] for sub in col.children: if sub not in cols: cols.append(sub) if len(sub.children): cols = get_collection_childs_recursive(sub, cols) if include_root and col not in cols: # add root col cols.append(col) return cols def unlink_objects_from_scene(oblist, scn): all_col = get_collection_childs_recursive(scn.collection) for col in all_col: for ob in reversed(col.objects): if ob in oblist: col.objects.unlink(ob) def remove_scene_nodes_by_obj_names(scn, name_list, negative=False): for n in reversed(scn.node_tree.nodes): if negative: if (n.parent and n.parent.label not in name_list) or (n.type == 'FRAME' and n.label not in name_list): scn.node_tree.nodes.remove(n) else: if (n.parent and n.parent.label in name_list) or (n.type == 'FRAME' and n.label in name_list): scn.node_tree.nodes.remove(n) def split_object_to_scene(objs=None, scene_name=None): '''Create a new scene from object selection''' if not scene_name: active = bpy.context.object scene_name = active.name if not objs: objs = [o for o in bpy.context.selected_objects] if bpy.data.scenes.get(scene_name): print(f'Scene "{scene_name}" Already Exists') raise Exception(f'Scene "{scene_name}" Already Exists') src = bpy.context.scene bpy.ops.scene.new(type='LINK_COPY') new = bpy.context.scene new.name = scene_name ## OPT ## set individual output for composite or go in /tmp ? (might not be needed) # new.render.filepath = f'//render/preview/{bpy.path.clean_name(new.name.lower())}/preview_' # new.render.filepath = f'/tmp/' ## unlink unwanted objects from collection all_col = get_collection_childs_recursive(new.collection) for col in all_col: for sob in reversed(col.objects): if sob.type in ('CAMERA', 'LIGHT'): continue if sob not in objs: col.objects.unlink(sob) frame_names = [n.label for n in new.node_tree.nodes if n.type == 'FRAME' if new.objects.get(n.label)] remove_scene_nodes_by_obj_names(new, frame_names, negative=True) bpy.ops.gp.clean_compo_tree() # add crop new.render.use_border = True new.render.use_crop_to_border = True new.render.use_compositing = True new.render.use_sequencer = False ## remove asset from original scene #src_frame_names = [n.label for n in src.node_tree.nodes if n.type == 'FRAME' and n.label in [o.name for o in objs]] #remove_scene_nodes_by_obj_names(src, src_frame_names) remove_scene_nodes_by_obj_names(src, frame_names, negative=False) # unlink objects ? unlink_objects_from_scene(objs, src) # border to GP objects of the scene gp_objs = [o for o in new.objects if o.type == 'GPENCIL'] coords = get_gp_box_all_frame_selection(oblist=gp_objs, scn=new, cam=new.camera) if not coords: return f'Scene "{scene_name}" created. But Border was not set (Timeout during GP analysis), should be done by hand if needed then use export crop to json' set_border_region_from_coord(coords, margin=30, scn=new, export_json=True) export_crop_to_json() def clear_frame_out_of_range(o, verbose=False): '''get a GP object delete frame out of active scene range in all layers return number of deleted frame ''' scn = bpy.context.scene ct = 0 if o.type != 'GPENCIL': print(f'{o.name} not a Gpencil') return 0 for l in o.data.layers: first = True for f in reversed(l.frames): # after if f.frame_number > scn.frame_end + 1: if verbose: print(f'del: obj {o.name} > layer {l.info} > frame {f.frame_number}') l.frames.remove(f) ct += 1 # before elif f.frame_number < scn.frame_start - 1: if first: first = False continue if verbose: print(f'del: obj {o.name} > layer {l.info} > frame {f.frame_number}') l.frames.remove(f) ct += 1 # print('INFO', f'{ct} frames deleted') return ct ## not used def clear_frame_out_of_range_all_object(): scene = bpy.context.scene ct = 0 for o in scene.objects: if o.type == 'GPENCIL': nct = clear_frame_out_of_range(o, verbose=False) print(f'{o.name}: {nct} frames deleted') ct += nct print(f'{ct} gp frames deleted') return ct def set_scene_output_from_active_fileout_item(): scn = bpy.context.scene rd = scn.render ntree =scn.node_tree fo = ntree.nodes.active if fo.type != 'OUTPUT_FILE': return sl = fo.file_slots[fo.active_input_index] full_path = os.path.join(fo.base_path, sl.path) rd.filepath = full_path fmt = fo.format if sl.use_node_format else sl.format ## set those attr first to avoid error settings other attributes in next loop rd.image_settings.file_format = fmt.file_format rd.image_settings.color_mode = fmt.color_mode rd.image_settings.color_depth = fmt.color_depth if fmt.color_depth else 8 # Force set since Sometimes it's weirdly set to "" (not in enum choice) excluded = ['file_format', 'color_mode', 'color_depth', 'view_settings', 'views_format'] ''' ## all attrs # 'cineon_black', 'cineon_gamma', 'cineon_white', # 'color_depth', 'color_mode', 'compression', 'display_settings', # 'exr_codec', 'file_format', 'jpeg2k_codec', 'quality', # 'rna_type', 'stereo_3d_format', 'tiff_codec', 'use_cineon_log', # 'use_jpeg2k_cinema_48', 'use_jpeg2k_cinema_preset', 'use_jpeg2k_ycc', # 'use_preview', 'use_zbuffer'] ''' for attr in dir(fmt): if attr.startswith('__') or attr.startswith('bl_') or attr in excluded: continue if hasattr(scn.render.image_settings, attr) and not scn.render.image_settings.is_property_readonly(attr): setattr(scn.render.image_settings, attr, getattr(fmt, attr)) def set_layer_colors(skip_if_colored=False): '''Set hardcoded color to grease pencil layer channel according to prefixes''' ## UW -> TO (here used fo CU): (0.015996, 0.246201, 0.246201) # Indigo ## invisible (close to violet light) in UW: (0.246201, 0.132868, 0.496933) prefix_color = { # 'MA_': (0.09, 0.08, 0.46), # Vivid blue 'MA_': (0.65, 0.4, 0.6), # Pink Light 'FX_': (0.12, 0.33, 0.58), # (0.3, 0.49, 0.63) # Blue Light # 'CO_': (0.35, 0.0085, 0.25), 'CO_': (0.5,0.1,0.5), # Clear Pink # 'CU': (0.092070, 0.177356, 0.447959), # Blue clear 'CU_': (0.02, 0.27, 0.27), # Indigo } for ob in bpy.context.scene.objects: if ob.type != 'GPENCIL': continue if skip_if_colored and any([has_channel_color(l) for l in ob.data.layers]): continue for l in ob.data.layers: # if l.info.startswith(prefix_color.keys()): color = prefix_color.get(l.info[:3]) if not color: continue print(l.info, '->', color) l.channel_color = color bpy.context.preferences.edit.use_anim_channel_group_colors = True def different_gp_mat(mata, matb): '''return None if no difference (False), string describing color difference (True)''' a = mata.grease_pencil b = matb.grease_pencil if a.color[:] != b.color[:]: return f'{mata.name} and {matb.name} stroke color is different' if a.fill_color[:] != b.fill_color[:]: return f'{mata.name} and {matb.name} fill_color color is different' if a.show_stroke != b.show_stroke: return f'{mata.name} and {matb.name} stroke has different state' if a.show_fill != b.show_fill: return f'{mata.name} and {matb.name} fill has different state' ## Clean dups def clean_mats_duplication(ob, skip_different_materials=True): '''Clean object material stack of duplication if a material is named "mat.001" and a "mat" exists, replace with the one with original name :skip_different_materials: Don't replace a "mat.???" if orignal "mat" has different color ''' import re diff_ct = 0 todel = [] if ob.type != 'GPENCIL': return if not hasattr(ob, 'material_slots'): return for i, ms in enumerate(ob.material_slots): mat = ms.material if not mat: continue match = re.search(r'(.*)\.\d{3}$', mat.name) if not match: continue basemat = bpy.data.materials.get(match.group(1)) if not basemat: continue if not basemat.is_grease_pencil: ## Skip material with matching "stem" but not being GP material continue diff = different_gp_mat(mat, basemat) if diff: print(f'! {ob.name} : {diff}') diff_ct += 1 if skip_different_materials: continue if mat not in todel: todel.append(mat) ms.material = basemat print(f'{ob.name} : slot {i} >> replaced {mat.name}') mat.use_fake_user = False ### delete (only when using on all objects loop, else can delete another objects mat...) ## for m in reversed(todel): ## bpy.data.materials.remove(m) if diff_ct: print(f'{diff_ct} mat skipped >> same name but different color settings!') # return ('INFO', f'{diff_ct} mat skipped >> same name but different color settings!') def recursive_node_connect_check(l, target_node): 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 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()