gp_render/OP_export_to_ae.py

688 lines
25 KiB
Python
Raw Permalink Normal View History

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
from . import fn
from bpy.props import (
StringProperty,
BoolProperty,
FloatProperty,
EnumProperty,
CollectionProperty,
)
from bpy.app.translations import pgettext_data as data_
'''
def Export_AE_2d_position_json_data():
scn = bpy.context.scene
cam = scn.objects.get('anim_cam')
if not cam:
print('Active camera not "anim_cam"')
cam = scn.camera
2023-01-18 14:28:27 +01:00
rx = scn.render
rx, ry = rd.resolution_x, rd.resolution_y
targets = [o for o in bpy.context.selected_objects if o.type != 'CAMERA']
for ob in targets:
# get an idea of the scale relative to image res ? (too complex since it's not even the right resolution...)
pos = world_to_camera_view(scn, scn.objects['anim_cam'], ob.matrix_world.to_translation())[:-1]
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_cam=True):
"""
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 item.type == 'GPENCIL': # isinstance(item.data, bpy.types.GreasePencil):
name = item.name
co_3d = item.matrix_world.to_translation()
## Check if there is a GP-scene at scene-frame (i) on any visible layer
is_animated = i in [f.frame_number for l in item.data.layers for f in l.frames if not l.hide]
animation[name] = co_3d
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 export_cam: #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 export_cam: #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
## Export operator without export_helper
class GPEXP_OT_export_anim_to_ae(bpy.types.Operator):
bl_idname = "gp.export_anim_to_ae"
bl_label = "Export AE Files"
bl_description = "Export the animation to After Effects, 2D transform of objects, camera\
\nand/or exposition (including greasepencil frames)"
bl_options = {"REGISTER"}
# 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)
## Only need directory
directory : StringProperty(
name="File Path",
description="File path used for export",
maxlen= 1024,
subtype='DIR_PATH'
)
prefix : StringProperty(
name="Prefix",
default='ae_',
description="Prefix name for exported txt and json files",
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,
)
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=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",
items=(('FR', "French", ""),
('EN', "English", ""),
),
description="Clipboard keyframe data language",
default='FR',
)
file_format: EnumProperty(
name="File Type",
options={'ENUM_FLAG'},
items=(('txt', "txt", ""),
('json', "json", ""),
),
description="File format to export (possible to select multiple choices with Shift + Click)",
default={'txt'},
)
def invoke(self, context, _event):
if not self.directory:
blend_filepath = context.blend_data.filepath
if blend_filepath:
dest_folder = Path(blend_filepath).parent
## If pre-enter a specific subfolder exists
## (Commented, should be used with a project environment variable)
# output_folder = dest_folder / 'render'
# if output_folder.exists() and output_folder.is_dir():
# dest_folder = output_folder
self.directory = str(dest_folder)
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
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_format(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
)
## Ensure output path is directory
output_path = Path(self.directory)
if not output_path.is_dir():
output_path = output_path.parent
print('Output directory: ', output_path)
cam = None
if context.scene.camera:
cam = context.scene.camera
if cam and cam in objects_selection:
## Remove active camera from objects list
objects_selection.pop(objects_selection.index(cam))
print('Export AE transform from objects:')
for o in objects_selection:
print('- ', o.name)
export_ae_transforms(directory=output_path,
selection=objects_selection,
camera=cam,
exposition=self.exposition,
prefix=self.prefix,
fr=self.data_lang == 'FR',
export_format=self.file_format,
export_cam=self.use_active_camera)
self.report({'INFO'}, f'File(s) saved in folder: {output_path}')
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
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):
col = layout.column()
col.prop(operator, 'exposition')
col.prop(operator, 'use_active_camera')
# col.prop(operator, 'use_object_keys')
def export_panel_format(layout, operator, is_file_browser):
header, body = layout.panel("AE_export_format", default_closed=False)
header.label(text="Format")
if body:
col = body.column()
col.prop(operator, 'prefix')
## Format (language and file)
col.row().prop(operator, 'data_lang', expand=True)
if is_file_browser:
col.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):
resX = bpy.context.scene.render.resolution_x
resY = bpy.context.scene.render.resolution_y
ratio = resX/resY
shiftX = 2*cam.data.shift_x
shiftY = 2*cam.data.shift_y
if ratio<1:
return vec - Vector((shiftX*(1/ratio), shiftY, 0))
elif ratio>1:
return vec - Vector((shiftX, shiftY*ratio, 0))
else:
return vec - Vector((shiftX, shiftY, 0))
def export_AE_objects_position_keys():
'''Export keys as paperclip to paste in after'''
scn = bpy.context.scene
result = {}
print(f'Exporting 2d position (scene range: {scn.frame_start} - {scn.frame_end})')
for fr in range(scn.frame_start,scn.frame_end + 1):
2023-01-18 14:28:27 +01:00
print(f'frame: {fr}')
scn.frame_set(fr)
for o in bpy.context.selected_objects:
if not result.get(o.name):
result[o.name] = []
proj2d = world_to_camera_view(scn, scn.camera, o.matrix_world.to_translation()) # + Vector((.5,.5,0))
2023-01-18 14:28:27 +01:00
# proj2d = correct_shift(proj2d, scn.camera) # needed ?
x = (proj2d[0]) * scn.render.resolution_x
y = -(proj2d[1]) * scn.render.resolution_y + scn.render.resolution_y
2023-01-18 14:28:27 +01:00
result[o.name].append((fr,x,y))
for name,value in result.items():
txt = fn.get_ae_keyframe_clipboard_header(scn)
2023-01-18 14:28:27 +01:00
for v in value:
txt += '\t%s\t%s\t%s\t0\t\n'%(v[0],v[1],v[2]) # add 0 for Z (probably not needed)
txt += '\n\nEnd of Keyframe Data\n' # keyframe txt footer
blend = Path(bpy.data.filepath)
keyfile = blend.parent / 'render' / f'pos_{name}.txt'
keyfile.parent.mkdir(parents=False, exist_ok=True)
2023-01-18 14:28:27 +01:00
print(f'exporting keys for {name} at {keyfile}')
2023-01-18 14:28:27 +01:00
## save forcing CRLF terminator (DOS style, damn windows)
## in case it's exported from linux
2023-01-18 14:28:27 +01:00
with open(keyfile, 'w', newline='\r\n') as fd:
fd.write(txt)
class GPEXP_OT_export_keys_to_ae(bpy.types.Operator):
bl_idname = "gp.export_keys_to_ae"
bl_label = "Export 2D Position To AE"
bl_description = "Export selected objects positions as text file containing key paperclip for AfterEffects layers"
bl_options = {"REGISTER"}
@classmethod
def poll(cls, context):
return context.selected_objects
def execute(self, context):
export_AE_objects_position_keys()
return {"FINISHED"}
def export_anim_cam_position(camera=None, context=None):
context = context or bpy.context
scn = context.scene
camera = camera or bpy.data.objects.get('anim_cam')
if not camera:
return 'Abort: No "anim_cam" found!'
text = fn.get_ae_keyframe_clipboard_header(scn)
for i in range(scn.frame_start, scn.frame_end + 1):
scn.frame_set(i)
center = fn.get_cam_frame_center_world(camera)
coord = fn.get_coord_in_cam_space(scn, scn.camera, center, ae=True)
text += f'\t{i}\t{coord[0]}\t{coord[1]}\t\n'
text += '\n\nEnd of Keyframe Data\n' # Ae Frame ending
blend = Path(bpy.data.filepath)
keyfile = blend.parent / 'render' / f'anim_cam_pos.txt'
keyfile.parent.mkdir(parents=False, exist_ok=True)
print(f'Exporting anim cam positions keys at: {keyfile}')
with open(keyfile, 'w') as fd:
fd.write(text)
class GPEXP_OT_export_cam_keys_to_ae(bpy.types.Operator):
bl_idname = "gp.export_cam_keys_to_ae"
bl_label = "Export Camera 2D Position To AE"
bl_description = "Export anim cam positions as text file containing key paperclip for AfterEffects layers"
bl_options = {"REGISTER"}
@classmethod
def poll(cls, context):
return context.selected_objects
def execute(self, context):
cam = None
if context.object and context.object.type == 'CAMERA' and context.object != context.scene.camera:
cam = context.object
err = export_anim_cam_position(camera=cam, context=context)
if err:
self.report({'ERROR'}, err)
return {"CANCELLED"}
return {"FINISHED"}
class GPEXP_OT_fix_overscan_shift(bpy.types.Operator):
bl_idname = "gp.fix_overscan_shift"
bl_label = "Fix Cam Shift Value With Overscan"
bl_description = "(Gp render operator) change shift values to re-center overscan"
bl_options = {"REGISTER"}
# @classmethod
# def poll(cls, context):
# return context.object
init_rx : bpy.props.IntProperty(name='pre-overscan res x')
init_ry : bpy.props.IntProperty(name='pre-overscan res y')
def invoke(self, context, event):
# if not context.object:
# self.report({'ERROR'}, 'No object selected')
# return {'CANCELLED'}
self.use_selection = False
if context.active_object and context.active_object.type == 'CAMERA' and context.active_object != context.scene.camera:
self.use_selection = True
self.cam_ob = context.active_object
else:
self.cam_ob = context.scene.camera
self.init_rx = context.scene.render.resolution_x
self.init_ry = context.scene.render.resolution_y
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
2023-01-18 14:28:27 +01:00
if self.use_selection:
col = layout.column()
col.label(text=f'Camera "{self.cam_ob.name}" selected', icon='INFO')
col.label(text='Change in shifts will apply on this one', icon='BLANK1')
col = layout.column()
col.label(text='Overscan res (current)')
col.label(text=f'{context.scene.render.resolution_x} x {context.scene.render.resolution_y}')
col = layout.column()
col.label(text='Enter Initial (pre-overscan) resolution:')
row = col.row(align=True)
row.prop(self, 'init_rx', text='')
row.prop(self, 'init_ry', text='')
def execute(self, context):
cam = self.cam_ob.data
2023-01-18 14:28:27 +01:00
ratio_x = self.init_rx / context.scene.render.resolution_x
ratio_y = self.init_ry / context.scene.render.resolution_y
if ratio_x == 1 and ratio_y == 1:
self.report({'ERROR'}, 'Same init and overscan resolution, nothing to change')
return {'CANCELLED'}
if ratio_x != 1:
if fn.has_keyframe(cam, 'shift_x'):
fcu = cam.animation_data.action.fcurves.find('shift_x')
for k in fcu.keyframe_points:
k.co.y = k.co.y * ratio_x
else:
if cam.shift_x != 1:
cam.shift_x = cam.shift_x * ratio_x
2023-01-18 14:28:27 +01:00
if ratio_y != 1:
if fn.has_keyframe(cam, 'shift_y'):
fcu = cam.animation_data.action.fcurves.find('shift_y')
for k in fcu.keyframe_points:
k.co.y = k.co.y * ratio_y
else:
if cam.shift_y != 1:
cam.shift_y = cam.shift_y * ratio_y
return {"FINISHED"}
# ui panel
class GPEXP_PT_extra_gprender_func(bpy.types.Panel):
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "output"
bl_label = "GP Render Extras"
bl_parent_id = "RENDER_PT_format" if (3, 0, 0) <= bpy.app.version else "RENDER_PT_dimensions"
bl_options = {'DEFAULT_CLOSED'}
# COMPAT_ENGINES = {'CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'}
# def draw_header(self, context):
# overscan = context.scene.camera_overscan
# self.layout.prop(overscan, "RO_Activate", text="")
def draw(self, context):
layout = self.layout
col = layout.column()
col.operator("gp.fix_overscan_shift")
col.operator("gp.export_keys_to_ae")
col.operator("gp.export_cam_keys_to_ae")
# def overcan_shift_fix_ui(self, context):
# layout = self.layout
# layout.operator("gp.fix_overscan_shift")
def export_ae_anim_menu(self, context):
row = self.layout.row(align=False)
row.operator('gp.export_anim_to_ae', text='After Effects Keyframe Data (.txt/.json)')
# row.operator('gp.export_anim_to_ae', text='', icon='COPYDOWN')
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_OT_export_anim_to_ae,
)
2023-01-18 14:28:27 +01:00
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)
def unregister():
# if hasattr(bpy.types, 'RENDER_PT_overscan'):
# bpy.types.RENDER_PT_overscan.remove(overcan_shift_fix_ui)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
bpy.types.TOPBAR_MT_file_export.remove(export_ae_anim_menu)