First Commit

master
“christopheseux” 2022-12-24 15:30:32 +01:00
parent 3c7f133a85
commit e3d24aa4e3
49 changed files with 8466 additions and 4 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__
*.py[cod]

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"python.pythonPath": "/usr/bin/python3",
"python.linting.pylintEnabled": true,
"python.linting.enabled": true
}

View File

@ -1,4 +1,4 @@
# asset_library # asset_browser
@ -15,14 +15,14 @@ Already a pro? Just edit this README.md and make it your own. Want to make it ea
``` ```
cd existing_repo cd existing_repo
git remote add origin https://gitlab.com/autour-de-minuit/blender/asset_library.git git remote add origin https://gitlab.com/autour-de-minuit/blender/asset_browser.git
git branch -M main git branch -M main
git push -uf origin main git push -uf origin main
``` ```
## Integrate with your tools ## Integrate with your tools
- [ ] [Set up project integrations](https://gitlab.com/autour-de-minuit/blender/asset_library/-/settings/integrations) - [ ] [Set up project integrations](https://gitlab.com/autour-de-minuit/blender/asset_browser/-/settings/integrations)
## Collaborate with your team ## Collaborate with your team
@ -46,7 +46,7 @@ Use the built-in continuous integration in GitLab.
# Editing this README # Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README ## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.

92
__init__.py Normal file
View File

@ -0,0 +1,92 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Extending features of the Asset Browser for a studio use.
"""
bl_info = {
"name": "Asset Library",
"description": "Asset Library based on the Asset Browser.",
"author": "Sybren A. Stüvel, Clement Ducarteron, Christophe Seux, Samuel Bernou",
"version": (2, 0),
"blender": (3, 3, 0),
"warning": "In development, things may change",
"location": "Asset Browser -> Animations, and 3D Viewport -> Animation panel",
"category": "Animation",
}
#from typing import List, Tuple
from asset_library import pose
from asset_library import action
from asset_library import collection
from asset_library import file
from asset_library import (gui, keymaps, prefs, operators)
from asset_library import constants
#from asset_library.common.adapter import AssetLibraryAdapter
from asset_library.common.bl_utils import get_addon_prefs
from asset_library.common.functions import set_env_libraries
from asset_library.common.template import Template
import re
if 'bpy' in locals():
print("Reload Addon Asset Library")
import importlib
importlib.reload(gui)
importlib.reload(keymaps)
importlib.reload(prefs)
importlib.reload(operators)
importlib.reload(constants)
import bpy
import os
#addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = []
bl_modules = (
operators,
pose,
action,
collection,
file,
keymaps,
gui,
prefs
)
def load_handler():
print('load_handler')
set_env_libraries()
bpy.ops.assetlib.set_paths(all=True)
#bpy.ops.assetlib.#(all=True, only_recent=True)
bpy.ops.assetlib.bundle(blocking=False, mode='AUTO_BUNDLE')
def register() -> None:
for m in bl_modules:
m.register()
#prefs = get_addon_prefs()
bpy.app.timers.register(load_handler, first_interval=1)
def unregister() -> None:
for m in reversed(bl_modules):
m.unregister()

30
action/__init__.py Normal file
View File

@ -0,0 +1,30 @@
from asset_library.action import (
gui,
keymaps,
clear_asset,
concat_preview,
operators,
properties,
rename_pose,
render_preview,)
if 'bpy' in locals():
import importlib
importlib.reload(gui)
importlib.reload(keymaps)
importlib.reload(clear_asset)
importlib.reload(concat_preview)
importlib.reload(operators)
importlib.reload(properties)
importlib.reload(rename_pose)
importlib.reload(render_preview)
def register():
operators.register()
keymaps.register()
def unregister():
operators.unregister()
keymaps.unregister()

48
action/clear_asset.py Normal file
View File

@ -0,0 +1,48 @@
import argparse
import bpy
import json
import sys
from pathlib import Path
sys.path.append(Path(__file__).parents[3])
from asset_library.common.bl_utils import (
get_preview,
)
def clear_asset(action_name='', use_fake_user=False):
scn = bpy.context.scene
action = bpy.data.actions.get(action_name)
if not action:
print(f'No {action_name} not found.')
bpy.ops.wm.quit_blender()
action.asset_clear()
if use_fake_user:
action.use_fake_user = True
else:
preview = get_preview(asset_path=bpy.data.filepath, asset_name=action_name)
if preview:
preview.unlink()
bpy.data.actions.remove(action)
bpy.ops.wm.save_mainfile(
filepath=bpy.data.filepath, compress=True, exit=True
)
if __name__ == '__main__' :
parser = argparse.ArgumentParser(description='Add Comment To the tracker',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--action-name')
parser.add_argument('--use-fake-user', type=json.loads, default='false')
if '--' in sys.argv :
index = sys.argv.index('--')
sys.argv = [sys.argv[index-1], *sys.argv[index+1:]]
args = parser.parse_args()
clear_asset(**vars(args))

146
action/concat_preview.py Normal file
View File

@ -0,0 +1,146 @@
import bpy
import math
import numpy as np
from pathlib import Path
def alpha_to_color(pixels_data, color):
"""Convert Alpha to WhiteBG"""
new_pixels_data = []
for i in pixels_data:
height, width, array_d = i.shape
mask = i[:,:,3:]
background = np.array([color[0], color[1], color[2] ,1], dtype=np.float32)
background = np.tile(background, (height*width))
background = np.reshape(background, (height,width,4))
new_pixels_data.append(i * mask + background * (1 - mask))
# print(new_pixels_data)#Dbg
return new_pixels_data
def create_array(height, width):
return np.zeros((height*width*4), dtype=np.float32)
def read_pixels_data(img, source_height, source_width):
img_w, img_h = img.size
if img_w != source_width :
scale = abs(img_w/source_width)
img.scale(int(img_w/scale), int(img_h/scale))
img_w, img_h = img.size
array = create_array(img_h, img_w)
img.pixels.foreach_get(array)
array = array.reshape(img_h, img_w, 4)
if array.shape[0] != source_height:
#print('ARRAY SHAPE', array.shape[:], source_height)
missing_height = int(abs(source_height-img_h)/2)
empty_array = create_array(missing_height, source_width)
empty_array = empty_array.reshape(missing_height, source_width, 4)
array = np.vstack((empty_array, array, empty_array))
return array.reshape(source_height, source_width, 4)
def create_final(output_name, pixels_data, final_height, final_width):
#print('output_name: ', output_name)
new_img = bpy.data.images.get(output_name)
if new_img:
bpy.data.images.remove(new_img)
new_img = bpy.data.images.new(output_name, final_width, final_height)
new_img.generated_color=(0,0,0,0)
#print('pixels_data: ', pixels_data)
new_img.pixels.foreach_set(pixels_data)
return new_img
def guess_input_format(img_list):
for i in img_list:
if i.size[0] == i.size[1]:
return i.size
def format_files(files, catalog_data):
img_dict = {}
for k, v in catalog_data.items():
if '/' not in k:
continue
img_dict[v['name']] = [f for f in files if v['name'] in f]
return img_dict
def mosaic_export(
files, catalog_data, row=2, columns=2, auto_calculate=True,
bg_color=(0.18, 0.18, 0.18,), resize_output=100,
):
img_dict = format_files(files, catalog_data)
for cat, files_list in img_dict.items():
if not files_list:
continue
for i in bpy.data.images:
bpy.data.images.remove(i)
img_list = []
chars = Path(files_list[0]).parts[-4]
output_dir = str(Path(files_list[0]).parent.parent)
ext = 'jpg'
output_name = f'{chars}_{cat}.{ext}'
for img in files_list:
img_list.append(bpy.data.images.load(img, check_existing=True))
for i in img_list:
i.colorspace_settings.name = 'Raw'
if auto_calculate:
rows = int(math.sqrt(len(img_list)))
columns = math.ceil(len(img_list)/rows)
if rows*columns < len(img_list):
raise AttributeError('Grid too small for number of images')
src_w, src_h = img_list[0].size
final_w = src_w * columns
final_h = src_h * rows
img_pixels = [read_pixels_data(img, src_h, src_w) for img in img_list]
#Check if there is enough "data" to create an horizontal stack
##It not, create empty array
h_stack = []
total_len = rows*columns
if len(img_pixels) < total_len:
for i in range(total_len-len(img_pixels)):
img_pixels.append(create_array(src_h, src_w).reshape(src_h, src_w, 4))
img_pixels = alpha_to_color(img_pixels, bg_color)
for i in range(0,len(img_pixels),columns):
h_stack.append(np.hstack(img_pixels[i:i+columns]))
if rows > 1:
combined_stack = np.vstack(h_stack[::-1])
else:
combined_stack = np.hstack((h_stack[:]))
combined_img = create_final(output_name, combined_stack.flatten(), final_h, final_w)
if resize_output != 100:
w, h = combined_img.size
combined_img.scale(w*(resize_output*.01), h*(resize_output*.01))
combined_img.filepath_raw = '/'.join([output_dir, output_name])
combined_img.file_format = 'JPEG'
combined_img.save()
print(f"""
Image saved: {combined_img.filepath_raw}
""")

207
action/functions.py Normal file
View File

@ -0,0 +1,207 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Functions related to anim and pose.
"""
from collections.abc import Collection
from typing import Optional, FrozenSet, Set, Union, Iterable, cast
import dataclasses
import functools
import re
import bpy
from bpy.types import (
Action,
Bone,
Context,
FCurve,
Keyframe,
Object,
TimelineMarker
)
from asset_library.common.bl_utils import active_catalog_id, split_path
FCurveValue = Union[float, int]
pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]')
"""RegExp for matching FCurve data paths."""
def is_pose(action):
for fc in action.fcurves:
if len(fc.keyframe_points) > 1:
return False
return True
def get_bone_visibility(data_path):
bone, prop = split_path(data_path)
ob = bpy.context.object
b_layers = [i for i, val in enumerate(ob.pose.bones[bone].bone.layers) if val]
rig_layers = [(i, val) for i, val in enumerate(ob.data.layers)]
return ob.data.layers[b_layers[0]]
def get_keyframes(action, selected=False, includes=[]):
if selected:
# keyframes = sorted([int(k.co[0]) for f in action.fcurves for k in f.keyframe_points if k.select_control_point and get_bone_visibility(f.data_path)])
keyframes = []
for f in action.fcurves:
bone, prop = split_path(f.data_path)
for k in f.keyframe_points:
if bone not in includes:
continue
if not k.select_control_point:
continue
if not get_bone_visibility(f.data_path):
continue
keyframes += [int(k.co[0])]
if len(keyframes) <= 1:
keyframes = [bpy.context.scene.frame_current]
else:
keyframes = sorted([int(k.co[0]) for f in action.fcurves for k in f.keyframe_points])
return keyframes
def get_marker(action):
if action.pose_markers:
markers = action.pose_markers
return next((m.name for m in markers if m.frame == bpy.context.scene.frame_current), None)
def reset_bone(bone, transform=True, custom_props=True):
if transform:
bone.location = (0, 0, 0)
if bone.rotation_mode == "QUATERNION":
bone.rotation_quaternion = (0, 0, 0, 0)
elif bone.rotation_mode == "AXIS_ANGLE":
bone.rotation_axis_angle = (0, 0, 0, 0)
else:
bone.rotation_euler = (0, 0, 0)
bone.scale = (1, 1, 1)
if custom_props:
for key, value in bone.items():
try:
id_prop = bone.id_properties_ui(key)
except TypeError:
continue
if not isinstance(value, (int, float)) or not id_prop:
continue
bone[key] = id_prop.as_dict()['default']
def is_asset_action(action):
return action.asset_data and action.asset_data.catalog_id != str(uuid.UUID(int=0))
def conform_action(action):
tags = ('pose', 'anim')
if any(tag in action.asset_data.tags.keys() for tag in tags):
return
for fc in action.fcurves:
action.asset_data['is_single_frame'] = True
if len(fc.keyframe_points) > 1:
action.asset_data['is_single_frame'] = False
break
if action.asset_data['is_single_frame']:
action.asset_data.tags.new('pose')
else:
action.asset_data.tags.new('anim')
def clean_action(action='', frame_start=0, frame_end=0, excludes=[], includes=[]):
## Clean Keyframe Before/After Range
for fc in action.fcurves:
bone, prop = split_path(fc.data_path)
# !! Mush Mush dependent. Need to be fix
if bone in excludes or bone not in includes:
action.fcurves.remove(fc)
continue
# Add Keyframe At Start/End Range
for fr in (frame_start, frame_end):
fc_val = fc.evaluate(fr)
fc.keyframe_points.insert(frame=fr, value=fc_val)
fc.update()
# Remove Keyframe out of range
for k in reversed(fc.keyframe_points):
if int(k.co[0]) not in range(frame_start, frame_end+1):
fc.keyframe_points.remove(k)
fc.update()
def append_action(action_path='', action_name=''):
print(f'Loading {action_name} from: {action_path}')
with bpy.data.libraries.load(str(action_path), link=False) as (data_from, data_to):
data_to.actions = [action_name]
return data_to.actions[0]
def apply_anim(action_lib, ob, bones=[]):
from mathutils import Vector
scn = bpy.context.scene
if not ob.animation_data:
ob.animation_data_create()
action = ob.animation_data.action
if not action:
action = bpy.data.actions.new(ob.name)
ob.animation_data.action = action
keys = sorted([k.co[0] for f in action_lib.fcurves for k in f.keyframe_points])
if not keys:
print(f'The action {action_lib.name} has no keyframes')
return
first_key = keys[0]
key_offset = scn.frame_current - first_key
key_attr = ('type', 'interpolation', 'handle_left_type', 'handle_right_type',
'amplitude', 'back', 'easing', 'period', 'handle_right', 'handle_left'
)
for fc in action_lib.fcurves:
bone_name, prop_name = split_path(fc.data_path)
if bones and bone_name not in bones:
continue
action_fc = action.fcurves.find(fc.data_path, index=fc.array_index)
if not action_fc:
action_fc = action.fcurves.new(
fc.data_path,
index=fc.array_index,
action_group=fc.group.name if fc.group else fc.data_path.split('"')[1]
)
for kf_lib in fc.keyframe_points:
kf = action_fc.keyframe_points.insert(
frame=kf_lib.co[0] + key_offset,
value=kf_lib.co[1]
)
for attr in key_attr:
src_val = getattr(kf_lib, attr)
if attr.startswith('handle') and 'type' not in attr:
src_val += Vector((key_offset, 0))
setattr(kf, attr, src_val)
fc.update()
# redraw graph areas
for window in bpy.context.window_manager.windows:
screen = window.screen
for area in screen.areas:
if area.type == 'GRAPH_EDITOR':
area.tag_redraw()

49
action/gui.py Normal file
View File

@ -0,0 +1,49 @@
import bpy
def draw_context_menu(layout):
params = bpy.context.space_data.params
asset = bpy.context.asset_file_handle
layout.operator("assetlib.open_blend", text="Open blend file")#.asset = asset.name
layout.operator("assetlib.play_preview", text="Play Preview")
layout.separator()
layout.operator_context = 'INVOKE_DEFAULT'
#layout.operator("assetlib.rename_asset", text="Rename Action")
layout.operator("assetlib.clear_asset", text="Remove Asset")
layout.operator("assetlib.edit_data", text="Edit Asset data")
#layout.operator("actionlib.clear_asset", text="Clear Asset (Fake User)").use_fake_user = True
layout.separator()
layout.operator("actionlib.apply_selected_action", text="Apply Pose").flipped = False
layout.operator("actionlib.apply_selected_action", text="Apply Pose (Flipped)").flipped = True
layout.separator()
layout.operator("poselib.blend_pose_asset_for_keymap", text="Blend Pose").flipped = False
layout.operator("poselib.blend_pose_asset_for_keymap", text="Blend Pose (Flipped)").flipped = True
layout.separator()
layout.operator("poselib.pose_asset_select_bones", text="Select Bones").selected_side = 'CURRENT'
layout.operator("poselib.pose_asset_select_bones", text="Select Bones (Flipped)").selected_side = 'FLIPPED'
layout.operator("poselib.pose_asset_select_bones", text="Select Bones (Both)").selected_side = 'BOTH'
layout.separator()
#layout.operator("asset.library_refresh")
if params.display_type == 'THUMBNAIL':
layout.prop_menu_enum(params, "display_size")
def draw_header(layout):
'''Draw the header of the Asset Browser Window'''
layout.separator()
layout.operator("actionlib.store_anim_pose", text='Add Action', icon='FILE_NEW')

49
action/keymaps.py Normal file
View File

@ -0,0 +1,49 @@
from typing import List, Tuple
import bpy
addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = []
def register():
wm = bpy.context.window_manager
addon = wm.keyconfigs.addon
if not addon:
return
km = addon.keymaps.new(name="File Browser Main", space_type="FILE_BROWSER")
# DblClick to apply pose.
kmi = km.keymap_items.new("actionlib.apply_selected_action", "LEFTMOUSE", "DOUBLE_CLICK")
kmi.properties.flipped = False
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new("actionlib.apply_selected_action", "LEFTMOUSE", "DOUBLE_CLICK", alt=True)
kmi.properties.flipped = True
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new("poselib.blend_pose_asset_for_keymap", "LEFTMOUSE", "DOUBLE_CLICK", shift=True)
kmi.properties.flipped = False
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new("poselib.blend_pose_asset_for_keymap", "LEFTMOUSE", "DOUBLE_CLICK", alt=True, shift=True)
kmi.properties.flipped = True
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new("poselib.pose_asset_select_bones", "S", "PRESS")
kmi.properties.selected_side = 'CURRENT'
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new("poselib.pose_asset_select_bones", "S", "PRESS", alt=True)
kmi.properties.selected_side = 'FLIPPED'
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new("poselib.pose_asset_select_bones", "S", "PRESS", alt=True, ctrl=True)
kmi.properties.selected_side = 'BOTH'
addon_keymaps.append((km, kmi))
def unregister():
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()

1075
action/operators.py Normal file

File diff suppressed because it is too large Load Diff

34
action/properties.py Normal file
View File

