diff --git a/client/ayon_core/hosts/fusion/api/plugin.py b/client/ayon_core/hosts/fusion/api/plugin.py index 492841f967..efe8269120 100644 --- a/client/ayon_core/hosts/fusion/api/plugin.py +++ b/client/ayon_core/hosts/fusion/api/plugin.py @@ -16,6 +16,12 @@ from ayon_core.pipeline import ( AVALON_INSTANCE_ID, AYON_INSTANCE_ID, ) +from ayon_core.pipeline.workfile import get_workdir +from ayon_api import ( + get_project, + get_folder_by_path, + get_task_by_name +) class GenericCreateSaver(Creator): @@ -125,6 +131,8 @@ class GenericCreateSaver(Creator): product_name = data["productName"] if ( original_product_name != product_name + or tool.GetData("openpype.task") != data["task"] + or tool.GetData("openpype.folderPath") != data["folderPath"] or original_format != data["creator_attributes"]["image_format"] ): self._configure_saver_tool(data, tool, product_name) @@ -145,7 +153,30 @@ class GenericCreateSaver(Creator): folder_path = formatting_data["folderPath"] folder_name = folder_path.rsplit("/", 1)[-1] - workdir = os.path.normpath(os.getenv("AYON_WORKDIR")) + # If the folder path and task do not match the current context then the + # workdir is not just the `AYON_WORKDIR`. Hence, we need to actually + # compute the resulting workdir + if ( + data["folderPath"] == self.create_context.get_current_folder_path() + and data["task"] == self.create_context.get_current_task_name() + ): + workdir = os.path.normpath(os.getenv("AYON_WORKDIR")) + else: + # TODO: Optimize this logic + project_name = self.create_context.get_current_project_name() + project_entity = get_project(project_name) + folder_entity = get_folder_by_path(project_name, + data["folderPath"]) + task_entity = get_task_by_name(project_name, + folder_id=folder_entity["id"], + task_name=data["task"]) + workdir = get_workdir( + project_entity=project_entity, + folder_entity=folder_entity, + task_entity=task_entity, + host_name=self.create_context.host_name, + ) + formatting_data.update({ "workdir": workdir, "frame": "0" * frame_padding, diff --git a/client/ayon_core/hosts/fusion/hooks/pre_fusion_launch_menu_hook.py b/client/ayon_core/hosts/fusion/hooks/pre_fusion_launch_menu_hook.py new file mode 100644 index 0000000000..e70d4b844e --- /dev/null +++ b/client/ayon_core/hosts/fusion/hooks/pre_fusion_launch_menu_hook.py @@ -0,0 +1,36 @@ +import os +from ayon_core.lib import PreLaunchHook +from ayon_core.hosts.fusion import FUSION_HOST_DIR + + +class FusionLaunchMenuHook(PreLaunchHook): + """Launch AYON menu on start of Fusion""" + app_groups = ["fusion"] + order = 9 + + def execute(self): + # Prelaunch hook is optional + settings = self.data["project_settings"][self.host_name] + if not settings["hooks"]["FusionLaunchMenuHook"]["enabled"]: + return + + variant = self.application.name + if variant.isnumeric(): + version = int(variant) + if version < 18: + print("Skipping launch of OpenPype menu on Fusion start " + "because Fusion version below 18.0 does not support " + "/execute argument on launch. " + f"Version detected: {version}") + return + else: + print(f"Application variant is not numeric: {variant}. " + "Validation for Fusion version 18+ for /execute " + "prelaunch argument skipped.") + + path = os.path.join(FUSION_HOST_DIR, + "deploy", + "MenuScripts", + "launch_menu.py").replace("\\", "/") + script = f"fusion:RunScript('{path}')" + self.launch_context.launch_args.extend(["/execute", script]) diff --git a/client/ayon_core/hosts/hiero/plugins/create/create_shot_clip.py b/client/ayon_core/hosts/hiero/plugins/create/create_shot_clip.py index 2985a81317..1fc808fdd1 100644 --- a/client/ayon_core/hosts/hiero/plugins/create/create_shot_clip.py +++ b/client/ayon_core/hosts/hiero/plugins/create/create_shot_clip.py @@ -137,7 +137,7 @@ class CreateShotClip(phiero.Creator): "value": ["", "main", "bg", "fg", "bg", "animatic"], "type": "QComboBox", - "label": "pRODUCT Name", + "label": "Product Name", "target": "ui", "toolTip": "chose product name pattern, if is selected, name of track layer will be used", # noqa "order": 0}, @@ -159,7 +159,7 @@ class CreateShotClip(phiero.Creator): "type": "QCheckBox", "label": "Include audio", "target": "tag", - "toolTip": "Process productS with corresponding audio", # noqa + "toolTip": "Process products with corresponding audio", # noqa "order": 3}, "sourceResolution": { "value": False, diff --git a/client/ayon_core/hosts/hiero/plugins/publish/extract_thumbnail.py b/client/ayon_core/hosts/hiero/plugins/publish/extract_thumbnail.py index bcaf5308d9..3599a830d2 100644 --- a/client/ayon_core/hosts/hiero/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/hosts/hiero/plugins/publish/extract_thumbnail.py @@ -4,12 +4,12 @@ import pyblish.api from ayon_core.pipeline import publish -class ExtractThumnail(publish.Extractor): +class ExtractThumbnail(publish.Extractor): """ - Extractor for track item's tumnails + Extractor for track item's tumbnails """ - label = "Extract Thumnail" + label = "Extract Thumbnail" order = pyblish.api.ExtractorOrder families = ["plate", "take"] hosts = ["hiero"] @@ -48,7 +48,7 @@ class ExtractThumnail(publish.Extractor): self.log.debug( "__ thumb_path: `{}`, frame: `{}`".format(thumbnail, thumb_frame)) - self.log.info("Thumnail was generated to: {}".format(thumb_path)) + self.log.info("Thumbnail was generated to: {}".format(thumb_path)) thumb_representation = { 'files': thumb_file, 'stagingDir': staging_dir, diff --git a/client/ayon_core/hosts/maya/api/lib.py b/client/ayon_core/hosts/maya/api/lib.py index 060c16056c..ff5bee03ca 100644 --- a/client/ayon_core/hosts/maya/api/lib.py +++ b/client/ayon_core/hosts/maya/api/lib.py @@ -37,7 +37,7 @@ from ayon_core.pipeline import ( AYON_CONTAINER_ID, ) from ayon_core.lib import NumberDef -from ayon_core.pipeline.context_tools import get_current_folder_entity +from ayon_core.pipeline.context_tools import get_current_task_entity from ayon_core.pipeline.create import CreateContext from ayon_core.lib.profiles_filtering import filter_profiles @@ -1519,24 +1519,30 @@ def extract_alembic(file, # region ID -def get_id_required_nodes(referenced_nodes=False, nodes=None): - """Filter out any node which are locked (reference) or readOnly +def get_id_required_nodes(referenced_nodes=False, + nodes=None, + existing_ids=True): + """Return nodes that should receive a `cbId` attribute. + + This includes only mesh and curve nodes, parent transforms of the shape + nodes, file texture nodes and object sets (including shading engines). + + This filters out any node which is locked, referenced, read-only, + intermediate object. Args: - referenced_nodes (bool): set True to filter out reference nodes + referenced_nodes (bool): set True to include referenced nodes nodes (list, Optional): nodes to consider + existing_ids (bool): set True to include nodes with `cbId` attribute + Returns: nodes (set): list of filtered nodes """ - lookup = None - if nodes is None: - # Consider all nodes - nodes = cmds.ls() - else: - # Build a lookup for the only allowed nodes in output based - # on `nodes` input of the function (+ ensure long names) - lookup = set(cmds.ls(nodes, long=True)) + if nodes is not None and not nodes: + # User supplied an empty `nodes` list to check so all we can + # do is return the empty result + return set() def _node_type_exists(node_type): try: @@ -1545,63 +1551,142 @@ def get_id_required_nodes(referenced_nodes=False, nodes=None): except RuntimeError: return False + def iterate(maya_iterator): + while not maya_iterator.isDone(): + yield maya_iterator.thisNode() + maya_iterator.next() + # `readOnly` flag is obsolete as of Maya 2016 therefore we explicitly # remove default nodes and reference nodes - camera_shapes = ["frontShape", "sideShape", "topShape", "perspShape"] + default_camera_shapes = { + "frontShape", "sideShape", "topShape", "perspShape" + } - ignore = set() - if not referenced_nodes: - ignore |= set(cmds.ls(long=True, referencedNodes=True)) - - # list all defaultNodes to filter out from the rest - ignore |= set(cmds.ls(long=True, defaultNodes=True)) - ignore |= set(cmds.ls(camera_shapes, long=True)) - - # Remove Turtle from the result of `cmds.ls` if Turtle is loaded - # TODO: This should be a less specific check for a single plug-in. - if _node_type_exists("ilrBakeLayer"): - ignore |= set(cmds.ls(type="ilrBakeLayer", long=True)) - - # Establish set of nodes types to include - types = ["objectSet", "file", "mesh", "nurbsCurve", "nurbsSurface"] + # The filtered types do not include transforms because we only want the + # parent transforms that have a child shape that we filtered to, so we + # include the parents here + types = ["mesh", "nurbsCurve", "nurbsSurface", "file", "objectSet"] # Check if plugin nodes are available for Maya by checking if the plugin # is loaded if cmds.pluginInfo("pgYetiMaya", query=True, loaded=True): types.append("pgYetiMaya") - # We *always* ignore intermediate shapes, so we filter them out directly - nodes = cmds.ls(nodes, type=types, long=True, noIntermediate=True) + iterator_type = OpenMaya.MIteratorType() + # This tries to be closest matching API equivalents of `types` variable + iterator_type.filterList = [ + OpenMaya.MFn.kMesh, # mesh + OpenMaya.MFn.kNurbsSurface, # nurbsSurface + OpenMaya.MFn.kNurbsCurve, # nurbsCurve + OpenMaya.MFn.kFileTexture, # file + OpenMaya.MFn.kSet, # objectSet + OpenMaya.MFn.kPluginShape # pgYetiMaya + ] + it = OpenMaya.MItDependencyNodes(iterator_type) - # The items which need to pass the id to their parent - # Add the collected transform to the nodes - dag = cmds.ls(nodes, type="dagNode", long=True) # query only dag nodes - transforms = cmds.listRelatives(dag, - parent=True, - fullPath=True) or [] + fn_dep = OpenMaya.MFnDependencyNode() + fn_dag = OpenMaya.MFnDagNode() + result = set() - nodes = set(nodes) - nodes |= set(transforms) + def _should_include_parents(obj): + """Whether to include parents of obj in output""" + if not obj.hasFn(OpenMaya.MFn.kShape): + return False - nodes -= ignore # Remove the ignored nodes - if not nodes: - return nodes + fn_dag.setObject(obj) + if fn_dag.isIntermediateObject: + return False - # Ensure only nodes from the input `nodes` are returned when a - # filter was applied on function call because we also iterated - # to parents and alike - if lookup is not None: - nodes &= lookup + # Skip default cameras + if ( + obj.hasFn(OpenMaya.MFn.kCamera) and + fn_dag.name() in default_camera_shapes + ): + return False - # Avoid locked nodes - nodes_list = list(nodes) - locked = cmds.lockNode(nodes_list, query=True, lock=True) - for node, lock in zip(nodes_list, locked): - if lock: - log.warning("Skipping locked node: %s" % node) - nodes.remove(node) + return True - return nodes + def _add_to_result_if_valid(obj): + """Add to `result` if the object should be included""" + fn_dep.setObject(obj) + if not existing_ids and fn_dep.hasAttribute("cbId"): + return + + if not referenced_nodes and fn_dep.isFromReferencedFile: + return + + if fn_dep.isDefaultNode: + return + + if fn_dep.isLocked: + return + + # Skip default cameras + if ( + obj.hasFn(OpenMaya.MFn.kCamera) and + fn_dep.name() in default_camera_shapes + ): + return + + if obj.hasFn(OpenMaya.MFn.kDagNode): + # DAG nodes + fn_dag.setObject(obj) + + # Skip intermediate objects + if fn_dag.isIntermediateObject: + return + + # DAG nodes can be instanced and thus may have multiple paths. + # We need to identify each path + paths = OpenMaya.MDagPath.getAllPathsTo(obj) + for dag in paths: + path = dag.fullPathName() + result.add(path) + else: + # Dependency node + path = fn_dep.name() + result.add(path) + + for obj in iterate(it): + # For any non-intermediate shape node always include the parent + # even if we exclude the shape itself (e.g. when locked, default) + if _should_include_parents(obj): + fn_dag.setObject(obj) + parents = [ + fn_dag.parent(index) for index in range(fn_dag.parentCount()) + ] + for parent_obj in parents: + _add_to_result_if_valid(parent_obj) + + _add_to_result_if_valid(obj) + + if not result: + return result + + # Exclude some additional types + exclude_types = [] + if _node_type_exists("ilrBakeLayer"): + # Remove Turtle from the result if Turtle is loaded + exclude_types.append("ilrBakeLayer") + + if exclude_types: + exclude_nodes = set(cmds.ls(nodes, long=True, type=exclude_types)) + if exclude_nodes: + result -= exclude_nodes + + # Filter to explicit input nodes if provided + if nodes is not None: + # The amount of input nodes to filter to can be large and querying + # many nodes can be slow in Maya. As such we want to try and reduce + # it as much as possible, so we include the type filter to try and + # reduce the result of `maya.cmds.ls` here. + nodes = set(cmds.ls(nodes, long=True, type=types + ["dagNode"])) + if nodes: + result &= nodes + else: + return set() + + return result def get_id(node): @@ -2629,21 +2714,21 @@ def reset_frame_range(playback=True, render=True, fps=True): def reset_scene_resolution(): """Apply the scene resolution from the project definition - scene resolution can be overwritten by an folder if the folder.attrib - contains any information regarding scene resolution . + The scene resolution will be retrieved from the current task entity's + attributes. Returns: None """ - folder_attributes = get_current_folder_entity()["attrib"] + task_attributes = get_current_task_entity(fields={"attrib"})["attrib"] # Set resolution - width = folder_attributes.get("resolutionWidth", 1920) - height = folder_attributes.get("resolutionHeight", 1080) - pixelAspect = folder_attributes.get("pixelAspect", 1) + width = task_attributes.get("resolutionWidth", 1920) + height = task_attributes.get("resolutionHeight", 1080) + pixel_aspect = task_attributes.get("pixelAspect", 1) - set_scene_resolution(width, height, pixelAspect) + set_scene_resolution(width, height, pixel_aspect) def set_context_settings( @@ -3129,7 +3214,7 @@ def load_capture_preset(data): return options -def get_attr_in_layer(attr, layer): +def get_attr_in_layer(attr, layer, as_string=True): """Return attribute value in specified renderlayer. Same as cmds.getAttr but this gets the attribute's value in a @@ -3147,6 +3232,7 @@ def get_attr_in_layer(attr, layer): Args: attr (str): attribute name, ex. "node.attribute" layer (str): layer name + as_string (bool): whether attribute should convert to a string value Returns: The return value from `maya.cmds.getAttr` @@ -3156,7 +3242,8 @@ def get_attr_in_layer(attr, layer): try: if cmds.mayaHasRenderSetup(): from . import lib_rendersetup - return lib_rendersetup.get_attr_in_layer(attr, layer) + return lib_rendersetup.get_attr_in_layer( + attr, layer, as_string=as_string) except AttributeError: pass @@ -3164,7 +3251,7 @@ def get_attr_in_layer(attr, layer): current_layer = cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) if layer == current_layer: - return cmds.getAttr(attr) + return cmds.getAttr(attr, asString=as_string) connections = cmds.listConnections(attr, plugs=True, @@ -3215,7 +3302,7 @@ def get_attr_in_layer(attr, layer): value *= conversion return value - return cmds.getAttr(attr) + return cmds.getAttr(attr, asString=as_string) def fix_incompatible_containers(): @@ -3244,33 +3331,46 @@ def update_content_on_context_change(): """ This will update scene content to match new folder on context change """ - scene_sets = cmds.listSets(allSets=True) - folder_entity = get_current_folder_entity() - folder_attributes = folder_entity["attrib"] - new_folder_path = folder_entity["path"] - for s in scene_sets: - try: - if cmds.getAttr("{}.id".format(s)) in { - AYON_INSTANCE_ID, AVALON_INSTANCE_ID - }: - attr = cmds.listAttr(s) - print(s) - if "folderPath" in attr: - print( - " - setting folder to: [ {} ]".format(new_folder_path) - ) - cmds.setAttr( - "{}.folderPath".format(s), - new_folder_path, type="string" - ) - if "frameStart" in attr: - cmds.setAttr("{}.frameStart".format(s), - folder_attributes["frameStart"]) - if "frameEnd" in attr: - cmds.setAttr("{}.frameEnd".format(s), - folder_attributes["frameEnd"],) - except ValueError: - pass + + host = registered_host() + create_context = CreateContext(host) + folder_entity = get_current_task_entity(fields={"attrib"}) + + instance_values = { + "folderPath": create_context.get_current_folder_path(), + "task": create_context.get_current_task_name(), + } + creator_attribute_values = { + "frameStart": folder_entity["attrib"]["frameStart"], + "frameEnd": folder_entity["attrib"]["frameEnd"], + } + + has_changes = False + for instance in create_context.instances: + for key, value in instance_values.items(): + if key not in instance or instance[key] == value: + continue + + # Update instance value + print(f"Updating {instance.product_name} {key} to: {value}") + instance[key] = value + has_changes = True + + creator_attributes = instance.creator_attributes + for key, value in creator_attribute_values.items(): + if ( + key not in creator_attributes + or creator_attributes[key] == value + ): + continue + + # Update instance creator attribute value + print(f"Updating {instance.product_name} {key} to: {value}") + instance[key] = value + has_changes = True + + if has_changes: + create_context.save_changes() def show_message(title, msg): diff --git a/client/ayon_core/hosts/maya/api/lib_renderproducts.py b/client/ayon_core/hosts/maya/api/lib_renderproducts.py index 7f26145e1d..832d1c21c2 100644 --- a/client/ayon_core/hosts/maya/api/lib_renderproducts.py +++ b/client/ayon_core/hosts/maya/api/lib_renderproducts.py @@ -297,7 +297,7 @@ class ARenderProducts: """ return self._get_attr("defaultRenderGlobals", attribute) - def _get_attr(self, node_attr, attribute=None): + def _get_attr(self, node_attr, attribute=None, as_string=True): """Return the value of the attribute in the renderlayer For readability this allows passing in the attribute in two ways. @@ -317,7 +317,7 @@ class ARenderProducts: else: plug = "{}.{}".format(node_attr, attribute) - return lib.get_attr_in_layer(plug, layer=self.layer) + return lib.get_attr_in_layer(plug, layer=self.layer, as_string=as_string) @staticmethod def extract_separator(file_prefix): @@ -1133,9 +1133,24 @@ class RenderProductsRedshift(ARenderProducts): aovs = list(set(aovs) - set(ref_aovs)) products = [] + global_aov_enabled = bool( + self._get_attr("redshiftOptions.aovGlobalEnableMode", as_string=False) + ) + colorspace = lib.get_color_management_output_transform() + if not global_aov_enabled: + # only beauty output + for camera in cameras: + products.insert(0, + RenderProduct(productName="", + ext=ext, + multipart=self.multipart, + camera=camera, + colorspace=colorspace)) + return products + light_groups_enabled = False has_beauty_aov = False - colorspace = lib.get_color_management_output_transform() + for aov in aovs: enabled = self._get_attr(aov, "enabled") if not enabled: diff --git a/client/ayon_core/hosts/maya/api/lib_rendersetup.py b/client/ayon_core/hosts/maya/api/lib_rendersetup.py index c2b5ec843c..6dca8eb6dd 100644 --- a/client/ayon_core/hosts/maya/api/lib_rendersetup.py +++ b/client/ayon_core/hosts/maya/api/lib_rendersetup.py @@ -77,7 +77,7 @@ def get_rendersetup_layer(layer): if conn.endswith(".legacyRenderLayer")), None) -def get_attr_in_layer(node_attr, layer): +def get_attr_in_layer(node_attr, layer, as_string=True): """Return attribute value in Render Setup layer. This will only work for attributes which can be @@ -124,7 +124,7 @@ def get_attr_in_layer(node_attr, layer): node = history_overrides[-1] if history_overrides else override node_attr_ = node + ".original" - return get_attribute(node_attr_, asString=True) + return get_attribute(node_attr_, asString=as_string) layer = get_rendersetup_layer(layer) rs = renderSetup.instance() @@ -144,7 +144,7 @@ def get_attr_in_layer(node_attr, layer): # we will let it error out. rs.switchToLayer(current_layer) - return get_attribute(node_attr, asString=True) + return get_attribute(node_attr, asString=as_string) overrides = get_attr_overrides(node_attr, layer) default_layer_value = get_default_layer_value(node_attr) diff --git a/client/ayon_core/hosts/maya/api/pipeline.py b/client/ayon_core/hosts/maya/api/pipeline.py index 8e6e2ccd8a..864a0c1599 100644 --- a/client/ayon_core/hosts/maya/api/pipeline.py +++ b/client/ayon_core/hosts/maya/api/pipeline.py @@ -580,7 +580,8 @@ def on_save(): _remove_workfile_lock() # Generate ids of the current context on nodes in the scene - nodes = lib.get_id_required_nodes(referenced_nodes=False) + nodes = lib.get_id_required_nodes(referenced_nodes=False, + existing_ids=False) for node, new_id in lib.generate_ids(nodes): lib.set_id(node, new_id, overwrite=False) @@ -653,10 +654,6 @@ def on_task_changed(): "Can't set project for new context because path does not exist: {}" ).format(workdir)) - with lib.suspended_refresh(): - lib.set_context_settings() - lib.update_content_on_context_change() - global _about_to_save if not lib.IS_HEADLESS and _about_to_save: # Let's prompt the user to update the context settings or not diff --git a/client/ayon_core/hosts/maya/api/workfile_template_builder.py b/client/ayon_core/hosts/maya/api/workfile_template_builder.py index cb25a722f0..75386d7e64 100644 --- a/client/ayon_core/hosts/maya/api/workfile_template_builder.py +++ b/client/ayon_core/hosts/maya/api/workfile_template_builder.py @@ -286,7 +286,7 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): if not container: return - roots = cmds.sets(container, q=True) + roots = cmds.sets(container, q=True) or [] ref_node = None try: ref_node = get_reference_node(roots) diff --git a/client/ayon_core/hosts/maya/plugins/create/create_render.py b/client/ayon_core/hosts/maya/plugins/create/create_render.py index 213d5b543e..e5a8d4dbd8 100644 --- a/client/ayon_core/hosts/maya/plugins/create/create_render.py +++ b/client/ayon_core/hosts/maya/plugins/create/create_render.py @@ -40,8 +40,15 @@ class CreateRenderlayer(plugin.RenderlayerCreator): def create(self, product_name, instance_data, pre_create_data): # Only allow a single render instance to exist if self._get_singleton_node(): - raise CreatorError("A Render instance already exists - only " - "one can be configured.") + raise CreatorError( + "A Render instance already exists - only one can be " + "configured.\n\n" + "To render multiple render layers, create extra Render Setup " + "Layers via Maya's Render Setup UI.\n" + "Then refresh the publisher to detect the new layers for " + "rendering.\n\n" + "With a render instance present all Render Setup layers in " + "your workfile are renderable instances.") # Apply default project render settings on create if self.render_settings.get("apply_render_settings"): diff --git a/client/ayon_core/hosts/maya/plugins/publish/collect_file_dependencies.py b/client/ayon_core/hosts/maya/plugins/publish/collect_file_dependencies.py index 94fcc834e1..93b46c511b 100644 --- a/client/ayon_core/hosts/maya/plugins/publish/collect_file_dependencies.py +++ b/client/ayon_core/hosts/maya/plugins/publish/collect_file_dependencies.py @@ -1,5 +1,3 @@ -import json - from maya import cmds import pyblish.api @@ -11,18 +9,24 @@ class CollectFileDependencies(pyblish.api.ContextPlugin): label = "Collect File Dependencies" order = pyblish.api.CollectorOrder - 0.49 hosts = ["maya"] + families = ["renderlayer"] + + @classmethod + def apply_settings(cls, project_settings, system_settings): + # Disable plug-in if not used for deadline submission anyway + settings = project_settings["deadline"]["publish"]["MayaSubmitDeadline"] # noqa + cls.enabled = settings.get("asset_dependencies", True) def process(self, context): - dependencies = [] + dependencies = set() for node in cmds.ls(type="file"): path = cmds.getAttr("{}.{}".format(node, "fileTextureName")) if path not in dependencies: - dependencies.append(path) + dependencies.add(path) for node in cmds.ls(type="AlembicNode"): path = cmds.getAttr("{}.{}".format(node, "abc_File")) if path not in dependencies: - dependencies.append(path) + dependencies.add(path) - context.data["fileDependencies"] = dependencies - self.log.debug(json.dumps(dependencies, indent=4)) + context.data["fileDependencies"] = list(dependencies) diff --git a/client/ayon_core/hosts/maya/plugins/publish/validate_current_renderlayer_renderable.py b/client/ayon_core/hosts/maya/plugins/publish/validate_current_renderlayer_renderable.py index 4590c53931..045e22545c 100644 --- a/client/ayon_core/hosts/maya/plugins/publish/validate_current_renderlayer_renderable.py +++ b/client/ayon_core/hosts/maya/plugins/publish/validate_current_renderlayer_renderable.py @@ -1,14 +1,18 @@ +import inspect + import pyblish.api from maya import cmds from ayon_core.pipeline.publish import ( context_plugin_should_run, + PublishValidationError, OptionalPyblishPluginMixin ) + class ValidateCurrentRenderLayerIsRenderable(pyblish.api.ContextPlugin, OptionalPyblishPluginMixin): - """Validate if current render layer has a renderable camera + """Validate if current render layer has a renderable camera. There is a bug in Redshift which occurs when the current render layer at file open has no renderable camera. The error raised is as follows: @@ -32,8 +36,39 @@ class ValidateCurrentRenderLayerIsRenderable(pyblish.api.ContextPlugin, if not context_plugin_should_run(self, context): return - layer = cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) + # This validator only makes sense when publishing renderlayer instances + # with Redshift. We skip validation if there isn't any. + if not any(self.is_active_redshift_render_instance(instance) + for instance in context): + return + cameras = cmds.ls(type="camera", long=True) renderable = any(c for c in cameras if cmds.getAttr(c + ".renderable")) - assert renderable, ("Current render layer '%s' has no renderable " - "camera" % layer) + if not renderable: + layer = cmds.editRenderLayerGlobals(query=True, + currentRenderLayer=True) + raise PublishValidationError( + "Current render layer '{}' has no renderable camera".format( + layer + ), + description=inspect.getdoc(self) + ) + + @staticmethod + def is_active_redshift_render_instance(instance) -> bool: + """Return whether instance is an active renderlayer instance set to + render with Redshift renderer.""" + if not instance.data.get("active", True): + return False + + # Check this before families just because it's a faster check + if not instance.data.get("renderer") == "redshift": + return False + + families = set() + families.add(instance.data.get("family")) + families.update(instance.data.get("families", [])) + if "renderlayer" not in families: + return False + + return True diff --git a/client/ayon_core/hosts/maya/plugins/publish/validate_model_content.py b/client/ayon_core/hosts/maya/plugins/publish/validate_model_content.py index 37c78a72ee..1001bee91f 100644 --- a/client/ayon_core/hosts/maya/plugins/publish/validate_model_content.py +++ b/client/ayon_core/hosts/maya/plugins/publish/validate_model_content.py @@ -1,3 +1,5 @@ +import inspect + from maya import cmds import pyblish.api @@ -14,8 +16,7 @@ class ValidateModelContent(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Adheres to the content of 'model' product type - - Must have one top group. (configurable) - - Must only contain: transforms, meshes and groups + See `get_description` for more details. """ @@ -28,13 +29,16 @@ class ValidateModelContent(pyblish.api.InstancePlugin, validate_top_group = True optional = False + allowed = ('mesh', 'transform', 'nurbsCurve', 'nurbsSurface', 'locator') + @classmethod def get_invalid(cls, instance): content_instance = instance.data.get("setMembers", None) if not content_instance: - cls.log.error("Instance has no nodes!") - return [instance.data["name"]] + cls.log.error("Model instance has no nodes. " + "It is not allowed to be empty") + return [instance.data["instance_node"]] # All children will be included in the extracted export so we also # validate *all* descendents of the set members and we skip any @@ -46,30 +50,42 @@ class ValidateModelContent(pyblish.api.InstancePlugin, content_instance = list(set(content_instance + descendants)) # Ensure only valid node types - allowed = ('mesh', 'transform', 'nurbsCurve', 'nurbsSurface', 'locator') nodes = cmds.ls(content_instance, long=True) - valid = cmds.ls(content_instance, long=True, type=allowed) + valid = cmds.ls(content_instance, long=True, type=cls.allowed) invalid = set(nodes) - set(valid) if invalid: - cls.log.error("These nodes are not allowed: %s" % invalid) + # List as bullet points + invalid_bullets = "\n".join(f"- {node}" for node in invalid) + + cls.log.error( + "These nodes are not allowed:\n{}\n\n" + "The valid node types are: {}".format( + invalid_bullets, ", ".join(cls.allowed)) + ) return list(invalid) if not valid: - cls.log.error("No valid nodes in the instance") - return True + cls.log.error( + "No valid nodes in the model instance.\n" + "The valid node types are: {}".format(", ".join(cls.allowed)) + ) + return [instance.data["instance_node"]] # Ensure it has shapes shapes = cmds.ls(valid, long=True, shapes=True) if not shapes: cls.log.error("No shapes in the model instance") - return True + return [instance.data["instance_node"]] - # Top group - top_parents = set([x.split("|")[1] for x in content_instance]) + # Ensure single top group + top_parents = {x.split("|", 2)[1] for x in content_instance} if cls.validate_top_group and len(top_parents) != 1: - cls.log.error("Must have exactly one top group") - return top_parents + cls.log.error( + "A model instance must have exactly one top group. " + "Found top groups: {}".format(", ".join(top_parents)) + ) + return list(top_parents) def _is_visible(node): """Return whether node is visible""" @@ -101,5 +117,21 @@ class ValidateModelContent(pyblish.api.InstancePlugin, if invalid: raise PublishValidationError( title="Model content is invalid", - message="See log for more details" + message="Model content is invalid. See log for more details.", + description=self.get_description() ) + + @classmethod + def get_description(cls): + return inspect.cleandoc(f""" + ### Model content is invalid + + Your model instance does not adhere to the rules of a + model product type: + + - Must have at least one visible shape in it, like a mesh. + - Must have one root node. When exporting multiple meshes they + must be inside a group. + - May only contain the following node types: + {", ".join(cls.allowed)} + """) diff --git a/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids.py b/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids.py index ba748a4fc4..2d6f231cb5 100644 --- a/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids.py +++ b/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids.py @@ -60,7 +60,8 @@ class ValidateNodeIDs(pyblish.api.InstancePlugin): # We do want to check the referenced nodes as it might be # part of the end product. id_nodes = lib.get_id_required_nodes(referenced_nodes=True, - nodes=instance[:]) - invalid = [n for n in id_nodes if not lib.get_id(n)] - - return invalid + nodes=instance[:], + # Exclude those with already + # existing ids + existing_ids=False) + return id_nodes diff --git a/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids_in_database.py b/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids_in_database.py index 5ca9690fd7..d679c510af 100644 --- a/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids_in_database.py +++ b/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids_in_database.py @@ -37,27 +37,27 @@ class ValidateNodeIdsInDatabase(pyblish.api.InstancePlugin): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( - ("Found folder ids which are not related to " - "current project in instance: `{}`").format(instance.name)) + "Found folder ids which are not related to " + "current project in instance: `{}`".format(instance.name)) @classmethod def get_invalid(cls, instance): - invalid = [] + nodes = instance[:] + if not nodes: + return # Get all id required nodes - id_required_nodes = lib.get_id_required_nodes(referenced_nodes=True, - nodes=instance[:]) + id_required_nodes = lib.get_id_required_nodes(referenced_nodes=False, + nodes=nodes) + if not id_required_nodes: + return # check ids against database ids - project_name = instance.context.data["projectName"] - folder_entities = ayon_api.get_folders(project_name, fields={"id"}) - folder_ids = { - folder_entity["id"] - for folder_entity in folder_entities - } + folder_ids = cls.get_project_folder_ids(context=instance.context) # Get all asset IDs + invalid = [] for node in id_required_nodes: cb_id = lib.get_id(node) @@ -71,3 +71,31 @@ class ValidateNodeIdsInDatabase(pyblish.api.InstancePlugin): invalid.append(node) return invalid + + @classmethod + def get_project_folder_ids(cls, context): + """Return all folder ids in the current project. + + Arguments: + context (pyblish.api.Context): The publish context. + + Returns: + set[str]: All folder ids in the current project. + + """ + # We query the database only for the first instance instead of + # per instance by storing a cache in the context + key = "__cache_project_folder_ids" + if key in context.data: + return context.data[key] + + # check ids against database + project_name = context.data["projectName"] + folder_entities = ayon_api.get_folders(project_name, fields={"id"}) + folder_ids = { + folder_entity["id"] + for folder_entity in folder_entities + } + + context.data[key] = folder_ids + return folder_ids diff --git a/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids_unique.py b/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids_unique.py index f4994922ce..6b44a307d2 100644 --- a/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids_unique.py +++ b/client/ayon_core/hosts/maya/plugins/publish/validate_node_ids_unique.py @@ -8,6 +8,8 @@ from ayon_core.pipeline.publish import ( import ayon_core.hosts.maya.api.action from ayon_core.hosts.maya.api import lib +from maya import cmds + class ValidateNodeIdsUnique(pyblish.api.InstancePlugin): """Validate the nodes in the instance have a unique Colorbleed Id @@ -41,7 +43,7 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin): if invalid: label = "Nodes found with non-unique folder ids" raise PublishValidationError( - message="{}: {}".format(label, invalid), + message="{}, see log".format(label), title="Non-unique folder ids on nodes", description="{}\n- {}".format(label, "\n- ".join(sorted(invalid))) @@ -54,7 +56,6 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin): # Check only non intermediate shapes # todo: must the instance itself ensure to have no intermediates? # todo: how come there are intermediates? - from maya import cmds instance_members = cmds.ls(instance, noIntermediate=True, long=True) # Collect each id with their members @@ -67,10 +68,14 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin): # Take only the ids with more than one member invalid = list() - _iteritems = getattr(ids, "iteritems", ids.items) - for _ids, members in _iteritems(): + for members in ids.values(): if len(members) > 1: - cls.log.error("ID found on multiple nodes: '%s'" % members) + members_text = "\n".join( + "- {}".format(member) for member in sorted(members) + ) + cls.log.error( + "ID found on multiple nodes:\n{}".format(members_text) + ) invalid.extend(members) return invalid diff --git a/client/ayon_core/hosts/nuke/plugins/load/load_clip.py b/client/ayon_core/hosts/nuke/plugins/load/load_clip.py index 8a41d854d9..062a5295ed 100644 --- a/client/ayon_core/hosts/nuke/plugins/load/load_clip.py +++ b/client/ayon_core/hosts/nuke/plugins/load/load_clip.py @@ -130,6 +130,18 @@ class LoadClip(plugin.NukeLoader): first = 1 last = first + duration + # If a slate is present, the frame range is 1 frame longer for movies, + # but file sequences its the first frame that is 1 frame lower. + slate_frames = repre_entity["data"].get("slateFrames", 0) + extension = "." + repre_entity["context"]["ext"] + + if extension in VIDEO_EXTENSIONS: + last += slate_frames + + files_count = len(repre_entity["files"]) + if extension in IMAGE_EXTENSIONS and files_count != 1: + first -= slate_frames + # Fallback to folder name when namespace is None if namespace is None: namespace = context["folder"]["name"] @@ -167,7 +179,9 @@ class LoadClip(plugin.NukeLoader): repre_entity ) - self._set_range_to_node(read_node, first, last, start_at_workfile) + self._set_range_to_node( + read_node, first, last, start_at_workfile, slate_frames + ) version_name = version_entity["version"] if version_name < 0: @@ -402,14 +416,21 @@ class LoadClip(plugin.NukeLoader): for member in members: nuke.delete(member) - def _set_range_to_node(self, read_node, first, last, start_at_workfile): + def _set_range_to_node( + self, read_node, first, last, start_at_workfile, slate_frames=0 + ): read_node['origfirst'].setValue(int(first)) read_node['first'].setValue(int(first)) read_node['origlast'].setValue(int(last)) read_node['last'].setValue(int(last)) # set start frame depending on workfile or version - self._loader_shift(read_node, start_at_workfile) + if start_at_workfile: + read_node['frame_mode'].setValue("start at") + + start_frame = self.script_start - slate_frames + + read_node['frame'].setValue(str(start_frame)) def _make_retimes(self, parent_node, version_data): ''' Create all retime and timewarping nodes with copied animation ''' @@ -466,18 +487,6 @@ class LoadClip(plugin.NukeLoader): for i, n in enumerate(dependent_nodes): last_node.setInput(i, n) - def _loader_shift(self, read_node, workfile_start=False): - """ Set start frame of read node to a workfile start - - Args: - read_node (nuke.Node): The nuke's read node - workfile_start (bool): set workfile start frame if true - - """ - if workfile_start: - read_node['frame_mode'].setValue("start at") - read_node['frame'].setValue(str(self.script_start)) - def _get_node_name(self, context): folder_entity = context["folder"] product_name = context["product"]["name"] diff --git a/client/ayon_core/hosts/nuke/plugins/publish/extract_slate_frame.py b/client/ayon_core/hosts/nuke/plugins/publish/extract_slate_frame.py index c013da84d2..627888ac92 100644 --- a/client/ayon_core/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/client/ayon_core/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -300,6 +300,10 @@ class ExtractSlateFrame(publish.Extractor): self.log.debug( "__ matching_repre: {}".format(pformat(matching_repre))) + data = matching_repre.get("data", {}) + data["slateFrames"] = 1 + matching_repre["data"] = data + self.log.info("Added slate frame to representation files") def add_comment_slate_node(self, instance, node): diff --git a/client/ayon_core/modules/deadline/plugins/publish/submit_publish_cache_job.py b/client/ayon_core/modules/deadline/plugins/publish/submit_publish_cache_job.py index 910b2e46db..4e4657d886 100644 --- a/client/ayon_core/modules/deadline/plugins/publish/submit_publish_cache_job.py +++ b/client/ayon_core/modules/deadline/plugins/publish/submit_publish_cache_job.py @@ -67,7 +67,7 @@ class ProcessSubmittedCacheJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AYON_APP_NAME", "AYON_USERNAME", - "OPENPYPE_SG_USER", + "AYON_SG_USERNAME", "KITSU_LOGIN", "KITSU_PWD" ] 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 af5839d0cf..8def9cc63c 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 @@ -130,7 +130,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AYON_APP_NAME", "AYON_USERNAME", - "OPENPYPE_SG_USER", + "AYON_SG_USERNAME", "KITSU_LOGIN", "KITSU_PWD" ] diff --git a/client/ayon_core/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/client/ayon_core/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 662913cadf..f3287b7638 100644 --- a/client/ayon_core/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/client/ayon_core/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -65,7 +65,7 @@ class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AYON_APP_NAME", "AYON_USERNAME", - "OPENPYPE_SG_USER", + "AYON_SG_USERNAME", ] priority = 50 diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index f8184c8567..cd358846ea 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -91,9 +91,15 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): longest_key = max(self.templates.keys(), key=len) dropdown.setMinimumContentsLength(len(longest_key)) - template_label = QtWidgets.QLabel() - template_label.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) - template_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) + template_dir_label = QtWidgets.QLabel() + template_dir_label.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) + template_dir_label.setTextInteractionFlags( + QtCore.Qt.TextSelectableByMouse) + + template_file_label = QtWidgets.QLabel() + template_file_label.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) + template_file_label.setTextInteractionFlags( + QtCore.Qt.TextSelectableByMouse) renumber_frame = QtWidgets.QCheckBox() @@ -123,7 +129,8 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): input_layout.addRow("Selected representations", selected_label) input_layout.addRow("Delivery template", dropdown) - input_layout.addRow("Template value", template_label) + input_layout.addRow("Directory template", template_dir_label) + input_layout.addRow("File template", template_file_label) input_layout.addRow("Renumber Frame", renumber_frame) input_layout.addRow("Renumber start frame", first_frame_start) input_layout.addRow("Root", root_line_edit) @@ -151,7 +158,8 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): layout.addWidget(text_area) self.selected_label = selected_label - self.template_label = template_label + self.template_dir_label = template_dir_label + self.template_file_label = template_file_label self.dropdown = dropdown self.first_frame_start = first_frame_start self.renumber_frame = renumber_frame @@ -282,11 +290,13 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): """Adds list of delivery templates from Anatomy to dropdown.""" templates = {} for template_name, value in anatomy.templates["delivery"].items(): - path_template = value["path"] - if ( - not isinstance(path_template, str) - or not path_template.startswith('{root') - ): + directory_template = value["directory"] + if not directory_template.startswith("{root"): + self.log.warning( + "Skipping template '%s' because directory template does " + "not start with `{root` in value: %s", + template_name, directory_template + ) continue templates[template_name] = value @@ -350,7 +360,8 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): name = self.dropdown.currentText() template_value = self.templates.get(name) if template_value: - self.template_label.setText(template_value) + self.template_dir_label.setText(template_value["directory"]) + self.template_file_label.setText(template_value["file"]) self.btn_delivery.setEnabled(bool(self._get_selected_repres())) def _update_progress(self, uploaded): diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index d51a7374d3..1891c25521 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -32,6 +32,35 @@ from ayon_core.pipeline.publish import ( from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup +def frame_to_timecode(frame: int, fps: float) -> str: + """Convert a frame number and FPS to editorial timecode (HH:MM:SS:FF). + + Unlike `ayon_core.pipeline.editorial.frames_to_timecode` this does not + rely on the `opentimelineio` package, so it can be used across hosts that + do not have it available. + + Args: + frame (int): The frame number to be converted. + fps (float): The frames per second of the video. + + Returns: + str: The timecode in HH:MM:SS:FF format. + """ + # Calculate total seconds + total_seconds = frame / fps + + # Extract hours, minutes, and seconds + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + + # Adjust for non-integer FPS by rounding the remaining frames appropriately + remaining_frames = round((total_seconds - int(total_seconds)) * fps) + + # Format and return the timecode + return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{remaining_frames:02d}" + + class ExtractReview(pyblish.api.InstancePlugin): """Extracting Review mov file for Ftrack @@ -390,7 +419,16 @@ class ExtractReview(pyblish.api.InstancePlugin): # add outputName to anatomy format fill_data fill_data.update({ "output": output_name, - "ext": output_ext + "ext": output_ext, + + # By adding `timecode` as data we can use it + # in the ffmpeg arguments for `--timecode` so that editorial + # like Resolve or Premiere can detect the start frame for e.g. + # review output files + "timecode": frame_to_timecode( + frame=temp_data["frame_start_handle"], + fps=float(instance.data["fps"]) + ) }) try: # temporary until oiiotool is supported cross platform diff --git a/client/ayon_core/plugins/publish/extract_slate_data.py b/client/ayon_core/plugins/publish/extract_slate_data.py new file mode 100644 index 0000000000..750fb5d60a --- /dev/null +++ b/client/ayon_core/plugins/publish/extract_slate_data.py @@ -0,0 +1,22 @@ +import pyblish.api + +from ayon_core.pipeline import publish + + +class ExtractSlateData(publish.Extractor): + """Add slate data for integration.""" + + label = "Slate Data" + # Offset from ExtractReviewSlate and ExtractGenerateSlate. + order = pyblish.api.ExtractorOrder + 0.49 + families = ["slate", "review"] + hosts = ["nuke", "shell"] + + def process(self, instance): + for representation in instance.data.get("representations", []): + if "slate-frame" not in representation.get("tags", []): + continue + + data = representation.get("data", {}) + data["slateFrames"] = 1 + representation["data"] = data diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 7fa7af1662..3048e6be94 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -659,16 +659,7 @@ class BaseWorkfileController( folder_id != self.get_current_folder_id() or task_name != self.get_current_task_name() ): - folder_entity = ayon_api.get_folder_by_id( - event_data["project_name"], - event_data["folder_id"], - ) - task_entity = ayon_api.get_task_by_name( - event_data["project_name"], - event_data["folder_id"], - event_data["task_name"] - ) - change_current_context(folder_entity, task_entity) + self._change_current_context(project_name, folder_id, task_id) self._host_open_workfile(filepath) @@ -710,16 +701,8 @@ class BaseWorkfileController( folder_id != self.get_current_folder_id() or task_name != self.get_current_task_name() ): - folder_entity = ayon_api.get_folder_by_id( - project_name, folder["id"] - ) - task_entity = ayon_api.get_task_by_name( - project_name, folder["id"], task_name - ) - change_current_context( - folder_entity, - task_entity, - template_key=template_key + self._change_current_context( + project_name, folder_id, task_id, template_key ) # Save workfile @@ -744,4 +727,18 @@ class BaseWorkfileController( # Trigger after save events emit_event("workfile.save.after", event_data, source="workfiles.tool") - self.reset() + + def _change_current_context( + self, project_name, folder_id, task_id, template_key=None + ): + # Change current context + folder_entity = self.get_folder_entity(project_name, folder_id) + task_entity = self.get_task_entity(project_name, task_id) + change_current_context( + folder_entity, + task_entity, + template_key=template_key + ) + self._current_folder_id = folder_entity["id"] + self._current_folder_path = folder_entity["path"] + self._current_task_name = task_entity["name"] diff --git a/server/settings/tools.py b/server/settings/tools.py index 488d27e8f1..fb8430a71c 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -173,6 +173,7 @@ def _product_types_enum(): "rig", "setdress", "take", + "usd", "usdShade", "vdbcache", "vrayproxy", diff --git a/server_addon/fusion/server/settings.py b/server_addon/fusion/server/settings.py index a913db16da..f16ae6e3e7 100644 --- a/server_addon/fusion/server/settings.py +++ b/server_addon/fusion/server/settings.py @@ -75,6 +75,12 @@ class HooksModel(BaseSettingsModel): default_factory=HookOptionalModel, title="Install PySide2" ) + FusionLaunchMenuHook: HookOptionalModel = SettingsField( + default_factory=HookOptionalModel, + title="Launch AYON Menu on Fusion Start", + description="Launch the AYON menu on Fusion application startup. " + "This is only supported for Fusion 18+" + ) class CreateSaverModel(CreateSaverPluginModel): @@ -143,6 +149,9 @@ DEFAULT_VALUES = { "hooks": { "InstallPySideToFusion": { "enabled": True + }, + "FusionLaunchMenuHook": { + "enabled": False } }, "create": { diff --git a/server_addon/fusion/server/version.py b/server_addon/fusion/server/version.py index bbab0242f6..1276d0254f 100644 --- a/server_addon/fusion/server/version.py +++ b/server_addon/fusion/server/version.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.1.5"