From d1c17581ff911f982854f989bd8c1b64d3fc007e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cchristopheseux=E2=80=9D?= <“seuxchristophe@hotmail.fr”> Date: Tue, 17 Jan 2023 18:05:22 +0100 Subject: [PATCH] Make all features working --- __init__.py | 19 +- action/gui.py | 2 +- action/operators.py | 198 +++-- adapters/__init__.py | 4 - adapters/adapter.py | 764 +------------------- adapters/kitsu.py | 153 ---- adapters/scan_folder.py | 165 ----- collection/operators.py | 4 +- collection/preview.blend | Bin 1452073 -> 1423561 bytes common/bl_utils.py | 16 + common/functions.py | 6 +- common/template.py | 37 +- constants.py | 9 + file/bundle.py | 8 +- file/gui.py | 2 +- file/operators.py | 2 +- gui.py | 4 +- library_types/__init__.py | 17 + library_types/conform.py | 212 ++++++ {adapters => library_types}/copy_folder.py | 8 +- {adapters => library_types}/data_file.py | 0 library_types/kitsu.py | 275 +++++++ library_types/library_type.py | 799 +++++++++++++++++++++ library_types/poly_haven.py | 141 ++++ library_types/scan_folder.py | 347 +++++++++ operators.py | 290 ++++++-- prefs.py => preferences.py | 309 ++++---- 27 files changed, 2391 insertions(+), 1400 deletions(-) delete mode 100644 adapters/kitsu.py delete mode 100644 adapters/scan_folder.py create mode 100644 library_types/__init__.py create mode 100644 library_types/conform.py rename {adapters => library_types}/copy_folder.py (75%) rename {adapters => library_types}/data_file.py (100%) create mode 100644 library_types/kitsu.py create mode 100644 library_types/library_type.py create mode 100644 library_types/poly_haven.py create mode 100644 library_types/scan_folder.py rename prefs.py => preferences.py (70%) diff --git a/__init__.py b/__init__.py index 80a4722..693c1b1 100644 --- a/__init__.py +++ b/__init__.py @@ -23,9 +23,9 @@ 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 (gui, keymaps, preferences, operators) from asset_library import constants -#from asset_library.common.adapter import AssetLibraryAdapter +#from asset_library.common.library_type import LibraryType 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 @@ -38,10 +38,11 @@ if 'bpy' in locals(): import importlib + importlib.reload(constants) importlib.reload(gui) importlib.reload(keymaps) - importlib.reload(prefs) + importlib.reload(preferences) importlib.reload(operators) importlib.reload(constants) @@ -63,7 +64,7 @@ bl_modules = ( file, keymaps, gui, - prefs + preferences ) @@ -72,13 +73,14 @@ def 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') + if not bpy.app.background: + bpy.ops.assetlib.bundle(blocking=False, mode='AUTO_BUNDLE') def register() -> None: + for m in bl_modules: m.register() @@ -92,5 +94,10 @@ def register() -> None: def unregister() -> None: + prefs = get_addon_prefs() + bpy.utils.previews.remove(prefs.previews) + for m in reversed(bl_modules): m.unregister() + + diff --git a/action/gui.py b/action/gui.py index 9e96a39..0802a54 100644 --- a/action/gui.py +++ b/action/gui.py @@ -14,7 +14,7 @@ def draw_context_menu(layout): layout.operator_context = 'INVOKE_DEFAULT' #layout.operator("assetlib.rename_asset", text="Rename Action") - layout.operator("assetlib.clear_asset", text="Remove Asset") + layout.operator("assetlib.remove_assets", text="Remove Assets") layout.operator("assetlib.edit_data", text="Edit Asset data") #layout.operator("actionlib.clear_asset", text="Clear Asset (Fake User)").use_fake_user = True diff --git a/action/operators.py b/action/operators.py index 6dcb6ce..6d81cc2 100644 --- a/action/operators.py +++ b/action/operators.py @@ -21,6 +21,7 @@ import uuid import time from pathlib import Path from functools import partial +from pprint import pprint from asset_library.pose.pose_creation import( @@ -90,7 +91,8 @@ from asset_library.common.bl_utils import ( split_path, get_preview, get_view3d_persp, - load_assets_from, + get_viewport, + #load_assets_from, get_asset_space_params, get_bl_cmd, get_overriden_col @@ -275,7 +277,7 @@ class ACTIONLIB_OT_apply_anim(Operator): lib = get_active_library() if 'filepath' in asset_file_handle.asset_data: action_path = asset_file_handle.asset_data['filepath'] - action_path = lib.adapter.format_path(action_path) + action_path = lib.library_type.format_path(action_path) else: action_path = bpy.types.AssetHandle.get_full_library_path( asset_file_handle, asset_library_ref @@ -778,14 +780,68 @@ class ACTIONLIB_OT_open_blendfile(Operator): return {'FINISHED'} + + #LIBRARY_ITEMS = [] +''' +def callback_operator(modal_func, operator, override={}): + + def wrap(self, context, event): + ret, = retset = modal_func(self, context, event) + if ret in {'FINISHED'}: + + with context.temp_override(**override): + callback() + + return retset + return wrap +''' + + +class ACTIONLIB_OT_make_custom_preview(Operator): + bl_idname = "actionlib.make_custom_preview" + bl_label = "Custom Preview" + bl_description = "Set a camera to preview an asset" + + def modal(self, context, event): + prefs = get_addons_prefs() + + if not prefs.preview_modal: + with context.temp_override(area=self.source_area, region=self.source_area.regions[-1]): + bpy.ops.actionlib.store_anim_pose("INVOKE_DEFAULT", clear_previews=False, **prefs.add_asset_dict) + return {"FINISHED"} + + return {'PASS_THROUGH'} + + def invoke(self, context, event): + + self.source_area = bpy.context.area + + view3d = get_viewport() + with context.temp_override(area=view3d, region=view3d.regions[-1], window=context.window): + # To close the popup + bpy.ops.screen.screen_full_area() + bpy.ops.screen.back_to_previous() + + view3d = get_viewport() + with context.temp_override(area=view3d, region=view3d.regions[-1], window=context.window): + bpy.ops.assetlib.make_custom_preview('INVOKE_DEFAULT', modal=True) + + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + +def get_preview_items(self, context): + prefs = get_addon_prefs() + return sorted([(k, k, '', v.icon_id, index) for index, (k, v) in enumerate(prefs.previews.items())], reverse=True) + + class ACTIONLIB_OT_store_anim_pose(Operator): bl_idname = "actionlib.store_anim_pose" bl_label = "Add Action to the current library" bl_description = "Store current pose/anim to local library" - #use_new_folder: BoolProperty(default=False) warning: StringProperty(name='') path: StringProperty(name='Path') catalog: StringProperty(name='Catalog', update=asset_warning_callback, options={'TEXTEDIT_UPDATE'}) @@ -795,13 +851,12 @@ class ACTIONLIB_OT_store_anim_pose(Operator): frame_end: IntProperty(name="Frame End") tags: StringProperty(name='Tags', description='Tags need to separate with a comma (,)') description: StringProperty(name='Description') + preview : EnumProperty(items=get_preview_items) + clear_previews : BoolProperty(default=True) + store_library: StringProperty(name='Store Library') - #library: EnumProperty(items=lambda s, c: s.library_items, name="Library") - #library: EnumProperty(items=lambda s, c: LIBRARY_ITEMS, name="Library") - #CLIPBOARD_ASSET_MARKER = "ASSET-BLEND=" - @classmethod - def poll(cls, context: Context) -> bool: + def poll(cls, context: Context) -> bool: ob = context.object if not ob: cls.poll_message_set(f'You have no active object') @@ -826,16 +881,36 @@ class ACTIONLIB_OT_store_anim_pose(Operator): # col.operator("asset.tag_add", icon='ADD', text="") # col.operator("asset.tag_remove", icon='REMOVE', text="") + def to_dict(self): + keys = ("catalog", "name", "action_type", "frame_start", "frame_end", "tags", "description", "store_library") + return {k : getattr(self, k) for k in keys} + def draw(self, context): layout = self.layout layout.separator() prefs = get_addon_prefs() - #row = layout.row(align=True) - layout.use_property_split = True - #layout.alignment = 'LEFT' + split = layout.split(factor=0.39, align=True) + #row = split.row(align=False) + #split.use_property_split = False + split.alignment = 'RIGHT' + + split.label(text='Preview') + + sub = split.row(align=True) + sub.template_icon_view(self, "preview", show_labels=False) + sub.separator() + sub.operator("actionlib.make_custom_preview", icon='RESTRICT_RENDER_OFF', text='') + + prefs.add_asset_dict.clear() + prefs.add_asset_dict.update(self.to_dict()) + + sub.label(icon='BLANK1') #layout.ui_units_x = 50 + #row = layout.row(align=True) + layout.use_property_split = True + if self.current_library.merge_libraries: layout.prop(self.current_library, 'store_library', expand=False) @@ -890,7 +965,14 @@ class ACTIONLIB_OT_store_anim_pose(Operator): self.asset_action.asset_mark() self.area = context.area self.current_library = get_active_library() - #self.sce + + if self.store_library: + self.current_library.store_library = self.store_library + else: + lib = self.current_library.library_type.get_active_asset_library() + if lib.name: + self.current_library.store_library = lib.name + self.store_library = lib.name #lib = self.current_library self.tags = '' @@ -899,9 +981,21 @@ class ACTIONLIB_OT_store_anim_pose(Operator): #print(self, self.library_items) self.catalog = get_active_catalog() - self.set_action_type() + self.set_action_type() - return context.window_manager.invoke_props_dialog(self, width=450) + if self.clear_previews: + prefs.previews.clear() + + view3d = get_viewport() + with context.temp_override(area=view3d, region=view3d.regions[-1]): + bpy.ops.assetlib.make_custom_preview('INVOKE_DEFAULT') + + else: + preview_items = get_preview_items(self, context) + if preview_items: + self.preview = preview_items[0][0] + + return context.window_manager.invoke_props_dialog(self, width=350) def action_to_asset(self, action): #action.asset_mark() @@ -945,34 +1039,20 @@ class ACTIONLIB_OT_store_anim_pose(Operator): return action - def render_preview(self, image_path, video_path): + def render_animation(self, video_path): ctx = bpy.context scn = ctx.scene vl = ctx.view_layer area = get_view3d_persp() space = area.spaces.active - preview_attrs = [ + attrs = [ (scn, 'use_preview_range', True), (scn, 'frame_preview_start', self.frame_start), (scn, 'frame_preview_end', self.frame_end), (scn.render, 'resolution_percentage', 100), (space.overlay, 'show_overlays', False), (space.region_3d, 'view_perspective', 'CAMERA'), - ] - - image_attrs = [ - (scn.render, 'resolution_x', 512), - (scn.render, 'resolution_y', 512), - (scn.render, 'film_transparent', True), - (scn.render.image_settings, 'file_format', 'PNG'), - (scn.render.image_settings, 'color_mode', 'RGBA'), - (scn.render.image_settings, 'color_depth', 8), - (scn.render, 'use_overwrite', True), - (scn.render, 'filepath', str(image_path)) - ] - - video_attrs = [ (scn.render, 'resolution_x', 1280), (scn.render, 'resolution_y', 720), (scn.render.image_settings, 'file_format', 'FFMPEG'), @@ -984,10 +1064,6 @@ class ACTIONLIB_OT_store_anim_pose(Operator): (scn.render, 'filepath', str(video_path)), ] - with attr_set(preview_attrs+image_attrs): - with ctx.temp_override(area=area): - bpy.ops.render.opengl(write_still=True) - if self.action_type == "ANIMATION": with attr_set(preview_attrs+video_attrs): with ctx.temp_override(area=area): @@ -1000,7 +1076,8 @@ class ACTIONLIB_OT_store_anim_pose(Operator): 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): + def execute(self, context: Context): + scn = context.scene vl = context.view_layer ob = context.object @@ -1011,11 +1088,11 @@ class ACTIONLIB_OT_store_anim_pose(Operator): if lib.merge_libraries: lib = prefs.libraries[self.current_library.store_library] - #lib_path = lib.library_path - #name = lib.adapter.norm_file_name(self.name) - asset_path = lib.adapter.get_asset_path(name=self.name, catalog=self.catalog) - img_path = lib.adapter.get_image_path(name=self.name, catalog=self.catalog, filepath=asset_path) - video_path = lib.adapter.get_video_path(name=self.name, catalog=self.catalog, filepath=asset_path) + lib_type = lib.library_type + + asset_path = lib_type.get_asset_path(name=self.name, catalog=self.catalog) + img_path = lib_type.get_image_path(name=self.name, catalog=self.catalog, filepath=asset_path) + video_path = lib_type.get_video_path(name=self.name, catalog=self.catalog, filepath=asset_path) ## Copy Action current_action = ob.animation_data.action @@ -1025,31 +1102,31 @@ class ACTIONLIB_OT_store_anim_pose(Operator): self.action_to_asset(asset_action) - #lib.adapter.new_asset() + #lib_type.new_asset() - #Saving the preview - self.render_preview(img_path, video_path) - with context.temp_override(id=asset_action): - bpy.ops.ed.lib_id_load_custom_preview( - filepath=str(img_path) - ) + #Saving the video + if self.action_type == "ANIMATION": + self.render_animation(video_path) + + #Saving the preview image + preview = prefs.previews[self.preview] + lib_type.write_preview(preview, img_path) - lib.adapter.write_asset(asset=asset_action, asset_path=asset_path) + # Transfert the pixel to the action preview + pixels = [0] * preview.image_size[0] * preview.image_size[1] * 4 + preview.image_pixels_float.foreach_get(pixels) + asset_action.preview_ensure().image_pixels_float.foreach_set(pixels) + lib_type.write_asset(asset=asset_action, asset_path=asset_path) - asset_data = lib.adapter.get_asset_data(asset_action) + asset_data = dict(lib_type.get_asset_data(asset_action), catalog=self.catalog) + asset_info = lib_type.format_asset_info([asset_data], asset_path=asset_path) - diff = [dict(asset_data, - image=str(img_path), - filepath=str(asset_path), - type='ACTION', - library_id=lib.id, - catalog=self.catalog, - operation='ADD' - )] - # lib.adapter.write_description_file(asset_description, asset_path) + #print('asset_info') + #pprint(asset_info) + + diff = [dict(a, operation='ADD') for a in lib_type.flatten_cache([asset_info])] - # Restore action and cleanup ob.animation_data.action = current_action asset_action.asset_clear() @@ -1059,7 +1136,7 @@ class ACTIONLIB_OT_store_anim_pose(Operator): # TODO Write a proper method for this diff_path = Path(bpy.app.tempdir, 'diff.json') - #diff = [dict(a, operation='ADD') for a in [asset_description])] + #diff = [dict(a, operation='ADD') for a in [asset_info])] diff_path.write_text(json.dumps(diff, indent=4)) bpy.ops.assetlib.bundle(name=lib.name, diff=str(diff_path), blocking=True) @@ -1085,6 +1162,7 @@ classes = ( ACTIONLIB_OT_update_action_data, ACTIONLIB_OT_assign_rest_pose, ACTIONLIB_OT_store_anim_pose, + ACTIONLIB_OT_make_custom_preview ) register, unregister = bpy.utils.register_classes_factory(classes) diff --git a/adapters/__init__.py b/adapters/__init__.py index eae4d79..e69de29 100644 --- a/adapters/__init__.py +++ b/adapters/__init__.py @@ -1,4 +0,0 @@ - -from asset_library.adapters.adapter import AssetLibraryAdapter -from asset_library.adapters.copy_folder import CopyFolderLibrary -from asset_library.adapters.scan_folder import ScanFolderLibrary \ No newline at end of file diff --git a/adapters/adapter.py b/adapters/adapter.py index 4f43f34..6c627d9 100644 --- a/adapters/adapter.py +++ b/adapters/adapter.py @@ -1,772 +1,12 @@ -#from asset_library.common.functions import (norm_asset_datas,) -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.constants import (MODULE_DIR, RESOURCES_DIR) - -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 -import time -from functools import partial -import subprocess -from glob import glob -class AssetLibraryAdapter(PropertyGroup): +class Adapter(PropertyGroup): #def __init__(self): name = "Base Adapter" #library = None - - @property - def library(self): - prefs = self.addon_prefs - for lib in prefs.libraries: - if lib.adapter == self: - return lib - - @property - def bundle_directory(self): - return self.library.library_path - - @property - def data_type(self): - return self.library.data_type - - @property - def data_types(self): - return self.library.data_types - - def get_catalog_path(self, directory=None): - directory = directory or self.bundle_directory - return Path(directory, 'blender_assets.cats.txt') - - @property - def cache_file(self): - return Path(self.bundle_directory) / f"blender_assets.{self.library.id}.json" - - @property - def tmp_cache_file(self): - return Path(bpy.app.tempdir) / f"blender_assets.{self.library.id}.json" - - @property - def diff_file(self): - return Path(bpy.app.tempdir, 'diff.json') - - @property - def preview_blend(self): - return MODULE_DIR / self.data_type.lower() / "preview.blend" - - @property - def preview_assets_file(self): - return Path(bpy.app.tempdir, "preview_assets_file.json") - - @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'} - - @property - def format_data(self): - """Dict for formating template""" - return dict(self.to_dict(), bundle_dir=self.library.library_path) - - def fetch(self): - raise Exception('This method need to be define in the adapter') - - def norm_file_name(self, name): - return name.replace(' ', '_') - - def read_file(self, file): - return read_file(file) - - def write_file(self, file, data): - return write_file(file, data) - - def copy_file(self, source, destination): - src = Path(source) - dst = Path(destination) - - if not src.exists(): - print(f'Cannot copy file {src}: file not exist') - return - - dst.parent.mkdir(exist_ok=True, parents=True) - - if src == dst: - print(f'Cannot copy file {src}: source and destination are the same') - return - - print(f'Copy file from {src} to {dst}') - shutil.copy2(str(src), str(dst)) - - def load_datablocks(self, src, names=None, type='objects', link=True, expr=None, assets_only=False): - """Link or append a datablock from a blendfile""" - - if type.isupper(): - type = f'{type.lower()}s' - - return load_datablocks(src, names=names, type=type, link=link, expr=expr, assets_only=assets_only) - - def get_asset_data(self, asset): - """Extract asset information on a datablock""" - - return dict( - name=asset.name, - author=asset.asset_data.author, - tags=list(asset.asset_data.tags.keys()), - metadata=dict(asset.asset_data), - description=asset.asset_data.description, - ) - - 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_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_image_path(self, name, catalog, filepath): - raise Exception('Need to be defined in the adapter') - - def get_video_path(self, name, catalog, filepath): - raise Exception('Need to be defined in the adapter') - - def new_asset(self, asset, asset_data): - raise Exception('Need to be defined in the adapter') - - def remove_asset(self, asset, asset_data): - raise Exception('Need to be defined in the adapter') - - - def format_asset_data(self, data): - """Get a dict for use in template fields""" - return { - 'asset_name': data['name'], - 'asset_path': Path(data['filepath']), - 'catalog': data['catalog'], - 'catalog_name': data['catalog'].replace('/', '_'), - } - - def format_path(self, template, data={}, **kargs): - if not template: - return None - - if data: - data = self.format_asset_data(dict(data, **kargs)) - else: - data = kargs - - - if template.startswith('.'): #the template is relative - template = Path(data['asset_path'], template).as_posix() - - params = dict( - **data, - **self.format_data, - ) - - return Template(template).format(params).resolve() - - def find_path(self, template, data, **kargs): - path = self.format_path(template, data, **kargs) - paths = glob(str(path)) - if paths: - return Path(paths[0]) - - def read_asset_description_file(self, asset_path) -> dict: - """Read the description file of the asset""" - - description_path = self.get_description_path(asset_path) - return self.read_file(description_path) - - def write_description_file(self, asset_data, asset_path) -> None: - description_path = self.get_description_path(asset_path) - return write_file(description_path, asset_data) - - def write_asset(self, asset, asset_path): - - Path(asset_path).parent.mkdir(exist_ok=True, parents=True) - - bpy.data.libraries.write( - str(asset_path), - {asset}, - path_remap="NONE", - fake_user=True, - compress=True - ) - - def read_catalog(self, directory=None): - """Read the catalog file of the library target directory or of the specified directory""" - catalog_path = self.get_catalog_path(directory) - - if not catalog_path.exists(): - return {} - - cat_data = {} - - for line in catalog_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(self, catalog_data, directory=None): - """Write the catalog file in the library target directory or of the specified directory""" - - catalog_path = self.get_catalog_path(directory) - - lines = ['VERSION 1', ''] - - # Add missing parents catalog - norm_data = {} - for cat_path, cat_data in catalog_data.items(): - norm_data[cat_path] = cat_data - for p in Path(cat_path).parents[:-1]: - if p in cat_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: {catalog_path}') - catalog_path.write_text('\n'.join(lines), encoding="utf-8") - - def read_cache(self, cache_path=None): - cache_path = cache_path or self.cache_file - print(f'Read cache from {cache_path}') - return self.read_file(cache_path) - - def write_cache(self, asset_descriptions, cache_path=None): - cache_path = cache_path or self.cache_file - print(f'cache file writen to {cache_path}') - return write_file(cache_path, list(asset_descriptions)) - - 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""" - self.module_type.gui.draw_context_menu(layout) - - def generate_blend_preview(self, asset_description): - asset_name = asset_description['name'] - catalog = asset_description['catalog'] - - asset_path = self.format_path(asset_description['filepath']) - dst_image_path = self.get_image_path(asset_name, asset_path, catalog) - - if dst_image_path.exists(): - return - - # Check if a source image exists and if so copying it in the new directory - src_image_path = asset_description.get('image') - if src_image_path: - src_image_path = self.get_template_path(src_image_path, asset_name, asset_path, catalog) - if src_image_path and src_image_path.exists(): - self.copy_file(src_image_path, dst_image_path) - return - - print(f'Thumbnailing {asset_path} to {dst_image_path}') - blender_thumbnailer = Path(bpy.app.binary_path).parent / 'blender-thumbnailer' - - dst_image_path.parent.mkdir(exist_ok=True, parents=True) - - subprocess.call([blender_thumbnailer, str(asset_path), str(dst_image_path)]) - - success = dst_image_path.exists() - - if not success: - empty_preview = RESOURCES_DIR / 'empty_preview.png' - self.copy_file(str(empty_preview), str(dst_image_path)) - - return success - - def generate_asset_preview(self, asset_description): - """Only generate preview when conforming a library""" - - #print('\ngenerate_preview', asset_description['filepath']) - - scn = bpy.context.scene - #Creating the preview for collection, object or material - camera = scn.camera - vl = bpy.context.view_layer - - data_type = self.data_type #asset_description['data_type'] - asset_path = self.format_path(asset_description['filepath']) - - # Check if a source video exists and if so copying it in the new directory - if self.library.template_video: - for asset_data in asset_description['assets']: - dst_asset_path = self.get_asset_bundle_path(asset_data) - dst_video_path = self.format_path(self.library.template_video, asset_data, filepath=dst_asset_path) #Template(src_video_path).find(asset_data, asset_path=dst_asset_path, **self.format_data) - - if dst_video_path.exists(): - print(f'The dest video {dst_video_path} already exist') - continue - - src_video_template = asset_data.get('video') - if not src_video_template: - continue - - src_video_path = self.find_path(src_video_template, asset_data, filepath=asset_path)#Template(src_video_path).find(asset_data, asset_path=dst_asset_path, **self.format_data) - if src_video_path: - print(f'Copy video from {src_video_path} to {dst_video_path}') - self.copy_file(src_video_path, dst_video_path) - - # Check if asset as a preview image or need it to be generated - asset_data_names = {} - - if self.library.template_image: - for asset_data in asset_description['assets']: - name = asset_data['name'] - dst_asset_path = self.get_asset_bundle_path(asset_data) - - dst_image_path = self.format_path(self.library.template_image, asset_data, filepath=dst_asset_path) - if dst_image_path.exists(): - print(f'The dest image {dst_image_path} already exist') - continue - - # Check if a source image exists and if so copying it in the new directory - src_image_template = asset_data.get('image') - if src_image_template: - src_image_path = self.find_path(src_image_template, asset_data, filepath=asset_path) - - if src_image_path: - self.copy_file(src_image_path, dst_image_path) - #print(f'Copy image from {src_image_path} to {dst_image_path}') - return - - #Store in a dict all asset_data that does not have preview - asset_data_names[name] = dict(asset_data, image_path=dst_image_path) - - - if not asset_data_names: - # No preview to generate - return - - #print('Making Preview for', asset_data_names) - - asset_names = list(asset_data_names.keys()) - assets = self.load_datablocks(asset_path, names=asset_names, link=True, type=data_type) - - for asset in assets: - if not asset: - continue - - asset_data = asset_data_names[asset.name] - image_path = asset_data['image_path'] - - if asset.preview: - print(f'Writing asset preview to {image_path}') - self.write_preview(asset.preview, image_path) - continue - - if data_type == 'COLLECTION': - - bpy.ops.object.collection_instance_add(name=asset.name) - - bpy.ops.view3d.camera_to_view_selected() - instance = vl.objects.active - - #scn.collection.children.link(asset) - - scn.render.filepath = str(image_path) - - print(f'Render asset {asset.name} to {image_path}') - bpy.ops.render.render(write_still=True) - - #instance.user_clear() - asset.user_clear() - - bpy.data.objects.remove(instance) - - bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - - - def generate_previews(self, cache=None): - - print('Generate previews') - - if cache in (None, ''): - cache = self.fetch() - elif isinstance(cache, (Path, str)): - cache = self.read_cache(cache) - - #cache_diff.sort(key=lambda x :x['filepath']) - #blend_groups = groupby(cache_diff, key=lambda x :x['filepath']) - - #TODO Support all multiple data_type - for asset_description in cache: - - if asset_description.get('type', self.data_type) == 'FILE': - self.generate_blend_preview(asset_description) - else: - self.generate_asset_preview(asset_description) - - # filepath = asset_description['filepath'] - - # asset_datas = asset_description["assets"] - - # asset_datas.sort(key=lambda x :x.get('type', self.data_type)) - # data_type_groups = groupby(asset_datas, key=lambda x :x.get('type', self.data_type)) - - # for data_type, same_type_asset_datas in data_type_groups: - - # asset_names = [a['name'] for a in same_type_asset_datas] - # self.generate_preview(filepath, asset_names, data_type) - - def set_asset_preview(self, asset, asset_data): - '''Load an externalize image as preview for an asset''' - - asset_path = self.format_path(asset_data['filepath']) - - image_template = asset_data.get('image') - if self.library.template_image: - asset_path = self.get_asset_bundle_path(asset_data) - image_template = self.library.template_image - - image_path = self.find_path(image_template, asset_data, filepath=asset_path) - - if image_path: - #print(f'Set asset preview for {image_path} for {asset}') - with bpy.context.temp_override(id=asset): - bpy.ops.ed.lib_id_load_custom_preview( - filepath=str(image_path) - ) - - if asset.preview: - return asset.preview - - 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""" - - if 'tags' in asset_data: - for tag in asset.asset_data.tags[:]: - asset.asset_data.tags.remove(tag) - - for tag in asset_data['tags']: - if not tag: - continue - asset.asset_data.tags.new(tag, skip_if_exists=True) - - def set_asset_info(self, asset, asset_data): - """Set asset description base on provided data""" - - for key in ('author', 'description'): - if key in asset_data: - setattr(asset.asset_data, key, asset_data.get(key) or '') - - def get_asset_bundle_path(self, asset_data): - - catalog_parts = asset_data['catalog'].split('/') + [asset_data['name']] - - sub_path = catalog_parts[:self.library.blend_depth] - - blend_name = sub_path[-1].replace(' ', '_').lower() - return Path(self.bundle_directory, *sub_path, blend_name).with_suffix('.blend') - - 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 - - catalog_data = self.read_catalog() #TODO remove unused catalog - - write_cache = False - if not cache_diff: - # Get list of all modifications - asset_descriptions = self.fetch() - - - cache, cache_diff = self.diff(asset_descriptions) - - # Only write complete cache at the end - write_cache = True - - #self.generate_previews(asset_descriptions) - self.write_cache(asset_descriptions, self.tmp_cache_file) - bpy.ops.assetlib.generate_previews(name=self.library.name, cache=str(self.tmp_cache_file)) - - #print() - #print(cache) - #raise Exception() - - elif isinstance(cache_diff, (Path, str)): - cache_diff = json.loads(Path(cache_diff).read_text(encoding='utf-8')) - - - if self.library.blend_depth == 0: - raise Exception('Blender depth must be 1 at min') - #groups = [(cache_diff)] - else: - cache_diff.sort(key=self.get_asset_bundle_path) - groups = groupby(cache_diff, key=self.get_asset_bundle_path) - - total_assets = len(cache_diff) - print(f'total_assets={total_assets}') - - if total_assets == 0: - print('No assets found') - return - - #data_types = self.data_types - #if self.data_types == 'FILE' - - i = 0 - #assets_to_preview = [] - for blend_path, asset_datas in groups: - #blend_name = sub_path[-1].replace(' ', '_').lower() - #blend_path = Path(self.bundle_directory, *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") - print(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 not asset_preview: - # assets_to_preview.append((asset_data['filepath'], asset_data['name'], asset_data['data_type'])) - #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) - self.set_asset_info(asset, asset_data) - - - i += 1 - - #self.write_asset_preview_file() - - 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(asset_descriptions) - - 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""" - - if not cache or not isinstance(cache[0], dict): - return [] - - 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, asset_descriptions=None): - """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 = [] - - asset_descriptions = asset_descriptions or self.fetch() - - #print('\n-------------------------', cache) - - 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(asset_descriptions)} - - 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)) - \ No newline at end of file + return {p: getattr(self, p) for p in self.bl_rna.properties.keys() if p !='rna_type'} \ No newline at end of file diff --git a/adapters/kitsu.py b/adapters/kitsu.py deleted file mode 100644 index a3f2f68..0000000 --- a/adapters/kitsu.py +++ /dev/null @@ -1,153 +0,0 @@ - -""" -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 - - -class KitsuLibrary(AssetLibraryAdapter): - - name = "Kitsu" - template_name : StringProperty() - template_file : StringProperty() - source_directory : StringProperty(subtype='DIR_PATH') - #blend_depth: IntProperty(default=1) - - 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''' - - gazu = install_module('gazu') - 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, asset_path): - - modified = time.time_ns() - catalog = data['entity_type_name'] - asset_path = self.prop_rel_path(asset_path, 'source_directory') - #asset_name = self.norm_file_name(data['name']) - - asset_description = dict( - filepath=asset_path, - modified=modified, - library_id=self.library.id, - assets=[dict( - catalog=catalog, - metadata=data.get('data', {}), - description=data['description'], - tags=[], - type=self.data_type, - image=self.library.template_image, - video=self.library.template_video, - 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 fetch(self): - """Gather in a list all assets found in the folder""" - - print(f'Fetch Assets for {self.library.name}') - - gazu = install_module('gazu') - 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} - - asset_descriptions = [] - 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(asset_name=asset_name, type=asset_data['entity_type_name'], source_directory=self.source_directory) - - 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) - - # TODO group when multiple asset are store in the same blend - asset_descriptions.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) - - #asset_descriptions.append(asset_description) - - #print(assets) - # for k, v in assets[0].items(): - # print(f'- {k} {v}') - - #print('+++++++++++++') - #print(asset_descriptions) - - return asset_descriptions diff --git a/adapters/scan_folder.py b/adapters/scan_folder.py deleted file mode 100644 index 2bc5ec4..0000000 --- a/adapters/scan_folder.py +++ /dev/null @@ -1,165 +0,0 @@ - -""" -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 -import time - - -class ScanFolderLibrary(AssetLibraryAdapter): - - name = "Scan Folder" - source_directory : StringProperty(subtype='DIR_PATH') - template_file : StringProperty() - template_image : StringProperty() - template_video : StringProperty() - template_description : StringProperty() - - def get_asset_path(self, name, catalog, directory=None): - directory = directory or self.source_directory - catalog = self.norm_file_name(catalog) - name = self.norm_file_name(name) - - return Path(directory, self.get_asset_relative_path(name, catalog)) - - def get_image_path(self, name, catalog, filepath): - catalog = self.norm_file_name(catalog) - name = self.norm_file_name(name) - return self.format_path(self.template_image, dict(name=name, catalog=catalog, filepath=filepath)) - - def get_video_path(self, name, catalog, filepath): - catalog = self.norm_file_name(catalog) - name = self.norm_file_name(name) - return self.format_path(self.template_video, dict(name=name, catalog=catalog, filepath=filepath)) - - def format_asset_description(self, asset_description, asset_path): - - asset_path = self.prop_rel_path(asset_path, 'source_directory') - modified = asset_description.get('modified', time.time_ns()) - - if self.data_type == 'FILE': - return dict( - filepath=asset_path, - author=asset_description.get('author'), - modified=modified, - catalog=asset_description['catalog'], - tags=[], - description=asset_description.get('description', ''), - type=self.data_type, - image=self.template_image, - name=asset_description['name'] - ) - - return dict( - filepath=asset_path, - modified=modified, - library_id=self.library.id, - assets=[dict( - catalog=asset_data.get('catalog', asset_description['catalog']), - author=asset_data.get('author'), - metadata=asset_data.get('metadata', {}), - description=asset_data.get('description', ''), - tags=asset_data.get('tags', []), - type=self.data_type, - image=self.template_image, - video=self.template_video, - name=asset_data['name']) for asset_data in asset_description['assets'] - ] - ) - - def fetch(self): - """Gather in a list all assets found in the folder""" - - print(f'Fetch Assets for {self.library.name}') - - source_directory = Path(self.source_directory) - template_file = Template(self.template_file) - catalog_data = self.read_catalog(directory=source_directory) - catalog_ids = {v['id']: k for k, v in catalog_data.items()} - - cache = self.read_cache() or [] - - print(f'Search for blend using glob template: {template_file.glob_pattern}') - print(f'Scanning Folder {source_directory}...') - - new_cache = [] - - for asset_path in template_file.glob(source_directory):#sorted(blend_files): - - source_rel_path = self.prop_rel_path(asset_path, 'source_directory') - modified = asset_path.stat().st_mtime_ns - - # Check if the asset description as already been cached - asset_description = next((a for a in cache if a['filepath'] == source_rel_path), None) - - if asset_description and asset_description['modified'] >= modified: - print(asset_path, 'is skipped because not modified') - new_cache.append(asset_description) - continue - - rel_path = asset_path.relative_to(source_directory).as_posix() - field_data = template_file.parse(rel_path) - - catalogs = [v for k,v in sorted(field_data.items()) if k.isdigit()] - #catalogs = [c.replace('_', ' ').title() for c in catalogs] - - asset_name = field_data.get('asset_name', asset_path.stem) - - asset_description = { - "name": asset_name, - "catalog": '/'.join(catalogs), - "assets": [], - 'modified': modified - } - - if self.data_type == 'FILE': - asset_description = self.format_asset_description(asset_description, asset_path) - new_cache.append(asset_description) - continue - - # Now check if there is a asset description file - asset_description_path = self.find_path(self.template_description, asset_description, filepath=asset_path) - if asset_description_path: - new_cache.append(self.read_file(asset_description_path)) - continue - - # Scan the blend file for assets inside and write a custom asset description for info found - print(f'Scanning blendfile {asset_path}...') - assets = self.load_datablocks(asset_path, type=self.data_types, link=True, assets_only=True) - print(f'Found {len(assets)} {self.data_types} inside') - - for asset in assets: - #catalog_path = catalog_ids.get(asset.asset_data.catalog_id) - - #if not catalog_path: - # print(f'No catalog found for asset {asset.name}') - #catalog_path = asset_description['catalog']#asset_path.relative_to(self.source_directory).as_posix() - - # For now the catalog used is the one extract from the template file - asset_description['assets'].append(self.get_asset_data(asset)) - - getattr(bpy.data, self.data_types).remove(asset) - - asset_description = self.format_asset_description(asset_description, asset_path) - - new_cache.append(asset_description) - - - new_cache.sort(key=lambda x:x['filepath']) - - return new_cache - diff --git a/collection/operators.py b/collection/operators.py index bf5767b..0f5de03 100644 --- a/collection/operators.py +++ b/collection/operators.py @@ -50,9 +50,9 @@ class ASSETLIB_OT_load_asset(Operator): self.report({"ERROR"}, 'No asset selected') return {'CANCELLED'} - active_lib = lib.adapter.get_active_asset_library() + active_lib = lib.library_type.get_active_asset_library() asset_path = asset.asset_data['filepath'] - asset_path = active_lib.adapter.format_path(asset_path) + asset_path = active_lib.library_type.format_path(asset_path) name = asset.name ## set mode to object diff --git a/collection/preview.blend b/collection/preview.blend index a92321bead56b36911049850592e2bca7c608921..fd75cdf922ecbec4a8d5e10a6d6017fa37946632 100644 GIT binary patch delta 131203 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 h<9|D;ra7BKXj_poxKc$uRZOh{{v08-J<{i delta 97018 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

