diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 8c36719b77..4fb8b886a9 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -87,7 +87,9 @@ class IntegrateHeroVersion( ] # QUESTION/TODO this process should happen on server if crashed due to # permissions error on files (files were used or user didn't have perms) - # *but all other plugins must be sucessfully completed + # *but all other plugins must be successfully completed + + use_hardlinks = False def process(self, instance): if not self.is_active(instance.data): @@ -617,24 +619,32 @@ class IntegrateHeroVersion( self.log.debug("Folder already exists: \"{}\"".format(dirname)) + if self.use_hardlinks: + # First try hardlink and copy if paths are cross drive + self.log.debug("Hardlinking file \"{}\" to \"{}\"".format( + src_path, dst_path + )) + try: + create_hard_link(src_path, dst_path) + # Return when successful + return + + except OSError as exc: + # re-raise exception if different than + # EXDEV - cross drive path + # EINVAL - wrong format, must be NTFS + self.log.debug( + "Hardlink failed with errno:'{}'".format(exc.errno)) + if exc.errno not in [errno.EXDEV, errno.EINVAL]: + raise + + self.log.debug( + "Hardlinking failed, falling back to regular copy...") + self.log.debug("Copying file \"{}\" to \"{}\"".format( src_path, dst_path )) - # First try hardlink and copy if paths are cross drive - try: - create_hard_link(src_path, dst_path) - # Return when successful - return - - except OSError as exc: - # re-raise exception if different than - # EXDEV - cross drive path - # EINVAL - wrong format, must be NTFS - self.log.debug("Hardlink failed with errno:'{}'".format(exc.errno)) - if exc.errno not in [errno.EXDEV, errno.EINVAL]: - raise - shutil.copy(src_path, dst_path) def version_from_representations(self, project_name, repres): diff --git a/client/ayon_core/tools/common_models/__init__.py b/client/ayon_core/tools/common_models/__init__.py index f09edfeab2..40394c4732 100644 --- a/client/ayon_core/tools/common_models/__init__.py +++ b/client/ayon_core/tools/common_models/__init__.py @@ -2,6 +2,8 @@ from .cache import CacheItem, NestedCacheItem from .projects import ( + StatusItem, + StatusStates, ProjectItem, ProjectsModel, PROJECTS_MODEL_SENDER, @@ -21,6 +23,8 @@ __all__ = ( "CacheItem", "NestedCacheItem", + "StatusItem", + "StatusStates", "ProjectItem", "ProjectsModel", "PROJECTS_MODEL_SENDER", diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 4e8925388d..7ec941e6bd 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,8 +1,8 @@ import contextlib -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod +from typing import Dict, Any import ayon_api -import six from ayon_core.style import get_default_entity_icon_color from ayon_core.lib import CacheItem, NestedCacheItem @@ -10,8 +10,14 @@ from ayon_core.lib import CacheItem, NestedCacheItem PROJECTS_MODEL_SENDER = "projects.model" -@six.add_metaclass(ABCMeta) -class AbstractHierarchyController: +class StatusStates: + not_started = "not_started" + in_progress = "in_progress" + done = "done" + blocked = "blocked" + + +class AbstractHierarchyController(ABC): @abstractmethod def emit_event(self, topic, data, source): pass @@ -25,18 +31,24 @@ class StatusItem: color (str): Status color in hex ("#434a56"). short (str): Short status name ("NRD"). icon (str): Icon name in MaterialIcons ("fiber_new"). - state (Literal["not_started", "in_progress", "done", "blocked"]): - Status state. + state (str): Status state. """ - def __init__(self, name, color, short, icon, state): - self.name = name - self.color = color - self.short = short - self.icon = icon - self.state = state + def __init__( + self, + name: str, + color: str, + short: str, + icon: str, + state: str + ): + self.name: str = name + self.color: str = color + self.short: str = short + self.icon: str = icon + self.state: str = state - def to_data(self): + def to_data(self) -> Dict[str, Any]: return { "name": self.name, "color": self.color, diff --git a/client/ayon_core/tools/sceneinventory/model.py b/client/ayon_core/tools/sceneinventory/model.py index 3e0c361535..335df87b95 100644 --- a/client/ayon_core/tools/sceneinventory/model.py +++ b/client/ayon_core/tools/sceneinventory/model.py @@ -217,7 +217,9 @@ class InventoryModel(QtGui.QStandardItemModel): version_label = format_version(version_item.version) is_hero = version_item.version < 0 is_latest = version_item.is_latest - if not is_latest: + # TODO maybe use different colors for last approved and last + # version? Or don't care about color at all? + if not is_latest and not version_item.is_last_approved: version_color = self.OUTDATED_COLOR status_name = version_item.status diff --git a/client/ayon_core/tools/sceneinventory/models/containers.py b/client/ayon_core/tools/sceneinventory/models/containers.py index 95c5322343..c3881ea40d 100644 --- a/client/ayon_core/tools/sceneinventory/models/containers.py +++ b/client/ayon_core/tools/sceneinventory/models/containers.py @@ -3,7 +3,9 @@ import collections import ayon_api from ayon_api.graphql import GraphQlQuery + from ayon_core.host import ILoadHost +from ayon_core.tools.common_models.projects import StatusStates # --- Implementation that should be in ayon-python-api --- @@ -149,26 +151,35 @@ class RepresentationInfo: class VersionItem: - def __init__(self, version_id, product_id, version, status, is_latest): - self.version = version - self.version_id = version_id - self.product_id = product_id - self.version = version - self.status = status - self.is_latest = is_latest + def __init__( + self, + version_id: str, + product_id: str, + version: int, + status: str, + is_latest: bool, + is_last_approved: bool, + ): + self.version_id: str = version_id + self.product_id: str = product_id + self.version: int = version + self.status: str = status + self.is_latest: bool = is_latest + self.is_last_approved: bool = is_last_approved @property def is_hero(self): return self.version < 0 @classmethod - def from_entity(cls, version_entity, is_latest): + def from_entity(cls, version_entity, is_latest, is_last_approved): return cls( version_id=version_entity["id"], product_id=version_entity["productId"], version=version_entity["version"], status=version_entity["status"], is_latest=is_latest, + is_last_approved=is_last_approved, ) @@ -275,6 +286,11 @@ class ContainersModel: if product_id not in self._version_items_by_product_id } if missing_ids: + status_items_by_name = { + status_item.name: status_item + for status_item in self._controller.get_project_status_items() + } + def version_sorted(entity): return entity["version"] @@ -300,9 +316,21 @@ class ContainersModel: version_entities_by_product_id.items() ): last_version = abs(version_entities[-1]["version"]) + last_approved_id = None + for version_entity in version_entities: + status_item = status_items_by_name.get( + version_entity["status"] + ) + if status_item is None: + continue + if status_item.state == StatusStates.done: + last_approved_id = version_entity["id"] + version_items_by_id = { entity["id"]: VersionItem.from_entity( - entity, abs(entity["version"]) == last_version + entity, + abs(entity["version"]) == last_version, + entity["id"] == last_approved_id ) for entity in version_entities } diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index c8cc3299a2..22ba15fda8 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -233,19 +233,38 @@ class SceneInventoryView(QtWidgets.QTreeView): has_outdated = False has_loaded_hero_versions = False has_available_hero_version = False - for version_items_by_id in version_items_by_product_id.values(): + has_outdated_approved = False + last_version_by_product_id = {} + for product_id, version_items_by_id in ( + version_items_by_product_id.items() + ): + _has_outdated_approved = False + _last_approved_version_item = None for version_item in version_items_by_id.values(): if version_item.is_hero: has_available_hero_version = True + elif version_item.is_last_approved: + _last_approved_version_item = version_item + _has_outdated_approved = True + if version_item.version_id not in version_ids: continue + if version_item.is_hero: has_loaded_hero_versions = True - elif not version_item.is_latest: has_outdated = True + if ( + _has_outdated_approved + and _last_approved_version_item is not None + ): + last_version_by_product_id[product_id] = ( + _last_approved_version_item + ) + has_outdated_approved = True + switch_to_versioned = None if has_loaded_hero_versions: update_icon = qtawesome.icon( @@ -261,6 +280,42 @@ class SceneInventoryView(QtWidgets.QTreeView): lambda: self._on_switch_to_versioned(item_ids) ) + update_to_last_approved_action = None + approved_version_by_item_id = {} + if has_outdated_approved: + for container_item in container_items_by_id.values(): + repre_id = container_item.representation_id + repre_info = repre_info_by_id.get(repre_id) + if not repre_info or not repre_info.is_valid: + continue + version_item = last_version_by_product_id.get( + repre_info.product_id + ) + if ( + version_item is None + or version_item.version_id == repre_info.version_id + ): + continue + approved_version_by_item_id[container_item.item_id] = ( + version_item.version + ) + + if approved_version_by_item_id: + update_icon = qtawesome.icon( + "fa.angle-double-up", + color="#00f0b4" + ) + update_to_last_approved_action = QtWidgets.QAction( + update_icon, + "Update to last approved", + menu + ) + update_to_last_approved_action.triggered.connect( + lambda: self._update_containers_to_approved_versions( + approved_version_by_item_id + ) + ) + update_to_latest_action = None if has_outdated or has_loaded_hero_versions: update_icon = qtawesome.icon( @@ -299,7 +354,9 @@ class SceneInventoryView(QtWidgets.QTreeView): # set version set_version_action = None if active_repre_id is not None: - set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR) + set_version_icon = qtawesome.icon( + "fa.hashtag", color=DEFAULT_COLOR + ) set_version_action = QtWidgets.QAction( set_version_icon, "Set version", @@ -323,6 +380,9 @@ class SceneInventoryView(QtWidgets.QTreeView): if switch_to_versioned: menu.addAction(switch_to_versioned) + if update_to_last_approved_action: + menu.addAction(update_to_last_approved_action) + if update_to_latest_action: menu.addAction(update_to_latest_action) @@ -970,3 +1030,24 @@ class SceneInventoryView(QtWidgets.QTreeView): """ versions = [version for _ in range(len(item_ids))] self._update_containers(item_ids, versions) + + def _update_containers_to_approved_versions( + self, approved_version_by_item_id + ): + """Helper to update items to given version (or version per item) + + If at least one item is specified this will always try to refresh + the inventory even if errors occurred on any of the items. + + Arguments: + approved_version_by_item_id (Dict[str, int]): Version to set by + item id. + + """ + versions = [] + item_ids = [] + for item_id, version in approved_version_by_item_id.items(): + item_ids.append(item_id) + versions.append(version) + + self._update_containers(item_ids, versions) diff --git a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py index 01880b94d7..c8b0c777de 100644 --- a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py +++ b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py @@ -130,7 +130,10 @@ def main(title="Scripts", parent=None, objectName=None): # Register control + shift callback to add to shelf (maya behavior) modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier - menu.register_callback(int(modifiers), to_shelf) + if int(cmds.about(version=True)) <= 2025: + modifiers = int(modifiers) + + menu.register_callback(modifiers, to_shelf) menu.register_callback(0, register_repeat_last) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index b37be1afe6..1b3d382f01 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -743,6 +743,14 @@ class IntegrateHeroVersionModel(BaseSettingsModel): optional: bool = SettingsField(False, title="Optional") active: bool = SettingsField(True, title="Active") families: list[str] = SettingsField(default_factory=list, title="Families") + use_hardlinks: bool = SettingsField( + False, title="Use Hardlinks", + description="When enabled first try to make a hardlink of the version " + "instead of a copy. This helps reduce disk usage, but may " + "create issues.\nFor example there are known issues on " + "Windows being unable to delete any of the hardlinks if " + "any of the links is in use creating issues with updating " + "hero versions.") class CleanUpModel(BaseSettingsModel): @@ -1136,7 +1144,8 @@ DEFAULT_PUBLISH_VALUES = { "layout", "mayaScene", "simpleUnrealTexture" - ] + ], + "use_hardlinks": False }, "CleanUp": { "paterns": [], diff --git a/server/settings/tools.py b/server/settings/tools.py index 1cb070e2af..3ed12d3d0a 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -448,6 +448,17 @@ DEFAULT_TOOLS_VALUES = { "task_types": [], "tasks": [], "template": "SK_{folder[name]}{variant}" + }, + { + "product_types": [ + "hda" + ], + "hosts": [ + "houdini" + ], + "task_types": [], + "tasks": [], + "template": "{folder[name]}_{variant}" } ], "filter_creator_profiles": [] diff --git a/server_addon/aftereffects/client/ayon_aftereffects/api/workfile_template_builder.py b/server_addon/aftereffects/client/ayon_aftereffects/api/workfile_template_builder.py index 77fd1059b5..7fbd469851 100644 --- a/server_addon/aftereffects/client/ayon_aftereffects/api/workfile_template_builder.py +++ b/server_addon/aftereffects/client/ayon_aftereffects/api/workfile_template_builder.py @@ -92,7 +92,7 @@ class AEPlaceholderPlugin(PlaceholderPlugin): return None, None def _collect_scene_placeholders(self): - """" Cache placeholder data to shared data. + """Cache placeholder data to shared data. Returns: (list) of dicts """ diff --git a/server_addon/blender/client/ayon_blender/plugins/publish/extract_thumbnail.py b/server_addon/blender/client/ayon_blender/plugins/publish/extract_thumbnail.py index 40097aaa89..e3bce8bf73 100644 --- a/server_addon/blender/client/ayon_blender/plugins/publish/extract_thumbnail.py +++ b/server_addon/blender/client/ayon_blender/plugins/publish/extract_thumbnail.py @@ -83,7 +83,7 @@ class ExtractThumbnail(plugin.BlenderExtractor): instance.data["representations"].append(representation) def _fix_output_path(self, filepath): - """"Workaround to return correct filepath. + """Workaround to return correct filepath. To workaround this we just glob.glob() for any file extensions and assume the latest modified file is the correct file and return it. diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index 85a93d49cd..1cf699db23 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -153,8 +153,8 @@ class FusionSubmitDeadlineModel(BaseSettingsModel): ) group: str = SettingsField("", title="Group Name") plugin: str = SettingsField("Fusion", - enum_resolver=fusion_deadline_plugin_enum, - title="Deadline Plugin") + enum_resolver=fusion_deadline_plugin_enum, + title="Deadline Plugin") class NukeSubmitDeadlineModel(BaseSettingsModel): @@ -375,11 +375,11 @@ class PublishPluginsModel(BaseSettingsModel): title="Nuke Submit to deadline") ProcessSubmittedCacheJobOnFarm: ProcessCacheJobFarmModel = SettingsField( default_factory=ProcessCacheJobFarmModel, - title="Process submitted cache Job on farm.", - section="Publish Jobs") + title="Process submitted cache Job on farm", + section="Publish Jobs") ProcessSubmittedJobOnFarm: ProcessSubmittedJobOnFarmModel = SettingsField( default_factory=ProcessSubmittedJobOnFarmModel, - title="Process submitted job on farm.") + title="Process submitted job on farm") DEFAULT_DEADLINE_PLUGINS_SETTINGS = { diff --git a/server_addon/houdini/client/ayon_houdini/api/__init__.py b/server_addon/houdini/client/ayon_houdini/api/__init__.py index 2663a55f6f..358113a555 100644 --- a/server_addon/houdini/client/ayon_houdini/api/__init__.py +++ b/server_addon/houdini/client/ayon_houdini/api/__init__.py @@ -4,10 +4,6 @@ from .pipeline import ( containerise ) -from .plugin import ( - Creator, -) - from .lib import ( lsattr, lsattrs, @@ -23,8 +19,6 @@ __all__ = [ "ls", "containerise", - "Creator", - # Utility functions "lsattr", "lsattrs", diff --git a/server_addon/houdini/client/ayon_houdini/api/lib.py b/server_addon/houdini/client/ayon_houdini/api/lib.py index 671265fae9..a536b27f08 100644 --- a/server_addon/houdini/client/ayon_houdini/api/lib.py +++ b/server_addon/houdini/client/ayon_houdini/api/lib.py @@ -148,89 +148,6 @@ def validate_fps(): return True -def create_remote_publish_node(force=True): - """Function to create a remote publish node in /out - - This is a hacked "Shell" node that does *nothing* except for triggering - `colorbleed.lib.publish_remote()` as pre-render script. - - All default attributes of the Shell node are hidden to the Artist to - avoid confusion. - - Additionally some custom attributes are added that can be collected - by a Collector to set specific settings for the publish, e.g. whether - to separate the jobs per instance or process in one single job. - - """ - - cmd = "import colorbleed.lib; colorbleed.lib.publish_remote()" - - existing = hou.node("/out/REMOTE_PUBLISH") - if existing: - if force: - log.warning("Removing existing '/out/REMOTE_PUBLISH' node..") - existing.destroy() - else: - raise RuntimeError("Node already exists /out/REMOTE_PUBLISH. " - "Please remove manually or set `force` to " - "True.") - - # Create the shell node - out = hou.node("/out") - node = out.createNode("shell", node_name="REMOTE_PUBLISH") - node.moveToGoodPosition() - - # Set color make it stand out (avalon/pyblish color) - node.setColor(hou.Color(0.439, 0.709, 0.933)) - - # Set the pre-render script - node.setParms({ - "prerender": cmd, - "lprerender": "python" # command language - }) - - # Lock the attributes to ensure artists won't easily mess things up. - node.parm("prerender").lock(True) - node.parm("lprerender").lock(True) - - # Lock up the actual shell command - command_parm = node.parm("command") - command_parm.set("") - command_parm.lock(True) - shellexec_parm = node.parm("shellexec") - shellexec_parm.set(False) - shellexec_parm.lock(True) - - # Get the node's parm template group so we can customize it - template = node.parmTemplateGroup() - - # Hide default tabs - template.hideFolder("Shell", True) - template.hideFolder("Scripts", True) - - # Hide default settings - template.hide("execute", True) - template.hide("renderdialog", True) - template.hide("trange", True) - template.hide("f", True) - template.hide("take", True) - - # Add custom settings to this node. - parm_folder = hou.FolderParmTemplate("folder", "Submission Settings") - - # Separate Jobs per Instance - parm = hou.ToggleParmTemplate(name="separateJobPerInstance", - label="Separate Job per Instance", - default_value=False) - parm_folder.addParmTemplate(parm) - - # Add our custom Submission Settings folder - template.append(parm_folder) - - # Apply template back to the node - node.setParmTemplateGroup(template) - - def render_rop(ropnode): """Render ROP node utility for Publishing. @@ -1038,17 +955,25 @@ def add_self_publish_button(node): node.setParmTemplateGroup(template) -def get_scene_viewer(): +def get_scene_viewer(visible_only=True): """ Return an instance of a visible viewport. There may be many, some could be closed, any visible are current + Arguments: + visible_only (Optional[bool]): Only return viewers that currently + are the active tab (and hence are visible). + Returns: Optional[hou.SceneViewer]: A scene viewer, if any. """ panes = hou.ui.paneTabs() panes = [x for x in panes if x.type() == hou.paneTabType.SceneViewer] + + if visible_only: + return next((pane for pane in panes if pane.isCurrentTab()), None) + panes = sorted(panes, key=lambda x: x.isCurrentTab()) if panes: return panes[-1] @@ -1067,12 +992,10 @@ def sceneview_snapshot( So, it's capable of generating snapshots image sequence. It works in different Houdini context e.g. Objects, Solaris - Example: - This is how the function can be used:: - - from ayon_houdini.api import lib - sceneview = hou.ui.paneTabOfType(hou.paneTabType.SceneViewer) - lib.sceneview_snapshot(sceneview) + Example:: + >>> from ayon_houdini.api import lib + >>> sceneview = hou.ui.paneTabOfType(hou.paneTabType.SceneViewer) + >>> lib.sceneview_snapshot(sceneview) Notes: .png output will render poorly, so use .jpg. diff --git a/server_addon/houdini/client/ayon_houdini/api/pipeline.py b/server_addon/houdini/client/ayon_houdini/api/pipeline.py index 6af4993d25..5efbcc6ff9 100644 --- a/server_addon/houdini/client/ayon_houdini/api/pipeline.py +++ b/server_addon/houdini/client/ayon_houdini/api/pipeline.py @@ -6,7 +6,7 @@ import logging import hou # noqa from ayon_core.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost - +from ayon_core.tools.utils import host_tools import pyblish.api from ayon_core.pipeline import ( @@ -23,6 +23,7 @@ from ayon_houdini.api import lib, shelves, creator_node_shelves from ayon_core.lib import ( register_event_callback, emit_event, + env_value_to_bool, ) @@ -85,10 +86,9 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): # initialization during start up delays Houdini UI by minutes # making it extremely slow to launch. hdefereval.executeDeferred(shelves.generate_shelves) - - if not IS_HEADLESS: - import hdefereval # noqa, hdefereval is only available in ui mode hdefereval.executeDeferred(creator_node_shelves.install) + if env_value_to_bool("AYON_WORKFILE_TOOL_ON_START"): + hdefereval.executeDeferred(lambda: host_tools.show_workfiles(parent=hou.qt.mainWindow())) def workfile_has_unsaved_changes(self): return hou.hipFile.hasUnsavedChanges() @@ -221,12 +221,8 @@ def containerise(name, """ - # Ensure AVALON_CONTAINERS subnet exists - subnet = hou.node(AVALON_CONTAINERS) - if subnet is None: - obj_network = hou.node("/obj") - subnet = obj_network.createNode("subnet", - node_name="AVALON_CONTAINERS") + # Get AVALON_CONTAINERS subnet + subnet = get_or_create_avalon_container() # Create proper container name container_name = "{}_{}".format(name, suffix or "CON") @@ -401,6 +397,18 @@ def on_new(): _enforce_start_frame() +def get_or_create_avalon_container() -> "hou.OpNode": + avalon_container = hou.node(AVALON_CONTAINERS) + if avalon_container: + return avalon_container + + parent_path, name = AVALON_CONTAINERS.rsplit("/", 1) + parent = hou.node(parent_path) + return parent.createNode( + "subnet", node_name=name + ) + + def _set_context_settings(): """Apply the project settings from the project definition diff --git a/server_addon/houdini/client/ayon_houdini/api/plugin.py b/server_addon/houdini/client/ayon_houdini/api/plugin.py index 9c6bba925a..2eb34bc727 100644 --- a/server_addon/houdini/client/ayon_houdini/api/plugin.py +++ b/server_addon/houdini/client/ayon_houdini/api/plugin.py @@ -10,8 +10,7 @@ import hou import pyblish.api from ayon_core.pipeline import ( CreatorError, - LegacyCreator, - Creator as NewCreator, + Creator, CreatedInstance, AYON_INSTANCE_ID, AVALON_INSTANCE_ID, @@ -26,80 +25,6 @@ from .lib import imprint, read, lsattr, add_self_publish_button SETTINGS_CATEGORY = "houdini" -class Creator(LegacyCreator): - """Creator plugin to create instances in Houdini - - To support the wide range of node types for render output (Alembic, VDB, - Mantra) the Creator needs a node type to create the correct instance - - By default, if none is given, is `geometry`. An example of accepted node - types: geometry, alembic, ifd (mantra) - - Please check the Houdini documentation for more node types. - - Tip: to find the exact node type to create press the `i` left of the node - when hovering over a node. The information is visible under the name of - the node. - - Deprecated: - This creator is deprecated and will be removed in future version. - - """ - defaults = ['Main'] - - def __init__(self, *args, **kwargs): - super(Creator, self).__init__(*args, **kwargs) - self.nodes = [] - - def process(self): - """This is the base functionality to create instances in Houdini - - The selected nodes are stored in self to be used in an override method. - This is currently necessary in order to support the multiple output - types in Houdini which can only be rendered through their own node. - - Default node type if none is given is `geometry` - - It also makes it easier to apply custom settings per instance type - - Example of override method for Alembic: - - def process(self): - instance = super(CreateEpicNode, self, process() - # Set parameters for Alembic node - instance.setParms( - {"sop_path": "$HIP/%s.abc" % self.nodes[0]} - ) - - Returns: - hou.Node - - """ - try: - if (self.options or {}).get("useSelection"): - self.nodes = hou.selectedNodes() - - # Get the node type and remove it from the data, not needed - node_type = self.data.pop("node_type", None) - if node_type is None: - node_type = "geometry" - - # Get out node - out = hou.node("/out") - instance = out.createNode(node_type, node_name=self.name) - instance.moveToGoodPosition() - - imprint(instance, self.data) - - self._process(instance) - - except hou.Error as er: - six.reraise( - CreatorError, - CreatorError("Creator error: {}".format(er)), - sys.exc_info()[2]) - - class HoudiniCreatorBase(object): @staticmethod def cache_instance_data(shared_data): @@ -148,7 +73,11 @@ class HoudiniCreatorBase(object): @staticmethod def create_instance_node( - folder_path, node_name, parent, node_type="geometry" + folder_path, + node_name, + parent, + node_type="geometry", + pre_create_data=None ): """Create node representing instance. @@ -157,6 +86,7 @@ class HoudiniCreatorBase(object): node_name (str): Name of the new node. parent (str): Name of the parent node. node_type (str, optional): Type of the node. + pre_create_data (Optional[Dict]): Pre create data. Returns: hou.Node: Newly created instance node. @@ -170,7 +100,7 @@ class HoudiniCreatorBase(object): @six.add_metaclass(ABCMeta) -class HoudiniCreator(NewCreator, HoudiniCreatorBase): +class HoudiniCreator(Creator, HoudiniCreatorBase): """Base class for most of the Houdini creator plugins.""" selected_nodes = [] settings_name = None @@ -193,7 +123,12 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): folder_path = instance_data["folderPath"] instance_node = self.create_instance_node( - folder_path, product_name, "/out", node_type) + folder_path, + product_name, + "/out", + node_type, + pre_create_data + ) self.customize_node_look(instance_node) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/create/create_hda.py b/server_addon/houdini/client/ayon_houdini/plugins/create/create_hda.py index 694bc4f3c3..179a6c2b00 100644 --- a/server_addon/houdini/client/ayon_houdini/plugins/create/create_hda.py +++ b/server_addon/houdini/client/ayon_houdini/plugins/create/create_hda.py @@ -1,13 +1,19 @@ # -*- coding: utf-8 -*- """Creator plugin for creating publishable Houdini Digital Assets.""" -import ayon_api +import hou +from assettools import setToolSubmenu +import ayon_api from ayon_core.pipeline import ( CreatorError, get_current_project_name ) +from ayon_core.lib import ( + get_ayon_username, + BoolDef +) + from ayon_houdini.api import plugin -import hou class CreateHDA(plugin.HoudiniCreator): @@ -37,19 +43,38 @@ class CreateHDA(plugin.HoudiniCreator): return product_name.lower() in existing_product_names_low def create_instance_node( - self, folder_path, node_name, parent, node_type="geometry" + self, + folder_path, + node_name, + parent, + node_type="geometry", + pre_create_data=None ): + if pre_create_data is None: + pre_create_data = {} - parent_node = hou.node("/obj") if self.selected_nodes: # if we have `use selection` enabled, and we have some # selected nodes ... - subnet = parent_node.collapseIntoSubnet( - self.selected_nodes, - subnet_name="{}_subnet".format(node_name)) - subnet.moveToGoodPosition() - to_hda = subnet + if self.selected_nodes[0].type().name() == "subnet": + to_hda = self.selected_nodes[0] + to_hda.setName("{}_subnet".format(node_name), unique_name=True) + else: + parent_node = self.selected_nodes[0].parent() + subnet = parent_node.collapseIntoSubnet( + self.selected_nodes, + subnet_name="{}_subnet".format(node_name)) + subnet.moveToGoodPosition() + to_hda = subnet else: + # Use Obj as the default path + parent_node = hou.node("/obj") + # Find and return the NetworkEditor pane tab with the minimum index + pane = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor) + if isinstance(pane, hou.NetworkEditor): + # Use the NetworkEditor pane path as the parent path. + parent_node = pane.pwd() + to_hda = parent_node.createNode( "subnet", node_name="{}_subnet".format(node_name)) if not to_hda.type().definition(): @@ -71,7 +96,8 @@ class CreateHDA(plugin.HoudiniCreator): hda_node = to_hda.createDigitalAsset( name=type_name, description=node_name, - hda_file_name="$HIP/{}.hda".format(node_name) + hda_file_name="$HIP/{}.hda".format(node_name), + ignore_external_references=True ) hda_node.layoutChildren() elif self._check_existing(folder_path, node_name): @@ -81,21 +107,92 @@ class CreateHDA(plugin.HoudiniCreator): else: hda_node = to_hda - hda_node.setName(node_name) + # If user tries to create the same HDA instance more than + # once, then all of them will have the same product name and + # point to the same hda_file_name. But, their node names will + # be incremented. + hda_node.setName(node_name, unique_name=True) self.customize_node_look(hda_node) + + # Set Custom settings. + hda_def = hda_node.type().definition() + + if pre_create_data.get("set_user"): + hda_def.setUserInfo(get_ayon_username()) + + if pre_create_data.get("use_project"): + setToolSubmenu(hda_def, "AYON/{}".format(self.project_name)) + return hda_node def create(self, product_name, instance_data, pre_create_data): instance_data.pop("active", None) - instance = super(CreateHDA, self).create( + return super(CreateHDA, self).create( product_name, instance_data, pre_create_data) - return instance - def get_network_categories(self): + # Houdini allows creating sub-network nodes inside + # these categories. + # Therefore this plugin can work in these categories. return [ - hou.objNodeTypeCategory() + hou.chopNodeTypeCategory(), + hou.cop2NodeTypeCategory(), + hou.dopNodeTypeCategory(), + hou.ropNodeTypeCategory(), + hou.lopNodeTypeCategory(), + hou.objNodeTypeCategory(), + hou.sopNodeTypeCategory(), + hou.topNodeTypeCategory(), + hou.vopNodeTypeCategory() ] + + def get_pre_create_attr_defs(self): + attrs = super(CreateHDA, self).get_pre_create_attr_defs() + return attrs + [ + BoolDef("set_user", + tooltip="Set current user as the author of the HDA", + default=False, + label="Set Current User"), + BoolDef("use_project", + tooltip="Use project name as tab submenu path.\n" + "The location in TAB Menu will be\n" + "'AYON/project_name/your_HDA_name'", + default=True, + label="Use Project as menu entry"), + ] + + def get_dynamic_data( + self, + project_name, + folder_entity, + task_entity, + variant, + host_name, + instance + ): + """ + Pass product name from product name templates as dynamic data. + """ + dynamic_data = super(CreateHDA, self).get_dynamic_data( + project_name, + folder_entity, + task_entity, + variant, + host_name, + instance + ) + + dynamic_data.update( + { + "asset": folder_entity["name"], + "folder": { + "label": folder_entity["label"], + "name": folder_entity["name"] + } + } + ) + + return dynamic_data diff --git a/server_addon/houdini/client/ayon_houdini/plugins/create/create_mantra_ifd.py b/server_addon/houdini/client/ayon_houdini/plugins/create/create_mantra_ifd.py deleted file mode 100644 index fc5c4819d0..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/create/create_mantra_ifd.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating pointcache alembics.""" -from ayon_houdini.api import plugin -from ayon_core.lib import BoolDef - - -class CreateMantraIFD(plugin.HoudiniCreator): - """Mantra .ifd Archive""" - identifier = "io.openpype.creators.houdini.mantraifd" - label = "Mantra IFD" - product_type = "mantraifd" - icon = "gears" - - def create(self, product_name, instance_data, pre_create_data): - import hou - instance_data.pop("active", None) - instance_data.update({"node_type": "ifd"}) - creator_attributes = instance_data.setdefault( - "creator_attributes", dict()) - creator_attributes["farm"] = pre_create_data["farm"] - instance = super(CreateMantraIFD, self).create( - product_name, - instance_data, - pre_create_data) - - instance_node = hou.node(instance.get("instance_node")) - - filepath = "{}{}".format( - hou.text.expandString("$HIP/pyblish/"), - "{}.$F4.ifd".format(product_name)) - parms = { - # Render frame range - "trange": 1, - # Arnold ROP settings - "soho_diskfile": filepath, - "soho_outputmode": 1 - } - - instance_node.setParms(parms) - - # Lock any parameters in this list - to_lock = ["soho_outputmode", "productType", "id"] - self.lock_parameters(instance_node, to_lock) - - def get_instance_attr_defs(self): - return [ - BoolDef("farm", - label="Submitting to Farm", - default=False) - ] - - def get_pre_create_attr_defs(self): - attrs = super().get_pre_create_attr_defs() - # Use same attributes as for instance attributes - return attrs + self.get_instance_attr_defs() diff --git a/server_addon/houdini/client/ayon_houdini/plugins/load/load_hda.py b/server_addon/houdini/client/ayon_houdini/plugins/load/load_hda.py index 5738ba7fab..fcf0e834f8 100644 --- a/server_addon/houdini/client/ayon_houdini/plugins/load/load_hda.py +++ b/server_addon/houdini/client/ayon_houdini/plugins/load/load_hda.py @@ -1,8 +1,13 @@ # -*- coding: utf-8 -*- import os -from ayon_core.pipeline import get_representation_path +import hou +from ayon_core.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID +) from ayon_core.pipeline.load import LoadError from ayon_houdini.api import ( + lib, pipeline, plugin ) @@ -19,42 +24,43 @@ class HdaLoader(plugin.HoudiniLoader): color = "orange" def load(self, context, name=None, namespace=None, data=None): - import hou # Format file name, Houdini only wants forward slashes file_path = self.filepath_from_context(context) file_path = os.path.normpath(file_path) file_path = file_path.replace("\\", "/") - # Get the root node - obj = hou.node("/obj") - namespace = namespace or context["folder"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name hou.hda.installFile(file_path) - # Get the type name from the HDA definition. hda_defs = hou.hda.definitionsInFile(file_path) if not hda_defs: raise LoadError(f"No HDA definitions found in file: {file_path}") - type_name = hda_defs[0].nodeTypeName() - hda_node = obj.createNode(type_name, node_name) + parent_node = self._create_dedicated_parent_node(hda_defs[-1]) - self[:] = [hda_node] + # Get the type name from the HDA definition. + type_name = hda_defs[-1].nodeTypeName() + hda_node = parent_node.createNode(type_name, node_name) + hda_node.moveToGoodPosition() - return pipeline.containerise( - node_name, - namespace, - [hda_node], - context, - self.__class__.__name__, - suffix="", - ) + # Imprint it manually + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": node_name, + "namespace": namespace, + "loader": self.__class__.__name__, + "representation": context["representation"]["id"], + } + + lib.imprint(hda_node, data) + + return hda_node def update(self, container, context): - import hou repre_entity = context["representation"] hda_node = container["node"] @@ -71,4 +77,45 @@ class HdaLoader(plugin.HoudiniLoader): def remove(self, container): node = container["node"] + parent = node.parent() node.destroy() + + if parent.path() == pipeline.AVALON_CONTAINERS: + return + + # Remove parent if empty. + if not parent.children(): + parent.destroy() + + def _create_dedicated_parent_node(self, hda_def): + + # Get the root node + parent_node = pipeline.get_or_create_avalon_container() + node = None + node_type = None + if hda_def.nodeTypeCategory() == hou.objNodeTypeCategory(): + return parent_node + elif hda_def.nodeTypeCategory() == hou.chopNodeTypeCategory(): + node_type, node_name = "chopnet", "MOTION" + elif hda_def.nodeTypeCategory() == hou.cop2NodeTypeCategory(): + node_type, node_name = "cop2net", "IMAGES" + elif hda_def.nodeTypeCategory() == hou.dopNodeTypeCategory(): + node_type, node_name = "dopnet", "DOPS" + elif hda_def.nodeTypeCategory() == hou.ropNodeTypeCategory(): + node_type, node_name = "ropnet", "ROPS" + elif hda_def.nodeTypeCategory() == hou.lopNodeTypeCategory(): + node_type, node_name = "lopnet", "LOPS" + elif hda_def.nodeTypeCategory() == hou.sopNodeTypeCategory(): + node_type, node_name = "geo", "SOPS" + elif hda_def.nodeTypeCategory() == hou.topNodeTypeCategory(): + node_type, node_name = "topnet", "TOPS" + # TODO: Create a dedicated parent node based on Vop Node vex context. + elif hda_def.nodeTypeCategory() == hou.vopNodeTypeCategory(): + node_type, node_name = "matnet", "MATSandVOPS" + + node = parent_node.node(node_name) + if not node: + node = parent_node.createNode(node_type, node_name) + + node.moveToGoodPosition() + return node diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_cache_farm.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_cache_farm.py index ecfebccfef..c558f35208 100644 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_cache_farm.py +++ b/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_cache_farm.py @@ -12,9 +12,7 @@ class CollectDataforCache(plugin.HoudiniInstancePlugin): # Run after Collect Frames order = pyblish.api.CollectorOrder + 0.11 - families = ["ass", "pointcache", - "mantraifd", "redshiftproxy", - "vdbcache", "model"] + families = ["ass", "pointcache", "redshiftproxy", "vdbcache", "model"] targets = ["local", "remote"] label = "Collect Data for Cache" diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_chunk_size.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_chunk_size.py index 6ff53b7695..cd94827ba7 100644 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_chunk_size.py +++ b/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_chunk_size.py @@ -9,9 +9,7 @@ class CollectChunkSize(plugin.HoudiniInstancePlugin, """Collect chunk size for cache submission to Deadline.""" order = pyblish.api.CollectorOrder + 0.05 - families = ["ass", "pointcache", - "vdbcache", "mantraifd", - "redshiftproxy", "model"] + families = ["ass", "pointcache", "vdbcache", "redshiftproxy", "model"] targets = ["local", "remote"] label = "Collect Chunk Size" chunk_size = 999999 diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_frames.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_frames.py index 3378657bfd..01dd5fdf05 100644 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_frames.py +++ b/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_frames.py @@ -15,9 +15,8 @@ class CollectFrames(plugin.HoudiniInstancePlugin): # this plugin runs after CollectRopFrameRange order = pyblish.api.CollectorOrder + 0.1 label = "Collect Frames" - families = ["vdbcache", "imagesequence", "ass", - "mantraifd", "redshiftproxy", "review", - "pointcache"] + families = ["camera", "vdbcache", "imagesequence", "ass", + "redshiftproxy", "review", "pointcache", "fbx"] def process(self, instance): @@ -60,7 +59,10 @@ class CollectFrames(plugin.HoudiniInstancePlugin): # todo: `frames` currently conflicts with "explicit frames" for a # for a custom frame list. So this should be refactored. - instance.data.update({"frames": result}) + instance.data.update({ + "frames": result, + "stagingDir": os.path.dirname(output) + }) @staticmethod def create_file_list(match, start_frame, end_frame): diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_remote_publish.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_remote_publish.py deleted file mode 100644 index e695b57518..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/collect_remote_publish.py +++ /dev/null @@ -1,29 +0,0 @@ -import hou -import pyblish.api - -from ayon_core.pipeline.publish import RepairAction -from ayon_houdini.api import lib, plugin - - -class CollectRemotePublishSettings(plugin.HoudiniContextPlugin): - """Collect custom settings of the Remote Publish node.""" - - order = pyblish.api.CollectorOrder - families = ["*"] - targets = ["deadline"] - label = "Remote Publish Submission Settings" - actions = [RepairAction] - - def process(self, context): - - node = hou.node("/out/REMOTE_PUBLISH") - if not node: - return - - attributes = lib.read(node) - - # Debug the settings we have collected - for key, value in sorted(attributes.items()): - self.log.debug("Collected %s: %s" % (key, value)) - - context.data.update(attributes) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_alembic.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_alembic.py deleted file mode 100644 index e82f07284a..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_alembic.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import hou - -import pyblish.api - -from ayon_houdini.api import plugin -from ayon_houdini.api.lib import render_rop - - -class ExtractAlembic(plugin.HoudiniExtractorPlugin): - - order = pyblish.api.ExtractorOrder - label = "Extract Alembic" - families = ["abc", "camera"] - targets = ["local", "remote"] - - def process(self, instance): - if instance.data.get("farm"): - self.log.debug("Should be processed on farm, skipping.") - return - - ropnode = hou.node(instance.data["instance_node"]) - - # Get the filename from the filename parameter - output = ropnode.evalParm("filename") - staging_dir = os.path.dirname(output) - instance.data["stagingDir"] = staging_dir - - 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'" % (files, - staging_dir)) - - render_rop(ropnode) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - 'name': 'abc', - 'ext': 'abc', - 'files': files, - "stagingDir": staging_dir, - } - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_ass.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_ass.py deleted file mode 100644 index a796bbf4b3..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_ass.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import hou - -import pyblish.api - -from ayon_houdini.api import plugin -from ayon_houdini.api.lib import render_rop - - -class ExtractAss(plugin.HoudiniExtractorPlugin): - - order = pyblish.api.ExtractorOrder + 0.1 - label = "Extract Ass" - families = ["ass"] - targets = ["local", "remote"] - - def process(self, instance): - if instance.data.get("farm"): - self.log.debug("Should be processed on farm, skipping.") - return - ropnode = hou.node(instance.data["instance_node"]) - - # Get the filename from the filename parameter - # `.evalParm(parameter)` will make sure all tokens are resolved - output = ropnode.evalParm("ar_ass_file") - staging_dir = os.path.dirname(output) - instance.data["stagingDir"] = staging_dir - file_name = os.path.basename(output) - - # We run the render - self.log.info("Writing ASS '%s' to '%s'" % (file_name, staging_dir)) - - render_rop(ropnode) - - # Unfortunately user interrupting the extraction does not raise an - # error and thus still continues to the integrator. To capture that - # we make sure all files exist - files = instance.data["frames"] - missing = [] - for file_name in files: - full_path = os.path.normpath(os.path.join(staging_dir, file_name)) - if not os.path.exists(full_path): - missing.append(full_path) - - if missing: - raise RuntimeError("Failed to complete Arnold ass extraction. " - "Missing output files: {}".format(missing)) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - # Allow ass.gz extension as well - ext = "ass.gz" if file_name.endswith(".ass.gz") else "ass" - - representation = { - 'name': 'ass', - 'ext': ext, - "files": files, - "stagingDir": staging_dir, - "frameStart": instance.data["frameStartHandle"], - "frameEnd": instance.data["frameEndHandle"], - } - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_bgeo.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_bgeo.py deleted file mode 100644 index ab8837065d..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_bgeo.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import hou - -import pyblish.api - -from ayon_houdini.api import lib, plugin - - -class ExtractBGEO(plugin.HoudiniExtractorPlugin): - - order = pyblish.api.ExtractorOrder - label = "Extract BGEO" - families = ["bgeo"] - - def process(self, instance): - if instance.data.get("farm"): - self.log.debug("Should be processed on farm, skipping.") - return - ropnode = hou.node(instance.data["instance_node"]) - - # Get the filename from the filename parameter - output = ropnode.evalParm("sopoutput") - staging_dir, file_name = os.path.split(output) - instance.data["stagingDir"] = staging_dir - - # We run the render - self.log.info("Writing bgeo files '{}' to '{}'.".format( - file_name, staging_dir)) - - # write files - lib.render_rop(ropnode) - - output = instance.data["frames"] - - _, ext = lib.splitext( - output[0], allowed_multidot_extensions=[ - ".ass.gz", ".bgeo.sc", ".bgeo.gz", - ".bgeo.lzma", ".bgeo.bz2"]) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - "name": "bgeo", - "ext": ext.lstrip("."), - "files": output, - "stagingDir": staging_dir, - "frameStart": instance.data["frameStartHandle"], - "frameEnd": instance.data["frameEndHandle"] - } - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_composite.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_composite.py deleted file mode 100644 index cab462aef6..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_composite.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -import hou -import pyblish.api - -from ayon_core.pipeline import publish -from ayon_houdini.api import plugin -from ayon_houdini.api.lib import render_rop, splitext - - -class ExtractComposite(plugin.HoudiniExtractorPlugin, - publish.ColormanagedPyblishPluginMixin): - - order = pyblish.api.ExtractorOrder - label = "Extract Composite (Image Sequence)" - families = ["imagesequence"] - - def process(self, instance): - - ropnode = hou.node(instance.data["instance_node"]) - - # Get the filename from the copoutput parameter - # `.evalParm(parameter)` will make sure all tokens are resolved - output = ropnode.evalParm("copoutput") - staging_dir = os.path.dirname(output) - instance.data["stagingDir"] = staging_dir - file_name = os.path.basename(output) - - self.log.info("Writing comp '%s' to '%s'" % (file_name, staging_dir)) - - render_rop(ropnode) - - output = instance.data["frames"] - _, ext = splitext(output[0], []) - ext = ext.lstrip(".") - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - "name": ext, - "ext": ext, - "files": output, - "stagingDir": staging_dir, - "frameStart": instance.data["frameStartHandle"], - "frameEnd": instance.data["frameEndHandle"], - } - - if ext.lower() == "exr": - # Inject colorspace with 'scene_linear' as that's the - # default Houdini working colorspace and all extracted - # OpenEXR images should be in that colorspace. - # https://www.sidefx.com/docs/houdini/render/linear.html#image-formats - self.set_representation_colorspace( - representation, instance.context, - colorspace="scene_linear" - ) - - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_fbx.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_fbx.py deleted file mode 100644 index 49b3fa07ca..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_fbx.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- coding: utf-8 -*- -"""Fbx Extractor for houdini. """ - -import os -import hou -import pyblish.api -from ayon_houdini.api import plugin -from ayon_houdini.api.lib import render_rop - - -class ExtractFBX(plugin.HoudiniExtractorPlugin): - - label = "Extract FBX" - families = ["fbx"] - - order = pyblish.api.ExtractorOrder + 0.1 - - def process(self, instance): - - # get rop node - ropnode = hou.node(instance.data.get("instance_node")) - output_file = ropnode.evalParm("sopoutput") - - # get staging_dir and file_name - staging_dir = os.path.normpath(os.path.dirname(output_file)) - file_name = os.path.basename(output_file) - - # render rop - self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir) - render_rop(ropnode) - - # prepare representation - representation = { - "name": "fbx", - "ext": "fbx", - "files": file_name, - "stagingDir": staging_dir - } - - # A single frame may also be rendered without start/end frame. - if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa - representation["frameStart"] = instance.data["frameStartHandle"] - representation["frameEnd"] = instance.data["frameEndHandle"] - - # set value type for 'representations' key to list - if "representations" not in instance.data: - instance.data["representations"] = [] - - # update instance data - instance.data["stagingDir"] = staging_dir - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_mantra_ifd.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_mantra_ifd.py deleted file mode 100644 index b424f2e452..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_mantra_ifd.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -import hou - -import pyblish.api - -from ayon_houdini.api import plugin - - -class ExtractMantraIFD(plugin.HoudiniExtractorPlugin): - - order = pyblish.api.ExtractorOrder - label = "Extract Mantra ifd" - families = ["mantraifd"] - targets = ["local", "remote"] - - def process(self, instance): - if instance.data.get("farm"): - self.log.debug("Should be processed on farm, skipping.") - return - - ropnode = hou.node(instance.data.get("instance_node")) - output = ropnode.evalParm("soho_diskfile") - staging_dir = os.path.dirname(output) - instance.data["stagingDir"] = staging_dir - - files = instance.data["frames"] - missing_frames = [ - frame - for frame in instance.data["frames"] - if not os.path.exists( - os.path.normpath(os.path.join(staging_dir, frame))) - ] - if missing_frames: - raise RuntimeError("Failed to complete Mantra ifd extraction. " - "Missing output files: {}".format( - missing_frames)) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - 'name': 'ifd', - 'ext': 'ifd', - 'files': files, - "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], - } - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_opengl.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_opengl.py deleted file mode 100644 index bee1bf871f..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_opengl.py +++ /dev/null @@ -1,69 +0,0 @@ -import os -import hou - -import pyblish.api - -from ayon_core.pipeline import publish -from ayon_houdini.api import plugin -from ayon_houdini.api.lib import render_rop - - -class ExtractOpenGL(plugin.HoudiniExtractorPlugin, - publish.ColormanagedPyblishPluginMixin): - - order = pyblish.api.ExtractorOrder - 0.01 - label = "Extract OpenGL" - families = ["review"] - - def process(self, instance): - ropnode = hou.node(instance.data.get("instance_node")) - - # This plugin is triggered when marking render as reviewable. - # Therefore, this plugin will run on over wrong instances. - # TODO: Don't run this plugin on wrong instances. - # This plugin should run only on review product type - # with instance node of opengl type. - if ropnode.type().name() != "opengl": - self.log.debug("Skipping OpenGl extraction. Rop node {} " - "is not an OpenGl node.".format(ropnode.path())) - return - - output = ropnode.evalParm("picture") - staging_dir = os.path.normpath(os.path.dirname(output)) - instance.data["stagingDir"] = staging_dir - file_name = os.path.basename(output) - - self.log.info("Extracting '%s' to '%s'" % (file_name, - staging_dir)) - - render_rop(ropnode) - - output = instance.data["frames"] - - tags = ["review"] - if not instance.data.get("keepImages"): - tags.append("delete") - - representation = { - "name": instance.data["imageFormat"], - "ext": instance.data["imageFormat"], - "files": output, - "stagingDir": staging_dir, - "frameStart": instance.data["frameStartHandle"], - "frameEnd": instance.data["frameEndHandle"], - "tags": tags, - "preview": True, - "camera_name": instance.data.get("review_camera") - } - - if ropnode.evalParm("colorcorrect") == 2: # OpenColorIO enabled - colorspace = ropnode.evalParm("ociocolorspace") - # inject colorspace data - self.set_representation_colorspace( - representation, instance.context, - colorspace=colorspace - ) - - if "representations" not in instance.data: - instance.data["representations"] = [] - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_redshift_proxy.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_redshift_proxy.py deleted file mode 100644 index 3e8a79df00..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_redshift_proxy.py +++ /dev/null @@ -1,52 +0,0 @@ -import os -import hou - -import pyblish.api - -from ayon_houdini.api import plugin -from ayon_houdini.api.lib import render_rop - - -class ExtractRedshiftProxy(plugin.HoudiniExtractorPlugin): - - order = pyblish.api.ExtractorOrder + 0.1 - label = "Extract Redshift Proxy" - families = ["redshiftproxy"] - targets = ["local", "remote"] - - def process(self, instance): - if instance.data.get("farm"): - self.log.debug("Should be processed on farm, skipping.") - return - ropnode = hou.node(instance.data.get("instance_node")) - - # Get the filename from the filename parameter - # `.evalParm(parameter)` will make sure all tokens are resolved - output = ropnode.evalParm("RS_archive_file") - staging_dir = os.path.normpath(os.path.dirname(output)) - instance.data["stagingDir"] = staging_dir - file_name = os.path.basename(output) - - self.log.info("Writing Redshift Proxy '%s' to '%s'" % (file_name, - staging_dir)) - - render_rop(ropnode) - - output = instance.data["frames"] - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - "name": "rs", - "ext": "rs", - "files": output, - "stagingDir": staging_dir, - } - - # A single frame may also be rendered without start/end frame. - if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa - representation["frameStart"] = instance.data["frameStartHandle"] - representation["frameEnd"] = instance.data["frameEndHandle"] - - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_rop.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_rop.py new file mode 100644 index 0000000000..62a38c0b93 --- /dev/null +++ b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_rop.py @@ -0,0 +1,150 @@ +import os +import hou + +import pyblish.api + +from ayon_core.pipeline import publish +from ayon_houdini.api import plugin +from ayon_houdini.api.lib import render_rop, splitext + + +class ExtractROP(plugin.HoudiniExtractorPlugin): + """Generic Extractor for any ROP node.""" + label = "Extract ROP" + order = pyblish.api.ExtractorOrder + + families = ["abc", "camera", "bgeo", "pointcache", "fbx", + "vdbcache", "ass", "redshiftproxy", "mantraifd"] + targets = ["local", "remote"] + + def process(self, instance: pyblish.api.Instance): + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return + + rop_node = hou.node(instance.data["instance_node"]) + + files = instance.data["frames"] + first_file = files[0] if isinstance(files, (list, tuple)) else files + _, ext = splitext( + first_file, allowed_multidot_extensions=[ + ".ass.gz", ".bgeo.sc", ".bgeo.gz", + ".bgeo.lzma", ".bgeo.bz2"] + ) + ext = ext.lstrip(".") + + self.log.debug(f"Rendering {rop_node.path()} to {first_file}..") + + render_rop(rop_node) + self.validate_expected_frames(instance) + + # In some cases representation name is not the the extension + # TODO: Preferably we remove this very specific naming + product_type = instance.data["productType"] + name = { + "bgeo": "bgeo", + "rs": "rs", + "ass": "ass" + }.get(product_type, ext) + + representation = { + "name": name, + "ext": ext, + "files": instance.data["frames"], + "stagingDir": instance.data["stagingDir"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], + } + self.update_representation_data(instance, representation) + instance.data.setdefault("representations", []).append(representation) + + def validate_expected_frames(self, instance: pyblish.api.Instance): + """ + Validate all expected files in `instance.data["frames"]` exist in + the staging directory. + """ + filenames = instance.data["frames"] + staging_dir = instance.data["stagingDir"] + if isinstance(filenames, str): + # Single frame + filenames = [filenames] + + missing_filenames = [ + filename for filename in filenames + if not os.path.isfile(os.path.join(staging_dir, filename)) + ] + if missing_filenames: + raise RuntimeError(f"Missing frames: {missing_filenames}") + + def update_representation_data(self, + instance: pyblish.api.Instance, + representation: dict): + """Allow subclass to override the representation data in-place""" + pass + + +class ExtractOpenGL(ExtractROP, + publish.ColormanagedPyblishPluginMixin): + + order = pyblish.api.ExtractorOrder - 0.01 + label = "Extract OpenGL" + families = ["review"] + + def process(self, instance): + # This plugin is triggered when marking render as reviewable. + # Therefore, this plugin will run over wrong instances. + # TODO: Don't run this plugin on wrong instances. + # This plugin should run only on review product type + # with instance node of opengl type. + instance_node = instance.data.get("instance_node") + if not instance_node: + self.log.debug("Skipping instance without instance node.") + return + + rop_node = hou.node(instance_node) + if rop_node.type().name() != "opengl": + self.log.debug("Skipping OpenGl extraction. Rop node {} " + "is not an OpenGl node.".format(rop_node.path())) + return + + super(ExtractOpenGL, self).process(instance) + + def update_representation_data(self, + instance: pyblish.api.Instance, + representation: dict): + tags = ["review"] + if not instance.data.get("keepImages"): + tags.append("delete") + + representation.update({ + # TODO: Avoid this override? + "name": instance.data["imageFormat"], + "ext": instance.data["imageFormat"], + + "tags": tags, + "preview": True, + "camera_name": instance.data.get("review_camera") + }) + + +class ExtractComposite(ExtractROP, + publish.ColormanagedPyblishPluginMixin): + + label = "Extract Composite (Image Sequence)" + families = ["imagesequence"] + + def update_representation_data(self, + instance: pyblish.api.Instance, + representation: dict): + + if representation["ext"].lower() != "exr": + return + + # Inject colorspace with 'scene_linear' as that's the + # default Houdini working colorspace and all extracted + # OpenEXR images should be in that colorspace. + # https://www.sidefx.com/docs/houdini/render/linear.html#image-formats + self.set_representation_colorspace( + representation, instance.context, + colorspace="scene_linear" + ) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_vdb_cache.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_vdb_cache.py deleted file mode 100644 index a944d81e9b..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/extract_vdb_cache.py +++ /dev/null @@ -1,46 +0,0 @@ -import os -import hou - -import pyblish.api - -from ayon_houdini.api import plugin -from ayon_houdini.api.lib import render_rop - - -class ExtractVDBCache(plugin.HoudiniExtractorPlugin): - - order = pyblish.api.ExtractorOrder + 0.1 - label = "Extract VDB Cache" - families = ["vdbcache"] - - def process(self, instance): - if instance.data.get("farm"): - self.log.debug("Should be processed on farm, skipping.") - return - ropnode = hou.node(instance.data["instance_node"]) - - # Get the filename from the filename parameter - # `.evalParm(parameter)` will make sure all tokens are resolved - sop_output = ropnode.evalParm("sopoutput") - staging_dir = os.path.normpath(os.path.dirname(sop_output)) - instance.data["stagingDir"] = staging_dir - file_name = os.path.basename(sop_output) - - self.log.info("Writing VDB '%s' to '%s'" % (file_name, staging_dir)) - - render_rop(ropnode) - - output = instance.data["frames"] - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - "name": "vdb", - "ext": "vdb", - "files": output, - "stagingDir": staging_dir, - "frameStart": instance.data["frameStartHandle"], - "frameEnd": instance.data["frameEndHandle"], - } - instance.data["representations"].append(representation) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_remote_publish.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_remote_publish.py deleted file mode 100644 index 08597c0a6f..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_remote_publish.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*-coding: utf-8 -*- -import hou - -import pyblish.api -from ayon_core.pipeline.publish import RepairContextAction -from ayon_core.pipeline import PublishValidationError - -from ayon_houdini.api import lib, plugin - - -class ValidateRemotePublishOutNode(plugin.HoudiniContextPlugin): - """Validate the remote publish out node exists for Deadline to trigger.""" - - order = pyblish.api.ValidatorOrder - 0.4 - families = ["*"] - targets = ["deadline"] - label = "Remote Publish ROP node" - actions = [RepairContextAction] - - def process(self, context): - - cmd = "import colorbleed.lib; colorbleed.lib.publish_remote()" - - node = hou.node("/out/REMOTE_PUBLISH") - if not node: - raise RuntimeError("Missing REMOTE_PUBLISH node.") - - # We ensure it's a shell node and that it has the pre-render script - # set correctly. Plus the shell script it will trigger should be - # completely empty (doing nothing) - if node.type().name() != "shell": - self.raise_error("Must be shell ROP node") - if node.parm("command").eval() != "": - self.raise_error("Must have no command") - if node.parm("shellexec").eval(): - self.raise_error("Must not execute in shell") - if node.parm("prerender").eval() != cmd: - self.raise_error("REMOTE_PUBLISH node does not have " - "correct prerender script.") - if node.parm("lprerender").eval() != "python": - self.raise_error("REMOTE_PUBLISH node prerender script " - "type not set to 'python'") - - @classmethod - def repair(cls, context): - """(Re)create the node if it fails to pass validation.""" - lib.create_remote_publish_node(force=True) - - def raise_error(self, message): - raise PublishValidationError(message) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_remote_publish_enabled.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_remote_publish_enabled.py deleted file mode 100644 index dc5666609f..0000000000 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_remote_publish_enabled.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -import hou - -import pyblish.api -from ayon_core.pipeline.publish import RepairContextAction -from ayon_core.pipeline import PublishValidationError - -from ayon_houdini.api import plugin - - -class ValidateRemotePublishEnabled(plugin.HoudiniContextPlugin): - """Validate the remote publish node is *not* bypassed.""" - - order = pyblish.api.ValidatorOrder - 0.39 - families = ["*"] - targets = ["deadline"] - label = "Remote Publish ROP enabled" - actions = [RepairContextAction] - - def process(self, context): - - node = hou.node("/out/REMOTE_PUBLISH") - if not node: - raise PublishValidationError( - "Missing REMOTE_PUBLISH node.", title=self.label) - - if node.isBypassed(): - raise PublishValidationError( - "REMOTE_PUBLISH must not be bypassed.", title=self.label) - - @classmethod - def repair(cls, context): - """(Re)create the node if it fails to pass validation.""" - - node = hou.node("/out/REMOTE_PUBLISH") - if not node: - raise PublishValidationError( - "Missing REMOTE_PUBLISH node.", title=cls.label) - - cls.log.info("Disabling bypass on /out/REMOTE_PUBLISH") - node.bypass(False) diff --git a/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_subset_name.py b/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_subset_name.py index dfd353bddf..a63a4f16c7 100644 --- a/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_subset_name.py +++ b/server_addon/houdini/client/ayon_houdini/plugins/publish/validate_subset_name.py @@ -10,10 +10,9 @@ from ayon_core.pipeline.publish import ( ValidateContentsOrder, RepairAction, ) - +from ayon_core.pipeline.create import get_product_name from ayon_houdini.api import plugin from ayon_houdini.api.action import SelectInvalidAction -from ayon_core.pipeline.create import get_product_name class FixProductNameAction(RepairAction): @@ -26,7 +25,7 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin, """ - families = ["staticMesh"] + families = ["staticMesh", "hda"] label = "Validate Product Name" order = ValidateContentsOrder + 0.1 actions = [FixProductNameAction, SelectInvalidAction] @@ -67,7 +66,13 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin, instance.context.data["hostName"], instance.data["productType"], variant=instance.data["variant"], - dynamic_data={"asset": folder_entity["name"]} + dynamic_data={ + "asset": folder_entity["name"], + "folder": { + "label": folder_entity["label"], + "name": folder_entity["name"] + } + } ) if instance.data.get("productName") != product_name: @@ -97,7 +102,13 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin, instance.context.data["hostName"], instance.data["productType"], variant=instance.data["variant"], - dynamic_data={"asset": folder_entity["name"]} + dynamic_data={ + "asset": folder_entity["name"], + "folder": { + "label": folder_entity["label"], + "name": folder_entity["name"] + } + } ) instance.data["productName"] = product_name diff --git a/server_addon/houdini/client/ayon_houdini/version.py b/server_addon/houdini/client/ayon_houdini/version.py index 10d1478249..3dbbb4c23e 100644 --- a/server_addon/houdini/client/ayon_houdini/version.py +++ b/server_addon/houdini/client/ayon_houdini/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'houdini' version.""" -__version__ = "0.3.2" +__version__ = "0.3.7" diff --git a/server_addon/houdini/package.py b/server_addon/houdini/package.py index 1f7879483e..c01cc6044d 100644 --- a/server_addon/houdini/package.py +++ b/server_addon/houdini/package.py @@ -1,6 +1,6 @@ name = "houdini" title = "Houdini" -version = "0.3.2" +version = "0.3.7" client_dir = "ayon_houdini" diff --git a/server_addon/houdini/server/settings/create.py b/server_addon/houdini/server/settings/create.py index cd1e110c23..02fb052a36 100644 --- a/server_addon/houdini/server/settings/create.py +++ b/server_addon/houdini/server/settings/create.py @@ -51,9 +51,6 @@ class CreatePluginsModel(BaseSettingsModel): CreateKarmaROP: CreatorModel = SettingsField( default_factory=CreatorModel, title="Create Karma ROP") - CreateMantraIFD: CreatorModel = SettingsField( - default_factory=CreatorModel, - title="Create Mantra IFD") CreateMantraROP: CreatorModel = SettingsField( default_factory=CreatorModel, title="Create Mantra ROP") @@ -119,10 +116,6 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "enabled": True, "default_variants": ["Main"] }, - "CreateMantraIFD": { - "enabled": True, - "default_variants": ["Main"] - }, "CreateMantraROP": { "enabled": True, "default_variants": ["Main"] diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py index 336de8e046..4f324f0726 100644 --- a/server_addon/houdini/server/settings/publish.py +++ b/server_addon/houdini/server/settings/publish.py @@ -31,6 +31,7 @@ class AOVFilterSubmodel(BaseSettingsModel): title="AOV regex" ) + class CollectLocalRenderInstancesModel(BaseSettingsModel): use_deadline_aov_filter: bool = SettingsField( @@ -66,36 +67,36 @@ class BasicValidateModel(BaseSettingsModel): class PublishPluginsModel(BaseSettingsModel): CollectAssetHandles: CollectAssetHandlesModel = SettingsField( default_factory=CollectAssetHandlesModel, - title="Collect Asset Handles.", + title="Collect Asset Handles", section="Collectors" ) CollectChunkSize: CollectChunkSizeModel = SettingsField( default_factory=CollectChunkSizeModel, - title="Collect Chunk Size." + title="Collect Chunk Size" ) CollectLocalRenderInstances: CollectLocalRenderInstancesModel = SettingsField( default_factory=CollectLocalRenderInstancesModel, - title="Collect Local Render Instances." + title="Collect Local Render Instances" ) ValidateInstanceInContextHoudini: BasicValidateModel = SettingsField( default_factory=BasicValidateModel, - title="Validate Instance is in same Context.", + title="Validate Instance is in same Context", section="Validators") ValidateMeshIsStatic: BasicValidateModel = SettingsField( default_factory=BasicValidateModel, - title="Validate Mesh is Static.") + title="Validate Mesh is Static") ValidateReviewColorspace: BasicValidateModel = SettingsField( default_factory=BasicValidateModel, - title="Validate Review Colorspace.") + title="Validate Review Colorspace") ValidateSubsetName: BasicValidateModel = SettingsField( default_factory=BasicValidateModel, - title="Validate Subset Name.") + title="Validate Subset Name") ValidateUnrealStaticMeshName: BasicValidateModel = SettingsField( default_factory=BasicValidateModel, - title="Validate Unreal Static Mesh Name.") + title="Validate Unreal Static Mesh Name") ValidateWorkfilePaths: ValidateWorkfilePathsModel = SettingsField( default_factory=ValidateWorkfilePathsModel, - title="Validate workfile paths settings.") + title="Validate workfile paths settings") DEFAULT_HOUDINI_PUBLISH_SETTINGS = { @@ -109,7 +110,7 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { }, "CollectLocalRenderInstances": { "use_deadline_aov_filter": False, - "aov_filter" : { + "aov_filter": { "host_name": "houdini", "value": [ ".*([Bb]eauty).*" diff --git a/server_addon/maya/client/ayon_maya/api/__init__.py b/server_addon/maya/client/ayon_maya/api/__init__.py index 0948282f57..8783fbeeb7 100644 --- a/server_addon/maya/client/ayon_maya/api/__init__.py +++ b/server_addon/maya/client/ayon_maya/api/__init__.py @@ -12,7 +12,6 @@ from .pipeline import ( MayaHost, ) from .plugin import ( - Creator, Loader ) @@ -45,7 +44,6 @@ __all__ = [ "containerise", "MayaHost", - "Creator", "Loader", # Workfiles API diff --git a/server_addon/maya/client/ayon_maya/api/lib.py b/server_addon/maya/client/ayon_maya/api/lib.py index 2b41ffc06c..0242dafc0b 100644 --- a/server_addon/maya/client/ayon_maya/api/lib.py +++ b/server_addon/maya/client/ayon_maya/api/lib.py @@ -1721,7 +1721,7 @@ def is_valid_reference_node(reference_node): Reference node 'reference_node' is not associated with a reference file. Note that this does *not* check whether the reference node points to an - existing file. Instead it only returns whether maya considers it valid + existing file. Instead, it only returns whether maya considers it valid and thus is not an unassociated reference node Arguments: @@ -1731,9 +1731,18 @@ def is_valid_reference_node(reference_node): bool: Whether reference node is a valid reference """ + # maya 2022 is missing `isValidReference` so the check needs to be + # done in different way. + if int(cmds.about(version=True)) < 2023: + try: + cmds.referenceQuery(reference_node, filename=True) + return True + except RuntimeError: + return False sel = OpenMaya.MSelectionList() sel.add(reference_node) depend_node = sel.getDependNode(0) + return OpenMaya.MFnReference(depend_node).isValidReference() diff --git a/server_addon/maya/client/ayon_maya/api/plugin.py b/server_addon/maya/client/ayon_maya/api/plugin.py index b8d9748ef1..d2678e2100 100644 --- a/server_addon/maya/client/ayon_maya/api/plugin.py +++ b/server_addon/maya/client/ayon_maya/api/plugin.py @@ -15,10 +15,9 @@ from ayon_core.pipeline import ( Anatomy, AutoCreator, CreatedInstance, - Creator as NewCreator, + Creator, CreatorError, HiddenCreator, - LegacyCreator, LoaderPlugin, get_current_project_name, get_representation_path, @@ -70,22 +69,6 @@ def get_reference_node_parents(*args, **kwargs): return lib.get_reference_node_parents(*args, **kwargs) -class Creator(LegacyCreator): - defaults = ['Main'] - - def process(self): - nodes = list() - - with lib.undo_chunk(): - if (self.options or {}).get("useSelection"): - nodes = cmds.ls(selection=True) - - instance = cmds.sets(nodes, name=self.name) - lib.imprint(instance, self.data) - - return instance - - @six.add_metaclass(ABCMeta) class MayaCreatorBase(object): @@ -274,7 +257,7 @@ class MayaCreatorBase(object): @six.add_metaclass(ABCMeta) -class MayaCreator(NewCreator, MayaCreatorBase): +class MayaCreator(Creator, MayaCreatorBase): settings_category = "maya" @@ -381,7 +364,7 @@ def ensure_namespace(namespace): return cmds.namespace(add=namespace) -class RenderlayerCreator(NewCreator, MayaCreatorBase): +class RenderlayerCreator(Creator, MayaCreatorBase): """Creator which creates an instance per renderlayer in the workfile. Create and manages renderlayer product per renderLayer in workfile. diff --git a/server_addon/maya/client/ayon_maya/plugins/create/create_setdress.py b/server_addon/maya/client/ayon_maya/plugins/create/create_setdress.py index 12532e0724..6e1c4e1c4f 100644 --- a/server_addon/maya/client/ayon_maya/plugins/create/create_setdress.py +++ b/server_addon/maya/client/ayon_maya/plugins/create/create_setdress.py @@ -9,11 +9,16 @@ class CreateSetDress(plugin.MayaCreator): label = "Set Dress" product_type = "setdress" icon = "cubes" + exactSetMembersOnly = True + shader = True default_variants = ["Main", "Anim"] def get_instance_attr_defs(self): return [ BoolDef("exactSetMembersOnly", label="Exact Set Members Only", - default=True) + default=self.exactSetMembersOnly), + BoolDef("shader", + label="Include shader", + default=self.shader) ] diff --git a/server_addon/maya/client/ayon_maya/plugins/publish/collect_fbx_model.py b/server_addon/maya/client/ayon_maya/plugins/publish/collect_fbx_model.py new file mode 100644 index 0000000000..f3902a2868 --- /dev/null +++ b/server_addon/maya/client/ayon_maya/plugins/publish/collect_fbx_model.py @@ -0,0 +1,29 @@ +import pyblish.api +from ayon_core.pipeline import OptionalPyblishPluginMixin +from ayon_maya.api import plugin + + + +class CollectFbxModel(plugin.MayaInstancePlugin, + OptionalPyblishPluginMixin): + """Collect Camera for FBX export.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Fbx Model" + families = ["model"] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + + if not instance.data.get("families"): + instance.data["families"] = [] + + if "fbx" not in instance.data["families"]: + instance.data["families"].append("fbx") + + for key in { + "bakeComplexAnimation", "bakeResampleAnimation", + "skins", "constraints", "lights"}: + instance.data[key] = False diff --git a/server_addon/maya/client/ayon_maya/plugins/publish/extract_maya_scene_raw.py b/server_addon/maya/client/ayon_maya/plugins/publish/extract_maya_scene_raw.py index 6e66353c7a..047b7f6e6c 100644 --- a/server_addon/maya/client/ayon_maya/plugins/publish/extract_maya_scene_raw.py +++ b/server_addon/maya/client/ayon_maya/plugins/publish/extract_maya_scene_raw.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- """Extract data as Maya scene (raw).""" import os - +import contextlib from ayon_core.lib import BoolDef from ayon_core.pipeline import AVALON_CONTAINER_ID, AYON_CONTAINER_ID from ayon_core.pipeline.publish import AYONPyblishPluginMixin -from ayon_maya.api.lib import maintained_selection +from ayon_maya.api.lib import maintained_selection, shader from ayon_maya.api import plugin from maya import cmds @@ -88,17 +88,21 @@ class ExtractMayaSceneRaw(plugin.MayaExtractorPlugin, AYONPyblishPluginMixin): ) with maintained_selection(): cmds.select(selection, noExpand=True) - cmds.file(path, - force=True, - typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 - exportSelected=True, - preserveReferences=attribute_values[ - "preserve_references" - ], - constructionHistory=True, - shader=True, - constraints=True, - expressions=True) + with contextlib.ExitStack() as stack: + if not instance.data.get("shader", True): + # Fix bug where export without shader may import the geometry 'green' + # due to the lack of any shader on import. + stack.enter_context(shader(selection, shadingEngine="initialShadingGroup")) + + cmds.file(path, + force=True, + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", + exportSelected=True, + preserveReferences=attribute_values["preserve_references"], + constructionHistory=True, + shader=instance.data.get("shader", True), + constraints=True, + expressions=True) if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/server_addon/maya/client/ayon_maya/version.py b/server_addon/maya/client/ayon_maya/version.py index 37f9026945..80af287e97 100644 --- a/server_addon/maya/client/ayon_maya/version.py +++ b/server_addon/maya/client/ayon_maya/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'maya' version.""" -__version__ = "0.2.4" +__version__ = "0.2.8" diff --git a/server_addon/maya/package.py b/server_addon/maya/package.py index 17614ed9c1..b2d0622493 100644 --- a/server_addon/maya/package.py +++ b/server_addon/maya/package.py @@ -1,6 +1,6 @@ name = "maya" title = "Maya" -version = "0.2.4" +version = "0.2.8" client_dir = "ayon_maya" ayon_required_addons = { diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 5f3b850a1f..ede33b6eec 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -124,6 +124,14 @@ class CreateVrayProxyModel(BaseSettingsModel): default_factory=list, title="Default Products") +class CreateSetDressModel(BaseSettingsModel): + enabled: bool = SettingsField(True) + exactSetMembersOnly: bool = SettingsField(title="Exact Set Members Only") + shader: bool = SettingsField(title="Include shader") + default_variants: list[str] = SettingsField( + default_factory=list, title="Default Products") + + class CreateMultishotLayout(BasicCreatorModel): shotParent: str = SettingsField(title="Shot Parent Folder") groupLoadedAssets: bool = SettingsField(title="Group Loaded Assets") @@ -217,8 +225,8 @@ class CreatorsModel(BaseSettingsModel): default_factory=BasicCreatorModel, title="Create Rig" ) - CreateSetDress: BasicCreatorModel = SettingsField( - default_factory=BasicCreatorModel, + CreateSetDress: CreateSetDressModel = SettingsField( + default_factory=CreateSetDressModel, title="Create Set Dress" ) CreateVrayProxy: CreateVrayProxyModel = SettingsField( @@ -396,6 +404,8 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateSetDress": { "enabled": True, + "exactSetMembersOnly": True, + "shader": True, "default_variants": [ "Main", "Anim" diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index 9c552e17fa..6a127cc998 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -625,6 +625,10 @@ class PublishersModel(BaseSettingsModel): default_factory=CollectFbxCameraModel, title="Collect Camera for FBX export", ) + CollectFbxModel: BasicValidateModel = SettingsField( + default_factory=BasicValidateModel, + title="Collect Model for FBX export", + ) CollectGLTF: CollectGLTFModel = SettingsField( default_factory=CollectGLTFModel, title="Collect Assets for GLB/GLTF export" @@ -1047,6 +1051,11 @@ DEFAULT_PUBLISH_SETTINGS = { "CollectFbxCamera": { "enabled": False }, + "CollectFbxModel": { + "enabled": False, + "optional": True, + "active": True + }, "CollectGLTF": { "enabled": False }, diff --git a/server_addon/traypublisher/client/ayon_traypublisher/plugins/create/create_csv_ingest.py b/server_addon/traypublisher/client/ayon_traypublisher/plugins/create/create_csv_ingest.py index 5a5deeada8..fd4dedd48e 100644 --- a/server_addon/traypublisher/client/ayon_traypublisher/plugins/create/create_csv_ingest.py +++ b/server_addon/traypublisher/client/ayon_traypublisher/plugins/create/create_csv_ingest.py @@ -1,11 +1,14 @@ import os import re import csv -import clique +import collections from io import StringIO from copy import deepcopy, copy +from typing import Optional, List, Set, Dict, Union, Any + +import clique +import ayon_api -from ayon_api import get_folder_by_path, get_task_by_name from ayon_core.pipeline.create import get_product_name from ayon_core.pipeline import CreatedInstance from ayon_core.lib import FileDef, BoolDef @@ -16,6 +19,213 @@ from ayon_core.pipeline.create import CreatorError from ayon_traypublisher.api.plugin import TrayPublishCreator +def _get_row_value_with_validation( + columns_config: Dict[str, Any], + column_name: str, + row_data: Dict[str, Any], +): + """Get row value with validation""" + + # get column data from column config + column_data = None + for column in columns_config["columns"]: + if column["name"] == column_name: + column_data = column + break + + if not column_data: + raise CreatorError( + f"Column '{column_name}' not found in column config." + ) + + # get column value from row + column_value = row_data.get(column_name) + column_required = column_data["required_column"] + + # check if column value is not empty string and column is required + if column_value == "" and column_required: + raise CreatorError( + f"Value in column '{column_name}' is required." + ) + + # get column type + column_type = column_data["type"] + # get column validation regex + column_validation = column_data["validation_pattern"] + # get column default value + column_default = column_data["default"] + + if column_type in ["number", "decimal"] and column_default == 0: + column_default = None + + # check if column value is not empty string + if column_value == "": + # set default value if column value is empty string + column_value = column_default + + # set column value to correct type following column type + if column_type == "number" and column_value is not None: + column_value = int(column_value) + elif column_type == "decimal" and column_value is not None: + column_value = float(column_value) + elif column_type == "bool": + column_value = column_value in ["true", "True"] + + # check if column value matches validation regex + if ( + column_value is not None and + not re.match(str(column_validation), str(column_value)) + ): + raise CreatorError( + f"Column '{column_name}' value '{column_value}'" + f" does not match validation regex '{column_validation}'" + f"\nRow data: {row_data}" + f"\nColumn data: {column_data}" + ) + + return column_value + + +class RepreItem: + def __init__( + self, + name, + filepath, + frame_start, + frame_end, + handle_start, + handle_end, + fps, + thumbnail_path, + colorspace, + comment, + slate_exists, + tags, + ): + self.name = name + self.filepath = filepath + self.frame_start = frame_start + self.frame_end = frame_end + self.handle_start = handle_start + self.handle_end = handle_end + self.fps = fps + self.thumbnail_path = thumbnail_path + self.colorspace = colorspace + self.comment = comment + self.slate_exists = slate_exists + self.tags = tags + + @classmethod + def from_csv_row(cls, columns_config, repre_config, row): + kwargs = { + dst_key: _get_row_value_with_validation( + columns_config, column_name, row + ) + for dst_key, column_name in ( + # Representation information + ("filepath", "File Path"), + ("frame_start", "Frame Start"), + ("frame_end", "Frame End"), + ("handle_start", "Handle Start"), + ("handle_end", "Handle End"), + ("fps", "FPS"), + + # Optional representation information + ("thumbnail_path", "Version Thumbnail"), + ("colorspace", "Representation Colorspace"), + ("comment", "Version Comment"), + ("name", "Representation"), + ("slate_exists", "Slate Exists"), + ("repre_tags", "Representation Tags"), + ) + } + + # Should the 'int' and 'float' conversion happen? + # - looks like '_get_row_value_with_validation' is already handling it + for key in {"frame_start", "frame_end", "handle_start", "handle_end"}: + kwargs[key] = int(kwargs[key]) + + kwargs["fps"] = float(kwargs["fps"]) + + # Convert tags value to list + tags_list = copy(repre_config["default_tags"]) + repre_tags: Optional[str] = kwargs.pop("repre_tags") + if repre_tags: + tags_list = [] + tags_delimiter = repre_config["tags_delimiter"] + # strip spaces from repre_tags + if tags_delimiter in repre_tags: + tags = repre_tags.split(tags_delimiter) + for _tag in tags: + tags_list.append(_tag.strip().lower()) + else: + tags_list.append(repre_tags) + kwargs["tags"] = tags_list + return cls(**kwargs) + + +class ProductItem: + def __init__( + self, + folder_path: str, + task_name: str, + version: int, + variant: str, + product_type: str, + task_type: Optional[str] = None, + ): + self.folder_path = folder_path + self.task_name = task_name + self.task_type = task_type + self.version = version + self.variant = variant + self.product_type = product_type + self.repre_items: List[RepreItem] = [] + self._unique_name = None + self._pre_product_name = None + + @property + def unique_name(self) -> str: + if self._unique_name is None: + self._unique_name = "/".join([ + self.folder_path, + self.task_name, + f"{self.variant}{self.product_type}{self.version}".replace( + " ", "" + ).lower() + ]) + return self._unique_name + + @property + def instance_name(self): + if self._pre_product_name is None: + self._pre_product_name = ( + f"{self.task_name}{self.variant}" + f"{self.product_type}{self.version}" + ).replace(" ", "").lower() + return self._pre_product_name + + def add_repre_item(self, repre_item: RepreItem): + self.repre_items.append(repre_item) + + @classmethod + def from_csv_row(cls, columns_config, row): + kwargs = { + dst_key: _get_row_value_with_validation( + columns_config, column_name, row + ) + for dst_key, column_name in ( + # Context information + ("folder_path", "Folder Path"), + ("task_name", "Task Name"), + ("version", "Version"), + ("variant", "Variant"), + ("product_type", "Product Type"), + ) + } + return cls(**kwargs) + + class IngestCSV(TrayPublishCreator): """CSV ingest creator class""" @@ -40,658 +250,64 @@ configuration in project settings. columns_config = {} representations_config = {} - def create(self, subset_name, instance_data, pre_create_data): - """Create an product from each row found in the CSV. + def get_instance_attr_defs(self): + return [ + BoolDef( + "add_review_family", + default=True, + label="Review" + ) + ] + + def get_pre_create_attr_defs(self): + """Creating pre-create attributes at creator plugin. + + Returns: + list: list of attribute object instances + """ + # Use same attributes as for instance attributes + return [ + FileDef( + "csv_filepath_data", + folders=False, + extensions=[".csv"], + allow_sequences=False, + single_item=True, + label="CSV File", + ), + ] + + def create( + self, + product_name: str, + instance_data: Dict[str, Any], + pre_create_data: Dict[str, Any] + ): + """Create product from each row found in the CSV. Args: - subset_name (str): The subset name. + product_name (str): The subset name. instance_data (dict): The instance data. pre_create_data (dict): """ csv_filepath_data = pre_create_data.get("csv_filepath_data", {}) - folder = csv_filepath_data.get("directory", "") - if not os.path.exists(folder): + csv_dir = csv_filepath_data.get("directory", "") + if not os.path.exists(csv_dir): raise CreatorError( - f"Directory '{folder}' does not exist." + f"Directory '{csv_dir}' does not exist." ) filename = csv_filepath_data.get("filenames", []) - self._process_csv_file(subset_name, instance_data, folder, filename[0]) - - def _process_csv_file( - self, subset_name, instance_data, staging_dir, filename): - """Process CSV file. - - Args: - subset_name (str): The subset name. - instance_data (dict): The instance data. - staging_dir (str): The staging directory. - filename (str): The filename. - """ - - # create new instance from the csv file via self function - self._pass_data_to_csv_instance( - instance_data, - staging_dir, - filename + self._process_csv_file( + product_name, instance_data, csv_dir, filename[0] ) - csv_instance = CreatedInstance( - self.product_type, subset_name, instance_data, self - ) - self._store_new_instance(csv_instance) - - csv_instance["csvFileData"] = { - "filename": filename, - "staging_dir": staging_dir, - } - - # from special function get all data from csv file and convert them - # to new instances - csv_data_for_instances = self._get_data_from_csv( - staging_dir, filename) - - # create instances from csv data via self function - self._create_instances_from_csv_data( - csv_data_for_instances, staging_dir - ) - - def _create_instances_from_csv_data( - self, - csv_data_for_instances, - staging_dir - ): - """Create instances from csv data""" - - for folder_path, prepared_data in csv_data_for_instances.items(): - project_name = self.create_context.get_current_project_name() - products = prepared_data["products"] - - for instance_name, product_data in products.items(): - # get important instance variables - task_name = product_data["task_name"] - task_type = product_data["task_type"] - variant = product_data["variant"] - product_type = product_data["product_type"] - version = product_data["version"] - - # create subset/product name - product_name = get_product_name( - project_name, - task_name, - task_type, - self.host_name, - product_type, - variant - ) - - # make sure frame start/end is inherited from csv columns - # expected frame range data are handles excluded - for _, repre_data in product_data["representations"].items(): # noqa: E501 - frame_start = repre_data["frameStart"] - frame_end = repre_data["frameEnd"] - handle_start = repre_data["handleStart"] - handle_end = repre_data["handleEnd"] - fps = repre_data["fps"] - break - - # try to find any version comment in representation data - version_comment = next( - iter( - repre_data["comment"] - for repre_data in product_data["representations"].values() # noqa: E501 - if repre_data["comment"] - ), - None - ) - - # try to find any slate switch in representation data - slate_exists = any( - repre_data["slate"] - for _, repre_data in product_data["representations"].items() # noqa: E501 - ) - - # get representations from product data - representations = product_data["representations"] - label = f"{folder_path}_{product_name}_v{version:>03}" - - families = ["csv_ingest"] - if slate_exists: - # adding slate to families mainly for loaders to be able - # to filter out slates - families.append("slate") - - # make product data - product_data = { - "name": instance_name, - "folderPath": folder_path, - "families": families, - "label": label, - "task": task_name, - "variant": variant, - "source": "csv", - "frameStart": frame_start, - "frameEnd": frame_end, - "handleStart": handle_start, - "handleEnd": handle_end, - "fps": fps, - "version": version, - "comment": version_comment, - } - - # create new instance - new_instance = CreatedInstance( - product_type, product_name, product_data, self - ) - self._store_new_instance(new_instance) - - if not new_instance.get("prepared_data_for_repres"): - new_instance["prepared_data_for_repres"] = [] - - base_thumbnail_repre_data = { - "name": "thumbnail", - "ext": None, - "files": None, - "stagingDir": None, - "stagingDir_persistent": True, - "tags": ["thumbnail", "delete"], - } - # need to populate all thumbnails for all representations - # so we can check if unique thumbnail per representation - # is needed - thumbnails = [ - repre_data["thumbnailPath"] - for repre_data in representations.values() - if repre_data["thumbnailPath"] - ] - multiple_thumbnails = len(set(thumbnails)) > 1 - explicit_output_name = None - thumbnails_processed = False - for filepath, repre_data in representations.items(): - # check if any review derivate tag is present - reviewable = any( - tag for tag in repre_data.get("tags", []) - # tag can be `ftrackreview` or `review` - if "review" in tag - ) - # since we need to populate multiple thumbnails as - # representation with outputName for (Ftrack instance - # integrator) pairing with reviewable video representations - if ( - thumbnails - and multiple_thumbnails - and reviewable - ): - # multiple unique thumbnails per representation needs - # grouping by outputName - # mainly used in Ftrack instance integrator - explicit_output_name = repre_data["representationName"] - relative_thumbnail_path = repre_data["thumbnailPath"] - # representation might not have thumbnail path - # so ignore this one - if not relative_thumbnail_path: - continue - thumb_dir, thumb_file = \ - self._get_refactor_thumbnail_path( - staging_dir, relative_thumbnail_path) - filename, ext = os.path.splitext(thumb_file) - thumbnail_repr_data = deepcopy( - base_thumbnail_repre_data) - thumbnail_repr_data.update({ - "name": "thumbnail_{}".format(filename), - "ext": ext[1:], - "files": thumb_file, - "stagingDir": thumb_dir, - "outputName": explicit_output_name, - }) - new_instance["prepared_data_for_repres"].append({ - "type": "thumbnail", - "colorspace": None, - "representation": thumbnail_repr_data, - }) - # also add thumbnailPath for ayon to integrate - if not new_instance.get("thumbnailPath"): - new_instance["thumbnailPath"] = ( - os.path.join(thumb_dir, thumb_file) - ) - elif ( - thumbnails - and not multiple_thumbnails - and not thumbnails_processed - or not reviewable - ): - """ - For case where we have only one thumbnail - and not reviewable medias. This needs to be processed - only once per instance. - """ - if not thumbnails: - continue - # here we will use only one thumbnail for - # all representations - relative_thumbnail_path = repre_data["thumbnailPath"] - # popping last thumbnail from list since it is only one - # and we do not need to iterate again over it - if not relative_thumbnail_path: - relative_thumbnail_path = thumbnails.pop() - thumb_dir, thumb_file = \ - self._get_refactor_thumbnail_path( - staging_dir, relative_thumbnail_path) - _, ext = os.path.splitext(thumb_file) - thumbnail_repr_data = deepcopy( - base_thumbnail_repre_data) - thumbnail_repr_data.update({ - "ext": ext[1:], - "files": thumb_file, - "stagingDir": thumb_dir - }) - new_instance["prepared_data_for_repres"].append({ - "type": "thumbnail", - "colorspace": None, - "representation": thumbnail_repr_data, - }) - # also add thumbnailPath for ayon to integrate - if not new_instance.get("thumbnailPath"): - new_instance["thumbnailPath"] = ( - os.path.join(thumb_dir, thumb_file) - ) - - thumbnails_processed = True - - # get representation data - representation_data = self._get_representation_data( - filepath, repre_data, staging_dir, - explicit_output_name - ) - - new_instance["prepared_data_for_repres"].append({ - "type": "media", - "colorspace": repre_data["colorspace"], - "representation": representation_data, - }) - - def _get_refactor_thumbnail_path( - self, staging_dir, relative_thumbnail_path): - thumbnail_abs_path = os.path.join( - staging_dir, relative_thumbnail_path) - return os.path.split( - thumbnail_abs_path) - - def _get_representation_data( - self, filepath, repre_data, staging_dir, explicit_output_name=None - ): - """Get representation data - - Args: - filepath (str): Filepath to representation file. - repre_data (dict): Representation data from CSV file. - staging_dir (str): Staging directory. - explicit_output_name (Optional[str]): Explicit output name. - For grouping purposes with reviewable components. - Defaults to None. - """ - - # get extension of file - basename = os.path.basename(filepath) - extension = os.path.splitext(filepath)[-1].lower() - - # validate filepath is having correct extension based on output - repre_name = repre_data["representationName"] - repre_config_data = None - for repre in self.representations_config["representations"]: - if repre["name"] == repre_name: - repre_config_data = repre - break - - if not repre_config_data: - raise CreatorError( - f"Representation '{repre_name}' not found " - "in config representation data." - ) - - validate_extensions = repre_config_data["extensions"] - if extension not in validate_extensions: - raise CreatorError( - f"File extension '{extension}' not valid for " - f"output '{validate_extensions}'." - ) - - is_sequence = (extension in IMAGE_EXTENSIONS) - # convert ### string in file name to %03d - # this is for correct frame range validation - # example: file.###.exr -> file.%03d.exr - if "#" in basename: - padding = len(basename.split("#")) - 1 - basename = basename.replace("#" * padding, f"%0{padding}d") - is_sequence = True - - # make absolute path to file - absfilepath = os.path.normpath(os.path.join(staging_dir, filepath)) - dirname = os.path.dirname(absfilepath) - - # check if dirname exists - if not os.path.isdir(dirname): - raise CreatorError( - f"Directory '{dirname}' does not exist." - ) - - # collect all data from dirname - paths_for_collection = [] - for file in os.listdir(dirname): - filepath = os.path.join(dirname, file) - paths_for_collection.append(filepath) - - collections, _ = clique.assemble(paths_for_collection) - - if collections: - collections = collections[0] - else: - if is_sequence: - raise CreatorError( - f"No collections found in directory '{dirname}'." - ) - - frame_start = None - frame_end = None - if is_sequence: - files = [os.path.basename(file) for file in collections] - frame_start = list(collections.indexes)[0] - frame_end = list(collections.indexes)[-1] - else: - files = basename - - tags = deepcopy(repre_data["tags"]) - # if slate in repre_data is True then remove one frame from start - if repre_data["slate"]: - tags.append("has_slate") - - # get representation data - representation_data = { - "name": repre_name, - "ext": extension[1:], - "files": files, - "stagingDir": dirname, - "stagingDir_persistent": True, - "tags": tags, - } - if extension in VIDEO_EXTENSIONS: - representation_data.update({ - "fps": repre_data["fps"], - "outputName": repre_name, - }) - - if explicit_output_name: - representation_data["outputName"] = explicit_output_name - - if frame_start: - representation_data["frameStart"] = frame_start - if frame_end: - representation_data["frameEnd"] = frame_end - - return representation_data - - def _get_data_from_csv( - self, package_dir, filename - ): - """Generate instances from the csv file""" - # get current project name and code from context.data - project_name = self.create_context.get_current_project_name() - - csv_file_path = os.path.join( - package_dir, filename - ) - - # make sure csv file contains columns from following list - required_columns = [ - column["name"] for column in self.columns_config["columns"] - if column["required_column"] - ] - - # read csv file - with open(csv_file_path, "r") as csv_file: - csv_content = csv_file.read() - - # read csv file with DictReader - csv_reader = csv.DictReader( - StringIO(csv_content), - delimiter=self.columns_config["csv_delimiter"] - ) - - # fix fieldnames - # sometimes someone can keep extra space at the start or end of - # the column name - all_columns = [ - " ".join(column.rsplit()) for column in csv_reader.fieldnames] - - # return back fixed fieldnames - csv_reader.fieldnames = all_columns - - # check if csv file contains all required columns - if any(column not in all_columns for column in required_columns): - raise CreatorError( - f"Missing required columns: {required_columns}" - ) - - csv_data = {} - # get data from csv file - for row in csv_reader: - # Get required columns first - # TODO: will need to be folder path in CSV - # TODO: `context_asset_name` is now `folder_path` - folder_path = self._get_row_value_with_validation( - "Folder Path", row) - task_name = self._get_row_value_with_validation( - "Task Name", row) - version = self._get_row_value_with_validation( - "Version", row) - - # Get optional columns - variant = self._get_row_value_with_validation( - "Variant", row) - product_type = self._get_row_value_with_validation( - "Product Type", row) - - pre_product_name = ( - f"{task_name}{variant}{product_type}" - f"{version}".replace(" ", "").lower() - ) - - # get representation data - filename, representation_data = \ - self._get_representation_row_data(row) - - # TODO: batch query of all folder paths and task names - - # get folder entity from folder path - folder_entity = get_folder_by_path( - project_name, folder_path) - - # make sure asset exists - if not folder_entity: - raise CreatorError( - f"Asset '{folder_path}' not found." - ) - - # first get all tasks on the folder entity and then find - task_entity = get_task_by_name( - project_name, folder_entity["id"], task_name) - - # check if task name is valid task in asset doc - if not task_entity: - raise CreatorError( - f"Task '{task_name}' not found in asset doc." - ) - - # get all csv data into one dict and make sure there are no - # duplicates data are already validated and sorted under - # correct existing asset also check if asset exists and if - # task name is valid task in asset doc and representations - # are distributed under products following variants - if folder_path not in csv_data: - csv_data[folder_path] = { - "folder_entity": folder_entity, - "products": { - pre_product_name: { - "task_name": task_name, - "task_type": task_entity["taskType"], - "variant": variant, - "product_type": product_type, - "version": version, - "representations": { - filename: representation_data, - }, - } - } - } - else: - csv_products = csv_data[folder_path]["products"] - if pre_product_name not in csv_products: - csv_products[pre_product_name] = { - "task_name": task_name, - "task_type": task_entity["taskType"], - "variant": variant, - "product_type": product_type, - "version": version, - "representations": { - filename: representation_data, - }, - } - else: - csv_representations = \ - csv_products[pre_product_name]["representations"] - if filename in csv_representations: - raise CreatorError( - f"Duplicate filename '{filename}' in csv file." - ) - csv_representations[filename] = representation_data - - return csv_data - - def _get_representation_row_data(self, row_data): - """Get representation row data""" - # Get required columns first - file_path = self._get_row_value_with_validation( - "File Path", row_data) - frame_start = self._get_row_value_with_validation( - "Frame Start", row_data) - frame_end = self._get_row_value_with_validation( - "Frame End", row_data) - handle_start = self._get_row_value_with_validation( - "Handle Start", row_data) - handle_end = self._get_row_value_with_validation( - "Handle End", row_data) - fps = self._get_row_value_with_validation( - "FPS", row_data) - - # Get optional columns - thumbnail_path = self._get_row_value_with_validation( - "Version Thumbnail", row_data) - colorspace = self._get_row_value_with_validation( - "Representation Colorspace", row_data) - comment = self._get_row_value_with_validation( - "Version Comment", row_data) - repre = self._get_row_value_with_validation( - "Representation", row_data) - slate_exists = self._get_row_value_with_validation( - "Slate Exists", row_data) - repre_tags = self._get_row_value_with_validation( - "Representation Tags", row_data) - - # convert tags value to list - tags_list = copy(self.representations_config["default_tags"]) - if repre_tags: - tags_list = [] - tags_delimiter = self.representations_config["tags_delimiter"] - # strip spaces from repre_tags - if tags_delimiter in repre_tags: - tags = repre_tags.split(tags_delimiter) - for _tag in tags: - tags_list.append(("".join(_tag.strip())).lower()) - else: - tags_list.append(repre_tags) - - representation_data = { - "colorspace": colorspace, - "comment": comment, - "representationName": repre, - "slate": slate_exists, - "tags": tags_list, - "thumbnailPath": thumbnail_path, - "frameStart": int(frame_start), - "frameEnd": int(frame_end), - "handleStart": int(handle_start), - "handleEnd": int(handle_end), - "fps": float(fps), - } - return file_path, representation_data - - def _get_row_value_with_validation( - self, column_name, row_data, default_value=None - ): - """Get row value with validation""" - - # get column data from column config - column_data = None - for column in self.columns_config["columns"]: - if column["name"] == column_name: - column_data = column - break - - if not column_data: - raise CreatorError( - f"Column '{column_name}' not found in column config." - ) - - # get column value from row - column_value = row_data.get(column_name) - column_required = column_data["required_column"] - - # check if column value is not empty string and column is required - if column_value == "" and column_required: - raise CreatorError( - f"Value in column '{column_name}' is required." - ) - - # get column type - column_type = column_data["type"] - # get column validation regex - column_validation = column_data["validation_pattern"] - # get column default value - column_default = default_value or column_data["default"] - - if column_type in ["number", "decimal"] and column_default == 0: - column_default = None - - # check if column value is not empty string - if column_value == "": - # set default value if column value is empty string - column_value = column_default - - # set column value to correct type following column type - if column_type == "number" and column_value is not None: - column_value = int(column_value) - elif column_type == "decimal" and column_value is not None: - column_value = float(column_value) - elif column_type == "bool": - column_value = column_value in ["true", "True"] - - # check if column value matches validation regex - if ( - column_value is not None and - not re.match(str(column_validation), str(column_value)) - ): - raise CreatorError( - f"Column '{column_name}' value '{column_value}' " - f"does not match validation regex '{column_validation}' \n" - f"Row data: {row_data} \n" - f"Column data: {column_data}" - ) - - return column_value - def _pass_data_to_csv_instance( - self, instance_data, staging_dir, filename + self, + instance_data: Dict[str, Any], + staging_dir: str, + filename: str ): """Pass CSV representation file to instance data""" @@ -710,30 +326,487 @@ configuration in project settings. "stagingDir_persistent": True, }) - def get_instance_attr_defs(self): - return [ - BoolDef( - "add_review_family", - default=True, - label="Review" - ) + def _process_csv_file( + self, + product_name: str, + instance_data: Dict[str, Any], + csv_dir: str, + filename: str + ): + """Process CSV file. + + Args: + product_name (str): The subset name. + instance_data (dict): The instance data. + csv_dir (str): The csv directory. + filename (str): The filename. + + """ + # create new instance from the csv file via self function + self._pass_data_to_csv_instance( + instance_data, + csv_dir, + filename + ) + + csv_instance = CreatedInstance( + self.product_type, product_name, instance_data, self + ) + + csv_instance["csvFileData"] = { + "filename": filename, + "staging_dir": csv_dir, + } + + # create instances from csv data via self function + instances = self._create_instances_from_csv_data(csv_dir, filename) + for instance in instances: + self._store_new_instance(instance) + self._store_new_instance(csv_instance) + + def _resolve_repre_path( + self, csv_dir: str, filepath: Union[str, None] + ) -> Union[str, None]: + if not filepath: + return filepath + + # Validate only existence of file directory as filename + # may contain frame specific char (e.g. '%04d' or '####'). + filedir, filename = os.path.split(filepath) + if not filedir or filedir == ".": + # If filedir is empty or "." then use same directory as + # csv path + filepath = os.path.join(csv_dir, filepath) + + elif not os.path.exists(filedir): + # If filepath does not exist, first try to find it in the + # same directory as the csv file is, but keep original + # value otherwise. + new_filedir = os.path.join(csv_dir, filedir) + if os.path.exists(new_filedir): + filepath = os.path.join(new_filedir, filename) + + return filepath + + def _get_data_from_csv( + self, csv_dir: str, filename: str + ) -> Dict[str, ProductItem]: + """Generate instances from the csv file""" + # get current project name and code from context.data + project_name = self.create_context.get_current_project_name() + csv_path = os.path.join(csv_dir, filename) + + # make sure csv file contains columns from following list + required_columns = [ + column["name"] + for column in self.columns_config["columns"] + if column["required_column"] ] - def get_pre_create_attr_defs(self): - """Creating pre-create attributes at creator plugin. + # read csv file + with open(csv_path, "r") as csv_file: + csv_content = csv_file.read() + + # read csv file with DictReader + csv_reader = csv.DictReader( + StringIO(csv_content), + delimiter=self.columns_config["csv_delimiter"] + ) + + # fix fieldnames + # sometimes someone can keep extra space at the start or end of + # the column name + all_columns = [ + " ".join(column.rsplit()) + for column in csv_reader.fieldnames + ] + + # return back fixed fieldnames + csv_reader.fieldnames = all_columns + + # check if csv file contains all required columns + if any(column not in all_columns for column in required_columns): + raise CreatorError( + f"Missing required columns: {required_columns}" + ) + + product_items_by_name: Dict[str, ProductItem] = {} + for row in csv_reader: + _product_item: ProductItem = ProductItem.from_csv_row( + self.columns_config, row + ) + unique_name = _product_item.unique_name + if unique_name not in product_items_by_name: + product_items_by_name[unique_name] = _product_item + product_item: ProductItem = product_items_by_name[unique_name] + product_item.add_repre_item( + RepreItem.from_csv_row( + self.columns_config, + self.representations_config, + row + ) + ) + + folder_paths: Set[str] = { + product_item.folder_path + for product_item in product_items_by_name.values() + } + folder_ids_by_path: Dict[str, str] = { + folder_entity["path"]: folder_entity["id"] + for folder_entity in ayon_api.get_folders( + project_name, folder_paths=folder_paths, fields={"id", "path"} + ) + } + missing_paths: Set[str] = folder_paths - set(folder_ids_by_path.keys()) + if missing_paths: + ending = "" if len(missing_paths) == 1 else "s" + joined_paths = "\n".join(sorted(missing_paths)) + raise CreatorError( + f"Folder{ending} not found.\n{joined_paths}" + ) + + task_names: Set[str] = { + product_item.task_name + for product_item in product_items_by_name.values() + } + task_entities_by_folder_id = collections.defaultdict(list) + for task_entity in ayon_api.get_tasks( + project_name, + folder_ids=set(folder_ids_by_path.values()), + task_names=task_names, + fields={"folderId", "name", "taskType"} + ): + folder_id = task_entity["folderId"] + task_entities_by_folder_id[folder_id].append(task_entity) + + missing_tasks: Set[str] = set() + for product_item in product_items_by_name.values(): + folder_path = product_item.folder_path + task_name = product_item.task_name + folder_id = folder_ids_by_path[folder_path] + task_entities = task_entities_by_folder_id[folder_id] + task_entity = next( + ( + task_entity + for task_entity in task_entities + if task_entity["name"] == task_name + ), + None + ) + if task_entity is None: + missing_tasks.add("/".join([folder_path, task_name])) + else: + product_item.task_type = task_entity["taskType"] + + if missing_tasks: + ending = "" if len(missing_tasks) == 1 else "s" + joined_paths = "\n".join(sorted(missing_tasks)) + raise CreatorError( + f"Task{ending} not found.\n{joined_paths}" + ) + + for product_item in product_items_by_name.values(): + repre_paths: Set[str] = set() + duplicated_paths: Set[str] = set() + for repre_item in product_item.repre_items: + # Resolve relative paths in csv file + repre_item.filepath = self._resolve_repre_path( + csv_dir, repre_item.filepath + ) + repre_item.thumbnail_path = self._resolve_repre_path( + csv_dir, repre_item.thumbnail_path + ) + + filepath = repre_item.filepath + if filepath in repre_paths: + duplicated_paths.add(filepath) + repre_paths.add(filepath) + + if duplicated_paths: + ending = "" if len(duplicated_paths) == 1 else "s" + joined_names = "\n".join(sorted(duplicated_paths)) + raise CreatorError( + f"Duplicate filename{ending} in csv file.\n{joined_names}" + ) + + return product_items_by_name + + def _add_thumbnail_repre( + self, + thumbnails: Set[str], + instance: CreatedInstance, + repre_item: RepreItem, + multiple_thumbnails: bool, + ) -> Union[str, None]: + """Add thumbnail to instance. + + Add thumbnail as representation and set 'thumbnailPath' if is not set + yet. + + Args: + thumbnails (Set[str]): Set of all thumbnail paths that should + create representation. + instance (CreatedInstance): Instance from create plugin. + repre_item (RepreItem): Representation item. + multiple_thumbnails (bool): There are multiple representations + with thumbnail. Returns: - list: list of attribute object instances + Uniom[str, None]: Explicit output name for thumbnail + representation. + """ - # Use same attributes as for instance attributes - attr_defs = [ - FileDef( - "csv_filepath_data", - folders=False, - extensions=[".csv"], - allow_sequences=False, - single_item=True, - label="CSV File", - ), - ] - return attr_defs + if not thumbnails: + return None + + thumbnail_path = repre_item.thumbnail_path + if not thumbnail_path or thumbnail_path not in thumbnails: + return None + + thumbnails.remove(thumbnail_path) + + thumb_dir, thumb_file = os.path.split(thumbnail_path) + thumb_basename, thumb_ext = os.path.splitext(thumb_file) + + # NOTE 'explicit_output_name' and custom repre name was set only + # when 'multiple_thumbnails' is True and 'review' tag is present. + # That was changed to set 'explicit_output_name' is set when + # 'multiple_thumbnails' is True. + # is_reviewable = "review" in repre_item.tags + + repre_name = "thumbnail" + explicit_output_name = None + if multiple_thumbnails: + repre_name = f"thumbnail_{thumb_basename}" + explicit_output_name = repre_item.name + + thumbnail_repre_data = { + "name": repre_name, + "ext": thumb_ext.lstrip("."), + "files": thumb_file, + "stagingDir": thumb_dir, + "stagingDir_persistent": True, + "tags": ["thumbnail", "delete"], + } + if explicit_output_name: + thumbnail_repre_data["outputName"] = explicit_output_name + + instance["prepared_data_for_repres"].append({ + "type": "thumbnail", + "colorspace": None, + "representation": thumbnail_repre_data, + }) + # also add thumbnailPath for ayon to integrate + if not instance.get("thumbnailPath"): + instance["thumbnailPath"] = thumbnail_path + + return explicit_output_name + + def _add_representation( + self, + instance: CreatedInstance, + repre_item: RepreItem, + explicit_output_name: Optional[str] = None + ): + """Get representation data + + Args: + repre_item (RepreItem): Representation item based on csv row. + explicit_output_name (Optional[str]): Explicit output name. + For grouping purposes with reviewable components. + + """ + # get extension of file + basename: str = os.path.basename(repre_item.filepath) + extension: str = os.path.splitext(basename)[-1].lower() + + # validate filepath is having correct extension based on output + repre_config_data: Union[Dict[str, Any], None] = None + for repre in self.representations_config["representations"]: + if repre["name"] == repre_item.name: + repre_config_data = repre + break + + if not repre_config_data: + raise CreatorError( + f"Representation '{repre_item.name}' not found " + "in config representation data." + ) + + validate_extensions: List[str] = repre_config_data["extensions"] + if extension not in validate_extensions: + raise CreatorError( + f"File extension '{extension}' not valid for " + f"output '{validate_extensions}'." + ) + + is_sequence: bool = extension in IMAGE_EXTENSIONS + # convert ### string in file name to %03d + # this is for correct frame range validation + # example: file.###.exr -> file.%03d.exr + if "#" in basename: + padding = len(basename.split("#")) - 1 + basename = basename.replace("#" * padding, f"%0{padding}d") + is_sequence = True + + # make absolute path to file + dirname: str = os.path.dirname(repre_item.filepath) + + # check if dirname exists + if not os.path.isdir(dirname): + raise CreatorError( + f"Directory '{dirname}' does not exist." + ) + + frame_start: Union[int, None] = None + frame_end: Union[int, None] = None + files: Union[str, List[str]] = basename + if is_sequence: + # collect all data from dirname + cols, _ = clique.assemble(list(os.listdir(dirname))) + if not cols: + raise CreatorError( + f"No collections found in directory '{dirname}'." + ) + + col = cols[0] + files = list(col) + frame_start = min(col.indexes) + frame_end = max(col.indexes) + + tags: List[str] = deepcopy(repre_item.tags) + # if slate in repre_data is True then remove one frame from start + if repre_item.slate_exists: + tags.append("has_slate") + + # get representation data + representation_data: Dict[str, Any] = { + "name": repre_item.name, + "ext": extension[1:], + "files": files, + "stagingDir": dirname, + "stagingDir_persistent": True, + "tags": tags, + } + if extension in VIDEO_EXTENSIONS: + representation_data.update({ + "fps": repre_item.fps, + "outputName": repre_item.name, + }) + + if explicit_output_name: + representation_data["outputName"] = explicit_output_name + + if frame_start: + representation_data["frameStart"] = frame_start + if frame_end: + representation_data["frameEnd"] = frame_end + + instance["prepared_data_for_repres"].append({ + "type": "media", + "colorspace": repre_item.colorspace, + "representation": representation_data, + }) + + def _prepare_representations( + self, product_item: ProductItem, instance: CreatedInstance + ): + # Collect thumbnail paths from all representation items + # to check if multiple thumbnails are present. + # Once representation is created for certain thumbnail it is removed + # from the set. + thumbnails: Set[str] = { + repre_item.thumbnail_path + for repre_item in product_item.repre_items + if repre_item.thumbnail_path + } + multiple_thumbnails: bool = len(thumbnails) > 1 + + for repre_item in product_item.repre_items: + explicit_output_name = self._add_thumbnail_repre( + thumbnails, + instance, + repre_item, + multiple_thumbnails, + ) + + # get representation data + self._add_representation( + instance, + repre_item, + explicit_output_name + ) + + def _create_instances_from_csv_data(self, csv_dir: str, filename: str): + """Create instances from csv data""" + # from special function get all data from csv file and convert them + # to new instances + product_items_by_name: Dict[str, ProductItem] = ( + self._get_data_from_csv(csv_dir, filename) + ) + + instances = [] + project_name: str = self.create_context.get_current_project_name() + for product_item in product_items_by_name.values(): + folder_path: str = product_item.folder_path + version: int = product_item.version + product_name: str = get_product_name( + project_name, + product_item.task_name, + product_item.task_type, + self.host_name, + product_item.product_type, + product_item.variant + ) + label: str = f"{folder_path}_{product_name}_v{version:>03}" + + repre_items: List[RepreItem] = product_item.repre_items + first_repre_item: RepreItem = repre_items[0] + version_comment: Union[str, None] = next( + ( + repre_item.comment + for repre_item in repre_items + if repre_item.comment + ), + None + ) + slate_exists: bool = any( + repre_item.slate_exists + for repre_item in repre_items + ) + + families: List[str] = ["csv_ingest"] + if slate_exists: + # adding slate to families mainly for loaders to be able + # to filter out slates + families.append("slate") + + instance_data = { + "name": product_item.instance_name, + "folderPath": folder_path, + "families": families, + "label": label, + "task": product_item.task_name, + "variant": product_item.variant, + "source": "csv", + "frameStart": first_repre_item.frame_start, + "frameEnd": first_repre_item.frame_end, + "handleStart": first_repre_item.handle_start, + "handleEnd": first_repre_item.handle_end, + "fps": first_repre_item.fps, + "version": version, + "comment": version_comment, + "prepared_data_for_repres": [] + } + + # create new instance + new_instance: CreatedInstance = CreatedInstance( + product_item.product_type, + product_name, + instance_data, + self + ) + self._prepare_representations(product_item, new_instance) + instances.append(new_instance) + + return instances diff --git a/server_addon/traypublisher/client/ayon_traypublisher/version.py b/server_addon/traypublisher/client/ayon_traypublisher/version.py index 5a2d84194c..16e0ec1829 100644 --- a/server_addon/traypublisher/client/ayon_traypublisher/version.py +++ b/server_addon/traypublisher/client/ayon_traypublisher/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'traypublisher' version.""" -__version__ = "0.2.4" +__version__ = "0.2.5" diff --git a/server_addon/traypublisher/package.py b/server_addon/traypublisher/package.py index 5475033d22..6a9ecdb0be 100644 --- a/server_addon/traypublisher/package.py +++ b/server_addon/traypublisher/package.py @@ -1,6 +1,6 @@ name = "traypublisher" title = "TrayPublisher" -version = "0.2.4" +version = "0.2.5" client_dir = "ayon_traypublisher" diff --git a/server_addon/traypublisher/server/settings/creator_plugins.py b/server_addon/traypublisher/server/settings/creator_plugins.py index 1ff14002aa..0e7963a33e 100644 --- a/server_addon/traypublisher/server/settings/creator_plugins.py +++ b/server_addon/traypublisher/server/settings/creator_plugins.py @@ -182,7 +182,7 @@ DEFAULT_CREATORS = { "type": "text", "default": "", "required_column": True, - "validation_pattern": "^([a-z0-9#._\\/]*)$" + "validation_pattern": "^([a-zA-Z\\:\\ 0-9#._\\\\/]*)$" }, { "name": "Folder Path", @@ -215,7 +215,7 @@ DEFAULT_CREATORS = { { "name": "Version", "type": "number", - "default": 1, + "default": "1", "required_column": True, "validation_pattern": "^(\\d{1,3})$" }, @@ -231,47 +231,47 @@ DEFAULT_CREATORS = { "type": "text", "default": "", "required_column": False, - "validation_pattern": "^([a-zA-Z0-9#._\\/]*)$" + "validation_pattern": "^([a-zA-Z\\:\\ 0-9#._\\\\/]*)$" }, { "name": "Frame Start", "type": "number", - "default": 0, + "default": "0", "required_column": True, "validation_pattern": "^(\\d{1,8})$" }, { "name": "Frame End", "type": "number", - "default": 0, + "default": "0", "required_column": True, "validation_pattern": "^(\\d{1,8})$" }, { "name": "Handle Start", "type": "number", - "default": 0, + "default": "0", "required_column": True, "validation_pattern": "^(\\d)$" }, { "name": "Handle End", "type": "number", - "default": 0, + "default": "0", "required_column": True, "validation_pattern": "^(\\d)$" }, { "name": "FPS", "type": "decimal", - "default": 0.0, + "default": "0.0", "required_column": True, "validation_pattern": "^[0-9]*\\.[0-9]+$|^[0-9]+$" }, { "name": "Slate Exists", "type": "bool", - "default": True, + "default": "True", "required_column": False, "validation_pattern": "(True|False)" },