@ -0,0 +1,34 @@
import bpy
from bpy.types import PropertyGroup
from bpy.props import PointerProperty, StringProperty, BoolProperty
class ACTIONLIB_PG_scene(PropertyGroup):
flipped : BoolProperty(
name="Flip Pose",
default=False,
)
previous_action : PointerProperty(type=bpy.types.Action)
publish_path : StringProperty(subtype='FILE_PATH')
camera : PointerProperty(type=bpy.types.Object, poll=lambda s, o: o.type == 'CAMERA')
rest_pose : PointerProperty(type=bpy.types.Action, poll=lambda s, a: a.asset_data)
classes = (
ACTIONLIB_PG_scene,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.actionlib = PointerProperty(type=ACTIONLIB_PG_scene)
def unregister():
try:
del bpy.types.Scene.actionlib
except AttributeError:
pass
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

44
action/rename_pose.py Normal file
View File

@ -0,0 +1,44 @@
import argparse
import bpy
import json
import re
import sys
from pathlib import Path
sys.path.append(Path(__file__).parents[3])
from asset_library.common.bl_utils import (
get_preview,
)
def rename_pose(src_name='', dst_name=''):
scn = bpy.context.scene
action = bpy.data.actions.get(src_name)
if not action:
print(f'No {src_name} not found.')
bpy.ops.wm.quit_blender()
action.name = dst_name
preview = get_preview(asset_path=bpy.data.filepath, asset_name=src_name)
if preview:
preview.rename(re.sub(src_name, dst_name, str(preview)))
bpy.ops.wm.save_mainfile(
filepath=bpy.data.filepath, compress=True, exit=True
)
if __name__ == '__main__' :
parser = argparse.ArgumentParser(description='Add Comment To the tracker',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--src-name')
parser.add_argument('--dst-name')
if '--' in sys.argv :
index = sys.argv.index('--')
sys.argv = [sys.argv[index-1], *sys.argv[index+1:]]
args = parser.parse_args()
rename_pose(**vars(args))

292
action/render_preview.py Normal file
View File

@ -0,0 +1,292 @@
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parents[3]))
from asset_library.action.concat_preview import mosaic_export
from asset_library.common.file_utils import open_file
from asset_library.action.functions import reset_bone, get_keyframes
from asset_library.common.functions import read_catalog
import bpy
import argparse
import json
import re
import shutil
import subprocess
from tempfile import gettempdir
def rm_tree(pth):
pth = Path(pth)
for child in pth.glob('*'):
if child.is_file():
child.unlink()
else:
rm_tree(child)
pth.rmdir()
def render_preview(directory, asset_catalog, render_actions, publish_actions, remove_folder):
scn = bpy.context.scene
rnd = bpy.context.scene.render
rnd.resolution_x = rnd.resolution_y = 512
report = []
blendfile = Path(bpy.data.filepath)
asset_catalog_data = read_catalog(asset_catalog)
anim_render_dir = Path(gettempdir()) / 'actionlib_render' #/tmp/actionlib_render. Removed at the end
anim_render_dir.mkdir(exist_ok=True, parents=True)
preview_render_dir = Path(directory) / 'preview'
if preview_render_dir.exists() and remove_folder:
rm_tree(preview_render_dir)
preview_render_dir.mkdir(exist_ok=True, parents=True)
for i in ('anim', 'pose'):
Path(preview_render_dir / i).mkdir(exist_ok=True, parents=True)
for f in preview_render_dir.rglob('*'):
if f.is_dir():
print(f'{f} is dir. Skipped.')
continue
if all(i not in f.parts for i in ('anim', 'pose')) and f.parent.parts[-1] != 'preview':
print(f'{f} is out of pipe. Approved or Rtk pictures. Skipped.')
continue
if not any(f.stem.endswith(a) for a in publish_actions):
print(f'{str(f)} not in publish actions anymore. Removing...')
f.unlink()
# Set Scene
# ----------
# Scene Setting
scn.use_preview_range = True
scn.eevee.use_gtao = True
scn.tool_settings.use_keyframe_insert_auto = False
# Render Setting
rnd.engine = 'BLENDER_EEVEE'
rnd.use_simplify = False
rnd.use_stamp_date = True
rnd.use_stamp_time = True
rnd.use_stamp_render_time = False
rnd.use_stamp_frame = True
rnd.use_stamp_frame_range = False
rnd.use_stamp_memory = False
rnd.use_stamp_hostname = False
rnd.use_stamp_camera = True
rnd.use_stamp_lens = False
rnd.use_stamp_scene = False
rnd.use_stamp_marker = False
rnd.use_stamp_filename = False
rnd.use_stamp_sequencer_strip = False
rnd.use_stamp_note = True
rnd.use_stamp = True
rnd.stamp_font_size = 16
rnd.use_stamp_labels = False
rnd.image_settings.file_format = 'JPEG'
# Viewport Look
# ----------
"""
# Eevee
for screen in bpy.data.screens:
for area in screen.areas:
for space in area.spaces:
if space.type == 'VIEW_3D':
space.overlay.show_overlays = False
space.shading.type = 'RENDERED'
space.shading.use_scene_lights_render = False
space.shading.use_scene_world_render = False
space.region_3d.view_perspective = 'CAMERA'
"""
# Cycles Mat Shading
for a in bpy.context.screen.areas:
if a.type == 'VIEW_3D':
a.spaces[0].overlay.show_overlays = False
a.spaces[0].region_3d.view_perspective = 'CAMERA'
a.spaces[0].shading.show_cavity = True
a.spaces[0].shading.cavity_type = 'WORLD'
a.spaces[0].shading.cavity_ridge_factor = 0.75
a.spaces[0].shading.cavity_valley_factor = 1.0
# Add Subsurf
# -----------
deform_ob = [m.object for o in scn.objects \
for m in o.modifiers if m.type == 'MESH_DEFORM'
]
deform_ob += [m.target for o in scn.objects \
for m in o.modifiers if m.type == 'SURFACE_DEFORM'
]
objects = [o for o in bpy.context.scene.objects if (o.type == 'MESH'
and o not in deform_ob and o not in bpy.context.scene.collection.objects[:])
]
for o in objects:
subsurf = False
for m in o.modifiers:
if m.type == 'SUBSURF':
m.show_viewport = m.show_render
m.levels = m.render_levels
subsurf = True
break
if not subsurf:
subsurf = o.modifiers.new('', 'SUBSURF')
subsurf.show_viewport = subsurf.show_render
subsurf.levels = subsurf.render_levels
# Loop through action and render
# ------------------------------
rig = next((o for o in scn.objects if o.type == 'ARMATURE'), None)
# actions = [a for a in bpy.data.actions if a.asset_data]
rig.animation_data_create()
for action_name in render_actions:
action = bpy.data.actions.get(action_name)
if not action:
print(f'\'{action_name}\' not found.')
continue
print(f"-- Current --: {action.name}")
rnd.stamp_note_text = '{type} : {pose_name}'
action_data = action.asset_data
if 'camera' not in action_data.keys():
report.append(f"'{action.name}' has no CameraData.")
continue
catalog_name = next((v['name'] for v in asset_catalog_data.values() if action_data.catalog_id == v['id']), None)
pose_name = '/'.join([*catalog_name.split('-'), action.name])
filename = bpy.path.clean_name(f'{catalog_name}_{action.name}')
ext = 'jpg'
rig.animation_data.action = None
bpy.context.view_layer.update()
for b in rig.pose.bones:
if re.match('^[A-Z]+\.', b.name):
continue
reset_bone(b)
rest_pose = None
if isinstance(action.asset_data.get('rest_pose'), str):
rest_pose = bpy.data.actions.get(action.asset_data['rest_pose'])
rig.animation_data.action = rest_pose
bpy.context.view_layer.update()
rig.animation_data.action = action
if 'camera' in action.asset_data.keys():
action_cam = bpy.data.objects.get(action.asset_data['camera'], '')
if action_cam:
scn.camera = action_cam
# Is Anim
if not action_data['is_single_frame'] or 'anim' in action_data.tags.keys():
keyframes = get_keyframes(action)
if not keyframes:
continue
anim_start = keyframes[0]
anim_end = keyframes[-1]
if anim_start < scn.frame_start:
report.append(f"Issue found for '{action.name}'. Has keyframes before 'Start Frame'.")
continue
scn.frame_preview_start = anim_start
scn.frame_preview_end = anim_end
rnd.stamp_note_text = rnd.stamp_note_text.format(
type='ANIM',
pose_name=pose_name,
)
rnd.filepath = f'{str(anim_render_dir)}/{filename}_####.{ext}'
bpy.ops.render.opengl(animation=True)
ffmpeg_cmd = [
'ffmpeg', '-y',
'-start_number', f'{anim_start:04d}',
'-i', rnd.filepath.replace('####', '%04d'),
'-c:v', 'libx264',
str((preview_render_dir/'anim'/filename).with_suffix('.mov')),
]
subprocess.call(ffmpeg_cmd)
# Is Pose
elif action_data['is_single_frame'] or 'pose' in action_data.tags.keys():
scn.frame_preview_start = scn.frame_preview_end = scn.frame_start
rnd.stamp_note_text = rnd.stamp_note_text.format(
type='POSE',
pose_name=pose_name,
)
rnd.filepath = f'{str(preview_render_dir)}/pose/{filename}_####.{ext}'
bpy.ops.render.opengl(animation=True)
filename = rnd.filepath.replace('####', f'{scn.frame_preview_end:04d}')
Path(filename).rename(re.sub('_[0-9]{4}.', '.', filename))
shutil.rmtree(anim_render_dir)
# Report
# ------
if report:
report_file = blendfile.parent / Path(f'{blendfile.stem}report').with_suffix('.txt')
if not report_file.exists():
report_file.touch(exist_ok=False)
report_file.write_text('-')
report_file.write_text('\n'.join(report))
result = report_file
else:
result = preview_render_dir
open_file(result)
files = [str(f) for f in sorted((preview_render_dir/'pose').glob('*.jpg'))]
mosaic_export(
files=files, catalog_data=asset_catalog_data,
row=2, columns=2, auto_calculate=True,
bg_color=(0.18, 0.18, 0.18,), resize_output=100
)
bpy.ops.wm.quit_blender()
if __name__ == '__main__' :
parser = argparse.ArgumentParser(description='Add Comment To the tracker',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--directory')
parser.add_argument('--asset-catalog')
parser.add_argument('--render-actions', nargs='+')
parser.add_argument('--publish-actions', nargs='+')
parser.add_argument('--remove-folder', type=json.loads, default='false')
if '--' in sys.argv :
index = sys.argv.index('--')
sys.argv = [sys.argv[index-1], *sys.argv[index+1:]]
args = parser.parse_args()
render_preview(**vars(args))

4
adapters/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from asset_library.adapters.adapter import AssetLibraryAdapter
from asset_library.adapters.copy_folder import CopyFolderLibrary
from asset_library.adapters.scan_folder import ScanFolderLibrary

545
adapters/adapter.py Normal file
View File

@ -0,0 +1,545 @@
from asset_library.common.functions import (read_catalog, write_catalog, norm_asset_datas, get_catalog_path)
from asset_library.common.bl_utils import get_addon_prefs, load_datablocks
from asset_library.common.file_utils import read_file, write_file
from asset_library.common.template import Template
from asset_library import (action, collection, file)
from bpy.types import PropertyGroup
from bpy.props import StringProperty
import bpy
from itertools import groupby
from pathlib import Path
import shutil
import os
import json
import uuid
class AssetLibraryAdapter(PropertyGroup):
#def __init__(self):
name = "Base Adapter"
#library = None
bundle_directory : StringProperty()
@property
def library(self):
prefs = self.addon_prefs
for lib in prefs.libraries:
if lib.adapter == self:
return lib
if lib.conform.adapter == self:
return lib
@property
def library_path(self):
return self.library.library_path
@property
def image_template(self):
return Template(self.library.image_template)
@property
def video_template(self):
return Template(self.library.video_template)
@property
def asset_description_template(self):
return Template(self.library.asset_description_template)
@property
def data_type(self):
return self.library.data_type
@property
def data_types(self):
return self.library.data_types
@property
def blend_depth(self):
return self.library.blend_depth
@property
def externalize_data(self):
return self.library.externalize_data
@property
def catalog_path(self):
return self.library.catalog_path
def get_catalog_path(self, filepath):
return get_catalog_path(filepath)
@property
def cache_file(self):
return Path(self.library_path) / f"blender_assets.{self.library.id}.json"
#return get_asset_datas_file(self.library_path)
@property
def addon_prefs(self):
return get_addon_prefs()
@property
def module_type(self):
lib_type = self.library.data_type
if lib_type == 'ACTION':
return action
elif lib_type == 'FILE':
return file
elif lib_type == 'COLLECTION':
return collection
def to_dict(self):
return {p: getattr(self, p) for p in self.bl_rna.properties.keys() if p !='rna_type'}
def fetch(self):
raise Exception('This method need to be define in the adapter')
def norm_file_name(self, name):
return name.replace(' ', '_')
def copy_file(self, source, destination):
src = Path(source)
dst = Path(destination)
if not source.exists():
print(f'Cannot copy file {source}: file not exist')
return
dst.parent.mkdir(exist_ok=True, parents=True)
if src == dst:
print(f'Cannot copy file {source}: source and destination are the same')
return
print(f'Copy file from {source} to {destination}')
shutil.copy2(str(source), str(destination))
def load_datablocks(self, src, names=None, type='objects', link=True, expr=None):
"""Link or append a datablock from a blendfile"""
return load_datablocks(src, names=names, type=type, link=link, expr=expr)
def get_asset_relative_path(self, name, catalog):
'''Get a relative path for the asset'''
name = self.norm_file_name(name)
return Path(catalog, name, name).with_suffix('.blend')
#def _get_file_name(self, name, filepath):
# '''Ensure having a unique name per asset if in the same folder by prefixing with the blend_file name'''
# file_name = name
# if filepath.stem != name:
# file_name = f'{file_name}_{name}'
#
# return file_name
def get_active_asset_library(self):
asset_handle = bpy.context.asset_file_handle
prefs = get_addon_prefs()
asset_handle = bpy.context.asset_file_handle
lib = None
if '.library_id' in asset_handle.asset_data:
lib_id = asset_handle.asset_data['.library_id']
lib = next((l for l in prefs.libraries if l.id == lib_id), None)
if not lib:
print(f"No library found for id {lib_id}")
if not lib:
lib = self
return lib
def get_active_asset_path(self):
'''Get the full path of the active asset_handle from the asset brower'''
prefs = get_addon_prefs()
asset_handle = bpy.context.asset_file_handle
lib = self.get_active_asset_library()
if 'filepath' in asset_handle.asset_data:
asset_path = asset_handle.asset_data['filepath']
asset_path = lib.adapter.format_path(asset_path)
else:
asset_path = bpy.types.AssetHandle.get_full_library_path(
asset_handle, bpy.context.asset_library_ref
)
return asset_path
def get_path(self, type, name, asset_path, template=None) -> Path:
if not template:
template = getattr(self, f'{type}_template')
if isinstance(template, str):
template = Template(template)
filepath = Path(asset_path)
return (filepath / template.format(name=name, path=Path(asset_path))).resolve()
#def get_image_path(self, name, asset_path):
# filepath = Path(asset_path)
# image_name = self._get_file_name(name, asset_path)
# return (filepath / self.image_template.format(name=image_name)).resolve()
def get_cache_image_path(self, name, catalog) -> Path:
""""Get the the cache path of a image for asset without an externalized image"""
return Path(self.library_path, '.previews', f"{catalog.replace('/', '_')}_{name}").with_suffix(('.png'))
def get_cache_image(self, name, catalog):
cache_image_path = self.get_cache_image_path(name, catalog)
if cache_image_path.exists():
return cache_image_path
#def get_video_path(self, name, asset_path):
# filepath = Path(asset_path)
# video_name = self._get_file_name(name, asset_path)
# return (filepath / self.video_template.format(name=video_name)).resolve()
def get_image(self, name, asset_path):
image_path = self.get_path('image', name, asset_path)
if image_path.exists():
return image_path
def get_video(self, name, asset_path):
video_path = self.get_path('video', name, asset_path)
if video_path.exists():
return video_path
def get_asset_description_path(self, asset_path) -> Path:
""""Get the path of the json or yaml describing all assets data in onle file"""
filepath = Path(asset_path)
return (filepath / self.asset_description_template.format(name=filepath.stem)).resolve()
def read_asset_description(self, asset_path) -> dict:
"""Read the description file of the asset"""
asset_description_path = self.get_asset_description_path(asset_path)
return read_file(asset_description_path)
def write_asset_description(self, asset_data, asset_path) -> None:
asset_description_path = self.get_asset_description_path(asset_path)
return write_file(asset_description_path, asset_data)
def write_asset(self, asset, asset_path):
bpy.data.libraries.write(
str(asset_path),
{asset},
path_remap="NONE",
fake_user=True,
compress=True
)
def read_catalog(self, filepath=None):
"""Read the catalog file of the library bundle path or of the specified filepath"""
catalog_path = self.catalog_path
if filepath:
catalog_path = self.get_catalog_path(filepath)
return read_catalog(catalog_path)
def write_catalog(self, catalog_data, filepath=None):
"""Write the catalog file in the library bundle path or of the specified filepath"""
catalog_path = self.catalog_path
if filepath:
catalog_path = self.get_catalog_path(filepath)
return write_catalog(catalog_path, catalog_data)
def read_cache(self):
return read_file(self.cache_file)
def norm_asset_datas(self, asset_file_datas):
''' Return a new flat list of asset data
the filepath keys are merge with the assets keys
'''
return norm_asset_datas(asset_file_datas)
def write_cache(self, asset_datas):
path = self.cache_file
print(f'cache file writen to {path}')
return write_file(path, list(asset_datas))
def prop_rel_path(self, path, prop):
'''Get a filepath relative to a property of the adapter'''
field_prop = '{%s}/'%prop
prop_value = getattr(self, prop)
prop_value = Path(os.path.expandvars(prop_value)).resolve()
rel_path = Path(path).resolve().relative_to(prop_value).as_posix()
return field_prop + rel_path
def write_preview(self, preview, filepath):
if not preview or not filepath:
return
filepath = Path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)
img_size = preview.image_size
px = [0] * img_size[0] * img_size[1] * 4
preview.image_pixels_float.foreach_get(px)
img = bpy.data.images.new(name=filepath.name, width=img_size[0], height=img_size[1], is_data=True, alpha=True)
img.pixels.foreach_set(px)
img.filepath_raw = str(filepath.with_suffix('.png'))
img.file_format = 'PNG'
img.save()
def draw_header(self, layout):
"""Draw the header of the Asset Browser Window"""
#layout.separator()
self.module_type.gui.draw_header(layout)
def draw_context_menu(self, layout):
"""Draw the context menu of the Asset Browser Window"""
#layout.separator()
self.module_type.gui.draw_context_menu(layout)
def group_key(self, asset_data):
"""Key used to group assets inside one blend"""
catalog_parts = asset_data['catalog'].split('/') + [asset_data['name']]
return catalog_parts[:self.blend_depth]
def set_asset_preview(self, asset, asset_data):
"""Load an externalize image as preview for an asset"""
image_path = Path(asset_data['image'])
if not image_path.is_absolute():
image_path = Path(asset_data['filepath'], image_path)
image_path = self.format_path(image_path.as_posix())
if image_path and image_path.exists():
with bpy.context.temp_override(id=asset):
bpy.ops.ed.lib_id_load_custom_preview(
filepath=str(image_path)
)
return
if asset.preview:
return
#Creating the preview for collection, object or material
src_asset = self.load_datablocks(asset_data['filepath'], names=asset_data['name'], link=True, type=self.data_types)
if not src_asset:
print(f'No asset named {asset_data["name"]} in {asset_data["filepath"]}')
return
bpy.ops.ed.lib_id_generate_preview({"id": src_asset})
#Transfering pixels between previews
pixels = [0] * (128*128*4)
src_asset.preview.image_pixels_float.foreach_get(pixels)
asset.preview.image_pixels_float.foreach_set(pixels)
getattr(bpy.data, self.data_types).remove(src_asset)
def set_asset_catalog(self, asset, asset_data, catalog_data):
"""Find the catalog if already exist or create it"""
catalog_name = asset_data['catalog']
catalog = catalog_data.get(catalog_name)
if not catalog:
catalog = {'id': str(uuid.uuid4()), 'name': catalog_name}
catalog_data[catalog_name] = catalog
asset.asset_data.catalog_id = catalog['id']
def set_asset_metadata(self, asset, asset_data):
"""Create custom prop to an asset base on provided data"""
metadata = asset_data.get('metadata', {})
library_id = self.library.id
if 'library_id' in asset_data:
library_id = asset_data['library_id']
metadata['.library_id'] = library_id
metadata['filepath'] = asset_data['filepath']
for k, v in metadata.items():
asset.asset_data[k] = v
def set_asset_tags(self, asset, asset_data):
"""Create asset tags base on provided data"""
tags = asset_data.get('tags', [])
if tags:
#Clear all tags first
for tag in asset.asset_data.tags[:]:
asset.asset_data.tags.remove(tag)
for tag in tags:
if not tag:
continue
asset.asset_data.tags.new(tag, skip_if_exists=True)
def bundle(self, cache_diff=None):
"""Group all new assets in one or multiple blends for the asset browser"""
if self.data_type not in ('FILE', 'ACTION', 'COLLECTION'):
print(f'{self.data_type} is not supported yet')
return
lib_path = self.library_path
catalog_data = self.read_catalog() #TODO remove unused catalog
write_cache = False
if not cache_diff:
# Get list of all modifications
cache, cache_diff = self.diff()
# Only write complete cache at the end
write_cache = True
elif isinstance(cache_diff, (Path, str)):
cache_diff = json.loads(Path(cache_diff).read_text(encoding='utf-8'))
if self.blend_depth == 0:
groups = [(cache_diff)]
else:
cache_diff.sort(key=self.group_key)
groups = groupby(cache_diff, key=self.group_key)
total_assets = len(cache_diff)
print(f'total_assets={total_assets}')
if total_assets == 0:
print('No assets found')
return
i = 0
for sub_path, asset_datas in groups:
blend_name = sub_path[-1].replace(' ', '_').lower()
blend_path = Path(lib_path, *sub_path, blend_name).with_suffix('.blend')
if blend_path.exists():
print(f'Opening existing bundle blend: {blend_path}')
bpy.ops.wm.open_mainfile(filepath=str(blend_path))
else:
print(f'Create new bundle blend to: {blend_path}')
bpy.ops.wm.read_homefile(use_empty=True)
for asset_data in asset_datas:
if total_assets <= 100 or i % int(total_assets / 10) == 0:
print(f'Progress: {int(i / total_assets * 100)+1}')
operation = asset_data.get('operation', 'ADD')
asset = getattr(bpy.data, self.data_types).get(asset_data['name'])
if operation == 'REMOVE':
if asset:
getattr(bpy.data, self.data_types).remove(asset)
else:
print(f'ERROR : Remove Asset: {asset_data["name"]} not found in {blend_path}')
continue
if operation == 'MODIFY' and not asset:
print(f'WARNING: Modifiy Asset: {asset_data["name"]} not found in {blend_path} it will be created')
elif operation == 'ADD' or not asset:
if asset:
#raise Exception(f"Asset {asset_data['name']} Already in Blend")
getattr(bpy.data, self.data_types).remove(asset)
#print(f"INFO: Add new asset: {asset_data['name']}")
asset = getattr(bpy.data, self.data_types).new(name=asset_data['name'])
else:
print(f'operation {operation} not supported should be in (ADD, REMOVE, MODIFIED)')
continue
asset.asset_mark()
self.set_asset_preview(asset, asset_data)
#if self.externalize_data:
# self.write_preview(preview, filepath)
self.set_asset_catalog(asset, asset_data, catalog_data)
self.set_asset_metadata(asset, asset_data)
self.set_asset_tags(asset, asset_data)
asset.asset_data.description = asset_data.get('description', '')
i += 1
print(f'Saving Blend to {blend_path}')
blend_path.parent.mkdir(exist_ok=True, parents=True)
bpy.ops.wm.save_as_mainfile(filepath=str(blend_path), compress=True)
if write_cache:
self.write_cache(cache)
self.write_catalog(catalog_data)
bpy.ops.wm.quit_blender()
def norm_cache(self, cache):
""" Return a new flat list of asset data
the filepath keys are merge with the assets keys"""
new_cache = []
for asset_description in cache:
asset_description = asset_description.copy()
if 'assets' in asset_description:
assets = asset_description.pop('assets')
for asset_data in assets:
new_cache.append({**asset_description, **asset_data})
else:
new_cache.append(asset_description)
return new_cache
def diff(self):
"""Compare the library cache with it current state and return the difference"""
cache = self.read_cache()
if cache is None:
print(f'Fetch The library {self.library.name} for the first time, might be long...')
cache = []
new_cache = self.fetch()
cache = {f"{a['filepath']}/{a['name']}": a for a in self.norm_cache(cache)}
new_cache = {f"{a['filepath']}/{a['name']}" : a for a in self.norm_cache(new_cache)}
assets_added = [v for k, v in new_cache.items() if k not in cache]
assets_removed = [v for k, v in cache.items() if k not in new_cache]
assets_modified = [v for k, v in cache.items() if v not in assets_removed and v!= new_cache[k]]
if assets_added:
print(f'{len(assets_added)} Assets Added \n{tuple(a["name"] for a in assets_added[:10])}\n')
if assets_removed:
print(f'{len(assets_removed)} Assets Removed \n{tuple(a["name"] for a in assets_removed[:10])}\n')
if assets_modified:
print(f'{len(assets_modified)} Assets Modified \n{tuple(a["name"] for a in assets_modified[:10])}\n')
assets_added = [dict(a, operation='ADD') for a in assets_added]
assets_removed = [dict(a, operation='REMOVE') for a in assets_removed]
assets_modified = [dict(a, operation='MODIFY') for a in assets_modified]
cache_diff = assets_added + assets_removed + assets_modified
if not cache_diff:
print('No change in the library')
return new_cache, cache_diff
def draw_prefs(self, layout):
"""Draw the options in the addon preference for this adapter"""
annotations = self.__class__.__annotations__
for k, v in annotations.items():
layout.prop(self, k, text=bpy.path.display_name(k))
def format_path(self, template, **kargs):
return Template(template).format(self.to_dict(), **kargs).resolve()

34
adapters/copy_folder.py Normal file
View File

@ -0,0 +1,34 @@
"""
Adapter for making an asset library of all blender file found in a folder
"""
from asset_library.adapters.adapter import AssetLibraryAdapter
from asset_library.common.file_utils import copy_dir
from bpy.props import StringProperty
from os.path import expandvars
class CopyFolderLibrary(AssetLibraryAdapter):
"""Copy library folder from a server to a local disk for better performance"""
name = "Copy Folder"
source_directory : StringProperty()
includes : StringProperty()
excludes : StringProperty()
def bundle(self):
src = expandvars(self.source_directory)
dst = expandvars(self.library_path)
includes = [inc.strip() for inc in self.includes.split(',')]
excludes = [ex.strip() for ex in self.excludes.split(',')]
print(f'Copy Folder from {src} to {dst}...')
copy_dir(
src, dst, only_recent=True,
excludes=excludes, includes=includes
)

0
adapters/data_file.py Normal file
View File

162
adapters/kitsu.py Normal file
View File

@ -0,0 +1,162 @@
"""
Plugin for making an asset library of all blender file found in a folder
"""
from asset_library.adapters.adapter import AssetLibraryAdapter
from asset_library.common.template import Template
from asset_library.common.file_utils import install_module
import bpy
from bpy.props import (StringProperty, IntProperty, BoolProperty)
import re
from pathlib import Path
from itertools import groupby
import uuid
import os
import shutil
import json
import urllib3
import traceback
import time
gazu = install_module('gazu')
class KitsuLibrary(AssetLibraryAdapter):
name = "Kitsu"
template_name : StringProperty()
template_file : StringProperty()
url: StringProperty()
login: StringProperty()
password: StringProperty(subtype='PASSWORD')
project_name: StringProperty()
def connect(self, url=None, login=None, password=None):
'''Connect to kitsu api using provided url, login and password'''
urllib3.disable_warnings()
if not self.url:
print(f'Kitsu Url: {self.url} is empty')
return
url = self.url
if not url.endswith('/api'):
url += '/api'
print(f'Info: Setting Host for kitsu {url}')
gazu.client.set_host(url)
if not gazu.client.host_is_up():
print('Error: Kitsu Host is down')
try:
print(f'Info: Log in to kitsu as {self.login}')
res = gazu.log_in(self.login, self.password)
print(f'Info: Sucessfully login to Kitsu as {res["user"]["full_name"]}')
return res['user']
except Exception as e:
print(f'Error: {traceback.format_exc()}')
def get_asset_path(self, name, catalog, directory=None):
directory = directory or self.source_directory
return Path(directory, self.get_asset_relative_path(name, catalog))
def get_asset_description(self, data, path):
modified = time.time_ns()
catalog = data['entity_type_name']
asset_path = Path(path)
asset_name = self.norm_file_name(data['name'])
asset_description = dict(
filepath='{source_directory}/' + asset_path.as_posix(),
modified=modified,
library_id=self.library.id,
assets=[dict(
catalog=catalog,
metadata=data.get('data', {}),
description=data['description'],
tags=[],
type=self.data_type,
image=str(self.image_template.format(name=asset_name)),
video=str(self.video_template.format(name=asset_name)),
name=data['name'])
]
)
return asset_description
def bundle(self, cache_diff=None):
"""Group all asset in one or multiple blends for the asset browser"""
return super().bundle(cache_diff=cache_diff)
def get_preview(self, asset_data):
name = asset_data['name']
preview = (f / image_template.format(name=name)).resolve()
if not preview.exists():
preview_blend_file(f, preview)
return preview
def conform(self, directory, templates):
"""Split each assets per blend and externalize preview"""
print(f'Conforming {self.library.name} to {directory}')
def fetch(self):
"""Gather in a list all assets found in the folder"""
print(f'Fetch Assets for {self.library.name}')
self.connect()
template_file = Template(self.template_file)
template_name = Template(self.template_name)
project = gazu.client.fetch_first('projects', {'name': self.project_name})
entity_types = gazu.client.fetch_all('entity-types')
entity_types_ids = {e['id']: e['name'] for e in entity_types}
new_cache = []
for asset_data in gazu.asset.all_assets_for_project(project):
asset_data['entity_type_name'] = entity_types_ids[asset_data.pop('entity_type_id')]
asset_name = asset_data['name']
asset_field_data = dict(name=asset_name, type=asset_data['entity_type_name'])
try:
asset_field_data.update(template_name.parse(asset_name))
except Exception:
print(f'Warning: Could not parse {asset_name} with template {template_name}')
asset_path = template_file.find(asset_field_data)
if not asset_path:
print(f'Warning: Could not find file for {template_file.format(asset_field_data)}')
continue
#print(asset_path)
new_cache.append(self.get_asset_description(asset_data, asset_path))
#asset = load_datablocks(asset_path, data_type='collections', names=asset_data['name'], link=True)
#if not asset:
# print(f"Asset {asset_name} not found in {asset_path}")
#asset_description = self.get_asset_description(asset)
#new_cache.append(asset_description)
#print(assets)
# for k, v in assets[0].items():
# print(f'- {k} {v}')
return new_cache

459
adapters/scan_folder.py Normal file
View File