!YxR4hq`!QP8fBLOdK4wCkgwT^|*5^c)nl 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 diff --git a/common/bl_utils.py b/common/bl_utils.py index 4008441..58d70a9 100644 --- a/common/bl_utils.py +++ b/common/bl_utils.py @@ -28,6 +28,10 @@ class attr_set(): for item in attrib_list: prop, attr = item[:2] self.store.append( (prop, attr, getattr(prop, attr)) ) + + for item in attrib_list: + prop, attr = item[:2] + if len(item) >= 3: try: setattr(prop, attr, item[2]) @@ -38,6 +42,9 @@ class attr_set(): return self def __exit__(self, exc_type, exc_value, exc_traceback): + self.restore() + + def restore(self): for prop, attr, old_val in self.store: setattr(prop, attr, old_val) @@ -55,6 +62,15 @@ def get_view3d_persp(): 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 get_viewport(): + screen = bpy.context.screen + + areas = [a for a in screen.areas if a.type == 'VIEW_3D'] + areas.sort(key=lambda x : x.width*x.height) + + return areas[-1] + + def biggest_asset_browser_area(screen: bpy.types.Screen) -> Optional[bpy.types.Area]: """Return the asset browser Area that's largest on screen. diff --git a/common/functions.py b/common/functions.py index 6b9cc1b..bdff6a9 100644 --- a/common/functions.py +++ b/common/functions.py @@ -52,14 +52,14 @@ def asset_warning_callback(self, context): return lib = get_active_library() - action_path = lib.adapter.get_asset_relative_path(self.name, self.catalog) + action_path = lib.library_type.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(): + if not lib.library_type.get_asset_path(self.name, self.catalog).parents[1].exists(): self.warning = 'A new folder will be created' def get_active_library(): @@ -76,7 +76,7 @@ def get_active_catalog(): '''Get the active catalog path''' lib = get_active_library() - cat_data = lib.adapter.read_catalog() + cat_data = lib.library_type.read_catalog() cat_data = {v['id']:k for k,v in cat_data.items()} cat_id = bpy.context.space_data.params.catalog_id diff --git a/common/template.py b/common/template.py index 36b1997..59cdce4 100644 --- a/common/template.py +++ b/common/template.py @@ -3,8 +3,28 @@ import os from pathlib import Path from fnmatch import fnmatch from glob import glob +import string +class TemplateFormatter(string.Formatter): + def format_field(self, value, format_spec): + if isinstance(value, str): + spec, sep = [*format_spec.split(':'), None][:2] + + if sep: + value = value.replace('_', ' ') + value = value = re.sub(r'([a-z])([A-Z])', rf'\1{sep}\2', value) + value = value.replace(' ', sep) + + if spec == 'u': + value = value.upper() + elif spec == 'l': + value = value.lower() + elif spec == 't': + value = value.title() + + return super().format(value, format_spec) + class Template: field_pattern = re.compile(r'{(\w+)\*{0,2}}') field_pattern_recursive = re.compile(r'{(\w+)\*{2}}') @@ -13,6 +33,7 @@ class Template: #asset_data_path = Path(lib_path) / ASSETLIB_FILENAME self.raw = template + self.formatter = TemplateFormatter() @property def glob_pattern(self): @@ -52,12 +73,24 @@ class Template: return {k:v for k,v in zip(fields, field_values)} + def norm_data(self, data): + norm_data = {} + for k, v in data.items(): + + if isinstance(v, Path): + v = v.as_posix() + + norm_data[k] = v + + return norm_data + def format(self, data=None, **kargs): data = {**(data or {}), **kargs} try: - path = self.raw.format(**data) + #print('FORMAT', self.raw, data) + path = self.formatter.format(self.raw, **self.norm_data(data)) except KeyError as e: print(f'Cannot format {self.raw} with {data}, field {e} is missing') return @@ -88,7 +121,7 @@ class Template: if paths: return Path(paths[0]) - return pattern + #return pattern def __repr__(self): return f'Template({self.raw})' \ No newline at end of file diff --git a/constants.py b/constants.py index df7b508..afc57c2 100644 --- a/constants.py +++ b/constants.py @@ -1,4 +1,5 @@ from pathlib import Path +import bpy DATA_TYPE_ITEMS = [ @@ -12,6 +13,14 @@ ICONS = {identifier: icon for identifier, name, description, icon, number in DAT ASSETLIB_FILENAME = "blender_assets.libs.json" MODULE_DIR = Path(__file__).parent RESOURCES_DIR = MODULE_DIR / 'resources' + +LIBRARY_TYPE_DIR = MODULE_DIR / 'library_types' +LIBRARY_TYPES = [] + ADAPTER_DIR = MODULE_DIR / 'adapters' ADAPTERS = [] + PREVIEW_ASSETS_SCRIPT = MODULE_DIR / 'common' / 'preview_assets.py' + +#ADD_ASSET_DICT = {} + diff --git a/file/bundle.py b/file/bundle.py index 2e5e440..56d8dbc 100644 --- a/file/bundle.py +++ b/file/bundle.py @@ -17,7 +17,7 @@ command, write_catalog) @command -def bundle_library(source_directory, bundle_directory, template_description, thumbnail_template, +def bundle_library(source_directory, bundle_directory, template_info, thumbnail_template, template=None, data_file=None): field_pattern = r'{(\w+)}' @@ -38,9 +38,9 @@ def bundle_library(source_directory, bundle_directory, template_description, thu name = field_data.get('name', f.stem) thumbnail = (f / thumbnail_template.format(name=name)).resolve() - asset_data = (f / template_description.format(name=name)).resolve() + asset_data = (f / template_info.format(name=name)).resolve() - catalogs = sorted([v for k,v in sorted(field_data.items()) if k.isdigit()]) + catalogs = sorted([v for k,v in sorted(field_data.items()) if re.findall('cat[0-9]+', k)]) catalogs = [c.replace('_', ' ').title() for c in catalogs] if not thumbnail.exists(): @@ -163,7 +163,7 @@ if __name__ == '__main__' : bundle_library( source_directory=args.source_directory, bundle_directory=args.bundle_directory, - template_description=args.template_description, + template_info=args.template_info, thumbnail_template=args.thumbnail_template, template=args.template, data_file=args.data_file) diff --git a/file/gui.py b/file/gui.py index 4a664eb..27b00ac 100644 --- a/file/gui.py +++ b/file/gui.py @@ -21,7 +21,7 @@ def draw_context_menu(layout): #asset = context.active_file layout.operator_context = "INVOKE_DEFAULT" lib = get_active_library() - filepath = lib.adapter.get_active_asset_path() + filepath = lib.library_type.get_active_asset_path() layout.operator("assetlib.open_blend_file", text="Open Blend File")#.filepath = asset.asset_data['filepath'] op = layout.operator("wm.link", text="Link") diff --git a/file/operators.py b/file/operators.py index 582b4d1..1acff6f 100644 --- a/file/operators.py +++ b/file/operators.py @@ -37,7 +37,7 @@ class ASSETLIB_OT_open_blend_file(Operator): lib = get_active_library() - filepath = lib.get_active_asset_path() + filepath = lib.library_type.get_active_asset_path() open_blender_file(filepath) diff --git a/gui.py b/gui.py index 2da8be4..3d81c31 100644 --- a/gui.py +++ b/gui.py @@ -176,7 +176,7 @@ class ASSETLIB_MT_context_menu(AssetLibraryMenu, Menu): def draw(self, context): lib = get_active_library() - lib.adapter.draw_context_menu(self.layout) + lib.library_type.draw_context_menu(self.layout) def is_option_region_visible(context, space): @@ -214,7 +214,7 @@ def draw_assetbrowser_header(self, context): #op.clean = False #op.only_recent = True - lib.adapter.draw_header(row) + lib.library_type.draw_header(row) if context.selected_files and context.active_file: row.separator() diff --git a/library_types/__init__.py b/library_types/__init__.py new file mode 100644 index 0000000..9edfe9b --- /dev/null +++ b/library_types/__init__.py @@ -0,0 +1,17 @@ + +from asset_library.library_types import library_type +from asset_library.library_types import copy_folder +from asset_library.library_types import scan_folder + +if 'bpy' in locals(): + import importlib + + importlib.reload(library_type) + importlib.reload(copy_folder) + importlib.reload(scan_folder) + +import bpy + +LibraryType = library_type.LibraryType +CopyFolder = copy_folder.CopyFolder +ScanFolder = scan_folder.ScanFolder diff --git a/library_types/conform.py b/library_types/conform.py new file mode 100644 index 0000000..0f576c2 --- /dev/null +++ b/library_types/conform.py @@ -0,0 +1,212 @@ + +""" +Plugin for making an asset library of all blender file found in a folder +""" + + +from asset_library.library_types.scan_folder import ScanFolder +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 +import time +from pprint import pprint + + +class Conform(ScanFolder): + + name = "Conform" + source_directory : StringProperty(subtype='DIR_PATH') + + target_template_file : StringProperty() + target_template_info : StringProperty() + target_template_image : StringProperty() + target_template_video : StringProperty() + + def draw_prefs(self, layout): + layout.prop(self, "source_directory", text="Source : Directory") + + col = layout.column(align=True) + col.prop(self, "source_template_file", icon='COPY_ID', text='Template file') + col.prop(self, "source_template_image", icon='COPY_ID', text='Template image') + col.prop(self, "source_template_video", icon='COPY_ID', text='Template video') + col.prop(self, "source_template_info", icon='COPY_ID', text='Template info') + + col = layout.column(align=True) + col.prop(self, "target_template_file", icon='COPY_ID', text='Target : Template file') + col.prop(self, "target_template_image", icon='COPY_ID', text='Template image') + col.prop(self, "target_template_video", icon='COPY_ID', text='Template video') + col.prop(self, "target_template_info", icon='COPY_ID', text='Template info') + + def get_asset_bundle_path(self, asset_data): + """Template file are relative""" + + src_directory = Path(self.source_directory).resolve() + src_template_file = Template(self.source_template_file) + + asset_path = Path(asset_data['filepath']).as_posix() + asset_path = self.format_path(asset_path) + + rel_path = asset_path.relative_to(src_directory).as_posix() + field_data = src_template_file.parse(rel_path) + #field_data = {f"catalog_{k}": v for k, v in field_data.items()} + + # Change the int in the template by string to allow format + #target_template_file = re.sub(r'{(\d+)}', r'{cat\1}', self.target_template_file) + + format_data = self.format_asset_data(asset_data) + #format_data['asset_name'] = format_data['asset_name'].lower().replace(' ', '_') + + path = Template(self.target_template_file).format(format_data, **field_data).with_suffix('.blend') + path = Path(self.bundle_directory, path).resolve() + + return path + + def set_asset_preview(self, asset, asset_data): + '''Load an externalize image as preview for an asset using the target template''' + + image_template = self.target_template_image + if not image_template: + return + + asset_path = self.get_asset_bundle_path(asset_data) + image_path = self.find_path(image_template, asset_data, filepath=asset_path) + + if image_path: + with bpy.context.temp_override(id=asset): + bpy.ops.ed.lib_id_load_custom_preview( + filepath=str(image_path) + ) + else: + print(f'No image found for {image_template} on {asset.name}') + + if asset.preview: + return asset.preview + + def generate_previews(self, cache=None): + + print('Generate previews...') + + if cache in (None, ''): + cache = self.fetch() + elif isinstance(cache, (Path, str)): + cache = self.read_cache(cache) + + + #TODO Support all multiple data_type + for asset_info in cache: + + if asset_info.get('type', self.data_type) == 'FILE': + self.generate_blend_preview(asset_info) + else: + self.generate_asset_preview(asset_info) + + def generate_asset_preview(self, asset_info): + """Only generate preview when conforming a library""" + + #print('\ngenerate_preview', asset_info['filepath']) + + scn = bpy.context.scene + vl = bpy.context.view_layer + #Creating the preview for collection, object or material + #camera = scn.camera + + data_type = self.data_type #asset_info['data_type'] + asset_path = self.format_path(asset_info['filepath']) + + # Check if a source video exists and if so copying it in the new directory + if self.source_template_video and self.target_template_video: + for asset_data in asset_info['assets']: + asset_data = dict(asset_data, filepath=asset_path) + + dst_asset_path = self.get_asset_bundle_path(asset_data) + dst_video_path = self.format_path(self.target_template_video, asset_data, filepath=dst_asset_path) + if dst_video_path.exists(): + print(f'The dest video {dst_video_path} already exist') + continue + + src_video_path = self.find_path(self.source_template_video, asset_data) + if src_video_path: + print(f'Copy video from {src_video_path} to {dst_video_path}') + self.copy_file(src_video_path, dst_video_path) + + # Check if asset as a preview image or need it to be generated + asset_data_names = {} + + if self.target_template_image: + for asset_data in asset_info['assets']: + asset_data = dict(asset_data, filepath=asset_path) + name = asset_data['name'] + dst_asset_path = self.get_asset_bundle_path(asset_data) + + dst_image_path = self.format_path(self.target_template_image, asset_data, filepath=dst_asset_path) + if dst_image_path.exists(): + print(f'The dest image {dst_image_path} already exist') + continue + + # Check if a source image exists and if so copying it in the new directory + if self.source_template_image: + src_image_path = self.find_path(self.source_template_image, asset_data) + + if src_image_path: + if src_image_path.suffix == dst_image_path.suffix: + self.copy_file(src_image_path, dst_image_path) + else: + + print(src_image_path) + self.save_image(src_image_path, dst_image_path, remove=True) + + continue + + #Store in a dict all asset_data that does not have preview + asset_data_names[name] = dict(asset_data, image_path=dst_image_path) + + + if not asset_data_names:# No preview to generate + return + + print('Making Preview for', list(asset_data_names.keys())) + + asset_names = list(asset_data_names.keys()) + assets = self.load_datablocks(asset_path, names=asset_names, link=True, type=data_type) + + for asset in assets: + if not asset: + continue + + asset_data = asset_data_names[asset.name] + image_path = asset_data['image_path'] + + if asset.preview: + print(f'Writing asset preview to {image_path}') + self.write_preview(asset.preview, image_path) + continue + + if data_type == 'COLLECTION': + + bpy.ops.object.collection_instance_add(name=asset.name) + + bpy.ops.view3d.camera_to_view_selected() + instance = vl.objects.active + + #scn.collection.children.link(asset) + + scn.render.filepath = str(image_path) + + print(f'Render asset {asset.name} to {image_path}') + bpy.ops.render.render(write_still=True) + + #instance.user_clear() + asset.user_clear() + + bpy.data.objects.remove(instance) + + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) \ No newline at end of file diff --git a/adapters/copy_folder.py b/library_types/copy_folder.py similarity index 75% rename from adapters/copy_folder.py rename to library_types/copy_folder.py index 115820d..c016fb0 100644 --- a/adapters/copy_folder.py +++ b/library_types/copy_folder.py @@ -4,14 +4,14 @@ Adapter for making an asset library of all blender file found in a folder """ -from asset_library.adapters.adapter import AssetLibraryAdapter +from asset_library.library_types.library_type import LibraryType from asset_library.common.file_utils import copy_dir from bpy.props import StringProperty from os.path import expandvars import bpy -class CopyFolderLibrary(AssetLibraryAdapter): +class CopyFolder(LibraryType): """Copy library folder from a server to a local disk for better performance""" name = "Copy Folder" @@ -34,13 +34,13 @@ class CopyFolderLibrary(AssetLibraryAdapter): ) def filter_prop(self, prop): - if prop in ('template_description', 'template_video', 'template_image', 'blend_depth'): + if prop in ('template_info', 'template_video', 'template_image', 'blend_depth'): return False return True # def draw_prop(self, layout, prop): - # if prop in ('template_description', 'template_video', 'template_image', 'blend_depth'): + # if prop in ('template_info', 'template_video', 'template_image', 'blend_depth'): # return # super().draw_prop(layout) \ No newline at end of file diff --git a/adapters/data_file.py b/library_types/data_file.py similarity index 100% rename from adapters/data_file.py rename to library_types/data_file.py diff --git a/library_types/kitsu.py b/library_types/kitsu.py new file mode 100644 index 0000000..6420942 --- /dev/null +++ b/library_types/kitsu.py @@ -0,0 +1,275 @@ + +""" +Plugin for making an asset library of all blender file found in a folder +""" + + +from asset_library.library_types.library_type import LibraryType +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 + + +class Kitsu(LibraryType): + + name = "Kitsu" + template_name : StringProperty() + template_file : StringProperty() + source_directory : StringProperty(subtype='DIR_PATH') + #blend_depth: IntProperty(default=1) + source_template_image : StringProperty() + target_template_image : 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''' + + gazu = install_module('gazu') + 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_info(self, data, asset_path): + + modified = time.time_ns() + catalog = data['entity_type_name'].title() + asset_path = self.prop_rel_path(asset_path, 'source_directory') + #asset_name = self.norm_file_name(data['name']) + + asset_info = dict( + filepath=asset_path, + modified=modified, + library_id=self.library.id, + assets=[dict( + catalog=catalog, + metadata=data.get('data', {}), + description=data['description'], + tags=[], + type=self.data_type, + #image=self.library.template_image, + #video=self.library.template_video, + name=data['name']) + ] + ) + + return asset_info + + # 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 set_asset_preview(self, asset, asset_data): + '''Load an externalize image as preview for an asset using the source template''' + + asset_path = self.format_path(Path(asset_data['filepath']).as_posix()) + + image_path = self.find_path(self.target_template_image, asset_data, filepath=asset_path) + + if image_path: + with bpy.context.temp_override(id=asset): + bpy.ops.ed.lib_id_load_custom_preview( + filepath=str(image_path) + ) + else: + print(f'No image found for {self.target_template_image} on {asset.name}') + + if asset.preview: + return asset.preview + + + def generate_previews(self, cache=None): + + print('Generate previews...') + + if cache in (None, ''): + cache = self.fetch() + elif isinstance(cache, (Path, str)): + cache = self.read_cache(cache) + + #TODO Support all multiple data_type + for asset_info in cache: + + if asset_info.get('type', self.data_type) == 'FILE': + self.generate_blend_preview(asset_info) + else: + self.generate_asset_preview(asset_info) + + def generate_asset_preview(self, asset_info): + + data_type = self.data_type + scn = bpy.context.scene + vl = bpy.context.view_layer + + asset_path = self.format_path(asset_info['filepath']) + + lens = 85 + + if not asset_path.exists(): + print(f'Blend file {asset_path} not exit') + return + + + asset_data_names = {} + + # First check wich assets need a preview + for asset_data in asset_info['assets']: + name = asset_data['name'] + image_path = self.format_path(self.target_template_image, asset_data, filepath=asset_path) + + if image_path.exists(): + continue + + #Store in a dict all asset_data that does not have preview + asset_data_names[name] = dict(asset_data, image_path=image_path) + + if not asset_data_names: + print(f'All previews already existing for {asset_path}') + return + + #asset_names = [a['name'] for a in asset_info['assets']] + asset_names = list(asset_data_names.keys()) + assets = self.load_datablocks(asset_path, names=asset_names, link=True, type=data_type) + + print(asset_names) + print(assets) + + for asset in assets: + if not asset: + continue + + print(f'Generate Preview for asset {asset.name}') + + asset_data = asset_data_names[asset.name] + + #print(self.target_template_image, asset_path) + image_path = self.format_path(self.target_template_image, asset_data, filepath=asset_path) + + # Force redo preview + # if asset.preview: + # print(f'Writing asset preview to {image_path}') + # self.write_preview(asset.preview, image_path) + # continue + + if data_type == 'COLLECTION': + + bpy.ops.object.collection_instance_add(name=asset.name) + + scn.camera.data.lens = lens + bpy.ops.view3d.camera_to_view_selected() + scn.camera.data.lens -= 5 + + instance = vl.objects.active + + #scn.collection.children.link(asset) + + scn.render.filepath = str(image_path) + scn.render.image_settings.file_format = self.format_from_ext(image_path.suffix) + scn.render.image_settings.color_mode = 'RGBA' + scn.render.image_settings.quality = 90 + + + print(f'Render asset {asset.name} to {image_path}') + bpy.ops.render.render(write_still=True) + + #instance.user_clear() + asset.user_clear() + + bpy.data.objects.remove(instance) + + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + + def fetch(self): + """Gather in a list all assets found in the folder""" + + print(f'Fetch Assets for {self.library.name}') + + gazu = install_module('gazu') + 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} + + asset_infos = [] + 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(asset_name=asset_name, type=asset_data['entity_type_name'], source_directory=self.source_directory) + + 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) + + # TODO group when multiple asset are store in the same blend + asset_infos.append(self.get_asset_info(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_info = self.get_asset_info(asset) + + #asset_infos.append(asset_info) + + #print(assets) + # for k, v in assets[0].items(): + # print(f'- {k} {v}') + + #print('+++++++++++++') + #print(asset_infos) + + return asset_infos diff --git a/library_types/library_type.py b/library_types/library_type.py new file mode 100644 index 0000000..491def2 --- /dev/null +++ b/library_types/library_type.py @@ -0,0 +1,799 @@ + +#from asset_library.common.functions import (norm_asset_datas,) +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.constants import (MODULE_DIR, RESOURCES_DIR) + +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 +import time +from functools import partial +import subprocess +from glob import glob +from copy import deepcopy + + +class LibraryType(PropertyGroup): + + #def __init__(self): + name = "Base Adapter" + #library = None + + @property + def library(self): + prefs = self.addon_prefs + for lib in prefs.libraries: + if lib.library_type == self: + return lib + + @property + def bundle_directory(self): + return self.library.library_path + + @property + def data_type(self): + return self.library.data_type + + @property + def data_types(self): + return self.library.data_types + + def get_catalog_path(self, directory=None): + directory = directory or self.bundle_directory + return Path(directory, 'blender_assets.cats.txt') + + @property + def cache_file(self): + return Path(self.bundle_directory) / f"blender_assets.{self.library.id}.json" + + @property + def tmp_cache_file(self): + return Path(bpy.app.tempdir) / f"blender_assets.{self.library.id}.json" + + @property + def diff_file(self): + return Path(bpy.app.tempdir, 'diff.json') + + @property + def preview_blend(self): + return MODULE_DIR / self.data_type.lower() / "preview.blend" + + @property + def preview_assets_file(self): + return Path(bpy.app.tempdir, "preview_assets_file.json") + + @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'} + + @property + def format_data(self): + """Dict for formating template""" + return dict(self.to_dict(), bundle_dir=self.library.bundle_dir, parent=self.library.parent) + + def fetch(self): + raise Exception('This method need to be define in the library_type') + + def norm_file_name(self, name): + return name.replace(' ', '_') + + def read_file(self, file): + return read_file(file) + + def write_file(self, file, data): + return write_file(file, data) + + def copy_file(self, source, destination): + src = Path(source) + dst = Path(destination) + + if not src.exists(): + print(f'Cannot copy file {src}: file not exist') + return + + dst.parent.mkdir(exist_ok=True, parents=True) + + if src == dst: + print(f'Cannot copy file {src}: source and destination are the same') + return + + print(f'Copy file from {src} to {dst}') + shutil.copy2(str(src), str(dst)) + + def load_datablocks(self, src, names=None, type='objects', link=True, expr=None, assets_only=False): + """Link or append a datablock from a blendfile""" + + if type.isupper(): + type = f'{type.lower()}s' + + return load_datablocks(src, names=names, type=type, link=link, expr=expr, assets_only=assets_only) + + def get_asset_data(self, asset): + """Extract asset information on a datablock""" + + return dict( + name=asset.name, + author=asset.asset_data.author, + tags=list(asset.asset_data.tags.keys()), + metadata=dict(asset.asset_data), + description=asset.asset_data.description, + ) + + 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_active_asset_library(self): + asset_handle = bpy.context.asset_file_handle + prefs = get_addon_prefs() + asset_handle = bpy.context.asset_file_handle + + if not asset_handle: + return self + + 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.library_type.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 generate_previews(self): + raise Exception('Need to be defined in the library_type') + + def get_image_path(self, name, catalog, filepath): + raise Exception('Need to be defined in the library_type') + + def get_video_path(self, name, catalog, filepath): + raise Exception('Need to be defined in the library_type') + + def new_asset(self, asset, asset_data): + raise Exception('Need to be defined in the library_type') + + def remove_asset(self, asset, asset_data): + raise Exception('Need to be defined in the library_type') + + def set_asset_preview(asset, asset_data): + raise Exception('Need to be defined in the library_type') + + def format_asset_data(self, data): + """Get a dict for use in template fields""" + return { + 'asset_name': data['name'], + 'asset_path': Path(data['filepath']), + 'catalog': data['catalog'], + 'catalog_name': data['catalog'].replace('/', '_'), + } + + def format_path(self, template, data={}, **kargs): + if not template: + return None + + if data: + data = self.format_asset_data(dict(data, **kargs)) + else: + data = kargs + + if template.startswith('.'): #the template is relative + template = Path(data['asset_path'], template).as_posix() + + params = dict( + **data, + **self.format_data, + ) + + return Template(template).format(params).resolve() + + def find_path(self, template, data, **kargs): + path = self.format_path(template, data, **kargs) + paths = glob(str(path)) + if paths: + return Path(paths[0]) + + def read_asset_info_file(self, asset_path) -> dict: + """Read the description file of the asset""" + + description_path = self.get_description_path(asset_path) + return self.read_file(description_path) + + def write_description_file(self, asset_data, asset_path) -> None: + description_path = self.get_description_path(asset_path) + return write_file(description_path, asset_data) + + def write_asset(self, asset, asset_path): + + Path(asset_path).parent.mkdir(exist_ok=True, parents=True) + + bpy.data.libraries.write( + str(asset_path), + {asset}, + path_remap="NONE", + fake_user=True, + compress=True + ) + + def read_catalog(self, directory=None): + """Read the catalog file of the library target directory or of the specified directory""" + catalog_path = self.get_catalog_path(directory) + + if not catalog_path.exists(): + return {} + + cat_data = {} + + for line in catalog_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(self, catalog_data, directory=None): + """Write the catalog file in the library target directory or of the specified directory""" + + catalog_path = self.get_catalog_path(directory) + + lines = ['VERSION 1', ''] + + # Add missing parents catalog + norm_data = {} + for cat_path, cat_data in catalog_data.items(): + norm_data[cat_path] = cat_data + for p in Path(cat_path).parents[:-1]: + if p in cat_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: {catalog_path}') + catalog_path.write_text('\n'.join(lines), encoding="utf-8") + + def read_cache(self, cache_path=None): + cache_path = cache_path or self.cache_file + print(f'Read cache from {cache_path}') + return self.read_file(cache_path) + + def write_cache(self, asset_infos, cache_path=None): + cache_path = cache_path or self.cache_file + print(f'cache file writen to {cache_path}') + return write_file(cache_path, list(asset_infos)) + + def prop_rel_path(self, path, prop): + '''Get a filepath relative to a property of the library_type''' + 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 format_from_ext(self, ext): + if ext.startswith('.'): + ext = ext[1:] + + file_format = ext.upper() + + if file_format == 'JPG': + file_format = 'JPEG' + elif file_format == 'EXR': + file_format = 'OPEN_EXR' + + return file_format + + def save_image(self, image, filepath, remove=False): + filepath = Path(filepath) + + if isinstance(image, (str, Path)): + image = bpy.data.images.load(str(image)) + image.update() + + image.filepath_raw = str(filepath) + file_format = self.format_from_ext(filepath.suffix) + + image.file_format = file_format + image.save() + + if remove: + bpy.data.images.remove(image) + else: + return image + + 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) + + self.save_image(img, filepath, remove=True) + + + 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""" + self.module_type.gui.draw_context_menu(layout) + + def generate_blend_preview(self, asset_info): + asset_name = asset_info['name'] + catalog = asset_info['catalog'] + + asset_path = self.format_path(asset_info['filepath']) + dst_image_path = self.get_image_path(asset_name, asset_path, catalog) + + if dst_image_path.exists(): + return + + # Check if a source image exists and if so copying it in the new directory + src_image_path = asset_info.get('image') + if src_image_path: + src_image_path = self.get_template_path(src_image_path, asset_name, asset_path, catalog) + if src_image_path and src_image_path.exists(): + self.copy_file(src_image_path, dst_image_path) + return + + print(f'Thumbnailing {asset_path} to {dst_image_path}') + blender_thumbnailer = Path(bpy.app.binary_path).parent / 'blender-thumbnailer' + + dst_image_path.parent.mkdir(exist_ok=True, parents=True) + + subprocess.call([blender_thumbnailer, str(asset_path), str(dst_image_path)]) + + success = dst_image_path.exists() + + if not success: + empty_preview = RESOURCES_DIR / 'empty_preview.png' + self.copy_file(str(empty_preview), str(dst_image_path)) + + return success + + ''' + def generate_asset_preview(self, asset_info): + """Only generate preview when conforming a library""" + + #print('\ngenerate_preview', asset_info['filepath']) + + scn = bpy.context.scene + #Creating the preview for collection, object or material + camera = scn.camera + vl = bpy.context.view_layer + + data_type = self.data_type #asset_info['data_type'] + asset_path = self.format_path(asset_info['filepath']) + + # Check if a source video exists and if so copying it in the new directory + if self.library.template_video: + for asset_data in asset_info['assets']: + dst_asset_path = self.get_asset_bundle_path(asset_data) + dst_video_path = self.format_path(self.library.template_video, asset_data, filepath=dst_asset_path) #Template(src_video_path).find(asset_data, asset_path=dst_asset_path, **self.format_data) + + if dst_video_path.exists(): + print(f'The dest video {dst_video_path} already exist') + continue + + src_video_template = asset_data.get('video') + if not src_video_template: + continue + + src_video_path = self.find_path(src_video_template, asset_data, filepath=asset_path)#Template(src_video_path).find(asset_data, asset_path=dst_asset_path, **self.format_data) + if src_video_path: + print(f'Copy video from {src_video_path} to {dst_video_path}') + self.copy_file(src_video_path, dst_video_path) + + # Check if asset as a preview image or need it to be generated + asset_data_names = {} + + if self.library.template_image: + for asset_data in asset_info['assets']: + name = asset_data['name'] + dst_asset_path = self.get_asset_bundle_path(asset_data) + + dst_image_path = self.format_path(self.library.template_image, asset_data, filepath=dst_asset_path) + if dst_image_path.exists(): + print(f'The dest image {dst_image_path} already exist') + continue + + # Check if a source image exists and if so copying it in the new directory + src_image_template = asset_data.get('image') + if src_image_template: + src_image_path = self.find_path(src_image_template, asset_data, filepath=asset_path) + + if src_image_path: + if src_image_path.suffix == dst_image_path.suffix: + self.copy_file(src_image_path, dst_image_path) + else: + #img = bpy.data.images.load(str(src_image_path)) + self.save_image(src_image_path, dst_image_path, remove=True) + + return + + #Store in a dict all asset_data that does not have preview + asset_data_names[name] = dict(asset_data, image_path=dst_image_path) + + + if not asset_data_names: + # No preview to generate + return + + print('Making Preview for', asset_data_names) + + asset_names = list(asset_data_names.keys()) + assets = self.load_datablocks(asset_path, names=asset_names, link=True, type=data_type) + + for asset in assets: + if not asset: + continue + + asset_data = asset_data_names[asset.name] + image_path = asset_data['image_path'] + + if asset.preview: + print(f'Writing asset preview to {image_path}') + self.write_preview(asset.preview, image_path) + continue + + if data_type == 'COLLECTION': + + bpy.ops.object.collection_instance_add(name=asset.name) + + bpy.ops.view3d.camera_to_view_selected() + instance = vl.objects.active + + #scn.collection.children.link(asset) + + scn.render.filepath = str(image_path) + + print(f'Render asset {asset.name} to {image_path}') + bpy.ops.render.render(write_still=True) + + #instance.user_clear() + asset.user_clear() + + bpy.data.objects.remove(instance) + + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + ''' + + 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""" + + if 'tags' in asset_data: + for tag in asset.asset_data.tags[:]: + asset.asset_data.tags.remove(tag) + + for tag in asset_data['tags']: + if not tag: + continue + asset.asset_data.tags.new(tag, skip_if_exists=True) + + def set_asset_info(self, asset, asset_data): + """Set asset description base on provided data""" + + for key in ('author', 'description'): + if key in asset_data: + setattr(asset.asset_data, key, asset_data.get(key) or '') + + def get_asset_bundle_path(self, asset_data): + + catalog_parts = asset_data['catalog'].split('/') + [asset_data['name']] + + sub_path = catalog_parts[:self.library.blend_depth] + + blend_name = sub_path[-1].replace(' ', '_').lower() + return Path(self.bundle_directory, *sub_path, blend_name).with_suffix('.blend') + + def bundle(self, cache_diff=None): + """Group all new assets in one or multiple blends for the asset browser""" + + supported_types = ('FILE', 'ACTION', 'COLLECTION') + supported_operations = ('ADD', 'REMOVE', 'MODIFY') + + if self.data_type not in supported_types: + print(f'{self.data_type} is not supported yet supported types are {supported_types}') + return + + catalog_data = self.read_catalog() #TODO remove unused catalog + + write_cache = False + if not cache_diff: + # Get list of all modifications + asset_infos = self.fetch() + + + cache, cache_diff = self.diff(asset_infos) + + # Only write complete cache at the end + write_cache = True + + #self.generate_previews(asset_infos) + self.write_cache(asset_infos, self.tmp_cache_file) + bpy.ops.assetlib.generate_previews(name=self.library.name, cache=str(self.tmp_cache_file)) + + #print() + #print(cache) + #raise Exception() + + elif isinstance(cache_diff, (Path, str)): + cache_diff = json.loads(Path(cache_diff).read_text(encoding='utf-8')) + + + if self.library.blend_depth == 0: + raise Exception('Blender depth must be 1 at min') + #groups = [(cache_diff)] + else: + cache_diff.sort(key=self.get_asset_bundle_path) + groups = groupby(cache_diff, key=self.get_asset_bundle_path) + + total_assets = len(cache_diff) + print(f'total_assets={total_assets}') + + if total_assets == 0: + print('No assets found') + return + + #data_types = self.data_types + #if self.data_types == 'FILE' + + i = 0 + #assets_to_preview = [] + for blend_path, asset_datas in groups: + #blend_name = sub_path[-1].replace(' ', '_').lower() + #blend_path = Path(self.bundle_directory, *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 not in supported_operations: + print(f'operation {operation} not supported, supported operations are {supported_operations}') + continue + + 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') + + if operation == 'ADD' or not asset: + if asset: + #raise Exception(f"Asset {asset_data['name']} Already in Blend") + print(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']) + + + asset.asset_mark() + + self.set_asset_preview(asset, asset_data) + + #if not asset_preview: + # assets_to_preview.append((asset_data['filepath'], asset_data['name'], asset_data['data_type'])) + #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) + self.set_asset_info(asset, asset_data) + + + i += 1 + + #self.write_asset_preview_file() + + 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(asset_infos) + + self.write_catalog(catalog_data) + + + bpy.ops.wm.quit_blender() + + def unflatten_cache(self, cache): + """ Return a new unflattten list of asset data + grouped by filepath""" + + new_cache = [] + + cache = deepcopy(cache) + + cache.sort(key=lambda x : x['filepath']) + groups = groupby(cache, key=lambda x : x['filepath']) + + keys = ['filepath', 'modified', 'library_id'] + + for _, asset_datas in groups: + asset_datas = list(asset_datas) + + #print(asset_datas[0]) + + asset_info = {k:asset_datas[0][k] for k in keys} + asset_info['assets'] = [{k:v for k, v in a.items() if k not in keys+['operation']} for a in asset_datas] + + new_cache.append(asset_info) + + return new_cache + + def flatten_cache(self, cache): + """ Return a new flat list of asset data + the filepath keys are merge with the assets keys""" + + # If the cache has a wrong format + if not cache or not isinstance(cache[0], dict): + return [] + + new_cache = [] + + for asset_info in cache: + asset_info = asset_info.copy() + if 'assets' in asset_info: + + assets = asset_info.pop('assets') + for asset_data in assets: + new_cache.append({**asset_info, **asset_data}) + else: + new_cache.append(asset_info) + + return new_cache + + def diff(self, asset_infos=None): + """Compare the library cache with it current state and return the new cache and 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 = [] + + asset_infos = asset_infos or self.fetch() + + cache = {f"{a['filepath']}/{a['name']}": a for a in self.flatten_cache(cache)} + new_cache = {f"{a['filepath']}/{a['name']}" : a for a in self.flatten_cache(asset_infos)} + + # print('\n-------------------------') + # print([v for k,v in cache.items() if 'WIP_Test' in k]) + # print() + + # print([v for k,v in new_cache.items() if 'WIP_Test' in k]) + # print() + + 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 list(new_cache.values()), cache_diff + + def draw_prefs(self, layout): + """Draw the options in the addon preference for this library_type""" + + annotations = self.__class__.__annotations__ + for k, v in annotations.items(): + layout.prop(self, k, text=bpy.path.display_name(k)) + \ No newline at end of file diff --git a/library_types/poly_haven.py b/library_types/poly_haven.py new file mode 100644 index 0000000..074fb0f --- /dev/null +++ b/library_types/poly_haven.py @@ -0,0 +1,141 @@ + +""" +Plugin for making an asset library of all blender file found in a folder +""" + + +from asset_library.library_types.library_type import LibraryType +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, EnumProperty) +import re +from pathlib import Path +from itertools import groupby +import uuid +import os +import shutil +import json +import requests +import urllib3 +import traceback +import time + +from pprint import pprint as pp + +REQ_HEADERS = requests.utils.default_headers() +REQ_HEADERS.update({"User-Agent": "Blender: PH Assets"}) + +class PolyHaven(LibraryType): + + name = "Poly Haven" + # template_name : StringProperty() + # template_file : StringProperty() + directory : StringProperty(subtype='DIR_PATH') + asset_type : EnumProperty(items=[(i.replace(' ', '_').upper(), i, '') for i in ('HDRIs', 'Models', 'Textures')], default='HDRIS') + main_category : StringProperty( + default='artificial light, natural light, nature, studio, skies, urban' + ) + secondary_category : StringProperty( + default='high constrast, low constrast, medium constrast, midday, morning-afternoon, night, sunrise-sunset' + ) + + #blend_depth: IntProperty(default=1) + + # url: StringProperty() + # login: StringProperty() + # password: StringProperty(subtype='PASSWORD') + # project_name: StringProperty() + + def get_asset_path(self, name, catalog, directory=None): + # chemin: Source, Asset_type, asset_name / asset_name.blend -> PolyHaven/HDRIs/test/test.blend + directory = directory or self.source_directory + catalog = self.norm_file_name(catalog) + name = self.norm_file_name(name) + + return Path(directory, self.get_asset_relative_path(name, catalog)) + + # 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 format_asset_info(self, asset_info, asset_path): + # prend un asset info et output un asset description + + asset_path = self.prop_rel_path(asset_path, 'source_directory') + modified = asset_info.get('modified', time.time_ns()) + + return dict( + filepath=asset_path, + modified=modified, + library_id=self.library.id, + assets=[dict( + catalog=asset_data.get('catalog', asset_info['catalog']), + author=asset_data.get('author'), + metadata=asset_data.get('metadata', {}), + description=asset_data.get('description', ''), + tags=asset_data.get('tags', []), + type=self.data_type, + image=self.template_image, + video=self.template_video, + name=asset_data['name']) for asset_data in asset_info['assets'] + ] + ) + + def fetch(self): + """Gather in a list all assets found in the folder""" + + print(f'Fetch Assets for {self.library.name}') + + print('self.asset_type: ', self.asset_type) + url = f"https://api.polyhaven.com/assets?t={self.asset_type.lower()}" + # url2 = f"https://polyhaven.com/{self.asset_type.lower()}" + # url += "&future=true" if early_access else "" + # verify_ssl = not bpy.context.preferences.addons["polyhavenassets"].preferences.disable_ssl_verify + + verify_ssl = False + try: + res = requests.get(url, headers=REQ_HEADERS, verify=verify_ssl) + res2 = requests.get(url2, headers=REQ_HEADERS, verify=verify_ssl) + except Exception as e: + msg = f"[{type(e).__name__}] Error retrieving {url}" + print(msg) + # return (msg, None) + + if res.status_code != 200: + error = f"Error retrieving asset list, status code: {res.status_code}" + print(error) + # return (error, None) + + catalog = None + + # return (None, res.json()) + for asset_info in res.json().values(): + main_category = None + secondary_category = None + for category in asset_info['categories']: + if category in self.main_category and not main_category: + main_category = category + if category in self.secondary_category and not secondary_category: + secondary_category = category + + if main_category and secondary_category: + catalog = f'{main_category}_{secondary_category}' + + if not catalog: + return + + asset_path = self.get_asset_path(asset_info['name'], catalog) + print('asset_path: ', asset_path) + asset_info = self.format_asset_info(asset_info, asset_path) + print('asset_info: ', asset_info) + + # return self.format_asset_info([asset['name'], self.get_asset_path(asset['name'], catalog) for asset, asset_infos in res.json().items()]) + # pp(res.json()) + # pp(res2.json()) + # print(res2) + + + # return asset_infos diff --git a/library_types/scan_folder.py b/library_types/scan_folder.py new file mode 100644 index 0000000..3fe2a7f --- /dev/null +++ b/library_types/scan_folder.py @@ -0,0 +1,347 @@ + +""" +Plugin for making an asset library of all blender file found in a folder +""" + + +from asset_library.library_types.library_type import LibraryType +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 +import time + + +class ScanFolder(LibraryType): + + name = "Scan Folder" + source_directory : StringProperty(subtype='DIR_PATH') + + source_template_file : StringProperty() + source_template_image : StringProperty() + source_template_video : StringProperty() + source_template_info : StringProperty() + + def draw_prefs(self, layout): + layout.prop(self, "source_directory", text="Source: Directory") + + col = layout.column(align=True) + col.prop(self, "source_template_file", icon='COPY_ID', text='Template file') + col.prop(self, "source_template_image", icon='COPY_ID', text='Template image') + col.prop(self, "source_template_video", icon='COPY_ID', text='Template video') + col.prop(self, "source_template_info", icon='COPY_ID', text='Template info') + + def get_asset_path(self, name, catalog, directory=None): + directory = directory or self.source_directory + catalog = self.norm_file_name(catalog) + name = self.norm_file_name(name) + + return Path(directory, self.get_asset_relative_path(name, catalog)) + + def get_image_path(self, name, catalog, filepath): + catalog = self.norm_file_name(catalog) + name = self.norm_file_name(name) + return self.format_path(self.source_template_image, dict(name=name, catalog=catalog, filepath=filepath)) + + def get_video_path(self, name, catalog, filepath): + catalog = self.norm_file_name(catalog) + name = self.norm_file_name(name) + return self.format_path(self.source_template_video, dict(name=name, catalog=catalog, filepath=filepath)) + + def new_asset(self, asset, asset_data): + raise Exception('Need to be defined in the library_type') + + def remove_asset(self, asset, asset_data): + raise Exception('Need to be defined in the library_type') + + def format_asset_info(self, asset_datas, asset_path, modified=None): + + asset_path = self.prop_rel_path(asset_path, 'source_directory') + modified = modified or time.time_ns() + library_id = self.library.id + + # if self.data_type == 'FILE': + # return dict( + # filepath=asset_path, + # author=asset_info.get('author'), + # modified=modified, + # library_id=library_id, + # catalog=asset_info['catalog'], + # tags=[], + # description=asset_info.get('description', ''), + # type=self.data_type, + # #image=self.source_template_image, + # name=asset_info['name'] + # ) + + return dict( + filepath=asset_path, + modified=modified, + library_id=library_id, + assets=[dict( + catalog=asset_data['catalog'], + author=asset_data.get('author', ''), + metadata=asset_data.get('metadata', {}), + description=asset_data.get('description', ''), + tags=asset_data.get('tags', []), + type=self.data_type, + name=asset_data['name']) for asset_data in asset_datas + ] + ) + + def set_asset_preview(self, asset, asset_data): + '''Load an externalize image as preview for an asset using the source template''' + + asset_path = self.format_path(asset_data['filepath']) + + image_template = self.source_template_image + if not image_template: + return + + image_path = self.find_path(image_template, asset_data, filepath=asset_path) + + if image_path: + with bpy.context.temp_override(id=asset): + bpy.ops.ed.lib_id_load_custom_preview( + filepath=str(image_path) + ) + else: + print(f'No image found for {image_template} on {asset.name}') + + if asset.preview: + return asset.preview + + 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 + + catalog_data = self.read_catalog() #TODO remove unused catalog + + write_cache = False + if not cache_diff: + # Get list of all modifications + asset_infos = self.fetch() + + #print(asset_infos[:2]) + + flat_cache, cache_diff = self.diff(asset_infos) + + catalogs = [a['catalog'] for a in flat_cache] + catalog_data = {k:v for k, v in catalog_data.items() if k in catalogs} + + + print('cache_diff', cache_diff) + + # Only write complete cache at the end + write_cache = True + + #self.generate_previews(asset_infos) + self.write_cache(asset_infos, self.tmp_cache_file) + bpy.ops.assetlib.generate_previews(name=self.library.name, cache=str(self.tmp_cache_file)) + + elif isinstance(cache_diff, (Path, str)): + cache_diff = json.loads(Path(cache_diff).read_text(encoding='utf-8')) + + if self.library.blend_depth == 0: + raise Exception('Blender depth must be 1 at min') + #groups = [(cache_diff)] + else: + cache_diff.sort(key=self.get_asset_bundle_path) + groups = groupby(cache_diff, key=self.get_asset_bundle_path) + + total_assets = len(cache_diff) + print(f'total_assets={total_assets}') + + if total_assets == 0: + print('No assets found') + return + + #data_types = self.data_types + #if self.data_types == 'FILE' + + i = 0 + #assets_to_preview = [] + for blend_path, asset_datas in groups: + #blend_name = sub_path[-1].replace(' ', '_').lower() + #blend_path = Path(self.bundle_directory, *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') + + if operation == 'ADD' or not asset: + if asset: + #raise Exception(f"Asset {asset_data['name']} Already in Blend") + print(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, MODIFY)') + continue + + asset.asset_mark() + + self.set_asset_preview(asset, asset_data) + + #if not asset_preview: + # assets_to_preview.append((asset_data['filepath'], asset_data['name'], asset_data['data_type'])) + #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) + self.set_asset_info(asset, asset_data) + + i += 1 + + #self.write_asset_preview_file() + + 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(asset_infos) + else: + cache = self.read_cache() + + # Update the cache with the modification + if not cache: + cache = [] + + flat_cache = {f"{a['filepath']}/{a['name']}": a for a in self.flatten_cache(cache)} + flat_cache_diff = {f"{a['filepath']}/{a['name']}": a for a in cache_diff} + + #Update the cache with the operations + for k, v in flat_cache_diff.items(): + if v['operation'] == 'REMOVE': + if k in flat_cache: + flat_cache.remove(k) + elif v['operation'] in ('MODIFY', 'ADD'): + flat_cache[k] = v + + new_cache = self.unflatten_cache(list(flat_cache.values())) + self.write_cache(new_cache) + + + self.write_catalog(catalog_data) + + + bpy.ops.wm.quit_blender() + + def fetch(self): + """Gather in a list all assets found in the folder""" + + print(f'Fetch Assets for {self.library.name}') + + source_directory = Path(self.source_directory) + template_file = Template(self.source_template_file) + catalog_data = self.read_catalog(directory=source_directory) + catalog_ids = {v['id']: k for k, v in catalog_data.items()} + + cache = self.read_cache() or [] + + print(f'Search for blend using glob template: {template_file.glob_pattern}') + print(f'Scanning Folder {source_directory}...') + + new_cache = [] + + for asset_path in template_file.glob(source_directory):#sorted(blend_files): + + source_rel_path = self.prop_rel_path(asset_path, 'source_directory') + modified = asset_path.stat().st_mtime_ns + + # Check if the asset description as already been cached + asset_info = next((a for a in cache if a['filepath'] == source_rel_path), None) + + if asset_info and asset_info['modified'] >= modified: + #print(asset_path, 'is skipped because not modified') + new_cache.append(asset_info) + continue + + rel_path = asset_path.relative_to(source_directory).as_posix() + field_data = template_file.parse(rel_path) + + catalogs = [v for k,v in sorted(field_data.items()) if re.findall('cat[0-9]+', k)] + #catalogs = [c.replace('_', ' ').title() for c in catalogs] + + asset_name = field_data.get('asset_name', asset_path.stem) + + if self.data_type == 'FILE': + asset_datas = [{"name": asset_name, "catalog": '/'.join(catalogs)}] + asset_info = self.format_asset_info(asset_datas, asset_path, modified=modified) + new_cache.append(asset_info) + continue + + # Now check if there is a asset description file + asset_info_path = self.find_path(self.source_template_info, asset_info, filepath=asset_path) + if asset_info_path: + new_cache.append(self.read_file(asset_info_path)) + continue + + # Scan the blend file for assets inside and write a custom asset description for info found + print(f'Scanning blendfile {asset_path}...') + assets = self.load_datablocks(asset_path, type=self.data_types, link=True, assets_only=True) + print(f'Found {len(assets)} {self.data_types} inside') + + asset_datas = [] + for asset in assets: + #catalog_path = catalog_ids.get(asset.asset_data.catalog_id) + + #if not catalog_path: + # print(f'No catalog found for asset {asset.name}') + #catalog_path = asset_info['catalog']#asset_path.relative_to(self.source_directory).as_posix() + + # For now the catalog used is the one extract from the template file + asset_data = self.get_asset_data(asset) + asset_data['catalog'] = '/'.join(catalogs) + + asset_datas.append(asset_data) + + getattr(bpy.data, self.data_types).remove(asset) + + + asset_info = self.format_asset_info(asset_datas, asset_path, modified=modified) + + new_cache.append(asset_info) + + + new_cache.sort(key=lambda x:x['filepath']) + + return new_cache#[:5] + diff --git a/operators.py b/operators.py index 768582d..455b9b2 100644 --- a/operators.py +++ b/operators.py @@ -20,8 +20,10 @@ from bpy.props import ( #from asset_library.constants import (DATA_TYPES, DATA_TYPE_ITEMS, MODULE_DIR) import asset_library from asset_library.common.bl_utils import ( + attr_set, get_addon_prefs, get_bl_cmd, + get_view3d_persp, #suitable_areas, refresh_asset_browsers, load_datablocks) @@ -31,13 +33,17 @@ from asset_library.common.functions import get_active_library, asset_warning_cal from textwrap import dedent from tempfile import gettempdir +import gpu +from gpu_extras.batch import batch_for_shader +import blf +import bgl -class ASSETLIB_OT_clear_asset(Operator): - bl_idname = "assetlib.clear_asset" +class ASSETLIB_OT_remove_assets(Operator): + bl_idname = "assetlib.remove_assets" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - bl_label = 'Clear Asset' - bl_description = 'Clear Selected Assets' + bl_label = 'Remove Assets' + bl_description = 'Remove Selected Assets' @classmethod def poll(cls, context): @@ -54,20 +60,32 @@ class ASSETLIB_OT_clear_asset(Operator): asset = context.active_file lib = get_active_library() + lib_type = lib.library_type - 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) + asset_handle = context.asset_file_handle - if filepath: - if filepath.exists(): - filepath.unlink() - if asset_image: - asset_image.unlink() - if asset_video: - asset_video.unlink() + catalog_file = lib.library_type.read_catalog() + catalog_ids = {v['id']: {'path': k, 'name': v['name']} for k,v in catalog_file.items()} + catalog = catalog_ids[asset_handle.asset_data.catalog_id]['path'] + + asset_path = lib_type.format_path(asset.asset_data['filepath']) + + img_path = lib_type.get_image_path(name=asset_handle.name, catalog=catalog, filepath=asset_path) + video_path = lib_type.get_video_path(name=asset_handle.name, catalog=catalog, filepath=asset_path) + + if asset_path and asset_path.exists(): + asset_path.unlink() + if img_path and img_path.exists(): + img_path.unlink() + if video_path and video_path.exists(): + video_path.unlink() #open_blender_file(filepath) + try: + asset_path.parent.rmdir() + except Exception:#Directory not empty + pass + bpy.ops.assetlib.bundle(name=lib.name, blocking=True) return {'FINISHED'} @@ -99,29 +117,29 @@ class ASSETLIB_OT_edit_data(Operator): 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) + new_name = lib.library_type.norm_file_name(self.name) + new_asset_path = lib.library_type.get_asset_path(name=new_name, catalog=self.catalog) - #asset_data = lib.adapter.get_asset_data(self.asset) + #asset_data = lib.library_type.get_asset_data(self.asset) asset_data = dict( tags=[t.strip() for t in self.tags.split(',') if t], description=self.description, ) - #lib.adapter.set_asset_catalog(asset, asset_data, catalog_data) + #lib.library_type.set_asset_catalog(asset, asset_data, catalog_data) self.asset.name = self.name - lib.adapter.set_asset_tags(self.asset, asset_data) - lib.adapter.set_asset_info(self.asset, asset_data) + lib.library_type.set_asset_tags(self.asset, asset_data) + lib.library_type.set_asset_info(self.asset, asset_data) self.old_asset_path.unlink() - lib.adapter.write_asset(asset=self.asset, asset_path=new_asset_path) + lib.library_type.write_asset(asset=self.asset, asset_path=new_asset_path) if self.old_image_path.exists(): - new_img_path = lib.adapter.get_image_path(new_name, self.catalog, new_asset_path) + new_img_path = lib.library_type.get_image_path(new_name, self.catalog, new_asset_path) self.old_image_path.rename(new_img_path) if self.old_video_path.exists(): - new_video_path = lib.adapter.get_video_path(new_name, self.catalog, new_asset_path) + new_video_path = lib.library_type.get_video_path(new_name, self.catalog, new_asset_path) self.old_video_path.rename(new_video_path) #if self.old_description_path.exists(): @@ -135,7 +153,7 @@ class ASSETLIB_OT_edit_data(Operator): diff_path = Path(bpy.app.tempdir, 'diff.json') diff = [dict(name=self.old_asset_name, catalog=self.old_catalog, filepath=str(self.old_asset_path), operation='REMOVE')] - asset_data = lib.adapter.get_asset_data(self.asset) + asset_data = lib.library_type.get_asset_data(self.asset) diff += [dict(asset_data, image=str(new_img_path), filepath=str(new_asset_path), @@ -186,18 +204,18 @@ class ASSETLIB_OT_edit_data(Operator): lib = get_active_library() - active_lib = lib.adapter.get_active_asset_library() + active_lib = lib.library_type.get_active_asset_library() lib.store_library = active_lib.name asset_handle = context.asset_file_handle - catalog_file = lib.adapter.read_catalog() + catalog_file = lib.library_type.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.old_asset_path = lib.library_type.get_active_asset_path() self.asset = load_datablocks(self.old_asset_path, self.old_asset_name, type=lib.data_types) @@ -213,13 +231,13 @@ class ASSETLIB_OT_edit_data(Operator): self.old_catalog = catalog_ids[asset_handle.asset_data.catalog_id]['path'] self.catalog = self.old_catalog - self.old_image_path = lib.adapter.get_image_path(name=self.name, catalog=self.catalog, filepath=self.old_asset_path) - self.old_video_path = lib.adapter.get_video_path(name=self.name, catalog=self.catalog, filepath=self.old_asset_path) + self.old_image_path = lib.library_type.get_image_path(name=self.name, catalog=self.catalog, filepath=self.old_asset_path) + self.old_video_path = lib.library_type.get_video_path(name=self.name, catalog=self.catalog, filepath=self.old_asset_path) - #self.old_description_path = lib.adapter.get_description_path(self.old_asset_path) + #self.old_description_path = lib.library_type.get_description_path(self.old_asset_path) - #self.old_asset_description = lib.adapter.read_asset_description_file(self.old_asset_path) - #self.old_asset_description = lib.adapter.norm_asset_datas([self.old_asset_description])[0] + #self.old_asset_info = lib.library_type.read_asset_info_file(self.old_asset_path) + #self.old_asset_info = lib.library_type.norm_asset_datas([self.old_asset_info])[0] @@ -231,7 +249,7 @@ class ASSETLIB_OT_edit_data(Operator): print('Cancel Edit Data, removing the asset') lib = get_active_library() - active_lib = lib.adapter.get_active_asset_library() + active_lib = lib.library_type.get_active_asset_library() getattr(bpy.data, active_lib.data_types).remove(self.asset) @@ -281,9 +299,9 @@ class ASSETLIB_OT_open_blend(Operator): lib = get_active_library() - #filepath = lib.adapter.format_path(asset.asset_data['filepath']) + #filepath = lib.library_type.format_path(asset.asset_data['filepath']) - filepath = lib.adapter.get_active_asset_path() + filepath = lib.library_type.get_active_asset_path() open_blender_file(filepath) @@ -357,7 +375,7 @@ class ASSETLIB_OT_bundle_library(Operator): for lib_data in {lib_datas}: lib = prefs.env_libraries.add() lib.set_dict(lib_data) - lib.adapter.bundle(cache_diff='{self.diff}') + lib.library_type.bundle(cache_diff='{self.diff}') bpy.ops.wm.quit_blender() """) @@ -411,11 +429,7 @@ class ASSETLIB_OT_diff(Operator): prefs = get_addon_prefs() lib = prefs.libraries.get(self.name) - - if self.conform: - lib.conform.adapter.diff() - else: - lib.adapter.diff() + lib.library_type.diff() return {'FINISHED'} @@ -435,7 +449,7 @@ class ASSETLIB_OT_conform_library(Operator): prefs = get_addon_prefs() lib = prefs.libraries.get(self.name) - #lib.adapter.conform(self.directory) + #lib.library_type.conform(self.directory) templates = {} if self.template_image: @@ -450,7 +464,7 @@ class ASSETLIB_OT_conform_library(Operator): 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}) + lib.library_type.conform(directory='{self.directory}', templates={templates}) """) script_path.write_text(script_code) @@ -466,6 +480,179 @@ class ASSETLIB_OT_conform_library(Operator): return {'RUNNING_MODAL'} ''' +class ASSETLIB_OT_make_custom_preview(Operator): + bl_idname = "assetlib.make_custom_preview" + bl_label = "Custom Preview" + bl_description = "Set a camera to preview an asset" + + image_size : IntProperty(default=512) + modal : BoolProperty(default=False) + + def modal(self, context, event): + if event.type in {'ESC'}: # Cancel + self.restore() + return {'CANCELLED'} + + elif event.type in {'RET', 'NUMPAD_ENTER'}: # Cancel + return self.execute(context) + #return {'FINISHED'} + + return {'PASS_THROUGH'} + + def execute(self, context): + + prefs = get_addon_prefs() + bpy.ops.render.opengl(write_still=True) + + img_path = context.scene.render.filepath + + #print('Load Image to previews') + prefs.previews.load(Path(img_path).stem, img_path, 'IMAGE') + #img = bpy.data.images.load(context.scene.render.filepath) + #img.update() + #img.preview_ensure() + + + #Copy the image with a new name + # render = bpy.data.images['Render Result'] + + # render_pixels = [0] * self.image_size * self.image_size * 4 + # render.pixels.foreach_get(render_pixels) + # img = bpy.data.images.new(name=img_name, width=self.image_size, height=self.image_size, is_data=True, alpha=True) + # img.pixels.foreach_set(render_pixels) + + #img.scale(128, 128) + #img.preview_ensure() + + # preview_size = render.size + + # pixels = [0] * preview_size[0] * preview_size[1] * 4 + # render.pixels.foreach_get(pixels) + + # image.preview.image_size = preview_size + # image.preview.image_pixels_float.foreach_set(pixels) + + + + self.restore() + + #self.is_running = False + prefs.preview_modal = False + + return {"FINISHED"} + + def restore(self): + print('RESTORE') + try: + bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') + except: + print('Failed remove handler') + pass + + bpy.data.objects.remove(self.camera) + self.attr_changed.restore() + + def draw_callback_px(self, context): + if context.space_data != self._space_data: + return + + dpi = context.preferences.system.dpi + + bg_color = (0.8, 0.1, 0.1, 0.5) + font_color = (1, 1, 1, 1) + text = f'Escape: Cancel Enter: Make Preview' + font_id = 0 + dim = blf.dimensions(font_id, text) + + #gpu.state.line_width_set(100) + # bgl.glLineWidth(100) + # self.shader_2d.bind() + # self.shader_2d.uniform_float("color", bg_color) + # self.screen_framing.draw(self.shader_2d) + + # # Reset + # gpu.state.line_width_set(1) + + # -dim[0]/2, +dim[1]/2 + 5 + + # Display Text + blf.color(font_id, *font_color) # unpack color + blf.position(font_id, context.region.width/2 -dim[0]/2, dim[1]/2 + 5, 0) + blf.size(font_id, 12, dpi) + blf.draw(font_id, f'Escape: Cancel Enter: Make Preview') + + def get_image_name(self): + prefs = get_addon_prefs() + preview_names = [p for p in prefs.previews.keys()] + preview_names.sort() + + index = 0 + if preview_names: + index = int(preview_names[-1][-2:]) + 1 + + return f'preview_{index:03d}' + + def invoke(self, context, event): + prefs = get_addon_prefs() + cam_data = bpy.data.cameras.new(name='Preview Camera') + self.camera = bpy.data.objects.new(name='Preview Camera', object_data=cam_data) + + #view_3d = get_view3d_persp() + + scn = context.scene + space = context.space_data + + matrix = space.region_3d.view_matrix.inverted() + if space.region_3d.view_perspective == 'CAMERA': + matrix = scn.camera.matrix_world + + self.camera.matrix_world = matrix + + img_name = self.get_image_name() + img_path = Path(bpy.app.tempdir, img_name).with_suffix('.webp') + + self.attr_changed = attr_set([ + (space.overlay, 'show_overlays', False), + (space.region_3d, 'view_perspective', 'CAMERA'), + (space.region_3d, 'view_camera_offset'), + (space.region_3d, 'view_camera_zoom'), + (space, 'lock_camera', True), + (space, 'show_region_ui', False), + (scn, 'camera', self.camera), + (scn.render, 'resolution_percentage', 100), + (scn.render, 'resolution_x', self.image_size), + (scn.render, 'resolution_y', self.image_size), + (scn.render, 'film_transparent', True), + (scn.render.image_settings, 'file_format', 'WEBP'), + (scn.render.image_settings, 'color_mode', 'RGBA'), + #(scn.render.image_settings, 'color_depth', '8'), + (scn.render, 'use_overwrite', True), + (scn.render, 'filepath', str(img_path)), + ]) + + bpy.ops.view3d.view_center_camera() + space.region_3d.view_camera_zoom -= 6 + space.region_3d.view_camera_offset[1] += 0.03 + + w, h = (context.region.width, context.region.height) + + self._space_data = context.space_data + + if self.modal: + prefs.preview_modal = True + + self.shader_2d = gpu.shader.from_builtin('2D_UNIFORM_COLOR') + self.screen_framing = batch_for_shader( + self.shader_2d, 'LINE_LOOP', {"pos": [(0,0), (0,h), (w,h), (w,0)]}) + + self._handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, (context,), 'WINDOW', 'POST_PIXEL') + context.window_manager.modal_handler_add(self) + + return {'RUNNING_MODAL'} + else: + return self.execute(context) + + class ASSETLIB_OT_generate_previews(Operator): bl_idname = "assetlib.generate_previews" bl_options = {"REGISTER", "UNDO"} @@ -493,7 +680,7 @@ class ASSETLIB_OT_generate_previews(Operator): # '--preview-assets-file', str(self.preview_assets_file) # ] # subprocess.call(cmd) - preview_blend = self.preview_blend or lib.adapter.preview_blend + preview_blend = self.preview_blend or lib.library_type.preview_blend if not preview_blend or not Path(preview_blend).exists(): preview_blend = MODULE_DIR / 'common' / 'preview.blend' @@ -506,7 +693,7 @@ class ASSETLIB_OT_generate_previews(Operator): lib.set_dict({lib.to_dict()}) bpy.ops.wm.open_mainfile(filepath='{preview_blend}', load_ui=True) - lib.adapter.generate_previews(cache='{self.cache}') + lib.library_type.generate_previews(cache='{self.cache}') """) script_path.write_text(script_code) @@ -548,11 +735,11 @@ class ASSETLIB_OT_play_preview(Operator): lib = get_active_library() - #filepath = lib.adapter.format_path(asset.asset_data['filepath']) - asset_path = lib.adapter.get_active_asset_path() + #filepath = lib.library_type.format_path(asset.asset_data['filepath']) + asset_path = lib.library_type.get_active_asset_path() - asset_image = lib.adapter.get_image(asset.name, asset_path) - asset_video = lib.adapter.get_video(asset.name, asset_path) + asset_image = lib.library_type.get_image(asset.name, asset_path) + asset_video = lib.library_type.get_video(asset.name, asset_path) if not asset_image and not asset_video: self.report({'ERROR'}, f'Preview for {asset.name} not found.') @@ -629,10 +816,11 @@ classes = ( ASSETLIB_OT_diff, ASSETLIB_OT_generate_previews, ASSETLIB_OT_bundle_library, - ASSETLIB_OT_clear_asset, + ASSETLIB_OT_remove_assets, ASSETLIB_OT_edit_data, #ASSETLIB_OT_conform_library, - ASSETLIB_OT_reload_addon + ASSETLIB_OT_reload_addon, + ASSETLIB_OT_make_custom_preview ) def register(): diff --git a/prefs.py b/preferences.py similarity index 70% rename from prefs.py rename to preferences.py index 423b0da..f97749e 100644 --- a/prefs.py +++ b/preferences.py @@ -8,7 +8,7 @@ from bpy.props import (BoolProperty, StringProperty, CollectionProperty, EnumProperty, IntProperty) from asset_library.constants import (DATA_TYPES, DATA_TYPE_ITEMS, - ICONS, RESOURCES_DIR, ADAPTER_DIR, ADAPTERS) + ICONS, RESOURCES_DIR, LIBRARY_TYPE_DIR, LIBRARY_TYPES, ADAPTERS) from asset_library.common.file_utils import import_module_from_path, norm_str from asset_library.common.bl_utils import get_addon_prefs @@ -54,13 +54,21 @@ def update_all_library_path(self, context): update_library_path(lib, context) #lib.set_library_path() -def get_adapter_items(self, context): +def get_library_type_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(LIBRARY_TYPES)] + return items + +def get_adapters_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() @@ -77,47 +85,15 @@ def get_store_library_items(self, context): return [(l.name, l.name, "", i) for i, l in enumerate([self] + self.merge_libraries)] -class AssetLibraryAdapters(PropertyGroup): - parent = None - +class LibraryTypes(PropertyGroup): 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="Target Directory", - subtype='DIR_PATH', - default='' - ) - template_image : StringProperty(default='', description='../{name}_image.png') - template_video : StringProperty(default='', description='../{name}_video.mov') - template_description : StringProperty(default='', description='../{name}_asset_description.json') - - #externalize_data: BoolProperty(default=False, name='Externalize Data') - blend_depth: IntProperty(default=1, name='Blend Depth') +class Adapters(PropertyGroup): + def __iter__(self): + return (getattr(self, p) for p in self.bl_rna.properties.keys() if p not in ('rna_type', 'name')) - @property - def adapter(self): - name = norm_str(self.adapter_name) - if not hasattr(self.adapters, name): - return - - return getattr(self.adapters, name) - - def to_dict(self): - data = {p: getattr(self, p) for p in self.bl_rna.properties.keys() if p !='rna_type'} - - data['adapter'] = self.adapter.to_dict() - data['adapter']['name'] = data.pop('adapter_name') - - del data['adapters'] - - return data -''' class AssetLibrary(PropertyGroup): name : StringProperty(name='Name', default='Action Library', update=update_library_path) @@ -127,9 +103,10 @@ class AssetLibrary(PropertyGroup): use : BoolProperty(name='Use', default=True, update=update_library_path) data_type : EnumProperty(name='Type', items=DATA_TYPE_ITEMS, default='COLLECTION') - template_image : StringProperty(default='', description='../{name}_image.png') - template_video : StringProperty(default='', description='../{name}_video.mov') - template_description : StringProperty(default='', description='../{name}_asset_description.json') + + #template_image : StringProperty(default='', description='../{name}_image.png') + #template_video : StringProperty(default='', description='../{name}_video.mov') + #template_info : StringProperty(default='', description='../{name}_asset_info.json') bundle_directory : StringProperty( name="Bundle Directory", @@ -166,10 +143,14 @@ class AssetLibrary(PropertyGroup): # ) - #adapter : EnumProperty(items=adapter_ITEMS) - adapters : bpy.props.PointerProperty(type=AssetLibraryAdapters) - adapter_name : EnumProperty(items=get_adapter_items) - parent : StringProperty() + #library_type : EnumProperty(items=library_type_ITEMS) + library_types : bpy.props.PointerProperty(type=LibraryTypes) + library_type_name : EnumProperty(items=get_library_type_items) + + adapters : bpy.props.PointerProperty(type=Adapters) + adapter_name : EnumProperty(items=get_adapters_items) + + parent_name : StringProperty() # data_file_path : StringProperty( # name="Path", @@ -178,7 +159,13 @@ class AssetLibrary(PropertyGroup): # ) #def __init__(self): - # self.adapters.parent = self + # self.library_types.parent = self + + @property + def parent(self): + prefs = get_addon_prefs() + if self.parent_name: + return prefs.libraries[self.parent_name] @property def merge_libraries(self): @@ -188,7 +175,7 @@ class AssetLibrary(PropertyGroup): @property def child_libraries(self): prefs = get_addon_prefs() - return [l for l in prefs.libraries if l != self and (l.parent == self.name)] + return [l for l in prefs.libraries if l != self and (l.parent == self)] @property def data_types(self): @@ -197,6 +184,14 @@ class AssetLibrary(PropertyGroup): data_type = 'COLLECTION' return f'{data_type.lower()}s' + @property + def library_type(self): + name = norm_str(self.library_type_name) + if not hasattr(self.library_types, name): + return + + return getattr(self.library_types, name) + @property def adapter(self): name = norm_str(self.adapter_name) @@ -230,6 +225,10 @@ class AssetLibrary(PropertyGroup): library_name = norm_str(library_name) return Path(prefs.bundle_directory, library_name).resolve() + @property + def bundle_dir(self): + return self.library_path.as_posix() + @property def library_name(self): if self.use_custom_bundle_name: @@ -306,16 +305,16 @@ class AssetLibrary(PropertyGroup): 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']}") + # self.library_type_name = data['library_type'] + # if not self.library_type: + # print(f"No library_type named {data['library_type']}") # return # for key, value in data.items(): # if key == 'options': # for k, v in data['options'].items(): - # setattr(self.adapter, k, v) + # setattr(self.library_type, k, v) # elif key in self.bl_rna.properties.keys(): # if key == 'id': # value = str(value) @@ -328,12 +327,16 @@ class AssetLibrary(PropertyGroup): def to_dict(self): data = {p: getattr(self, p) for p in self.bl_rna.properties.keys() if p !='rna_type'} - data['adapter'] = self.adapter.to_dict() - #data['adapter'] = data.pop('adapter_name') - data['adapter']['name'] = data.pop('adapter_name') - del data['adapters'] - #data['conform'] = self.conform.to_dict() + if self.library_type: + data['library_type'] = self.library_type.to_dict() + data['library_type']['name'] = data.pop('library_type_name') + del data['library_types'] + + if self.adapter: + data['adapter'] = self.adapter.to_dict() + data['adapter']['name'] = data.pop('adapter_name') + del data['adapters'] return data @@ -341,32 +344,32 @@ class AssetLibrary(PropertyGroup): '''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}') + self.clear_library_path() - 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() + if not self.use or not lib_path: + # if all(not l.use for l in self.merge_libraries): + # self.clear_library_path() return + # lib = None + # if self.get('asset_library'): + # #print('old_name', self['asset_library']) + # lib = prefs.filepaths.asset_libraries.get(self['asset_library']) + + # if not lib: + # #print('keys', prefs.filepaths.asset_libraries.keys()) + # #print('name', name) + # #print(prefs.filepaths.asset_libraries.get(name)) + # lib = prefs.filepaths.asset_libraries.get(name) + # Create the Asset Library Path + lib = prefs.filepaths.asset_libraries.get(name) if not lib: + #print(f'Creating the lib {name}') try: bpy.ops.preferences.asset_library_add(directory=str(lib_path)) except AttributeError: @@ -392,7 +395,7 @@ class AssetLibrary(PropertyGroup): 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''' + '''Act like the use_property_split but with more control''' enabled = True split = layout.split(factor=factor, align=True) @@ -423,7 +426,7 @@ class AssetLibrary(PropertyGroup): def draw_operators(self, layout): row = layout.row(align=True) row.alignment = 'RIGHT' - row.prop(self, 'adapter_name', text='') + row.prop(self, 'library_type_name', text='') row.prop(self, 'auto_bundle', text='', icon='UV_SYNC_SELECT') row.operator("assetlib.diff", text='', icon='FILE_REFRESH').name = self.name @@ -433,65 +436,6 @@ class AssetLibrary(PropertyGroup): 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.generate_previews', text='', icon='SEQ_PREVIEW')#, 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 - op.conform = True - - subrow.label(icon='BLANK1') - #subrow.separator(factor=3) - - if self.expand_extra and self.conform.adapter: - col.separator() - self.conform.adapter.draw_prefs(col) - - col.separator() - 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") - subcol = col.column(align=True) - subcol.prop(self.conform, "template_description", text='Template Description', icon='COPY_ID') - subcol.prop(self.conform, "template_image", text='Template Image', icon='COPY_ID') - subcol.prop(self.conform, "template_video", text='Template Video', icon='COPY_ID') - - - - col.separator() - """ - - def draw(self, layout): prefs = get_addon_prefs() #box = layout.box() @@ -510,7 +454,7 @@ class AssetLibrary(PropertyGroup): self.draw_operators(row) - index = prefs.user_libraries.index(self) + index = list(prefs.user_libraries).index(self) row.operator("assetlib.remove_user_library", icon="X", text='', emboss=False).index = index else: @@ -551,15 +495,14 @@ class AssetLibrary(PropertyGroup): col.prop(self, "blend_depth") - subcol = col.column(align=True) - subcol.prop(self, "template_description", text='Template Description', icon='COPY_ID') - subcol.prop(self, "template_image", text='Template Image', icon='COPY_ID') - subcol.prop(self, "template_video", text='Template Video', icon='COPY_ID') + #subcol = col.column(align=True) + #subcol.prop(self, "template_info", text='Template Info', icon='COPY_ID') + #subcol.prop(self, "template_image", text='Template Image', icon='COPY_ID') + #subcol.prop(self, "template_video", text='Template Video', icon='COPY_ID') - - if self.adapter: + if self.library_type: col.separator() - self.adapter.draw_prefs(col) + self.library_type.draw_prefs(col) for lib in self.child_libraries: lib.draw(layout) @@ -634,19 +577,21 @@ class AssetLibraryPrefs(AddonPreferences): bl_idname = __package__ adapters = [] + library_types = [] + previews = bpy.utils.previews.new() + preview_modal = False + add_asset_dict = {} #action : bpy.props.PointerProperty(type=AssetLibraryPath) #asset : bpy.props.PointerProperty(type=AssetLibraryPath) - #adapters = {} + #library_types = {} author: StringProperty(default=os.getlogin()) image_player: StringProperty(default='') video_player: StringProperty(default='') - adapter_directory : StringProperty( - name="Adapter Directory", - subtype='DIR_PATH' - ) + library_type_directory : StringProperty(name="Library Type Directory", subtype='DIR_PATH') + adapter_directory : StringProperty(name="Adapter Directory", subtype='DIR_PATH') env_libraries : CollectionProperty(type=AssetLibrary) user_libraries : CollectionProperty(type=AssetLibrary) @@ -658,8 +603,6 @@ class AssetLibraryPrefs(AddonPreferences): update=update_all_library_path ) - - config_directory : StringProperty( name="Config Path", subtype='FILE_PATH', @@ -667,50 +610,52 @@ class AssetLibraryPrefs(AddonPreferences): update=update_library_config ) - def load_adapters(self): - from asset_library.adapters.adapter import AssetLibraryAdapter + def load_library_types(self): + from asset_library.library_types.library_type import LibraryType - #global ADAPTERS - - print('\n------Load Adapters') + print('Asset Library: Load Library Types') - ADAPTERS.clear() + LIBRARY_TYPES.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')) + library_type_files = list(LIBRARY_TYPE_DIR.glob('*.py')) + if self.library_type_directory: + user_LIBRARY_TYPE_DIR = Path(self.library_type_directory) + if user_LIBRARY_TYPE_DIR.exists(): + library_type_files += list(user_LIBRARY_TYPE_DIR.glob('*.py')) - for adapter_file in adapter_files: - mod = import_module_from_path(adapter_file) - - if adapter_file.stem.startswith('_'): + for library_type_file in library_type_files: + if library_type_file.stem.startswith('_'): continue + + mod = import_module_from_path(library_type_file) + - #print(adapter_file) + #print(library_type_file) for name, obj in inspect.getmembers(mod): if not inspect.isclass(obj): continue #print(obj.__bases__) - if not AssetLibraryAdapter in obj.__mro__: + if not LibraryType in obj.__mro__: continue - # Non registering base adapter - if obj is AssetLibraryAdapter or obj.name in (a.name for a in ADAPTERS): + # Non registering base library_type + if obj is LibraryType or obj.name in (a.name for a in LIBRARY_TYPES): 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) + setattr(LibraryTypes, norm_str(obj.name), bpy.props.PointerProperty(type=obj)) + LIBRARY_TYPES.append(obj) except Exception as e: - print(f'Could not register adapter {name}') + print(f'Could not register library_type {name}') print(e) + + def load_adapters(self): + return @property def libraries(self): @@ -744,12 +689,12 @@ class AssetLibraryPrefs(AddonPreferences): col.separator() - col.prop(self, 'adapter_directory') + col.prop(self, 'library_type_directory') col.prop(self, 'config_directory') col.separator() - #col.prop(self, 'template_description', text='Asset Description Template', icon='COPY_ID') + #col.prop(self, 'template_info', text='Asset Description Template', icon='COPY_ID') #col.separator() @@ -778,7 +723,8 @@ class AssetLibraryPrefs(AddonPreferences): classes = [ - AssetLibraryAdapters, + LibraryTypes, + Adapters, #ConformAssetLibrary, AssetLibrary, AssetLibraryPrefs, @@ -799,14 +745,19 @@ def register(): 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) + LIBRARY_TYPE_DIR = os.getenv('ASSETLIB_LIBRARY_TYPE_DIR') + if LIBRARY_TYPE_DIR: + prefs['library_type_directory'] = os.path.expandvars(LIBRARY_TYPE_DIR) + ADAPTER_DIR = os.getenv('ASSETLIB_ADAPTER_DIR') + if ADAPTER_DIR: + prefs['adapter_directory'] = os.path.expandvars(ADAPTER_DIR) + + prefs.load_library_types() prefs.load_adapters() def unregister(): - for cls in reversed(classes + ADAPTERS): + for cls in reversed(classes + LIBRARY_TYPES): bpy.utils.unregister_class(cls) - ADAPTERS.clear() \ No newline at end of file + LIBRARY_TYPES.clear() \ No newline at end of file