# ##### BEGIN GPL LICENSE BLOCK ##### # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # ##### END GPL LICENSE BLOCK ##### from __future__ import annotations import json import os import sys import traceback import urllib.request import uuid import logging import bpy bk_logger = logging.getLogger(__name__) _INT32_MIN = -2_147_483_648 _INT32_MAX = 2_147_483_647 def _sanitize_for_idprops(value): """Recursively sanitize a value so it can be stored as a Blender IDProperty. Large integers that would overflow int32 are converted to strings. """ if isinstance(value, int): if value < _INT32_MIN or value > _INT32_MAX: return str(value) return value if isinstance(value, dict): return {k: _sanitize_for_idprops(v) for k, v in value.items()} if isinstance(value, (list, tuple, set)): return [_sanitize_for_idprops(v) for v in value] return value _ASSET_TYPE_DIRS = { "models", "materials", "hdrs", "scenes", "brushes", "textures", "nodegroups", "printables", "addons", } def get_texture_filepath(tex_dir_path, image, resolution="blend", source_path=""): if source_path: path = source_path elif len(image.packed_files) > 0: path = image.packed_files[0].filepath else: path = image.filepath # backslashes needs to be replaced because bpy.path.basename(path) # does not work on Mac for Windows paths path = path or "" path = path.replace("\\", "/") image_file_name = bpy.path.basename(path) if image_file_name == "": image_file_name = image.name.split(".")[0] # check if there is already an image with same name and thus also assigned path # (can happen easily with generated tex sets and more materials) file_path_original = os.path.join(tex_dir_path, image_file_name) file_path_final = file_path_original i = 0 done = False while not done: is_solo = True for image1 in bpy.data.images: if image != image1 and image1.filepath == file_path_final: is_solo = False fpleft, fpext = os.path.splitext(file_path_original) file_path_final = fpleft + str(i).zfill(3) + fpext i += 1 if is_solo: done = True return file_path_final def get_resolution_from_file_path(file_path): possible_resolutions = { "_0_5K_": "resolution_0_5K", "_1K_": "resolution_1K", "_2K_": "resolution_2K", "_4K_": "resolution_4K", "_8K_": "resolution_8K", } for res in possible_resolutions: if res in file_path: return possible_resolutions[res] return "blend" def _resolve_author_name(asset_data: dict) -> str: author = asset_data.get("author") or {} full_name = author.get("fullName") or "" if full_name: return full_name first = author.get("firstName") or "" last = author.get("lastName") or "" return f"{first} {last}".strip() def _resolve_thumbnail_url(asset_data: dict) -> str: for key in ("thumbnailMiddleUrl", "thumbnailSmallUrl", "thumbnailLargeUrl"): url = asset_data.get(key) if url: return str(url) for file in asset_data.get("files", []): if file.get("fileType") not in ( "thumbnail", "photo_thumbnail", "wire_thumbnail", ): continue for key in ( "thumbnailMiddleUrl", "thumbnailSmallUrl", "fileThumbnailLarge", "fileThumbnail", ): url = file.get(key) if url: return str(url) return "" def _library_dir_from_fpath(blend_path: str) -> str: """Derive library root from a blend file path by stripping the asset_type folder. Expected structure: ///.blend Returns the portion up to . Falls back to two levels above the blend file directory. """ if not blend_path: return "" norm_path = os.path.abspath(blend_path) parts = norm_path.split(os.sep) for idx, part in enumerate(parts): if part.lower() in _ASSET_TYPE_DIRS and idx > 0: return os.sep.join(parts[:idx]) # Fallback: go two levels up from the blend file directory dir_path = os.path.dirname(norm_path) return os.path.abspath(os.path.join(dir_path, os.pardir, os.pardir)) def _download_thumbnail(url: str) -> str: """Download the thumbnail image from the given URL and save it to the same directory as the current .blend file. Returns the file path of the downloaded thumbnail, or an empty string if the download failed. """ if not url: return "" base_name = "preview.png" target_dir = os.path.dirname(bpy.data.filepath) if not target_dir: return "" target_path = os.path.join(target_dir, base_name) if os.path.exists(target_path): return target_path try: req = urllib.request.Request(url) req.add_header("User-Agent", "BlenderKit") req.add_header("Accept", "image/*") with urllib.request.urlopen(req, timeout=15) as response: with open(target_path, "wb") as handle: handle.write(response.read()) return target_path except Exception: return "" def _sanitize_preview_image(preview_path: str) -> str: """Some thumbnail images have issues libEx support. This function tries to sanitize the image by re-saving it as PNG from the blender. """ if not preview_path or not os.path.exists(preview_path): return "" base_dir = os.path.dirname(preview_path) base_name = os.path.splitext(os.path.basename(preview_path))[0] sanitized_path = os.path.join(base_dir, f"{base_name}_clean.png") if os.path.exists(sanitized_path): return sanitized_path img = None try: img = bpy.data.images.load(preview_path, check_existing=False) img.filepath_raw = sanitized_path img.file_format = "PNG" img.save() return sanitized_path except Exception: return "" finally: if img is not None: try: bpy.data.images.remove(img) except Exception: pass def _op_poll(op_callable, data_block) -> bool: """Check if the operator can run in the context of the given data block.""" try: if hasattr(bpy.context, "temp_override"): with bpy.context.temp_override(id=data_block): return op_callable.poll() override = bpy.context.copy() override["id"] = data_block return op_callable.poll(override) except Exception: return False def _op_call(op_callable, data_block, **kwargs): """Call the operator in the context of the given data block.""" if hasattr(bpy.context, "temp_override"): with bpy.context.temp_override(id=data_block): return op_callable(**kwargs) override = bpy.context.copy() override["id"] = data_block return op_callable(override, **kwargs) def _apply_asset_preview(data_block, asset_data: dict) -> None: """Apply asset preview image to the asset data block. It first tries to download the thumbnail from the URL provided in asset data. If that fails, it falls back to generating a preview within Blender.""" if data_block is None: return print("🖼️ applying asset preview") url = _resolve_thumbnail_url(asset_data) preview_path = _download_thumbnail(url) if url else "" if preview_path: clean_path = _sanitize_preview_image(preview_path) if clean_path: preview_path = clean_path try: loaded = False if _op_poll(bpy.ops.ed.lib_id_load_custom_preview, data_block): result = _op_call( bpy.ops.ed.lib_id_load_custom_preview, data_block, filepath=preview_path, ) loaded = "FINISHED" in result if loaded: print(" Thumbnail preview applied successfully.") return except Exception as e: print( "Failed to load thumbnail preview, falling back to generating preview: " f"{e}" ) try: if _op_poll(bpy.ops.ed.lib_id_generate_preview, data_block): _op_call(bpy.ops.ed.lib_id_generate_preview, data_block) print(" Generated preview applied successfully.") except Exception: print("Failed to generate preview, asset will have no preview") return def _write_metadata(data_block, asset_data: dict) -> None: """Write asset metadata to the asset data block. This includes tags, author, and description.""" if data_block is None: return print("📝 writing asset metadata") tags = data_block.asset_data.tags for t in tags: tags.remove(t) tags = data_block.asset_data.tags for t in asset_data.get("tags", []): tags.new(str(t)) # assign more metadata in tags, so it is searchable in asset browser, and also visible in metadata panel other_meta = {} if asset_data.get("assetBaseId"): other_meta["id"] = asset_data["assetBaseId"] if asset_data.get("assetType"): other_meta["asset_type"] = asset_data.get("assetType", "") if asset_data.get("sourceAppVersion"): other_meta["source_app_version"] = asset_data.get("sourceAppVersion", "") # further custom meta from dictParameters dict_parameters = asset_data.get("dictParameters", {}) if "category" in dict_parameters: other_meta["category"] = dict_parameters["category"] if "condition" in dict_parameters: other_meta["condition"] = dict_parameters["condition"] if "pbrType" in dict_parameters: other_meta["pbr_type"] = dict_parameters["pbrType"] if "materialStyle" in dict_parameters: other_meta["material_style"] = dict_parameters["materialStyle"] if "engine" in dict_parameters: other_meta["engine"] = dict_parameters["engine"] if "animated" in dict_parameters and dict_parameters["animated"]: other_meta["animated"] = "yes" if "simulation" in dict_parameters and dict_parameters["simulation"]: other_meta["simulation"] = "yes" # ad additional metadata to tags for key, value in other_meta.items(): tags.new(f"{key}:{value}") description = asset_data.get("description", "") author_name = _resolve_author_name(asset_data) data_block.asset_data.author = author_name data_block.asset_data.description = description if hasattr(data_block.asset_data, "copyright"): data_block.asset_data.copyright = asset_data.get("copyright", "") if hasattr(data_block.asset_data, "license"): data_block.asset_data.license = asset_data.get("license", "") def _sanitize_catalog_segment(segment: str) -> str: cleaned = (segment or "").strip() cleaned = cleaned.replace(":", "-").replace("/", "-").replace("\\", "-") return cleaned or "Uncategorized" def _resolve_category_segments(asset_data: dict) -> list[str]: category_slug = asset_data.get("category") or asset_data.get( "dictParameters", {} ).get("category") if not category_slug: return [] parts = [p for p in str(category_slug).split("-") if p] return [_sanitize_catalog_segment(part) for part in parts] def _resolve_catalog_path_parts(asset_data: dict) -> list[str]: asset_type = (asset_data.get("assetType") or "").lower() catalog_map = { "model": "Models", "material": "Materials", "hdr": "HDRIs", "hdri": "HDRIs", "printable": "Printables", "scene": "Scenes", "brush": "Brushes", "texture": "Textures", "nodegroup": "Node Groups", "addon": "Add-ons", } parts = [] top_level = catalog_map.get(asset_type) if top_level: parts.append(top_level) parts.extend(_resolve_category_segments(asset_data)) bk_logger.debug( "Resolved catalog path parts: %s for asset type '%s' and category '%s'", parts, asset_type, asset_data.get("category"), ) return parts def _ensure_catalog_exists( library_path: str, catalog_path: str, catalog_simple_name: str ) -> str: """Ensure that an asset catalog exists for the given path. Returns the catalog ID if it exists or was created successfully, otherwise returns an empty string. """ # TODO use python exposed API, (currently only C API is available)) head = ( "# This is an Asset Catalog Definition file for Blender.\n" "#\n" "# Empty lines and lines starting with `#` will be ignored.\n" "# The first non-ignored line should be the version indicator.\n" '# Other lines are of the format "UUID:catalog/path/for/assets:simple catalog name"\n' "\n" "VERSION 1\n" ) # check if file exists in library, if not create it # this is needed to assign asset to catalog, otherwise it will be assigned to "unc cat_path = os.path.join(library_path, "blender_assets.cats.txt") if not os.path.exists(cat_path): try: with open(cat_path, "w", encoding="utf-8") as f: f.write(head) except Exception as e: traceback.print_exc() return "" # create file if it does not exists with uuid and name # get all catalogs in the file, if there is one with same name, return its uuid cats = {} # read existing catalogs with open(cat_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue parts = line.split(":") if len(parts) != 3: continue cat_uuid, cat_path_entry, _ = parts cats[cat_path_entry] = cat_uuid # use regex to found the library name if catalog_path in cats: return cats[catalog_path] # create new catalog entry new_uuid = str(uuid.uuid4()) cats[catalog_path] = new_uuid # write new catalog entry to file try: with open(cat_path, "a", encoding="utf-8") as f: f.write(f"{new_uuid}:{catalog_path}:{catalog_simple_name}\n") return new_uuid except Exception as e: traceback.print_exc() return "" def _assign_asset_catalog( data_block, asset_data: dict, blend_path: str | None = None ) -> None: """Assign the asset to a catalog based on its type and category hierarchy.""" if data_block is None or data_block.asset_data is None: return print("📁 assigning asset to catalog") if not blend_path: print("Asset catalog assignment skipped: blend path missing.") return library_dir = _library_dir_from_fpath(blend_path) library_dir = os.path.abspath(bpy.path.abspath(library_dir)) print("Resolved library directory: '%s'" % library_dir) if not os.path.exists(library_dir): try: os.makedirs(library_dir, exist_ok=True) except Exception: print(f"Asset catalog assignment skipped: cannot create '{library_dir}'.") return path_parts = _resolve_catalog_path_parts(asset_data) print(f"Resolved catalog path parts: {path_parts}") if not path_parts: print( "Asset catalog assignment skipped: could not resolve catalog path from asset data." ) return catalog_path = "/".join(path_parts) catalog_simple_name = path_parts[-1] print( "Resolved catalog path: '%s' and simple name: '%s' for asset with type '%s'" % ( catalog_path, catalog_simple_name, asset_data.get("assetType"), ) ) catalog_id = _ensure_catalog_exists(library_dir, catalog_path, catalog_simple_name) if not catalog_id: print("Asset catalog assignment skipped: failed to create catalog entry.") return print(f"Assigning asset to catalog '{catalog_path}' with ID {catalog_id}") asset_meta = data_block.asset_data if hasattr(asset_meta, "catalog_id"): try: asset_meta.catalog_id = catalog_id except AttributeError: print("Asset catalog assignment skipped: catalog_id is read-only.") else: print( "Asset catalog assignment skipped: asset_data does not have catalog_id attribute." ) def unpack_asset(data): """Unpack asset data into the current Blender file. This function handles unpacking textures, writing metadata, applying previews, and assigning the asset to a catalog based on its type. """ asset_data = data["asset_data"] # assume unpack is true unpack = True if data.get("prefs", {}).get("unpack_files") is False: unpack = False # assume write_metadata is true write_metadata = True if data.get("prefs", {}).get("write_asset_metadata") is False: write_metadata = False if unpack: print("🗃️ unpacking asset") resolution = get_resolution_from_file_path(bpy.data.filepath) # TODO - passing resolution inside asset data might not be the best solution tex_dir_path = paths.get_texture_directory(asset_data, resolution=resolution) tex_dir_abs = bpy.path.abspath(tex_dir_path) if not os.path.exists(tex_dir_abs): try: os.mkdir(tex_dir_abs) except Exception as e: traceback.print_exc() bpy.data.use_autopack = False for image in bpy.data.images: if image.name == "Render Result": continue # skip rendered images # Keep per-packed-file paths (UDIM/sequence) so image.unpack writes all frames/tiles. if len(image.packed_files) > 0: unpack_paths = [] for pf in image.packed_files: pf_path = get_texture_filepath( tex_dir_path, image, resolution=resolution, source_path=pf.filepath, ) unpack_paths.append(pf_path) pf.filepath = pf_path # bpy.path.abspath(pf_path) image_path = get_texture_filepath( tex_dir_path, image, resolution=resolution, source_path=image.filepath, ) image.filepath = image_path # bpy.path.abspath(image_path) image.filepath_raw = image_path # bpy.path.abspath(image_path) print( f"🖼️ unpacking file: {image.name} - {image.filepath}, " f"{len(unpack_paths)} packed file(s)" ) image.unpack(method="WRITE_ORIGINAL") else: fp = get_texture_filepath( tex_dir_path, image, resolution=resolution, source_path=image.filepath, ) print(f"🖼️ unpacking file: {image.name} - {image.filepath}, {fp}") image.filepath = fp # bpy.path.abspath(fp) image.filepath_raw = fp # bpy.path.abspath(fp) # mark asset browser asset print("🏷️ marking asset") data_block = None if asset_data["assetType"] in ("model", "printable"): # Mark the main collection as the asset instead of the root object. # When upload_bg.py prepares a model for upload it places ALL objects # (including children) as direct members of a single named collection that # is a direct child of the scene collection. If we mark only the root # object, Blender's asset browser will import just that one object and # its children are left behind, resulting in an empty appearing in the # scene. Marking the collection lets Blender create a proper collection # instance that shows every object in the hierarchy. # # upload_bg.py calls asset_mark() on the root object before uploading, so # every downloaded .blend arrives with the root object already marked as an # asset. Clear those object-level marks first so only the collection entry # appears in the asset browser (no duplicates). for ob in bpy.data.objects: if ob.asset_data is not None: ob.asset_clear() scene_collection = bpy.context.scene.collection main_collection = None for col in scene_collection.children: has_root_objects = any( ob.parent is None and ob in bpy.context.visible_objects for ob in col.objects ) if has_root_objects: main_collection = col break if main_collection is not None: if bpy.app.version >= (3, 0, 0): main_collection.asset_mark() # Store asset_data on the collection so that collection-instance # EMPTYs added via Blender's native asset browser can be identified # as BlenderKit assets (rating, bookmarking, etc.). main_collection["asset_data"] = _sanitize_for_idprops(asset_data) data_block = main_collection else: # Fallback: no suitable collection found – mark root visible objects. for ob in bpy.data.objects: if ob.parent is None and ob in bpy.context.visible_objects: if bpy.app.version >= (3, 0, 0): ob.asset_mark() data_block = ob elif asset_data["assetType"] == "material": for m in bpy.data.materials: if bpy.app.version >= (3, 0, 0): m.asset_mark() data_block = m elif asset_data["assetType"] == "scene": if bpy.app.version >= (3, 0, 0): bpy.context.scene.asset_mark() data_block = bpy.context.scene elif asset_data["assetType"] == "brush": for b in bpy.data.brushes: if hasattr(b, "asset_data") and b.asset_data is not None: if bpy.app.version >= (3, 0, 0): b.asset_mark() data_block = b elif asset_data["assetType"] == "nodegroup": for ng in bpy.data.node_groups: if hasattr(ng, "asset_data") and ng.asset_data is not None: if ( hasattr(ng.asset_data, "copyright") and ng.asset_data.copyright == "Blender Foundation" or ng.asset_data.is_property_readonly("author") ): continue # skip official node groups, they are not assets if bpy.app.version >= (3, 0, 0): ng.asset_mark() data_block = ng if bpy.app.version >= (3, 0, 0) and data_block is not None and write_metadata: _write_metadata(data_block, asset_data) _apply_asset_preview(data_block, asset_data) _assign_asset_catalog( data_block, asset_data, blend_path=data.get("fpath"), ) # if this isn't here, blender crashes when saving file. if bpy.app.version >= (3, 0, 0): bpy.context.preferences.filepaths.file_preview_type = "NONE" bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath, compress=False) # now try to delete the .blend1 file try: os.remove(bpy.data.filepath + "1") except Exception as e: traceback.print_exc() bpy.ops.wm.quit_blender() sys.exit() def patch_imports(addon_module_name: str): """Patch the python configuration, so the relative imports work as expected. There are few problems to fix: 1. Script is not recognized as module which would break at relative import. We need to set __package__ = "blenderkit" for legacy addon. Or __package__ = "bl_ext.user_default.blenderkit"/"bl_ext.blenderkit_com.blenderkit_com". Otherwise we would see: from . import paths ImportError: attempted relative import with no known parent package 2. External repository (e.g. blenderkit_com) is not available as we start with --factory-startup, we need to enable it. We can add it as LOCAL repo as the add-on is installed and we do not care about updates or anything in this BG script. Otherwise we would see: from . import paths ModuleNotFoundError: No module named 'bl_ext.blenderkit_com'; 'bl_ext' is not a package """ print(f"- Setting __package__ = '{addon_module_name}'") global __package__ __package__ = addon_module_name if bpy.app.version < (4, 2, 0): print( f"- Skipping, Blender version {bpy.app.version} < (4,2,0), no need to handle repositories" ) return parts = addon_module_name.split(".") if len(parts) != 3: print("- Skipping, addon_module_name does not contain 3 parts") return bpy.ops.preferences.extension_repo_add( # type: ignore[attr-defined] name=parts[1], type="LOCAL" ) # Local is enough print(f"- Local repository {parts[1]} added") if __name__ == "__main__": # args order must match the order in blenderkit/client/download.go:UnpackAsset()! json_path = sys.argv[-2] patch_imports( sys.argv[-1] ) # will be something like: "bl_ext.user_default.blenderkit" or "bl_ext.blenderkit_com.blenderkit", or just "blenderkit" on Blender < 4.2 from . import paths print(json_path) with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) unpack_asset(data)