From 64efb7e39528b7f78ba0a8f875188573c0194113 Mon Sep 17 00:00:00 2001 From: Pullusb Date: Wed, 26 Jan 2022 16:32:33 +0100 Subject: [PATCH] render checkers and codefix 0.9.4 - feat: `Renumber files on disk` option using number in file outputs (under advanced gp render options) - feat: new `Check for problems` button, check if problem in layer state, missing file out, broken gp modifier target and report - added: clean nodes now also rearrange inside nodegroup - changed: `Check layers` now trigger `export layer infos` automatically. - fix: `export layer infos`: - create render folder if necessary - masks list in json file use name as keys instead of sub-value --- CHANGELOG.md | 10 +++ OP_check_layer_status.py | 84 -------------------- OP_check_scene.py | 138 +++++++++++++++++++++++--------- OP_clean.py | 11 +++ OP_post_render.py | 167 +++++++++++++++++++++++++++++++++++++++ OP_setup_layers.py | 36 ++++++--- __init__.py | 9 ++- fn.py | 48 +++++++++++ ui.py | 10 ++- 9 files changed, 376 insertions(+), 137 deletions(-) delete mode 100644 OP_check_layer_status.py create mode 100644 OP_post_render.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f3303..a35ee58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ Activate / deactivate layer opaticty according to prefix Activate / deactivate all masks using MA layers --> +0.9.4 + +- feat: `Renumber files on disk` option using number in file outputs (under advanced gp render options) +- feat: new `Check for problems` button, check if problem in layer state, missing file out, broken gp modifier target and report +- added: clean nodes now also rearrange inside nodegroup +- changed: `Check layers` now trigger `export layer infos` automatically. +- fix: `export layer infos`: + - create render folder if necessary + - masks list in json file use name as keys instead of sub-value + 0.9.3 - feat: export a json with layers info for compo. Masks, opacity, blend mode diff --git a/OP_check_layer_status.py b/OP_check_layer_status.py deleted file mode 100644 index 1835e56..0000000 --- a/OP_check_layer_status.py +++ /dev/null @@ -1,84 +0,0 @@ -import bpy -from . import fn - -## not used, replaced by "setup_layers.py" -class GPEXP_OT_check_layers_state(bpy.types.Operator): - bl_idname = "gp.check_layers_state" - bl_label = "Check Layers State" - bl_description = "Display state of layer that migh need adjustement" - bl_options = {"REGISTER"} # , "UNDO" - - # clear_unused_view_layers : bpy.props.BoolProperty(name="Clear unused view layers", - # description="Delete view layer that aren't used in the nodetree anymore", - # default=True) - - @classmethod - def poll(cls, context): - return context.object and context.object.type == 'GPENCIL' - - def invoke(self, context, event): - self.ctrl=event.ctrl - self.alt=event.alt - return self.execute(context) - return context.window_manager.invoke_props_dialog(self) - - def draw(self, context): - layout = self.layout - # layout.prop(self, 'clear_unused_view_layers') - - def execute(self, context): - if self.alt: - pool = [o for o in context.selected_objects if o.type == 'GPENCIL'] - else: - pool = [context.object] - - # TODO create a list to disaply everything in a message box ? - - for ob in pool: - layers = ob.data.layers - for l in layers: - used = False - if l.mask_layers: - print(f'-> masks') - state = '' if l.use_mask_layer else ' (disabled)' - print(f'{ob.name} > {l.info}{state}:') - used = True - for ml in l.mask_layers: - mlstate = ' (disabled)' if ml.hide else '' - mlinvert = ' <>' if ml.invert else '' - print(f' - {ml.info}{mlstate}{mlinvert}') - - if l.opacity != 1: - print(f'-> opacity {l.opacity}') - used = True - - if l.use_lights: - print(f'-> use lights !') - used = True - if l.blend_mode != 'REGULAR': - print(f'-> blend mode "{l.blend_mode}" !') - used = True - - if used: - print() - - # render = bpy.data.scenes.get('Render') - # if not render: - # print('SKIP, no Render scene') - # return {"CANCELLED"} - - return {"FINISHED"} - - - -classes=( -GPEXP_OT_check_layers_state, -) - -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/OP_check_scene.py b/OP_check_scene.py index a831c2c..2e2ef80 100644 --- a/OP_check_scene.py +++ b/OP_check_scene.py @@ -1,7 +1,83 @@ -import bpy +import bpy, re from . import fn -## not used, replaced by "setup_layers.py" +def check_broken_modifier_target(pool=None, reports=None): + if not reports: + reports = [] + if not pool: + pool = [o for o in bpy.context.scene.objects if o.type == 'GPENCIL'] + + for o in pool: + lay_name_list = [l.info for l in o.data.layers] + for m in o.grease_pencil_modifiers: + if not hasattr(m, 'layer'): + continue + if not m.layer in lay_name_list: + reports.append(f'Broken modifier target :{o.name} > {m.name} > {m.layer}') + # else: + # print(f'Modifier target :{o.name} > {m.name} > ok') + + return reports + +def check_layer_state(pool=None, reports=None): + if not reports: + reports = [] + if not pool: + pool = [o for o in bpy.context.scene.objects if o.type == 'GPENCIL'] + for ob in pool: + layers = ob.data.layers + for l in layers: + # if l.mask_layers: + # if not any(not x.hide for x in l.mask_layers): + # # all masks disable + # pass + + ## just list masks + # state = '' if l.use_mask_layer else ' (disabled)' + # reports.append(f'{ob.name} > {l.info} masks{state}:') + # for ml in l.mask_layers: + # mlstate = ' (disabled)' if ml.hide else '' + # mlinvert = ' <>' if ml.invert else '' + # reports.append(f' - {ml.name}{mlstate}{mlinvert}') + + if l.opacity != 1: + reports.append(f'{ob.name} > {l.info} > opacity {l.opacity}') + + # if l.use_lights: + # reports.append(f'-> use lights !') + + if l.blend_mode != 'REGULAR': + reports.append(f'{ob.name} > {l.info} > blend mode "{l.blend_mode}" !') + + return reports + +def check_file_output_numbering(reports=None): + if not reports: + reports = [] + prenum = re.compile(r'\d{3}_') + file_outs = [] + for S in bpy.data.scenes: + if S.name == 'Scene' or not S.node_tree or not S.use_nodes: + continue + file_outs += [n for n in S.node_tree.nodes if n.type == 'OUTPUT_FILE'] + + used=False + + if not file_outs: + reports.append('No file output nodes found') + return reports + + for fo in file_outs: + if not prenum.match(fo.base_path.split('/')[-1]): + reports.append(f'No object numbering : node {fo.name}') + pct = 0 + for fs in fo.file_slots: + if not prenum.match(fs.path.split('/')[0]): + pct += 1 + if pct: + reports.append(f'{pct}/{len(fo.file_slots)} slots not numbered: node {fo.name}') + + return reports class GPEXP_OT_check_render_scene(bpy.types.Operator): bl_idname = "gp.check_render_scene" bl_label = "Check render scene" @@ -25,48 +101,34 @@ class GPEXP_OT_check_render_scene(bpy.types.Operator): # layout.prop(self, 'clear_unused_view_layers') def execute(self, context): - gp_objs = [o for o in context.scene.objects if o.type == 'GPENCIL'] + reports = [] + # check gp modifiers + broken_mods = check_broken_modifier_target() + if broken_mods: + reports.append('GP modifiers targets:') + reports += broken_mods + # check layers + layer_state = check_layer_state() + if layer_state: + if reports: reports.append('') + reports.append('Layers State:') + reports += layer_state - # TODO create a list to disaply everything in a message box ? + # check file output numbering + numbering_problems = check_file_output_numbering() + if numbering_problems: + if reports: reports.append('') + reports.append('File output numbering:') + reports += numbering_problems - for ob in pool: - layers = ob.data.layers - for l in layers: - used = False - if l.mask_layers: - print(f'-> masks') - state = '' if l.use_mask_layer else ' (disabled)' - print(f'{ob.name} > {l.info}{state}:') - used = True - for ml in l.mask_layers: - mlstate = ' (disabled)' if ml.hide else '' - mlinvert = ' <>' if ml.invert else '' - print(f' - {ml.info}{mlstate}{mlinvert}') + if not reports: + self.report({'INFO'}, 'All OK !') + else: + fn.show_message_box(_message=reports, _title='Potential Problems list') - if l.opacity != 1: - print(f'-> opacity {l.opacity}') - used = True - - if l.use_lights: - print(f'-> use lights !') - used = True - if l.blend_mode != 'REGULAR': - print(f'-> blend mode "{l.blend_mode}" !') - used = True - - if used: - print() - - # render = bpy.data.scenes.get('Render') - # if not render: - # print('SKIP, no Render scene') - # return {"CANCELLED"} - return {"FINISHED"} - - classes=( GPEXP_OT_check_render_scene, ) diff --git a/OP_clean.py b/OP_clean.py index 35be461..eb3cd41 100644 --- a/OP_clean.py +++ b/OP_clean.py @@ -73,6 +73,10 @@ class GPEXP_OT_clean_compo_tree(bpy.types.Operator): description="Reorder inputs/outputs of all 'NG_' nodegroup and their connected file output", default=True) + clear_isolated_node_in_groups : bpy.props.BoolProperty(name="Clear Isolated Node In Groups", + description="Clean content of 'NG_' nodegroup bpy deleting isolated nodes)", + default=True) + fo_clear_disconnected : bpy.props.BoolProperty(name="Remove Disconnected Export Inputs", description="Clear any disconnected intput of every 'file output' node", default=False) @@ -91,6 +95,7 @@ class GPEXP_OT_clean_compo_tree(bpy.types.Operator): layout.prop(self, 'arrange_rl_nodes') layout.prop(self, 'arrange_frames') layout.prop(self, 'reorder_inputs') + layout.prop(self, 'clear_isolated_node_in_groups') layout.separator() layout.prop(self, 'fo_clear_disconnected') @@ -146,6 +151,12 @@ class GPEXP_OT_clean_compo_tree(bpy.types.Operator): fn.bridge_reconnect_nodegroup(n) + if self.clear_isolated_node_in_groups: + for n in nodes: + if n.type != 'GROUP' or not n.name.startswith('NG_'): + continue + fn.clear_nodegroup_content_if_disconnected(n.node_tree) + if self.fo_clear_disconnected: for fo in nodes: if fo.type != 'OUTPUT_FILE': diff --git a/OP_post_render.py b/OP_post_render.py new file mode 100644 index 0000000..1575e17 --- /dev/null +++ b/OP_post_render.py @@ -0,0 +1,167 @@ +import bpy +import os +from pathlib import Path +import re +from time import time + + +def renumber_sequence_on_disk_from_file_slots(apply=True, active_scene_only=False): + '''renumber sequence on disk from scenes file slots''' + + scn = bpy.context.scene + blend = Path(bpy.data.filepath) + render = blend.parent / 'render' + + prenum = re.compile(r'\d{3}_') + + print('-- starting rename sequences numbers from fileslots number') + if not apply: + print('-- Dry run') + + t0 = time() + ct = 0 + + if active_scene_only: + # Only on currrent scene + file_outs = [n for n in bpy.context.scene.node_tree.nodes if n.type == 'OUTPUT_FILE' and n.name.startswith('OUT_') and not n.mute] + else: + # multi scene check: + file_outs = [] + for S in bpy.data.scenes: + if S.name == 'Scene' or not S.node_tree or not S.use_nodes: + continue + file_outs += [n for n in S.node_tree.nodes if n.type == 'OUTPUT_FILE' and n.name.startswith('OUT_') and not n.mute] + + + if not file_outs: + return 'No file output found (should be unmuted nodes with name starting with OUT_)', '_' + + for fo in file_outs: + obj_full = fo.base_path.split('/')[-1] + obj = prenum.sub('', obj_full) + obj_num = prenum.search(obj_full) + if obj_num: + obj_num = obj_num.group(0) + + ## check if folder exists + folder_path = None + + for d in os.scandir(render): + if d.is_dir() and prenum.sub('', d.name) == obj: + folder_path = render / d.name + break + + if not folder_path: + print(f'Could not find obj folder for: {obj}') + continue + + # rename inside folder dirst so that root path isn't changed while iterating + for fs in fo.file_slots: + img_full = fs.path.split('/')[0] + img = prenum.sub('', img_full) + img_num = prenum.search(img_full) + if img_num: + img_num = img_num.group(0) + else: + print(f'! no num : {fo.base_path} : {img_full}') + continue # If no img_num no point in renaming sequences + + img_dir_path = None + + for img_dir in os.scandir(folder_path): + if img_dir.is_dir() and prenum.sub('', img_dir.name) == img: + img_dir_path = folder_path / img_dir.name + break + + if not img_dir_path: + print(f'Could not find img folder for: {img}') + continue + + # if folder exists check if full name is ok + if img_full == img_dir_path.name: + continue # name already (maybe not in sequence but should be good) + + + # rename sequence and image folder + for frame in os.scandir(img_dir_path): + good = img_num + prenum.sub('', frame.name) + if frame.name != good: + print(f' img: {frame.name} > {good}') + ct += 1 + if apply: + fp = Path(frame.path) + fp.rename(fp.parent / good) + + # rename image folder + if img_dir_path.name != img_full: + print(f' dir:{img_dir_path.name} > {img_full}') + ct += 1 + if apply: + img_dir_path.rename(img_dir_path.parent / img_full) + + # rename object folder + if obj_num and folder_path.name != obj_full: + print(f'obj: {folder_path.name} > {obj_full}') + ct += 1 + if apply: + folder_path.rename(folder_path.parent / obj_full) + + elapsed = f'{time() - t0:.2f}s' + print(f'Eslapsed time: {elapsed}') + return ct, elapsed + + +class GPEXP_OT_renumber_files_on_disk(bpy.types.Operator): + bl_idname = "gp.renumber_files_on_disk" + bl_label = "Renumber Files On Disk" + bl_description = "Rename folder/files in render folder on disk according to unmuted file output numbering" + bl_options = {"REGISTER"} + + def invoke(self, context, event): + # return self.execute(context) + return context.window_manager.invoke_props_dialog(self) + + dry_run: bpy.props.BoolProperty(name='Dry-run (no actions, prints in console only)', + default=False, + description='Test mode. If checked, no action is actually performed') + + active_scene_only: bpy.props.BoolProperty(name='Only Active Scene', + default=False, + description='use only file output of active scene instead of all scenes (skipping "Scene")') + + def draw(self, context): + layout = self.layout + layout.prop(self, 'dry_run') + layout.prop(self, 'active_scene_only') + + def execute(self, context): + ct, timing = renumber_sequence_on_disk_from_file_slots(apply = not self.dry_run, active_scene_only=self.active_scene_only) + if isinstance(ct, str): + self.report({'ERROR'}, ct) + return {"CANCELLED"} + + if not ct: + self.report({'WARNING'}, 'Already good or nothing to rename') + return {"CANCELLED"} + + if self.dry_run: + mess = f'Dry run : {ct} items would have been renamed, see console' + else: + mess = f'{ct} items renamed in {timing}' + + self.report({'INFO'}, mess) + return {"FINISHED"} + + + +classes=( +GPEXP_OT_renumber_files_on_disk, +) + +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/OP_setup_layers.py b/OP_setup_layers.py index 23d3a59..4eb24ac 100644 --- a/OP_setup_layers.py +++ b/OP_setup_layers.py @@ -55,7 +55,7 @@ class GPEXP_OT_export_infos_for_compo(bpy.types.Operator): bl_idname = "gp.export_infos_for_compo" bl_label = "Export Infos For Compo" bl_description = "Export informations for compositing, including layers with masks, fusion mode, opacity" - bl_options = {"REGISTER", "UNDO"} + bl_options = {"REGISTER"} @classmethod def poll(cls, context): @@ -110,15 +110,27 @@ class GPEXP_OT_export_infos_for_compo(bpy.types.Operator): if l.use_mask_layer: multi_mask = {} - for i, ml in enumerate(l.mask_layers): + + ## dict key as number for masks + # for i, ml in enumerate(l.mask_layers): + # mask = {} + # if ml.hide: + # continue + # mask['name'] = ml.name + # if ml.invert: # create key get only if inverted + # mask['invert'] = ml.invert + # # multi_mask[ml.name] = mask + # multi_mask[i] = mask + + ## dict key as mask name + for ml in l.mask_layers: mask = {} if ml.hide: continue - mask['name'] = ml.name + # mask['name'] = ml.name if ml.invert: # create key get only if inverted - mask['invert'] = ml.invert - # multi_mask[ml.name] = mask - multi_mask[i] = mask + mask['invert'] = ml.invert # ! no key if no invert + multi_mask[ml.name] = mask if multi_mask: ldic['masks'] = multi_mask @@ -130,6 +142,7 @@ class GPEXP_OT_export_infos_for_compo(bpy.types.Operator): dic[fn.normalize_layer_name(l, get_only=True)] = ldic if dic: + self.l_infos.parent.mkdir(exist_ok=True) # create render folder if needed with self.l_infos.open('w') as fd: json.dump(dic, fd, indent='\t') self.report({'INFO'}, f'Exported json at: {self.l_infos.as_posix()}') @@ -149,9 +162,6 @@ class GPEXP_OT_layers_state(bpy.types.Operator): # description="Delete view layer that aren't used in the nodetree anymore", # default=True) - # TODO : (optional) export layer opacity to json and/or text - # (that way compo artists can re-affect opacity quickly or at least have a reminder) - all_objects : BoolProperty(name='On All Object', default=True, description='On All object, else use selected objects') # , options={'SKIP_SAVE'} @@ -176,10 +186,14 @@ class GPEXP_OT_layers_state(bpy.types.Operator): return context.object and context.object.type == 'GPENCIL' def invoke(self, context, event): - # self.ctrl=event.ctrl - # self.alt=event.alt if event.alt: self.all_objects=True + + ## if no existing infos.json generated, call ops + l_infos = Path(bpy.data.filepath).parent / 'render' / 'infos.json' + if not l_infos.exists(): # only if infos not created + bpy.ops.gp.export_infos_for_compo('INVOKE_DEFAULT') + # return self.execute(context) return context.window_manager.invoke_props_dialog(self) diff --git a/__init__.py b/__init__.py index 044c4cb..f1557c1 100644 --- a/__init__.py +++ b/__init__.py @@ -19,7 +19,8 @@ from . import OP_manage_outputs from . import OP_scene_switch from . import OP_crop_to_object from . import OP_render_scenes -# from . import OP_check_layer_status +from . import OP_check_scene +from . import OP_post_render from . import OP_render_pdf from . import OP_export_to_ae from . import prefs @@ -47,7 +48,8 @@ def register(): OP_scene_switch.register() OP_crop_to_object.register() OP_render_scenes.register() - # OP_check_layer_status.register() + OP_check_scene.register() + OP_post_render.register() OP_render_pdf.register() OP_export_to_ae.register() OP_setup_layers.register() @@ -68,7 +70,8 @@ def unregister(): ui.unregister() OP_setup_layers.unregister() - # OP_check_layer_status.unregister() + OP_check_scene.unregister() + OP_post_render.unregister() OP_export_to_ae.unregister() OP_render_pdf.unregister() OP_render_scenes.unregister() diff --git a/fn.py b/fn.py index 267ba0e..9ae0103 100644 --- a/fn.py +++ b/fn.py @@ -474,16 +474,64 @@ def all_connected_forward(n, 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''' diff --git a/ui.py b/ui.py index 63c0f9a..4b142e1 100644 --- a/ui.py +++ b/ui.py @@ -106,6 +106,7 @@ class GPEXP_PT_gp_node_ui(Panel): col.operator('gp.clean_compo_tree', icon='BRUSHES_ALL', text='Clean Nodes') # NODE_CORNER col.operator('gp.reset_render_settings', icon='SCENE', text='Reset All Scenes Render Settings') + col.operator('gp.check_render_scene', icon='PRESET', text='Check For Problems') col.separator() @@ -123,6 +124,7 @@ class GPEXP_PT_gp_node_ui(Panel): subcol.operator('gp.set_active_fileout_to_compout', icon='OUTPUT', text='Active Slot to Composite') + layout.separator() col=layout.column() @@ -145,6 +147,12 @@ class GPEXP_PT_gp_node_ui(Panel): row.operator('gp.bg_render_script_selected_scenes', icon='TEXT', text='Gen Batch') # row.operator('gp.render_all_scenes', icon='RENDER_ANIMATION', text='Render All') + if advanced: + layout.separator() + col = layout.column() + col.label(text='Post-Render:') + col.operator('gp.renumber_files_on_disk', icon='FILE', text='Renumber Files On Disk') + layout.prop(context.scene, 'use_aa', text='Use Native AA Settings') layout.prop(prefs, 'advanced', text='Show Advanced Options') # layout.operator('gp.add_object_to_render', icon='RENDERLAYERS', text='Layer To Render').mode = 'ALL' @@ -212,7 +220,7 @@ class GPEXP_PT_gp_dopesheet_ui(Panel): row.operator('gp.auto_number_object', icon='OBJECT_DATAMODE', text='Renumber Objects') row.operator('gp.auto_number_object', icon='X', text='').delete = True col.operator('gp.lower_layers_name', icon='SYNTAX_OFF', text='Rename Lowercase') - col.operator('gp.export_infos_for_compo', icon='FILE', text='Export Layers Infos') + col.operator('gp.export_infos_for_compo', icon='FILE', text='Export Layers Infos') # Not really need, called in Check layers invoke col.operator('gp.layers_state', icon='CHECKMARK', text='Check layers') col.operator('gp.check_masks', icon='MOD_MASK', text='Has Masks')