@ -0,0 +1,459 @@
"""
Plugin for making an asset library of all blender file found in a folder
"""
from asset_library.adapters.adapter import AssetLibraryAdapter
from asset_library.common.bl_utils import load_datablocks
from asset_library.common.template import Template
import bpy
from bpy.props import (StringProperty, IntProperty, BoolProperty)
import re
from pathlib import Path
from itertools import groupby
import uuid
import os
import shutil
import json
class ScanFolderLibrary(AssetLibraryAdapter):
name = "Scan Folder"
source_directory : StringProperty(subtype='DIR_PATH')
template : StringProperty()
blend_depth : IntProperty()
#externalize_preview : BoolProperty(default=True)
#def draw_header(self, layout):
# '''Draw the header of the Asset Browser Window'''
# layout.separator()
# layout.operator("actionlib.store_anim_pose", text='Add Action', icon='FILE_NEW')
#def update(self):
#
def get_asset_path(self, name, catalog, directory=None):
directory = directory or self.source_directory
return Path(directory, self.get_asset_relative_path(name, catalog))
def get_asset_description(self, asset, catalog, modified):
asset_path = self.get_asset_relative_path(name=asset.name, catalog=catalog)
asset_name = self.norm_file_name(asset.name)
asset_description = dict(
filepath='{source_directory}/' + asset_path.as_posix(),
modified=modified,
library_id=self.library.id,
assets=[]
)
asset_description['assets'].append(dict(
catalog=catalog,
metadata=dict(asset.asset_data),
tags=asset.asset_data.tags.keys(),
type=self.data_type,
image=str(self.image_template.format(name=asset_name)),
video=str(self.video_template.format(name=asset_name)),
name=asset.name)
)
return asset_description
def _find_blend_files(self):
'''Get a sorted list of all blender files found matching the template'''
template = Template(self.template)
print(f'Search for blend using glob template: {template.glob_pattern}')
source_directory = Path(os.path.expandvars(self.source_directory))
print(f'Scanning Folder {source_directory}...')
blend_files = list(source_directory.glob(template.glob_pattern))
blend_files.sort()
return blend_files
def _group_key(self, asset_data):
"""Group assets inside one blend"""
catalog_parts = asset_data['catalog'].split('/') + [asset_data['name']]
return catalog_parts[:self.blend_depth]
def bundle(self, cache_diff=None):
"""Group all asset in one or multiple blends for the asset browser"""
if self.data_type not in ('FILE', 'ACTION'):
print(f'{self.data_type} is not supported yet')
return
lib_path = self.library_path
catalog_data = self.read_catalog() # TODO remove unused catalog
#asset_file_datas = self.fetch() # TODO replace to only change new assets
if not cache_diff:
# Get list of all modifications
cache, cache_diff = self.diff()
self.write_cache(cache)
elif isinstance(cache_diff, (Path, str)):
cache_diff = json.loads(Path(cache_diff).read_text(encoding='utf-8'))
if self.blend_depth == 0:
groups = [(cache_diff)]
else:
cache_diff.sort(key=self._group_key)
groups = groupby(cache_diff, key=self._group_key)
# #print(cache_diff)
# print('\n')
# for sub_path, asset_datas in groups:
# print('\n')
# print(f'{sub_path=}')
# print(f'asset_datas={list(asset_datas)}')
# raise Exception()
#progress = 0
total_assets = len(cache_diff)
print(f'total_assets={total_assets}')
if total_assets == 0:
print('No assets found')
return
i = 0
for sub_path, asset_datas in groups:
# print('\n')
# print(f'{sub_path=}')
# print(f'asset_datas={list(asset_datas)}')
# print('\n')
blend_name = sub_path[-1].replace(' ', '_').lower()
blend_path = Path(lib_path, *sub_path, blend_name).with_suffix('.blend')
if blend_path.exists():
print(f'Opening existing bundle blend: {blend_path}')
bpy.ops.wm.open_mainfile(filepath=str(blend_path))
else:
print(f'Create new bundle blend to: {blend_path}')
bpy.ops.wm.read_homefile(use_empty=True)
for asset_data in asset_datas:
if total_assets <= 100 or i % int(total_assets / 10) == 0:
print(f'Progress: {int(i / total_assets * 100)+1}')
operation = asset_data.get('operation', 'ADD')
asset = getattr(bpy.data, self.data_types).get(asset_data['name'])
if operation == 'REMOVE':
if asset:
getattr(bpy.data, self.data_types).remove(asset)
else:
print(f'ERROR : Remove Asset: {asset_data["name"]} not found in {blend_path}')
continue
elif operation == 'MODIFY':
if not asset:
print(f'WARNING: Modifiy Asset: {asset_data["name"]} not found in {blend_path} it will be created')
elif operation == 'ADD' or not asset:
if asset:
#raise Exception(f"Asset {asset_data['name']} Already in Blend")
getattr(bpy.data, self.data_types).remove(asset)
#print(f"INFO: Add new asset: {asset_data['name']}")
asset = getattr(bpy.data, self.data_types).new(name=asset_data['name'])
else:
print(f'operation {operation} not supported should be in (ADD, REMOVE, MODIFIED)')
continue
asset.asset_mark()
# Load external preview if exists
#image_template = Template(asset_data['preview'])
image_path = Path(asset_data['image'])
if not image_path.is_absolute():
image_path = Path(asset_data['filepath'], image_path)
image_path = self.format_path(image_path.as_posix())
if image_path and image_path.exists():
with bpy.context.temp_override(id=asset):
bpy.ops.ed.lib_id_load_custom_preview(
filepath=str(image_path)
)
#else:
# print(f'Preview {image_path} not found for asset {asset}')
asset.asset_data.description = asset_data.get('description', '')
catalog_name = asset_data['catalog']
catalog = catalog_data.get(catalog_name)
if not catalog:
catalog = {'id': str(uuid.uuid4()), 'name': catalog_name}
catalog_data[catalog_name] = catalog
asset.asset_data.catalog_id = catalog['id']
metadata = asset_data.get('metadata', {})
library_id = self.library.id
if 'library_id' in asset_data:
library_id = asset_data['library_id']
metadata['.library_id'] = library_id
#print(metadata)
metadata['filepath'] = asset_data['filepath']
for k, v in metadata.items():
asset.asset_data[k] = v
# Set tags if specified the asset_description
tags = asset_data.get('tags', [])
if tags:
for tag in asset.asset_data.tags[:]:
asset.asset_data.tags.remove(tag)
for tag in tags:
if not tag:
continue
asset.asset_data.tags.new(tag, skip_if_exists=True)
i += 1
print(f'Saving Blend to {blend_path}')
blend_path.parent.mkdir(exist_ok=True, parents=True)
bpy.ops.wm.save_as_mainfile(filepath=str(blend_path), compress=True)
self.write_catalog(catalog_data)
bpy.ops.wm.quit_blender()
def get_preview(self, asset_data):
name = asset_data['name']
preview = (f / image_template.format(name=name)).resolve()
if not preview.exists():
preview_blend_file(f, preview)
return preview
def conform(self, directory, templates):
"""Split each assets per blend and externalize preview"""
print(f'Conforming {self.library.name} to {directory}')
if self.data_type not in ('FILE', 'ACTION'):
print(f'{self.data_type} is not supported yet')
return
#lib_path = self.library_path
source_directory = Path(os.path.expandvars(self.source_directory))
catalog_data = self.read_catalog(filepath=source_directory)
catalog_ids = {v['id']: {'path': k, 'name': v['name']} for k,v in catalog_data.items()}
directory = Path(directory).resolve()
image_template = templates.get('image') or self.image_template
video_template = templates.get('video') or self.video_template
# Get list of all modifications
for blend_file in self._find_blend_files():
modified = blend_file.stat().st_mtime_ns
print(f'Scanning blendfile {blend_file}...')
with bpy.data.libraries.load(str(blend_file), link=True, assets_only=True) as (data_from, data_to):
asset_names = getattr(data_from, self.data_types)
print(f'Found {len(asset_names)} {self.data_types} inside')
setattr(data_to, self.data_types, asset_names)
assets = getattr(data_to, self.data_types)
#print('assets', assets)
for asset in assets:
#TODO options for choose beetween asset catalog and filepath directory
asset_catalog_data = catalog_ids.get(asset.asset_data.catalog_id)
if not asset_catalog_data:
print(f'No catalog found for asset {asset.name}')
asset_catalog_data = {"path": blend_file.parent.relative_to(source_directory).as_posix()}
catalog_path = asset_catalog_data['path']
asset_path = self.get_asset_path(name=asset.name, catalog=catalog_path, directory=directory)
asset_description = self.get_asset_description(asset, catalog=catalog_path, modified=modified)
self.write_asset_description(asset_description, asset_path)
#Write blend file containing only one asset
self.write_asset(asset=asset, asset_path=asset_path)
# Copy image if source image found else write the asset preview
src_image_path = self.get_path('image', name=asset.name, asset_path=blend_file, template=image_template)
dst_image_path = self.get_path('image', name=asset.name, asset_path=asset_path)
if src_image_path.exists():
self.copy_file(src_image_path, dst_image_path)
else:
self.write_preview(asset.preview, dst_image_path)
# Copy video if source video found
src_video_path = self.get_path('video', name=asset.name, asset_path=blend_file, template=video_template)
#print('src_video_path', src_video_path)
if src_video_path.exists():
dst_video_path = self.get_path('video', name=asset.name, asset_path=asset_path)
self.copy_file(src_video_path, dst_video_path)
self.write_catalog(catalog_data, filepath=directory)
def fetch(self):
"""Gather in a list all assets found in the folder"""
print(f'Fetch Assets for {self.library.name}')
source_directory = Path(os.path.expandvars(self.source_directory))
template = Template(self.template)
catalog_data = self.read_catalog(filepath=source_directory)
catalog_ids = {v['id']: {'path': k, 'name': v['name']} for k,v in catalog_data.items()}
cache = self.read_cache() or []
print(f'Search for blend using glob template: {template.glob_pattern}')
print(f'Scanning Folder {source_directory}...')
#blend_files = list(source_directory.glob(template.glob_pattern))
# Remove delete blends for the list
#blend_paths = [self.prop_rel_path(f, 'source_directory') for f in blend_files]
#print('blend_paths', blend_paths)
#cache = []
#blend_paths = []
new_cache = []
for blend_file in template.glob(source_directory):#sorted(blend_files):
source_rel_path = self.prop_rel_path(blend_file, 'source_directory')
modified = blend_file.stat().st_mtime_ns
asset_description = next((a for a in cache if a['filepath'] == source_rel_path), None)
if asset_description and asset_description['modified'] >= modified:
print(blend_file, 'is skipped because not modified')
new_cache.append(asset_description)
continue
rel_path = blend_file.relative_to(source_directory).as_posix()
#field_values = re.findall(re_pattern, rel_path)[0]
#field_data = {k:v for k,v in zip(field_names, field_values)}
field_data = template.parse(rel_path)
if not field_data:
raise Exception()
#asset_data = (blend_file / prefs.asset_description_template.format(name=name)).resolve()
catalogs = [v for k,v in sorted(field_data.items()) if k.isdigit()]
catalogs = [c.replace('_', ' ').title() for c in catalogs]
if self.data_type == 'FILE':
name = field_data.get('name', blend_file.stem)
image = self.get_path('image', name=name, asset_path=blend_file)
asset_description = dict(
filepath=source_rel_path,
modified=modified,
catalog='/'.join(catalogs),
tags=[],
type=self.data_type,
image=self.prop_rel_path(image, 'source_directory'),
name=name
)
new_cache.append(asset_description)
continue
#First Check if there is a asset_data .json
asset_description = self.read_asset_description(blend_file)
if not asset_description:
# Scan the blend file for assets inside and write a custom asset description for info found
print(f'Scanning blendfile {blend_file}...')
with bpy.data.libraries.load(str(blend_file), link=True, assets_only=True) as (data_from, data_to):
asset_names = getattr(data_from, self.data_types)
print(f'Found {len(asset_names)} {self.data_types} inside')
setattr(data_to, self.data_types, asset_names)
assets = getattr(data_to, self.data_types)
asset_description = dict(
filepath=source_rel_path,
modified=modified,
assets=[]
)
for asset in assets:
asset_catalog_data = catalog_ids.get(asset.asset_data.catalog_id)
if not asset_catalog_data:
print(f'No catalog found for asset {asset.name}')
asset_catalog_data = {"path": blend_file.relative_to(self.source_directory).as_posix()}
catalog_path = asset_catalog_data['path']
image_path = self.get_path('image', asset.name, catalog_path)
image = self.prop_rel_path(image_path, 'source_directory')
# Write image only if no image was found
if not image_path.exists():
image_path = self.get_cache_image_path(asset.name, catalog_path)
image = self.prop_rel_path(image_path, 'library_path')
self.write_preview(asset.preview, image_path)
video_path = self.get_path('video', asset.name, catalog_path)
video = self.prop_rel_path(video_path, 'source_directory')
asset_data = dict(
filepath=self.prop_rel_path(blend_file, 'source_directory'),
modified=modified,
catalog=catalog_path,
tags=asset.asset_data.tags.keys(),
type=self.data_type,
image=image,
video=video,
name=asset.name
)
asset_description['assets'].append(asset_data)
getattr(bpy.data, self.data_types).remove(asset)
new_cache.append(asset_description)
#cache = [a for a in cache if a['filepath'] in blend_paths]
#for a in asset_data:
# print(a)
#print(asset_data)
new_cache.sort(key=lambda x:x['filepath'])
return new_cache
# Write json data file to store all asset found
#print(f'Writing asset data file to, {asset_data_path}')
#asset_data_path.write_text(json.dumps(asset_data, indent=4))

25
collection/__init__.py Normal file
View File

@ -0,0 +1,25 @@
from asset_library.collection import (
gui,
operators,
keymaps,
build_collection_blends,
create_collection_library)
if 'bpy' in locals():
import importlib
importlib.reload(gui)
importlib.reload(operators)
importlib.reload(keymaps)
importlib.reload(build_collection_blends)
importlib.reload(create_collection_library)
def register():
operators.register()
keymaps.register()
def unregister():
operators.unregister()
keymaps.unregister()

View File

