First implementation of export helper for AE keyuframe data

main
pullusb 2024-10-14 18:42:18 +02:00
parent d6a102b044
commit 5c69bd0185
1 changed files with 390 additions and 2 deletions

View File

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