diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db5d1e..d81d765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ Activate / deactivate layer opaticty according to prefix Activate / deactivate all masks using MA layers --> +0.5.0 + +- feat: add a render operator that render all scene +- feat: split selected object to a separated scene +- feat: crop border to objects +- feat: export AE position coordinates to replace layers +- change: ops gp.clean_compo_tree now take context.scene +- fix: tick `use_compositing` and untick `sequencer` in new scenes 0.4.1 diff --git a/OP_add_layer.py b/OP_add_layer.py index 3440d15..315668f 100644 --- a/OP_add_layer.py +++ b/OP_add_layer.py @@ -89,9 +89,29 @@ class GPEXP_OT_add_objects_to_render(bpy.types.Operator): return {"FINISHED"} + +class GPEXP_OT_split_to_scene(bpy.types.Operator): + bl_idname = "gp.split_to_scene" + bl_label = "Split Objects To Scene" + bl_description = "Take selected objects and send them to separate scene" + bl_options = {"REGISTER"} + + @classmethod + def poll(cls, context): + return context.object and context.object.type == 'GPENCIL' + + mode : bpy.props.StringProperty(default='ALL', options={'SKIP_SAVE'}) + + def execute(self, context): + fn.split_object_to_scene() + return {"FINISHED"} + + + classes=( GPEXP_OT_add_layer_to_render, GPEXP_OT_add_objects_to_render, +GPEXP_OT_split_to_scene, ) def register(): diff --git a/OP_clean.py b/OP_clean.py index d757bae..d509657 100644 --- a/OP_clean.py +++ b/OP_clean.py @@ -98,11 +98,12 @@ class GPEXP_OT_clean_compo_tree(bpy.types.Operator): # box.prop(self, 'fo_clear_disconnected') def execute(self, context): - render = bpy.data.scenes.get('Render') - if not render: - print('SKIP, no Render scene') - return {"CANCELLED"} - + # render = bpy.data.scenes.get('Render') + # if not render: + # print('SKIP, no Render scene') + # return {"CANCELLED"} + render = context.scene + nodes = render.node_tree.nodes if self.clear_unused_view_layers: used_rlayer_names = [n.layer for n in nodes if n.type == 'R_LAYERS'] diff --git a/OP_crop_to_object.py b/OP_crop_to_object.py new file mode 100644 index 0000000..598139f --- /dev/null +++ b/OP_crop_to_object.py @@ -0,0 +1,51 @@ +import bpy +from . import fn + +class GPEXP_OT_set_crop_from_selection(bpy.types.Operator): + bl_idname = "gp.set_crop_from_selection" + bl_label = "Set Crop" + bl_description = "Automatic set crop from selection" + bl_options = {"REGISTER"} + + @classmethod + def poll(cls, context): + return True + + def execute(self, context): + scn = context.scene + fn.set_box_from_selected_objects(scn=scn, cam=scn.camera) + scn.render.use_border = True + scn.render.use_crop_to_border = True + return {"FINISHED"} + +class GPEXP_OT_export_crop_coord_to_json(bpy.types.Operator): + bl_idname = "gp.export_crop_coord_to_json" + bl_label = "Set Crop" + bl_description = "Export json of all scenes borders (when enabled)" # Automatic set crop from selection + bl_options = {"REGISTER"} + + @classmethod + def poll(cls, context): + return True + + def execute(self, context): + # scn = context.scene + # if not scn.render.use_border or not scn.render.use_crop_to_border: + # self.report({'ERROR'}, 'Current scene have cropping disabled or use crop_to_border disabled!') + # return {'CANCELLED'} + fn.export_crop_to_json() + return {"FINISHED"} + + +classes=( +GPEXP_OT_set_crop_from_selection, +GPEXP_OT_export_crop_coord_to_json, +) + +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_render_scenes.py b/OP_render_scenes.py new file mode 100644 index 0000000..1d12502 --- /dev/null +++ b/OP_render_scenes.py @@ -0,0 +1,47 @@ +import bpy +from . import fn +from time import time + +class GPEXP_OT_render_all_scenes(bpy.types.Operator): + bl_idname = "gp.render_all_scenes" + bl_label = "Render all scenes" + bl_description = "Render all scene except Render" + bl_options = {"REGISTER"} + + @classmethod + def poll(cls, context): + return True + + def execute(self, context): + start = time() + ct = 0 + for scn in bpy.data.scenes: + if scn.name == 'Scene': + continue + if not scn.use_nodes: + continue + if not [n for n in scn.node_tree.nodes if n.type == 'OUTPUT_FILE' and n.mute]: + # skip if no fileout + print(f'\n -!-> Skip {scn.name}, No output file, or all muted') + continue + + print(f'\n --> Rendering {scn.name}') + # bpy.context.window.scene = scn + bpy.ops.render.render(animation=True, scene=scn.name) + ct += 1 + + print(f'\nDone. {ct} scenes rendered in {time()-start:.2f}s') + return {"FINISHED"} + + +classes=( +GPEXP_OT_render_all_scenes, +) + +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/README.md b/README.md index a19ffb9..cc6c24a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - on renaming, correct also names in GP modifiers !!! - opt: multi-merge : also merge merged NG automatically disabling AA without group (or externalise AA node ?) - opt : How to disable main output - +- To add : Reconnect inside nodegroup for nodes when using clean nodes ## Done - set exlude VL on non-used layers diff --git a/__init__.py b/__init__.py index 7107122..b948669 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,7 @@ bl_info = { "name": "GP Render", "description": "Organise export of gp layers through compositor output", "author": "Samuel Bernou", - "version": (0, 4, 1), + "version": (0, 5, 0), "blender": (2, 93, 0), "location": "View3D", "warning": "", @@ -17,6 +17,8 @@ from . import OP_clean from . import OP_connect_toggle 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_render_pdf from . import prefs @@ -37,6 +39,8 @@ def register(): OP_merge_layers.register() OP_manage_outputs.register() OP_scene_switch.register() + OP_crop_to_object.register() + OP_render_scenes.register() # OP_check_layer_status.register() OP_render_pdf.register() OP_setup_layers.register() @@ -51,6 +55,8 @@ def unregister(): OP_setup_layers.unregister() # OP_check_layer_status.unregister() OP_render_pdf.unregister() + OP_render_scenes.unregister() + OP_crop_to_object.unregister() OP_scene_switch.unregister() OP_manage_outputs.unregister() OP_merge_layers.unregister() diff --git a/fn.py b/fn.py index c497f54..ff770a3 100644 --- a/fn.py +++ b/fn.py @@ -1,10 +1,12 @@ +from typing import Coroutine import bpy import re from mathutils import Vector from pathlib import Path from math import isclose from collections import defaultdict - +from time import time +import json def create_node(type, tree=None, **kargs): '''Get a type, a tree to add in, and optionnaly multiple attribute to set @@ -112,6 +114,8 @@ def set_settings(scene=None): scene.eevee.taa_render_samples = 1 scene.grease_pencil_settings.antialias_threshold = 0 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 @@ -119,10 +123,41 @@ def set_settings(scene=None): # output (fast write settings since this is just to delete afterwards...) scene.render.filepath = '//render/preview/preview_' scene.render.image_settings.file_format = 'JPEG' - scene.render.image_settings.color_mode = 'BW' + scene.render.image_settings.color_mode = 'RGB' scene.render.image_settings.quality = 0 +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 (no AA) + 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_render_scene(): '''Get / Create a scene named Render''' @@ -677,4 +712,345 @@ def show_message_box(_message = "", _title = "Message Box", _icon = 'INFO'): if isinstance(_message, str): _message = [_message] - bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon) \ No newline at end of file + bpy.context.window_manager.popup_menu(draw, title = _title, icon = _icon) + + +def get_bbox_3d(ob): + bbox_coords = ob.bound_box + return [ob.matrix_world @ Vector(b) for b in bbox_coords] + +def get_crop_pixel_coord(scn): + # 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), + } + return coord + +def export_crop_to_json(): + '''Export crop to json coords for AE + ''' + + blend = Path(bpy.data.filepath) + json_path = blend.parent / 'render' / f'{blend.stem}.json' #f'{ob.name}.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.name in {'Scene', 'Render'}: + if scn.name == 'Scene': + continue + if scn.render.use_border: + scn_border = get_crop_pixel_coord(scn) + for ob in [o for o in scn.objects if o.type == 'GPENCIL']: + coord_dic[ob.name] = scn_border + + # save bbox + with json_path.open('w') as fd: + json.dump(coord_dic, fd, indent='\t') + + print(f'coord 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''' + + 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 get_gp_box_all_frame_selection(oblist=None, scn=None, cam=None): + ''' + get points of all selection + return 2d bbox in pixels + ''' + + from bpy_extras.object_utils import world_to_camera_view + + 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: + 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] + 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: + 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(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) + _bbox_px = set_border_region_from_coord(coords, margin=30, scn=scn, export_json=export_json) + + +def get_collection_childs_recursive(col, cols=[]): + '''return a list of all the sub-collections in passed col''' + for sub in col.children: + if sub not in cols: + cols.append(sub) + if len(sub.children): + cols = get_collection_childs_recursive(sub, cols) + return cols + +def unlink_objects_from_scene(oblist, scn): + all_col = [scn.collection] + 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(): + '''Create a new scene from object selection''' + + active = bpy.context.object + scene_name = active.name + 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 + + ## unlink unwanted objects from collection + all_col = [new.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) + set_border_region_from_coord(coords, margin=30, scn=new, export_json=True) + + export_crop_to_json() + + +""" +def split_object_to_scene(): + '''Create a new scene from selection''' + + # send objects in a new render scene + ## define new scene name with active object names + active = bpy.context.object + scene_name = active.name + objs = [o for o in bpy.context.selected_objects] + + rd_scn = bpy.data.scenes.get('Render') + ## create scene and copy settings from render scene or current + # src_scn = bpy.data.scenes.get('Render') + # src_scn = src_scn or bpy.context.scene + # if src_scn.name == scene_name: + # print('! Problem ! Trying to to create new render scene without source') + # return + + + ## From current scene (might be Render OR Scene) + src_scn = bpy.context.scene + + new = new_scene_from(scene_name, src_scn=src_scn, regen=True) # crop=True, link_cam=True, link_light=True + + for ob in objs: + new.collection.objects.link(ob) + if ob.type == 'GPENCIL': + # recreate VL + vl_names = [l.viewlayer_render for l in ob.data.layers if l.viewlayer_render] + for names in vl_names: + new.view_layers.new(names) + # get_set_viewlayer_from_gp(ob, l, scene=new) + + +def set_crop_bbox_2d(ob, cam=None): + '''Basic crop using bouding box on current frame''' + from bpy_extras.object_utils import world_to_camera_view + + scn = bpy.context.scene + cam = cam or scn.camera + # bbox = [ob.matrix_world @ Vector(b) for b in bbox_coords] + 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]) + scn.render.border_min_x = coords2d_x[0] + scn.render.border_max_x = coords2d_x[-1] + scn.render.border_min_y = coords2d_y[0] + scn.render.border_max_y = coords2d_y[-1] + return +""" \ No newline at end of file diff --git a/ui.py b/ui.py index 3d98e6e..77a8a3a 100644 --- a/ui.py +++ b/ui.py @@ -118,6 +118,16 @@ class GPEXP_PT_gp_node_ui(Panel): col.operator('gp.clear_render_tree', icon='X', text='Clear Framed Nodes') col.operator('gp.clear_render_tree', icon='X', text='Clear & Delete Render Scene').mode = "COMPLETE" + layout.separator() + layout.label(text='Sub Scenes:') + layout.operator('gp.split_to_scene', icon='DUPLICATE', text='Split To Scene') + + row = layout.row(align=True) + row.operator('gp.set_crop_from_selection', icon='CON_OBJECTSOLVER', text='Set Crop') + row.operator('gp.export_crop_coord_to_json', icon='FILE', text='Export json') + + layout.operator('gp.render_all_scenes', icon='RENDER_ANIMATION', text='Render All Sub-Scene') + layout.prop(prefs, 'advanced', text='Show Advanced Options') # layout.operator('gp.add_object_to_render', icon='RENDERLAYERS', text='Layer To Render').mode = 'ALL' # layout.operator('gp.add_object_to_render', icon='RENDERLAYERS', text='Layer To Render').mode = 'SELECTED'