@ -0,0 +1,124 @@
import argparse
import bpy
import sys
import json
from pathlib import Path
from time import time, sleep
from itertools import groupby
from asset_library.common.bl_utils import load_datablocks, col_as_asset
from asset_library.constants import ASSETLIB_FILENAME
""" blender_assets.libs.json data Structure
[
{
'name': 'chars/main',
'id': '013562-56315-4563156-123',
'children':
[
{
'filepath' : '/z/...',
'name' : 'collection name',
'tags' : ['variation', 'machin', 'chose'],
'metadata' : {'filepath': '$PROJECT/...', 'version' : 'mushable'}
},
{
'filepath' : '/z/...',
},
],
},
]
"""
def build_collection_blends(path, categories=None, clean=True):
t0 = time()
scn = bpy.context.scene
scn.render.resolution_x = scn.render.resolution_y = 1000
json_path = Path(path) / ASSETLIB_FILENAME
if not json_path.exists():
return
# _col_datas = json.loads(json_path.read())[category]
category_datas = json.loads(json_path.read_text())
for category_data in category_datas:
if categories and category_data['name'] not in categories:
continue
bpy.ops.wm.read_homefile(use_empty=True)
#category_data = next(c for c in category_datas if c['name'] == category)
#_col_datas = category_data['children']
cat_name = category_data['name']
build_path = Path(path) / cat_name / f'{cat_name}.blend'
## re-iterate in grouped filepath
col_datas = sorted(category_data['children'], key=lambda x: x['filepath'])
for filepath, col_data_groups in groupby(col_datas, key=lambda x: x['filepath']):
#f = Path(f)
if not Path(filepath).exists():
print(f'Not exists: {filepath}')
continue
col_data_groups = list(col_data_groups)
col_names = [a['name'] for a in col_data_groups]
linked_cols = load_datablocks(filepath, col_names, link=True, type='collections')
for i, col in enumerate(linked_cols):
# iterate in linked collection and associated data
if not col:
continue
asset_data = col_data_groups[i]
## asset_data -> {'filepath': str, 'tags': list, 'metadata': dict}
## Directly link as collection inside a marked collection with same name
marked_col = col_as_asset(col, verbose=True)
marked_col.asset_data.description = asset_data.get('description', '')
marked_col.asset_data.catalog_id = category_data['id'] # assign catalog
for k, v in asset_data.get('metadata', {}).items():
marked_col.asset_data[k] = v
## exclude collections and generate preview
bpy.ops.ed.lib_id_generate_preview({"id": marked_col}) # preview gen
vcol = bpy.context.view_layer.layer_collection.children[marked_col.name]
vcol.exclude = True
sleep(1.0)
## clear all objects (can be very long with a lot of objects...):
if clean:
print('Removing links...')
for lib in reversed(bpy.data.libraries):
bpy.data.libraries.remove(lib)
# Créer les dossiers intermediaires
build_path.parent.mkdir(parents=True, exist_ok=True)
print('Saving to', build_path)
bpy.ops.wm.save_as_mainfile(filepath=str(build_path), compress=False)
print("build time:", f'{time() - t0:.1f}s')
bpy.ops.wm.quit_blender()
if __name__ == '__main__' :
parser = argparse.ArgumentParser(description='build_collection_blends',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-path') # Trouve/créer le json assetlib.json en sous-dossier de libdir
parser.add_argument('--category') # Lit la category dans le json et a link tout dans le blend
if '--' in sys.argv :
index = sys.argv.index('--')
sys.argv = [sys.argv[index-1], *sys.argv[index+1:]]
args = parser.parse_args()
build_collection_blends(**vars(args))

View File

@ -0,0 +1,163 @@
import argparse
import sys
import os
import json
import uuid
import subprocess
import bpy
from pathlib import Path
from asset_library.common.functions import create_catalog_file
from asset_library.common.file_utils import get_last_files
from asset_library.constants import ASSETLIB_FILENAME
"""
### Create asset collection
## create_collection_library: generate all category blend from json
## if source_directory is set, call create_collection_json
## # create_collection_json:
## # scan marked blend, create json and call create_catalog_file
## # create_catalog_file
## # create catalog file from json file
### Json Structure
[
{
'name': 'chars/main',
'id': '013562-56315-4563156-123',
'children':
[
{
'filepath' : '/z/...',
'name' : 'collection name',
'tags' : ['variation', 'machin', 'chose'],
'metadata' : {'filepath': '$PROJECT/...', 'version' : 'mushable'}
},
{
'filepath' : '/z/...',
},
],
},
]
"""
def create_collection_json(path, source_directory):
'''Create a Json from every marked collection in blends
contained in folderpath (respect hierachy)
'''
json_path = Path(path) / ASSETLIB_FILENAME
# scan all last version of the assets ?
# get last version files ?
# or open all blends and look only for marked collection ? (if versionned, get still get only last)
# get all blend in dir and subdirs (only last when versionned _v???)
blends = get_last_files(source_directory, pattern=r'(_v\d{3})?\.blend$', only_matching=True)
root_path = Path(source_directory).as_posix().rstrip('/') + '/'
print('root_path: ', root_path)
# open and check data block marked as asset
category_datas = []
for i, blend in enumerate(blends):
fp = Path(blend)
print(f'{i+1}/{len(blends)}')
## What is considered a grouping category ? top level folders ? parents[1] ?
## Remove root path and extension
## top level folder ('chars'), problem if blends at root
category = fp.as_posix().replace(root_path, '').split('/')[0]
## full blend path (chars/perso/perso)
# category = fp.as_posix().replace(root_path, '').rsplit('.', 1)[0]
print(category)
with bpy.data.libraries.load(blend, link=True, assets_only=True) as (data_from, data_to):
## just listing
col_name_list = [c for c in data_from.collections]
if not col_name_list:
continue
col_list = next((c['children'] for c in category_datas if c['name'] == category), None)
if col_list is None:
col_list = []
category_data = {
'name': category,
'id': str(uuid.uuid4()),
'children': col_list,
}
category_datas.append(category_data)
blend_source_path = blend.as_posix()
if (project_root := os.environ.get('PROJECT_ROOT')):
blend_source_path = blend_source_path.replace(project_root, '$PROJECT_ROOT')
for name in col_name_list:
data = {
'filepath' : blend,
'name' : name,
# 'tags' : [],
'metadata' : {'filepath': blend_source_path},
}
col_list.append(data)
json_path.write_text(json.dumps(category_datas, indent='\t'))
## create text catalog from json (keep_existing_category ?)
create_catalog_file(json_path, keep_existing_category=True)
def create_collection_library(path, source_directory=None):
'''
path: store collection library (json and blends database)
source_directory: if a source is set, rebuild json and library
'''
if source_directory:
if not Path(source_directory).exists():
print(f'Source directory not exists: {source_directory}')
return
## scan source and build json in assetlib dir root
create_collection_json(path, source_directory)
json_path = Path(path) / ASSETLIB_FILENAME
if not json_path.exists():
print(f'No json found at: {json_path}')
return
file_datas = json.loads(json_path.read())
## For each category in json, execute build_assets_blend script
script = Path(__file__).parent / 'build_collection_blends.py'
#empty_blend = Path(__file__).parent / 'empty_scene.blend'
# for category, asset_datas in file_datas.items():
for category_data in file_datas:
## add an empty blend as second arg
cmd = [bpy.app.binary_path, '--python', str(script), '--', '--path', path, '--category', category_data['name']]
print('cmd: ', cmd)
subprocess.call(cmd)
if __name__ == '__main__' :
parser = argparse.ArgumentParser(description='Create Collection Library',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--path') # trouve/créer le json assetlib.json en sous-dossier de libdir
if '--' in sys.argv :
index = sys.argv.index('--')
sys.argv = [sys.argv[index-1], *sys.argv[index+1:]]
args = parser.parse_args()
create_collection_library(**vars(args))

14
collection/gui.py Normal file
View File

@ -0,0 +1,14 @@
import bpy
def draw_context_menu(layout):
params = bpy.context.space_data.params
return
def draw_header(layout):
'''Draw the header of the Asset Browser Window'''
return

22
collection/keymaps.py Normal file
View File

@ -0,0 +1,22 @@
from typing import List, Tuple
import bpy
addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = []
def register():
wm = bpy.context.window_manager
addon = wm.keyconfigs.addon
if not addon:
return
km = addon.keymaps.new(name="File Browser Main", space_type="FILE_BROWSER")
kmi = km.keymap_items.new("assetlib.load_asset", "LEFTMOUSE", "DOUBLE_CLICK") # , shift=True
addon_keymaps.append((km, kmi))
def unregister():
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()

102
collection/operators.py Normal file
View File

@ -0,0 +1,102 @@
import bpy
from bpy.types import Context, Operator
from bpy_extras import asset_utils
from fnmatch import fnmatch
import os
import fnmatch
from os.path import expandvars
from typing import List, Tuple, Set
from pathlib import Path
import subprocess
from asset_library.common.bl_utils import load_col
from asset_library.common.functions import get_active_library
class ASSETLIB_OT_load_asset(Operator):
bl_idname = "assetlib.load_asset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
bl_label = 'Load Asset'
bl_description = 'Link and override asset in current file'
@classmethod
def poll(cls, context: Context) -> bool:
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
cls.poll_message_set("Current editor is not an asset browser")
return False
lib = get_active_library()
if not lib or lib.data_type != 'COLLECTION':
return False
if not context.active_file or 'filepath' not in context.active_file.asset_data:
cls.poll_message_set("Has not filepath property")
return False
return True
def execute(self, context: Context) -> Set[str]:
print('Load Asset')
lib = get_active_library()
print(lib, lib.data_type)
# dir(asset) : 'asset_data', 'bl_rna', 'id_type', 'local_id', 'name', 'preview_icon_id', 'relative_path', 'rna_type']
# dir(asset.asset_data) : 'active_tag', 'author', 'bl_rna', 'catalog_id', 'catalog_simple_name', 'description', 'rna_type', 'tags']
## get source path
# asset_file_handle = context.asset_file_handle
# if asset_file_handle is None:
# return {'CANCELLED'}
# if asset_file_handle.local_id:
# return {'CANCELLED'}
# asset_library_ref = context.asset_library_ref
# source_directory = bpy.types.AssetHandle.get_full_library_path(
# asset_file_handle, asset_library_ref
# )
asset = context.active_file
if not asset:
self.report({"ERROR"}, 'No asset selected')
return {'CANCELLED'}
fp = expandvars(asset.asset_data['filepath'])
name = asset.name
## set mode to object
if context.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
## get the real direct path with expand_var
print('path expanded: ', fp)
if not Path(fp).exists():
self.report({'ERROR'}, f'Not exists: {fp}')
return {'CANCELLED'}
res = load_col(fp, name, link=True, override=True, rig_pattern='*_rig')
if res:
if res.type == 'ARMATURE':
self.report({'INFO'}, f'Override rig {res.name}')
elif res.type == 'EMPTY':
self.report({'INFO'}, f'Instance collection {res.name}')
return {'FINISHED'}
### --- REGISTER ---
classes = (
ASSETLIB_OT_load_asset,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

26
common/__init__.py Normal file
View File

@ -0,0 +1,26 @@
#from asset_library.bundle_blend import bundle_blend, bundle_library
#from file_utils import (norm_str, norm_value,
# norm_arg, get_bl_cmd, copy_file, copy_dir)
#from asset_library.functions import
#from asset_library.common import bundle_blend
from asset_library.common import file_utils
from asset_library.common import functions
from asset_library.common import synchronize
from asset_library.common import template
if 'bpy' in locals():
import importlib
#importlib.reload(bundle_blend)
importlib.reload(file_utils)
importlib.reload(functions)
importlib.reload(synchronize)
importlib.reload(template)
import bpy

465
common/bl_utils.py Normal file
View File

@ -0,0 +1,465 @@
"""
Generic Blender functions
"""
from pathlib import Path
from fnmatch import fnmatch
from typing import Any, List, Iterable, Optional, Tuple
Datablock = Any
import bpy
from bpy_extras import asset_utils
from asset_library.constants import RESOURCES_DIR
#from asset_library.common.file_utils import no
from os.path import abspath
class attr_set():
'''Receive a list of tuple [(data_path, "attribute" [, wanted value)] ]
entering with-statement : Store existing values, assign wanted value (if any)
exiting with-statement: Restore values to their old values
'''
def __init__(self, attrib_list):
self.store = []
# item = (prop, attr, [new_val])
for item in attrib_list:
prop, attr = item[:2]
self.store.append( (prop, attr, getattr(prop, attr)) )
if len(item) >= 3:
try:
setattr(prop, attr, item[2])
except TypeError:
print(f'Cannot set attribute {attr} to {prop}')
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
for prop, attr, old_val in self.store:
setattr(prop, attr, old_val)
def get_overriden_col(ob, scene=None):
scn = scene or bpy.context.scene
cols = [c for c in bpy.data.collections if scn.user_of_id(c)]
return next((c for c in cols if ob in c.all_objects[:]
if all(not c.override_library for c in get_col_parents(c))), None)
def get_view3d_persp():
windows = bpy.context.window_manager.windows
view_3ds = [a for w in windows for a in w.screen.areas if a.type == 'VIEW_3D']
view_3d = next((a for a in view_3ds if a.spaces.active.region_3d.view_perspective == 'PERSP'), view_3ds[0])
return view_3d
def biggest_asset_browser_area(screen: bpy.types.Screen) -> Optional[bpy.types.Area]:
"""Return the asset browser Area that's largest on screen.
:param screen: context.window.screen
:return: the Area, or None if no Asset Browser area exists.
"""
def area_sorting_key(area: bpy.types.Area) -> Tuple[bool, int]:
"""Return area size in pixels."""
return (area.width * area.height)
areas = list(suitable_areas(screen))
if not areas:
return None
return max(areas, key=area_sorting_key)
def suitable_areas(screen: bpy.types.Screen) -> Iterable[bpy.types.Area]:
"""Generator, yield Asset Browser areas."""
for area in screen.areas:
space_data = area.spaces[0]
if not asset_utils.SpaceAssetInfo.is_asset_browser(space_data):
continue
yield area
def area_from_context(context: bpy.types.Context) -> Optional[bpy.types.Area]:
"""Return an Asset Browser suitable for the given category.
Prefers the current Asset Browser if available, otherwise the biggest.
"""
space_data = context.space_data
if asset_utils.SpaceAssetInfo.is_asset_browser(space_data):
return context.area
# Try the current screen first.
browser_area = biggest_asset_browser_area(context.screen)
if browser_area:
return browser_area
for win in context.window_manager.windows:
if win.screen == context.screen:
continue
browser_area = biggest_asset_browser_area(win.screen)
if browser_area:
return browser_area
return None
def activate_asset(
asset: bpy.types.Action, asset_browser: bpy.types.Area, *, deferred: bool
) -> None:
"""Select & focus the asset in the browser."""
space_data = asset_browser.spaces[0]
assert asset_utils.SpaceAssetInfo.is_asset_browser(space_data)
space_data.activate_asset_by_id(asset, deferred=deferred)
def active_catalog_id(asset_browser: bpy.types.Area) -> str:
"""Return the ID of the catalog shown in the asset browser."""
return params(asset_browser).catalog_id
def get_asset_space_params(asset_browser: bpy.types.Area) -> bpy.types.FileAssetSelectParams:
"""Return the asset browser parameters given its Area."""
space_data = asset_browser.spaces[0]
assert asset_utils.SpaceAssetInfo.is_asset_browser(space_data)
return space_data.params
def refresh_asset_browsers():
for area in suitable_areas(bpy.context.screen):
bpy.ops.asset.library_refresh({"area": area, 'region': area.regions[3]})
def tag_redraw(screen: bpy.types.Screen) -> None:
"""Tag all asset browsers for redrawing."""
for area in suitable_areas(screen):
area.tag_redraw()
# def get_blender_command(file=None, script=None, background=True, **args):
# '''Return a Blender Command as a list to be used in a subprocess'''
# cmd = [bpy.app.binary_path]
# if file:
# cmd += [str(file)]
# if background:
# cmd += ['--background']
# if script:
# cmd += ['--python', str(script)]
# if args:
# cmd += ['--']
# for k, v in args.items():
# cmd += [f"--{k.replace('_', '-')}", str(v)]
# return cmd
def norm_value(value):
if isinstance(value, (tuple, list)):
values = []
for v in value:
if not isinstance(v, str):
v = json.dumps(v)
values.append(v)
return values
if isinstance(value, Path):
return str(value)
if not isinstance(value, str):
value = json.dumps(value)
return value
def norm_arg(arg_name, format=str.lower, prefix='--', separator='-'):
arg_name = norm_str(arg_name, format=format, separator=separator)
return prefix + arg_name
def get_bl_cmd(blender=None, background=False, focus=True, blendfile=None, script=None, **kargs):
cmd = [str(blender)] if blender else [bpy.app.binary_path]
if background:
cmd += ['--background']
if not focus and not background:
cmd += ['--no-window-focus']
cmd += ['--window-geometry', '5000', '0', '10', '10']
if blendfile:
cmd += [str(blendfile)]
if script:
cmd += ['--python', str(script)]
if kargs:
cmd += ['--']
for k, v in kargs.items():
k = norm_arg(k)
v = norm_value(v)
cmd += [k]
if isinstance(v, (tuple, list)):
cmd += v
else:
cmd += [v]
return cmd
def get_addon_prefs():
addon_name = __package__.split('.')[0]
return bpy.context.preferences.addons[addon_name].preferences
def thumbnail_blend_file(input_blend, output_img):
input_blend = Path(input_blend).resolve()
output_img = Path(output_img).resolve()
print(f'Thumbnailing {input_blend} to {output_img}')
blender_thumbnailer = Path(bpy.app.binary_path).parent / 'blender-thumbnailer'
output_img.parent.mkdir(exist_ok=True, parents=True)
subprocess.call([blender_thumbnailer, str(input_blend), str(output_img)])
success = output_img.exists()
if not success:
empty_preview = RESOURCES_DIR / 'empty_preview.png'
shutil.copy(str(empty_preview), str(output_img))
return success
def get_col_parents(col, root=None, cols=None):
'''Return a list of parents collections of passed col
root : Pass a collection to search in (recursive)
else search in master collection
'''
if cols is None:
cols = []
if root == None:
root = bpy.context.scene.collection
for sub in root.children:
if sub == col:
cols.append(root)
if len(sub.children):
cols = get_col_parents(col, root=sub, cols=cols)
return cols
def get_overriden_col(ob, scene=None):
'''Get the collection use for making the override'''
scn = scene or bpy.context.scene
cols = [c for c in bpy.data.collections if scn.user_of_id(c)]
return next((c for c in cols if ob in c.all_objects[:]
if all(not c.override_library for c in get_col_parents(c))), None)
def load_assets_from(filepath: Path) -> List[Datablock]:
if not has_assets(filepath):
# Avoid loading any datablocks when there are none marked as asset.
return []
# Append everything from the file.
with bpy.data.libraries.load(str(filepath)) as (
data_from,
data_to,
):
for attr in dir(data_to):
setattr(data_to, attr, getattr(data_from, attr))
# Iterate over the appended datablocks to find assets.
def loaded_datablocks() -> Iterable[Datablock]:
for attr in dir(data_to):
datablocks = getattr(data_to, attr)
for datablock in datablocks:
yield datablock
loaded_assets = []
for datablock in loaded_datablocks():
if not getattr(datablock, "asset_data", None):
continue
# Fake User is lost when appending from another file.
datablock.use_fake_user = True
loaded_assets.append(datablock)
return loaded_assets
def has_assets(filepath: Path) -> bool:
with bpy.data.libraries.load(str(filepath), assets_only=True) as (
data_from,
_,
):
for attr in dir(data_from):
data_names = getattr(data_from, attr)
if data_names:
return True
return False
def copy_frames(start, end, offset, path):
for i in range (start, end):
src = path.replace('####', f'{i:04d}')
dst = src.replace(src.split('_')[-1].split('.')[0], f'{i+offset:04d}')
shutil.copy2(src, dst)
def split_path(path) :
try :
bone_name = path.split('["')[1].split('"]')[0]
except :
bone_name = None
try :
prop_name = path.split('["')[2].split('"]')[0]
except :
prop_name = path.split('.')[-1]
return bone_name, prop_name
def load_datablocks(src, names=None, type='objects', link=True, expr=None) -> list:
return_list = not isinstance(names, str)
names = names or []
if not isinstance(names, (list, tuple)):
names = [names]
if isinstance(expr, str):
pattern = expr
expr = lambda x : fnmatch(x, pattern)
with bpy.data.libraries.load(str(src), link=link) as (data_from, data_to):
datablocks = getattr(data_from, type)
if expr:
names += [i for i in datablocks if expr(i)]
elif not names:
names = datablocks
setattr(data_to, type, names)
datablocks = getattr(data_to, type)
if return_list:
return datablocks
elif datablocks:
return datablocks[0]
"""
# --- Collection handling
"""
def col_as_asset(col, verbose=False):
if col is None:
return
if verbose:
print('linking:', col.name)
pcol = bpy.data.collections.new(col.name)
bpy.context.scene.collection.children.link(pcol)
pcol.children.link(col)
pcol.asset_mark()
return pcol
def load_col(filepath, name, link=True, override=True, rig_pattern=None, context=None):
'''Link a collection by name from a file and override if has armature'''
# with bpy.data.libraries.load(filepath, link=link) as (data_from, data_to):
# data_to.collections = [c for c in data_from.collections if c == name]
# if not data_to.collections:
# return
# return data_to.collections[0]
context = context or bpy.context
collections = load_datablocks(filepath, name, link=link, type='collections')
if not collections:
print(f'No collection "{name}" found in: {filepath}')
return
col = collections[0]
print('collection:', col.name)
## create instance object
inst = bpy.data.objects.new(col.name, None)
inst.instance_collection = col
inst.instance_type = 'COLLECTION'
context.scene.collection.objects.link(inst)
# make active
inst.select_set(True)
context.view_layer.objects.active = inst
## simple object (no armatures)
if not link or not override:
return inst
if not next((o for o in col.all_objects if o.type == 'ARMATURE'), None):
return inst
## Create the override
# Search
parent_cols = inst.users_collection
child_cols = [child for pcol in parent_cols for child in pcol.children]
params = {'active_object': inst, 'selected_objects': [inst]}
try:
bpy.ops.object.make_override_library(params)
## check which collection is new in parents collection
asset_col = next((c for pcol in parent_cols for c in pcol.children if c not in child_cols), None)
if not asset_col:
print('Overriden, but no collection found !!')
return
for ob in asset_col.all_objects:
if ob.type != 'ARMATURE':
continue
if rig_pattern and not fnmatch(ob.name, rig_pattern):
continue
ob.hide_select = ob.hide_viewport = False
ob.select_set(True)
context.view_layer.objects.active = ob
print(ob.name)
return ob
except Exception as e:
print(f'Override failed on {col.name}')
print(e)
return inst
def get_preview(asset_path='', asset_name=''):
asset_preview_dir = Path(asset_path).parents[1]
name = asset_name.lower()
return next((f for f in asset_preview_dir.rglob('*') if f.stem.lower().endswith(name)), None)
def get_object_libraries(ob):
if not ob :
return []
libraries = [ob.library, ob.data.library]
if ob.type in ('MESH', 'CURVE'):
libraries += [m.library for m in ob.data.materials if m]
filepaths = []
for l in libraries:
if not l or not l.filepath:
continue
absolute_filepath = abspath(bpy.path.abspath(l.filepath, library=l))
if absolute_filepath in filepaths:
continue
filepaths.append(absolute_filepath)
return filepaths

307
common/file_utils.py Normal file
View File

@ -0,0 +1,307 @@
"""Generic python functions to make operation on file and names"""
import fnmatch
import json
import platform
import re
import subprocess
import unicodedata
import os
from pathlib import Path
import importlib
import sys
import shutil
def install_module(module_name, package_name=None):
'''Install a python module with pip or return it if already installed'''
try:
module = importlib.import_module(module_name)
except ModuleNotFoundError:
print(f'Installing Module {module_name} ....')
subprocess.call([sys.executable, '-m', 'ensurepip'])
subprocess.call([sys.executable, '-m', 'pip', 'install', package_name or module_name])
module = importlib.import_module(module_name)
return module
def import_module_from_path(path):
from importlib import util
try:
path = Path(path)
spec = util.spec_from_file_location(path.stem, str(path))
mod = util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
except Exception as e:
print(f'Cannot import file {path}')
print(e)
def norm_str(string, separator='_', format=str.lower, padding=0):
string = str(string)
string = string.replace('_', ' ')
string = string.replace('-', ' ')
string = re.sub('[ ]+', ' ', string)
string = re.sub('[ ]+\/[ ]+', '/', string)
string = string.strip()
if format:
string = format(string)
# Padd rightest number
string = re.sub(r'(\d+)(?!.*\d)', lambda x : x.group(1).zfill(padding), string)
string = string.replace(' ', separator)
string = unicodedata.normalize('NFKD', string).encode('ASCII', 'ignore').decode("utf-8")
return string
def remove_version(filepath):
pattern = '_v[0-9]+\.'
search = re.search(pattern, filepath)
if search:
filepath = filepath.replace(search.group()[:-1], '')
return Path(filepath).name
def is_exclude(name, patterns) -> bool:
# from fnmatch import fnmatch
if not isinstance(patterns, (list,tuple)) :
patterns = [patterns]
return any([fnmatch(name, p) for p in patterns])
def get_last_files(root, pattern=r'_v\d{3}\.\w+', only_matching=False, ex_file=None, ex_dir=None, keep=1, verbose=False) -> list:
'''Recursively get last(s) file(s) (when there is multiple versions) in passed directory
root -> str: Filepath of the folder to scan.
pattern -> str: Regex pattern to group files.
only_matching -> bool: Discard files that aren't matched by regex pattern.
ex_file -> list : List of fn_match pattern to exclude files.
ex_dir -> list : List of fn_match pattern of directory name to skip.
keep -> int: Number of lasts versions to keep when there are mutliple versionned files (e.g: 1 keep only last).
verbose -> bool: Print infos in console.
'''
files = []
if ex_file is None:
all_items = [f for f in os.scandir(root)]
else:
all_items = [f for f in os.scandir(root) if not is_exclude(f.name, ex_file)]
allfiles = [f for f in all_items if f.is_file()]
# Need to sort to effectively group separated key in list
allfiles.sort(key=lambda x: x.name)
dirs = [f for f in all_items if f.is_dir()]
for i in range(len(allfiles)-1,-1,-1):# fastest way to iterate on index in reverse
if not re.search(pattern, allfiles[i].name):
if only_matching:
allfiles.pop(i)
else:
files.append(allfiles.pop(i).path)
# separate remaining files in prefix grouped lists
lilist = [list(v) for k, v in groupby(allfiles, key=lambda x: re.split(pattern, x.name)[0])]
# get only item last of each sorted grouplist
for l in lilist:
versions = sorted(l, key=lambda x: x.name)[-keep:] # exclude older
for f in versions:
files.append(f.path)
if verbose and len(l) > 1:
print(f'{root}: keep {str([x.name for x in versions])} out of {len(l)} elements')
for d in dirs: # recursively treat all detected directory
if ex_dir and is_exclude(d.name, ex_dir):
# skip folder with excluded name
continue
files += get_last_files(
d.path, pattern=pattern, only_matching=only_matching, ex_file=ex_file, ex_dir=ex_dir, keep=keep)
return sorted(files)
def copy_file(src, dst, only_new=False, only_recent=False):
if dst.exists():
if only_new:
return
elif only_recent and dst.stat().st_mtime >= src.stat().st_mtime:
return
dst.parent.mkdir(exist_ok=True, parents=True)
print(f'Copy file from {src} to {dst}')
if platform.system() == 'Windows':
subprocess.call(['copy', str(src), str(dst)], shell=True)
else:
subprocess.call(['cp', str(src), str(dst)])
def copy_dir(src, dst, only_new=False, only_recent=False, excludes=['.*'], includes=[]):
src, dst = Path(src), Path(dst)
if includes:
includes = r'|'.join([fnmatch.translate(x) for x in includes])
if excludes:
excludes = r'|'.join([fnmatch.translate(x) for x in excludes])
if dst.is_dir():
dst.mkdir(exist_ok=True, parents=True)
else:
dst.parent.mkdir(exist_ok=True, parents=True)
if src.is_file():
copy_file(src, dst, only_new=only_new, only_recent=only_recent)
elif src.is_dir():
src_files = list(src.rglob('*'))
if excludes:
src_files = [f for f in src_files if not re.match(excludes, f.name)]
if includes:
src_files = [f for f in src_files if re.match(includes, f.name)]
dst_files = [dst/f.relative_to(src) for f in src_files]
for src_file, dst_file in zip(src_files, dst_files) :
if src_file.is_dir():
dst_file.mkdir(exist_ok=True, parents=True)
else:
copy_file(src_file, dst_file, only_new=only_new, only_recent=only_recent)
def open_file(filepath, select=False):
'''Open a filepath inside the os explorer'''
if platform.system() == 'Darwin': # macOS
cmd = ['open']
elif platform.system() == 'Windows': # Windows
cmd = ['explorer']
if select:
cmd += ['/select,']
else: # linux variants
cmd = ['xdg-open']
if select:
cmd = ['nemo']
cmd += [str(filepath)]
subprocess.Popen(cmd)
def open_blender_file(filepath=None):
filepath = filepath or bpy.data.filepath
cmd = sys.argv
# if no filepath, use command as is to reopen blender
if filepath != '':
if len(cmd) > 1 and cmd[1].endswith('.blend'):
cmd[1] = str(filepath)
else:
cmd.insert(1, str(filepath))
subprocess.Popen(cmd)
def read_file(path):
'''Read a file with an extension in (json, yaml, yml, txt)'''
exts = ('.json', '.yaml', '.yml', '.txt')
if not path:
print('Try to read empty file')
path = Path(path)
if not path.exists():
print('File not exist', path)
return
if path.suffix not in exts:
print(f'Cannot read file {path}, extension must be in {exts}')
return
txt = path.read_text()
data = None
if path.suffix.lower() in ('.yaml', '.yml'):
yaml = install_module('yaml')
try:
data = yaml.safe_load(txt)
except Exception:
print(f'Could not load yaml file {path}')
return
elif path.suffix.lower() == '.json':
try:
data = json.loads(txt)
except Exception:
print(f'Could not load json file {path}')
return
else:
data = txt
return data
def write_file(path, data, indent=4):
'''Read a file with an extension in (json, yaml, yml, text)'''
exts = ('.json', '.yaml', '.yml', '.txt')
if not path:
print('Try to write empty file')
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
if path.suffix not in exts:
print(f'Cannot read file {path}, extension must be in {exts}')
return
if path.suffix.lower() in ('.yaml', '.yml'):
yaml = install_module('yaml')
try:
path.write_text(yaml.dump(data), encoding='utf8')
except Exception as e:
print(e)
print(f'Could not write yaml file {path}')
return
elif path.suffix.lower() == '.json':
try:
path.write_text(json.dumps(data, indent=indent), encoding='utf8')
except Exception as e:
print(e)
print(f'Could not write json file {path}')
return
else:
data = path.write_text(data, encoding='utf8')
def synchronize(src, dst, only_new=False, only_recent=False, clear=False):
#actionlib_dir = get_actionlib_dir(custom=custom)
#local_actionlib_dir = get_actionlib_dir(local=True, custom=custom)
try:
if clear and Path(dst).exists():
shutil.rmtree(dst)
#set_actionlib_dir(custom=custom)
script = Path(__file__).parent / 'synchronize.py'
cmd = [
sys.executable,
script,
'--src', str(src),
'--dst', str(dst),
'--only-new', json.dumps(only_new),
'--only-recent', json.dumps(only_recent),
]
subprocess.Popen(cmd)
except Exception as e:
print(e)

465
common/functions.py Normal file
View File

@ -0,0 +1,465 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Function relative to the asset browser addon
"""
from pathlib import Path
import json
import os
import re
import time
#from asset_library.constants import ASSETLIB_FILENAME
import inspect
from asset_library.common.file_utils import read_file
from asset_library.common.bl_utils import get_addon_prefs
import uuid
import bpy
def command(func):
'''Decorator to be used from printed functions argument and run time'''
func_name = func.__name__.replace('_', ' ').title()
def _command(*args, **kargs):
bound = inspect.signature(func).bind(*args, **kargs)
bound.apply_defaults()
args_str = ', '.join([f'{k}={v}' for k, v in bound.arguments.items()])
print(f'\n[>-] {func_name} ({args_str}) --- Start ---')
t0 = time.time()
result = func(*args, **kargs)
print(f'[>-] {func_name} --- Finished (total time : {time.time() - t0:.2f}s) ---')
return result
return _command
def asset_warning_callback(self, context):
"""Callback function to display a warning message when ading or modifying an asset"""
self.warning = ''
if not self.name:
self.warning = 'You need to specify a name'
return
if not self.catalog:
self.warning = 'You need to specify a catalog'
return
lib = get_active_library()
action_path = lib.adapter.get_asset_relative_path(self.name, self.catalog)
self.path = action_path.as_posix()
if lib.merge_libraries:
prefs = get_addon_prefs()
lib = prefs.libraries[lib.store_library]
if not lib.adapter.get_asset_path(self.name, self.catalog).parents[1].exists():
self.warning = 'A new folder will be created'
def get_active_library():
'''Get the pref library properties from the active library of the asset browser'''
prefs = get_addon_prefs()
asset_lib_ref = bpy.context.space_data.params.asset_library_ref
#Check for merged library
for l in prefs.libraries:
if l.library_name == asset_lib_ref:
return l
def get_active_catalog():
'''Get the active catalog path'''
lib = get_active_library()
cat_data = lib.adapter.read_catalog()
cat_data = {v['id']:k for k,v in cat_data.items()}
cat_id = bpy.context.space_data.params.catalog_id
if cat_id in cat_data:
return cat_data[cat_id]
return ''
def norm_asset_datas(asset_file_datas):
''' Return a new flat list of asset data
the filepath keys are merge with the assets keys'''
asset_datas = []
for asset_file_data in asset_file_datas:
asset_file_data = asset_file_data.copy()
if 'assets' in asset_file_data:
assets = asset_file_data.pop('assets')
for asset_data in assets:
asset_datas.append({**asset_file_data, **asset_data})
else:
asset_datas.append(asset_file_data)
return asset_datas
def cache_diff(cache, new_cache):
'''Compare and return the difference between two asset datas list'''
#TODO use an id to be able to tell modified asset if renamed
#cache = {a.get('id', a['name']) : a for a in norm_asset_datas(cache)}
#new_cache = {a.get('id', a['name']) : a for a in norm_asset_datas(new_cache)}
cache = {f"{a['filepath']}/{a['name']}": a for a in norm_asset_datas(cache)}
new_cache = {f"{a['filepath']}/{a['name']}" : a for a in norm_asset_datas(new_cache)}
assets_added = [v for k, v in new_cache.items() if k not in cache]
assets_removed = [v for k, v in cache.items() if k not in new_cache]
assets_modified = [v for k, v in cache.items() if v not in assets_removed and v!= new_cache[k]]
if assets_added:
print(f'{len(assets_added)} Assets Added \n{tuple(a["name"] for a in assets_added[:10])}\n')
if assets_removed:
print(f'{len(assets_removed)} Assets Removed \n{tuple(a["name"] for a in assets_removed[:10])}\n')
if assets_modified:
print(f'{len(assets_modified)} Assets Modified \n{tuple(a["name"] for a in assets_modified[:10])}\n')
assets_added = [dict(a, operation='ADD') for a in assets_added]
assets_removed = [dict(a, operation='REMOVE') for a in assets_removed]
assets_modified = [dict(a, operation='MODIFY') for a in assets_modified]
assets_diff = assets_added + assets_removed + assets_modified
if not assets_diff:
print('No change in the library')
return assets_diff
def clean_default_lib():
prefs = bpy.context.preferences
if not prefs.filepaths.asset_libraries:
print('[>-] No Asset Libraries Filepaths Setted.')
return
lib, lib_id = get_lib_id(
library_name='User Library',
asset_libraries=prefs.filepaths.asset_libraries
)
if lib:
bpy.ops.preferences.asset_library_remove(index=lib_id)
def get_asset_source(replace_local=False):
sp = bpy.context.space_data
prefs = bpy.context.preferences.addons[__package__].preferences
asset_file_handle = bpy.context.asset_file_handle
if asset_file_handle is None:
return None
if asset_file_handle.local_id:
publish_path = os.path.expandvars(scn.actionlib.get('publish_path'))
if not publish_path:
print('[>.] No \'Publish Dir\' found. Publish file first.' )
return None
return Path(publish_path)
asset_library_ref = bpy.context.asset_library_ref
source_path = bpy.types.AssetHandle.get_full_library_path(asset_file_handle, asset_library_ref)
if replace_local:
if 'custom' in sp.params.asset_library_ref.lower():
actionlib_path = prefs.action.custom_path
actionlib_path_local = prefs.action.custom_path_local
else:
actionlib_path = prefs.action.path
actionlib_path_local = prefs.action.path_local
source_path = re.sub(actionlib_dir_local, actionlib_dir, source_path)
return source_path
def get_catalog_path(filepath=None):
filepath = filepath or bpy.data.filepath
filepath = Path(filepath)
if filepath.is_file():
filepath = filepath.parent
filepath.mkdir(parents=True, exist_ok=True)
catalog = filepath / 'blender_assets.cats.txt'
if not catalog.exists():
catalog.touch(exist_ok=False)
return catalog
# def read_catalog(path, key='path'):
# cat_data = {}
# supported_keys = ('path', 'id', 'name')
# if key not in supported_keys:
# raise Exception(f'Not supported key: {key} for read catalog, supported keys are {supported_keys}')
# for line in Path(path).read_text(encoding="utf-8").split('\n'):
# if line.startswith(('VERSION', '#')) or not line:
# continue
# cat_id, cat_path, cat_name = line.split(':')
# if key == 'id':
# cat_data[cat_id] = {'path':cat_path, 'name':cat_name}
# elif key == 'path':
# cat_data[cat_path] = {'id':cat_id, 'name':cat_name}
# elif key =='name':
# cat_data[cat_name] = {'id':cat_id, 'path':cat_path}
# return cat_data
def read_catalog(path):
cat_data = {}
for line in Path(path).read_text(encoding="utf-8").split('\n'):
if line.startswith(('VERSION', '#')) or not line:
continue
cat_id, cat_path, cat_name = line.split(':')
cat_data[cat_path] = {'id':cat_id, 'name':cat_name}
return cat_data
def write_catalog(path, data):
lines = ['VERSION 1', '']
# Add missing parents catalog
norm_data = {}
for cat_path, cat_data in data.items():
norm_data[cat_path] = cat_data
for p in Path(cat_path).parents[:-1]:
if p in data or p in norm_data:
continue
norm_data[p.as_posix()] = {'id': str(uuid.uuid4()), 'name': '-'.join(p.parts)}
for cat_path, cat_data in sorted(norm_data.items()):
cat_name = cat_data['name'].replace('/', '-')
lines.append(f"{cat_data['id']}:{cat_path}:{cat_name}")
print(f'Catalog writen at: {path}')
Path(path).write_text('\n'.join(lines), encoding="utf-8")
def create_catalog_file(json_path : str|Path, keep_existing_category : bool = True):
'''create asset catalog file from json
if catalog already exists, keep existing catalog uid'''
json_path = Path(json_path)
# if not json.exists(): return
assert json_path.exists(), 'Json not exists !'
category_datas = json.loads(json_path.read_text(encoding="utf-8"))
catalog_path = json_path.parent / 'blender_assets.cats.txt'
catalog_data = {}
if catalog_path.exists():
catalog_data = read_catalog(catalog_path)
## retrun a format catalog_data[path] = {'id':id, 'name':name}
## note: 'path' in catalog is 'name' in category_datas
catalog_lines = ['VERSION 1', '']
## keep existing
for c in category_datas:
# keep same catalog line for existing category keys
if keep_existing_category and catalog_data.get(c['name']):
print(c['name'], 'category exists')
cat = catalog_data[c['name']] #get
catalog_lines.append(f"{cat['id']}:{c['name']}:{cat['name']}")
else:
print(c['name'], 'new category')
# add new category
catalog_lines.append(f"{c['id']}:{c['name']}:{c['name'].replace('/', '-')}")
## keep category that are non-existing in json ?
if keep_existing_category:
for k in catalog_data.keys():
if next((c['name'] for c in category_datas if c['name'] == k), None):
continue
print(k, 'category not existing in json')
cat = catalog_data[k]
# rebuild existing line
catalog_lines.append(f"{cat['id']}:{k}:{cat['name']}")
## write_text overwrite the file
catalog_path.write_text('\n'.join(catalog_lines), encoding="utf-8")
print(f'Catalog saved at: {catalog_path}')
return
def clear_env_libraries():
print('clear_env_libraries')
prefs = get_addon_prefs()
asset_libraries = bpy.context.preferences.filepaths.asset_libraries
for env_lib in prefs.env_libraries:
name = env_lib.get('asset_library')
if not name:
continue
asset_lib = asset_libraries.get(name)
if not asset_lib:
continue
index = list(asset_libraries).index(asset_lib)
bpy.ops.preferences.asset_library_remove(index=index)
prefs.env_libraries.clear()
'''
env_libs = get_env_libraries()
paths = [Path(l['path']).resolve().as_posix() for n, l in env_libs.items()]
for i, l in reversed(enumerate(libs)):
lib_path = Path(l.path).resolve().as_posix()
if (l.name in env_libs or lib_path in paths):
libs.remove(i)
'''
def set_env_libraries(path=None) -> list:
'''Read the environments variables and create the libraries'''
#from asset_library.prefs import AssetLibraryOptions
prefs = get_addon_prefs()
path = path or prefs.config_directory
#print('Read', path)
library_data = read_file(path)
clear_env_libraries()
if not library_data:
return
libs = []
for lib_info in library_data:
lib = prefs.env_libraries.add()
lib.set_dict(lib_info)
libs.append(lib)
return libs
'''
def get_env_libraries():
env_libraries = {}
for k, v in os.environ.items():
if not re.findall('ASSET_LIBRARY_[0-9]', k):
continue
lib_infos = v.split(os.pathsep)
if len(lib_infos) == 5:
name, data_type, tpl, src_path, bdl_path = lib_infos
elif len(lib_infos) == 4:
name, data_type, tpl, src_path = lib_infos
bdl_path = ''
else:
print(f'Wrong env key {k}', lib_infos)
continue
source_type = 'TEMPLATE'
if tpl.lower().endswith(('.json', '.yml', 'yaml')):
source_type = 'DATA_FILE'
env_libraries[name] = {
'data_type': data_type,
'source_directory': src_path,
'bundle_directory': bdl_path,
'source_type': source_type,
'template': tpl,
}
return env_libraries
'''
def resync_lib(name, waiting_time):
bpy.app.timers.register(
lambda: bpy.ops.assetlib.synchronize(only_recent=True, name=name),
first_interval=waiting_time
)
'''
def set_assetlib_paths():
prefs = bpy.context.preferences
assetlib_name = 'Assets'
assetlib = prefs.filepaths.asset_libraries.get(assetlib_name)
if not assetlib:
bpy.ops.preferences.asset_library_add(directory=str(assetlib_path))
assetlib = prefs.filepaths.asset_libraries[-1]
assetlib.name = assetlib_name
assetlib.path = str(actionlib_dir)
def set_actionlib_paths():
prefs = bpy.context.preferences
actionlib_name = 'Action Library'
actionlib_custom_name = 'Action Library Custom'
actionlib = prefs.filepaths.asset_libraries.get(actionlib_name)
if not assetlib:
bpy.ops.preferences.asset_library_add(directory=str(assetlib_path))
assetlib = prefs.filepaths.asset_libraries[-1]
assetlib.name = assetlib_name
actionlib_dir = get_actionlib_dir(custom=custom)
local_actionlib_dir = get_actionlib_dir(local=True, custom=custom)
if local_actionlib_dir:
actionlib_dir = local_actionlib_dir
if actionlib_name not in prefs.filepaths.asset_libraries:
bpy.ops.preferences.asset_library_add(directory=str(actionlib_dir))
#lib, lib_id = get_lib_id(
# library_path=actionlib_dir,
# asset_libraries=prefs.filepaths.asset_libraries
#)
#if not lib:
# print(f'Cannot set dir for {actionlib_name}')
# return
prefs.filepaths.asset_libraries[lib_id].name = actionlib_name
#prefs.filepaths.asset_libraries[lib_id].path = str(actionlib_dir)
'''

42
common/synchronize.py Normal file
View File

@ -0,0 +1,42 @@
import argparse
import fnmatch
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
# import module utils without excuting __init__
spec = importlib.util.spec_from_file_location(
"utils", Path(__file__).parent/"file_utils.py"
)
utils = importlib.util.module_from_spec(spec)
spec.loader.exec_module(utils)
def synchronize(src, dst, only_new=False, only_recent=False):
excludes=['*.sync-conflict-*', '.*']
includes=['*.blend', 'blender_assets.cats.txt']
utils.copy_dir(
src, dst,
only_new=only_new, only_recent=only_recent,
excludes=excludes, includes=includes
)
if __name__ == '__main__' :
parser = argparse.ArgumentParser(description='Add Comment To the tracker',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--src')
parser.add_argument('--dst')
parser.add_argument('--only-new', type=json.loads, default='false')
parser.add_argument('--only-recent', type=json.loads, default='false')
args = parser.parse_args()
synchronize(**vars(args))

89
common/template.py Normal file
View File

@ -0,0 +1,89 @@
import re
import os
from pathlib import Path
from fnmatch import fnmatch
from glob import glob
class Template:
field_pattern = re.compile(r'{(\w+)\*{0,2}}')
field_pattern_recursive = re.compile(r'{(\w+)\*{2}}')
def __init__(self, template):
#asset_data_path = Path(lib_path) / ASSETLIB_FILENAME
self.template = template
@property
def glob_pattern(self):
pattern = self.field_pattern_recursive.sub('**', self.template)
pattern = self.field_pattern.sub('*', pattern)
return pattern
@property
def re_pattern(self):
pattern = self.field_pattern_recursive.sub('([\\\w -_.\/]+)', self.template)
pattern = self.field_pattern.sub('([\\\w -_.]+)', pattern)
pattern = pattern.replace('?', '.')
pattern = pattern.replace('*', '.*')
return re.compile(pattern)
@property
def fields(self):
return self.field_pattern.findall(self.template)
#return [f or '0' for f in fields]
def parse(self, path):
path = Path(path).as_posix()
res = self.re_pattern.findall(path)
if not res:
print('Could not parse {path} with {self.re_pattern}')
return {}
fields = self.fields
if len(fields) == 1:
field_values = res
else:
field_values = res[0]
return {k:v for k,v in zip(fields, field_values)}
def format(self, data=None, **kargs):
#print('format', self.template, data, kargs)
data = {**(data or {}), **kargs}
try:
path = self.template.format(**data)
except KeyError:
print(f'Cannot format {self.template} with {data}')
return
path = os.path.expandvars(path)
return Path(path)
def glob(self, directory, pattern=None):
'''If pattern is given it need to be absolute'''
if pattern is None:
pattern = Path(directory, self.glob_pattern).as_posix()
for entry in os.scandir(directory):
entry_path = Path(entry.path)
if entry.is_file() and fnmatch(entry_path.as_posix(), pattern):
yield entry_path
elif entry.is_dir():
yield from self.glob(entry.path, pattern)
def find(self, data, **kargs):
pattern = self.format(data, **kargs)
paths = glob(pattern.as_posix())
if paths:
return Path(paths[0])
def __repr__(self):
return f'Template({self.template})'

15
constants.py Normal file
View File

@ -0,0 +1,15 @@
from pathlib import Path
DATA_TYPE_ITEMS = [
("ACTION", "Action", "", "ACTION", 1),
("COLLECTION", "Collection", "", "OUTLINER_OB_GROUP_INSTANCE", 2),
("FILE", "File", "", "FILE", 3)
]
DATA_TYPES = [i[0] for i in DATA_TYPE_ITEMS]
ICONS = {identifier: icon for identifier, name, description, icon, number in DATA_TYPE_ITEMS}
ASSETLIB_FILENAME = "blender_assets.libs.json"
MODULE_DIR = Path(__file__).parent
RESOURCES_DIR = MODULE_DIR / 'resources'
ADAPTER_DIR = MODULE_DIR / 'adapters'

18
file/__init__.py Normal file
View File

@ -0,0 +1,18 @@
from asset_library.file import (
operators, gui, keymaps)
if 'bpy' in locals():
import importlib
importlib.reload(operators)
importlib.reload(gui)
importlib.reload(keymaps)
def register():
operators.register()
keymaps.register()
def unregister():
operators.unregister()
keymaps.unregister()

171
file/bundle.py Normal file
View File

@ -0,0 +1,171 @@
import argparse
import sys
import json
from pathlib import Path
import bpy
import re
import uuid
from itertools import groupby
from asset_library.constants import ASSETLIB_FILENAME, MODULE_DIR
from asset_library.common.bl_utils import thumbnail_blend_file
from asset_library.common.functions import (read_catalog, get_catalog_path,
command, write_catalog)
@command
def bundle_library(source_directory, bundle_directory, asset_description_template, thumbnail_template,
template=None, data_file=None):
field_pattern = r'{(\w+)}'
asset_data_path = Path(bundle_directory) / ASSETLIB_FILENAME
glob_pattern = re.sub(field_pattern, '*', template)
re_pattern = re.sub(field_pattern, r'([\\w -_.]+)', template)
re_pattern = re_pattern.replace('?', '.')
field_names = re.findall(field_pattern, template)
asset_file_datas = []
for f in sorted(Path(source_directory).glob(glob_pattern)):
rel_path = f.relative_to(source_directory).as_posix()
field_values = re.findall(re_pattern, rel_path)[0]
field_data = {k:v for k,v in zip(field_names, field_values)}
name = field_data.get('name', f.stem)
thumbnail = (f / thumbnail_template.format(name=name)).resolve()
asset_data = (f / asset_description_template.format(name=name)).resolve()
catalogs = sorted([v for k,v in sorted(field_data.items()) if k.isdigit()])
catalogs = [c.replace('_', ' ').title() for c in catalogs]
if not thumbnail.exists():
thumbnail_blend_file(f, thumbnail)
asset_data = {
'catalog' : '/'.join(catalogs),
'preview' : thumbnail.as_posix(), #'./' + bpy.path.relpath(str(thumbnail), start=str(f))[2:],
'filepath' : f.as_posix(), #'./' + bpy.path.relpath(str(f), start=str(asset_data_path))[2:],
'name': name,
'tags': [],
'metadata': {'filepath': f.as_posix()}
}
asset_file_datas.append(asset_data)
# Write json data file to store all asset found
print(f'Writing asset data file to, {asset_data_path}')
asset_data_path.write_text(json.dumps(asset_file_datas, indent=4))
#script = MODULE_DIR / 'common' / 'bundle_blend.py'
#cmd = [bpy.app.binary_path, '--python', str(script), '--', '--filepath', str(filepath)]
#print(cmd)
#subprocess.call(cmd)
@command
def bundle_blend(filepath, depth=0):
#print('Bundle Blend...')
filepath = Path(filepath)
#asset_data_path = get_asset_datas_file(filepath)
asset_data_path = filepath / ASSETLIB_FILENAME
blend_name = filepath.name.replace(' ', '_').lower()
blend_path = (filepath / blend_name).with_suffix('.blend')
if not asset_data_path.exists():
raise Exception(f'The file {asset_data_path} not exist')
catalog_path = get_catalog_path(filepath)
catalog_data = read_catalog(catalog_path)
asset_file_data = json.loads(asset_data_path.read_text())
#asset_file_data = {i['catalog']:i for i in asset_file_data}
if depth == 0:
groups = [asset_file_data]
else:
asset_file_data.sort(key=lambda x :x['catalog'].split('/')[:depth])
groups = groupby(asset_file_data, key=lambda x :x['catalog'].split('/')[:depth])
#progress = 0
total_assets = len(asset_file_data)
i = 0
for sub_path, asset_datas in groups:
bpy.ops.wm.read_homefile(use_empty=True)
for asset_data in asset_datas:
blend_name = sub_path[-1].replace(' ', '_').lower()
blend_path = Path(filepath, *sub_path, blend_name).with_suffix('.blend')
if i % int(total_assets / 100) == 0:
print(f'Progress: {int(i / total_assets * 100)}')
col = bpy.data.collections.new(name=asset_data['name'])
# Seems slow
#bpy.context.scene.collection.children.link(col)
col.asset_mark()
with bpy.context.temp_override(id=col):
bpy.ops.ed.lib_id_load_custom_preview(
filepath=asset_data['preview']
)
col.asset_data.description = asset_data.get('description', '')
catalog_name = asset_data['catalog']
catalog = catalog_data.get(catalog_name)
if not catalog:
catalog = {'id': str(uuid.uuid4()), 'name': catalog_name}
catalog_data[catalog_name] = catalog
col.asset_data.catalog_id = catalog['id']
for k, v in asset_data.get('metadata', {}).items():
col.asset_data[k] = v
i += 1
print(f'Saving Blend to {blend_path}')
blend_path.mkdir(exist_ok=True, parents=True)
bpy.ops.wm.save_as_mainfile(filepath=str(blend_path), compress=True)
write_catalog(catalog_path, catalog_data)
bpy.ops.wm.quit_blender()
if __name__ == '__main__' :
parser = argparse.ArgumentParser(description='bundle_blend',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--source-path')
parser.add_argument('--bundle-path')
parser.add_argument('--asset-data-template')
parser.add_argument('--thumbnail-template')
parser.add_argument('--template', default=None)
parser.add_argument('--data-file', default=None)
parser.add_argument('--depth', default=0, type=int)
if '--' in sys.argv :
index = sys.argv.index('--')
sys.argv = [sys.argv[index-1], *sys.argv[index+1:]]
args = parser.parse_args()
bundle_library(
source_directory=args.source_directory,
bundle_directory=args.bundle_directory,
asset_description_template=args.asset_description_template,
thumbnail_template=args.thumbnail_template,
template=args.template,
data_file=args.data_file)
bundle_blend(filepath=args.bundle_directory, depth=args.depth)

30
file/gui.py Normal file
View File

@ -0,0 +1,30 @@
import bpy
from pathlib import Path
from bpy.types import (
Context,
Header,
Menu,
Panel,
UIList,
WindowManager,
WorkSpace,
)
from bpy_extras import asset_utils
from asset_library.common.bl_utils import get_object_libraries, get_addon_prefs
def draw_context_menu(self, context):
layout = self.layout
#asset = context.active_file
layout.operator("assetlib.open_blend_file", text="Open Blend File")#.filepath = asset.asset_data['filepath']
def draw_header(layout):
'''Draw the header of the Asset Browser Window'''
layout.separator()
#layout.operator("actionlib.store_anim_pose", text='Add Action', icon='FILE_NEW')

25
file/keymaps.py Normal file
View File

@ -0,0 +1,25 @@
from typing import List, Tuple
import bpy
from bpy.app.handlers import persistent
addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = []
def register() -> None:
wm = bpy.context.window_manager
if not wm.keyconfigs.addon:
# This happens when Blender is running in the background.
return
km = wm.keyconfigs.addon.keymaps.new(name="File Browser Main", space_type="FILE_BROWSER")
kmi = km.keymap_items.new("assetlib.open_blend_file", "LEFTMOUSE", "DOUBLE_CLICK")
addon_keymaps.append((km, kmi))
def unregister() -> None:
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()

57
file/operators.py Normal file
View File

@ -0,0 +1,57 @@
import bpy
from bpy.types import Context, Operator
from bpy_extras import asset_utils
from bpy.props import StringProperty
from typing import List, Tuple, Set
from asset_library.common.file_utils import (open_blender_file,
synchronize, open_blender_file)
from asset_library.common.functions import get_active_library
class ASSETLIB_OT_open_blend_file(Operator):
bl_idname = "assetlib.open_blend_file"
bl_options = {"REGISTER", "UNDO"}
bl_label = 'Open Blender File'
bl_description = 'Open blender file'
@classmethod
def poll(cls, context: Context) -> bool:
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
cls.poll_message_set("Current editor is not an asset browser")
return False
lib = get_active_library()
if not lib or lib.data_type != 'FILE':
return False
if not context.active_file or 'filepath' not in context.active_file.asset_data:
cls.poll_message_set("Has not filepath property")
return False
return True
def execute(self, context: Context) -> Set[str]:
lib = get_active_library()
print(lib, lib.data_type)
filepath = context.active_file.asset_data['filepath']
open_blender_file(filepath)
return {'FINISHED'}
classes = (
ASSETLIB_OT_open_blend_file,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

333
gui.py Normal file
View File

@ -0,0 +1,333 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Action Library - GUI definition.
"""
import bpy
from pathlib import Path
from bpy.types import (
AssetHandle,
Context,
Header,
Menu,
Panel,
UIList,
WindowManager,
WorkSpace,
)
from bpy_extras import asset_utils
from asset_library.common.bl_utils import (
get_addon_prefs,
get_object_libraries,
)
from asset_library.common.functions import (
get_active_library
)
def pose_library_panel_poll():
return bpy.context.object and bpy.context.object.mode == 'POSE'
class PoseLibraryPanel:
@classmethod
def pose_library_panel_poll(cls, context: Context) -> bool:
return bool(
context.object
and context.object.mode == 'POSE'
)
@classmethod
def poll(cls, context: Context) -> bool:
return cls.pose_library_panel_poll(context);
class AssetLibraryMenu:
@classmethod
def poll(cls, context):
from bpy_extras.asset_utils import SpaceAssetInfo
return SpaceAssetInfo.is_asset_browser_poll(context)
class ASSETLIB_PT_libraries(Panel):
bl_label = "Libraries"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Item'
@classmethod
def poll(cls, context: Context) -> bool:
return context.object and get_object_libraries(context.object)
def draw(self, context: Context) -> None:
layout = self.layout
for f in get_object_libraries(context.object):
row = layout.row(align=True)
row.label(text=f)
row.operator("assetlib.open_blend", icon='FILE_BLEND', text='').filepath = f
'''
class ASSETLIB_PT_pose_library_usage(Panel):
bl_space_type = 'FILE_BROWSER'
bl_region_type = "TOOLS"
bl_label = "Action Library"
# asset_categories = {'ANIMATIONS'}
@classmethod
def poll(cls, context: Context) -> bool:
sp = context.space_data
if not context.object or not context.object.mode == 'POSE':
return False
if not (sp and sp.type == 'FILE_BROWSER' and sp.browse_mode == 'ASSETS'):
return False
return True
def draw(self, context: Context) -> None:
layout = self.layout
wm = context.window_manager
sp = context.space_data
sp.params.asset_library_ref
if sp.params.asset_library_ref == 'LOCAL':
col = layout.column(align=True)
row = col.row(align=True)
row.operator("poselib.create_pose_asset", text="Create Pose", icon='POSE_HLT').activate_new_action = False
row.operator("actionlib.replace_pose", text='Replace Pose', icon='FILE_REFRESH')
col.operator("actionlib.create_anim_asset", text="Create Anim", icon='ANIM')
col.separator()
row = col.row(align=True)
row.operator("actionlib.edit_action", text='Edit Action', icon='ACTION')
row.operator("actionlib.clear_action", text='Finish Edit', icon='CHECKBOX_HLT')
col.separator()
col.operator("actionlib.generate_preview", icon='RESTRICT_RENDER_OFF', text="Generate Thumbnail")
col.operator("actionlib.update_action_data", icon='FILE_TEXT', text="Update Action Data")
else:
col = layout.column(align=True)
row = col.row(align=True)
row.operator("actionlib.store_anim_pose", text='Store Anim/Pose', icon='ACTION')
'''
class ASSETLIB_PT_pose_library_editing(PoseLibraryPanel, asset_utils.AssetBrowserPanel, Panel):
bl_space_type = 'FILE_BROWSER'
bl_region_type = "TOOL_PROPS"
bl_label = "Metadata"
#bl_options = {'HIDE_HEADER'}
# asset_categories = {'ANIMATIONS'}
@classmethod
def poll(cls, context: Context) -> bool:
sp = context.space_data
if not (sp and sp.type == 'FILE_BROWSER' and sp.browse_mode == 'ASSETS'):
return False
if not (context.active_file and context.active_file.asset_data):
return False
return True
def draw(self, context: Context) -> None:
layout = self.layout
layout.use_property_split = True
asset_data = context.active_file.asset_data
metadata = ['camera', 'is_single_frame', 'rest_pose']
if 'camera' in asset_data.keys():
layout.prop(asset_data, f'["camera"]', text='Camera', icon='CAMERA_DATA')
if 'is_single_frame' in asset_data.keys():
layout.prop(asset_data, f'["is_single_frame"]', text='Is Single Frame')
if 'rest_pose' in asset_data.keys():
layout.prop(asset_data, f'["rest_pose"]', text='Rest Pose', icon='ACTION')
if 'filepath' in asset_data.keys():
layout.prop(asset_data, f'["filepath"]', text='Filepath')
class ASSETLIB_MT_context_menu(AssetLibraryMenu, Menu):
bl_label = "Asset Library Menu"
@classmethod
def poll(cls, context):
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
cls.poll_message_set("Current editor is not an asset browser")
return False
prefs = get_addon_prefs()
asset_lib_ref = context.space_data.params.asset_library_ref
lib = get_active_library()
if not lib:
return False
return True
def draw(self, context):
lib = get_active_library()
lib.adapter.draw_context_menu(self.layout)
def is_option_region_visible(context, space):
from bpy_extras.asset_utils import SpaceAssetInfo
if SpaceAssetInfo.is_asset_browser(space):
pass
# For the File Browser, there must be an operator for there to be options
# (irrelevant for the Asset Browser).
elif not space.active_operator:
return False
for region in context.area.regions:
if region.type == 'TOOL_PROPS' and region.width <= 1:
return False
return True
def draw_assetbrowser_header(self, context):
lib = get_active_library()
if not lib:
bpy.types.FILEBROWSER_HT_header._draw_asset_browser_buttons(self, context)
return
space_data = context.space_data
params = context.space_data.params
row = self.layout.row(align=True)
row.separator()
row.operator("assetlib.bundle", icon='UV_SYNC_SELECT', text='').name = lib.name
#op
#op.clean = False
#op.only_recent = True
lib.adapter.draw_header(row)
if context.selected_files and context.active_file:
row.separator()
row.label(text=context.active_file.name)
row.separator_spacer()
sub = row.row()
sub.ui_units_x = 10
sub.prop(params, "filter_search", text="", icon='VIEWZOOM')
row.separator_spacer()
row.prop_with_popover(
params,
"display_type",
panel="ASSETBROWSER_PT_display",
text="",
icon_only=True,
)
row.operator(
"screen.region_toggle",
text="",
icon='PREFERENCES',
depress=is_option_region_visible(context, space_data)
).region_type = 'TOOL_PROPS'
### Messagebus subscription to monitor asset library changes.
_msgbus_owner = object()
def _on_asset_library_changed() -> None:
"""Update areas when a different asset library is selected."""
refresh_area_types = {'DOPESHEET_EDITOR', 'VIEW_3D'}
for win in bpy.context.window_manager.windows:
for area in win.screen.areas:
if area.type not in refresh_area_types:
continue
area.tag_redraw()
def register_message_bus() -> None:
bpy.msgbus.subscribe_rna(
key=(bpy.types.FileAssetSelectParams, "asset_library_ref"),
owner=_msgbus_owner,
args=(),
notify=_on_asset_library_changed,
options={'PERSISTENT'},
)
def unregister_message_bus() -> None:
bpy.msgbus.clear_by_owner(_msgbus_owner)
@bpy.app.handlers.persistent
def _on_blendfile_load_pre(none, other_none) -> None:
# The parameters are required, but both are None.
unregister_message_bus()
@bpy.app.handlers.persistent
def _on_blendfile_load_post(none, other_none) -> None:
# The parameters are required, but both are None.
register_message_bus()
classes = (
ASSETLIB_PT_pose_library_editing,
#ASSETLIB_PT_pose_library_usage,
ASSETLIB_MT_context_menu,
ASSETLIB_PT_libraries
)
def register() -> None:
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.FILEBROWSER_HT_header._draw_asset_browser_buttons = bpy.types.FILEBROWSER_HT_header.draw_asset_browser_buttons
bpy.types.FILEBROWSER_HT_header.draw_asset_browser_buttons = draw_assetbrowser_header
#WorkSpace.active_pose_asset_index = bpy.props.IntProperty(
# name="Active Pose Asset",
# # TODO explain which list the index belongs to, or how it can be used to get the pose.
# description="Per workspace index of the active pose asset"
#)
# Register for window-manager. This is a global property that shouldn't be
# written to files.
#WindowManager.pose_assets = bpy.props.CollectionProperty(type=AssetHandle)
# bpy.types.UI_MT_list_item_context_menu.prepend(pose_library_list_item_context_menu)
# bpy.types.ASSETLIB_MT_context_menu.prepend(pose_library_list_item_context_menu)
# bpy.types.ACTIONLIB_MT_context_menu.prepend(pose_library_list_item_context_menu)
#bpy.types.ASSETBROWSER_MT_editor_menus.append(draw_assetbrowser_header)
register_message_bus()
bpy.app.handlers.load_pre.append(_on_blendfile_load_pre)
bpy.app.handlers.load_post.append(_on_blendfile_load_post)
def unregister() -> None:
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
bpy.types.FILEBROWSER_HT_header.draw_asset_browser_buttons = bpy.types.FILEBROWSER_HT_header._draw_asset_browser_buttons
del bpy.types.FILEBROWSER_HT_header._draw_asset_browser_buttons
unregister_message_bus()
#del WorkSpace.active_pose_asset_index
#del WindowManager.pose_assets
# bpy.types.UI_MT_list_item_context_menu.remove(pose_library_list_item_context_menu)
# bpy.types.ASSETLIB_MT_context_menu.remove(pose_library_list_item_context_menu)
# bpy.types.ACTIONLIB_MT_context_menu.remove(pose_library_list_item_context_menu)
#bpy.types.ASSETBROWSER_MT_editor_menus.remove(draw_assetbrowser_header)

58
keymaps.py Normal file
View File

@ -0,0 +1,58 @@
# SPDX-License-Identifier: GPL-2.0-or-later
from typing import List, Tuple
import bpy
from bpy.app.handlers import persistent
addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = []
@persistent
def copy_play_anim(dummy):
wm = bpy.context.window_manager
km = wm.keyconfigs.addon.keymaps.new(name="File Browser Main", space_type="FILE_BROWSER")
km_frames = wm.keyconfigs.user.keymaps.get('Frames')
if km_frames:
play = km_frames.keymap_items.get('screen.animation_play')
if play:
kmi = km.keymap_items.new(
"assetlib.play_preview",
play.type, play.value,
any=play.any, shift=play.shift, ctrl=play.ctrl, alt=play.alt,
oskey=play.oskey, key_modifier=play.key_modifier,
)
addon_keymaps.append((km, kmi))
def register() -> None:
wm = bpy.context.window_manager
if wm.keyconfigs.addon is None:
# This happens when Blender is running in the background.
return
km = wm.keyconfigs.addon.keymaps.new(name="File Browser Main", space_type="FILE_BROWSER")
kmi = km.keymap_items.new("wm.call_menu", "RIGHTMOUSE", "PRESS")
kmi.properties.name = 'ASSETLIB_MT_context_menu'
addon_keymaps.append((km, kmi))
kmi = km.keymap_items.new("assetlib.play_preview", "SPACE", "PRESS")
addon_keymaps.append((km, kmi))
# km = addon.keymaps.new(name = "Grease Pencil Stroke Paint Mode", space_type = "EMPTY")
# kmi = km.keymap_items.new('wm.call_panel', type='F2', value='PRESS')
if 'copy_play_anim' not in [hand.__name__ for hand in bpy.app.handlers.load_post]:
bpy.app.handlers.load_post.append(copy_play_anim)
def unregister() -> None:
# Clear shortcuts from the keymap.
if 'copy_play_anim' in [hand.__name__ for hand in bpy.app.handlers.load_post]:
bpy.app.handlers.load_post.remove(copy_play_anim)
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()

562
operators.py Normal file
View File

@ -0,0 +1,562 @@
from typing import Set
#import shutil
from pathlib import Path
import subprocess
import importlib
import time
import json
import bpy
from bpy_extras import asset_utils
from bpy.types import Context, Operator
from bpy.props import (
BoolProperty,
EnumProperty,
StringProperty,
IntProperty)
#from asset_library.constants import (DATA_TYPES, DATA_TYPE_ITEMS, MODULE_DIR)
import asset_library
from asset_library.common.bl_utils import (
get_addon_prefs,
get_bl_cmd,
#suitable_areas,
refresh_asset_browsers,
load_datablocks)
from asset_library.common.file_utils import open_blender_file, synchronize
from asset_library.common.functions import get_active_library, asset_warning_callback
from textwrap import dedent
from tempfile import gettempdir
class ASSETLIB_OT_clear_asset(Operator):
bl_idname = "assetlib.clear_asset"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
bl_label = 'Clear Asset'
bl_description = 'Clear Selected Assets'
@classmethod
def poll(cls, context):
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
return False
sp = context.space_data
if sp.params.asset_library_ref == 'LOCAL':
return False
return True
def execute(self, context: Context) -> Set[str]:
asset = context.active_file
lib = get_active_library()
filepath = lib.adapter.format_path(asset.asset_data['filepath'])
asset_image = lib.adapter.get_path('image', asset.name, filepath)
asset_video = lib.adapter.get_path('video', asset.name, filepath)
if filepath:
if filepath.exists():
filepath.unlink()
if asset_image:
asset_image.unlink()
if asset_video:
asset_video.unlink()
#open_blender_file(filepath)
bpy.ops.assetlib.bundle(name=lib.name, blocking=True)
return {'FINISHED'}
class ASSETLIB_OT_edit_data(Operator):
bl_idname = "assetlib.edit_data"
bl_label = "Edit Asset Data"
bl_description = "Edit Current Asset Data"
bl_options = {"REGISTER", "UNDO"}
warning: StringProperty(name='')
path: StringProperty(name='Path')
catalog: StringProperty(name='Catalog', update=asset_warning_callback, options={'TEXTEDIT_UPDATE'})
name: StringProperty(name='Name', update=asset_warning_callback, options={'TEXTEDIT_UPDATE'})
tags: StringProperty(name='Tags', description='Tags need to separate with a comma (,)')
@classmethod
def poll(cls, context):
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
return False
return True
def execute(self, context: Context) -> Set[str]:
prefs = get_addon_prefs()
lib = get_active_library()
if lib.merge_libraries:
lib = prefs.libraries[lib.store_library]
new_name = lib.adapter.norm_file_name(self.name)
new_asset_path = lib.adapter.get_asset_path(name=new_name, catalog=self.catalog)
self.old_asset_path.unlink()
lib.adapter.write_asset(asset=self.asset, asset_path=new_asset_path)
if self.old_image_path.exists():
new_img_path = lib.adapter.get_path('image', new_name, new_asset_path)
self.old_image_path.rename(new_img_path)
if self.old_video_path.exists():
new_video_path = lib.adapter.get_path('video', new_name, new_asset_path)
self.old_video_path.rename(new_video_path)
if self.old_asset_description_path.exists():
self.old_asset_description_path.unlink()
new_asset_description = lib.adapter.get_asset_description(
asset=self.asset,
catalog=self.catalog,
modified=time.time_ns()
)
lib.adapter.write_asset_description(new_asset_description, new_asset_path)
if not list(self.old_asset_path.parent.iterdir()):
self.old_asset_path.parent.rmdir()
diff_path = Path(bpy.app.tempdir, 'diff.json')
diff = [dict(self.old_asset_description, operation='REMOVE')]
diff += [dict(lib.adapter.norm_asset_datas([new_asset_description])[0], operation='ADD')]
diff_path.write_text(json.dumps(diff, indent=4), encoding='utf-8')
bpy.ops.assetlib.bundle(name=lib.name, diff=str(diff_path), blocking=True)
return {"FINISHED"}
def draw(self, context):
layout = self.layout
layout.separator()
layout.use_property_split = True
lib = get_active_library()
if lib.merge_libraries:
layout.prop(lib, 'store_library', expand=False)
layout.prop(self, "catalog", text="Catalog")
layout.prop(self, "name", text="Name")
layout.prop(self, 'tags')
#layout.prop()
layout.separator()
col = layout.column()
col.use_property_split = False
#row.enabled = False
if self.path:
col.label(text=self.path)
if self.warning:
col.label(icon='ERROR', text=self.warning)
def invoke(self, context, event):
lib = get_active_library()
active_lib = lib.adapter.get_active_asset_library()
lib.store_library = active_lib.name
asset_handle = context.asset_file_handle
catalog_file = lib.adapter.read_catalog()
catalog_ids = {v['id']: {'path': k, 'name': v['name']} for k,v in catalog_file.items()}
#asset_handle = context.asset_file_handle
self.old_asset_name = asset_handle.name
self.old_asset_path = lib.adapter.get_active_asset_path()
self.asset = load_datablocks(self.old_asset_path, self.old_asset_name, type=lib.data_types)
self.old_image_path = lib.adapter.get_path('image', self.old_asset_name, self.old_asset_path)
self.old_video_path = lib.adapter.get_path('video', self.old_asset_name, self.old_asset_path)
self.old_asset_description_path = lib.adapter.get_asset_description_path(self.old_asset_path)
self.old_asset_description = lib.adapter.read_asset_description(self.old_asset_path)
self.old_asset_description = lib.adapter.norm_asset_datas([self.old_asset_description])[0]
if not self.asset:
self.report({'ERROR'}, 'No asset found')
self.name = self.old_asset_name
self.tags = ', '.join(self.asset.asset_data.tags.keys())
#asset_path
self.catalog = catalog_ids[asset_handle.asset_data.catalog_id]['path']
return context.window_manager.invoke_props_dialog(self)
def cancel(self, context):
print('Cancel Edit Data, removing the asset')
lib = get_active_library()
active_lib = lib.adapter.get_active_asset_library()
getattr(bpy.data, active_lib.data_types).remove(self.asset)
class ASSETLIB_OT_remove_user_library(Operator):
bl_idname = "assetlib.remove_user_library"
bl_options = {"REGISTER", "UNDO"}
bl_label = 'Remove User Library'
bl_description = 'Remove User Library'
index : IntProperty(default=-1)
def execute(self, context: Context) -> Set[str]:
prefs = get_addon_prefs()
prefs.user_libraries.remove(self.index)
return {'FINISHED'}
class ASSETLIB_OT_add_user_library(Operator):
bl_idname = "assetlib.add_user_library"
bl_options = {"REGISTER", "UNDO"}
bl_label = 'Add User Library'
bl_description = 'Add User Library'
def execute(self, context: Context) -> Set[str]:
prefs = get_addon_prefs()
lib = prefs.user_libraries.add()
lib.expand = True
return {'FINISHED'}
class ASSETLIB_OT_open_blend(Operator):
bl_idname = "assetlib.open_blend"
bl_options = {"REGISTER", "UNDO"}
bl_label = 'Open Blender File'
bl_description = 'Open blender file'
#filepath : StringProperty(subtype='FILE_PATH')
def execute(self, context: Context) -> Set[str]:
#asset = context.active_file
#prefs = get_addon_prefs()
lib = get_active_library()
#filepath = lib.adapter.format_path(asset.asset_data['filepath'])
filepath = lib.adapter.get_active_asset_path()
open_blender_file(filepath)
return {'FINISHED'}
class ASSETLIB_OT_set_paths(Operator):
bl_idname = "assetlib.set_paths"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
bl_label = 'Set Paths'
bl_description = 'Set Library Paths'
name: StringProperty()
all: BoolProperty(default=False)
def execute(self, context: Context) -> Set[str]:
prefs = get_addon_prefs()
print('Set Paths')
if self.all:
libs = prefs.libraries
else:
libs = [prefs.libraries[self.name]]
for lib in libs:
lib.clear_library_path()
lib.set_library_path()
return {'FINISHED'}
class ASSETLIB_OT_bundle_library(Operator):
bl_idname = "assetlib.bundle"
bl_options = {"INTERNAL"}
bl_label = 'Bundle Library'
bl_description = 'Bundle all matching asset found inside one blend'
name : StringProperty()
diff : StringProperty()
blocking : BoolProperty(default=False)
mode : EnumProperty(items=[(i.replace(' ', '_').upper(), i, '') for i in ('None', 'All', 'Auto Bundle')], default='NONE')
directory : StringProperty(subtype='DIR_PATH')
#def refresh(self):
# for area in suitable_areas(bpy.context.screen):
# bpy.ops.asset.library_refresh({"area": area, 'region': area.regions[3]})
#space_data.activate_asset_by_id(asset, deferred=deferred)
def execute(self, context: Context) -> Set[str]:
prefs = get_addon_prefs()
libs = []
if self.name:
libs += [prefs.libraries[self.name]]
if self.mode == 'ALL':
libs += prefs.libraries.values()
elif self.mode == 'AUTO_BUNDLE':
libs += [l for l in prefs.libraries if l.auto_bundle]
lib_datas = [l.to_dict() for l in libs]
print(f'Bundle Libraries: {[l.name for l in libs]}')
script_path = Path(gettempdir()) / 'bundle_library.py'
script_code = dedent(f"""
import bpy
prefs = bpy.context.preferences.addons["asset_library"].preferences
for lib_data in {lib_datas}:
lib = prefs.env_libraries.add()
lib.set_dict(lib_data)
lib.adapter.bundle(cache_diff='{self.diff}')
""")
script_path.write_text(script_code)
#print(script_code)
#raise Exception()
cmd = get_bl_cmd(script=str(script_path), background=True)
#print(cmd)
if self.blocking:
subprocess.call(cmd)
bpy.app.timers.register(refresh_asset_browsers, first_interval=0.2)
else:
subprocess.Popen(cmd)
return {'FINISHED'}
class ASSETLIB_OT_reload_addon(Operator):
bl_idname = "assetlib.reload_addon"
bl_options = {"UNDO"}
bl_label = 'Reload Asset Library Addon'
bl_description = 'Reload The Asset Library Addon and the addapters'
def execute(self, context: Context) -> Set[str]:
print('Execute reload')
asset_library.unregister()
importlib.reload(asset_library)
asset_library.register()
return {'FINISHED'}
class ASSETLIB_OT_diff(Operator):
bl_idname = "assetlib.diff"
bl_options = {"REGISTER", "UNDO"}
bl_label = 'Synchronize'
bl_description = 'Synchronize Action Lib to Local Directory'
name : StringProperty()
conform : BoolProperty(default=False)
def execute(self, context: Context) -> Set[str]:
prefs = get_addon_prefs()
lib = prefs.libraries.get(self.name)
if self.conform:
lib.conform.adapter.diff()
else:
lib.adapter.diff()
return {'FINISHED'}
class ASSETLIB_OT_conform_library(Operator):
bl_idname = "assetlib.conform_library"
bl_options = {"REGISTER", "UNDO"}
bl_label = "Conform Library"
bl_description = "Split each assets per blend and externalize preview"
name : StringProperty()
image_template : StringProperty()
video_template : StringProperty()
directory : StringProperty(subtype='DIR_PATH', name='Filepath')
def execute(self, context: Context) -> Set[str]:
prefs = get_addon_prefs()
lib = prefs.libraries.get(self.name)
#lib.adapter.conform(self.directory)
templates = {}
if self.image_template:
templates['image'] = self.image_template
if self.video_template:
templates['video'] = self.video_template
script_path = Path(gettempdir()) / 'bundle_library.py'
script_code = dedent(f"""
import bpy
prefs = bpy.context.preferences.addons["asset_library"].preferences
lib = prefs.env_libraries.add()
lib.set_dict({lib.to_dict()})
lib.adapter.conform(directory='{self.directory}', templates={templates})
""")
script_path.write_text(script_code)
cmd = get_bl_cmd(script=str(script_path), background=True)
subprocess.Popen(cmd)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class ASSETLIB_OT_play_preview(Operator):
bl_idname = "assetlib.play_preview"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
bl_label = 'Play Preview'
bl_description = 'Play Preview'
@classmethod
def poll(cls, context: Context) -> bool:
if not context.active_file:
return False
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
cls.poll_message_set("Current editor is not an asset browser")
return False
lib = get_active_library()
if not lib:
return False
return True
def execute(self, context: Context) -> Set[str]:
asset = context.active_file
prefs = get_addon_prefs()
lib = get_active_library()
#filepath = lib.adapter.format_path(asset.asset_data['filepath'])
asset_path = lib.adapter.get_active_asset_path()
asset_image = lib.adapter.get_image(asset.name, asset_path)
asset_video = lib.adapter.get_video(asset.name, asset_path)
if not asset_image and not asset_video:
self.report({'ERROR'}, f'Preview for {asset.name} not found.')
return {"CANCELLED"}
if asset_video:
self.report({'INFO'}, f'Video found. {asset_video}.')
if prefs.video_player:
subprocess.Popen([prefs.video_player, asset_video])
else:
bpy.ops.wm.path_open(filepath=str(asset_video))
else:
self.report({'INFO'}, f'Image found. {asset_image}.')
if prefs.image_player:
subprocess.Popen([prefs.image_player, asset_image])
else:
bpy.ops.wm.path_open(filepath=str(asset_image))
return {"FINISHED"}
class ASSETLIB_OT_synchronize(Operator):
bl_idname = "assetlib.synchronize"
bl_options = {"REGISTER", "UNDO"}
bl_label = 'Synchronize'
bl_description = 'Synchronize Action Lib to Local Directory'
clean : BoolProperty(default=False)
only_new : BoolProperty(default=False)
only_recent : BoolProperty(default=False)
name: StringProperty()
all: BoolProperty(default=False)
def execute(self, context: Context) -> Set[str]:
print('Not yet Implemented, have to be replace by Bundle instead')
return {'FINISHED'}
prefs = get_addon_prefs()
print('Synchronize')
if self.all:
libs = prefs.libraries
else:
libs = [prefs.libraries.get(self.name)]
for lib in libs:
if self.clean and Path(lib.path_local).exists():
pass
print('To check first')
#shutil.rmtree(path_local)
if not lib.path_local:
continue
synchronize(
src=lib.path,
dst=lib.path_local,
only_new=self.only_new,
only_recent=self.only_recent
)
return {'FINISHED'}
classes = (
ASSETLIB_OT_play_preview,
ASSETLIB_OT_open_blend,
ASSETLIB_OT_set_paths,
ASSETLIB_OT_synchronize,
ASSETLIB_OT_add_user_library,
ASSETLIB_OT_remove_user_library,
ASSETLIB_OT_diff,
ASSETLIB_OT_bundle_library,
ASSETLIB_OT_clear_asset,
ASSETLIB_OT_edit_data,
ASSETLIB_OT_conform_library,
ASSETLIB_OT_reload_addon
)
def register():
#bpy.types.UserAssetLibrary.is_env = False
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

14
pose/__init__.py Normal file
View File

@ -0,0 +1,14 @@
from asset_library.pose import (
operators)
if 'bpy' in locals():
import importlib
importlib.reload(operators)
def register():
operators.register()
def unregister():
operators.unregister()

63
pose/conversion.py Normal file
View File

@ -0,0 +1,63 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Pose Library - Conversion of old pose libraries.
"""
from typing import Optional
from collections.abc import Collection
if "pose_creation" not in locals():
from . import pose_creation
else:
import importlib
pose_creation = importlib.reload(pose_creation)
import bpy
from bpy.types import (
Action,
TimelineMarker,
)
def convert_old_poselib(old_poselib: Action) -> Collection[Action]:
"""Convert an old-style pose library to a set of pose Actions.
Old pose libraries were one Action with multiple pose markers. Each pose
marker will be converted to an Action by itself and marked as asset.
"""
pose_assets = [
action
for marker in old_poselib.pose_markers
if (action := convert_old_pose(old_poselib, marker))
]
# Mark all Actions as assets in one go. Ideally this would be done on an
# appropriate frame in the scene (to set up things like the background
# colour), but the old-style poselib doesn't contain such information. All
# we can do is just render on the current frame.
bpy.ops.asset.mark({'selected_ids': pose_assets})
return pose_assets
def convert_old_pose(old_poselib: Action, marker: TimelineMarker) -> Optional[Action]:
"""Convert an old-style pose library pose to a pose action."""
frame: int = marker.frame
action: Optional[Action] = None
for fcurve in old_poselib.fcurves:
key = pose_creation.find_keyframe(fcurve, frame)
if not key:
continue
if action is None:
action = bpy.data.actions.new(marker.name)
pose_creation.create_single_key_fcurve(action, fcurve, key)
return action

615
pose/operators.py Normal file
View File

@ -0,0 +1,615 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Pose Library - operators.
"""
from math import radians
from mathutils import Vector
from pathlib import Path
from tempfile import gettempdir
from typing import Optional, Set
from asset_library.pose import pose_creation, pose_usage
import bpy
import json
import os
import re
import shutil
import subprocess
import uuid
import time
from bpy.props import BoolProperty, CollectionProperty, EnumProperty, PointerProperty, StringProperty
from bpy.types import (
Action,
Context,
Event,
FileSelectEntry,
Object,
Operator,
PropertyGroup,
)
from bpy_extras import asset_utils
from bpy_extras.io_utils import ExportHelper, ImportHelper
from asset_library.action.functions import (
get_marker,
get_keyframes,
)
from asset_library.common.bl_utils import (
get_view3d_persp,
load_assets_from,
split_path
)
class POSELIB_OT_create_pose_asset(Operator):
bl_idname = "poselib.create_pose_asset"
bl_label = "Create Pose Asset"
bl_description = (
"Create a new Action that contains the pose of the selected bones, and mark it as Asset. "
"The asset will be stored in the current blend file"
)
bl_options = {"REGISTER", "UNDO"}
pose_name: StringProperty(name="Pose Name") # type: ignore
activate_new_action: BoolProperty(name="Activate New Action", default=True) # type: ignore
@classmethod
def poll(cls, context: Context) -> bool:
# Make sure that if there is an asset browser open, the artist can see the newly created pose asset.
asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
if not asset_browse_area:
# No asset browser is visible, so there also aren't any expectations
# that this asset will be visible.
return True
asset_space_params = asset_browser.params(asset_browse_area)
if asset_space_params.asset_library_ref != 'LOCAL':
cls.poll_message_set("Asset Browser must be set to the Current File library")
return False
return True
def execute(self, context: Context) -> Set[str]:
#pose_name = self.pose_name or context.object.name
pose_name = False
if context.object.animation_data:
if context.object.animation_data.action:
pose_name = get_marker(context.object.animation_data.action)
if pose_name:
prefix = True
asset_name = Path(bpy.data.filepath).stem.split('_')[0]
action_asset_name = re.search(f'^{asset_name}.', pose_name)
if action_asset_name:
pose_name = pose_name.replace(action_asset_name.group(0), '')
side = re.search('_\w$', pose_name)
if side:
pose_name = pose_name.replace(side.group(0), '')
if 'hands' in context.object.animation_data.action.name.lower():
pose_name = f'hand_{pose_name}'
if pose_name.startswith('lips_'):
pose_name.replace('lips_', '')
split = pose_name.split('_')
pose_name = '-'.join([s for s in split if s.isupper()])
pose_name = f'{pose_name}_{split[-1]}'
prefix = False
if prefix and not pose_name.startswith(asset_name):
pose_name = f'{asset_name}_{pose_name}'
else:
pose_name = self.pose_name or context.object.name
asset = pose_creation.create_pose_asset_from_context(context, pose_name)
if not asset:
self.report({"WARNING"}, "No keyframes were found for this pose")
return {"CANCELLED"}
### ADD ADM
data = asset.asset_data
data.catalog_id = str(uuid.UUID(int=0))
data_dict = dict(
is_single_frame=True,
)
if context.scene.camera:
data_dict.update(dict(camera=context.scene.camera.name))
for k, v in data_dict.items():
data[k] = v
###
if self.activate_new_action:
self._set_active_action(context, asset)
self._activate_asset_in_browser(context, asset)
return {'FINISHED'}
def _set_active_action(self, context: Context, asset: Action) -> None:
self._prevent_action_loss(context.object)
anim_data = context.object.animation_data_create()
context.scene.actionlib.previous_action = anim_data.action
anim_data.action = asset
def _activate_asset_in_browser(self, context: Context, asset: Action) -> None:
"""Activate the new asset in the appropriate Asset Browser.
This makes it possible to immediately check & edit the created pose asset.
"""
asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
if not asset_browse_area:
return
# After creating an asset, the window manager has to process the
# notifiers before editors should be manipulated.
pose_creation.assign_from_asset_browser(asset, asset_browse_area)
# Pass deferred=True, because we just created a new asset that isn't
# known to the Asset Browser space yet. That requires the processing of
# notifiers, which will only happen after this code has finished
# running.
asset_browser.activate_asset(asset, asset_browse_area, deferred=True)
def _prevent_action_loss(self, object: Object) -> None:
"""Mark the action with Fake User if necessary.
This is to prevent action loss when we reduce its reference counter by one.
"""
if not object.animation_data:
return
action = object.animation_data.action
if not action:
return
if action.use_fake_user or action.users > 1:
# Removing one user won't GC it.
return
action.use_fake_user = True
self.report({'WARNING'}, "Action %s marked Fake User to prevent loss" % action.name)
class POSELIB_OT_restore_previous_action(Operator):
bl_idname = "poselib.restore_previous_action"
bl_label = "Restore Previous Action"
bl_description = "Switch back to the previous Action, after creating a pose asset"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
return bool(
context.scene.actionlib.previous_action
and context.object
and context.object.animation_data
and context.object.animation_data.action
and context.object.animation_data.action.asset_data is not None
)
def execute(self, context: Context) -> Set[str]:
# This is the Action that was just created with "Create Pose Asset".
# It has to be re-applied after switching to the previous action,
# to ensure the character keeps the same pose.
self.pose_action = context.object.animation_data.action
prev_action = context.scene.actionlib.previous_action
context.object.animation_data.action = prev_action
context.scene.actionlib.previous_action = None
# Wait a bit for the action assignment to be handled, before applying the pose.
wm = context.window_manager
self._timer = wm.event_timer_add(0.001, window=context.window)
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context, event):
if event.type != 'TIMER':
return {'RUNNING_MODAL'}
wm = context.window_manager
wm.event_timer_remove(self._timer)
context.object.pose.apply_pose_from_action(self.pose_action)
return {'FINISHED'}
class ASSET_OT_assign_action(Operator):
bl_idname = "asset.assign_action"
bl_label = "Assign Action"
bl_description = "Set this pose Action as active Action on the active Object"
bl_options = {"REGISTER", "UNDO"}
assign: BoolProperty(name="Assign", default=True) # type: ignore
@classmethod
def poll(cls, context: Context) -> bool:
return bool(
isinstance(getattr(context, "id", None), Action)
and context.object
and context.object.mode == "POSE" # This condition may not be desired.
)
def execute(self, context: Context) -> Set[str]:
if self.assign:
context.object.animation_data_create().action = context.id
else:
if context.object.animation_data.action:
context.object.animation_data.action = None
return {"FINISHED"}
class POSELIB_OT_copy_as_asset(Operator):
bl_idname = "poselib.copy_as_asset"
bl_label = "Copy Pose As Asset"
bl_description = "Create a new pose asset on the clipboard, to be pasted into an Asset Browser"
bl_options = {"REGISTER"}
CLIPBOARD_ASSET_MARKER = "ASSET-BLEND="
@classmethod
def poll(cls, context: Context) -> bool:
return bool(
# There must be an object.
context.object
# It must be in pose mode with selected bones.
and context.object.mode == "POSE"
and context.object.pose
and context.selected_pose_bones_from_active_object
)
def execute(self, context: Context) -> Set[str]:
asset = pose_creation.create_pose_asset_from_context(
context,
context.object.name,
)
if asset is None:
self.report({"WARNING"}, "No animation data found to create asset from")
return {"CANCELLED"}
filepath = self.save_datablock(asset)
context.window_manager.clipboard = "%s%s" % (
self.CLIPBOARD_ASSET_MARKER,
filepath,
)
asset_browser.tag_redraw(context.screen)
self.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
# The asset has been saved to disk, so to clean up it has to loose its asset & fake user status.
asset.asset_clear()
asset.use_fake_user = False
# The asset can be removed from the main DB, as it was purely created to
# be stored to disk, and not to be used in this file.
if asset.users > 0:
# This should never happen, and indicates a bug in the code. Having a warning about it is nice,
# but it shouldn't stand in the way of actually cleaning up the meant-to-be-temporary datablock.
self.report({"WARNING"}, "Unexpected non-zero user count for the asset, please report this as a bug")
bpy.data.actions.remove(asset)
return {"FINISHED"}
def save_datablock(self, action: Action) -> Path:
tempdir = Path(bpy.app.tempdir)
filepath = tempdir / "copied_asset.blend"
bpy.data.libraries.write(
str(filepath),
datablocks={action},
path_remap="NONE",
fake_user=True,
compress=True, # Single-datablock blend file, likely little need to diff.
)
return filepath
class POSELIB_OT_paste_asset(Operator):
bl_idname = "poselib.paste_asset"
bl_label = "Paste As New Asset"
bl_description = "Paste the Asset that was previously copied using Copy As Asset"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
cls.poll_message_set("Current editor is not an asset browser")
return False
asset_lib_ref = context.space_data.params.asset_library_ref
if asset_lib_ref != 'LOCAL':
cls.poll_message_set("Asset Browser must be set to the Current File library")
return False
# Delay checking the clipboard as much as possible, as it's CPU-heavier than the other checks.
clipboard: str = context.window_manager.clipboard
if not clipboard:
cls.poll_message_set("Clipboard is empty")
return False
marker = POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER
if not clipboard.startswith(marker):
cls.poll_message_set("Clipboard does not contain an asset")
return False
return True
def execute(self, context: Context) -> Set[str]:
clipboard = context.window_manager.clipboard
marker_len = len(POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER)
filepath = Path(clipboard[marker_len:])
assets = load_assets_from(filepath)
if not assets:
self.report({"ERROR"}, "Did not find any assets on clipboard")
return {"CANCELLED"}
self.report({"INFO"}, "Pasted %d assets" % len(assets))
bpy.ops.asset.library_refresh()
asset_browser_area = asset_browser.area_from_context(context)
if not asset_browser_area:
return {"FINISHED"}
# Assign same catalog_global as in asset browser.
catalog_id = asset_browser.active_catalog_id(asset_browser_area)
for asset in assets:
asset.asset_data.catalog_id = catalog_id
asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True)
return {"FINISHED"}
class POSELIB_OT_pose_asset_select_bones(Operator):
bl_idname = "poselib.pose_asset_select_bones"
bl_label = "Select Bones"
#bl_description = "Select those bones that are used in this pose"
bl_description = "Click: Select used Bones\nAlt+Click: Select Flipped Bones\nCtrl+Click: Select Both sides."
bl_options = {"REGISTER", "UNDO"}
#bl_property = "selected_side"
selected_side: EnumProperty(
name='Selected Side',
items=(
('CURRENT', "Current", ""),
('FLIPPED', "Flipped", ""),
('BOTH', "Both", ""),
)
)
@classmethod
def poll(cls, context: Context) -> bool:
if not (
context.object
and context.object.mode == "POSE" # This condition may not be desired.
and context.asset_library_ref
and context.asset_file_handle
):
return False
return context.asset_file_handle.id_type == 'ACTION'
def execute(self, context: Context) -> Set[str]:
asset: FileSelectEntry = context.asset_file_handle
if asset.local_id:
return self.use_pose(context, asset.local_id)
return self._load_and_use_pose(context)
def use_pose(self, context: Context, asset: bpy.types.ID) -> Set[str]:
# Implement in subclass.
pass
def _load_and_use_pose(self, context: Context) -> Set[str]:
asset_library_ref = context.asset_library_ref
asset = context.asset_file_handle
asset_lib_path = bpy.types.AssetHandle.get_full_library_path(asset, asset_library_ref)
if not asset_lib_path:
self.report( # type: ignore
{"ERROR"},
# TODO: Add some way to get the library name from the library reference (just asset_library_ref.name?).
f"Selected asset {asset.name} could not be located inside the asset library",
)
return {"CANCELLED"}
if asset.id_type != 'ACTION':
self.report( # type: ignore
{"ERROR"},
f"Selected asset {asset.name} is not an Action",
)
return {"CANCELLED"}
with bpy.types.BlendData.temp_data() as temp_data:
with temp_data.libraries.load(asset_lib_path) as (data_from, data_to):
data_to.actions = [asset.name]
action: Action = data_to.actions[0]
return self.use_pose(context, action)
def use_pose(self, context: Context, pose_asset: Action) -> Set[str]:
arm_object: Object = context.object
#pose_usage.select_bones(arm_object, pose_asset, select=self.select, flipped=self.flipped)
pose_usage.select_bones(arm_object, pose_asset, selected_side=self.selected_side)
return {"FINISHED"}
# This operator takes the Window Manager's `actionlib_flipped` property, and
# passes it to the `POSELIB_OT_blend_pose_asset` operator. This makes it
# possible to bind a key to the operator and still have it respect the global
# "Flip Pose" checkbox.
class POSELIB_OT_blend_pose_asset_for_keymap(Operator):
bl_idname = "poselib.blend_pose_asset_for_keymap"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
_rna = bpy.ops.poselib.blend_pose_asset.get_rna_type()
bl_label = _rna.name
bl_description = _rna.description
del _rna
flipped: BoolProperty(name="Flipped", default=False) # type: ignore
@classmethod
def poll(cls, context: Context) -> bool:
return bpy.ops.poselib.blend_pose_asset.poll(context.copy())
"""
def invoke(self, context, event):
if event.type == 'LEFTMOUSE':
self.flipped = True if event.alt else False
if event.ctrl:
bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=not self.flipped)
bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=self.flipped)
return {'FINISHED'}
"""
def invoke(self, context: Context, event: Event) -> Set[str]:
return bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=self.flipped)
def execute(self, context: Context) -> Set[str]:
return bpy.ops.poselib.blend_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=self.flipped)
# This operator takes the Window Manager's `actionlib_flipped` property, and
# passes it to the `POSELIB_OT_apply_pose_asset` operator. This makes it
# possible to bind a key to the operator and still have it respect the global
# "Flip Pose" checkbox.
class POSELIB_OT_apply_pose_asset_for_keymap(Operator):
bl_idname = "poselib.apply_pose_asset_for_keymap"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
_rna = bpy.ops.poselib.apply_pose_asset.get_rna_type()
bl_label = _rna.name
#bl_description = _rna.description
bl_description = 'Apply Pose to Bones'
del _rna
flipped: BoolProperty(name="Flipped", default=False) # type: ignore
@classmethod
def poll(cls, context: Context) -> bool:
if not asset_utils.SpaceAssetInfo.is_asset_browser(context.space_data):
return False
return bpy.ops.poselib.apply_pose_asset.poll(context.copy())
def execute(self, context: Context) -> Set[str]:
if self.flipped:
action = bpy.data.actions.get(context.active_file.name)
store_bones = {}
bones = [
'blendshape-eyes', 'blendshape-eye.L', 'blendshape-eye.R',
'blendshape-corner-mouth', 'blendshape-corner-mouth.L',
'blendshape-corner-down-mouth.L', 'blendshape-corner-up-mouth.L',
'blendshape-corner-mouth-add.L','blendshape-corner-mouth.R',
'blendshape-corner-down-mouth.R', 'blendshape-corner-up-mouth.R',
'blendshape-corner-mouth-add.R', 'blendshape-center-up-mouth',
'blendshape-center-down-mouth',
'hat1.R', 'hat2.R', 'hat3.R', 'hat1.L', 'hat2.L', 'hat3.L',
]
attributes = [
'location', 'rotation_quaternion',
'rotation_euler', 'rotation_axis_angle', 'scale'
]
if action:
for fc in action.fcurves:
bone_name, prop_name = split_path(fc.data_path)
if bone_name not in bones:
continue
if not bone_name in store_bones.keys():
store_bones[bone_name] = {}
for attr in attributes:
if prop_name == attr:
if not prop_name in store_bones[bone_name].keys():
store_bones[bone_name][prop_name] = []
val = getattr(context.object.pose.bones[bone_name], prop_name)
store_bones[bone_name][prop_name].append(fc.evaluate(context.scene.frame_current))
bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=True)
for bone, v in store_bones.items():
for attr, attr_val in v.items():
flipped_vector = 1
### TODO FAIRE ÇA PROPREMENT AVEC UNE COMPREHENSION LIST OU AUTRE
if re.search(r'\.[RL]$', bone):
flipped_bone = pose_usage.flip_side_name(bone)
if attr == 'location':
flipped_vector = Vector((-1, 1, 1))
# print('-----', store_bones.get(flipped_bone)[attr])
attr_val = Vector(store_bones.get(flipped_bone)[attr]) * flipped_vector
setattr(context.object.pose.bones[bone], attr, attr_val)
return {'FINISHED'}
else:
return bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=False)
class POSELIB_OT_convert_old_poselib(Operator):
bl_idname = "poselib.convert_old_poselib"
bl_label = "Convert Old-Style Pose Library"
bl_description = "Create a pose asset for each pose marker in the current action"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: Context) -> bool:
action = context.object and context.object.animation_data and context.object.animation_data.action
if not action:
cls.poll_message_set("Active object has no Action")
return False
if not action.pose_markers:
cls.poll_message_set("Action %r is not a old-style pose library" % action.name)
return False
return True
def execute(self, context: Context) -> Set[str]:
from . import conversion
old_poselib = context.object.animation_data.action
new_actions = conversion.convert_old_poselib(old_poselib)
if not new_actions:
self.report({'ERROR'}, "Unable to convert to pose assets")
return {'CANCELLED'}
self.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions))
return {'FINISHED'}
classes = (
POSELIB_OT_apply_pose_asset_for_keymap,
POSELIB_OT_blend_pose_asset_for_keymap,
POSELIB_OT_convert_old_poselib,
POSELIB_OT_copy_as_asset,
POSELIB_OT_create_pose_asset,
POSELIB_OT_paste_asset,
POSELIB_OT_pose_asset_select_bones,
POSELIB_OT_restore_previous_action
)
register, unregister = bpy.utils.register_classes_factory(classes)

