diff --git a/client/ayon_core/hosts/blender/api/lib.py b/client/ayon_core/hosts/blender/api/lib.py
index 458a275b51..32137f0fcd 100644
--- a/client/ayon_core/hosts/blender/api/lib.py
+++ b/client/ayon_core/hosts/blender/api/lib.py
@@ -33,7 +33,7 @@ def load_scripts(paths):
if register:
try:
register()
- except:
+ except: # noqa E722
traceback.print_exc()
else:
print("\nWarning! '%s' has no register function, "
@@ -45,7 +45,7 @@ def load_scripts(paths):
if unregister:
try:
unregister()
- except:
+ except: # noqa E722
traceback.print_exc()
def test_reload(mod):
@@ -57,7 +57,7 @@ def load_scripts(paths):
try:
return importlib.reload(mod)
- except:
+ except: # noqa E722
traceback.print_exc()
def test_register(mod):
diff --git a/client/ayon_core/hosts/blender/api/plugin.py b/client/ayon_core/hosts/blender/api/plugin.py
index 6c9bfb6569..4a13d16805 100644
--- a/client/ayon_core/hosts/blender/api/plugin.py
+++ b/client/ayon_core/hosts/blender/api/plugin.py
@@ -143,13 +143,19 @@ def deselect_all():
if obj.mode != 'OBJECT':
modes.append((obj, obj.mode))
bpy.context.view_layer.objects.active = obj
- bpy.ops.object.mode_set(mode='OBJECT')
+ context_override = create_blender_context(active=obj)
+ with bpy.context.temp_override(**context_override):
+ bpy.ops.object.mode_set(mode='OBJECT')
- bpy.ops.object.select_all(action='DESELECT')
+ context_override = create_blender_context()
+ with bpy.context.temp_override(**context_override):
+ bpy.ops.object.select_all(action='DESELECT')
for p in modes:
bpy.context.view_layer.objects.active = p[0]
- bpy.ops.object.mode_set(mode=p[1])
+ context_override = create_blender_context(active=p[0])
+ with bpy.context.temp_override(**context_override):
+ bpy.ops.object.mode_set(mode=p[1])
bpy.context.view_layer.objects.active = active
diff --git a/client/ayon_core/hosts/houdini/plugins/publish/collect_cache_farm.py b/client/ayon_core/hosts/houdini/plugins/publish/collect_cache_farm.py
index 040ad68a1a..e931c7bf1b 100644
--- a/client/ayon_core/hosts/houdini/plugins/publish/collect_cache_farm.py
+++ b/client/ayon_core/hosts/houdini/plugins/publish/collect_cache_farm.py
@@ -7,7 +7,8 @@ from ayon_core.hosts.houdini.api import lib
class CollectDataforCache(pyblish.api.InstancePlugin):
"""Collect data for caching to Deadline."""
- order = pyblish.api.CollectorOrder + 0.04
+ # Run after Collect Frames
+ order = pyblish.api.CollectorOrder + 0.11
families = ["ass", "pointcache",
"mantraifd", "redshiftproxy",
"vdbcache"]
diff --git a/client/ayon_core/hosts/houdini/plugins/publish/collect_frames.py b/client/ayon_core/hosts/houdini/plugins/publish/collect_frames.py
index a643ab0d38..b38ebc6e2f 100644
--- a/client/ayon_core/hosts/houdini/plugins/publish/collect_frames.py
+++ b/client/ayon_core/hosts/houdini/plugins/publish/collect_frames.py
@@ -17,7 +17,7 @@ class CollectFrames(pyblish.api.InstancePlugin):
label = "Collect Frames"
families = ["vdbcache", "imagesequence", "ass",
"mantraifd", "redshiftproxy", "review",
- "bgeo"]
+ "pointcache"]
def process(self, instance):
diff --git a/client/ayon_core/hosts/houdini/plugins/publish/extract_alembic.py b/client/ayon_core/hosts/houdini/plugins/publish/extract_alembic.py
index daf30b26ed..7ae476d2b4 100644
--- a/client/ayon_core/hosts/houdini/plugins/publish/extract_alembic.py
+++ b/client/ayon_core/hosts/houdini/plugins/publish/extract_alembic.py
@@ -28,10 +28,15 @@ class ExtractAlembic(publish.Extractor):
staging_dir = os.path.dirname(output)
instance.data["stagingDir"] = staging_dir
- file_name = os.path.basename(output)
+ if instance.data.get("frames"):
+ # list of files
+ files = instance.data["frames"]
+ else:
+ # single file
+ files = os.path.basename(output)
# We run the render
- self.log.info("Writing alembic '%s' to '%s'" % (file_name,
+ self.log.info("Writing alembic '%s' to '%s'" % (files,
staging_dir))
render_rop(ropnode)
@@ -42,7 +47,7 @@ class ExtractAlembic(publish.Extractor):
representation = {
'name': 'abc',
'ext': 'abc',
- 'files': file_name,
+ 'files': files,
"stagingDir": staging_dir,
}
instance.data["representations"].append(representation)
diff --git a/client/ayon_core/hosts/max/api/lib.py b/client/ayon_core/hosts/max/api/lib.py
index d9a3af3336..0e3abe25ec 100644
--- a/client/ayon_core/hosts/max/api/lib.py
+++ b/client/ayon_core/hosts/max/api/lib.py
@@ -6,12 +6,9 @@ import json
from typing import Any, Dict, Union
import six
-import ayon_api
from ayon_core.pipeline import (
get_current_project_name,
- get_current_folder_path,
- get_current_task_name,
colorspace
)
from ayon_core.settings import get_project_settings
diff --git a/client/ayon_core/hosts/maya/api/alembic.py b/client/ayon_core/hosts/maya/api/alembic.py
index bf887df4c7..6bd00e1cb1 100644
--- a/client/ayon_core/hosts/maya/api/alembic.py
+++ b/client/ayon_core/hosts/maya/api/alembic.py
@@ -22,7 +22,6 @@ ALEMBIC_ARGS = {
"melPostJobCallback": str,
"noNormals": bool,
"preRoll": bool,
- "preRollStartFrame": int,
"pythonPerFrameCallback": str,
"pythonPostJobCallback": str,
"renderableOnly": bool,
@@ -54,15 +53,22 @@ def extract_alembic(
endFrame=None,
eulerFilter=True,
frameRange="",
+ melPerFrameCallback=None,
+ melPostJobCallback=None,
noNormals=False,
preRoll=False,
preRollStartFrame=0,
+ pythonPerFrameCallback=None,
+ pythonPostJobCallback=None,
renderableOnly=False,
root=None,
selection=True,
startFrame=None,
step=1.0,
stripNamespaces=True,
+ userAttr=None,
+ userAttrPrefix=None,
+ uvsOnly=False,
uvWrite=True,
verbose=False,
wholeFrameGeo=False,
@@ -102,6 +108,11 @@ def extract_alembic(
string formatted as: "startFrame endFrame". This argument
overrides `startFrame` and `endFrame` arguments.
+ melPerFrameCallback (Optional[str]): MEL callback run per frame.
+
+ melPostJobCallback (Optional[str]): MEL callback after last frame is
+ written.
+
noNormals (bool): When on, normal data from the original polygon
objects is not included in the exported Alembic cache file.
@@ -113,6 +124,11 @@ def extract_alembic(
dependent translations and can be used to evaluate run-up that
isn't actually translated. Defaults to 0.
+ pythonPerFrameCallback (Optional[str]): Python callback run per frame.
+
+ pythonPostJobCallback (Optional[str]): Python callback after last frame
+ is written.
+
renderableOnly (bool): When on, any non-renderable nodes or hierarchy,
such as hidden objects, are not included in the Alembic file.
Defaults to False.
@@ -137,6 +153,15 @@ def extract_alembic(
object with the namespace taco:foo:bar appears as bar in the
Alembic file.
+ userAttr (list of str, optional): A specific user defined attribute to
+ write out. Defaults to [].
+
+ userAttrPrefix (list of str, optional): Prefix filter for determining
+ which user defined attributes to write out. Defaults to [].
+
+ uvsOnly (bool): When on, only uv data for PolyMesh and SubD shapes
+ will be written to the Alembic file.
+
uvWrite (bool): When on, UV data from polygon meshes and subdivision
objects are written to the Alembic file. Only the current UV map is
included.
@@ -183,6 +208,8 @@ def extract_alembic(
# Ensure list arguments are valid.
attr = attr or []
attrPrefix = attrPrefix or []
+ userAttr = userAttr or []
+ userAttrPrefix = userAttrPrefix or []
root = root or []
# Pass the start and end frame on as `frameRange` so that it
@@ -213,8 +240,10 @@ def extract_alembic(
"eulerFilter": eulerFilter,
"noNormals": noNormals,
"preRoll": preRoll,
+ "root": root,
"renderableOnly": renderableOnly,
"uvWrite": uvWrite,
+ "uvsOnly": uvsOnly,
"writeColorSets": writeColorSets,
"writeFaceSets": writeFaceSets,
"wholeFrameGeo": wholeFrameGeo,
@@ -226,9 +255,10 @@ def extract_alembic(
"step": step,
"attr": attr,
"attrPrefix": attrPrefix,
+ "userAttr": userAttr,
+ "userAttrPrefix": userAttrPrefix,
"stripNamespaces": stripNamespaces,
- "verbose": verbose,
- "preRollStartFrame": preRollStartFrame
+ "verbose": verbose
}
# Validate options
@@ -264,6 +294,17 @@ def extract_alembic(
if maya_version >= 2018:
options['autoSubd'] = options.pop('writeCreases', False)
+ # Only add callbacks if they are set so that we're not passing `None`
+ callbacks = {
+ "melPerFrameCallback": melPerFrameCallback,
+ "melPostJobCallback": melPostJobCallback,
+ "pythonPerFrameCallback": pythonPerFrameCallback,
+ "pythonPostJobCallback": pythonPostJobCallback,
+ }
+ for key, callback in callbacks.items():
+ if callback:
+ options[key] = str(callback)
+
# Format the job string from options
job_args = list()
for key, value in options.items():
@@ -297,7 +338,11 @@ def extract_alembic(
# exports are made. (PLN-31)
# TODO: Make sure this actually fixes the issues
with evaluation("off"):
- cmds.AbcExport(j=job_str, verbose=verbose)
+ cmds.AbcExport(
+ j=job_str,
+ verbose=verbose,
+ preRollStartFrame=preRollStartFrame
+ )
if verbose:
log.debug("Extracted Alembic to: %s", file)
diff --git a/client/ayon_core/hosts/maya/plugins/create/create_animation_pointcache.py b/client/ayon_core/hosts/maya/plugins/create/create_animation_pointcache.py
index 08d50a1ab8..069762e4ae 100644
--- a/client/ayon_core/hosts/maya/plugins/create/create_animation_pointcache.py
+++ b/client/ayon_core/hosts/maya/plugins/create/create_animation_pointcache.py
@@ -6,7 +6,6 @@ from ayon_core.lib import (
BoolDef,
NumberDef,
)
-from ayon_core.pipeline import CreatedInstance
def _get_animation_attr_defs(cls):
diff --git a/client/ayon_core/hosts/maya/plugins/publish/extract_pointcache.py b/client/ayon_core/hosts/maya/plugins/publish/extract_pointcache.py
index d7f9594374..cc930e49cc 100644
--- a/client/ayon_core/hosts/maya/plugins/publish/extract_pointcache.py
+++ b/client/ayon_core/hosts/maya/plugins/publish/extract_pointcache.py
@@ -6,6 +6,7 @@ from maya import cmds
from ayon_core.pipeline import publish
from ayon_core.hosts.maya.api.alembic import extract_alembic
from ayon_core.hosts.maya.api.lib import (
+ get_all_children,
suspended_refresh,
maintained_selection,
iter_visible_nodes_in_range
@@ -40,7 +41,6 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
# From settings
attr = []
attrPrefix = []
- autoSubd = False
bake_attributes = []
bake_attribute_prefixes = []
dataFormat = "ogawa"
@@ -63,6 +63,7 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
wholeFrameGeo = False
worldSpace = True
writeColorSets = False
+ writeCreases = False
writeFaceSets = False
writeNormals = True
writeUVSets = False
@@ -173,15 +174,9 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
"writeVisibility": attribute_values.get(
"writeVisibility", self.writeVisibility
),
- "autoSubd": attribute_values.get(
- "autoSubd", self.autoSubd
- ),
"uvsOnly": attribute_values.get(
"uvsOnly", self.uvsOnly
),
- "writeNormals": attribute_values.get(
- "writeNormals", self.writeNormals
- ),
"melPerFrameCallback": attribute_values.get(
"melPerFrameCallback", self.melPerFrameCallback
),
@@ -193,7 +188,12 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
),
"pythonPostJobCallback": attribute_values.get(
"pythonPostJobCallback", self.pythonPostJobCallback
- )
+ ),
+ # Note that this converts `writeNormals` to `noNormals` for the
+ # `AbcExport` equivalent in `extract_alembic`
+ "noNormals": not attribute_values.get(
+ "writeNormals", self.writeNormals
+ ),
}
if instance.data.get("visibleOnly", False):
@@ -249,7 +249,6 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
with maintained_selection():
cmds.select(instance.data["proxy"])
extract_alembic(**kwargs)
-
representation = {
"name": "proxy",
"ext": "abc",
@@ -268,20 +267,6 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
return []
override_defs = OrderedDict({
- "autoSubd": BoolDef(
- "autoSubd",
- label="Auto Subd",
- default=cls.autoSubd,
- tooltip=(
- "If this flag is present and the mesh has crease edges, "
- "crease vertices or holes, the mesh (OPolyMesh) would now "
- "be written out as an OSubD and crease info will be stored"
- " in the Alembic file. Otherwise, creases info won't be "
- "preserved in Alembic file unless a custom Boolean "
- "attribute SubDivisionMesh has been added to mesh node and"
- " its value is true."
- )
- ),
"eulerFilter": BoolDef(
"eulerFilter",
label="Euler Filter",
@@ -354,6 +339,13 @@ class ExtractAlembic(publish.Extractor, AYONPyblishPluginMixin):
default=cls.writeColorSets,
tooltip="Write vertex colors with the geometry."
),
+ "writeCreases": BoolDef(
+ "writeCreases",
+ label="Write Creases",
+ default=cls.writeCreases,
+ tooltip="Write the geometry's edge and vertex crease "
+ "information."
+ ),
"writeFaceSets": BoolDef(
"writeFaceSets",
label="Write Face Sets",
@@ -527,9 +519,7 @@ class ExtractAnimation(ExtractAlembic):
roots = cmds.sets(out_set, query=True) or []
# Include all descendants
- nodes = roots
- nodes += cmds.listRelatives(
- roots, allDescendents=True, fullPath=True
- ) or []
+ nodes = roots.copy()
+ nodes.extend(get_all_children(roots, ignore_intermediate_objects=True))
return nodes, roots
diff --git a/client/ayon_core/hosts/maya/plugins/publish/validate_alembic_options_defaults.py b/client/ayon_core/hosts/maya/plugins/publish/validate_alembic_options_defaults.py
index 5197100406..11f4c313fa 100644
--- a/client/ayon_core/hosts/maya/plugins/publish/validate_alembic_options_defaults.py
+++ b/client/ayon_core/hosts/maya/plugins/publish/validate_alembic_options_defaults.py
@@ -1,3 +1,4 @@
+import inspect
import pyblish.api
from ayon_core.pipeline import OptionalPyblishPluginMixin
@@ -29,29 +30,28 @@ class ValidateAlembicDefaultsPointcache(
@classmethod
def _get_publish_attributes(cls, instance):
- attributes = instance.data["publish_attributes"][
- cls.plugin_name(
- instance.data["publish_attributes"]
- )
- ]
-
- return attributes
+ return instance.data["publish_attributes"][cls.plugin_name]
def process(self, instance):
if not self.is_active(instance.data):
return
settings = self._get_settings(instance.context)
-
attributes = self._get_publish_attributes(instance)
- msg = (
- "Alembic Extract setting \"{}\" is not the default value:"
- "\nCurrent: {}"
- "\nDefault Value: {}\n"
- )
- errors = []
+ invalid = {}
for key, value in attributes.items():
+ if key not in settings:
+ # This may occur if attributes have changed over time and an
+ # existing instance has older legacy attributes that do not
+ # match the current settings definition.
+ self.log.warning(
+ "Publish attribute %s not found in Alembic Export "
+ "default settings. Ignoring validation for attribute.",
+ key
+ )
+ continue
+
default_value = settings[key]
# Lists are best to compared sorted since we cant rely on the order
@@ -61,10 +61,35 @@ class ValidateAlembicDefaultsPointcache(
default_value = sorted(default_value)
if value != default_value:
- errors.append(msg.format(key, value, default_value))
+ invalid[key] = value, default_value
- if errors:
- raise PublishValidationError("\n".join(errors))
+ if invalid:
+ non_defaults = "\n".join(
+ f"- {key}: {value} \t(default: {default_value})"
+ for key, (value, default_value) in invalid.items()
+ )
+
+ raise PublishValidationError(
+ "Alembic extract options differ from default values:\n"
+ f"{non_defaults}",
+ description=self.get_description()
+ )
+
+ @staticmethod
+ def get_description():
+ return inspect.cleandoc(
+ """### Alembic Extract settings differ from defaults
+
+ The alembic export options differ from the project default values.
+
+ If this is intentional you can disable this validation by
+ disabling **Validate Alembic Options Default**.
+
+ If not you may use the "Repair" action to revert all the options to
+ their default values.
+
+ """
+ )
@classmethod
def repair(cls, instance):
@@ -75,13 +100,20 @@ class ValidateAlembicDefaultsPointcache(
)
# Set the settings values on the create context then save to workfile.
- publish_attributes = instance.data["publish_attributes"]
- plugin_name = cls.plugin_name(publish_attributes)
- attributes = cls._get_publish_attributes(instance)
settings = cls._get_settings(instance.context)
- create_publish_attributes = create_instance.data["publish_attributes"]
+ attributes = cls._get_publish_attributes(create_instance)
for key in attributes:
- create_publish_attributes[plugin_name][key] = settings[key]
+ if key not in settings:
+ # This may occur if attributes have changed over time and an
+ # existing instance has older legacy attributes that do not
+ # match the current settings definition.
+ cls.log.warning(
+ "Publish attribute %s not found in Alembic Export "
+ "default settings. Ignoring repair for attribute.",
+ key
+ )
+ continue
+ attributes[key] = settings[key]
create_context.save_changes()
@@ -93,6 +125,6 @@ class ValidateAlembicDefaultsAnimation(
The defaults are defined in the project settings.
"""
- label = "Validate Alembic Options Defaults"
+ label = "Validate Alembic Options Defaults"
families = ["animation"]
plugin_name = "ExtractAnimation"
diff --git a/client/ayon_core/hosts/maya/plugins/workfile_build/script_placeholder.py b/client/ayon_core/hosts/maya/plugins/workfile_build/script_placeholder.py
new file mode 100644
index 0000000000..62e10ba023
--- /dev/null
+++ b/client/ayon_core/hosts/maya/plugins/workfile_build/script_placeholder.py
@@ -0,0 +1,201 @@
+from maya import cmds
+
+from ayon_core.hosts.maya.api.workfile_template_builder import (
+ MayaPlaceholderPlugin
+)
+from ayon_core.lib import NumberDef, TextDef, EnumDef
+from ayon_core.lib.events import weakref_partial
+
+
+EXAMPLE_SCRIPT = """
+# Access maya commands
+from maya import cmds
+
+# Access the placeholder node
+placeholder_node = placeholder.scene_identifier
+
+# Access the event callback
+if event is None:
+ print(f"Populating {placeholder}")
+else:
+ if event.topic == "template.depth_processed":
+ print(f"Processed depth: {event.get('depth')}")
+ elif event.topic == "template.finished":
+ print("Build finished.")
+""".strip()
+
+
+class MayaPlaceholderScriptPlugin(MayaPlaceholderPlugin):
+ """Execute a script at the given `order` during workfile build.
+
+ This is a very low-level placeholder to run Python scripts at a given
+ point in time during the workfile template build.
+
+ It can create either a locator or an objectSet as placeholder node.
+ It defaults to an objectSet, since allowing to run on e.g. other
+ placeholder node members can be useful, e.g. using:
+
+ >>> members = cmds.sets(placeholder.scene_identifier, query=True)
+
+ """
+
+ identifier = "maya.runscript"
+ label = "Run Python Script"
+
+ use_selection_as_parent = False
+
+ def get_placeholder_options(self, options=None):
+ options = options or {}
+ return [
+ NumberDef(
+ "order",
+ label="Order",
+ default=options.get("order") or 0,
+ decimals=0,
+ minimum=0,
+ maximum=999,
+ tooltip=(
+ "Order"
+ "\nOrder defines asset loading priority (0 to 999)"
+ "\nPriority rule is : \"lowest is first to load\"."
+ )
+ ),
+ TextDef(
+ "prepare_script",
+ label="Run at\nprepare",
+ tooltip="Run before populate at prepare order",
+ multiline=True,
+ default=options.get("prepare_script", "")
+ ),
+ TextDef(
+ "populate_script",
+ label="Run at\npopulate",
+ tooltip="Run script at populate node order
"
+ "This is the default behavior",
+ multiline=True,
+ default=options.get("populate_script", EXAMPLE_SCRIPT)
+ ),
+ TextDef(
+ "depth_processed_script",
+ label="Run after\ndepth\niteration",
+ tooltip="Run script after every build depth iteration",
+ multiline=True,
+ default=options.get("depth_processed_script", "")
+ ),
+ TextDef(
+ "finished_script",
+ label="Run after\nbuild",
+ tooltip=(
+ "Run script at build finished.
"
+ "Note: this even runs if other placeholders had "
+ "errors during the build"
+ ),
+ multiline=True,
+ default=options.get("finished_script", "")
+ ),
+ EnumDef(
+ "create_nodetype",
+ label="Nodetype",
+ items={
+ "spaceLocator": "Locator",
+ "objectSet": "ObjectSet"
+ },
+ tooltip=(
+ "The placeholder's node type to be created.
"
+ "Note this only works on create, not on update"
+ ),
+ default=options.get("create_nodetype", "objectSet")
+ ),
+ ]
+
+ def create_placeholder(self, placeholder_data):
+ nodetype = placeholder_data.get("create_nodetype", "objectSet")
+
+ if nodetype == "spaceLocator":
+ super(MayaPlaceholderScriptPlugin, self).create_placeholder(
+ placeholder_data
+ )
+ elif nodetype == "objectSet":
+ placeholder_data["plugin_identifier"] = self.identifier
+
+ # Create maya objectSet on selection
+ selection = cmds.ls(selection=True, long=True)
+ name = self._create_placeholder_name(placeholder_data)
+ node = cmds.sets(selection, name=name)
+
+ self.imprint(node, placeholder_data)
+
+ def prepare_placeholders(self, placeholders):
+ super(MayaPlaceholderScriptPlugin, self).prepare_placeholders(
+ placeholders
+ )
+ for placeholder in placeholders:
+ prepare_script = placeholder.data.get("prepare_script")
+ if not prepare_script:
+ continue
+
+ self.run_script(placeholder, prepare_script)
+
+ def populate_placeholder(self, placeholder):
+
+ populate_script = placeholder.data.get("populate_script")
+ depth_script = placeholder.data.get("depth_processed_script")
+ finished_script = placeholder.data.get("finished_script")
+
+ # Run now
+ if populate_script:
+ self.run_script(placeholder, populate_script)
+
+ if not any([depth_script, finished_script]):
+ # No callback scripts to run
+ if not placeholder.data.get("keep_placeholder", True):
+ self.delete_placeholder(placeholder)
+ return
+
+ # Run at each depth processed
+ if depth_script:
+ callback = weakref_partial(
+ self.run_script, placeholder, depth_script)
+ self.builder.add_on_depth_processed_callback(
+ callback, order=placeholder.order)
+
+ # Run at build finish
+ if finished_script:
+ callback = weakref_partial(
+ self.run_script, placeholder, finished_script)
+ self.builder.add_on_finished_callback(
+ callback, order=placeholder.order)
+
+ # If placeholder should be deleted, delete it after finish so
+ # the scripts have access to it up to the last run
+ if not placeholder.data.get("keep_placeholder", True):
+ delete_callback = weakref_partial(
+ self.delete_placeholder, placeholder)
+ self.builder.add_on_finished_callback(
+ delete_callback, order=placeholder.order + 1)
+
+ def run_script(self, placeholder, script, event=None):
+ """Run script
+
+ Even though `placeholder` is an unused arguments by exposing it as
+ an input argument it means it makes it available through
+ globals()/locals() in the `exec` call, giving the script access
+ to the placeholder.
+
+ For example:
+ >>> node = placeholder.scene_identifier
+
+ In the case the script is running at a callback level (not during
+ populate) then it has access to the `event` as well, otherwise the
+ value is None if it runs during `populate_placeholder` directly.
+
+ For example adding this as the callback script:
+ >>> if event is not None:
+ >>> if event.topic == "on_depth_processed":
+ >>> print(f"Processed depth: {event.get('depth')}")
+ >>> elif event.topic == "on_finished":
+ >>> print("Build finished.")
+
+ """
+ self.log.debug(f"Running script at event: {event}")
+ exec(script, locals())
diff --git a/client/ayon_core/hosts/photoshop/plugins/create/create_image.py b/client/ayon_core/hosts/photoshop/plugins/create/create_image.py
index 26f2469844..a44c3490c6 100644
--- a/client/ayon_core/hosts/photoshop/plugins/create/create_image.py
+++ b/client/ayon_core/hosts/photoshop/plugins/create/create_image.py
@@ -35,8 +35,12 @@ class ImageCreator(Creator):
create_empty_group = False
stub = api.stub() # only after PS is up
- top_level_selected_items = stub.get_selected_layers()
if pre_create_data.get("use_selection"):
+ try:
+ top_level_selected_items = stub.get_selected_layers()
+ except ValueError:
+ raise CreatorError("Cannot group locked Background layer!")
+
only_single_item_selected = len(top_level_selected_items) == 1
if (
only_single_item_selected or
@@ -50,11 +54,12 @@ class ImageCreator(Creator):
group = stub.group_selected_layers(product_name_from_ui)
groups_to_create.append(group)
else:
- stub.select_layers(stub.get_layers())
try:
+ stub.select_layers(stub.get_layers())
group = stub.group_selected_layers(product_name_from_ui)
- except:
+ except ValueError:
raise CreatorError("Cannot group locked Background layer!")
+
groups_to_create.append(group)
# create empty group if nothing selected
diff --git a/client/ayon_core/hosts/traypublisher/csv_publish.py b/client/ayon_core/hosts/traypublisher/csv_publish.py
index b43792a357..2762172936 100644
--- a/client/ayon_core/hosts/traypublisher/csv_publish.py
+++ b/client/ayon_core/hosts/traypublisher/csv_publish.py
@@ -1,5 +1,3 @@
-import os
-
import pyblish.api
import pyblish.util
diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py
index e436396c6c..e25d3479ee 100644
--- a/client/ayon_core/lib/__init__.py
+++ b/client/ayon_core/lib/__init__.py
@@ -139,6 +139,7 @@ from .path_tools import (
)
from .ayon_info import (
+ is_in_ayon_launcher_process,
is_running_from_build,
is_using_ayon_console,
is_staging_enabled,
@@ -248,6 +249,7 @@ __all__ = [
"Logger",
+ "is_in_ayon_launcher_process",
"is_running_from_build",
"is_using_ayon_console",
"is_staging_enabled",
diff --git a/client/ayon_core/lib/ayon_info.py b/client/ayon_core/lib/ayon_info.py
index fc09a7c90c..c4333fab95 100644
--- a/client/ayon_core/lib/ayon_info.py
+++ b/client/ayon_core/lib/ayon_info.py
@@ -1,4 +1,5 @@
import os
+import sys
import json
import datetime
import platform
@@ -25,6 +26,18 @@ def get_ayon_launcher_version():
return content["__version__"]
+def is_in_ayon_launcher_process():
+ """Determine if current process is running from AYON launcher.
+
+ Returns:
+ bool: True if running from AYON launcher.
+
+ """
+ ayon_executable_path = os.path.normpath(os.environ["AYON_EXECUTABLE"])
+ executable_path = os.path.normpath(sys.executable)
+ return ayon_executable_path == executable_path
+
+
def is_running_from_build():
"""Determine if current process is running from build or code.
diff --git a/client/ayon_core/modules/deadline/plugins/publish/submit_publish_job.py b/client/ayon_core/modules/deadline/plugins/publish/submit_publish_job.py
index 06dd62e18b..0f505dce78 100644
--- a/client/ayon_core/modules/deadline/plugins/publish/submit_publish_job.py
+++ b/client/ayon_core/modules/deadline/plugins/publish/submit_publish_job.py
@@ -467,8 +467,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
# Inject deadline url to instances to query DL for job id for overrides
for inst in instances:
- if not "deadline" in inst:
- inst["deadline"] = {}
inst["deadline"] = instance.data["deadline"]
# publish job file
diff --git a/client/ayon_core/modules/deadline/plugins/publish/validate_deadline_pools.py b/client/ayon_core/modules/deadline/plugins/publish/validate_deadline_pools.py
index 5094b3deaf..2fb511bf51 100644
--- a/client/ayon_core/modules/deadline/plugins/publish/validate_deadline_pools.py
+++ b/client/ayon_core/modules/deadline/plugins/publish/validate_deadline_pools.py
@@ -72,7 +72,7 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin,
auth=auth,
log=self.log)
# some DL return "none" as a pool name
- if not "none" in pools:
+ if "none" not in pools:
pools.append("none")
self.log.info("Available pools: {}".format(pools))
self.pools_per_url[deadline_url] = pools
diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py
index dd005d250c..7615ce6aee 100644
--- a/client/ayon_core/pipeline/create/context.py
+++ b/client/ayon_core/pipeline/create/context.py
@@ -2053,7 +2053,7 @@ class CreateContext:
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
- except:
+ except: # noqa: E722
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
@@ -2163,7 +2163,7 @@ class CreateContext:
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
- except:
+ except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
@@ -2197,7 +2197,7 @@ class CreateContext:
try:
convertor.find_instances()
- except:
+ except: # noqa: E722
failed_info.append(
prepare_failed_convertor_operation_info(
convertor.identifier, sys.exc_info()
@@ -2373,7 +2373,7 @@ class CreateContext:
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
- except:
+ except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
@@ -2440,7 +2440,7 @@ class CreateContext:
error_message.format(identifier, exc_info[1])
)
- except:
+ except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
@@ -2546,7 +2546,7 @@ class CreateContext:
try:
self.run_convertor(convertor_identifier)
- except:
+ except: # noqa: E722
failed_info.append(
prepare_failed_convertor_operation_info(
convertor_identifier, sys.exc_info()
diff --git a/client/ayon_core/pipeline/template_data.py b/client/ayon_core/pipeline/template_data.py
index 526c7d35c5..d5f06d6a59 100644
--- a/client/ayon_core/pipeline/template_data.py
+++ b/client/ayon_core/pipeline/template_data.py
@@ -73,8 +73,8 @@ def get_folder_template_data(folder_entity, project_name):
- 'parent' - direct parent name, project name used if is under
project
- Required document fields:
- Folder: 'path' -> Plan to require: 'folderType'
+ Required entity fields:
+ Folder: 'path', 'folderType'
Args:
folder_entity (Dict[str, Any]): Folder entity.
@@ -101,6 +101,8 @@ def get_folder_template_data(folder_entity, project_name):
return {
"folder": {
"name": folder_name,
+ "type": folder_entity["folderType"],
+ "path": path,
},
"asset": folder_name,
"hierarchy": hierarchy,
diff --git a/client/ayon_core/pipeline/thumbnails.py b/client/ayon_core/pipeline/thumbnails.py
new file mode 100644
index 0000000000..dbb38615d8
--- /dev/null
+++ b/client/ayon_core/pipeline/thumbnails.py
@@ -0,0 +1,263 @@
+import os
+import time
+import collections
+
+import ayon_api
+
+from ayon_core.lib.local_settings import get_ayon_appdirs
+
+
+FileInfo = collections.namedtuple(
+ "FileInfo",
+ ("path", "size", "modification_time")
+)
+
+
+class ThumbnailsCache:
+ """Cache of thumbnails on local storage.
+
+ Thumbnails are cached to appdirs to predefined directory. Each project has
+ own subfolder with thumbnails -> that's because each project has own
+ thumbnail id validation and file names are thumbnail ids with matching
+ extension. Extensions are predefined (.png and .jpeg).
+
+ Cache has cleanup mechanism which is triggered on initialized by default.
+
+ The cleanup has 2 levels:
+ 1. soft cleanup which remove all files that are older then 'days_alive'
+ 2. max size cleanup which remove all files until the thumbnails folder
+ contains less then 'max_filesize'
+ - this is time consuming so it's not triggered automatically
+
+ Args:
+ cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails).
+ """
+
+ # Lifetime of thumbnails (in seconds)
+ # - default 3 days
+ days_alive = 3
+ # Max size of thumbnail directory (in bytes)
+ # - default 2 Gb
+ max_filesize = 2 * 1024 * 1024 * 1024
+
+ def __init__(self, cleanup=True):
+ self._thumbnails_dir = None
+ self._days_alive_secs = self.days_alive * 24 * 60 * 60
+ if cleanup:
+ self.cleanup()
+
+ def get_thumbnails_dir(self):
+ """Root directory where thumbnails are stored.
+
+ Returns:
+ str: Path to thumbnails root.
+ """
+
+ if self._thumbnails_dir is None:
+ self._thumbnails_dir = get_ayon_appdirs("thumbnails")
+ return self._thumbnails_dir
+
+ thumbnails_dir = property(get_thumbnails_dir)
+
+ def get_thumbnails_dir_file_info(self):
+ """Get information about all files in thumbnails directory.
+
+ Returns:
+ List[FileInfo]: List of file information about all files.
+ """
+
+ thumbnails_dir = self.thumbnails_dir
+ files_info = []
+ if not os.path.exists(thumbnails_dir):
+ return files_info
+
+ for root, _, filenames in os.walk(thumbnails_dir):
+ for filename in filenames:
+ path = os.path.join(root, filename)
+ files_info.append(FileInfo(
+ path, os.path.getsize(path), os.path.getmtime(path)
+ ))
+ return files_info
+
+ def get_thumbnails_dir_size(self, files_info=None):
+ """Got full size of thumbnail directory.
+
+ Args:
+ files_info (List[FileInfo]): Prepared file information about
+ files in thumbnail directory.
+
+ Returns:
+ int: File size of all files in thumbnail directory.
+ """
+
+ if files_info is None:
+ files_info = self.get_thumbnails_dir_file_info()
+
+ if not files_info:
+ return 0
+
+ return sum(
+ file_info.size
+ for file_info in files_info
+ )
+
+ def cleanup(self, check_max_size=False):
+ """Cleanup thumbnails directory.
+
+ Args:
+ check_max_size (bool): Also cleanup files to match max size of
+ thumbnails directory.
+ """
+
+ thumbnails_dir = self.get_thumbnails_dir()
+ # Skip if thumbnails dir does not exist yet
+ if not os.path.exists(thumbnails_dir):
+ return
+
+ self._soft_cleanup(thumbnails_dir)
+ if check_max_size:
+ self._max_size_cleanup(thumbnails_dir)
+
+ def _soft_cleanup(self, thumbnails_dir):
+ current_time = time.time()
+ for root, _, filenames in os.walk(thumbnails_dir):
+ for filename in filenames:
+ path = os.path.join(root, filename)
+ modification_time = os.path.getmtime(path)
+ if current_time - modification_time > self._days_alive_secs:
+ os.remove(path)
+
+ def _max_size_cleanup(self, thumbnails_dir):
+ files_info = self.get_thumbnails_dir_file_info()
+ size = self.get_thumbnails_dir_size(files_info)
+ if size < self.max_filesize:
+ return
+
+ sorted_file_info = collections.deque(
+ sorted(files_info, key=lambda item: item.modification_time)
+ )
+ diff = size - self.max_filesize
+ while diff > 0:
+ if not sorted_file_info:
+ break
+
+ file_info = sorted_file_info.popleft()
+ diff -= file_info.size
+ os.remove(file_info.path)
+
+ def get_thumbnail_filepath(self, project_name, thumbnail_id):
+ """Get thumbnail by thumbnail id.
+
+ Args:
+ project_name (str): Name of project.
+ thumbnail_id (str): Thumbnail id.
+
+ Returns:
+ Union[str, None]: Path to thumbnail image or None if thumbnail
+ is not cached yet.
+ """
+
+ if not thumbnail_id:
+ return None
+
+ for ext in (
+ ".png",
+ ".jpeg",
+ ):
+ filepath = os.path.join(
+ self.thumbnails_dir, project_name, thumbnail_id + ext
+ )
+ if os.path.exists(filepath):
+ return filepath
+ return None
+
+ def get_project_dir(self, project_name):
+ """Path to root directory for specific project.
+
+ Args:
+ project_name (str): Name of project for which root directory path
+ should be returned.
+
+ Returns:
+ str: Path to root of project's thumbnails.
+ """
+
+ return os.path.join(self.thumbnails_dir, project_name)
+
+ def make_sure_project_dir_exists(self, project_name):
+ project_dir = self.get_project_dir(project_name)
+ if not os.path.exists(project_dir):
+ os.makedirs(project_dir)
+ return project_dir
+
+ def store_thumbnail(self, project_name, thumbnail_id, content, mime_type):
+ """Store thumbnail to cache folder.
+
+ Args:
+ project_name (str): Project where the thumbnail belong to.
+ thumbnail_id (str): Thumbnail id.
+ content (bytes): Byte content of thumbnail file.
+ mime_type (str): Type of content.
+
+ Returns:
+ str: Path to cached thumbnail image file.
+ """
+
+ if mime_type == "image/png":
+ ext = ".png"
+ elif mime_type == "image/jpeg":
+ ext = ".jpeg"
+ else:
+ raise ValueError(
+ "Unknown mime type for thumbnail \"{}\"".format(mime_type))
+
+ project_dir = self.make_sure_project_dir_exists(project_name)
+ thumbnail_path = os.path.join(project_dir, thumbnail_id + ext)
+ with open(thumbnail_path, "wb") as stream:
+ stream.write(content)
+
+ current_time = time.time()
+ os.utime(thumbnail_path, (current_time, current_time))
+
+ return thumbnail_path
+
+
+class _CacheItems:
+ thumbnails_cache = ThumbnailsCache()
+
+
+def get_thumbnail_path(project_name, thumbnail_id):
+ """Get path to thumbnail image.
+
+ Args:
+ project_name (str): Project where thumbnail belongs to.
+ thumbnail_id (Union[str, None]): Thumbnail id.
+
+ Returns:
+ Union[str, None]: Path to thumbnail image or None if thumbnail
+ id is not valid or thumbnail was not possible to receive.
+
+ """
+ if not thumbnail_id:
+ return None
+
+ filepath = _CacheItems.thumbnails_cache.get_thumbnail_filepath(
+ project_name, thumbnail_id
+ )
+ if filepath is not None:
+ return filepath
+
+ # 'ayon_api' had a bug, public function
+ # 'get_thumbnail_by_id' did not return output of
+ # 'ServerAPI' method.
+ con = ayon_api.get_server_api_connection()
+ result = con.get_thumbnail_by_id(project_name, thumbnail_id)
+
+ if result is not None and result.is_valid:
+ return _CacheItems.thumbnails_cache.store_thumbnail(
+ project_name,
+ thumbnail_id,
+ result.content,
+ result.content_type
+ )
+ return None
diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py
index f8cc81e718..ad5a5d43fc 100644
--- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py
+++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py
@@ -33,6 +33,7 @@ import collections
import pyblish.api
import ayon_api
+from ayon_core.pipeline.template_data import get_folder_template_data
from ayon_core.pipeline.version_start import get_versioning_start
@@ -383,24 +384,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# - 'folder', 'hierarchy', 'parent', 'folder'
folder_entity = instance.data.get("folderEntity")
if folder_entity:
- folder_name = folder_entity["name"]
- folder_path = folder_entity["path"]
- hierarchy_parts = folder_path.split("/")
- hierarchy_parts.pop(0)
- hierarchy_parts.pop(-1)
- parent_name = project_entity["name"]
- if hierarchy_parts:
- parent_name = hierarchy_parts[-1]
-
- hierarchy = "/".join(hierarchy_parts)
- anatomy_data.update({
- "asset": folder_name,
- "hierarchy": hierarchy,
- "parent": parent_name,
- "folder": {
- "name": folder_name,
- },
- })
+ folder_data = get_folder_template_data(
+ folder_entity,
+ project_entity["name"]
+ )
+ anatomy_data.update(folder_data)
return
if instance.data.get("newAssetPublishing"):
@@ -418,6 +406,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
"parent": parent_name,
"folder": {
"name": folder_name,
+ "path": instance.data["folderPath"],
+ # TODO get folder type from hierarchy
+ # Using 'Shot' is current default behavior of editorial
+ # (or 'newAssetPublishing') publishing.
+ "type": "Shot",
},
})
diff --git a/client/ayon_core/tools/adobe_webserver/app.py b/client/ayon_core/tools/adobe_webserver/app.py
index 7d97d7d66d..26bf638c91 100644
--- a/client/ayon_core/tools/adobe_webserver/app.py
+++ b/client/ayon_core/tools/adobe_webserver/app.py
@@ -104,14 +104,11 @@ class WebServerTool:
again. In that case, use existing running webserver.
Check here is easier than capturing exception from thread.
"""
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- result = True
- try:
- sock.bind((host_name, port))
- result = False
- except:
- print("Port is in use")
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as con:
+ result = con.connect_ex((host_name, port)) == 0
+ if result:
+ print(f"Port {port} is already in use")
return result
def call(self, func):
diff --git a/client/ayon_core/tools/common_models/thumbnails.py b/client/ayon_core/tools/common_models/thumbnails.py
index 6d14783b9a..2fa1e36e5c 100644
--- a/client/ayon_core/tools/common_models/thumbnails.py
+++ b/client/ayon_core/tools/common_models/thumbnails.py
@@ -1,234 +1,15 @@
-import os
-import time
import collections
import ayon_api
-import appdirs
from ayon_core.lib import NestedCacheItem
-
-FileInfo = collections.namedtuple(
- "FileInfo",
- ("path", "size", "modification_time")
-)
-
-
-class ThumbnailsCache:
- """Cache of thumbnails on local storage.
-
- Thumbnails are cached to appdirs to predefined directory. Each project has
- own subfolder with thumbnails -> that's because each project has own
- thumbnail id validation and file names are thumbnail ids with matching
- extension. Extensions are predefined (.png and .jpeg).
-
- Cache has cleanup mechanism which is triggered on initialized by default.
-
- The cleanup has 2 levels:
- 1. soft cleanup which remove all files that are older then 'days_alive'
- 2. max size cleanup which remove all files until the thumbnails folder
- contains less then 'max_filesize'
- - this is time consuming so it's not triggered automatically
-
- Args:
- cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails).
- """
-
- # Lifetime of thumbnails (in seconds)
- # - default 3 days
- days_alive = 3
- # Max size of thumbnail directory (in bytes)
- # - default 2 Gb
- max_filesize = 2 * 1024 * 1024 * 1024
-
- def __init__(self, cleanup=True):
- self._thumbnails_dir = None
- self._days_alive_secs = self.days_alive * 24 * 60 * 60
- if cleanup:
- self.cleanup()
-
- def get_thumbnails_dir(self):
- """Root directory where thumbnails are stored.
-
- Returns:
- str: Path to thumbnails root.
- """
-
- if self._thumbnails_dir is None:
- # TODO use generic function
- directory = appdirs.user_data_dir("AYON", "Ynput")
- self._thumbnails_dir = os.path.join(directory, "thumbnails")
- return self._thumbnails_dir
-
- thumbnails_dir = property(get_thumbnails_dir)
-
- def get_thumbnails_dir_file_info(self):
- """Get information about all files in thumbnails directory.
-
- Returns:
- List[FileInfo]: List of file information about all files.
- """
-
- thumbnails_dir = self.thumbnails_dir
- files_info = []
- if not os.path.exists(thumbnails_dir):
- return files_info
-
- for root, _, filenames in os.walk(thumbnails_dir):
- for filename in filenames:
- path = os.path.join(root, filename)
- files_info.append(FileInfo(
- path, os.path.getsize(path), os.path.getmtime(path)
- ))
- return files_info
-
- def get_thumbnails_dir_size(self, files_info=None):
- """Got full size of thumbnail directory.
-
- Args:
- files_info (List[FileInfo]): Prepared file information about
- files in thumbnail directory.
-
- Returns:
- int: File size of all files in thumbnail directory.
- """
-
- if files_info is None:
- files_info = self.get_thumbnails_dir_file_info()
-
- if not files_info:
- return 0
-
- return sum(
- file_info.size
- for file_info in files_info
- )
-
- def cleanup(self, check_max_size=False):
- """Cleanup thumbnails directory.
-
- Args:
- check_max_size (bool): Also cleanup files to match max size of
- thumbnails directory.
- """
-
- thumbnails_dir = self.get_thumbnails_dir()
- # Skip if thumbnails dir does not exist yet
- if not os.path.exists(thumbnails_dir):
- return
-
- self._soft_cleanup(thumbnails_dir)
- if check_max_size:
- self._max_size_cleanup(thumbnails_dir)
-
- def _soft_cleanup(self, thumbnails_dir):
- current_time = time.time()
- for root, _, filenames in os.walk(thumbnails_dir):
- for filename in filenames:
- path = os.path.join(root, filename)
- modification_time = os.path.getmtime(path)
- if current_time - modification_time > self._days_alive_secs:
- os.remove(path)
-
- def _max_size_cleanup(self, thumbnails_dir):
- files_info = self.get_thumbnails_dir_file_info()
- size = self.get_thumbnails_dir_size(files_info)
- if size < self.max_filesize:
- return
-
- sorted_file_info = collections.deque(
- sorted(files_info, key=lambda item: item.modification_time)
- )
- diff = size - self.max_filesize
- while diff > 0:
- if not sorted_file_info:
- break
-
- file_info = sorted_file_info.popleft()
- diff -= file_info.size
- os.remove(file_info.path)
-
- def get_thumbnail_filepath(self, project_name, thumbnail_id):
- """Get thumbnail by thumbnail id.
-
- Args:
- project_name (str): Name of project.
- thumbnail_id (str): Thumbnail id.
-
- Returns:
- Union[str, None]: Path to thumbnail image or None if thumbnail
- is not cached yet.
- """
-
- if not thumbnail_id:
- return None
-
- for ext in (
- ".png",
- ".jpeg",
- ):
- filepath = os.path.join(
- self.thumbnails_dir, project_name, thumbnail_id + ext
- )
- if os.path.exists(filepath):
- return filepath
- return None
-
- def get_project_dir(self, project_name):
- """Path to root directory for specific project.
-
- Args:
- project_name (str): Name of project for which root directory path
- should be returned.
-
- Returns:
- str: Path to root of project's thumbnails.
- """
-
- return os.path.join(self.thumbnails_dir, project_name)
-
- def make_sure_project_dir_exists(self, project_name):
- project_dir = self.get_project_dir(project_name)
- if not os.path.exists(project_dir):
- os.makedirs(project_dir)
- return project_dir
-
- def store_thumbnail(self, project_name, thumbnail_id, content, mime_type):
- """Store thumbnail to cache folder.
-
- Args:
- project_name (str): Project where the thumbnail belong to.
- thumbnail_id (str): Id of thumbnail.
- content (bytes): Byte content of thumbnail file.
- mime_data (str): Type of content.
-
- Returns:
- str: Path to cached thumbnail image file.
- """
-
- if mime_type == "image/png":
- ext = ".png"
- elif mime_type == "image/jpeg":
- ext = ".jpeg"
- else:
- raise ValueError(
- "Unknown mime type for thumbnail \"{}\"".format(mime_type))
-
- project_dir = self.make_sure_project_dir_exists(project_name)
- thumbnail_path = os.path.join(project_dir, thumbnail_id + ext)
- with open(thumbnail_path, "wb") as stream:
- stream.write(content)
-
- current_time = time.time()
- os.utime(thumbnail_path, (current_time, current_time))
-
- return thumbnail_path
+from ayon_core.pipeline.thumbnails import get_thumbnail_path
class ThumbnailsModel:
entity_cache_lifetime = 240 # In seconds
def __init__(self):
- self._thumbnail_cache = ThumbnailsCache()
self._paths_cache = collections.defaultdict(dict)
self._folders_cache = NestedCacheItem(
levels=2, lifetime=self.entity_cache_lifetime)
@@ -283,28 +64,7 @@ class ThumbnailsModel:
if thumbnail_id in project_cache:
return project_cache[thumbnail_id]
- filepath = self._thumbnail_cache.get_thumbnail_filepath(
- project_name, thumbnail_id
- )
- if filepath is not None:
- project_cache[thumbnail_id] = filepath
- return filepath
-
- # 'ayon_api' had a bug, public function
- # 'get_thumbnail_by_id' did not return output of
- # 'ServerAPI' method.
- con = ayon_api.get_server_api_connection()
- result = con.get_thumbnail_by_id(project_name, thumbnail_id)
- if result is None:
- pass
-
- elif result.is_valid:
- filepath = self._thumbnail_cache.store_thumbnail(
- project_name,
- thumbnail_id,
- result.content,
- result.content_type
- )
+ filepath = get_thumbnail_path(project_name, thumbnail_id)
project_cache[thumbnail_id] = filepath
return filepath
diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py
index 47c5399cf7..4e34f9b58c 100644
--- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py
+++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py
@@ -52,6 +52,7 @@ class SelectionTypes:
class BaseGroupWidget(QtWidgets.QWidget):
selected = QtCore.Signal(str, str, str)
removed_selected = QtCore.Signal()
+ double_clicked = QtCore.Signal()
def __init__(self, group_name, parent):
super(BaseGroupWidget, self).__init__(parent)
@@ -192,6 +193,7 @@ class ConvertorItemsGroupWidget(BaseGroupWidget):
else:
widget = ConvertorItemCardWidget(item, self)
widget.selected.connect(self._on_widget_selection)
+ widget.double_clicked(self.double_clicked)
self._widgets_by_id[item.id] = widget
self._content_layout.insertWidget(widget_idx, widget)
widget_idx += 1
@@ -254,6 +256,7 @@ class InstanceGroupWidget(BaseGroupWidget):
)
widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed)
+ widget.double_clicked.connect(self.double_clicked)
self._widgets_by_id[instance.id] = widget
self._content_layout.insertWidget(widget_idx, widget)
widget_idx += 1
@@ -271,6 +274,7 @@ class CardWidget(BaseClickableFrame):
# Group identifier of card
# - this must be set because if send when mouse is released with card id
_group_identifier = None
+ double_clicked = QtCore.Signal()
def __init__(self, parent):
super(CardWidget, self).__init__(parent)
@@ -279,6 +283,11 @@ class CardWidget(BaseClickableFrame):
self._selected = False
self._id = None
+ def mouseDoubleClickEvent(self, event):
+ super(CardWidget, self).mouseDoubleClickEvent(event)
+ if self._is_valid_double_click(event):
+ self.double_clicked.emit()
+
@property
def id(self):
"""Id of card."""
@@ -312,6 +321,9 @@ class CardWidget(BaseClickableFrame):
self.selected.emit(self._id, self._group_identifier, selection_type)
+ def _is_valid_double_click(self, event):
+ return True
+
class ContextCardWidget(CardWidget):
"""Card for global context.
@@ -527,6 +539,15 @@ class InstanceCardWidget(CardWidget):
def _on_expend_clicked(self):
self._set_expanded()
+ def _is_valid_double_click(self, event):
+ widget = self.childAt(event.pos())
+ if (
+ widget is self._active_checkbox
+ or widget is self._expand_btn
+ ):
+ return False
+ return True
+
class InstanceCardView(AbstractInstanceView):
"""Publish access to card view.
@@ -534,6 +555,8 @@ class InstanceCardView(AbstractInstanceView):
Wrapper of all widgets in card view.
"""
+ double_clicked = QtCore.Signal()
+
def __init__(self, controller, parent):
super(InstanceCardView, self).__init__(parent)
@@ -715,6 +738,7 @@ class InstanceCardView(AbstractInstanceView):
)
group_widget.active_changed.connect(self._on_active_changed)
group_widget.selected.connect(self._on_widget_selection)
+ group_widget.double_clicked.connect(self.double_clicked)
self._content_layout.insertWidget(widget_idx, group_widget)
self._widgets_by_group[group_name] = group_widget
@@ -755,6 +779,7 @@ class InstanceCardView(AbstractInstanceView):
widget = ContextCardWidget(self._content_widget)
widget.selected.connect(self._on_widget_selection)
+ widget.double_clicked.connect(self.double_clicked)
self._context_widget = widget
@@ -778,6 +803,7 @@ class InstanceCardView(AbstractInstanceView):
CONVERTOR_ITEM_GROUP, self._content_widget
)
group_widget.selected.connect(self._on_widget_selection)
+ group_widget.double_clicked.connect(self.double_clicked)
self._content_layout.insertWidget(1, group_widget)
self._convertor_items_group = group_widget
diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py
index 3322a73be6..71be0ab1a4 100644
--- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py
+++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py
@@ -110,6 +110,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
This is required to be able use custom checkbox on custom place.
"""
active_changed = QtCore.Signal(str, bool)
+ double_clicked = QtCore.Signal()
def __init__(self, instance, parent):
super(InstanceListItemWidget, self).__init__(parent)
@@ -149,6 +150,12 @@ class InstanceListItemWidget(QtWidgets.QWidget):
self._set_valid_property(instance.has_valid_context)
+ def mouseDoubleClickEvent(self, event):
+ widget = self.childAt(event.pos())
+ super(InstanceListItemWidget, self).mouseDoubleClickEvent(event)
+ if widget is not self._active_checkbox:
+ self.double_clicked.emit()
+
def _set_valid_property(self, valid):
if self._has_valid_context == valid:
return
@@ -209,6 +216,8 @@ class InstanceListItemWidget(QtWidgets.QWidget):
class ListContextWidget(QtWidgets.QFrame):
"""Context (or global attributes) widget."""
+ double_clicked = QtCore.Signal()
+
def __init__(self, parent):
super(ListContextWidget, self).__init__(parent)
@@ -225,6 +234,10 @@ class ListContextWidget(QtWidgets.QFrame):
self.label_widget = label_widget
+ def mouseDoubleClickEvent(self, event):
+ super(ListContextWidget, self).mouseDoubleClickEvent(event)
+ self.double_clicked.emit()
+
class InstanceListGroupWidget(QtWidgets.QFrame):
"""Widget representing group of instances.
@@ -317,6 +330,7 @@ class InstanceListGroupWidget(QtWidgets.QFrame):
class InstanceTreeView(QtWidgets.QTreeView):
"""View showing instances and their groups."""
toggle_requested = QtCore.Signal(int)
+ double_clicked = QtCore.Signal()
def __init__(self, *args, **kwargs):
super(InstanceTreeView, self).__init__(*args, **kwargs)
@@ -425,6 +439,9 @@ class InstanceListView(AbstractInstanceView):
This is public access to and from list view.
"""
+
+ double_clicked = QtCore.Signal()
+
def __init__(self, controller, parent):
super(InstanceListView, self).__init__(parent)
@@ -454,6 +471,7 @@ class InstanceListView(AbstractInstanceView):
instance_view.collapsed.connect(self._on_collapse)
instance_view.expanded.connect(self._on_expand)
instance_view.toggle_requested.connect(self._on_toggle_request)
+ instance_view.double_clicked.connect(self.double_clicked)
self._group_items = {}
self._group_widgets = {}
@@ -687,6 +705,7 @@ class InstanceListView(AbstractInstanceView):
self._active_toggle_enabled
)
widget.active_changed.connect(self._on_active_changed)
+ widget.double_clicked.connect(self.double_clicked)
self._instance_view.setIndexWidget(proxy_index, widget)
self._widgets_by_id[instance.id] = widget
@@ -717,6 +736,7 @@ class InstanceListView(AbstractInstanceView):
)
proxy_index = self._proxy_model.mapFromSource(index)
widget = ListContextWidget(self._instance_view)
+ widget.double_clicked.connect(self.double_clicked)
self._instance_view.setIndexWidget(proxy_index, widget)
self._context_widget = widget
diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py
index dd82185830..cedf52ae01 100644
--- a/client/ayon_core/tools/publisher/widgets/overview_widget.py
+++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py
@@ -18,6 +18,7 @@ class OverviewWidget(QtWidgets.QFrame):
instance_context_changed = QtCore.Signal()
create_requested = QtCore.Signal()
convert_requested = QtCore.Signal()
+ publish_tab_requested = QtCore.Signal()
anim_end_value = 200
anim_duration = 200
@@ -113,9 +114,15 @@ class OverviewWidget(QtWidgets.QFrame):
product_list_view.selection_changed.connect(
self._on_product_change
)
+ product_list_view.double_clicked.connect(
+ self.publish_tab_requested
+ )
product_view_cards.selection_changed.connect(
self._on_product_change
)
+ product_view_cards.double_clicked.connect(
+ self.publish_tab_requested
+ )
# Active instances changed
product_list_view.active_changed.connect(
self._on_active_changed
diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py
index 123864ff6c..1b13ced317 100644
--- a/client/ayon_core/tools/publisher/window.py
+++ b/client/ayon_core/tools/publisher/window.py
@@ -258,6 +258,9 @@ class PublisherWindow(QtWidgets.QDialog):
overview_widget.convert_requested.connect(
self._on_convert_requested
)
+ overview_widget.publish_tab_requested.connect(
+ self._go_to_publish_tab
+ )
save_btn.clicked.connect(self._on_save_clicked)
reset_btn.clicked.connect(self._on_reset_clicked)
diff --git a/server_addon/maya/package.py b/server_addon/maya/package.py
index 5c6ce923aa..fe3e3039f5 100644
--- a/server_addon/maya/package.py
+++ b/server_addon/maya/package.py
@@ -1,3 +1,3 @@
name = "maya"
title = "Maya"
-version = "0.1.17"
+version = "0.1.18"
diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py
index bc38d5f746..20523b2ca9 100644
--- a/server_addon/maya/server/settings/publishers.py
+++ b/server_addon/maya/server/settings/publishers.py
@@ -46,7 +46,6 @@ def extract_alembic_overrides_enum():
return [
{"label": "Custom Attributes", "value": "attr"},
{"label": "Custom Attributes Prefix", "value": "attrPrefix"},
- {"label": "Auto Subd", "value": "autoSubd"},
{"label": "Data Format", "value": "dataFormat"},
{"label": "Euler Filter", "value": "eulerFilter"},
{"label": "Mel Per Frame Callback", "value": "melPerFrameCallback"},
@@ -347,17 +346,6 @@ class ExtractAlembicModel(BaseSettingsModel):
families: list[str] = SettingsField(
default_factory=list,
title="Families")
- autoSubd: bool = SettingsField(
- title="Auto Subd",
- description=(
- "If this flag is present and the mesh has crease edges, crease "
- "vertices or holes, the mesh (OPolyMesh) would now be written out "
- "as an OSubD and crease info will be stored in the Alembic file. "
- "Otherwise, creases info won't be preserved in Alembic file unless"
- " a custom Boolean attribute SubDivisionMesh has been added to "
- "mesh node and its value is true."
- )
- )
eulerFilter: bool = SettingsField(
title="Euler Filter",
description="Apply Euler filter while sampling rotations."
@@ -409,6 +397,10 @@ class ExtractAlembicModel(BaseSettingsModel):
title="Write Color Sets",
description="Write vertex colors with the geometry."
)
+ writeCreases: bool = SettingsField(
+ title="Write Creases",
+ description="Write the geometry's edge and vertex crease information."
+ )
writeFaceSets: bool = SettingsField(
title="Write Face Sets",
description="Write face sets with the geometry."
@@ -1617,7 +1609,6 @@ DEFAULT_PUBLISH_SETTINGS = {
],
"attr": "",
"attrPrefix": "",
- "autoSubd": False,
"bake_attributes": [],
"bake_attribute_prefixes": [],
"dataFormat": "ogawa",
@@ -1641,7 +1632,7 @@ DEFAULT_PUBLISH_SETTINGS = {
"renderableOnly": False,
"stripNamespaces": True,
"uvsOnly": False,
- "uvWrite": False,
+ "uvWrite": True,
"userAttr": "",
"userAttrPrefix": "",
"verbose": False,
@@ -1649,6 +1640,7 @@ DEFAULT_PUBLISH_SETTINGS = {
"wholeFrameGeo": False,
"worldSpace": True,
"writeColorSets": False,
+ "writeCreases": False,
"writeFaceSets": False,
"writeNormals": True,
"writeUVSets": False,