save startup blend for animation tab & whatnot
This commit is contained in:
2026-04-08 12:10:18 -06:00
parent 57a652524a
commit 692e200ffe
180 changed files with 12336 additions and 3431 deletions
+364 -41
View File
@@ -20,12 +20,15 @@ import json
import logging
import os
import re
import sys
import requests
import tempfile
from pathlib import Path
from typing import Optional
from typing import Optional, Any
import bpy
from bpy.props import ( # TODO only keep the ones actually used when cleaning
IntProperty,
BoolProperty,
EnumProperty,
StringProperty,
@@ -49,7 +52,6 @@ from . import (
search,
)
NAME_MINIMUM = 3
NAME_MAXIMUM = 40
TAGS_MINIMUM = 3
@@ -64,6 +66,15 @@ licenses = (
)
def wire_thumbnail_upload_enabled() -> bool:
"""Feature gate for experimental wireframe thumbnail uploads."""
addon = bpy.context.preferences.addons.get(__package__)
if addon is None:
return False
preferences = addon.preferences
return getattr(preferences, "enable_wire_thumbnail_upload", False)
def add_version(data):
data["sourceAppName"] = "blender"
data["sourceAppVersion"] = utils.get_blender_version()
@@ -199,7 +210,7 @@ def check_missing_data(asset_type, props, upload_set):
)
if "THUMBNAIL" in upload_set:
if asset_type in ("MODEL", "SCENE", "MATERIAL", "PRINTABLE"):
if asset_type in ("MODEL", "SCENE", "MATERIAL", "PRINTABLE", "BRUSH"):
thumb_path = bpy.path.abspath(props.thumbnail)
if props.thumbnail == "":
write_to_report(
@@ -213,23 +224,6 @@ def check_missing_data(asset_type, props, upload_set):
"Thumbnail filepath does not exist on the disk.\n"
" Please check the filepath and try again.",
)
if asset_type == "BRUSH":
brush = utils.get_active_brush()
if brush is not None:
thumb_path = bpy.path.abspath(brush.icon_filepath)
if thumb_path == "":
write_to_report(
props,
"Brush Icon Filepath has not been provided.\n"
" Please check Custom Icon option add a Brush Icon in JPG or PNG format, ensuring at least 1024x1024 pixels.",
)
elif not os.path.exists(Path(thumb_path)):
write_to_report(
props,
"Brush Icon Filepath does not exist on the disk.\n"
" Please check the filepath and try again.",
)
if "PHOTO_THUMBNAIL" in upload_set: # for printable assets
# Add validation for the photo thumbnail for printable assets
# only if it's in the upload set
@@ -251,6 +245,24 @@ def check_missing_data(asset_type, props, upload_set):
" Please check the filepath and try again.",
)
if wire_thumbnail_upload_enabled() and "WIRE_THUMBNAIL" in upload_set:
if props.wire_thumbnail_will_upload_on_website:
pass
else:
wire_thumb_path = bpy.path.abspath(props.wire_thumbnail)
if props.wire_thumbnail == "":
write_to_report(
props,
"A wireframe thumbnail image has not been provided.\n"
" Please add a wireframe thumbnail in JPG or PNG format, ensuring at least 1024x1024 pixels.",
)
elif not os.path.exists(Path(wire_thumb_path)):
write_to_report(
props,
"Wireframe thumbnail filepath does not exist on the disk.\n"
" Please check the filepath and try again.",
)
if props.is_private == "PUBLIC":
check_public_requirements(props)
@@ -333,7 +345,7 @@ def sub_to_camel(content):
def get_upload_data(caller=None, context=None, asset_type=None):
"""
works though metadata from addom props and prepares it for upload to dicts.
works though metadata from addon props and prepares it for upload to dicts.
Parameters
----------
caller - upload operator or none
@@ -342,8 +354,8 @@ def get_upload_data(caller=None, context=None, asset_type=None):
Returns
-------
export_ddta- all extra data that the process needs to upload and communicate with UI from a thread.
- eval_path_computing - string path to UI prop that denots if upload is still running
export_data- all extra data that the process needs to upload and communicate with UI from a thread.
- eval_path_computing - string path to UI prop that denotes if upload is still running
- eval_path_state - string path to UI prop that delivers messages about upload to ui
- eval_path - path to object holding upload data to be able to access it with various further commands
- models - in case of model upload, list of objects
@@ -353,10 +365,13 @@ def get_upload_data(caller=None, context=None, asset_type=None):
"""
user_preferences = bpy.context.preferences.addons[__package__].preferences
export_data = {
export_data: dict[str, Any] = {
# "type": asset_type,
}
upload_params = {}
upload_params: dict[str, Any] = {}
# initialize here to prevent unbound
upload_data: dict[str, Any] = {}
if asset_type in ("MODEL", "PRINTABLE"):
# Prepare to save the file
mainmodel = utils.get_active_model()
@@ -375,6 +390,13 @@ def get_upload_data(caller=None, context=None, asset_type=None):
export_data["photo_thumbnail_path"] = bpy.path.abspath(
props.photo_thumbnail
)
# Add wire thumbnail path to export_data for models and printable assets
if (
wire_thumbnail_upload_enabled()
and asset_type in ("MODEL", "SCENE", "PRINTABLE")
and props.wire_thumbnail
):
export_data["wire_thumbnail_path"] = bpy.path.abspath(props.wire_thumbnail)
eval_path_computing = (
"bpy.data.objects['%s'].blenderkit.uploading" % mainmodel.name
@@ -511,9 +533,9 @@ def get_upload_data(caller=None, context=None, asset_type=None):
"animated": props.animated,
# "simulation": props.simulation,
"purePbr": props.pbr,
"faceCount": 1, # props.face_count,
"faceCountRender": 1, # props.face_count_render,
"objectCount": 1, # props.object_count,
"faceCount": max(0, props.face_count),
"faceCountRender": max(0, props.face_count_render),
"objectCount": max(0, props.object_count),
# "scene": props.is_scene,
}
if props.use_design_year:
@@ -588,7 +610,7 @@ def get_upload_data(caller=None, context=None, asset_type=None):
# props.name = brush.name
export_data["brush"] = str(brush.name)
export_data["thumbnail_path"] = bpy.path.abspath(brush.icon_filepath)
export_data["thumbnail_path"] = bpy.path.abspath(props.thumbnail)
eval_path_computing = "bpy.data.brushes['%s'].blenderkit.uploading" % brush.name
eval_path_state = "bpy.data.brushes['%s'].blenderkit.upload_state" % brush.name
@@ -749,7 +771,7 @@ def update_free_full(self, context):
message="Any material uploaded to BlenderKit is free."
" However, it can still earn money for the author,"
" based on our fair share system. "
"Part of subscription is sent to artists based on usage by paying users.",
"Part of subscription is sent to authors based on usage by paying users.",
)
@@ -883,6 +905,38 @@ class FastMetadata(bpy.types.Operator):
update=update_free_full,
)
# Design metadata
manufacturer: StringProperty( # type: ignore[valid-type]
name="Manufacturer",
description="Manufacturer, company making a design piece or product.",
default="",
)
designer: StringProperty( # type: ignore[valid-type]
name="Designer",
description="Author of the original design piece depicted.",
default="",
)
design_collection: StringProperty( # type: ignore[valid-type]
name="Design Collection",
description="Name of the collection this design belongs to.",
default="",
)
design_variant: StringProperty( # type: ignore[valid-type]
name="Design Variant",
description="Colour or material variant of the product.",
default="",
)
use_design_year: BoolProperty( # type: ignore[valid-type]
name="Use Design Year",
description="Whether to include the design year in the metadata. If enabled, the design year will be included as a parameter in the asset metadata.",
default=False,
)
design_year: IntProperty( # type: ignore[valid-type]
name="Design Year",
description="When this item was designed.",
default=1960,
)
####################
@classmethod
@@ -905,6 +959,13 @@ class FastMetadata(bpy.types.Operator):
layout.prop(self, "free_full", expand=True)
if self.is_private == "PUBLIC":
layout.prop(self, "license")
layout.prop(self, "manufacturer")
layout.prop(self, "designer")
layout.prop(self, "design_collection")
layout.prop(self, "design_variant")
layout.prop(self, "use_design_year")
if self.use_design_year:
layout.prop(self, "design_year")
# layout.label(text="Content Flags:")
content_flag_box = layout.box()
content_flag_box.alignment = "EXPAND"
@@ -934,9 +995,38 @@ class FastMetadata(bpy.types.Operator):
},
],
}
# Optional design-related parameters
extra_parameters = []
if self.designer:
extra_parameters.append(
{"parameterType": "designer", "value": self.designer}
)
if self.manufacturer:
extra_parameters.append(
{"parameterType": "manufacturer", "value": self.manufacturer}
)
if self.design_collection:
extra_parameters.append(
{
"parameterType": "designCollection",
"value": self.design_collection,
}
)
if self.design_variant:
extra_parameters.append(
{"parameterType": "designVariant", "value": self.design_variant}
)
if self.use_design_year:
extra_parameters.append(
{"parameterType": "designYear", "value": self.design_year}
)
if extra_parameters:
metadata["parameters"].extend(extra_parameters)
url = f"{paths.BLENDERKIT_API}/assets/{self.asset_id}/"
messages = {
"success": "Metadata upload succeded",
"success": "Metadata upload succeeded",
"error": "Metadata upload failed",
}
client_lib.nonblocking_request(url, "PATCH", {}, metadata, messages)
@@ -984,6 +1074,13 @@ class FastMetadata(bpy.types.Operator):
"sexualizedContent", False
)
params = asset_data.get("dictParameters", {})
self.designer = params.get("designer", "")
self.manufacturer = params.get("manufacturer", "")
self.design_collection = params.get("designCollection", "")
self.design_variant = params.get("designVariant", "")
self.design_year = params.get("designYear", 1960)
wm = context.window_manager
return wm.invoke_props_dialog(self, width=600)
@@ -991,7 +1088,7 @@ class FastMetadata(bpy.types.Operator):
def get_upload_location(props):
"""
not used by now, gets location of uploaded asset - potentially usefull if we draw a nice upload gizmo in viewport.
not used by now, gets location of uploaded asset - potentially useful if we draw a nice upload gizmo in viewport.
Parameters
----------
props
@@ -1037,6 +1134,179 @@ def storage_quota_available(props) -> bool:
return False
def _get_upload_datablock(asset_type: str):
if bpy.app.version < (3, 0, 0):
return None
if asset_type in ("MODEL", "PRINTABLE"):
return utils.get_active_model()
if asset_type == "SCENE":
return bpy.context.scene
if asset_type == "MATERIAL":
obj = bpy.context.active_object
if obj is not None:
return obj.active_material
return None
if asset_type == "BRUSH":
return utils.get_active_brush()
if asset_type == "NODEGROUP":
return bpy.context.window_manager.blenderkitUI.nodegroup_upload
return None
def ensure_asset_metadata_on_datablock(asset_type: str, props) -> None:
"""Write tags/description/author into the datablock before we save for upload."""
data_block = _get_upload_datablock(asset_type)
if data_block is None:
return
if getattr(data_block, "asset_data", None) is None:
mark_fn = getattr(data_block, "asset_mark", None)
if callable(mark_fn):
mark_fn()
asset_meta = getattr(data_block, "asset_data", None)
if asset_meta is None:
return
try:
tags_prop = asset_meta.tags
for tag in list(tags_prop):
tags_prop.remove(tag)
for tag in utils.string2list(props.tags):
tags_prop.new(str(tag))
profile = global_vars.BKIT_PROFILE
author_name = getattr(profile, "fullName", "") or getattr(
profile, "username", ""
)
asset_meta.author = author_name
asset_meta.description = props.description
# inject also additional metadata
other_meta = {}
if props.id:
other_meta["id"] = props.asset_base_id
# further custom meta from dictParameters
if props.condition:
other_meta["condition"] = props.condition
if props.pbr_type:
other_meta["pbr_type"] = props.pbr_type
if props.style:
other_meta["style"] = props.style
if props.engine:
other_meta["engine"] = props.engine
if props.animated:
other_meta["animated"] = "yes"
if props.simulation:
other_meta["simulation"] = "yes"
# ad additional metadata to tags
for key, value in other_meta.items():
tags_prop.new(f"{key}:{value}")
except Exception as e: # pragma: no cover - defensive for asset_data API quirks
bk_logger.warning("Failed to write asset metadata before upload: %s", e)
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, props) -> 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
thumbnail = getattr(props, "thumbnail", "")
if not thumbnail:
return
thmb_path = bpy.path.abspath(thumbnail)
if not os.path.exists(thmb_path):
return
if thmb_path:
clean_path = _sanitize_preview_image(thmb_path)
if clean_path:
thmb_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=thmb_path,
)
loaded = "FINISHED" in result
if loaded:
bk_logger.info("Thumbnail preview applied successfully.")
return
except Exception as e:
bk_logger.warning(
"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)
bk_logger.info("Generated preview applied successfully.")
except Exception:
bk_logger.warning("Failed to generate preview, asset will have no preview")
return
def auto_fix(asset_type=""):
# this applies various procedures to ensure coherency in the database.
asset = utils.get_active_asset()
@@ -1067,6 +1337,9 @@ def prepare_asset_data(self, context, asset_type, reupload, upload_set):
if props.report != "":
return False, None, None
ensure_asset_metadata_on_datablock(asset_type, props)
apply_asset_preview(_get_upload_datablock(asset_type), props)
if not reupload:
props.asset_base_id = ""
props.id = ""
@@ -1097,6 +1370,13 @@ def prepare_asset_data(self, context, asset_type, reupload, upload_set):
props.uploading = False
return False, None, None
# check if we have wire_thumbnail
if wire_thumbnail_upload_enabled() and "wire_thumbnail" in upload_set:
if not os.path.exists(export_data.get("wire_thumbnail_path", "")):
props.upload_state = "0% - wire thumbnail not found"
props.uploading = False
return False, None, None
# save a copy of the file for processing. Only for blend files
_, ext = os.path.splitext(bpy.data.filepath)
if not ext:
@@ -1164,8 +1444,17 @@ class UploadOperator(Operator):
# Add new property for photo thumbnail
photo_thumbnail: BoolProperty(name="photo thumbnail", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
# Add new property for wire thumbnail
wire_thumbnail: BoolProperty(name="wire thumbnail", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
main_file: BoolProperty(name="main file", default=False, options={"SKIP_SAVE"}) # type: ignore[valid-type]
skip_hdr_tune_popup: BoolProperty( # type: ignore[valid-type]
name="Skip HDR tune popup",
default=False,
options={"SKIP_SAVE", "HIDDEN"},
)
@classmethod
def poll(cls, context):
return utils.uploadable_asset_poll()
@@ -1173,6 +1462,7 @@ class UploadOperator(Operator):
def execute(self, context):
bpy.ops.object.blenderkit_auto_tags()
props = utils.get_upload_props()
wire_upload_enabled = wire_thumbnail_upload_enabled()
upload_set = []
if not self.reupload:
@@ -1180,6 +1470,14 @@ class UploadOperator(Operator):
# Add photo_thumbnail to the upload set for printable assets
if self.asset_type == "PRINTABLE" and props.photo_thumbnail:
upload_set.append("photo_thumbnail")
# add wire_thumbnail for models if it exists
if (
wire_upload_enabled
and self.asset_type in {"MODEL", "SCENE", "PRINTABLE"}
and props.wire_thumbnail
):
upload_set.append("wire_thumbnail")
else:
if self.metadata:
upload_set.append("METADATA")
@@ -1187,6 +1485,8 @@ class UploadOperator(Operator):
upload_set.append("THUMBNAIL")
if self.photo_thumbnail:
upload_set.append("photo_thumbnail")
if wire_upload_enabled and self.wire_thumbnail:
upload_set.append("wire_thumbnail")
if self.main_file:
upload_set.append("MAINFILE")
@@ -1207,6 +1507,7 @@ class UploadOperator(Operator):
props.uploading = True
client_lib.asset_upload(upload_data, export_data, upload_set)
return {"FINISHED"}
def draw(self, context):
@@ -1227,6 +1528,14 @@ class UploadOperator(Operator):
if self.asset_type == "PRINTABLE":
layout.prop(self, "photo_thumbnail")
# Show wire_thumbnail option for models, scenes, and printable assets
if wire_thumbnail_upload_enabled() and self.asset_type in {
"MODEL",
"SCENE",
"PRINTABLE",
}:
layout.prop(self, "wire_thumbnail")
if props.asset_base_id != "" and not self.reupload:
utils.label_multiline(
layout,
@@ -1290,7 +1599,7 @@ class UploadOperator(Operator):
utils.label_multiline(
layout,
width=500,
text="Would you like tu upload your asset to BlenderKit?",
text="Would you like to upload your asset to BlenderKit?",
)
def invoke(self, context, event):
@@ -1300,6 +1609,20 @@ class UploadOperator(Operator):
)
return {"CANCELLED"}
ui_props = bpy.context.window_manager.blenderkitUI
if (
self.asset_type == "HDR"
and ui_props.hdr_use_custom_thumbnail_tone
and not self.skip_hdr_tune_popup
):
bpy.ops.wm.blenderkit_hdr_thumbnail_tune(
"INVOKE_DEFAULT",
trigger_upload=True,
upload_reupload=self.reupload,
)
return {"CANCELLED"}
if self.asset_type == "HDR":
# getting upload data for images ensures true_hdr check so users can be informed about their handling
# simple 360 photos or renders with LDR are hidden by default..
@@ -1425,7 +1748,7 @@ def handle_asset_upload(task: client_tasks.Task):
task.message, type="ERROR", details=task.message_detailed
)
# crazy shit to parse stupid Django incosistent error messages
# crazy shit to parse stupid Django inconsistent error messages
if "detail" in task.result:
if type(task.result["detail"]) == dict:
for key in task.result["detail"]:
@@ -1458,7 +1781,7 @@ def handle_asset_upload(task: client_tasks.Task):
if task.status == "finished":
asset.uploading = False
return reports.add_report("Upload successfull")
return reports.add_report("Upload successful")
def handle_asset_metadata_upload(task: client_tasks.Task):
@@ -1469,20 +1792,20 @@ def handle_asset_metadata_upload(task: client_tasks.Task):
new_asset_base_id = task.result.get("assetBaseId", "")
if new_asset_base_id != "":
asset.asset_base_id = new_asset_base_id
bk_logger.info(f"Assigned new asset.asset_base_id: {new_asset_base_id}")
bk_logger.info("Assigned new asset.asset_base_id: %s", new_asset_base_id)
else:
asset.asset_base_id = task.data["export_data"]["assetBaseId"]
bk_logger.info(f"Assigned original asset.asset_base_id: {asset.asset_base_id}")
bk_logger.info("Assigned original asset.asset_base_id: %s", asset.asset_base_id)
new_asset_id = task.result.get("id", "")
if new_asset_id != "":
asset.id = new_asset_id
bk_logger.info(f"Assigned new asset.id: {new_asset_id}")
bk_logger.info("Assigned new asset.id: %s", new_asset_id)
else:
asset.id = task.data["export_data"]["id"]
bk_logger.info(f"Assigned original asset.id: {asset.id}")
bk_logger.info("Assigned original asset.id: %s", asset.id)
return reports.add_report("Metadata upload successfull")
return reports.add_report("Metadata upload successful")
def patch_individual_parameter(asset_id="", param_name="", param_value="", api_key=""):
@@ -1593,7 +1916,7 @@ def mark_for_thumbnail(
asset_id, "markThumbnailRender", json_data, api_key
)
except Exception as e:
bk_logger.error(f"Failed to mark asset for thumbnail regeneration: {e}")
bk_logger.error("Failed to mark asset for thumbnail regeneration: %s", e)
return False