423
pose/pose_creation.py Normal file
View File

@ -0,0 +1,423 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Pose Library - creation functions.
"""
import dataclasses
import functools
import re
from typing import Optional, FrozenSet, Set, Union, Iterable, cast
import bpy
from bpy.types import (
Action,
Bone,
Context,
FCurve,
Keyframe,
)
FCurveValue = Union[float, int]
pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]')
"""RegExp for matching FCurve data paths."""
@dataclasses.dataclass(unsafe_hash=True, frozen=True)
class PoseCreationParams:
armature_ob: bpy.types.Object
src_action: Optional[Action]
src_frame_nr: float
bone_names: FrozenSet[str]
new_asset_name: str
class UnresolvablePathError(ValueError):
"""Raised when a data_path cannot be resolved to a current value."""
@dataclasses.dataclass(unsafe_hash=True)
class PoseActionCreator:
"""Create an Action that's suitable for marking as Asset.
Does not mark as asset yet, nor does it add asset metadata.
"""
params: PoseCreationParams
# These were taken from Blender's Action baking code in `anim_utils.py`.
# Items are (name, array_length) tuples.
_bbone_props = [
("bbone_curveinx", None),
("bbone_curveoutx", None),
("bbone_curveinz", None),
("bbone_curveoutz", None),
("bbone_rollin", None),
("bbone_rollout", None),
("bbone_scalein", 3),
("bbone_scaleout", 3),
("bbone_easein", None),
("bbone_easeout", None),
]
def create(self) -> Optional[Action]:
"""Create a single-frame Action containing only the given bones, or None if no anim data was found."""
try:
dst_action = self._create_new_action()
self._store_pose(dst_action)
finally:
# Prevent next instantiations of this class from reusing pointers to
# bones. They may not be valid by then any more.
self._find_bone.cache_clear()
if len(dst_action.fcurves) == 0:
bpy.data.actions.remove(dst_action)
return None
return dst_action
def _create_new_action(self) -> Action:
dst_action = bpy.data.actions.new(self.params.new_asset_name)
if self.params.src_action:
dst_action.id_root = self.params.src_action.id_root
dst_action.user_clear() # actions.new() sets users=1, but marking as asset also increments user count.
return dst_action
def _store_pose(self, dst_action: Action) -> None:
"""Store the current pose into the given action."""
self._store_bone_pose_parameters(dst_action)
self._store_animated_parameters(dst_action)
self._store_parameters_from_callback(dst_action)
def _store_bone_pose_parameters(self, dst_action: Action) -> None:
"""Store loc/rot/scale/bbone values in the Action."""
for bone_name in sorted(self.params.bone_names):
self._store_location(dst_action, bone_name)
self._store_rotation(dst_action, bone_name)
self._store_scale(dst_action, bone_name)
self._store_bbone(dst_action, bone_name)
def _store_animated_parameters(self, dst_action: Action) -> None:
"""Store the current value of any animated bone properties."""
if self.params.src_action is None:
return
armature_ob = self.params.armature_ob
for fcurve in self.params.src_action.fcurves:
match = pose_bone_re.match(fcurve.data_path)
if not match:
# Not animating a bone property.
continue
bone_name = match.group(1)
if bone_name not in self.params.bone_names:
# Bone is not our export set.
continue
if dst_action.fcurves.find(fcurve.data_path, index=fcurve.array_index):
# This property is already handled by a previous _store_xxx() call.
continue
# Only include in the pose if there is a key on this frame.
if not self._has_key_on_frame(fcurve):
continue
try:
value = self._current_value(armature_ob, fcurve.data_path, fcurve.array_index)
except UnresolvablePathError:
# A once-animated property no longer exists.
continue
dst_fcurve = dst_action.fcurves.new(
fcurve.data_path, index=fcurve.array_index, action_group=bone_name
)
dst_fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value)
dst_fcurve.update()
def _store_parameters_from_callback(self, dst_action: Action) -> None:
"""Store extra parameters in the pose based on arbitrary callbacks.
Not implemented yet, needs a proper design & some user stories.
"""
pass
def _store_location(self, dst_action: Action, bone_name: str) -> None:
"""Store bone location."""
self._store_bone_array(dst_action, bone_name, "location", 3)
def _store_rotation(self, dst_action: Action, bone_name: str) -> None:
"""Store bone rotation given current rotation mode."""
bone = self._find_bone(bone_name)
if bone.rotation_mode == "QUATERNION":
self._store_bone_array(dst_action, bone_name, "rotation_quaternion", 4)
elif bone.rotation_mode == "AXIS_ANGLE":
self._store_bone_array(dst_action, bone_name, "rotation_axis_angle", 4)
else:
self._store_bone_array(dst_action, bone_name, "rotation_euler", 3)
def _store_scale(self, dst_action: Action, bone_name: str) -> None:
"""Store bone scale."""
self._store_bone_array(dst_action, bone_name, "scale", 3)
def _store_bbone(self, dst_action: Action, bone_name: str) -> None:
"""Store bendy-bone parameters."""
for prop_name, array_length in self._bbone_props:
if array_length:
self._store_bone_array(dst_action, bone_name, prop_name, array_length)
else:
self._store_bone_property(dst_action, bone_name, prop_name)
def _store_bone_array(
self, dst_action: Action, bone_name: str, property_name: str, array_length: int
) -> None:
"""Store all elements of an array property."""
for array_index in range(array_length):
self._store_bone_property(dst_action, bone_name, property_name, array_index)
def _store_bone_property(
self,
dst_action: Action,
bone_name: str,
property_path: str,
array_index: int = -1,
) -> None:
"""Store the current value of a single bone property."""
bone = self._find_bone(bone_name)
value = self._current_value(bone, property_path, array_index)
# Get the full 'pose.bones["bone_name"].blablabla' path suitable for FCurves.
rna_path = bone.path_from_id(property_path)
fcurve: Optional[FCurve] = dst_action.fcurves.find(rna_path, index=array_index)
if fcurve is None:
fcurve = dst_action.fcurves.new(rna_path, index=array_index, action_group=bone_name)
fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value)
fcurve.update()
@classmethod
def _current_value(
cls, datablock: bpy.types.ID, data_path: str, array_index: int
) -> FCurveValue:
"""Resolve an RNA path + array index to an actual value."""
value_or_array = cls._path_resolve(datablock, data_path)
# Both indices -1 and 0 are used for non-array properties.
# -1 cannot be used in arrays, whereas 0 can be used in both arrays and non-arrays.
if array_index == -1:
return cast(FCurveValue, value_or_array)
if array_index == 0:
value_or_array = cls._path_resolve(datablock, data_path)
try:
# MyPy doesn't understand this try/except is to determine the type.
value = value_or_array[array_index] # type: ignore
except TypeError:
# Not an array after all.
return cast(FCurveValue, value_or_array)
return cast(FCurveValue, value)
# MyPy doesn't understand that array_index>0 implies this is indexable.
return cast(FCurveValue, value_or_array[array_index]) # type: ignore
@staticmethod
def _path_resolve(
datablock: bpy.types.ID, data_path: str
) -> Union[FCurveValue, Iterable[FCurveValue]]:
"""Wrapper for datablock.path_resolve(data_path).
Raise UnresolvablePathError when the path cannot be resolved.
This is easier to deal with upstream than the generic ValueError raised
by Blender.
"""
try:
return datablock.path_resolve(data_path) # type: ignore
except ValueError as ex:
raise UnresolvablePathError(str(ex)) from ex
@functools.lru_cache(maxsize=1024)
def _find_bone(self, bone_name: str) -> Bone:
"""Find a bone by name.
Assumes the named bone exists, as the bones this class handles comes
from the user's selection, and you can't select a non-existent bone.
"""
bone: Bone = self.params.armature_ob.pose.bones[bone_name]
return bone
def _has_key_on_frame(self, fcurve: FCurve) -> bool:
"""Return True iff the FCurve has a key on the source frame."""
points = fcurve.keyframe_points
if not points:
return False
frame_to_find = self.params.src_frame_nr
margin = 0.001
high = len(points) - 1
low = 0
while low <= high:
mid = (high + low) // 2
diff = points[mid].co.x - frame_to_find
if abs(diff) < margin:
return True
if diff < 0:
# Frame to find is bigger than the current middle.
low = mid + 1
else:
# Frame to find is smaller than the current middle
high = mid - 1
return False
def create_pose_asset(
params: PoseCreationParams,
) -> Optional[Action]:
"""Create a single-frame Action containing only the pose of the given bones.
DOES mark as asset, DOES NOT configure asset metadata.
"""
creator = PoseActionCreator(params)
pose_action = creator.create()
if pose_action is None:
return None
pose_action.asset_mark()
pose_action.asset_generate_preview()
return pose_action
#def create_pose_asset_from_context(context: Context, new_asset_name: str, selection=True) -> Optional[Action]:
def create_pose_asset_from_context(context: Context, new_asset_name: str) -> Optional[Action]:
"""Create Action asset from active object & selected bones."""
bones = context.selected_pose_bones_from_active_object
bone_names = {bone.name for bone in bones}
params = PoseCreationParams(
context.object,
getattr(context.object.animation_data, "action", None),
context.scene.frame_current,
frozenset(bone_names),
new_asset_name,
)
return create_pose_asset(params)
def copy_fcurves(
dst_action: Action,
src_action: Action,
src_frame_nr: float,
bone_names: Set[str],
) -> int:
"""Copy FCurves, returning number of curves copied."""
num_fcurves_copied = 0
for fcurve in src_action.fcurves:
match = pose_bone_re.match(fcurve.data_path)
if not match:
continue
bone_name = match.group(1)
if bone_name not in bone_names:
continue
# Check if there is a keyframe on this frame.
keyframe = find_keyframe(fcurve, src_frame_nr)
if keyframe is None:
continue
create_single_key_fcurve(dst_action, fcurve, keyframe)
num_fcurves_copied += 1
return num_fcurves_copied
def create_single_key_fcurve(
dst_action: Action, src_fcurve: FCurve, src_keyframe: Keyframe
) -> FCurve:
"""Create a copy of the source FCurve, but only for the given keyframe.
Returns a new FCurve with just one keyframe.
"""
dst_fcurve = copy_fcurve_without_keys(dst_action, src_fcurve)
copy_keyframe(dst_fcurve, src_keyframe)
return dst_fcurve
def copy_fcurve_without_keys(dst_action: Action, src_fcurve: FCurve) -> FCurve:
"""Create a new FCurve and copy some properties."""
src_group_name = src_fcurve.group.name if src_fcurve.group else ""
dst_fcurve = dst_action.fcurves.new(
src_fcurve.data_path, index=src_fcurve.array_index, action_group=src_group_name
)
for propname in {"auto_smoothing", "color", "color_mode", "extrapolation"}:
setattr(dst_fcurve, propname, getattr(src_fcurve, propname))
return dst_fcurve
def copy_keyframe(dst_fcurve: FCurve, src_keyframe: Keyframe) -> Keyframe:
"""Copy a keyframe from one FCurve to the other."""
dst_keyframe = dst_fcurve.keyframe_points.insert(
src_keyframe.co.x, src_keyframe.co.y, options={'FAST'}, keyframe_type=src_keyframe.type
)
for propname in {
"amplitude",
"back",
"easing",
"handle_left",
"handle_left_type",
"handle_right",
"handle_right_type",
"interpolation",
"period",
}:
setattr(dst_keyframe, propname, getattr(src_keyframe, propname))
dst_fcurve.update()
return dst_keyframe
def find_keyframe(fcurve: FCurve, frame: float) -> Optional[Keyframe]:
# Binary search adapted from https://pythonguides.com/python-binary-search/
keyframes = fcurve.keyframe_points
low = 0
high = len(keyframes) - 1
mid = 0
# Accept any keyframe that's within 'epsilon' of the requested frame.
# This should account for rounding errors and the likes.
epsilon = 1e-4
frame_lowerbound = frame - epsilon
frame_upperbound = frame + epsilon
while low <= high:
mid = (high + low) // 2
keyframe = keyframes[mid]
if keyframe.co.x < frame_lowerbound:
low = mid + 1
elif keyframe.co.x > frame_upperbound:
high = mid - 1
else:
return keyframe
return None
def assign_from_asset_browser(asset: Action, asset_browser_area: bpy.types.Area) -> None:
"""Assign some things from the asset browser to the asset.
This sets the current catalog ID, and in the future could include tags
from the active dynamic catalog, etc.
"""
cat_id = asset_browser.active_catalog_id(asset_browser_area)
asset.asset_data.catalog_id = cat_id

180
pose/pose_usage.py Normal file
View File

@ -0,0 +1,180 @@
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Pose Library - usage functions.
"""
from typing import Set
import re
from bpy.types import (
Action,
Object,
)
#def select_bones(arm_object: Object, action: Action, *, select: bool, flipped: bool, both=False) -> None:
def select_bones(arm_object: Object, action: Action, *, selected_side, toggle=True):
pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]')
pose = arm_object.pose
seen_bone_names: Set[str] = set()
bones_to_select = set()
for fcurve in action.fcurves:
data_path: str = fcurve.data_path
match = pose_bone_re.match(data_path)
if not match:
continue
bone_name = match.group(1)
bone_name_flip = flip_side_name(bone_name)
if bone_name in seen_bone_names:
continue
seen_bone_names.add(bone_name)
if selected_side == 'FLIPPED':
bones_to_select.add(bone_name_flip)
elif selected_side == 'BOTH':
bones_to_select.add(bone_name_flip)
bones_to_select.add(bone_name)
elif selected_side == 'CURRENT':
bones_to_select.add(bone_name)
for bone in bones_to_select:
pose_bone = pose.bones.get(bone)
if pose_bone:
if toggle:
pose_bone.bone.select = not pose_bone.bone.select
else:
pose_bone.bone.select = True
_FLIP_SEPARATORS = set(". -_")
# These are single-character replacements, others are handled differently.
_FLIP_REPLACEMENTS = {
"l": "r",
"L": "R",
"r": "l",
"R": "L",
}
def flip_side_name(to_flip: str) -> str:
"""Flip left and right indicators in the name.
Basically a Python implementation of BLI_string_flip_side_name.
>>> flip_side_name('bone_L.004')
'bone_R.004'
>>> flip_side_name('left_bone')
'right_bone'
>>> flip_side_name('Left_bone')
'Right_bone'
>>> flip_side_name('LEFT_bone')
'RIGHT_bone'
>>> flip_side_name('some.bone-RIGHT.004')
'some.bone-LEFT.004'
>>> flip_side_name('some.bone-right.004')
'some.bone-left.004'
>>> flip_side_name('some.bone-Right.004')
'some.bone-Left.004'
>>> flip_side_name('some.bone-LEFT.004')
'some.bone-RIGHT.004'
>>> flip_side_name('some.bone-left.004')
'some.bone-right.004'
>>> flip_side_name('some.bone-Left.004')
'some.bone-Right.004'
>>> flip_side_name('.004')
'.004'
>>> flip_side_name('L.004')
'R.004'
"""
import string
if len(to_flip) < 3:
# we don't flip names like .R or .L
return to_flip
# We first check the case with a .### extension, let's find the last period.
number = ""
replace = to_flip
if to_flip[-1] in string.digits:
try:
index = to_flip.rindex(".")
except ValueError:
pass
else:
if to_flip[index + 1] in string.digits:
# TODO(Sybren): this doesn't handle "bone.1abc2" correctly.
number = to_flip[index:]
replace = to_flip[:index]
if not replace:
# Nothing left after the number, so no flips necessary.
return replace + number
if len(replace) == 1:
replace = _FLIP_REPLACEMENTS.get(replace, replace)
return replace + number
# First case; separator . - _ with extensions r R l L.
if replace[-2] in _FLIP_SEPARATORS and replace[-1] in _FLIP_REPLACEMENTS:
replace = replace[:-1] + _FLIP_REPLACEMENTS[replace[-1]]
return replace + number
# Second case; beginning with r R l L, with separator after it.
if replace[1] in _FLIP_SEPARATORS and replace[0] in _FLIP_REPLACEMENTS:
replace = _FLIP_REPLACEMENTS[replace[0]] + replace[1:]
return replace + number
lower = replace.lower()
prefix = suffix = ""
if lower.startswith("right"):
bit = replace[0:2]
if bit == "Ri":
prefix = "Left"
elif bit == "RI":
prefix = "LEFT"
else:
prefix = "left"
replace = replace[5:]
elif lower.startswith("left"):
bit = replace[0:2]
if bit == "Le":
prefix = "Right"
elif bit == "LE":
prefix = "RIGHT"
else:
prefix = "right"
replace = replace[4:]
elif lower.endswith("right"):
bit = replace[-5:-3]
if bit == "Ri":
suffix = "Left"
elif bit == "RI":
suffix = "LEFT"
else:
suffix = "left"
replace = replace[:-5]
elif lower.endswith("left"):
bit = replace[-4:-2]
if bit == "Le":
suffix = "Right"
elif bit == "LE":
suffix = "RIGHT"
else:
suffix = "right"
replace = replace[:-4]
return prefix + replace + suffix + number
if __name__ == '__main__':
import doctest
print(f"Test result: {doctest.testmod()}")

