diff --git a/OP_export_to_ae.py b/OP_export_to_ae.py index 881feea..97c0072 100644 --- a/OP_export_to_ae.py +++ b/OP_export_to_ae.py @@ -1,12 +1,22 @@ from encodings import utf_8 import bpy +import json + # import bpy_extras from bpy_extras.object_utils import world_to_camera_view # as cam_space +from bpy_extras.io_utils import ExportHelper from mathutils import Vector from pathlib import Path -import json from . import fn +from bpy.props import ( + StringProperty, + BoolProperty, + FloatProperty, + EnumProperty, + CollectionProperty, +) + ''' def Export_AE_2d_position_json_data(): scn = bpy.context.scene @@ -26,6 +36,378 @@ def Export_AE_2d_position_json_data(): pix_pos = Vector((pos[0]*rx, (1-pos[1])*ry)) ''' +def export_ae_transforms(directory, selection=None, camera=None, exposition=True, prefix='ae_', suffix='', fr=False, export_format='txt'): + """ + Export After Effects transform data for selected objects and camera in Blender. + + This function exports keyframe data for position, scale, and optional exposition for + selected objects and the camera. The data can be exported in TXT and/or JSON formats, + which can be imported into Adobe After Effects. + + Parameters: + directory (str): The directory path where the exported files will be saved. + selection (list, optional): List of Blender objects or pose bones to export. Defaults to None. + camera (bpy.types.Object, optional): The camera object to use for calculations. Defaults to the scene's active camera. + exposition (bool, optional): Whether to export exposition data. Defaults to True. + prefix (str, optional): Prefix for the exported filenames. Defaults to 'ae_'. + suffix (str, optional): Suffix for the exported filenames. Defaults to ''. + fr (bool, optional): If True, uses French labels for exposition data. Defaults to False. + export_format (str, optional): The format(s) to export. Can be 'txt', 'json', or 'txt,json'. Defaults to 'txt'. + + Example usage: + export_ae_transforms('/path/to/export', selection=bpy.context.selected_objects, export_format='txt,json') + """ + + scn = bpy.context.scene + res = Vector((scn.render.resolution_x, scn.render.resolution_y)) + + def get_keyframe_data(coords_2d, exposition=False): + frame_start, org_2d, _ = coords_2d[0] + org_distance = org_2d[-1] + + keyframe_data = {'position': [], 'scale': []} + + for frame, co_2d, is_animated in coords_2d: + position = Vector((co_2d[0], 1-co_2d[1])) + position *= res # Multiply by resolution to get pixels value + keyframe_data['position'].append((frame-frame_start, (round(position[0]), round(position[1])))) + + scale = (org_distance / co_2d[-1]) * 100 + keyframe_data['scale'].append((frame-frame_start, (round(scale, 5),)*3)) + + if is_animated: + keyframe_data.setdefault('exposition', []).append(frame-frame_start) + + return keyframe_data + + def get_keyframe_data_txt(keyframe_data, exposition=True): + keyframe_data_txt = ( + 'Adobe After Effects 8.0 Keyframe Data\n\n' + f'\tUnits Per Second\t{scn.render.fps}\n' + f'\tSource Width\t{scn.render.resolution_x}\n' + f'\tSource Height\t{scn.render.resolution_y}\n' + '\tSource Pixel Aspect Ratio\t1\n' + '\tComp Pixel Aspect Ratio\t1\n\n' + ) + + # Position + keyframe_data_txt += ( + '\nTransform\tPosition\n' + '\tFrame\tX pixels\tY pixels\tZ pixels\t\n' + ) + for frame, position in keyframe_data['position']: + keyframe_data_txt += f'\t{frame}\t{position[0]}\t{position[1]}\t0\t\n' + + # Scale + keyframe_data_txt += ( + '\nTransform\tScale\n' + '\tFrame\tX percent\tY percent\tZ percent\t\n' + ) + for frame, scale in keyframe_data['scale']: + keyframe_data_txt += f'\t{frame}\t{scale[0]}\t{scale[1]}\t{scale[2]}\t\n' + + if exposition: + if fr: + keyframe_data_txt += '\nEffects\tParamètre case #1\tCase #2\t\n' + else: + keyframe_data_txt += '\nEffects\tCheckbox Control #1\tCheckbox #2\t\n' + + keyframe_data_txt += '\tFrame\t\t\n' + + for i, frame in enumerate(keyframe_data['exposition']): + keyframe_data_txt += f'\t{frame}\t{i%2}\t\n' + + keyframe_data_txt += '\nEnd of Keyframe Data\n' + + return keyframe_data_txt + + + if selection is None: + selection = [] + + if camera is None: + camera = scn.camera + + use_simplify = scn.render.use_simplify + simplify_subdivision = scn.render.simplify_subdivision + scn.render.use_simplify = True + scn.render.simplify_subdivision = 0 + + # Get the camera at the first frame for reference + scn.frame_set(scn.frame_start) + cam_org_3d = camera.matrix_world @ Vector((0, 0, -100)) + + camera_coords = [] + ob_coords = {} + animation = {} + for i in range(scn.frame_start, scn.frame_end + 1): + scn.frame_set(i) + + if camera is not None: + co_2d = world_to_camera_view(scn, camera, cam_org_3d) + camera_coords.append((i, co_2d, False)) + + for item in selection: + if isinstance(item, bpy.types.PoseBone): + co_3d = item.id_data.matrix_world @ item.matrix.to_translation() + name = f'{item.id_data.name}_{item.name}' + rig_transforms = [tuple(round(x, 5) for v in b.matrix for x in v) for b in item.id_data.pose.bones] + is_animated = rig_transforms != animation.get(name) + animation[name] = rig_transforms + elif isinstance(item, bpy.types.GreasePencil): + co_3d = item.matrix_world.to_translation() + ## Check if there is keys on any layer + is_animated = False + ## append with is_animated flag to True + for l in item.data.layers: + is_animated = next((True for f in l.frames if l.frame_number == i), False) + if is_animated: + break + + else: + name = item.name + co_3d = item.matrix_world.to_translation() + is_animated = co_3d != animation.get(name) + animation[name] = co_3d + + co_2d = world_to_camera_view(scn, camera, co_3d) + ob_coords.setdefault(name, []).append((i, co_2d, is_animated)) + + cam_key_frame_data = get_keyframe_data(camera_coords) + objs_keyframe_data = {name: get_keyframe_data(co, exposition=exposition) for name, co in ob_coords.items()} + + if 'json' in export_format or export_format == 'json': + 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, + 'objects': objs_keyframe_data + } + if camera is not None: + keyframe_data['camera'] = cam_key_frame_data + + Path(directory, f'{prefix}keyframe_data{suffix}.json').write_text(json.dumps(keyframe_data, indent=4), encoding='utf-8') + + if 'txt' in export_format or export_format == 'txt': + for name, keyframe_data in objs_keyframe_data.items(): + name = name.replace('.', '_') + keyframe_data_txt = get_keyframe_data_txt(keyframe_data, exposition=exposition) + Path(directory, f'{prefix}{name}{suffix}.txt').write_text(keyframe_data_txt, newline='\r\n') + + if camera is not None: + keyframe_data_txt = get_keyframe_data_txt(cam_key_frame_data, exposition=False) + Path(directory, f'{prefix}{camera.name}{suffix}.txt').write_text(keyframe_data_txt, newline='\r\n') + + scn.render.use_simplify = use_simplify + scn.render.simplify_subdivision = simplify_subdivision + + +class GPEXP_OT_export_anim_to_ae(bpy.types.Operator, ExportHelper): + bl_idname = "gp.export_anim_to_ae" + bl_label = "Export To After Effects" + bl_description = "Export the animation to after effect, object, greasepencil keys exposition, camera" + bl_options = {"REGISTER"} + + # filter_glob: StringProperty(default='*.txt;*.json;', options={'HIDDEN'})# *.jpeg;*.png;*.tif;*.tiff;*.bmp + filter_glob: StringProperty(default='*.*', options={'HIDDEN'})# *.jpeg;*.png;*.tif;*.tiff;*.bmp + + filename_ext = '' + + filepath : StringProperty( + name="File Path", + description="File path used for export", + maxlen= 1024) + + use_selection: BoolProperty( + name="Selected Objects", + description="Export selected and visible objects only", + default=True, + ) + use_visible: BoolProperty( + name='Visible Objects', + description='Export visible objects only', + default=False + ) + use_active_collection: BoolProperty( + name="Active Collection", + description="Export only objects from the active collection (and its children)", + default=False, + ) + # collection: StringProperty( + # name="Source Collection", + # description="Export only objects from this collection (and its children)", + # default="", + # ) + object_types: EnumProperty( + name="Object Types", + options={'ENUM_FLAG'}, + items=(('EMPTY', "Empty", ""), + ('CAMERA', "Camera", ""), + ('LIGHT', "Lamp", ""), + ('ARMATURE', "Armature", "WARNING: not supported in dupli/group instances"), + ('MESH', "Mesh", ""), + ('GPENCIL', "Grease Pencil", ""), + ('OTHER', "Other", "Other geometry types, like curve, metaball, etc. (converted to meshes)"), + ), + description="Which kind of object to export", + default={'EMPTY', 'CAMERA', 'LIGHT', 'ARMATURE', 'MESH', 'GPENCIL', 'OTHER'}, + ) + exposition: BoolProperty( + name='Exposition', + description='Export the exposition of the keys', + default=False + ) + use_grease_pencil_keys: BoolProperty( + name='Grease Pencil Keys', + description='Consider grease pencil keys for animated exposition', + default=True + ) + use_object_keys: BoolProperty( + name='Object Keys', + description='Consider object transform keys for animated exposition', + default=True + ) + use_active_camera: BoolProperty( + name='Active Camera', + description='Export active camera keys', + default=True + ) + data_lang: EnumProperty( + name="AE Language", + # options={'ENUM_FLAG'}, + items=(('FR', "Français", ""), + ('EN', "Anglais", ""), + ), + description="Language for the exported keyframe data (depend on After Effect language settings)", + default='FR', + ) + file_format: EnumProperty( + name="File", + options={'ENUM_FLAG'}, + items=(('txt', "txt", ""), + ('json', "json", ""), + ), + description="File format to export (allow multiple at once)", + default={'txt'}, + ) + + # prefix ? (ensure file is prefixed at export), but weird in the the context of an export field + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False # No animation. + + # Are we inside the File browser + is_file_browser = context.space_data.type == 'FILE_BROWSER' + + export_main(layout, self, is_file_browser) + export_panel_include(layout, self, is_file_browser) + + def execute(self, context): + objects_selection = get_object_selection(use_selection=self.use_selection, + use_visible=self.use_visible, + use_active_collection=self.use_active_collection, + object_types=self.object_types + ) + + for o in objects_selection: + print(o.name) + # return {"FINISHED"} + + ## Find directory + output_path = Path(self.filepath) + if not output_path.is_dir(): + output_path = output_path.parent + + print('output_path: ', output_path) + cam = None + if self.use_active_camera and context.scene.camera: + cam = context.scene.camera + export_ae_transforms(directory=output_path, + selection=objects_selection, + camera=cam, + exposition=self.exposition, + prefix='ae_', + fr=self.data_lang == 'FR', + export_format=self.file_format) + + self.report({'INFO'}, f'File saved here: {self.filepath}') + return {"FINISHED"} + + + +def get_object_selection(use_selection=False, use_visible=False, use_active_collection=False, object_types=None): + context = bpy.context + ## determine selection based on filters4 + source_collection = None + if use_active_collection: + source_collection = context.view_layer.active_layer_collection.collection + # elif collection: + # local_collection = bpy.data.collections.get((collection, None)) + # if local_collection: + # source_collection = local_collection + # else: + # operator.report({'ERROR'}, "Collection '%s' was not found" % collection) + # return {'CANCELLED'} + + if source_collection: + if use_selection: + ctx_objects = tuple(obj for obj in source_collection.all_objects if obj.select_get()) + else: + ctx_objects = source_collection.all_objects + else: + if use_selection: + ctx_objects = context.selected_objects + else: + ctx_objects = context.view_layer.objects + + if use_visible: + ctx_objects = tuple(obj for obj in ctx_objects if obj.visible_get()) + + ## Filter by object type + if object_types is None: + object_types = {'EMPTY', 'CAMERA', 'LIGHT', 'ARMATURE', 'MESH', 'GPENCIL', 'OTHER'} + + ctx_objects = [obj for obj in ctx_objects if obj.type in object_types] + ## Constant for compatible other object type + # BLENDER_OTHER_OBJECT_TYPES = {'CURVE', 'SURFACE', 'FONT', 'META'} + + if 'OTHER' in object_types: + ## Any object that is not in proposed list + ctx_objects += [obj for obj in ctx_objects if obj.type not in object_types] + + return ctx_objects + + +def export_main(layout, operator, is_file_browser): + layout.prop(operator, 'exposition') + layout.prop(operator, 'use_grease_pencil_keys') + layout.prop(operator, 'use_object_keys') + layout.prop(operator, 'use_active_camera') + + ## Format (language and file) + layout.prop(operator, 'data_lang', expand=True) + if is_file_browser: + layout.column().prop(operator, 'file_format') + + +def export_panel_include(layout, operator, is_file_browser): + header, body = layout.panel("AE_export_include", default_closed=False) + header.label(text="Include") + if body: + sublayout = body.column(heading="Limit to") + # if is_file_browser: + sublayout.prop(operator, "use_selection") + sublayout.prop(operator, "use_visible") + sublayout.prop(operator, "use_active_collection") + + body.column().prop(operator, "object_types") + # body.prop(operator, "use_custom_props") + +### --- Old functions # Unused (old func that might not be usefull at all...) def correct_shift(vec, cam): @@ -252,18 +634,23 @@ class GPEXP_PT_extra_gprender_func(bpy.types.Panel): # layout = self.layout # layout.operator("gp.fix_overscan_shift") +def export_ae_anim_menu(self, context): + self.layout.operator('gp.export_anim_to_ae', text='After Effects Keyframe Data (.txt|.json)') + classes=( GPEXP_OT_export_keys_to_ae, GPEXP_OT_export_cam_keys_to_ae, GPEXP_OT_fix_overscan_shift, -GPEXP_PT_extra_gprender_func +GPEXP_PT_extra_gprender_func, +GPEXP_OT_export_anim_to_ae, ) def register(): for cls in classes: bpy.utils.register_class(cls) + bpy.types.TOPBAR_MT_file_export.append(export_ae_anim_menu) # if hasattr(bpy.types, 'RENDER_PT_overscan'): # bpy.types.RENDER_PT_overscan.append(overcan_shift_fix_ui) @@ -274,3 +661,4 @@ def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) + bpy.types.TOPBAR_MT_file_export.remove(export_ae_anim_menu)