777
prefs.py Normal file
View File

@ -0,0 +1,777 @@
import bpy
import os
from os.path import abspath, join
from bpy.types import (AddonPreferences, PointerProperty, PropertyGroup)
from bpy.props import (BoolProperty, StringProperty, CollectionProperty,
EnumProperty, IntProperty)
from asset_library.constants import (DATA_TYPES, DATA_TYPE_ITEMS,
ICONS, RESOURCES_DIR, ADAPTER_DIR)
from asset_library.common.file_utils import import_module_from_path, norm_str
from asset_library.common.bl_utils import get_addon_prefs
from asset_library.common.functions import get_catalog_path
from pathlib import Path
import importlib
import inspect
ADAPTERS = []
def update_library_config(self, context):
print('update_library_config not yet implemented')
def update_library_path(self, context):
prefs = get_addon_prefs()
self['bundle_directory'] = str(self.library_path)
if not self.custom_bundle_name:
self['custom_bundle_name'] = self.name
if not self.custom_bundle_directory:
custom_bundle_dir = Path(prefs.bundle_directory, self.library_name).resolve()
self['custom_bundle_directory'] = str(custom_bundle_dir)
#if self.custom_bundle_directory:
# self['custom_bundle_directory'] = abspath(bpy.path.abspath(self.custom_bundle_directory))
#else:
# bundle_directory = join(prefs.bundle_directory, norm_str(self.name))
# self['custom_bundle_directory'] = abspath(bundle_directory)
self.set_library_path()
def update_all_library_path(self, context):
#print('update_all_assetlib_paths')
prefs = get_addon_prefs()
#if self.custom_bundle_directory:
# self['custom_bundle_directory'] = abspath(bpy.path.abspath(self.custom_bundle_directory))
for lib in prefs.libraries:
update_library_path(lib, context)
#lib.set_library_path()
def get_adapter_items(self, context):
#prefs = get_addon_prefs()
items = [('NONE', 'None', '', 0)]
items += [(norm_str(a.name, format=str.upper), a.name, "", i+1) for i, a in enumerate(ADAPTERS)]
return items
def get_library_items(self, context):
prefs = get_addon_prefs()
items = [('NONE', 'None', '', 0)]
items += [(l.name, l.name, "", i+1) for i, l in enumerate(prefs.libraries) if l != self]
return items
def get_store_library_items(self, context):
prefs = get_addon_prefs()
libraries = [l for l in prefs.libraries if l.merge_library == self.name]
return [(l.name, l.name, "", i) for i, l in enumerate([self] + libraries)]
class AssetLibraryAdapters(PropertyGroup):
parent = None
def __iter__(self):
return (getattr(self, p) for p in self.bl_rna.properties.keys() if p not in ('rna_type', 'name'))
class ConformAssetLibrary(PropertyGroup):
adapters : bpy.props.PointerProperty(type=AssetLibraryAdapters)
adapter_name : EnumProperty(items=get_adapter_items)
directory : StringProperty(
name="Destination Directory",
subtype='DIR_PATH',
default=''
)
image_template : StringProperty()
video_template : StringProperty()
externalize_data: BoolProperty(default=False, name='Externalize Data')
blend_depth: IntProperty(default=1, name='Blend Depth')
@property
def adapter(self):
name = norm_str(self.adapter_name)
if not hasattr(self.adapters, name):
return
return getattr(self.adapters, name)
class AssetLibrary(PropertyGroup):
name : StringProperty(name='Name', default='Action Library', update=update_library_path)
id : StringProperty()
auto_bundle : BoolProperty(name='Auto Bundle', default=True)
expand : BoolProperty(name='Expand', default=False)
use : BoolProperty(name='Use', default=True, update=update_library_path)
data_type : EnumProperty(name='Type', items=DATA_TYPE_ITEMS, default='ACTION')
bundle_directory : StringProperty(
name="Bundle Directory",
subtype='DIR_PATH',
default=''
)
use_custom_bundle_directory : BoolProperty(default=False, update=update_library_path)
custom_bundle_directory : StringProperty(
name="Bundle Directory",
subtype='DIR_PATH',
default='',
update=update_library_path
)
#use_merge : BoolProperty(default=False, update=update_library_path)
use_custom_bundle_name : BoolProperty(default=False, update=update_library_path)
custom_bundle_name : StringProperty(name='Merge Name', update=update_library_path)
#merge_library : EnumProperty(name='Merge Library', items=get_library_items, update=update_library_path)
#merge_name : StringProperty(name='Merge Name', update=update_library_path)
#Library when adding an asset to the library if merge with another
store_library: EnumProperty(items=get_store_library_items, name="Library")
template: StringProperty()
expand_extra : BoolProperty(name='Expand', default=False)
blend_depth : IntProperty(name='Blend Depth', default=0)
# source_directory : StringProperty(
# name="Path",
# subtype='DIR_PATH',
# default='',
# update=update_library_path
# )
#adapter : EnumProperty(items=adapter_ITEMS)
adapters : bpy.props.PointerProperty(type=AssetLibraryAdapters)
adapter_name : EnumProperty(items=get_adapter_items)
conform: bpy.props.PointerProperty(type=ConformAssetLibrary)
# data_file_path : StringProperty(
# name="Path",
# subtype='FILE_PATH',
# default='',
# )
#expand_conform : BoolProperty(name='Expand Conform', default=False)
#def __init__(self):
# self.adapters.parent = self
@property
def merge_libraries(self):
prefs = get_addon_prefs()
return [l for l in prefs.libraries if l != self and (l.library_path == self.library_path)]
@property
def data_types(self):
return f'{self.data_type.lower()}s'
@property
def adapter(self):
name = norm_str(self.adapter_name)
if not hasattr(self.adapters, name):
return
return getattr(self.adapters, name)
@property
def library(self):
prefs = get_addon_prefs()
asset_lib_ref = bpy.context.space_data.params.asset_library_ref
#TODO work also outside asset_library_area
if asset_lib_ref not in prefs.libraries:
return None
return prefs.libraries[asset_lib_ref]
@property
def library_path(self):
prefs = get_addon_prefs()
library_name = self.library_name
#if not self.use_custom_bundle_name:
# library_name = norm_str(library_name)
if self.use_custom_bundle_directory:
return Path(self.custom_bundle_directory, library_name).resolve()
else:
library_name = norm_str(library_name)
return Path(prefs.bundle_directory, library_name).resolve()
@property
def library_name(self):
if self.use_custom_bundle_name:
return self.custom_bundle_name
return self.name
@property
def image_template(self):
prefs = get_addon_prefs()
return prefs.image_template
@property
def video_template(self):
prefs = get_addon_prefs()
return prefs.video_template
@property
def asset_description_template(self):
prefs = get_addon_prefs()
return prefs.asset_description_template
@property
def catalog_path(self):
return get_catalog_path(self.library_path)
@property
def options(self):
return {k: getattr(self.adapter, k) for k, v in self.options.bl_rna.properties.keys() if p !='rna_type'}
def clear_library_path(self):
#print('Clear Library Path', self.name)
prefs = bpy.context.preferences
libs = prefs.filepaths.asset_libraries
#path = self.library_path.as_posix()
for l in reversed(libs):
#lib_path = Path(l.path).resolve().as_posix()
prev_name = self.get('asset_library') or self.library_name
#print(l.name, prev_name)
if (l.name == prev_name):
index = list(libs).index(l)
try:
bpy.ops.preferences.asset_library_remove(index=index)
return
except AttributeError:
pass
#print('No library removed')
def set_dict(self, data, obj=None):
""""Recursive method to set all attribute from a dict to this instance"""
obj = obj or self
# Make shure the input dict is not modidied
data = data.copy()
#print(obj)
for key, value in data.items():
if isinstance(value, dict):
#print('Nested value', key)
if 'name' in value:
setattr(obj, f'{key}_name', value.pop('name'))
self.set_dict(value, obj=getattr(obj, key))
elif key in obj.bl_rna.properties.keys():
if key == 'id':
value = str(value)
elif key == 'custom_bundle_name':
if not 'use_custom_bundle_name' in data.values():
obj["use_custom_bundle_name"] = True
elif isinstance(value, str):
value = os.path.expandvars(value)
value = os.path.expanduser(value)
obj[key] = value
else:
print(f'Prop {key} of {obj} not exist')
self['bundle_directory'] = str(self.library_path)
if not self.custom_bundle_name:
self['custom_bundle_name'] = self.name
# self.adapter_name = data['adapter']
# if not self.adapter:
# print(f"No adapter named {data['adapter']}")
# return
# for key, value in data.items():
# if key == 'options':
# for k, v in data['options'].items():
# setattr(self.adapter, k, v)
# elif key in self.bl_rna.properties.keys():
# if key == 'id':
# value = str(value)
# if key == 'custom_bundle_name':
# if not 'use_custom_bundle_name' in data.values():
# self["use_custom_bundle_name"] = True
# self[key] = value
def to_dict(self):
data = {p: getattr(self, p) for p in self.bl_rna.properties.keys() if p !='rna_type'}
data['options'] = self.adapter.to_dict()
data['adapter'] = data.pop('adapter_name')
del data['adapters']
return data
def set_library_path(self):
'''Update the Blender Preference Filepaths tab with the addon libraries'''
prefs = bpy.context.preferences
name = self.library_name
prev_name = self.get('asset_library') or name
lib = prefs.filepaths.asset_libraries.get(prev_name)
lib_path = self.library_path
#print('name', name)
#print('lib', lib)
#print('lib_path', lib_path)
#print('self.merge_library ', self.merge_library)
#print('prev_name', prev_name)
#print('\nset_library_path')
#print(f'{self.name=}, {prev_name=}, {lib_path=}, {self.use}')
if not lib_path:
self.clear_library_path()
return
if not self.use:
if all(not l.use for l in self.merge_libraries):
self.clear_library_path()
return
# Create the Asset Library Path
if not lib:
try:
bpy.ops.preferences.asset_library_add(directory=str(lib_path))
except AttributeError:
return
lib = prefs.filepaths.asset_libraries[-1]
lib.name = name
self['asset_library'] = name
lib.path = str(lib_path)
@property
def is_user(self):
prefs = get_addon_prefs()
return self in prefs.user_libraries.values()
@property
def is_env(self):
prefs = get_addon_prefs()
return self in prefs.env_libraries.values()
def add_row(self, layout, data=None, prop=None, label='',
boolean=None, factor=0.39):
'''Act like the use_property_split but with much more control'''
enabled = True
split = layout.split(factor=factor, align=True)
row = split.row(align=False)
row.use_property_split = False
row.alignment= 'RIGHT'
row.label(text=str(label))
if boolean:
boolean_data = self
if isinstance(boolean, (list, tuple)):
boolean_data, boolean = boolean
row.prop(boolean_data, boolean, text='')
enabled = getattr(boolean_data, boolean)
row = split.row(align=True)
row.enabled = enabled
if isinstance(data, str):
row.label(text=data)
else:
row.prop(data or self, prop, text='')
return split
def draw_operators(self, layout):
row = layout.row(align=True)
row.alignment = 'RIGHT'
row.prop(self, 'adapter_name', text='')
row.prop(self, 'auto_bundle', text='', icon='UV_SYNC_SELECT')
row.operator("assetlib.diff", text='', icon='FILE_REFRESH').name = self.name
op = row.operator("assetlib.bundle", icon='MOD_BUILD', text='')
op.name = self.name
layout.separator(factor=3)
def draw_extra(self, layout):
#box = layout.box()
col = layout.column(align=False)
row = col.row(align=True)
row.use_property_split = False
#row.alignment = 'LEFT'
icon = "DISCLOSURE_TRI_DOWN" if self.expand_extra else "DISCLOSURE_TRI_RIGHT"
row.label(icon='BLANK1')
subrow = row.row(align=True)
subrow.alignment = 'LEFT'
subrow.prop(self, 'expand_extra', icon=icon, emboss=False, text="Conform Options")
#row.prop(self, 'expand_extra', text='', icon="OPTIONS", emboss=False)
#row.prop(self, 'expand_extra', emboss=False, text='Options')
#row.label(text='Conform Options')
subrow = row.row(align=True)
subrow.alignment = 'RIGHT'
subrow.prop(self.conform, "adapter_name", text='')
op = subrow.operator('assetlib.diff', text='', icon='FILE_REFRESH')#, icon='MOD_BUILD'
op.name = self.name
op.conform = True
op = subrow.operator('assetlib.bundle', text='', icon='MOD_BUILD')#, icon='MOD_BUILD'
op.name = self.name
op.directory = self.conform.directory
subrow.label(icon='BLANK1')
#subrow.separator(factor=3)
if self.expand_extra and self.conform.adapter:
col.separator()
#row = layout.row(align=True)
#row.label(text='Conform Library')
col.prop(self.conform, "directory")
col.prop(self.conform, "blend_depth")
col.prop(self.conform, "externalize_data")
col.prop(self.conform, "image_template", text='Image Template')
col.prop(self.conform, "video_template", text='Video Template')
col.separator()
self.conform.adapter.draw_prefs(col)
col.separator()
def draw(self, layout):
prefs = get_addon_prefs()
box = layout.box()
row = box.row(align=True)
icon = "DISCLOSURE_TRI_DOWN" if self.expand else "DISCLOSURE_TRI_RIGHT"
row.prop(self, 'expand', icon=icon, emboss=False, text='')
if self.is_user:
row.prop(self, 'use', text='')
row.prop(self, 'data_type', icon_only=True, emboss=False)
row.prop(self, 'name', text='')
self.draw_operators(row)
index = prefs.user_libraries.index(self)
row.operator("assetlib.remove_user_library", icon="X", text='', emboss=False).index = index
else:
row.prop(self, 'use', text='')
row.label(icon=ICONS[self.data_type])
row.label(text=self.name)
self.draw_operators(row)
sub_row = row.row()
sub_row.enabled = False
sub_row.label(icon='FAKE_USER_ON')
if self.expand:
col = box.column(align=False)
col.use_property_split = True
#row = col.row(align=True)
row = self.add_row(col,
prop="custom_bundle_name",
boolean="use_custom_bundle_name",
label='Custom Bundle Name')
row.enabled = not self.use_custom_bundle_directory
prop = "bundle_directory"
if self.use_custom_bundle_directory:
prop = "custom_bundle_directory"
self.add_row(col, prop=prop,
boolean="use_custom_bundle_directory",
label='Custom Bundle Directory',
)
if self.adapter:
col.separator()
self.adapter.draw_prefs(col)
col.separator()
self.draw_extra(col)
class Collections:
'''Util Class to merge multiple collections'''
collections = []
def __init__(self, *collection):
self.collections = collection
for col in collection:
#print('Merge methods')
for attr in dir(col):
if attr.startswith('_'):
continue
value = getattr(col, attr)
#if not callable(value):
# continue
setattr(self, attr, value)
def __contains__(self, item):
if isinstance(item, str):
return item in self.to_dict()
else:
return item in self
def __iter__(self):
return self.to_list().__iter__()
def __getitem__(self, item):
if isinstance(item, int):
return self.to_list()[item]
else:
return self.to_dict()[item]
def get(self, item, fallback=None):
return self.to_dict().get(item) or fallback
def to_dict(self):
return {k:v for c in self.collections for k, v in c.items()}
def to_list(self):
return [v for c in self.collections for v in c.values()]
def get_parent(self, item):
for c in self.collections:
if item in c.values():
return c
def index(self, item):
c = self.get_parent(item)
if not c:
return item in self
return list(c.values()).index(item)
#class AssetLibraryOptions(PropertyGroup):
# pass
class AssetLibraryPrefs(AddonPreferences):
bl_idname = __package__
#action : bpy.props.PointerProperty(type=AssetLibraryPath)
#asset : bpy.props.PointerProperty(type=AssetLibraryPath)
#adapters = {}
image_player: StringProperty(default='')
video_player: StringProperty(default='')
adapter_directory : StringProperty(
name="Adapter Directory",
subtype='DIR_PATH'
)
env_libraries : CollectionProperty(type=AssetLibrary)
user_libraries : CollectionProperty(type=AssetLibrary)
expand_settings: BoolProperty(default=False)
bundle_directory : StringProperty(
name="Path",
subtype='DIR_PATH',
default='',
update=update_all_library_path
)
use_single_path : BoolProperty(default=True)
asset_description_template : StringProperty(default='../{name}_asset_description.json')
image_template : StringProperty(default='../{name}_image.png')
video_template : StringProperty(default='../{name}_video.mov')
config_directory : StringProperty(
name="Config Path",
subtype='FILE_PATH',
default=str(RESOURCES_DIR/"asset_library_config.json"),
update=update_library_config
)
def load_adapters(self):
from asset_library.adapters.adapter import AssetLibraryAdapter
#global ADAPTERS
print('\n------Load Adapters')
ADAPTERS.clear()
adapter_files = list(ADAPTER_DIR.glob('*.py'))
if self.adapter_directory:
user_adapter_dir = Path(self.adapter_directory)
if user_adapter_dir.exists():
adapter_files += list(user_adapter_dir.glob('*.py'))
for adapter_file in adapter_files:
mod = import_module_from_path(adapter_file)
if adapter_file.stem.startswith('_'):
continue
print(adapter_file)
for name, obj in inspect.getmembers(mod):
if not inspect.isclass(obj):
continue
print(obj.__bases__)
if not AssetLibraryAdapter in obj.__mro__:
continue
# Non registering base adapter
if obj is AssetLibraryAdapter or obj.name in (a.name for a in ADAPTERS):
continue
try:
print(f'Register Plugin {name}')
bpy.utils.register_class(obj)
setattr(AssetLibraryAdapters, norm_str(obj.name), bpy.props.PointerProperty(type=obj))
ADAPTERS.append(obj)
except Exception as e:
print(f'Could not register adapter {name}')
print(e)
@property
def libraries(self):
return Collections(self.env_libraries, self.user_libraries)
def draw(self, context):
prefs = get_addon_prefs()
layout = self.layout
#layout.use_property_split = True
main_col = layout.column(align=False)
box = main_col.box()
row = box.row(align=True)
icon = "DISCLOSURE_TRI_DOWN" if self.expand_settings else "DISCLOSURE_TRI_RIGHT"
row.prop(self, 'expand_settings', icon=icon, emboss=False, text='')
row.label(icon='PREFERENCES')
row.label(text='Settings')
#row.separator_spacer()
subrow = row.row()
subrow.alignment = 'RIGHT'
subrow.operator("assetlib.reload_addon", text='Reload Addon')
if prefs.expand_settings:
col = box.column(align=True)
col.use_property_split = True
#col.prop(self, 'use_single_path', text='Single Path')
col.prop(self, 'bundle_directory', text='Bundle Directory')
col.separator()
col.prop(self, 'adapter_directory')
col.prop(self, 'config_directory')
col.separator()
col.prop(self, 'asset_description_template', text='Asset Description Template', icon='COPY_ID')
col.separator()
col.prop(self, 'image_template', text='Image Template', icon='COPY_ID')
col.prop(self, 'image_player', text='Image Player') #icon='OUTLINER_OB_IMAGE'
col.separator()
col.prop(self, 'video_template', text='Video Template', icon='COPY_ID')
col.prop(self, 'video_player', text='Video Player') #icon='FILE_MOVIE'
col.separator()
col.operator("assetlib.add_user_library", text='Bundle All Libraries', icon='MOD_BUILD')
for lib in self.libraries:# list(self.env_libraries) + list(self.user_libraries):
lib.draw(main_col)
row = main_col.row()
row.alignment = 'RIGHT'
row.operator("assetlib.add_user_library", icon="ADD", text='', emboss=False)
classes = (
AssetLibraryAdapters,
ConformAssetLibrary,
AssetLibrary,
AssetLibraryPrefs,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
prefs = get_addon_prefs()
# Read Env and override preferences
bundle_dir = os.getenv('ASSETLIB_BUNDLE_DIR')
if bundle_dir:
prefs.bundle_directory = os.path.expandvars(bundle_dir)
config_dir = os.getenv('ASSETLIB_CONFIG_DIR')
if config_dir:
prefs.config_directory = os.path.expandvars(config_dir)
adapter_dir = os.getenv('ASSETLIB_ADAPTER_DIR')
if adapter_dir:
prefs.adapter_directory = os.path.expandvars(adapter_dir)
prefs.load_adapters()
def unregister():
for cls in reversed(list(classes) + ADAPTERS):
bpy.utils.unregister_class(cls)
ADAPTERS.clear()

View File

BIN
resources/empty_preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B