diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 2849a4951a..73505368dd 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,11 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
+ - 3.17.5-nightly.1
+ - 3.17.4
+ - 3.17.4-nightly.2
+ - 3.17.4-nightly.1
+ - 3.17.3
- 3.17.3-nightly.2
- 3.17.3-nightly.1
- 3.17.2
@@ -130,11 +135,6 @@ body:
- 3.15.1-nightly.3
- 3.15.1-nightly.2
- 3.15.1-nightly.1
- - 3.15.0
- - 3.15.0-nightly.1
- - 3.14.11-nightly.4
- - 3.14.11-nightly.3
- - 3.14.11-nightly.2
validations:
required: true
- type: dropdown
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d5cf2c4d2..7432b33e24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,647 @@
# Changelog
+## [3.17.4](https://github.com/ynput/OpenPype/tree/3.17.4)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.3...3.17.4)
+
+### **🆕 New features**
+
+
+
+Add Support for Husk-AYON Integration #5816
+
+This draft pull request introduces support for integrating Husk with AYON within the OpenPype repository.
+
+
+___
+
+
+
+
+
+Push to project tool: Prepare push to project tool for AYON #5770
+
+Cloned Push to project tool for AYON and modified it.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Max: tycache family support #5624
+
+Tycache family supports for Tyflow Plugin in Max
+
+
+___
+
+
+
+
+
+Unreal: Changed behaviour for updating assets #5670
+
+Changed how assets are updated in Unreal.
+
+
+___
+
+
+
+
+
+Unreal: Improved error reporting for Sequence Frame Validator #5730
+
+Improved error reporting for Sequence Frame Validator.
+
+
+___
+
+
+
+
+
+Max: Setting tweaks on Review Family #5744
+
+- Bug fix of not being able to publish the preferred visual style when creating preview animation
+- Exposes the parameters after creating instance
+- Add the Quality settings and viewport texture settings for preview animation
+- add use selection for create review
+
+
+___
+
+
+
+
+
+Max: Add families with frame range extractions back to the frame range validator #5757
+
+In 3dsMax, there are some instances which exports the files in frame range but not being added to the optional frame range validator. In this PR, these instances would have the optional frame range validators to allow users to check if frame range aligns with the context data from DB.The following families have been added to have optional frame range validator:
+- maxrender
+- review
+- camera
+- redshift proxy
+- pointcache
+- point cloud(tyFlow PRT)
+
+
+___
+
+
+
+
+
+TimersManager: Use available data to get context info #5804
+
+Get context information from pyblish context data instead of using `legacy_io`.
+
+
+___
+
+
+
+
+
+Chore: Removed unused variable from `AbstractCollectRender` #5805
+
+Removed unused `_asset` variable from `RenderInstance`.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Bugfix/houdini: wrong frame calculation with handles #5698
+
+This PR make collect plugins to consider `handleStart` and `handleEnd` when collecting frame range it affects three parts:
+- get frame range in collect plugins
+- expected file in render plugins
+- submit houdini job deadline plugin
+
+
+___
+
+
+
+
+
+Nuke: ayon server settings improvements #5746
+
+Nuke settings were not aligned with OpenPype settings. Also labels needed to be improved.
+
+
+___
+
+
+
+
+
+Blender: Fix pointcache family and fix alembic extractor #5747
+
+Fixed `pointcache` family and fixed behaviour of the alembic extractor.
+
+
+___
+
+
+
+
+
+AYON: Remove 'shotgun_api3' from dependencies #5803
+
+Removed `shotgun_api3` dependency from openpype dependencies for AYON launcher. The dependency is already defined in shotgrid addon and change of version causes clashes.
+
+
+___
+
+
+
+
+
+Chore: Fix typo in filename #5807
+
+Move content of `contants.py` into `constants.py`.
+
+
+___
+
+
+
+
+
+Chore: Create context respects instance changes #5809
+
+Fix issue with unrespected change propagation in `CreateContext`. All successfully saved instances are marked as saved so they have no changes. Origin data of an instance are explicitly not handled directly by the object but by the attribute wrappers.
+
+
+___
+
+
+
+
+
+Blender: Fix tools handling in AYON mode #5811
+
+Skip logic in `before_window_show` in blender when in AYON mode. Most of the stuff called there happes on show automatically.
+
+
+___
+
+
+
+
+
+Blender: Include Grease Pencil in review and thumbnails #5812
+
+Include Grease Pencil in review and thumbnails.
+
+
+___
+
+
+
+
+
+Workfiles tool AYON: Fix double click of workfile #5813
+
+Fix double click on workfiles in workfiles tool to open the file.
+
+
+___
+
+
+
+
+
+Webpublisher: removal of usage of no_of_frames in error message #5819
+
+If it throws exception, `no_of_frames` value wont be available, so it doesn't make sense to log it.
+
+
+___
+
+
+
+
+
+Attribute Defs: Hide multivalue widget in Number by default #5821
+
+Fixed default look of `NumberAttrWidget` by hiding its multiselection widget.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Corrected a typo in Readme.md (Top -> To) #5800
+
+
+___
+
+
+
+
+
+Photoshop: Removed redundant copy of extension.zxp #5802
+
+`extension.zxp` shouldn't be inside of extension folder.
+
+
+___
+
+
+
+
+
+
+## [3.17.3](https://github.com/ynput/OpenPype/tree/3.17.3)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.2...3.17.3)
+
+### **🆕 New features**
+
+
+
+Maya: Multi-shot Layout Creator #5710
+
+New Multi-shot Layout creator is a way of automating creation of the new Layout instances in Maya, associated with correct shots, frame ranges and Camera Sequencer in Maya.
+
+
+___
+
+
+
+
+
+Colorspace: ociolook file product type workflow #5541
+
+Traypublisher support for publishing of colorspace look files (ociolook) which are json files holding any LUT files. This new product is available for loading in Nuke host at the moment.Added colorspace selector to publisher attribute with better labeling. We are supporting also Roles and Alias (only v2 configs).
+
+
+___
+
+
+
+
+
+Scene Inventory tool: Refactor Scene Inventory tool (for AYON) #5758
+
+Modified scene inventory tool for AYON. The main difference is in how project name is defined and replacement of assets combobox with folders dialog.
+
+
+___
+
+
+
+
+
+AYON: Support dev bundles #5783
+
+Modules can be loaded in AYON dev mode from different location.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Testing: Ingest Maya userSetup #5734
+
+Suggesting to ingest `userSetup.py` startup script for easier collaboration and transparency of testing.
+
+
+___
+
+
+
+
+
+Fusion: Work with pathmaps #5329
+
+Path maps are a big part of our Fusion workflow. We map the project folder to a path map within Fusion so all loaders and savers point to the path map variable. This way any computer on any OS can open any comp no matter where the project folder is located.
+
+
+___
+
+
+
+
+
+Maya: Add Maya 2024 and remove pre 2022. #5674
+
+Adding Maya 2024 as default application variant.Removing Maya 2020 and older, as these are not supported anymore.
+
+
+___
+
+
+
+
+
+Enhancement: Houdini: Allow using template keys in Houdini shelves manager #5727
+
+Allow using Template keys in Houdini shelves manager.
+
+
+___
+
+
+
+
+
+Houdini: Fix Show in usdview loader action #5737
+
+Fix the "Show in USD View" loader to show up in Houdini
+
+
+___
+
+
+
+
+
+Nuke: validator of asset context with repair actions #5749
+
+Instance nodes with different context of asset and task can be now validated and repaired via repair action.
+
+
+___
+
+
+
+
+
+AYON: Tools enhancements #5753
+
+Few enhancements and tweaks of AYON related tools.
+
+
+___
+
+
+
+
+
+Max: Tweaks on ValidateMaxContents #5759
+
+This PR provides enhancements on ValidateMaxContent as follow:
+- Rename `ValidateMaxContents` to `ValidateContainers`
+- Add related families which are required to pass the validation(All families except `Render` as the render instance is the one which only allows empty container)
+
+
+___
+
+
+
+
+
+Enhancement: Nuke refactor `SelectInvalidAction` #5762
+
+Refactor `SelectInvalidAction` to behave like other action for other host, create `SelectInstanceNodeAction` as dedicated action to select the instance node for a failed plugin.
+- Note: Selecting Instance Node will still select the instance node even if the user has currently 'fixed' the problem.
+
+
+___
+
+
+
+
+
+Enhancement: Tweak logging for Nuke for artist facing reports #5763
+
+Tweak logs that are not artist-facing to debug level + in some cases clarify what the logged value is.
+
+
+___
+
+
+
+
+
+AYON Settings: Disk mapping #5786
+
+Added disk mapping settings to core addon settings.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: add colorspace argument to redshiftTextureProcessor #5645
+
+In color managed Maya, texture processing during Look Extraction wasn't passing texture colorspaces set on textures to `redshiftTextureProcessor` tool. This in effect caused this tool to produce non-zero exit code (even though the texture was converted into wrong colorspace) and therefor crash of the extractor. This PR is passing colorspace to that tool if color management is enabled.
+
+
+___
+
+
+
+
+
+Maya: don't call `cmds.ogs()` in headless mode #5769
+
+`cmds.ogs()` is a call that will crash if Maya is running in headless mode (mayabatch, mayapy). This is handling that case.
+
+
+___
+
+
+
+
+
+Resolve: inventory management fix #5673
+
+Loaded Timeline item containers are now updating correctly and version management is working as it suppose to.
+- [x] updating loaded timeline items
+- [x] Removing of loaded timeline items
+
+
+___
+
+
+
+
+
+Blender: Remove 'update_hierarchy' #5756
+
+Remove `update_hierarchy` function which is causing crashes in scene inventory tool.
+
+
+___
+
+
+
+
+
+Max: bug fix on the settings in pointcloud family #5768
+
+Bug fix on the settings being errored out in validate point cloud(see links:https://github.com/ynput/OpenPype/pull/5759#pullrequestreview-1676681705) and passibly in point cloud extractor.
+
+
+___
+
+
+
+
+
+AYON settings: Fix default factory of tools #5773
+
+Fix default factory of application tools.
+
+
+___
+
+
+
+
+
+Fusion: added missing OPENPYPE_VERSION #5776
+
+Fusion submission to Deadline was missing OPENPYPE_VERSION env var when submitting from build (not source code directly). This missing env var might break rendering on DL if path to OP executable (openpype_console.exe) is not set explicitly and might cause an issue when different versions of OP are deployed.This PR adds this environment variable.
+
+
+___
+
+
+
+
+
+Ftrack: Skip tasks when looking for asset equivalent entity #5777
+
+Skip tasks when looking for asset equivalent entity.
+
+
+___
+
+
+
+
+
+Nuke: loading gizmos fixes #5779
+
+Gizmo product is not offered in Loader as plugin. It is also updating as expected.
+
+
+___
+
+
+
+
+
+General: thumbnail extractor as last extractor #5780
+
+Fixing issue with the order of the `ExtractOIIOTranscode` and `ExtractThumbnail` plugins. The problem was that the `ExtractThumbnail` plugin was processed before the `ExtractOIIOTranscode` plugin. As a result, the `ExtractThumbnail` plugin did not inherit the `review` tag into the representation data. This caused the `ExtractThumbnail` plugin to fail in processing and creating thumbnails.
+
+
+___
+
+
+
+
+
+Bug: fix key in application json #5787
+
+In PR #5705 `maya` was wrongly used instead of `mayapy`, breaking AYON defaults in AYON Application Addon.
+
+
+___
+
+
+
+
+
+'NumberAttrWidget' shows 'Multiselection' label on multiselection #5792
+
+Attribute definition widget 'NumberAttrWidget' shows `< Multiselection >` label on multiselection.
+
+
+___
+
+
+
+
+
+Publisher: Selection change by enabled checkbox on instance update attributes #5793
+
+Change of instance by clicking on enabled checkbox will actually update attributes on right side to match the selection.
+
+
+___
+
+
+
+
+
+Houdini: Remove `setParms` call since it's responsibility of `self.imprint` to set the values #5796
+
+Revert a recent change made in #5621 due to this comment. However the change is faulty as can be seen mentioned here
+
+
+___
+
+
+
+
+
+AYON loader: Fix SubsetLoader functionality #5799
+
+Fix SubsetLoader plugin processing in AYON loader tool.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Houdini: Add self publish button #5621
+
+This PR allows single publishing by adding a publish button to created rop nodes in HoudiniAdmins are much welcomed to enable it from houdini general settingsPublish Button also includes all input publish instances. in this screen shot the alembic instance is ignored because the switch is turned off
+
+
+___
+
+
+
+
+
+Nuke: fixing UNC support for OCIO path #5771
+
+UNC paths were broken on windows for custom OCIO path and this is solving the issue with removed double slash at start of path
+
+
+___
+
+
+
+
+
+
## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2)
diff --git a/README.md b/README.md
index ce98f845e6..ed3e058002 100644
--- a/README.md
+++ b/README.md
@@ -279,7 +279,7 @@ arguments and it will create zip file that OpenPype can use.
Building documentation
----------------------
-Top build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation
+To build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation
from current sources in `.\docs\build`.
**Note that it needs existing virtual environment.**
diff --git a/openpype/hosts/blender/api/capture.py b/openpype/hosts/blender/api/capture.py
index 849f8ee629..bad6831143 100644
--- a/openpype/hosts/blender/api/capture.py
+++ b/openpype/hosts/blender/api/capture.py
@@ -148,13 +148,14 @@ def applied_view(window, camera, isolate=None, options=None):
area.ui_type = "VIEW_3D"
- meshes = [obj for obj in window.scene.objects if obj.type == "MESH"]
+ types = {"MESH", "GPENCIL"}
+ objects = [obj for obj in window.scene.objects if obj.type in types]
if camera == "AUTO":
space.region_3d.view_perspective = "ORTHO"
- isolate_objects(window, isolate or meshes)
+ isolate_objects(window, isolate or objects)
else:
- isolate_objects(window, isolate or meshes)
+ isolate_objects(window, isolate or objects)
space.camera = window.scene.objects.get(camera)
space.region_3d.view_perspective = "CAMERA"
diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py
index 0eb90eeff9..208c11cfe8 100644
--- a/openpype/hosts/blender/api/ops.py
+++ b/openpype/hosts/blender/api/ops.py
@@ -284,6 +284,8 @@ class LaunchLoader(LaunchQtApp):
_tool_name = "loader"
def before_window_show(self):
+ if AYON_SERVER_ENABLED:
+ return
self._window.set_context(
{"asset": get_current_asset_name()},
refresh=True
@@ -309,6 +311,8 @@ class LaunchManager(LaunchQtApp):
_tool_name = "sceneinventory"
def before_window_show(self):
+ if AYON_SERVER_ENABLED:
+ return
self._window.refresh()
@@ -320,6 +324,8 @@ class LaunchLibrary(LaunchQtApp):
_tool_name = "libraryloader"
def before_window_show(self):
+ if AYON_SERVER_ENABLED:
+ return
self._window.refresh()
@@ -340,6 +346,8 @@ class LaunchWorkFiles(LaunchQtApp):
return result
def before_window_show(self):
+ if AYON_SERVER_ENABLED:
+ return
self._window.root = str(Path(
os.environ.get("AVALON_WORKDIR", ""),
os.environ.get("AVALON_SCENEDIR", ""),
diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py
index 6220f68dc5..65cf18472d 100644
--- a/openpype/hosts/blender/plugins/create/create_pointcache.py
+++ b/openpype/hosts/blender/plugins/create/create_pointcache.py
@@ -3,11 +3,11 @@
import bpy
from openpype.pipeline import get_current_task_name
-import openpype.hosts.blender.api.plugin
-from openpype.hosts.blender.api import lib
+from openpype.hosts.blender.api import plugin, lib, ops
+from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
-class CreatePointcache(openpype.hosts.blender.api.plugin.Creator):
+class CreatePointcache(plugin.Creator):
"""Polygonal static geometry"""
name = "pointcacheMain"
@@ -16,20 +16,36 @@ class CreatePointcache(openpype.hosts.blender.api.plugin.Creator):
icon = "gears"
def process(self):
+ """ Run the creator on Blender main thread"""
+ mti = ops.MainThreadItem(self._process)
+ ops.execute_in_main_thread(mti)
+ def _process(self):
+ # Get Instance Container or create it if it does not exist
+ instances = bpy.data.collections.get(AVALON_INSTANCES)
+ if not instances:
+ instances = bpy.data.collections.new(name=AVALON_INSTANCES)
+ bpy.context.scene.collection.children.link(instances)
+
+ # Create instance object
asset = self.data["asset"]
subset = self.data["subset"]
- name = openpype.hosts.blender.api.plugin.asset_name(asset, subset)
- collection = bpy.data.collections.new(name=name)
- bpy.context.scene.collection.children.link(collection)
+ name = plugin.asset_name(asset, subset)
+ asset_group = bpy.data.objects.new(name=name, object_data=None)
+ asset_group.empty_display_type = 'SINGLE_ARROW'
+ instances.objects.link(asset_group)
self.data['task'] = get_current_task_name()
- lib.imprint(collection, self.data)
+ lib.imprint(asset_group, self.data)
+ # Add selected objects to instance
if (self.options or {}).get("useSelection"):
- objects = lib.get_selection()
- for obj in objects:
- collection.objects.link(obj)
- if obj.type == 'EMPTY':
- objects.extend(obj.children)
+ bpy.context.view_layer.objects.active = asset_group
+ selected = lib.get_selection()
+ for obj in selected:
+ if obj.parent in selected:
+ obj.select_set(False)
+ continue
+ selected.append(asset_group)
+ bpy.ops.object.parent_set(keep_transform=True)
- return collection
+ return asset_group
diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py
index 9b3d940536..8d1863d4d5 100644
--- a/openpype/hosts/blender/plugins/load/load_abc.py
+++ b/openpype/hosts/blender/plugins/load/load_abc.py
@@ -60,18 +60,29 @@ class CacheModelLoader(plugin.AssetLoader):
imported = lib.get_selection()
- # Children must be linked before parents,
- # otherwise the hierarchy will break
+ # Use first EMPTY without parent as container
+ container = next(
+ (obj for obj in imported
+ if obj.type == "EMPTY" and not obj.parent),
+ None
+ )
+
objects = []
+ if container:
+ nodes = list(container.children)
- for obj in imported:
- obj.parent = asset_group
+ for obj in nodes:
+ obj.parent = asset_group
- for obj in imported:
- objects.append(obj)
- imported.extend(list(obj.children))
+ bpy.data.objects.remove(container)
- objects.reverse()
+ objects.extend(nodes)
+ for obj in nodes:
+ objects.extend(obj.children_recursive)
+ else:
+ for obj in imported:
+ obj.parent = asset_group
+ objects = imported
for obj in objects:
# Unlink the object from all collections
@@ -137,6 +148,7 @@ class CacheModelLoader(plugin.AssetLoader):
bpy.context.scene.collection.children.link(containers)
asset_group = bpy.data.objects.new(group_name, object_data=None)
+ asset_group.empty_display_type = 'SINGLE_ARROW'
containers.objects.link(asset_group)
objects = self._process(libpath, asset_group, group_name)
diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py
index bc4b5ab092..ad2ce54147 100644
--- a/openpype/hosts/blender/plugins/publish/collect_instances.py
+++ b/openpype/hosts/blender/plugins/publish/collect_instances.py
@@ -19,85 +19,51 @@ class CollectInstances(pyblish.api.ContextPlugin):
@staticmethod
def get_asset_groups() -> Generator:
- """Return all 'model' collections.
-
- Check if the family is 'model' and if it doesn't have the
- representation set. If the representation is set, it is a loaded model
- and we don't want to publish it.
+ """Return all instances that are empty objects asset groups.
"""
instances = bpy.data.collections.get(AVALON_INSTANCES)
- for obj in instances.objects:
- avalon_prop = obj.get(AVALON_PROPERTY) or dict()
+ for obj in list(instances.objects) + list(instances.children):
+ avalon_prop = obj.get(AVALON_PROPERTY) or {}
if avalon_prop.get('id') == 'pyblish.avalon.instance':
yield obj
@staticmethod
- def get_collections() -> Generator:
- """Return all 'model' collections.
-
- Check if the family is 'model' and if it doesn't have the
- representation set. If the representation is set, it is a loaded model
- and we don't want to publish it.
- """
- for collection in bpy.data.collections:
- avalon_prop = collection.get(AVALON_PROPERTY) or dict()
- if avalon_prop.get('id') == 'pyblish.avalon.instance':
- yield collection
+ def create_instance(context, group):
+ avalon_prop = group[AVALON_PROPERTY]
+ asset = avalon_prop['asset']
+ family = avalon_prop['family']
+ subset = avalon_prop['subset']
+ task = avalon_prop['task']
+ name = f"{asset}_{subset}"
+ return context.create_instance(
+ name=name,
+ family=family,
+ families=[family],
+ subset=subset,
+ asset=asset,
+ task=task,
+ )
def process(self, context):
"""Collect the models from the current Blender scene."""
asset_groups = self.get_asset_groups()
- collections = self.get_collections()
for group in asset_groups:
- avalon_prop = group[AVALON_PROPERTY]
- asset = avalon_prop['asset']
- family = avalon_prop['family']
- subset = avalon_prop['subset']
- task = avalon_prop['task']
- name = f"{asset}_{subset}"
- instance = context.create_instance(
- name=name,
- family=family,
- families=[family],
- subset=subset,
- asset=asset,
- task=task,
- )
- objects = list(group.children)
- members = set()
- for obj in objects:
- objects.extend(list(obj.children))
- members.add(obj)
- members.add(group)
- instance[:] = list(members)
- self.log.debug(json.dumps(instance.data, indent=4))
- for obj in instance:
- self.log.debug(obj)
+ instance = self.create_instance(context, group)
+ members = []
+ if isinstance(group, bpy.types.Collection):
+ members = list(group.objects)
+ family = instance.data["family"]
+ if family == "animation":
+ for obj in group.objects:
+ if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY):
+ members.extend(
+ child for child in obj.children
+ if child.type == 'ARMATURE')
+ else:
+ members = group.children_recursive
- for collection in collections:
- avalon_prop = collection[AVALON_PROPERTY]
- asset = avalon_prop['asset']
- family = avalon_prop['family']
- subset = avalon_prop['subset']
- task = avalon_prop['task']
- name = f"{asset}_{subset}"
- instance = context.create_instance(
- name=name,
- family=family,
- families=[family],
- subset=subset,
- asset=asset,
- task=task,
- )
- members = list(collection.objects)
- if family == "animation":
- for obj in collection.objects:
- if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY):
- for child in obj.children:
- if child.type == 'ARMATURE':
- members.append(child)
- members.append(collection)
+ members.append(group)
instance[:] = members
self.log.debug(json.dumps(instance.data, indent=4))
for obj in instance:
diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py
index 87159e53f0..b17d7cc6e4 100644
--- a/openpype/hosts/blender/plugins/publish/extract_abc.py
+++ b/openpype/hosts/blender/plugins/publish/extract_abc.py
@@ -12,8 +12,7 @@ class ExtractABC(publish.Extractor):
label = "Extract ABC"
hosts = ["blender"]
- families = ["model", "pointcache"]
- optional = True
+ families = ["pointcache"]
def process(self, instance):
# Define extract output file path
@@ -22,7 +21,7 @@ class ExtractABC(publish.Extractor):
filepath = os.path.join(stagingdir, filename)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
plugin.deselect_all()
@@ -62,3 +61,12 @@ class ExtractABC(publish.Extractor):
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)
+
+
+class ExtractModelABC(ExtractABC):
+ """Extract model as ABC."""
+
+ label = "Extract Model ABC"
+ hosts = ["blender"]
+ families = ["model"]
+ optional = True
diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
index 44b2ba3761..6866b05fea 100644
--- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
+++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
@@ -21,7 +21,7 @@ class ExtractAnimationABC(publish.Extractor):
filepath = os.path.join(stagingdir, filename)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
plugin.deselect_all()
diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py
index d4f26b4f3c..c8eeef7fd7 100644
--- a/openpype/hosts/blender/plugins/publish/extract_blend.py
+++ b/openpype/hosts/blender/plugins/publish/extract_blend.py
@@ -21,7 +21,7 @@ class ExtractBlend(publish.Extractor):
filepath = os.path.join(stagingdir, filename)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
data_blocks = set()
diff --git a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py
index 477411b73d..661cecce81 100644
--- a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py
+++ b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py
@@ -21,7 +21,7 @@ class ExtractBlendAnimation(publish.Extractor):
filepath = os.path.join(stagingdir, filename)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
data_blocks = set()
diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py
index 036be7bf3c..5916564ac0 100644
--- a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py
+++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py
@@ -22,7 +22,7 @@ class ExtractCameraABC(publish.Extractor):
filepath = os.path.join(stagingdir, filename)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
plugin.deselect_all()
diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py
index 315994140e..a541f5b375 100644
--- a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py
+++ b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py
@@ -21,7 +21,7 @@ class ExtractCamera(publish.Extractor):
filepath = os.path.join(stagingdir, filename)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
plugin.deselect_all()
diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py
index 0ad797c226..f2ce117dcd 100644
--- a/openpype/hosts/blender/plugins/publish/extract_fbx.py
+++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py
@@ -22,7 +22,7 @@ class ExtractFBX(publish.Extractor):
filepath = os.path.join(stagingdir, filename)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
plugin.deselect_all()
diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py
index 062b42e99d..5fe5931e65 100644
--- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py
+++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py
@@ -23,7 +23,7 @@ class ExtractAnimationFBX(publish.Extractor):
stagingdir = self.staging_dir(instance)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
# The first collection object in the instance is taken, as there
# should be only one that contains the asset group.
diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py
index f2d04f1178..05f86b8370 100644
--- a/openpype/hosts/blender/plugins/publish/extract_layout.py
+++ b/openpype/hosts/blender/plugins/publish/extract_layout.py
@@ -117,7 +117,7 @@ class ExtractLayout(publish.Extractor):
stagingdir = self.staging_dir(instance)
# Perform extraction
- self.log.info("Performing extraction..")
+ self.log.debug("Performing extraction..")
if "representations" not in instance.data:
instance.data["representations"] = []
diff --git a/openpype/hosts/blender/plugins/publish/extract_playblast.py b/openpype/hosts/blender/plugins/publish/extract_playblast.py
index 196e75b8cc..b0099cce85 100644
--- a/openpype/hosts/blender/plugins/publish/extract_playblast.py
+++ b/openpype/hosts/blender/plugins/publish/extract_playblast.py
@@ -24,9 +24,7 @@ class ExtractPlayblast(publish.Extractor):
order = pyblish.api.ExtractorOrder + 0.01
def process(self, instance):
- self.log.info("Extracting capture..")
-
- self.log.info(instance.data)
+ self.log.debug("Extracting capture..")
# get scene fps
fps = instance.data.get("fps")
@@ -34,14 +32,14 @@ class ExtractPlayblast(publish.Extractor):
fps = bpy.context.scene.render.fps
instance.data["fps"] = fps
- self.log.info(f"fps: {fps}")
+ self.log.debug(f"fps: {fps}")
# If start and end frames cannot be determined,
# get them from Blender timeline.
start = instance.data.get("frameStart", bpy.context.scene.frame_start)
end = instance.data.get("frameEnd", bpy.context.scene.frame_end)
- self.log.info(f"start: {start}, end: {end}")
+ self.log.debug(f"start: {start}, end: {end}")
assert end > start, "Invalid time range !"
# get cameras
@@ -55,7 +53,7 @@ class ExtractPlayblast(publish.Extractor):
filename = instance.name
path = os.path.join(stagingdir, filename)
- self.log.info(f"Outputting images to {path}")
+ self.log.debug(f"Outputting images to {path}")
project_settings = instance.context.data["project_settings"]["blender"]
presets = project_settings["publish"]["ExtractPlayblast"]["presets"]
@@ -100,7 +98,7 @@ class ExtractPlayblast(publish.Extractor):
frame_collection = collections[0]
- self.log.info(f"We found collection of interest {frame_collection}")
+ self.log.debug(f"Found collection of interest {frame_collection}")
instance.data.setdefault("representations", [])
diff --git a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py
index 65c3627375..52e5d98fc4 100644
--- a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py
+++ b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py
@@ -24,13 +24,13 @@ class ExtractThumbnail(publish.Extractor):
presets = {}
def process(self, instance):
- self.log.info("Extracting capture..")
+ self.log.debug("Extracting capture..")
stagingdir = self.staging_dir(instance)
filename = instance.name
path = os.path.join(stagingdir, filename)
- self.log.info(f"Outputting images to {path}")
+ self.log.debug(f"Outputting images to {path}")
camera = instance.data.get("review_camera", "AUTO")
start = instance.data.get("frameStart", bpy.context.scene.frame_start)
@@ -61,7 +61,7 @@ class ExtractThumbnail(publish.Extractor):
thumbnail = os.path.basename(self._fix_output_path(path))
- self.log.info(f"thumbnail: {thumbnail}")
+ self.log.debug(f"thumbnail: {thumbnail}")
instance.data.setdefault("representations", [])
diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py
index 3d176f9c30..6ace14d77c 100644
--- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py
+++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py
@@ -10,7 +10,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
optional = True
hosts = ["blender"]
families = ["animation", "model", "rig", "action", "layout", "blendScene",
- "render"]
+ "pointcache", "render"]
def process(self, context):
diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py
index c4a1488606..85f9c54a73 100644
--- a/openpype/hosts/fusion/api/lib.py
+++ b/openpype/hosts/fusion/api/lib.py
@@ -280,7 +280,11 @@ def get_current_comp():
@contextlib.contextmanager
-def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"):
+def comp_lock_and_undo_chunk(
+ comp,
+ undo_queue_name="Script CMD",
+ keep_undo=True,
+):
"""Lock comp and open an undo chunk during the context"""
try:
comp.Lock()
@@ -288,4 +292,4 @@ def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"):
yield
finally:
comp.Unlock()
- comp.EndUndo()
+ comp.EndUndo(keep_undo)
diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py
index 4564880b50..2dc48f4b60 100644
--- a/openpype/hosts/fusion/plugins/create/create_saver.py
+++ b/openpype/hosts/fusion/plugins/create/create_saver.py
@@ -69,8 +69,6 @@ class CreateSaver(NewCreator):
# TODO Is this needed?
saver[file_format]["SaveAlpha"] = 1
- self._imprint(saver, instance_data)
-
# Register the CreatedInstance
instance = CreatedInstance(
family=self.family,
@@ -78,6 +76,8 @@ class CreateSaver(NewCreator):
data=instance_data,
creator=self,
)
+ data = instance.data_to_store()
+ self._imprint(saver, data)
# Insert the transient data
instance.transient_data["tool"] = saver
diff --git a/openpype/hosts/fusion/plugins/load/actions.py b/openpype/hosts/fusion/plugins/load/actions.py
index f83ab433ee..94ba361b50 100644
--- a/openpype/hosts/fusion/plugins/load/actions.py
+++ b/openpype/hosts/fusion/plugins/load/actions.py
@@ -11,6 +11,7 @@ class FusionSetFrameRangeLoader(load.LoaderPlugin):
families = ["animation",
"camera",
"imagesequence",
+ "render",
"yeticache",
"pointcache",
"render"]
@@ -46,6 +47,7 @@ class FusionSetFrameRangeWithHandlesLoader(load.LoaderPlugin):
families = ["animation",
"camera",
"imagesequence",
+ "render",
"yeticache",
"pointcache",
"render"]
diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py
new file mode 100644
index 0000000000..efa7295d11
--- /dev/null
+++ b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py
@@ -0,0 +1,105 @@
+import pyblish.api
+from openpype.pipeline import (
+ PublishValidationError,
+ OptionalPyblishPluginMixin,
+)
+
+from openpype.hosts.fusion.api.action import SelectInvalidAction
+from openpype.hosts.fusion.api import comp_lock_and_undo_chunk
+
+
+def get_tool_resolution(tool, frame):
+ """Return the 2D input resolution to a Fusion tool
+
+ If the current tool hasn't been rendered its input resolution
+ hasn't been saved. To combat this, add an expression in
+ the comments field to read the resolution
+
+ Args
+ tool (Fusion Tool): The tool to query input resolution
+ frame (int): The frame to query the resolution on.
+
+ Returns:
+ tuple: width, height as 2-tuple of integers
+
+ """
+ comp = tool.Composition
+
+ # False undo removes the undo-stack from the undo list
+ with comp_lock_and_undo_chunk(comp, "Read resolution", False):
+ # Save old comment
+ old_comment = ""
+ has_expression = False
+ if tool["Comments"][frame] != "":
+ if tool["Comments"].GetExpression() is not None:
+ has_expression = True
+ old_comment = tool["Comments"].GetExpression()
+ tool["Comments"].SetExpression(None)
+ else:
+ old_comment = tool["Comments"][frame]
+ tool["Comments"][frame] = ""
+
+ # Get input width
+ tool["Comments"].SetExpression("self.Input.OriginalWidth")
+ width = int(tool["Comments"][frame])
+
+ # Get input height
+ tool["Comments"].SetExpression("self.Input.OriginalHeight")
+ height = int(tool["Comments"][frame])
+
+ # Reset old comment
+ tool["Comments"].SetExpression(None)
+ if has_expression:
+ tool["Comments"].SetExpression(old_comment)
+ else:
+ tool["Comments"][frame] = old_comment
+
+ return width, height
+
+
+class ValidateSaverResolution(
+ pyblish.api.InstancePlugin, OptionalPyblishPluginMixin
+):
+ """Validate that the saver input resolution matches the asset resolution"""
+
+ order = pyblish.api.ValidatorOrder
+ label = "Validate Asset Resolution"
+ families = ["render"]
+ hosts = ["fusion"]
+ optional = True
+ actions = [SelectInvalidAction]
+
+ def process(self, instance):
+ if not self.is_active(instance.data):
+ return
+
+ resolution = self.get_resolution(instance)
+ expected_resolution = self.get_expected_resolution(instance)
+ if resolution != expected_resolution:
+ raise PublishValidationError(
+ "The input's resolution does not match "
+ "the asset's resolution {}x{}.\n\n"
+ "The input's resolution is {}x{}.".format(
+ expected_resolution[0], expected_resolution[1],
+ resolution[0], resolution[1]
+ )
+ )
+
+ @classmethod
+ def get_invalid(cls, instance):
+ resolution = cls.get_resolution(instance)
+ expected_resolution = cls.get_expected_resolution(instance)
+ if resolution != expected_resolution:
+ saver = instance.data["tool"]
+ return [saver]
+
+ @classmethod
+ def get_resolution(cls, instance):
+ saver = instance.data["tool"]
+ first_frame = instance.data["frameStartHandle"]
+ return get_tool_resolution(saver, frame=first_frame)
+
+ @classmethod
+ def get_expected_resolution(cls, instance):
+ data = instance.data["assetEntity"]["data"]
+ return data["resolutionWidth"], data["resolutionHeight"]
diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py
index f258dda36e..cadeaa8ed4 100644
--- a/openpype/hosts/houdini/api/lib.py
+++ b/openpype/hosts/houdini/api/lib.py
@@ -332,52 +332,61 @@ def imprint(node, data, update=False):
return
current_parms = {p.name(): p for p in node.spareParms()}
- update_parms = []
- templates = []
+ update_parm_templates = []
+ new_parm_templates = []
for key, value in data.items():
if value is None:
continue
- parm = get_template_from_value(key, value)
+ parm_template = get_template_from_value(key, value)
if key in current_parms:
- if node.evalParm(key) == data[key]:
+ if node.evalParm(key) == value:
continue
if not update:
log.debug(f"{key} already exists on {node}")
else:
log.debug(f"replacing {key}")
- update_parms.append(parm)
+ update_parm_templates.append(parm_template)
continue
- templates.append(parm)
+ new_parm_templates.append(parm_template)
- parm_group = node.parmTemplateGroup()
- parm_folder = parm_group.findFolder("Extra")
-
- # if folder doesn't exist yet, create one and append to it,
- # else append to existing one
- if not parm_folder:
- parm_folder = hou.FolderParmTemplate("folder", "Extra")
- parm_folder.setParmTemplates(templates)
- parm_group.append(parm_folder)
- else:
- for template in templates:
- parm_group.appendToFolder(parm_folder, template)
- # this is needed because the pointer to folder
- # is for some reason lost every call to `appendToFolder()`
- parm_folder = parm_group.findFolder("Extra")
-
- node.setParmTemplateGroup(parm_group)
-
- # TODO: Updating is done here, by calling probably deprecated functions.
- # This needs to be addressed in the future.
- if not update_parms:
+ if not new_parm_templates and not update_parm_templates:
return
- for parm in update_parms:
- node.replaceSpareParmTuple(parm.name(), parm)
+ parm_group = node.parmTemplateGroup()
+
+ # Add new parm templates
+ if new_parm_templates:
+ parm_folder = parm_group.findFolder("Extra")
+
+ # if folder doesn't exist yet, create one and append to it,
+ # else append to existing one
+ if not parm_folder:
+ parm_folder = hou.FolderParmTemplate("folder", "Extra")
+ parm_folder.setParmTemplates(new_parm_templates)
+ parm_group.append(parm_folder)
+ else:
+ # Add to parm template folder instance then replace with updated
+ # one in parm template group
+ for template in new_parm_templates:
+ parm_folder.addParmTemplate(template)
+ parm_group.replace(parm_folder.name(), parm_folder)
+
+ # Update existing parm templates
+ for parm_template in update_parm_templates:
+ parm_group.replace(parm_template.name(), parm_template)
+
+ # When replacing a parm with a parm of the same name it preserves its
+ # value if before the replacement the parm was not at the default,
+ # because it has a value override set. Since we're trying to update the
+ # parm by using the new value as `default` we enforce the parm is at
+ # default state
+ node.parm(parm_template.name()).revertToDefaults()
+
+ node.setParmTemplateGroup(parm_group)
def lsattr(attr, value=None, root="/"):
@@ -559,29 +568,64 @@ def get_template_from_value(key, value):
return parm
-def get_frame_data(node):
- """Get the frame data: start frame, end frame and steps.
+def get_frame_data(node, handle_start=0, handle_end=0, log=None):
+ """Get the frame data: start frame, end frame, steps,
+ start frame with start handle and end frame with end handle.
+
+ This function uses Houdini node's `trange`, `t1, `t2` and `t3`
+ parameters as the source of truth for the full inclusive frame
+ range to render, as such these are considered as the frame
+ range including the handles.
+
+ The non-inclusive frame start and frame end without handles
+ are computed by subtracting the handles from the inclusive
+ frame range.
Args:
- node(hou.Node)
+ node (hou.Node): ROP node to retrieve frame range from,
+ the frame range is assumed to be the frame range
+ *including* the start and end handles.
+ handle_start (int): Start handles.
+ handle_end (int): End handles.
+ log (logging.Logger): Logger to log to.
Returns:
- dict: frame data for star, end and steps.
+ dict: frame data for start, end, steps,
+ start with handle and end with handle
"""
+
+ if log is None:
+ log = self.log
+
data = {}
if node.parm("trange") is None:
-
+ log.debug(
+ "Node has no 'trange' parameter: {}".format(node.path())
+ )
return data
if node.evalParm("trange") == 0:
- self.log.debug("trange is 0")
- return data
+ data["frameStartHandle"] = hou.intFrame()
+ data["frameEndHandle"] = hou.intFrame()
+ data["byFrameStep"] = 1.0
- data["frameStart"] = node.evalParm("f1")
- data["frameEnd"] = node.evalParm("f2")
- data["steps"] = node.evalParm("f3")
+ log.info(
+ "Node '{}' has 'Render current frame' set.\n"
+ "Asset Handles are ignored.\n"
+ "frameStart and frameEnd are set to the "
+ "current frame.".format(node.path())
+ )
+ else:
+ data["frameStartHandle"] = int(node.evalParm("f1"))
+ data["frameEndHandle"] = int(node.evalParm("f2"))
+ data["byFrameStep"] = node.evalParm("f3")
+
+ data["handleStart"] = handle_start
+ data["handleEnd"] = handle_end
+ data["frameStart"] = data["frameStartHandle"] + data["handleStart"]
+ data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"]
return data
diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py
index d79ccc71bd..72565f7211 100644
--- a/openpype/hosts/houdini/api/plugin.py
+++ b/openpype/hosts/houdini/api/plugin.py
@@ -250,14 +250,12 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase):
key: changes[key].new_value
for key in changes.changed_keys
}
- # Update ParmTemplates
+ # Update parm templates and values
self.imprint(
instance_node,
new_values,
update=True
)
- # Update values
- instance_node.setParms(new_values)
def imprint(self, node, values, update=False):
# Never store instance node and instance id since that data comes
diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py
index 21e44e494a..4b5ebd4202 100644
--- a/openpype/hosts/houdini/api/shelves.py
+++ b/openpype/hosts/houdini/api/shelves.py
@@ -6,6 +6,9 @@ import platform
from openpype.settings import get_project_settings
from openpype.pipeline import get_current_project_name
+from openpype.lib import StringTemplate
+from openpype.pipeline.context_tools import get_current_context_template_data
+
import hou
log = logging.getLogger("openpype.hosts.houdini.shelves")
@@ -26,10 +29,16 @@ def generate_shelves():
log.debug("No custom shelves found in project settings.")
return
+ # Get Template data
+ template_data = get_current_context_template_data()
+
for shelf_set_config in shelves_set_config:
shelf_set_filepath = shelf_set_config.get('shelf_set_source_path')
shelf_set_os_filepath = shelf_set_filepath[current_os]
if shelf_set_os_filepath:
+ shelf_set_os_filepath = get_path_using_template_data(
+ shelf_set_os_filepath, template_data
+ )
if not os.path.isfile(shelf_set_os_filepath):
log.error("Shelf path doesn't exist - "
"{}".format(shelf_set_os_filepath))
@@ -81,7 +90,9 @@ def generate_shelves():
"script path of the tool.")
continue
- tool = get_or_create_tool(tool_definition, shelf)
+ tool = get_or_create_tool(
+ tool_definition, shelf, template_data
+ )
if not tool:
continue
@@ -144,7 +155,7 @@ def get_or_create_shelf(shelf_label):
return new_shelf
-def get_or_create_tool(tool_definition, shelf):
+def get_or_create_tool(tool_definition, shelf, template_data):
"""This function verifies if the tool exists and updates it. If not, creates
a new one.
@@ -162,10 +173,16 @@ def get_or_create_tool(tool_definition, shelf):
return
script_path = tool_definition["script"]
+ script_path = get_path_using_template_data(script_path, template_data)
if not script_path or not os.path.exists(script_path):
log.warning("This path doesn't exist - {}".format(script_path))
return
+ icon_path = tool_definition["icon"]
+ if icon_path:
+ icon_path = get_path_using_template_data(icon_path, template_data)
+ tool_definition["icon"] = icon_path
+
existing_tools = shelf.tools()
existing_tool = next(
(tool for tool in existing_tools if tool.label() == tool_label),
@@ -184,3 +201,10 @@ def get_or_create_tool(tool_definition, shelf):
tool_name = re.sub(r"[^\w\d]+", "_", tool_label).lower()
return hou.shelves.newTool(name=tool_name, **tool_definition)
+
+
+def get_path_using_template_data(path, template_data):
+ path = StringTemplate.format_template(path, template_data)
+ path = path.replace("\\", "/")
+
+ return path
diff --git a/openpype/hosts/houdini/plugins/load/show_usdview.py b/openpype/hosts/houdini/plugins/load/show_usdview.py
index 7b03a0738a..d56c4acc4f 100644
--- a/openpype/hosts/houdini/plugins/load/show_usdview.py
+++ b/openpype/hosts/houdini/plugins/load/show_usdview.py
@@ -1,4 +1,5 @@
import os
+import platform
import subprocess
from openpype.lib.vendor_bin_utils import find_executable
@@ -8,17 +9,31 @@ from openpype.pipeline import load
class ShowInUsdview(load.LoaderPlugin):
"""Open USD file in usdview"""
- families = ["colorbleed.usd"]
label = "Show in usdview"
- representations = ["usd", "usda", "usdlc", "usdnc"]
- order = 10
+ representations = ["*"]
+ families = ["*"]
+ extensions = {"usd", "usda", "usdlc", "usdnc", "abc"}
+ order = 15
icon = "code-fork"
color = "white"
def load(self, context, name=None, namespace=None, data=None):
+ from pathlib import Path
- usdview = find_executable("usdview")
+ if platform.system() == "Windows":
+ executable = "usdview.bat"
+ else:
+ executable = "usdview"
+
+ usdview = find_executable(executable)
+ if not usdview:
+ raise RuntimeError("Unable to find usdview")
+
+ # For some reason Windows can return the path like:
+ # C:/PROGRA~1/SIDEEF~1/HOUDIN~1.435/bin/usdview
+ # convert to resolved path so `subprocess` can take it
+ usdview = str(Path(usdview).resolve().as_posix())
filepath = self.filepath_from_context(context)
filepath = os.path.normpath(filepath)
@@ -30,14 +45,4 @@ class ShowInUsdview(load.LoaderPlugin):
self.log.info("Start houdini variant of usdview...")
- # For now avoid some pipeline environment variables that initialize
- # Avalon in Houdini as it is redundant for usdview and slows boot time
- env = os.environ.copy()
- env.pop("PYTHONPATH", None)
- env.pop("HOUDINI_SCRIPT_PATH", None)
- env.pop("HOUDINI_MENU_PATH", None)
-
- # Force string to avoid unicode issues
- env = {str(key): str(value) for key, value in env.items()}
-
- subprocess.Popen([usdview, filepath, "--renderer", "GL"], env=env)
+ subprocess.Popen([usdview, filepath, "--renderer", "GL"])
diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py
index 43b8428c60..b489f83b29 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py
@@ -20,7 +20,9 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin):
"""
label = "Arnold ROP Render Products"
- order = pyblish.api.CollectorOrder + 0.4
+ # This specific order value is used so that
+ # this plugin runs after CollectRopFrameRange
+ order = pyblish.api.CollectorOrder + 0.4999
hosts = ["houdini"]
families = ["arnold_rop"]
@@ -126,8 +128,9 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin):
return path
expected_files = []
- start = instance.data["frameStart"]
- end = instance.data["frameEnd"]
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
+
for i in range(int(start), (int(end) + 1)):
expected_files.append(
os.path.join(dir, (file % i)).replace("\\", "/"))
diff --git a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py
deleted file mode 100644
index 584343cd64..0000000000
--- a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import hou
-
-import pyblish.api
-
-
-class CollectInstanceNodeFrameRange(pyblish.api.InstancePlugin):
- """Collect time range frame data for the instance node."""
-
- order = pyblish.api.CollectorOrder + 0.001
- label = "Instance Node Frame Range"
- hosts = ["houdini"]
-
- def process(self, instance):
-
- node_path = instance.data.get("instance_node")
- node = hou.node(node_path) if node_path else None
- if not node_path or not node:
- self.log.debug("No instance node found for instance: "
- "{}".format(instance))
- return
-
- frame_data = self.get_frame_data(node)
- if not frame_data:
- return
-
- self.log.info("Collected time data: {}".format(frame_data))
- instance.data.update(frame_data)
-
- def get_frame_data(self, node):
- """Get the frame data: start frame, end frame and steps
- Args:
- node(hou.Node)
-
- Returns:
- dict
-
- """
-
- data = {}
-
- if node.parm("trange") is None:
- self.log.debug("Node has no 'trange' parameter: "
- "{}".format(node.path()))
- return data
-
- if node.evalParm("trange") == 0:
- # Ignore 'render current frame'
- self.log.debug("Node '{}' has 'Render current frame' set. "
- "Time range data ignored.".format(node.path()))
- return data
-
- data["frameStart"] = node.evalParm("f1")
- data["frameEnd"] = node.evalParm("f2")
- data["byFrameStep"] = node.evalParm("f3")
-
- return data
diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py
index 3772c9e705..52966fb3c2 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_instances.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py
@@ -91,27 +91,3 @@ class CollectInstances(pyblish.api.ContextPlugin):
context[:] = sorted(context, key=sort_by_family)
return context
-
- def get_frame_data(self, node):
- """Get the frame data: start frame, end frame and steps
- Args:
- node(hou.Node)
-
- Returns:
- dict
-
- """
-
- data = {}
-
- if node.parm("trange") is None:
- return data
-
- if node.evalParm("trange") == 0:
- return data
-
- data["frameStart"] = node.evalParm("f1")
- data["frameEnd"] = node.evalParm("f2")
- data["byFrameStep"] = node.evalParm("f3")
-
- return data
diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py
index eabb1128d8..fe0b8711fc 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py
@@ -24,7 +24,9 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin):
"""
label = "Karma ROP Render Products"
- order = pyblish.api.CollectorOrder + 0.4
+ # This specific order value is used so that
+ # this plugin runs after CollectRopFrameRange
+ order = pyblish.api.CollectorOrder + 0.4999
hosts = ["houdini"]
families = ["karma_rop"]
@@ -95,8 +97,9 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin):
return path
expected_files = []
- start = instance.data["frameStart"]
- end = instance.data["frameEnd"]
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
+
for i in range(int(start), (int(end) + 1)):
expected_files.append(
os.path.join(dir, (file % i)).replace("\\", "/"))
diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py
index c4460f5350..cc412f30a1 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py
@@ -24,7 +24,9 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin):
"""
label = "Mantra ROP Render Products"
- order = pyblish.api.CollectorOrder + 0.4
+ # This specific order value is used so that
+ # this plugin runs after CollectRopFrameRange
+ order = pyblish.api.CollectorOrder + 0.4999
hosts = ["houdini"]
families = ["mantra_rop"]
@@ -118,8 +120,9 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin):
return path
expected_files = []
- start = instance.data["frameStart"]
- end = instance.data["frameEnd"]
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
+
for i in range(int(start), (int(end) + 1)):
expected_files.append(
os.path.join(dir, (file % i)).replace("\\", "/"))
diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py
index dbb15ab88f..deb9eac971 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py
@@ -24,7 +24,9 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin):
"""
label = "Redshift ROP Render Products"
- order = pyblish.api.CollectorOrder + 0.4
+ # This specific order value is used so that
+ # this plugin runs after CollectRopFrameRange
+ order = pyblish.api.CollectorOrder + 0.4999
hosts = ["houdini"]
families = ["redshift_rop"]
@@ -132,8 +134,9 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin):
return path
expected_files = []
- start = instance.data["frameStart"]
- end = instance.data["frameEnd"]
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
+
for i in range(int(start), (int(end) + 1)):
expected_files.append(
os.path.join(dir, (file % i)).replace("\\", "/"))
diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py
index 2a6be6b9f1..186244fedd 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py
@@ -2,40 +2,106 @@
"""Collector plugin for frames data on ROP instances."""
import hou # noqa
import pyblish.api
+from openpype.lib import BoolDef
from openpype.hosts.houdini.api import lib
+from openpype.pipeline import OpenPypePyblishPluginMixin
-class CollectRopFrameRange(pyblish.api.InstancePlugin):
+class CollectRopFrameRange(pyblish.api.InstancePlugin,
+ OpenPypePyblishPluginMixin):
+
"""Collect all frames which would be saved from the ROP nodes"""
- order = pyblish.api.CollectorOrder
+ hosts = ["houdini"]
+ # This specific order value is used so that
+ # this plugin runs after CollectAnatomyInstanceData
+ order = pyblish.api.CollectorOrder + 0.499
label = "Collect RopNode Frame Range"
+ use_asset_handles = True
def process(self, instance):
node_path = instance.data.get("instance_node")
if node_path is None:
# Instance without instance node like a workfile instance
+ self.log.debug(
+ "No instance node found for instance: {}".format(instance)
+ )
return
ropnode = hou.node(node_path)
- frame_data = lib.get_frame_data(ropnode)
- if "frameStart" in frame_data and "frameEnd" in frame_data:
+ attr_values = self.get_attr_values_from_data(instance.data)
- # Log artist friendly message about the collected frame range
- message = (
- "Frame range {0[frameStart]} - {0[frameEnd]}"
- ).format(frame_data)
- if frame_data.get("step", 1.0) != 1.0:
- message += " with step {0[step]}".format(frame_data)
- self.log.info(message)
+ if attr_values.get("use_handles", self.use_asset_handles):
+ asset_data = instance.data["assetEntity"]["data"]
+ handle_start = asset_data.get("handleStart", 0)
+ handle_end = asset_data.get("handleEnd", 0)
+ else:
+ handle_start = 0
+ handle_end = 0
- instance.data.update(frame_data)
+ frame_data = lib.get_frame_data(
+ ropnode, handle_start, handle_end, self.log
+ )
- # Add frame range to label if the instance has a frame range.
- label = instance.data.get("label", instance.data["name"])
- instance.data["label"] = (
- "{0} [{1[frameStart]} - {1[frameEnd]}]".format(label,
- frame_data)
+ if not frame_data:
+ return
+
+ # Log debug message about the collected frame range
+ frame_start = frame_data["frameStart"]
+ frame_end = frame_data["frameEnd"]
+
+ if attr_values.get("use_handles", self.use_asset_handles):
+ self.log.debug(
+ "Full Frame range with Handles "
+ "[{frame_start_handle} - {frame_end_handle}]"
+ .format(
+ frame_start_handle=frame_data["frameStartHandle"],
+ frame_end_handle=frame_data["frameEndHandle"]
+ )
)
+ else:
+ self.log.debug(
+ "Use handles is deactivated for this instance, "
+ "start and end handles are set to 0."
+ )
+
+ # Log collected frame range to the user
+ message = "Frame range [{frame_start} - {frame_end}]".format(
+ frame_start=frame_start,
+ frame_end=frame_end
+ )
+ if handle_start or handle_end:
+ message += " with handles [{handle_start}]-[{handle_end}]".format(
+ handle_start=handle_start,
+ handle_end=handle_end
+ )
+ self.log.info(message)
+
+ if frame_data.get("byFrameStep", 1.0) != 1.0:
+ self.log.info("Frame steps {}".format(frame_data["byFrameStep"]))
+
+ instance.data.update(frame_data)
+
+ # Add frame range to label if the instance has a frame range.
+ label = instance.data.get("label", instance.data["name"])
+ instance.data["label"] = (
+ "{label} [{frame_start} - {frame_end}]"
+ .format(
+ label=label,
+ frame_start=frame_start,
+ frame_end=frame_end
+ )
+ )
+
+ @classmethod
+ def get_attribute_defs(cls):
+ return [
+ BoolDef("use_handles",
+ tooltip="Disable this if you want the publisher to"
+ " ignore start and end handles specified in the"
+ " asset data for this publish instance",
+ default=cls.use_asset_handles,
+ label="Use asset handles")
+ ]
diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
index 277f922ba4..53072aebc6 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
@@ -24,7 +24,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin):
"""
label = "VRay ROP Render Products"
- order = pyblish.api.CollectorOrder + 0.4
+ # This specific order value is used so that
+ # this plugin runs after CollectRopFrameRange
+ order = pyblish.api.CollectorOrder + 0.4999
hosts = ["houdini"]
families = ["vray_rop"]
@@ -115,8 +117,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin):
return path
expected_files = []
- start = instance.data["frameStart"]
- end = instance.data["frameEnd"]
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
+
for i in range(int(start), (int(end) + 1)):
expected_files.append(
os.path.join(dir, (file % i)).replace("\\", "/"))
diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py
new file mode 100644
index 0000000000..6a66f3de9f
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+from openpype.pipeline.publish import RepairAction
+from openpype.hosts.houdini.api.action import SelectInvalidAction
+
+import hou
+
+
+class DisableUseAssetHandlesAction(RepairAction):
+ label = "Disable use asset handles"
+ icon = "mdi.toggle-switch-off"
+
+
+class ValidateFrameRange(pyblish.api.InstancePlugin):
+ """Validate Frame Range.
+
+ Due to the usage of start and end handles,
+ then Frame Range must be >= (start handle + end handle)
+ which results that frameEnd be smaller than frameStart
+ """
+
+ order = pyblish.api.ValidatorOrder - 0.1
+ hosts = ["houdini"]
+ label = "Validate Frame Range"
+ actions = [DisableUseAssetHandlesAction, SelectInvalidAction]
+
+ def process(self, instance):
+
+ invalid = self.get_invalid(instance)
+ if invalid:
+ raise PublishValidationError(
+ title="Invalid Frame Range",
+ message=(
+ "Invalid frame range because the instance "
+ "start frame ({0[frameStart]}) is higher than "
+ "the end frame ({0[frameEnd]})"
+ .format(instance.data)
+ ),
+ description=(
+ "## Invalid Frame Range\n"
+ "The frame range for the instance is invalid because "
+ "the start frame is higher than the end frame.\n\nThis "
+ "is likely due to asset handles being applied to your "
+ "instance or the ROP node's start frame "
+ "is set higher than the end frame.\n\nIf your ROP frame "
+ "range is correct and you do not want to apply asset "
+ "handles make sure to disable Use asset handles on the "
+ "publish instance."
+ )
+ )
+
+ @classmethod
+ def get_invalid(cls, instance):
+
+ if not instance.data.get("instance_node"):
+ return
+
+ rop_node = hou.node(instance.data["instance_node"])
+ if instance.data["frameStart"] > instance.data["frameEnd"]:
+ cls.log.info(
+ "The ROP node render range is set to "
+ "{0[frameStartHandle]} - {0[frameEndHandle]} "
+ "The asset handles applied to the instance are start handle "
+ "{0[handleStart]} and end handle {0[handleEnd]}"
+ .format(instance.data)
+ )
+ return [rop_node]
+
+ @classmethod
+ def repair(cls, instance):
+
+ if not cls.get_invalid(instance):
+ # Already fixed
+ return
+
+ # Disable use asset handles
+ context = instance.context
+ create_context = context.data["create_context"]
+ instance_id = instance.data.get("instance_id")
+ if not instance_id:
+ cls.log.debug("'{}' must have instance id"
+ .format(instance))
+ return
+
+ created_instance = create_context.get_instance_by_id(instance_id)
+ if not instance_id:
+ cls.log.debug("Unable to find instance '{}' by id"
+ .format(instance))
+ return
+
+ created_instance.publish_attributes["CollectRopFrameRange"]["use_handles"] = False # noqa
+
+ create_context.save_changes()
+ cls.log.debug("use asset handles is turned off for '{}'"
+ .format(instance))
diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py
index 8b70b3ced7..cbaf8a0c33 100644
--- a/openpype/hosts/max/api/lib.py
+++ b/openpype/hosts/max/api/lib.py
@@ -234,27 +234,40 @@ def reset_scene_resolution():
set_scene_resolution(width, height)
-def get_frame_range() -> Union[Dict[str, Any], None]:
+def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]:
"""Get the current assets frame range and handles.
+ Args:
+ asset_doc (dict): Asset Entity Data
+
Returns:
dict: with frame start, frame end, handle start, handle end.
"""
# Set frame start/end
- asset = get_current_project_asset()
- frame_start = asset["data"].get("frameStart")
- frame_end = asset["data"].get("frameEnd")
+ if asset_doc is None:
+ asset_doc = get_current_project_asset()
+
+ data = asset_doc["data"]
+ frame_start = data.get("frameStart")
+ frame_end = data.get("frameEnd")
if frame_start is None or frame_end is None:
- return
+ return {}
+
+ frame_start = int(frame_start)
+ frame_end = int(frame_end)
+ handle_start = int(data.get("handleStart", 0))
+ handle_end = int(data.get("handleEnd", 0))
+ frame_start_handle = frame_start - handle_start
+ frame_end_handle = frame_end + handle_end
- handle_start = asset["data"].get("handleStart", 0)
- handle_end = asset["data"].get("handleEnd", 0)
return {
"frameStart": frame_start,
"frameEnd": frame_end,
"handleStart": handle_start,
- "handleEnd": handle_end
+ "handleEnd": handle_end,
+ "frameStartHandle": frame_start_handle,
+ "frameEndHandle": frame_end_handle,
}
@@ -274,12 +287,11 @@ def reset_frame_range(fps: bool = True):
fps_number = float(data_fps["data"]["fps"])
rt.frameRate = fps_number
frame_range = get_frame_range()
- frame_start_handle = frame_range["frameStart"] - int(
- frame_range["handleStart"]
- )
- frame_end_handle = frame_range["frameEnd"] + int(frame_range["handleEnd"])
- set_timeline(frame_start_handle, frame_end_handle)
- set_render_frame_range(frame_start_handle, frame_end_handle)
+
+ set_timeline(
+ frame_range["frameStartHandle"], frame_range["frameEndHandle"])
+ set_render_frame_range(
+ frame_range["frameStartHandle"], frame_range["frameEndHandle"])
def set_context_setting():
@@ -321,21 +333,6 @@ def is_headless():
return rt.maxops.isInNonInteractiveMode()
-@contextlib.contextmanager
-def viewport_camera(camera):
- original = rt.viewport.getCamera()
- if not original:
- # if there is no original camera
- # use the current camera as original
- original = rt.getNodeByName(camera)
- review_camera = rt.getNodeByName(camera)
- try:
- rt.viewport.setCamera(review_camera)
- yield
- finally:
- rt.viewport.setCamera(original)
-
-
def set_timeline(frameStart, frameEnd):
"""Set frame range for timeline editor in Max
"""
@@ -497,3 +494,22 @@ def get_plugins() -> list:
plugin_info_list.append(plugin_info)
return plugin_info_list
+
+
+@contextlib.contextmanager
+def render_resolution(width, height):
+ """Set render resolution option during context
+
+ Args:
+ width (int): render width
+ height (int): render height
+ """
+ current_renderWidth = rt.renderWidth
+ current_renderHeight = rt.renderHeight
+ try:
+ rt.renderWidth = width
+ rt.renderHeight = height
+ yield
+ finally:
+ rt.renderWidth = current_renderWidth
+ rt.renderHeight = current_renderHeight
diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py
new file mode 100644
index 0000000000..1bf99b86d0
--- /dev/null
+++ b/openpype/hosts/max/api/preview_animation.py
@@ -0,0 +1,309 @@
+import logging
+import contextlib
+from pymxs import runtime as rt
+from .lib import get_max_version, render_resolution
+
+log = logging.getLogger("openpype.hosts.max")
+
+
+@contextlib.contextmanager
+def play_preview_when_done(has_autoplay):
+ """Set preview playback option during context
+
+ Args:
+ has_autoplay (bool): autoplay during creating
+ preview animation
+ """
+ current_playback = rt.preferences.playPreviewWhenDone
+ try:
+ rt.preferences.playPreviewWhenDone = has_autoplay
+ yield
+ finally:
+ rt.preferences.playPreviewWhenDone = current_playback
+
+
+@contextlib.contextmanager
+def viewport_camera(camera):
+ """Set viewport camera during context
+ ***For 3dsMax 2024+
+ Args:
+ camera (str): viewport camera
+ """
+ original = rt.viewport.getCamera()
+ if not original:
+ # if there is no original camera
+ # use the current camera as original
+ original = rt.getNodeByName(camera)
+ review_camera = rt.getNodeByName(camera)
+ try:
+ rt.viewport.setCamera(review_camera)
+ yield
+ finally:
+ rt.viewport.setCamera(original)
+
+
+@contextlib.contextmanager
+def viewport_preference_setting(general_viewport,
+ nitrous_viewport,
+ vp_button_mgr):
+ """Function to set viewport setting during context
+ ***For Max Version < 2024
+ Args:
+ camera (str): Viewport camera for review render
+ general_viewport (dict): General viewport setting
+ nitrous_viewport (dict): Nitrous setting for
+ preview animation
+ vp_button_mgr (dict): Viewport button manager Setting
+ preview_preferences (dict): Preview Preferences Setting
+ """
+ orig_vp_grid = rt.viewport.getGridVisibility(1)
+ orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode()
+
+ nitrousGraphicMgr = rt.NitrousGraphicsManager
+ viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting()
+ vp_button_mgr_original = {
+ key: getattr(rt.ViewportButtonMgr, key) for key in vp_button_mgr
+ }
+ nitrous_viewport_original = {
+ key: getattr(viewport_setting, key) for key in nitrous_viewport
+ }
+
+ try:
+ rt.viewport.setGridVisibility(1, general_viewport["dspGrid"])
+ rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"])
+ for key, value in vp_button_mgr.items():
+ setattr(rt.ViewportButtonMgr, key, value)
+ for key, value in nitrous_viewport.items():
+ if nitrous_viewport[key] != nitrous_viewport_original[key]:
+ setattr(viewport_setting, key, value)
+ yield
+
+ finally:
+ rt.viewport.setGridVisibility(1, orig_vp_grid)
+ rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg)
+ for key, value in vp_button_mgr_original.items():
+ setattr(rt.ViewportButtonMgr, key, value)
+ for key, value in nitrous_viewport_original.items():
+ setattr(viewport_setting, key, value)
+
+
+def _render_preview_animation_max_2024(
+ filepath, start, end, percentSize, ext, viewport_options):
+ """Render viewport preview with MaxScript using `CreateAnimation`.
+ ****For 3dsMax 2024+
+ Args:
+ filepath (str): filepath for render output without frame number and
+ extension, for example: /path/to/file
+ start (int): startFrame
+ end (int): endFrame
+ percentSize (float): render resolution multiplier by 100
+ e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x
+ viewport_options (dict): viewport setting options, e.g.
+ {"vpStyle": "defaultshading", "vpPreset": "highquality"}
+ Returns:
+ list: Created files
+ """
+ # the percentSize argument must be integer
+ percent = int(percentSize)
+ filepath = filepath.replace("\\", "/")
+ preview_output = f"{filepath}..{ext}"
+ frame_template = f"{filepath}.{{:04d}}.{ext}"
+ job_args = []
+ for key, value in viewport_options.items():
+ if isinstance(value, bool):
+ if value:
+ job_args.append(f"{key}:{value}")
+ elif isinstance(value, str):
+ if key == "vpStyle":
+ if value == "Realistic":
+ value = "defaultshading"
+ elif value == "Shaded":
+ log.warning(
+ "'Shaded' Mode not supported in "
+ "preview animation in Max 2024.\n"
+ "Using 'defaultshading' instead.")
+ value = "defaultshading"
+ elif value == "ConsistentColors":
+ value = "flatcolor"
+ else:
+ value = value.lower()
+ elif key == "vpPreset":
+ if value == "Quality":
+ value = "highquality"
+ elif value == "Customize":
+ value = "userdefined"
+ else:
+ value = value.lower()
+ job_args.append(f"{key}: #{value}")
+
+ job_str = (
+ f'CreatePreview filename:"{preview_output}" outputAVI:false '
+ f"percentSize:{percent} start:{start} end:{end} "
+ f"{' '.join(job_args)} "
+ "autoPlay:false"
+ )
+ rt.completeRedraw()
+ rt.execute(job_str)
+ # Return the created files
+ return [frame_template.format(frame) for frame in range(start, end + 1)]
+
+
+def _render_preview_animation_max_pre_2024(
+ filepath, startFrame, endFrame, percentSize, ext):
+ """Render viewport animation by creating bitmaps
+ ***For 3dsMax Version <2024
+ Args:
+ filepath (str): filepath without frame numbers and extension
+ startFrame (int): start frame
+ endFrame (int): end frame
+ percentSize (float): render resolution multiplier by 100
+ e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x
+ ext (str): image extension
+ Returns:
+ list: Created filepaths
+ """
+ # get the screenshot
+ percent = percentSize / 100.0
+ res_width = int(round(rt.renderWidth * percent))
+ res_height = int(round(rt.renderHeight * percent))
+ viewportRatio = float(res_width / res_height)
+ frame_template = "{}.{{:04}}.{}".format(filepath, ext)
+ frame_template.replace("\\", "/")
+ files = []
+ user_cancelled = False
+ for frame in range(startFrame, endFrame + 1):
+ rt.sliderTime = frame
+ filepath = frame_template.format(frame)
+ preview_res = rt.bitmap(
+ res_width, res_height, filename=filepath
+ )
+ dib = rt.gw.getViewportDib()
+ dib_width = float(dib.width)
+ dib_height = float(dib.height)
+ renderRatio = float(dib_width / dib_height)
+ if viewportRatio <= renderRatio:
+ heightCrop = (dib_width / renderRatio)
+ topEdge = int((dib_height - heightCrop) / 2.0)
+ tempImage_bmp = rt.bitmap(dib_width, heightCrop)
+ src_box_value = rt.Box2(0, topEdge, dib_width, heightCrop)
+ else:
+ widthCrop = dib_height * renderRatio
+ leftEdge = int((dib_width - widthCrop) / 2.0)
+ tempImage_bmp = rt.bitmap(widthCrop, dib_height)
+ src_box_value = rt.Box2(0, leftEdge, dib_width, dib_height)
+ rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0))
+ # copy the bitmap and close it
+ rt.copy(tempImage_bmp, preview_res)
+ rt.close(tempImage_bmp)
+ rt.save(preview_res)
+ rt.close(preview_res)
+ rt.close(dib)
+ files.append(filepath)
+ if rt.keyboard.escPressed:
+ user_cancelled = True
+ break
+ # clean up the cache
+ rt.gc(delayed=True)
+ if user_cancelled:
+ raise RuntimeError("User cancelled rendering of viewport animation.")
+ return files
+
+
+def render_preview_animation(
+ filepath,
+ ext,
+ camera,
+ start_frame=None,
+ end_frame=None,
+ percentSize=100.0,
+ width=1920,
+ height=1080,
+ viewport_options=None):
+ """Render camera review animation
+ Args:
+ filepath (str): filepath to render to, without frame number and
+ extension
+ ext (str): output file extension
+ camera (str): viewport camera for preview render
+ start_frame (int): start frame
+ end_frame (int): end frame
+ percentSize (float): render resolution multiplier by 100
+ e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x
+ width (int): render resolution width
+ height (int): render resolution height
+ viewport_options (dict): viewport setting options
+ Returns:
+ list: Rendered output files
+ """
+ if start_frame is None:
+ start_frame = int(rt.animationRange.start)
+ if end_frame is None:
+ end_frame = int(rt.animationRange.end)
+
+ if viewport_options is None:
+ viewport_options = viewport_options_for_preview_animation()
+ with play_preview_when_done(False):
+ with viewport_camera(camera):
+ with render_resolution(width, height):
+ if int(get_max_version()) < 2024:
+ with viewport_preference_setting(
+ viewport_options["general_viewport"],
+ viewport_options["nitrous_viewport"],
+ viewport_options["vp_btn_mgr"]
+ ):
+ return _render_preview_animation_max_pre_2024(
+ filepath,
+ start_frame,
+ end_frame,
+ percentSize,
+ ext
+ )
+ else:
+ return _render_preview_animation_max_2024(
+ filepath,
+ start_frame,
+ end_frame,
+ percentSize,
+ ext,
+ viewport_options
+ )
+
+
+def viewport_options_for_preview_animation():
+ """Get default viewport options for `render_preview_animation`.
+
+ Returns:
+ dict: viewport setting options
+ """
+ # viewport_options should be the dictionary
+ if int(get_max_version()) < 2024:
+ return {
+ "visualStyleMode": "defaultshading",
+ "viewportPreset": "highquality",
+ "vpTexture": False,
+ "dspGeometry": True,
+ "dspShapes": False,
+ "dspLights": False,
+ "dspCameras": False,
+ "dspHelpers": False,
+ "dspParticles": True,
+ "dspBones": False,
+ "dspBkg": True,
+ "dspGrid": False,
+ "dspSafeFrame": False,
+ "dspFrameNums": False
+ }
+ else:
+ viewport_options = {}
+ viewport_options["general_viewport"] = {
+ "dspBkg": True,
+ "dspGrid": False
+ }
+ viewport_options["nitrous_viewport"] = {
+ "VisualStyleMode": "defaultshading",
+ "ViewportPreset": "highquality",
+ "UseTextureEnabled": False
+ }
+ viewport_options["vp_btn_mgr"] = {
+ "EnableButtons": False}
+ return viewport_options
diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py
index 5737114fcc..8052b74f06 100644
--- a/openpype/hosts/max/plugins/create/create_review.py
+++ b/openpype/hosts/max/plugins/create/create_review.py
@@ -13,31 +13,50 @@ class CreateReview(plugin.MaxCreator):
icon = "video-camera"
def create(self, subset_name, instance_data, pre_create_data):
-
- instance_data["imageFormat"] = pre_create_data.get("imageFormat")
- instance_data["keepImages"] = pre_create_data.get("keepImages")
- instance_data["percentSize"] = pre_create_data.get("percentSize")
- instance_data["rndLevel"] = pre_create_data.get("rndLevel")
+ # Transfer settings from pre create to instance
+ creator_attributes = instance_data.setdefault(
+ "creator_attributes", dict())
+ for key in ["imageFormat",
+ "keepImages",
+ "review_width",
+ "review_height",
+ "percentSize",
+ "visualStyleMode",
+ "viewportPreset",
+ "vpTexture"]:
+ if key in pre_create_data:
+ creator_attributes[key] = pre_create_data[key]
super(CreateReview, self).create(
subset_name,
instance_data,
pre_create_data)
- def get_pre_create_attr_defs(self):
- attrs = super(CreateReview, self).get_pre_create_attr_defs()
+ def get_instance_attr_defs(self):
+ image_format_enum = ["exr", "jpg", "png"]
- image_format_enum = [
- "bmp", "cin", "exr", "jpg", "hdr", "rgb", "png",
- "rla", "rpf", "dds", "sgi", "tga", "tif", "vrimg"
+ visual_style_preset_enum = [
+ "Realistic", "Shaded", "Facets",
+ "ConsistentColors", "HiddenLine",
+ "Wireframe", "BoundingBox", "Ink",
+ "ColorInk", "Acrylic", "Tech", "Graphite",
+ "ColorPencil", "Pastel", "Clay", "ModelAssist"
]
+ preview_preset_enum = [
+ "Quality", "Standard", "Performance",
+ "DXMode", "Customize"]
- rndLevel_enum = [
- "smoothhighlights", "smooth", "facethighlights",
- "facet", "flat", "litwireframe", "wireframe", "box"
- ]
-
- return attrs + [
+ return [
+ NumberDef("review_width",
+ label="Review width",
+ decimals=0,
+ minimum=0,
+ default=1920),
+ NumberDef("review_height",
+ label="Review height",
+ decimals=0,
+ minimum=0,
+ default=1080),
BoolDef("keepImages",
label="Keep Image Sequences",
default=False),
@@ -50,8 +69,20 @@ class CreateReview(plugin.MaxCreator):
default=100,
minimum=1,
decimals=0),
- EnumDef("rndLevel",
- rndLevel_enum,
- default="smoothhighlights",
- label="Preference")
+ EnumDef("visualStyleMode",
+ visual_style_preset_enum,
+ default="Realistic",
+ label="Preference"),
+ EnumDef("viewportPreset",
+ preview_preset_enum,
+ default="Quality",
+ label="Pre-View Preset"),
+ BoolDef("vpTexture",
+ label="Viewport Texture",
+ default=False)
]
+
+ def get_pre_create_attr_defs(self):
+ # Use same attributes as for instance attributes
+ attrs = super().get_pre_create_attr_defs()
+ return attrs + self.get_instance_attr_defs()
diff --git a/openpype/hosts/max/plugins/create/create_tycache.py b/openpype/hosts/max/plugins/create/create_tycache.py
new file mode 100644
index 0000000000..92d12e012f
--- /dev/null
+++ b/openpype/hosts/max/plugins/create/create_tycache.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+"""Creator plugin for creating TyCache."""
+from openpype.hosts.max.api import plugin
+
+
+class CreateTyCache(plugin.MaxCreator):
+ """Creator plugin for TyCache."""
+ identifier = "io.openpype.creators.max.tycache"
+ label = "TyCache"
+ family = "tycache"
+ icon = "gear"
diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py
new file mode 100644
index 0000000000..41ea267c3d
--- /dev/null
+++ b/openpype/hosts/max/plugins/load/load_tycache.py
@@ -0,0 +1,64 @@
+import os
+from openpype.hosts.max.api import lib, maintained_selection
+from openpype.hosts.max.api.lib import (
+ unique_namespace,
+
+)
+from openpype.hosts.max.api.pipeline import (
+ containerise,
+ get_previous_loaded_object,
+ update_custom_attribute_data
+)
+from openpype.pipeline import get_representation_path, load
+
+
+class TyCacheLoader(load.LoaderPlugin):
+ """TyCache Loader."""
+
+ families = ["tycache"]
+ representations = ["tyc"]
+ order = -8
+ icon = "code-fork"
+ color = "green"
+
+ def load(self, context, name=None, namespace=None, data=None):
+ """Load tyCache"""
+ from pymxs import runtime as rt
+ filepath = os.path.normpath(self.filepath_from_context(context))
+ obj = rt.tyCache()
+ obj.filename = filepath
+
+ namespace = unique_namespace(
+ name + "_",
+ suffix="_",
+ )
+ obj.name = f"{namespace}:{obj.name}"
+
+ return containerise(
+ name, [obj], context,
+ namespace, loader=self.__class__.__name__)
+
+ def update(self, container, representation):
+ """update the container"""
+ from pymxs import runtime as rt
+
+ path = get_representation_path(representation)
+ node = rt.GetNodeByName(container["instance_node"])
+ node_list = get_previous_loaded_object(node)
+ update_custom_attribute_data(node, node_list)
+ with maintained_selection():
+ for tyc in node_list:
+ tyc.filename = path
+ lib.imprint(container["instance_node"], {
+ "representation": str(representation["_id"])
+ })
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def remove(self, container):
+ """remove the container"""
+ from pymxs import runtime as rt
+
+ node = rt.GetNodeByName(container["instance_node"])
+ rt.Delete(node)
diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py
new file mode 100644
index 0000000000..86fb6e856c
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+import pyblish.api
+from pymxs import runtime as rt
+
+
+class CollectFrameRange(pyblish.api.InstancePlugin):
+ """Collect Frame Range."""
+
+ order = pyblish.api.CollectorOrder + 0.01
+ label = "Collect Frame Range"
+ hosts = ['max']
+ families = ["camera", "maxrender",
+ "pointcache", "pointcloud",
+ "review", "redshiftproxy"]
+
+ def process(self, instance):
+ if instance.data["family"] == "maxrender":
+ instance.data["frameStartHandle"] = int(rt.rendStart)
+ instance.data["frameEndHandle"] = int(rt.rendEnd)
+ else:
+ instance.data["frameStartHandle"] = int(rt.animationRange.start)
+ instance.data["frameEndHandle"] = int(rt.animationRange.end)
diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py
index a359e61921..7765b3b924 100644
--- a/openpype/hosts/max/plugins/publish/collect_render.py
+++ b/openpype/hosts/max/plugins/publish/collect_render.py
@@ -14,7 +14,7 @@ from openpype.client import get_last_version_by_subset_name
class CollectRender(pyblish.api.InstancePlugin):
"""Collect Render for Deadline"""
- order = pyblish.api.CollectorOrder + 0.01
+ order = pyblish.api.CollectorOrder + 0.02
label = "Collect 3dsmax Render Layers"
hosts = ['max']
families = ["maxrender"]
@@ -97,8 +97,8 @@ class CollectRender(pyblish.api.InstancePlugin):
"renderer": renderer,
"source": filepath,
"plugin": "3dsmax",
- "frameStart": int(rt.rendStart),
- "frameEnd": int(rt.rendEnd),
+ "frameStart": instance.data["frameStartHandle"],
+ "frameEnd": instance.data["frameEndHandle"],
"version": version_int,
"farm": True
}
diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py
index 8e27a857d7..b1d9c2d25e 100644
--- a/openpype/hosts/max/plugins/publish/collect_review.py
+++ b/openpype/hosts/max/plugins/publish/collect_review.py
@@ -5,7 +5,10 @@ import pyblish.api
from pymxs import runtime as rt
from openpype.lib import BoolDef
from openpype.hosts.max.api.lib import get_max_version
-from openpype.pipeline.publish import OpenPypePyblishPluginMixin
+from openpype.pipeline.publish import (
+ OpenPypePyblishPluginMixin,
+ KnownPublishError
+)
class CollectReview(pyblish.api.InstancePlugin,
@@ -19,30 +22,41 @@ class CollectReview(pyblish.api.InstancePlugin,
def process(self, instance):
nodes = instance.data["members"]
- focal_length = None
- camera_name = None
- for node in nodes:
- if rt.classOf(node) in rt.Camera.classes:
- camera_name = node.name
- focal_length = node.fov
+ def is_camera(node):
+ is_camera_class = rt.classOf(node) in rt.Camera.classes
+ return is_camera_class and rt.isProperty(node, "fov")
+
+ # Use first camera in instance
+ cameras = [node for node in nodes if is_camera(node)]
+ if cameras:
+ if len(cameras) > 1:
+ self.log.warning(
+ "Found more than one camera in instance, using first "
+ f"one found: {cameras[0]}"
+ )
+ camera = cameras[0]
+ camera_name = camera.name
+ focal_length = camera.fov
+ else:
+ raise KnownPublishError(
+ "Unable to find a valid camera in 'Review' container."
+ " Only native max Camera supported. "
+ f"Found objects: {nodes}"
+ )
+ creator_attrs = instance.data["creator_attributes"]
attr_values = self.get_attr_values_from_data(instance.data)
- data = {
+
+ general_preview_data = {
"review_camera": camera_name,
- "frameStart": instance.context.data["frameStart"],
- "frameEnd": instance.context.data["frameEnd"],
+ "frameStart": instance.data["frameStartHandle"],
+ "frameEnd": instance.data["frameEndHandle"],
+ "percentSize": creator_attrs["percentSize"],
+ "imageFormat": creator_attrs["imageFormat"],
+ "keepImages": creator_attrs["keepImages"],
"fps": instance.context.data["fps"],
- "dspGeometry": attr_values.get("dspGeometry"),
- "dspShapes": attr_values.get("dspShapes"),
- "dspLights": attr_values.get("dspLights"),
- "dspCameras": attr_values.get("dspCameras"),
- "dspHelpers": attr_values.get("dspHelpers"),
- "dspParticles": attr_values.get("dspParticles"),
- "dspBones": attr_values.get("dspBones"),
- "dspBkg": attr_values.get("dspBkg"),
- "dspGrid": attr_values.get("dspGrid"),
- "dspSafeFrame": attr_values.get("dspSafeFrame"),
- "dspFrameNums": attr_values.get("dspFrameNums")
+ "review_width": creator_attrs["review_width"],
+ "review_height": creator_attrs["review_height"],
}
if int(get_max_version()) >= 2024:
@@ -55,14 +69,46 @@ class CollectReview(pyblish.api.InstancePlugin,
instance.data["colorspaceDisplay"] = display
instance.data["colorspaceView"] = view_transform
+ preview_data = {
+ "vpStyle": creator_attrs["visualStyleMode"],
+ "vpPreset": creator_attrs["viewportPreset"],
+ "vpTextures": creator_attrs["vpTexture"],
+ "dspGeometry": attr_values.get("dspGeometry"),
+ "dspShapes": attr_values.get("dspShapes"),
+ "dspLights": attr_values.get("dspLights"),
+ "dspCameras": attr_values.get("dspCameras"),
+ "dspHelpers": attr_values.get("dspHelpers"),
+ "dspParticles": attr_values.get("dspParticles"),
+ "dspBones": attr_values.get("dspBones"),
+ "dspBkg": attr_values.get("dspBkg"),
+ "dspGrid": attr_values.get("dspGrid"),
+ "dspSafeFrame": attr_values.get("dspSafeFrame"),
+ "dspFrameNums": attr_values.get("dspFrameNums")
+ }
+ else:
+ general_viewport = {
+ "dspBkg": attr_values.get("dspBkg"),
+ "dspGrid": attr_values.get("dspGrid")
+ }
+ nitrous_viewport = {
+ "VisualStyleMode": creator_attrs["visualStyleMode"],
+ "ViewportPreset": creator_attrs["viewportPreset"],
+ "UseTextureEnabled": creator_attrs["vpTexture"]
+ }
+ preview_data = {
+ "general_viewport": general_viewport,
+ "nitrous_viewport": nitrous_viewport,
+ "vp_btn_mgr": {"EnableButtons": False}
+ }
+
# Enable ftrack functionality
instance.data.setdefault("families", []).append('ftrack')
burnin_members = instance.data.setdefault("burninDataMembers", {})
burnin_members["focalLength"] = focal_length
- self.log.debug(f"data:{data}")
- instance.data.update(data)
+ instance.data.update(general_preview_data)
+ instance.data["viewport_options"] = preview_data
@classmethod
def get_attribute_defs(cls):
diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py
new file mode 100644
index 0000000000..0351ca45c5
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py
@@ -0,0 +1,76 @@
+import pyblish.api
+
+from openpype.lib import EnumDef, TextDef
+from openpype.pipeline.publish import OpenPypePyblishPluginMixin
+
+
+class CollectTyCacheData(pyblish.api.InstancePlugin,
+ OpenPypePyblishPluginMixin):
+ """Collect Channel Attributes for TyCache Export"""
+
+ order = pyblish.api.CollectorOrder + 0.02
+ label = "Collect tyCache attribute Data"
+ hosts = ['max']
+ families = ["tycache"]
+
+ def process(self, instance):
+ attr_values = self.get_attr_values_from_data(instance.data)
+ attributes = {}
+ for attr_key in attr_values.get("tycacheAttributes", []):
+ attributes[attr_key] = True
+
+ for key in ["tycacheLayer", "tycacheObjectName"]:
+ attributes[key] = attr_values.get(key, "")
+
+ # Collect the selected channel data before exporting
+ instance.data["tyc_attrs"] = attributes
+ self.log.debug(
+ f"Found tycache attributes: {attributes}"
+ )
+
+ @classmethod
+ def get_attribute_defs(cls):
+ # TODO: Support the attributes with maxObject array
+ tyc_attr_enum = ["tycacheChanAge", "tycacheChanGroups",
+ "tycacheChanPos", "tycacheChanRot",
+ "tycacheChanScale", "tycacheChanVel",
+ "tycacheChanSpin", "tycacheChanShape",
+ "tycacheChanMatID", "tycacheChanMapping",
+ "tycacheChanMaterials", "tycacheChanCustomFloat"
+ "tycacheChanCustomVector", "tycacheChanCustomTM",
+ "tycacheChanPhysX", "tycacheMeshBackup",
+ "tycacheCreateObject",
+ "tycacheCreateObjectIfNotCreated",
+ "tycacheAdditionalCloth",
+ "tycacheAdditionalSkin",
+ "tycacheAdditionalSkinID",
+ "tycacheAdditionalSkinIDValue",
+ "tycacheAdditionalTerrain",
+ "tycacheAdditionalVDB",
+ "tycacheAdditionalSplinePaths",
+ "tycacheAdditionalGeo",
+ "tycacheAdditionalGeoActivateModifiers",
+ "tycacheSplines",
+ "tycacheSplinesAdditionalSplines"
+ ]
+ tyc_default_attrs = ["tycacheChanGroups", "tycacheChanPos",
+ "tycacheChanRot", "tycacheChanScale",
+ "tycacheChanVel", "tycacheChanShape",
+ "tycacheChanMatID", "tycacheChanMapping",
+ "tycacheChanMaterials",
+ "tycacheCreateObjectIfNotCreated"]
+ return [
+ EnumDef("tycacheAttributes",
+ tyc_attr_enum,
+ default=tyc_default_attrs,
+ multiselection=True,
+ label="TyCache Attributes"),
+ TextDef("tycacheLayer",
+ label="TyCache Layer",
+ tooltip="Name of tycache layer",
+ default="$(tyFlowLayer)"),
+ TextDef("tycacheObjectName",
+ label="TyCache Object Name",
+ tooltip="TyCache Object Name",
+ default="$(tyFlowName)_tyCache")
+ ]
diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py
index b1918c53e0..a42f27be6e 100644
--- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py
+++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py
@@ -19,8 +19,8 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin):
def process(self, instance):
if not self.is_active(instance.data):
return
- start = float(instance.data.get("frameStartHandle", 1))
- end = float(instance.data.get("frameEndHandle", 1))
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
self.log.info("Extracting Camera ...")
diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py
index c3de623bc0..f6a8500c08 100644
--- a/openpype/hosts/max/plugins/publish/extract_pointcache.py
+++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py
@@ -51,8 +51,8 @@ class ExtractAlembic(publish.Extractor):
families = ["pointcache"]
def process(self, instance):
- start = float(instance.data.get("frameStartHandle", 1))
- end = float(instance.data.get("frameEndHandle", 1))
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
self.log.debug("Extracting pointcache ...")
diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py
index 190f049d23..d9fbe5e9dd 100644
--- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py
+++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py
@@ -40,8 +40,8 @@ class ExtractPointCloud(publish.Extractor):
def process(self, instance):
self.settings = self.get_setting(instance)
- start = int(instance.context.data.get("frameStart"))
- end = int(instance.context.data.get("frameEnd"))
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
self.log.info("Extracting PRT...")
stagingdir = self.staging_dir(instance)
diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py
index f67ed30c6b..47ed85977b 100644
--- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py
+++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py
@@ -16,8 +16,8 @@ class ExtractRedshiftProxy(publish.Extractor):
families = ["redshiftproxy"]
def process(self, instance):
- start = int(instance.context.data.get("frameStart"))
- end = int(instance.context.data.get("frameEnd"))
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
self.log.debug("Extracting Redshift Proxy...")
stagingdir = self.staging_dir(instance)
diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py
index 8e06e52b5c..99dc5c5cdc 100644
--- a/openpype/hosts/max/plugins/publish/extract_review_animation.py
+++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py
@@ -1,8 +1,9 @@
import os
import pyblish.api
-from pymxs import runtime as rt
from openpype.pipeline import publish
-from openpype.hosts.max.api.lib import viewport_camera, get_max_version
+from openpype.hosts.max.api.preview_animation import (
+ render_preview_animation
+)
class ExtractReviewAnimation(publish.Extractor):
@@ -18,24 +19,26 @@ class ExtractReviewAnimation(publish.Extractor):
def process(self, instance):
staging_dir = self.staging_dir(instance)
ext = instance.data.get("imageFormat")
- filename = "{0}..{1}".format(instance.name, ext)
start = int(instance.data["frameStart"])
end = int(instance.data["frameEnd"])
- fps = int(instance.data["fps"])
- filepath = os.path.join(staging_dir, filename)
- filepath = filepath.replace("\\", "/")
- filenames = self.get_files(
- instance.name, start, end, ext)
-
+ filepath = os.path.join(staging_dir, instance.name)
self.log.debug(
- "Writing Review Animation to"
- " '%s' to '%s'" % (filename, staging_dir))
+ "Writing Review Animation to '{}'".format(filepath))
review_camera = instance.data["review_camera"]
- with viewport_camera(review_camera):
- preview_arg = self.set_preview_arg(
- instance, filepath, start, end, fps)
- rt.execute(preview_arg)
+ viewport_options = instance.data.get("viewport_options", {})
+ files = render_preview_animation(
+ filepath,
+ ext,
+ review_camera,
+ start,
+ end,
+ percentSize=instance.data["percentSize"],
+ width=instance.data["review_width"],
+ height=instance.data["review_height"],
+ viewport_options=viewport_options)
+
+ filenames = [os.path.basename(path) for path in files]
tags = ["review"]
if not instance.data.get("keepImages"):
@@ -48,8 +51,8 @@ class ExtractReviewAnimation(publish.Extractor):
"ext": instance.data["imageFormat"],
"files": filenames,
"stagingDir": staging_dir,
- "frameStart": instance.data["frameStart"],
- "frameEnd": instance.data["frameEnd"],
+ "frameStart": instance.data["frameStartHandle"],
+ "frameEnd": instance.data["frameEndHandle"],
"tags": tags,
"preview": True,
"camera_name": review_camera
@@ -59,44 +62,3 @@ class ExtractReviewAnimation(publish.Extractor):
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(representation)
-
- def get_files(self, filename, start, end, ext):
- file_list = []
- for frame in range(int(start), int(end) + 1):
- actual_name = "{}.{:04}.{}".format(
- filename, frame, ext)
- file_list.append(actual_name)
-
- return file_list
-
- def set_preview_arg(self, instance, filepath,
- start, end, fps):
- job_args = list()
- default_option = f'CreatePreview filename:"{filepath}"'
- job_args.append(default_option)
- frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa
- job_args.append(frame_option)
- rndLevel = instance.data.get("rndLevel")
- if rndLevel:
- option = f"rndLevel:#{rndLevel}"
- job_args.append(option)
- options = [
- "percentSize", "dspGeometry", "dspShapes",
- "dspLights", "dspCameras", "dspHelpers", "dspParticles",
- "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums"
- ]
-
- for key in options:
- enabled = instance.data.get(key)
- if enabled:
- job_args.append(f"{key}:{enabled}")
-
- if get_max_version() == 2024:
- # hardcoded for current stage
- auto_play_option = "autoPlay:false"
- job_args.append(auto_play_option)
-
- job_str = " ".join(job_args)
- self.log.debug(job_str)
-
- return job_str
diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py
index 82f4fc7a8b..02fa75e032 100644
--- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py
+++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py
@@ -1,14 +1,11 @@
import os
-import tempfile
import pyblish.api
-from pymxs import runtime as rt
from openpype.pipeline import publish
-from openpype.hosts.max.api.lib import viewport_camera, get_max_version
+from openpype.hosts.max.api.preview_animation import render_preview_animation
class ExtractThumbnail(publish.Extractor):
- """
- Extract Thumbnail for Review
+ """Extract Thumbnail for Review
"""
order = pyblish.api.ExtractorOrder
@@ -17,34 +14,33 @@ class ExtractThumbnail(publish.Extractor):
families = ["review"]
def process(self, instance):
- # TODO: Create temp directory for thumbnail
- # - this is to avoid "override" of source file
- tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_")
- self.log.debug(
- f"Create temp directory {tmp_staging} for thumbnail"
- )
- fps = int(instance.data["fps"])
+ ext = instance.data.get("imageFormat")
frame = int(instance.data["frameStart"])
- instance.context.data["cleanupFullPaths"].append(tmp_staging)
- filename = "{name}_thumbnail..png".format(**instance.data)
- filepath = os.path.join(tmp_staging, filename)
- filepath = filepath.replace("\\", "/")
- thumbnail = self.get_filename(instance.name, frame)
+ staging_dir = self.staging_dir(instance)
+ filepath = os.path.join(
+ staging_dir, f"{instance.name}_thumbnail")
+ self.log.debug("Writing Thumbnail to '{}'".format(filepath))
- self.log.debug(
- "Writing Thumbnail to"
- " '%s' to '%s'" % (filename, tmp_staging))
review_camera = instance.data["review_camera"]
- with viewport_camera(review_camera):
- preview_arg = self.set_preview_arg(
- instance, filepath, fps, frame)
- rt.execute(preview_arg)
+ viewport_options = instance.data.get("viewport_options", {})
+ files = render_preview_animation(
+ filepath,
+ ext,
+ review_camera,
+ start_frame=frame,
+ end_frame=frame,
+ percentSize=instance.data["percentSize"],
+ width=instance.data["review_width"],
+ height=instance.data["review_height"],
+ viewport_options=viewport_options)
+
+ thumbnail = next(os.path.basename(path) for path in files)
representation = {
"name": "thumbnail",
- "ext": "png",
+ "ext": ext,
"files": thumbnail,
- "stagingDir": tmp_staging,
+ "stagingDir": staging_dir,
"thumbnail": True
}
@@ -53,39 +49,3 @@ class ExtractThumbnail(publish.Extractor):
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(representation)
-
- def get_filename(self, filename, target_frame):
- thumbnail_name = "{}_thumbnail.{:04}.png".format(
- filename, target_frame
- )
- return thumbnail_name
-
- def set_preview_arg(self, instance, filepath, fps, frame):
- job_args = list()
- default_option = f'CreatePreview filename:"{filepath}"'
- job_args.append(default_option)
- frame_option = f"outputAVI:false start:{frame} end:{frame} fps:{fps}" # noqa
- job_args.append(frame_option)
- rndLevel = instance.data.get("rndLevel")
- if rndLevel:
- option = f"rndLevel:#{rndLevel}"
- job_args.append(option)
- options = [
- "percentSize", "dspGeometry", "dspShapes",
- "dspLights", "dspCameras", "dspHelpers", "dspParticles",
- "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums"
- ]
-
- for key in options:
- enabled = instance.data.get(key)
- if enabled:
- job_args.append(f"{key}:{enabled}")
- if get_max_version() == 2024:
- # hardcoded for current stage
- auto_play_option = "autoPlay:false"
- job_args.append(auto_play_option)
-
- job_str = " ".join(job_args)
- self.log.debug(job_str)
-
- return job_str
diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py
new file mode 100644
index 0000000000..9bfe74f679
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/extract_tycache.py
@@ -0,0 +1,157 @@
+import os
+
+import pyblish.api
+from pymxs import runtime as rt
+
+from openpype.hosts.max.api import maintained_selection
+from openpype.pipeline import publish
+
+
+class ExtractTyCache(publish.Extractor):
+ """Extract tycache format with tyFlow operators.
+ Notes:
+ - TyCache only works for TyFlow Pro Plugin.
+
+ Methods:
+ self.get_export_particles_job_args(): sets up all job arguments
+ for attributes to be exported in MAXscript
+
+ self.get_operators(): get the export_particle operator
+
+ self.get_files(): get the files with tyFlow naming convention
+ before publishing
+ """
+
+ order = pyblish.api.ExtractorOrder - 0.2
+ label = "Extract TyCache"
+ hosts = ["max"]
+ families = ["tycache"]
+
+ def process(self, instance):
+ # TODO: let user decide the param
+ start = int(instance.context.data["frameStart"])
+ end = int(instance.context.data.get("frameEnd"))
+ self.log.debug("Extracting Tycache...")
+
+ stagingdir = self.staging_dir(instance)
+ filename = "{name}.tyc".format(**instance.data)
+ path = os.path.join(stagingdir, filename)
+ filenames = self.get_files(instance, start, end)
+ additional_attributes = instance.data.get("tyc_attrs", {})
+
+ with maintained_selection():
+ job_args = self.get_export_particles_job_args(
+ instance.data["members"],
+ start, end, path,
+ additional_attributes)
+ for job in job_args:
+ rt.Execute(job)
+ representations = instance.data.setdefault("representations", [])
+ representation = {
+ 'name': 'tyc',
+ 'ext': 'tyc',
+ 'files': filenames if len(filenames) > 1 else filenames[0],
+ "stagingDir": stagingdir,
+ }
+ representations.append(representation)
+
+ # Get the tyMesh filename for extraction
+ mesh_filename = f"{instance.name}__tyMesh.tyc"
+ mesh_repres = {
+ 'name': 'tyMesh',
+ 'ext': 'tyc',
+ 'files': mesh_filename,
+ "stagingDir": stagingdir,
+ "outputName": '__tyMesh'
+ }
+ representations.append(mesh_repres)
+ self.log.debug(f"Extracted instance '{instance.name}' to: {filenames}")
+
+ def get_files(self, instance, start_frame, end_frame):
+ """Get file names for tyFlow in tyCache format.
+
+ Set the filenames accordingly to the tyCache file
+ naming extension(.tyc) for the publishing purpose
+
+ Actual File Output from tyFlow in tyCache format:
+ __tyPart_.tyc
+
+ e.g. tycacheMain__tyPart_00000.tyc
+
+ Args:
+ instance (pyblish.api.Instance): instance.
+ start_frame (int): Start frame.
+ end_frame (int): End frame.
+
+ Returns:
+ filenames(list): list of filenames
+
+ """
+ filenames = []
+ for frame in range(int(start_frame), int(end_frame) + 1):
+ filename = f"{instance.name}__tyPart_{frame:05}.tyc"
+ filenames.append(filename)
+ return filenames
+
+ def get_export_particles_job_args(self, members, start, end,
+ filepath, additional_attributes):
+ """Sets up all job arguments for attributes.
+
+ Those attributes are to be exported in MAX Script.
+
+ Args:
+ members (list): Member nodes of the instance.
+ start (int): Start frame.
+ end (int): End frame.
+ filepath (str): Output path of the TyCache file.
+ additional_attributes (dict): channel attributes data
+ which needed to be exported
+
+ Returns:
+ list of arguments for MAX Script.
+
+ """
+ settings = {
+ "exportMode": 2,
+ "frameStart": start,
+ "frameEnd": end,
+ "tyCacheFilename": filepath.replace("\\", "/")
+ }
+ settings.update(additional_attributes)
+
+ job_args = []
+ for operator in self.get_operators(members):
+ for key, value in settings.items():
+ if isinstance(value, str):
+ # embed in quotes
+ value = f'"{value}"'
+
+ job_args.append(f"{operator}.{key}={value}")
+ job_args.append(f"{operator}.exportTyCache()")
+ return job_args
+
+ @staticmethod
+ def get_operators(members):
+ """Get Export Particles Operator.
+
+ Args:
+ members (list): Instance members.
+
+ Returns:
+ list of particle operators
+
+ """
+ opt_list = []
+ for member in members:
+ obj = member.baseobject
+ # TODO: see if it can use maxscript instead
+ anim_names = rt.GetSubAnimNames(obj)
+ for anim_name in anim_names:
+ sub_anim = rt.GetSubAnim(obj, anim_name)
+ boolean = rt.IsProperty(sub_anim, "Export_Particles")
+ if boolean:
+ event_name = sub_anim.Name
+ opt = f"${member.Name}.{event_name}.export_particles"
+ opt_list.append(opt)
+
+ return opt_list
diff --git a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py b/openpype/hosts/max/plugins/publish/validate_animation_timeline.py
deleted file mode 100644
index 2a9483c763..0000000000
--- a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import pyblish.api
-
-from pymxs import runtime as rt
-from openpype.pipeline.publish import (
- RepairAction,
- ValidateContentsOrder,
- PublishValidationError
-)
-from openpype.hosts.max.api.lib import get_frame_range, set_timeline
-
-
-class ValidateAnimationTimeline(pyblish.api.InstancePlugin):
- """
- Validates Animation Timeline for Preview Animation in Max
- """
-
- label = "Animation Timeline for Review"
- order = ValidateContentsOrder
- families = ["review"]
- hosts = ["max"]
- actions = [RepairAction]
-
- def process(self, instance):
- frame_range = get_frame_range()
- frame_start_handle = frame_range["frameStart"] - int(
- frame_range["handleStart"]
- )
- frame_end_handle = frame_range["frameEnd"] + int(
- frame_range["handleEnd"]
- )
- if rt.animationRange.start != frame_start_handle or (
- rt.animationRange.end != frame_end_handle
- ):
- raise PublishValidationError("Incorrect animation timeline "
- "set for preview animation.. "
- "\nYou can use repair action to "
- "the correct animation timeline")
-
- @classmethod
- def repair(cls, instance):
- frame_range = get_frame_range()
- frame_start_handle = frame_range["frameStart"] - int(
- frame_range["handleStart"]
- )
- frame_end_handle = frame_range["frameEnd"] + int(
- frame_range["handleEnd"]
- )
- set_timeline(frame_start_handle, frame_end_handle)
diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py
index 21e847405e..0e8316e844 100644
--- a/openpype/hosts/max/plugins/publish/validate_frame_range.py
+++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py
@@ -7,8 +7,10 @@ from openpype.pipeline import (
from openpype.pipeline.publish import (
RepairAction,
ValidateContentsOrder,
- PublishValidationError
+ PublishValidationError,
+ KnownPublishError
)
+from openpype.hosts.max.api.lib import get_frame_range, set_timeline
class ValidateFrameRange(pyblish.api.InstancePlugin,
@@ -27,38 +29,60 @@ class ValidateFrameRange(pyblish.api.InstancePlugin,
label = "Validate Frame Range"
order = ValidateContentsOrder
- families = ["maxrender"]
+ families = ["camera", "maxrender",
+ "pointcache", "pointcloud",
+ "review", "redshiftproxy"]
hosts = ["max"]
optional = True
actions = [RepairAction]
def process(self, instance):
if not self.is_active(instance.data):
- self.log.info("Skipping validation...")
+ self.log.debug("Skipping Validate Frame Range...")
return
- context = instance.context
- frame_start = int(context.data.get("frameStart"))
- frame_end = int(context.data.get("frameEnd"))
-
- inst_frame_start = int(instance.data.get("frameStart"))
- inst_frame_end = int(instance.data.get("frameEnd"))
+ frame_range = get_frame_range(
+ asset_doc=instance.data["assetEntity"])
+ inst_frame_start = instance.data.get("frameStartHandle")
+ inst_frame_end = instance.data.get("frameEndHandle")
+ if inst_frame_start is None or inst_frame_end is None:
+ raise KnownPublishError(
+ "Missing frame start and frame end on "
+ "instance to to validate."
+ )
+ frame_start_handle = frame_range["frameStartHandle"]
+ frame_end_handle = frame_range["frameEndHandle"]
errors = []
- if frame_start != inst_frame_start:
+ if frame_start_handle != inst_frame_start:
errors.append(
f"Start frame ({inst_frame_start}) on instance does not match " # noqa
- f"with the start frame ({frame_start}) set on the asset data. ") # noqa
- if frame_end != inst_frame_end:
+ f"with the start frame ({frame_start_handle}) set on the asset data. ") # noqa
+ if frame_end_handle != inst_frame_end:
errors.append(
f"End frame ({inst_frame_end}) on instance does not match "
- f"with the end frame ({frame_start}) from the asset data. ")
+ f"with the end frame ({frame_end_handle}) "
+ "from the asset data. ")
if errors:
- errors.append("You can use repair action to fix it.")
- raise PublishValidationError("\n".join(errors))
+ bullet_point_errors = "\n".join(
+ "- {}".format(error) for error in errors
+ )
+ report = (
+ "Frame range settings are incorrect.\n\n"
+ f"{bullet_point_errors}\n\n"
+ "You can use repair action to fix it."
+ )
+ raise PublishValidationError(report, title="Frame Range incorrect")
@classmethod
def repair(cls, instance):
- rt.rendStart = instance.context.data.get("frameStart")
- rt.rendEnd = instance.context.data.get("frameEnd")
+ frame_range = get_frame_range()
+ frame_start_handle = frame_range["frameStartHandle"]
+ frame_end_handle = frame_range["frameEndHandle"]
+
+ if instance.data["family"] == "maxrender":
+ rt.rendStart = frame_start_handle
+ rt.rendEnd = frame_end_handle
+ else:
+ set_timeline(frame_start_handle, frame_end_handle)
diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py
index a336cbd80c..54d6d0f11a 100644
--- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py
+++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py
@@ -14,29 +14,16 @@ class ValidatePointCloud(pyblish.api.InstancePlugin):
def process(self, instance):
"""
Notes:
-
- 1. Validate the container only include tyFlow objects
- 2. Validate if tyFlow operator Export Particle exists
- 3. Validate if the export mode of Export Particle is at PRT format
- 4. Validate the partition count and range set as default value
+ 1. Validate if the export mode of Export Particle is at PRT format
+ 2. Validate the partition count and range set as default value
Partition Count : 100
Partition Range : 1 to 1
- 5. Validate if the custom attribute(s) exist as parameter(s)
+ 3. Validate if the custom attribute(s) exist as parameter(s)
of export_particle operator
"""
report = []
- invalid_object = self.get_tyflow_object(instance)
- if invalid_object:
- report.append(f"Non tyFlow object found: {invalid_object}")
-
- invalid_operator = self.get_tyflow_operator(instance)
- if invalid_operator:
- report.append((
- "tyFlow ExportParticle operator not "
- f"found: {invalid_operator}"))
-
if self.validate_export_mode(instance):
report.append("The export mode is not at PRT")
@@ -52,46 +39,6 @@ class ValidatePointCloud(pyblish.api.InstancePlugin):
if report:
raise PublishValidationError(f"{report}")
- def get_tyflow_object(self, instance):
- invalid = []
- container = instance.data["instance_node"]
- self.log.info(f"Validating tyFlow container for {container}")
-
- selection_list = instance.data["members"]
- for sel in selection_list:
- sel_tmp = str(sel)
- if rt.ClassOf(sel) in [rt.tyFlow,
- rt.Editable_Mesh]:
- if "tyFlow" not in sel_tmp:
- invalid.append(sel)
- else:
- invalid.append(sel)
-
- return invalid
-
- def get_tyflow_operator(self, instance):
- invalid = []
- container = instance.data["instance_node"]
- self.log.info(f"Validating tyFlow object for {container}")
- selection_list = instance.data["members"]
- bool_list = []
- for sel in selection_list:
- obj = sel.baseobject
- anim_names = rt.GetSubAnimNames(obj)
- for anim_name in anim_names:
- # get all the names of the related tyFlow nodes
- sub_anim = rt.GetSubAnim(obj, anim_name)
- # check if there is export particle operator
- boolean = rt.IsProperty(sub_anim, "Export_Particles")
- bool_list.append(str(boolean))
- # if the export_particles property is not there
- # it means there is not a "Export Particle" operator
- if "True" not in bool_list:
- self.log.error("Operator 'Export Particles' not found!")
- invalid.append(sel)
-
- return invalid
-
def validate_custom_attribute(self, instance):
invalid = []
container = instance.data["instance_node"]
diff --git a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py
index 5ac41b10a0..7d91a7b991 100644
--- a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py
+++ b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py
@@ -21,7 +21,7 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin,
if not self.is_active(instance.data):
return
width, height = self.get_db_resolution(instance)
- current_width = rt.renderwidth
+ current_width = rt.renderWidth
current_height = rt.renderHeight
if current_width != width and current_height != height:
raise PublishValidationError("Resolution Setting "
diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py
new file mode 100644
index 0000000000..c0f29422ec
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py
@@ -0,0 +1,88 @@
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+from pymxs import runtime as rt
+
+
+class ValidateTyFlowData(pyblish.api.InstancePlugin):
+ """Validate TyFlow plugins or relevant operators are set correctly."""
+
+ order = pyblish.api.ValidatorOrder
+ families = ["pointcloud", "tycache"]
+ hosts = ["max"]
+ label = "TyFlow Data"
+
+ def process(self, instance):
+ """
+ Notes:
+ 1. Validate the container only include tyFlow objects
+ 2. Validate if tyFlow operator Export Particle exists
+
+ """
+
+ invalid_object = self.get_tyflow_object(instance)
+ if invalid_object:
+ self.log.error(f"Non tyFlow object found: {invalid_object}")
+
+ invalid_operator = self.get_tyflow_operator(instance)
+ if invalid_operator:
+ self.log.error(
+ "Operator 'Export Particles' not found in tyFlow editor.")
+ if invalid_object or invalid_operator:
+ raise PublishValidationError(
+ "issues occurred",
+ description="Container should only include tyFlow object "
+ "and tyflow operator 'Export Particle' should be in "
+ "the tyFlow editor.")
+
+ def get_tyflow_object(self, instance):
+ """Get the nodes which are not tyFlow object(s)
+ and editable mesh(es)
+
+ Args:
+ instance (pyblish.api.Instance): instance
+
+ Returns:
+ list: invalid nodes which are not tyFlow
+ object(s) and editable mesh(es).
+ """
+ container = instance.data["instance_node"]
+ self.log.debug(f"Validating tyFlow container for {container}")
+
+ allowed_classes = [rt.tyFlow, rt.Editable_Mesh]
+ return [
+ member for member in instance.data["members"]
+ if rt.ClassOf(member) not in allowed_classes
+ ]
+
+ def get_tyflow_operator(self, instance):
+ """Check if the Export Particle Operators in the node
+ connections.
+
+ Args:
+ instance (str): instance node
+
+ Returns:
+ invalid(list): list of invalid nodes which do
+ not consist of Export Particle Operators as parts
+ of the node connections
+ """
+ invalid = []
+ members = instance.data["members"]
+ for member in members:
+ obj = member.baseobject
+
+ # There must be at least one animation with export
+ # particles enabled
+ has_export_particles = False
+ anim_names = rt.GetSubAnimNames(obj)
+ for anim_name in anim_names:
+ # get name of the related tyFlow node
+ sub_anim = rt.GetSubAnim(obj, anim_name)
+ # check if there is export particle operator
+ if rt.IsProperty(sub_anim, "Export_Particles"):
+ has_export_particles = True
+ break
+
+ if not has_export_particles:
+ invalid.append(member)
+ return invalid
diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py
index 865f497710..d1ba3cc306 100644
--- a/openpype/hosts/maya/api/workfile_template_builder.py
+++ b/openpype/hosts/maya/api/workfile_template_builder.py
@@ -244,8 +244,14 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin):
return self.get_load_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
- """Hide placeholder, add them to placeholder set
+ """Cleanup placeholder after load of its corresponding representations.
+
+ Args:
+ placeholder (PlaceholderItem): Item which was just used to load
+ representation.
+ failed (bool): Loading of representation failed.
"""
+ # Hide placeholder and add them to placeholder set
node = placeholder.scene_identifier
cmds.sets(node, addElement=PLACEHOLDER_SET)
diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py
index 734a565541..8b1ba0ab0d 100644
--- a/openpype/hosts/nuke/api/lib.py
+++ b/openpype/hosts/nuke/api/lib.py
@@ -40,7 +40,6 @@ from openpype.settings import (
from openpype.modules import ModulesManager
from openpype.pipeline.template_data import get_template_data_with_names
from openpype.pipeline import (
- get_current_project_name,
discover_legacy_creator_plugins,
Anatomy,
get_current_host_name,
@@ -1099,26 +1098,6 @@ def check_subsetname_exists(nodes, subset_name):
False)
-def get_render_path(node):
- ''' Generate Render path from presets regarding avalon knob data
- '''
- avalon_knob_data = read_avalon_data(node)
-
- nuke_imageio_writes = get_imageio_node_setting(
- node_class=avalon_knob_data["families"],
- plugin_name=avalon_knob_data["creator"],
- subset=avalon_knob_data["subset"]
- )
-
- data = {
- "avalon": avalon_knob_data,
- "nuke_imageio_writes": nuke_imageio_writes
- }
-
- anatomy_filled = format_anatomy(data)
- return anatomy_filled["render"]["path"].replace("\\", "/")
-
-
def format_anatomy(data):
''' Helping function for formatting of anatomy paths
diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py
index 9d7604c58d..ee9f75d10d 100644
--- a/openpype/hosts/nuke/api/workfile_template_builder.py
+++ b/openpype/hosts/nuke/api/workfile_template_builder.py
@@ -163,8 +163,10 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin):
)
return loaded_representation_ids
- def _before_repre_load(self, placeholder, representation):
+ def _before_placeholder_load(self, placeholder):
placeholder.data["nodes_init"] = nuke.allNodes()
+
+ def _before_repre_load(self, placeholder, representation):
placeholder.data["last_repre_id"] = str(representation["_id"])
def collect_placeholders(self):
@@ -197,6 +199,13 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin):
return self.get_load_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
+ """Cleanup placeholder after load of its corresponding representations.
+
+ Args:
+ placeholder (PlaceholderItem): Item which was just used to load
+ representation.
+ failed (bool): Loading of representation failed.
+ """
# deselect all selected nodes
placeholder_node = nuke.toNode(placeholder.scene_identifier)
@@ -603,6 +612,13 @@ class NukePlaceholderCreatePlugin(
return self.get_create_plugin_options(options)
def post_placeholder_process(self, placeholder, failed):
+ """Cleanup placeholder after load of its corresponding representations.
+
+ Args:
+ placeholder (PlaceholderItem): Item which was just used to load
+ representation.
+ failed (bool): Loading of representation failed.
+ """
# deselect all selected nodes
placeholder_node = nuke.toNode(placeholder.scene_identifier)
diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py
index 0dd3a940db..6bffb97e6f 100644
--- a/openpype/hosts/nuke/plugins/load/load_image.py
+++ b/openpype/hosts/nuke/plugins/load/load_image.py
@@ -204,8 +204,6 @@ class LoadImage(load.LoaderPlugin):
last = first = int(frame_number)
# Set the global in to the start frame of the sequence
- read_name = self._get_node_name(representation)
- node["name"].setValue(read_name)
node["file"].setValue(file)
node["origfirst"].setValue(first)
node["first"].setValue(first)
diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py
new file mode 100644
index 0000000000..18c8cdba35
--- /dev/null
+++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py
@@ -0,0 +1,350 @@
+import os
+import json
+import secrets
+import nuke
+import six
+
+from openpype.client import (
+ get_version_by_id,
+ get_last_version_by_subset_id
+)
+from openpype.pipeline import (
+ load,
+ get_current_project_name,
+ get_representation_path,
+)
+from openpype.hosts.nuke.api import (
+ containerise,
+ viewer_update_and_undo_stop,
+ update_container,
+)
+
+
+class LoadOcioLookNodes(load.LoaderPlugin):
+ """Loading Ocio look to the nuke.Node graph"""
+
+ families = ["ociolook"]
+ representations = ["*"]
+ extensions = {"json"}
+
+ label = "Load OcioLook [nodes]"
+ order = 0
+ icon = "cc"
+ color = "white"
+ ignore_attr = ["useLifetime"]
+
+ # plugin attributes
+ current_node_color = "0x4ecd91ff"
+ old_node_color = "0xd88467ff"
+
+ # json file variables
+ schema_version = 1
+
+ def load(self, context, name, namespace, data):
+ """
+ Loading function to get the soft effects to particular read node
+
+ Arguments:
+ context (dict): context of version
+ name (str): name of the version
+ namespace (str): asset name
+ data (dict): compulsory attribute > not used
+
+ Returns:
+ nuke.Node: containerized nuke.Node object
+ """
+ namespace = namespace or context['asset']['name']
+ suffix = secrets.token_hex(nbytes=4)
+ object_name = "{}_{}_{}".format(
+ name, namespace, suffix)
+
+ # getting file path
+ filepath = self.filepath_from_context(context)
+
+ json_f = self._load_json_data(filepath)
+
+ group_node = self._create_group_node(
+ object_name, filepath, json_f["data"])
+
+ self._node_version_color(context["version"], group_node)
+
+ self.log.info(
+ "Loaded lut setup: `{}`".format(group_node["name"].value()))
+
+ return containerise(
+ node=group_node,
+ name=name,
+ namespace=namespace,
+ context=context,
+ loader=self.__class__.__name__,
+ data={
+ "objectName": object_name,
+ }
+ )
+
+ def _create_group_node(
+ self,
+ object_name,
+ filepath,
+ data
+ ):
+ """Creates group node with all the nodes inside.
+
+ Creating mainly `OCIOFileTransform` nodes with `OCIOColorSpace` nodes
+ in between - in case those are needed.
+
+ Arguments:
+ object_name (str): name of the group node
+ filepath (str): path to json file
+ data (dict): data from json file
+
+ Returns:
+ nuke.Node: group node with all the nodes inside
+ """
+ # get corresponding node
+
+ root_working_colorspace = nuke.root()["workingSpaceLUT"].value()
+
+ dir_path = os.path.dirname(filepath)
+ all_files = os.listdir(dir_path)
+
+ ocio_working_colorspace = _colorspace_name_by_type(
+ data["ocioLookWorkingSpace"])
+
+ # adding nodes to node graph
+ # just in case we are in group lets jump out of it
+ nuke.endGroup()
+
+ input_node = None
+ output_node = None
+ group_node = nuke.toNode(object_name)
+ if group_node:
+ # remove all nodes between Input and Output nodes
+ for node in group_node.nodes():
+ if node.Class() not in ["Input", "Output"]:
+ nuke.delete(node)
+ elif node.Class() == "Input":
+ input_node = node
+ elif node.Class() == "Output":
+ output_node = node
+ else:
+ group_node = nuke.createNode(
+ "Group",
+ "name {}_1".format(object_name),
+ inpanel=False
+ )
+
+ # adding content to the group node
+ with group_node:
+ pre_colorspace = root_working_colorspace
+
+ # reusing input node if it exists during update
+ if input_node:
+ pre_node = input_node
+ else:
+ pre_node = nuke.createNode("Input")
+ pre_node["name"].setValue("rgb")
+
+ # Compare script working colorspace with ocio working colorspace
+ # found in json file and convert to json's if needed
+ if pre_colorspace != ocio_working_colorspace:
+ pre_node = _add_ocio_colorspace_node(
+ pre_node,
+ pre_colorspace,
+ ocio_working_colorspace
+ )
+ pre_colorspace = ocio_working_colorspace
+
+ for ocio_item in data["ocioLookItems"]:
+ input_space = _colorspace_name_by_type(
+ ocio_item["input_colorspace"])
+ output_space = _colorspace_name_by_type(
+ ocio_item["output_colorspace"])
+
+ # making sure we are set to correct colorspace for otio item
+ if pre_colorspace != input_space:
+ pre_node = _add_ocio_colorspace_node(
+ pre_node,
+ pre_colorspace,
+ input_space
+ )
+
+ node = nuke.createNode("OCIOFileTransform")
+
+ # file path from lut representation
+ extension = ocio_item["ext"]
+ item_name = ocio_item["name"]
+
+ item_lut_file = next(
+ (
+ file for file in all_files
+ if file.endswith(extension)
+ ),
+ None
+ )
+ if not item_lut_file:
+ raise ValueError(
+ "File with extension '{}' not "
+ "found in directory".format(extension)
+ )
+
+ item_lut_path = os.path.join(
+ dir_path, item_lut_file).replace("\\", "/")
+ node["file"].setValue(item_lut_path)
+ node["name"].setValue(item_name)
+ node["direction"].setValue(ocio_item["direction"])
+ node["interpolation"].setValue(ocio_item["interpolation"])
+ node["working_space"].setValue(input_space)
+
+ pre_node.autoplace()
+ node.setInput(0, pre_node)
+ node.autoplace()
+ # pass output space into pre_colorspace for next iteration
+ # or for output node comparison
+ pre_colorspace = output_space
+ pre_node = node
+
+ # making sure we are back in script working colorspace
+ if pre_colorspace != root_working_colorspace:
+ pre_node = _add_ocio_colorspace_node(
+ pre_node,
+ pre_colorspace,
+ root_working_colorspace
+ )
+
+ # reusing output node if it exists during update
+ if not output_node:
+ output = nuke.createNode("Output")
+ else:
+ output = output_node
+
+ output.setInput(0, pre_node)
+
+ return group_node
+
+ def update(self, container, representation):
+
+ project_name = get_current_project_name()
+ version_doc = get_version_by_id(project_name, representation["parent"])
+
+ object_name = container['objectName']
+
+ filepath = get_representation_path(representation)
+
+ json_f = self._load_json_data(filepath)
+
+ group_node = self._create_group_node(
+ object_name,
+ filepath,
+ json_f["data"]
+ )
+
+ self._node_version_color(version_doc, group_node)
+
+ self.log.info("Updated lut setup: `{}`".format(
+ group_node["name"].value()))
+
+ return update_container(
+ group_node, {"representation": str(representation["_id"])})
+
+ def _load_json_data(self, filepath):
+ # getting data from json file with unicode conversion
+ with open(filepath, "r") as _file:
+ json_f = {self._bytify(key): self._bytify(value)
+ for key, value in json.load(_file).items()}
+
+ # check if the version in json_f is the same as plugin version
+ if json_f["version"] != self.schema_version:
+ raise KeyError(
+ "Version of json file is not the same as plugin version")
+
+ return json_f
+
+ def _bytify(self, input):
+ """
+ Converts unicode strings to strings
+ It goes through all dictionary
+
+ Arguments:
+ input (dict/str): input
+
+ Returns:
+ dict: with fixed values and keys
+
+ """
+
+ if isinstance(input, dict):
+ return {self._bytify(key): self._bytify(value)
+ for key, value in input.items()}
+ elif isinstance(input, list):
+ return [self._bytify(element) for element in input]
+ elif isinstance(input, six.text_type):
+ return str(input)
+ else:
+ return input
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def remove(self, container):
+ node = nuke.toNode(container['objectName'])
+ with viewer_update_and_undo_stop():
+ nuke.delete(node)
+
+ def _node_version_color(self, version, node):
+ """ Coloring a node by correct color by actual version"""
+
+ project_name = get_current_project_name()
+ last_version_doc = get_last_version_by_subset_id(
+ project_name, version["parent"], fields=["_id"]
+ )
+
+ # change color of node
+ if version["_id"] == last_version_doc["_id"]:
+ color_value = self.current_node_color
+ else:
+ color_value = self.old_node_color
+ node["tile_color"].setValue(int(color_value, 16))
+
+
+def _colorspace_name_by_type(colorspace_data):
+ """
+ Returns colorspace name by type
+
+ Arguments:
+ colorspace_data (dict): colorspace data
+
+ Returns:
+ str: colorspace name
+ """
+ if colorspace_data["type"] == "colorspaces":
+ return colorspace_data["name"]
+ elif colorspace_data["type"] == "roles":
+ return colorspace_data["colorspace"]
+ else:
+ raise KeyError("Unknown colorspace type: {}".format(
+ colorspace_data["type"]))
+
+
+def _add_ocio_colorspace_node(pre_node, input_space, output_space):
+ """
+ Adds OCIOColorSpace node to the node graph
+
+ Arguments:
+ pre_node (nuke.Node): node to connect to
+ input_space (str): input colorspace
+ output_space (str): output colorspace
+
+ Returns:
+ nuke.Node: node with OCIOColorSpace node
+ """
+ node = nuke.createNode("OCIOColorSpace")
+ node.setInput(0, pre_node)
+ node["in_colorspace"].setValue(input_space)
+ node["out_colorspace"].setValue(output_space)
+
+ pre_node.autoplace()
+ node.setInput(0, pre_node)
+ node.autoplace()
+
+ return node
diff --git a/openpype/hosts/photoshop/api/extension/extension.zxp b/openpype/hosts/photoshop/api/extension/extension.zxp
deleted file mode 100644
index 39b766cd0d..0000000000
Binary files a/openpype/hosts/photoshop/api/extension/extension.zxp and /dev/null differ
diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py
new file mode 100644
index 0000000000..5628d0973f
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py
@@ -0,0 +1,173 @@
+# -*- coding: utf-8 -*-
+"""Creator of colorspace look files.
+
+This creator is used to publish colorspace look files thanks to
+production type `ociolook`. All files are published as representation.
+"""
+from pathlib import Path
+
+from openpype.client import get_asset_by_name
+from openpype.lib.attribute_definitions import (
+ FileDef, EnumDef, TextDef, UISeparatorDef
+)
+from openpype.pipeline import (
+ CreatedInstance,
+ CreatorError
+)
+from openpype.pipeline import colorspace
+from openpype.hosts.traypublisher.api.plugin import TrayPublishCreator
+
+
+class CreateColorspaceLook(TrayPublishCreator):
+ """Creates colorspace look files."""
+
+ identifier = "io.openpype.creators.traypublisher.colorspace_look"
+ label = "Colorspace Look"
+ family = "ociolook"
+ description = "Publishes color space look file."
+ extensions = [".cc", ".cube", ".3dl", ".spi1d", ".spi3d", ".csp", ".lut"]
+ enabled = False
+
+ colorspace_items = [
+ (None, "Not set")
+ ]
+ colorspace_attr_show = False
+ config_items = None
+ config_data = None
+
+ def get_detail_description(self):
+ return """# Colorspace Look
+
+This creator publishes color space look file (LUT).
+ """
+
+ def get_icon(self):
+ return "mdi.format-color-fill"
+
+ def create(self, subset_name, instance_data, pre_create_data):
+ repr_file = pre_create_data.get("luts_file")
+ if not repr_file:
+ raise CreatorError("No files specified")
+
+ files = repr_file.get("filenames")
+ if not files:
+ # this should never happen
+ raise CreatorError("Missing files from representation")
+
+ asset_doc = get_asset_by_name(
+ self.project_name, instance_data["asset"])
+
+ subset_name = self.get_subset_name(
+ variant=instance_data["variant"],
+ task_name=instance_data["task"] or "Not set",
+ project_name=self.project_name,
+ asset_doc=asset_doc,
+ )
+
+ instance_data["creator_attributes"] = {
+ "abs_lut_path": (
+ Path(repr_file["directory"]) / files[0]).as_posix()
+ }
+
+ # Create new instance
+ new_instance = CreatedInstance(self.family, subset_name,
+ instance_data, self)
+ new_instance.transient_data["config_items"] = self.config_items
+ new_instance.transient_data["config_data"] = self.config_data
+
+ self._store_new_instance(new_instance)
+
+ def collect_instances(self):
+ super().collect_instances()
+ for instance in self.create_context.instances:
+ if instance.creator_identifier == self.identifier:
+ instance.transient_data["config_items"] = self.config_items
+ instance.transient_data["config_data"] = self.config_data
+
+ def get_instance_attr_defs(self):
+ return [
+ EnumDef(
+ "working_colorspace",
+ self.colorspace_items,
+ default="Not set",
+ label="Working Colorspace",
+ ),
+ UISeparatorDef(
+ label="Advanced1"
+ ),
+ TextDef(
+ "abs_lut_path",
+ label="LUT Path",
+ ),
+ EnumDef(
+ "input_colorspace",
+ self.colorspace_items,
+ default="Not set",
+ label="Input Colorspace",
+ ),
+ EnumDef(
+ "direction",
+ [
+ (None, "Not set"),
+ ("forward", "Forward"),
+ ("inverse", "Inverse")
+ ],
+ default="Not set",
+ label="Direction"
+ ),
+ EnumDef(
+ "interpolation",
+ [
+ (None, "Not set"),
+ ("linear", "Linear"),
+ ("tetrahedral", "Tetrahedral"),
+ ("best", "Best"),
+ ("nearest", "Nearest")
+ ],
+ default="Not set",
+ label="Interpolation"
+ ),
+ EnumDef(
+ "output_colorspace",
+ self.colorspace_items,
+ default="Not set",
+ label="Output Colorspace",
+ ),
+ ]
+
+ def get_pre_create_attr_defs(self):
+ return [
+ FileDef(
+ "luts_file",
+ folders=False,
+ extensions=self.extensions,
+ allow_sequences=False,
+ single_item=True,
+ label="Look Files",
+ )
+ ]
+
+ def apply_settings(self, project_settings, system_settings):
+ host = self.create_context.host
+ host_name = host.name
+ project_name = host.get_current_project_name()
+ config_data = colorspace.get_imageio_config(
+ project_name, host_name,
+ project_settings=project_settings
+ )
+
+ if not config_data:
+ self.enabled = False
+ return
+
+ filepath = config_data["path"]
+ config_items = colorspace.get_ocio_config_colorspaces(filepath)
+ labeled_colorspaces = colorspace.get_colorspaces_enumerator_items(
+ config_items,
+ include_aliases=True,
+ include_roles=True
+ )
+ self.config_items = config_items
+ self.config_data = config_data
+ self.colorspace_items.extend(labeled_colorspaces)
+ self.enabled = True
diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py
new file mode 100644
index 0000000000..6aede099bf
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py
@@ -0,0 +1,86 @@
+import os
+from pprint import pformat
+import pyblish.api
+from openpype.pipeline import publish
+from openpype.pipeline import colorspace
+
+
+class CollectColorspaceLook(pyblish.api.InstancePlugin,
+ publish.OpenPypePyblishPluginMixin):
+ """Collect OCIO colorspace look from LUT file
+ """
+
+ label = "Collect Colorspace Look"
+ order = pyblish.api.CollectorOrder
+ hosts = ["traypublisher"]
+ families = ["ociolook"]
+
+ def process(self, instance):
+ creator_attrs = instance.data["creator_attributes"]
+
+ lut_repre_name = "LUTfile"
+ file_url = creator_attrs["abs_lut_path"]
+ file_name = os.path.basename(file_url)
+ base_name, ext = os.path.splitext(file_name)
+
+ # set output name with base_name which was cleared
+ # of all symbols and all parts were capitalized
+ output_name = (base_name.replace("_", " ")
+ .replace(".", " ")
+ .replace("-", " ")
+ .title()
+ .replace(" ", ""))
+
+ # get config items
+ config_items = instance.data["transientData"]["config_items"]
+ config_data = instance.data["transientData"]["config_data"]
+
+ # get colorspace items
+ converted_color_data = {}
+ for colorspace_key in [
+ "working_colorspace",
+ "input_colorspace",
+ "output_colorspace"
+ ]:
+ if creator_attrs[colorspace_key]:
+ color_data = colorspace.convert_colorspace_enumerator_item(
+ creator_attrs[colorspace_key], config_items)
+ converted_color_data[colorspace_key] = color_data
+ else:
+ converted_color_data[colorspace_key] = None
+
+ # add colorspace to config data
+ if converted_color_data["working_colorspace"]:
+ config_data["colorspace"] = (
+ converted_color_data["working_colorspace"]["name"]
+ )
+
+ # create lut representation data
+ lut_repre = {
+ "name": lut_repre_name,
+ "output": output_name,
+ "ext": ext.lstrip("."),
+ "files": file_name,
+ "stagingDir": os.path.dirname(file_url),
+ "tags": []
+ }
+ instance.data.update({
+ "representations": [lut_repre],
+ "source": file_url,
+ "ocioLookWorkingSpace": converted_color_data["working_colorspace"],
+ "ocioLookItems": [
+ {
+ "name": lut_repre_name,
+ "ext": ext.lstrip("."),
+ "input_colorspace": converted_color_data[
+ "input_colorspace"],
+ "output_colorspace": converted_color_data[
+ "output_colorspace"],
+ "direction": creator_attrs["direction"],
+ "interpolation": creator_attrs["interpolation"],
+ "config_data": config_data
+ }
+ ],
+ })
+
+ self.log.debug(pformat(instance.data))
diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py
index eb7fbd87a0..5db2b0cbad 100644
--- a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py
+++ b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py
@@ -1,6 +1,8 @@
import pyblish.api
-from openpype.pipeline import registered_host
-from openpype.pipeline import publish
+from openpype.pipeline import (
+ publish,
+ registered_host
+)
from openpype.lib import EnumDef
from openpype.pipeline import colorspace
@@ -13,11 +15,14 @@ class CollectColorspace(pyblish.api.InstancePlugin,
label = "Choose representation colorspace"
order = pyblish.api.CollectorOrder + 0.49
hosts = ["traypublisher"]
+ families = ["render", "plate", "reference", "image", "online"]
+ enabled = False
colorspace_items = [
(None, "Don't override")
]
colorspace_attr_show = False
+ config_items = None
def process(self, instance):
values = self.get_attr_values_from_data(instance.data)
@@ -48,10 +53,14 @@ class CollectColorspace(pyblish.api.InstancePlugin,
if config_data:
filepath = config_data["path"]
config_items = colorspace.get_ocio_config_colorspaces(filepath)
- cls.colorspace_items.extend((
- (name, name) for name in config_items.keys()
- ))
- cls.colorspace_attr_show = True
+ labeled_colorspaces = colorspace.get_colorspaces_enumerator_items(
+ config_items,
+ include_aliases=True,
+ include_roles=True
+ )
+ cls.config_items = config_items
+ cls.colorspace_items.extend(labeled_colorspaces)
+ cls.enabled = True
@classmethod
def get_attribute_defs(cls):
@@ -60,7 +69,6 @@ class CollectColorspace(pyblish.api.InstancePlugin,
"colorspace",
cls.colorspace_items,
default="Don't override",
- label="Override Colorspace",
- hidden=not cls.colorspace_attr_show
+ label="Override Colorspace"
)
]
diff --git a/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py
new file mode 100644
index 0000000000..f94bbc7a49
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py
@@ -0,0 +1,45 @@
+import os
+import json
+import pyblish.api
+from openpype.pipeline import publish
+
+
+class ExtractColorspaceLook(publish.Extractor,
+ publish.OpenPypePyblishPluginMixin):
+ """Extract OCIO colorspace look from LUT file
+ """
+
+ label = "Extract Colorspace Look"
+ order = pyblish.api.ExtractorOrder
+ hosts = ["traypublisher"]
+ families = ["ociolook"]
+
+ def process(self, instance):
+ ociolook_items = instance.data["ocioLookItems"]
+ ociolook_working_color = instance.data["ocioLookWorkingSpace"]
+ staging_dir = self.staging_dir(instance)
+
+ # create ociolook file attributes
+ ociolook_file_name = "ocioLookFile.json"
+ ociolook_file_content = {
+ "version": 1,
+ "data": {
+ "ocioLookItems": ociolook_items,
+ "ocioLookWorkingSpace": ociolook_working_color
+ }
+ }
+
+ # write ociolook content into json file saved in staging dir
+ file_url = os.path.join(staging_dir, ociolook_file_name)
+ with open(file_url, "w") as f_:
+ json.dump(ociolook_file_content, f_, indent=4)
+
+ # create lut representation data
+ ociolook_repre = {
+ "name": "ocioLookFile",
+ "ext": "json",
+ "files": ociolook_file_name,
+ "stagingDir": staging_dir,
+ "tags": []
+ }
+ instance.data["representations"].append(ociolook_repre)
diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py
index 75b41cf606..03f9f299b2 100644
--- a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py
+++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py
@@ -18,6 +18,7 @@ class ValidateColorspace(pyblish.api.InstancePlugin,
label = "Validate representation colorspace"
order = pyblish.api.ValidatorOrder
hosts = ["traypublisher"]
+ families = ["render", "plate", "reference", "image", "online"]
def process(self, instance):
diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py
new file mode 100644
index 0000000000..548ce9d15a
--- /dev/null
+++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py
@@ -0,0 +1,89 @@
+import pyblish.api
+
+from openpype.pipeline import (
+ publish,
+ PublishValidationError
+)
+
+
+class ValidateColorspaceLook(pyblish.api.InstancePlugin,
+ publish.OpenPypePyblishPluginMixin):
+ """Validate colorspace look attributes"""
+
+ label = "Validate colorspace look attributes"
+ order = pyblish.api.ValidatorOrder
+ hosts = ["traypublisher"]
+ families = ["ociolook"]
+
+ def process(self, instance):
+ create_context = instance.context.data["create_context"]
+ created_instance = create_context.get_instance_by_id(
+ instance.data["instance_id"])
+ creator_defs = created_instance.creator_attribute_defs
+
+ ociolook_working_color = instance.data.get("ocioLookWorkingSpace")
+ ociolook_items = instance.data.get("ocioLookItems", [])
+
+ creator_defs_by_key = {_def.key: _def.label for _def in creator_defs}
+
+ not_set_keys = {}
+ if not ociolook_working_color:
+ not_set_keys["working_colorspace"] = creator_defs_by_key[
+ "working_colorspace"]
+
+ for ociolook_item in ociolook_items:
+ item_not_set_keys = self.validate_colorspace_set_attrs(
+ ociolook_item, creator_defs_by_key)
+ if item_not_set_keys:
+ not_set_keys[ociolook_item["name"]] = item_not_set_keys
+
+ if not_set_keys:
+ message = (
+ "Colorspace look attributes are not set: \n"
+ )
+ for key, value in not_set_keys.items():
+ if isinstance(value, list):
+ values_string = "\n\t- ".join(value)
+ message += f"\n\t{key}:\n\t- {values_string}"
+ else:
+ message += f"\n\t{value}"
+
+ raise PublishValidationError(
+ title="Colorspace Look attributes",
+ message=message,
+ description=message
+ )
+
+ def validate_colorspace_set_attrs(
+ self,
+ ociolook_item,
+ creator_defs_by_key
+ ):
+ """Validate colorspace look attributes"""
+
+ self.log.debug(f"Validate colorspace look attributes: {ociolook_item}")
+
+ check_keys = [
+ "input_colorspace",
+ "output_colorspace",
+ "direction",
+ "interpolation"
+ ]
+
+ not_set_keys = []
+ for key in check_keys:
+ if ociolook_item[key]:
+ # key is set and it is correct
+ continue
+
+ def_label = creator_defs_by_key.get(key)
+
+ if not def_label:
+ # raise since key is not recognized by creator defs
+ raise KeyError(
+ f"Colorspace look attribute '{key}' is not "
+ f"recognized by creator attributes: {creator_defs_by_key}"
+ )
+ not_set_keys.append(def_label)
+
+ return not_set_keys
diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py
index 72816c9b81..f2d7b5f73e 100644
--- a/openpype/hosts/unreal/api/pipeline.py
+++ b/openpype/hosts/unreal/api/pipeline.py
@@ -13,8 +13,10 @@ from openpype.client import get_asset_by_name, get_assets
from openpype.pipeline import (
register_loader_plugin_path,
register_creator_plugin_path,
+ register_inventory_action_path,
deregister_loader_plugin_path,
deregister_creator_plugin_path,
+ deregister_inventory_action_path,
AYON_CONTAINER_ID,
legacy_io,
)
@@ -28,6 +30,7 @@ import unreal # noqa
logger = logging.getLogger("openpype.hosts.unreal")
AYON_CONTAINERS = "AyonContainers"
+AYON_ASSET_DIR = "/Game/Ayon/Assets"
CONTEXT_CONTAINER = "Ayon/context.json"
UNREAL_VERSION = semver.VersionInfo(
*os.getenv("AYON_UNREAL_VERSION").split(".")
@@ -127,6 +130,7 @@ def install():
pyblish.api.register_plugin_path(str(PUBLISH_PATH))
register_loader_plugin_path(str(LOAD_PATH))
register_creator_plugin_path(str(CREATE_PATH))
+ register_inventory_action_path(str(INVENTORY_PATH))
_register_callbacks()
_register_events()
@@ -136,6 +140,7 @@ def uninstall():
pyblish.api.deregister_plugin_path(str(PUBLISH_PATH))
deregister_loader_plugin_path(str(LOAD_PATH))
deregister_creator_plugin_path(str(CREATE_PATH))
+ deregister_inventory_action_path(str(INVENTORY_PATH))
def _register_callbacks():
@@ -649,6 +654,141 @@ def generate_sequence(h, h_dir):
return sequence, (min_frame, max_frame)
+def _get_comps_and_assets(
+ component_class, asset_class, old_assets, new_assets, selected
+):
+ eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
+
+ components = []
+ if selected:
+ sel_actors = eas.get_selected_level_actors()
+ for actor in sel_actors:
+ comps = actor.get_components_by_class(component_class)
+ components.extend(comps)
+ else:
+ comps = eas.get_all_level_actors_components()
+ components = [
+ c for c in comps if isinstance(c, component_class)
+ ]
+
+ # Get all the static meshes among the old assets in a dictionary with
+ # the name as key
+ selected_old_assets = {}
+ for a in old_assets:
+ asset = unreal.EditorAssetLibrary.load_asset(a)
+ if isinstance(asset, asset_class):
+ selected_old_assets[asset.get_name()] = asset
+
+ # Get all the static meshes among the new assets in a dictionary with
+ # the name as key
+ selected_new_assets = {}
+ for a in new_assets:
+ asset = unreal.EditorAssetLibrary.load_asset(a)
+ if isinstance(asset, asset_class):
+ selected_new_assets[asset.get_name()] = asset
+
+ return components, selected_old_assets, selected_new_assets
+
+
+def replace_static_mesh_actors(old_assets, new_assets, selected):
+ smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
+
+ static_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets(
+ unreal.StaticMeshComponent,
+ unreal.StaticMesh,
+ old_assets,
+ new_assets,
+ selected
+ )
+
+ for old_name, old_mesh in old_meshes.items():
+ new_mesh = new_meshes.get(old_name)
+
+ if not new_mesh:
+ continue
+
+ smes.replace_mesh_components_meshes(
+ static_mesh_comps, old_mesh, new_mesh)
+
+
+def replace_skeletal_mesh_actors(old_assets, new_assets, selected):
+ skeletal_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets(
+ unreal.SkeletalMeshComponent,
+ unreal.SkeletalMesh,
+ old_assets,
+ new_assets,
+ selected
+ )
+
+ for old_name, old_mesh in old_meshes.items():
+ new_mesh = new_meshes.get(old_name)
+
+ if not new_mesh:
+ continue
+
+ for comp in skeletal_mesh_comps:
+ if comp.get_skeletal_mesh_asset() == old_mesh:
+ comp.set_skeletal_mesh_asset(new_mesh)
+
+
+def replace_geometry_cache_actors(old_assets, new_assets, selected):
+ geometry_cache_comps, old_caches, new_caches = _get_comps_and_assets(
+ unreal.GeometryCacheComponent,
+ unreal.GeometryCache,
+ old_assets,
+ new_assets,
+ selected
+ )
+
+ for old_name, old_mesh in old_caches.items():
+ new_mesh = new_caches.get(old_name)
+
+ if not new_mesh:
+ continue
+
+ for comp in geometry_cache_comps:
+ if comp.get_editor_property("geometry_cache") == old_mesh:
+ comp.set_geometry_cache(new_mesh)
+
+
+def delete_asset_if_unused(container, asset_content):
+ ar = unreal.AssetRegistryHelpers.get_asset_registry()
+
+ references = set()
+
+ for asset_path in asset_content:
+ asset = ar.get_asset_by_object_path(asset_path)
+ refs = ar.get_referencers(
+ asset.package_name,
+ unreal.AssetRegistryDependencyOptions(
+ include_soft_package_references=False,
+ include_hard_package_references=True,
+ include_searchable_names=False,
+ include_soft_management_references=False,
+ include_hard_management_references=False
+ ))
+ if not refs:
+ continue
+ references = references.union(set(refs))
+
+ # Filter out references that are in the Temp folder
+ cleaned_references = {
+ ref for ref in references if not str(ref).startswith("/Temp/")}
+
+ # Check which of the references are Levels
+ for ref in cleaned_references:
+ loaded_asset = unreal.EditorAssetLibrary.load_asset(ref)
+ if isinstance(loaded_asset, unreal.World):
+ # If there is at least a level, we can stop, we don't want to
+ # delete the container
+ return
+
+ unreal.log("Previous version unused, deleting...")
+
+ # No levels, delete the asset
+ unreal.EditorAssetLibrary.delete_directory(container["namespace"])
+
+
@contextmanager
def maintained_selection():
"""Stub to be either implemented or replaced.
diff --git a/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py b/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py
new file mode 100644
index 0000000000..8320e3c92d
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py
@@ -0,0 +1,66 @@
+import unreal
+
+from openpype.hosts.unreal.api.tools_ui import qt_app_context
+from openpype.hosts.unreal.api.pipeline import delete_asset_if_unused
+from openpype.pipeline import InventoryAction
+
+
+class DeleteUnusedAssets(InventoryAction):
+ """Delete all the assets that are not used in any level.
+ """
+
+ label = "Delete Unused Assets"
+ icon = "trash"
+ color = "red"
+ order = 1
+
+ dialog = None
+
+ def _delete_unused_assets(self, containers):
+ allowed_families = ["model", "rig"]
+
+ for container in containers:
+ container_dir = container.get("namespace")
+ if container.get("family") not in allowed_families:
+ unreal.log_warning(
+ f"Container {container_dir} is not supported.")
+ continue
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ container_dir, recursive=True, include_folder=False
+ )
+
+ delete_asset_if_unused(container, asset_content)
+
+ def _show_confirmation_dialog(self, containers):
+ from qtpy import QtCore
+ from openpype.widgets import popup
+ from openpype.style import load_stylesheet
+
+ dialog = popup.Popup()
+ dialog.setWindowFlags(
+ QtCore.Qt.Window
+ | QtCore.Qt.WindowStaysOnTopHint
+ )
+ dialog.setFocusPolicy(QtCore.Qt.StrongFocus)
+ dialog.setWindowTitle("Delete all unused assets")
+ dialog.setMessage(
+ "You are about to delete all the assets in the project that \n"
+ "are not used in any level. Are you sure you want to continue?"
+ )
+ dialog.setButtonText("Delete")
+
+ dialog.on_clicked.connect(
+ lambda: self._delete_unused_assets(containers)
+ )
+
+ dialog.show()
+ dialog.raise_()
+ dialog.activateWindow()
+ dialog.setStyleSheet(load_stylesheet())
+
+ self.dialog = dialog
+
+ def process(self, containers):
+ with qt_app_context():
+ self._show_confirmation_dialog(containers)
diff --git a/openpype/hosts/unreal/plugins/inventory/update_actors.py b/openpype/hosts/unreal/plugins/inventory/update_actors.py
new file mode 100644
index 0000000000..b0d941ba80
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/inventory/update_actors.py
@@ -0,0 +1,84 @@
+import unreal
+
+from openpype.hosts.unreal.api.pipeline import (
+ ls,
+ replace_static_mesh_actors,
+ replace_skeletal_mesh_actors,
+ replace_geometry_cache_actors,
+)
+from openpype.pipeline import InventoryAction
+
+
+def update_assets(containers, selected):
+ allowed_families = ["model", "rig"]
+
+ # Get all the containers in the Unreal Project
+ all_containers = ls()
+
+ for container in containers:
+ container_dir = container.get("namespace")
+ if container.get("family") not in allowed_families:
+ unreal.log_warning(
+ f"Container {container_dir} is not supported.")
+ continue
+
+ # Get all containers with same asset_name but different objectName.
+ # These are the containers that need to be updated in the level.
+ sa_containers = [
+ i
+ for i in all_containers
+ if (
+ i.get("asset_name") == container.get("asset_name") and
+ i.get("objectName") != container.get("objectName")
+ )
+ ]
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ container_dir, recursive=True, include_folder=False
+ )
+
+ # Update all actors in level
+ for sa_cont in sa_containers:
+ sa_dir = sa_cont.get("namespace")
+ old_content = unreal.EditorAssetLibrary.list_assets(
+ sa_dir, recursive=True, include_folder=False
+ )
+
+ if container.get("family") == "rig":
+ replace_skeletal_mesh_actors(
+ old_content, asset_content, selected)
+ replace_static_mesh_actors(
+ old_content, asset_content, selected)
+ elif container.get("family") == "model":
+ if container.get("loader") == "PointCacheAlembicLoader":
+ replace_geometry_cache_actors(
+ old_content, asset_content, selected)
+ else:
+ replace_static_mesh_actors(
+ old_content, asset_content, selected)
+
+ unreal.EditorLevelLibrary.save_current_level()
+
+
+class UpdateAllActors(InventoryAction):
+ """Update all the Actors in the current level to the version of the asset
+ selected in the scene manager.
+ """
+
+ label = "Replace all Actors in level to this version"
+ icon = "arrow-up"
+
+ def process(self, containers):
+ update_assets(containers, False)
+
+
+class UpdateSelectedActors(InventoryAction):
+ """Update only the selected Actors in the current level to the version
+ of the asset selected in the scene manager.
+ """
+
+ label = "Replace selected Actors in level to this version"
+ icon = "arrow-up"
+
+ def process(self, containers):
+ update_assets(containers, True)
diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py
index 1d60b63f9a..0328d2ae9f 100644
--- a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py
+++ b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py
@@ -69,7 +69,7 @@ class AnimationAlembicLoader(plugin.Loader):
"""
# Create directory for asset and ayon container
- root = "/Game/Ayon/Assets"
+ root = unreal_pipeline.AYON_ASSET_DIR
asset = context.get('asset').get('name')
suffix = "_CON"
if asset:
diff --git a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py
index 13ba236a7d..ec9c52b9fb 100644
--- a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py
+++ b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py
@@ -7,7 +7,11 @@ from openpype.pipeline import (
AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
-from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+from openpype.hosts.unreal.api.pipeline import (
+ AYON_ASSET_DIR,
+ create_container,
+ imprint,
+)
import unreal # noqa
@@ -21,8 +25,11 @@ class PointCacheAlembicLoader(plugin.Loader):
icon = "cube"
color = "orange"
+ root = AYON_ASSET_DIR
+
+ @staticmethod
def get_task(
- self, filename, asset_dir, asset_name, replace,
+ filename, asset_dir, asset_name, replace,
frame_start=None, frame_end=None
):
task = unreal.AssetImportTask()
@@ -38,8 +45,6 @@ class PointCacheAlembicLoader(plugin.Loader):
task.set_editor_property('automated', True)
task.set_editor_property('save', True)
- # set import options here
- # Unreal 4.24 ignores the settings. It works with Unreal 4.26
options.set_editor_property(
'import_type', unreal.AlembicImportType.GEOMETRY_CACHE)
@@ -64,13 +69,42 @@ class PointCacheAlembicLoader(plugin.Loader):
return task
- def load(self, context, name, namespace, data):
- """Load and containerise representation into Content Browser.
+ def import_and_containerize(
+ self, filepath, asset_dir, asset_name, container_name,
+ frame_start, frame_end
+ ):
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
- This is two step process. First, import FBX to temporary path and
- then call `containerise()` on it - this moves all content to new
- directory and then it will create AssetContainer there and imprint it
- with metadata. This will mark this path as container.
+ task = self.get_task(
+ filepath, asset_dir, asset_name, False, frame_start, frame_end)
+
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+
+ # Create Asset Container
+ create_container(container=container_name, path=asset_dir)
+
+ def imprint(
+ self, asset, asset_dir, container_name, asset_name, representation,
+ frame_start, frame_end
+ ):
+ data = {
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
+ "asset": asset,
+ "namespace": asset_dir,
+ "container_name": container_name,
+ "asset_name": asset_name,
+ "loader": str(self.__class__.__name__),
+ "representation": representation["_id"],
+ "parent": representation["parent"],
+ "family": representation["context"]["family"],
+ "frame_start": frame_start,
+ "frame_end": frame_end
+ }
+ imprint(f"{asset_dir}/{container_name}", data)
+
+ def load(self, context, name, namespace, options):
+ """Load and containerise representation into Content Browser.
Args:
context (dict): application context
@@ -79,30 +113,28 @@ class PointCacheAlembicLoader(plugin.Loader):
This is not passed here, so namespace is set
by `containerise()` because only then we know
real path.
- data (dict): Those would be data to be imprinted. This is not used
- now, data are imprinted by `containerise()`.
+ data (dict): Those would be data to be imprinted.
Returns:
list(str): list of container content
-
"""
# Create directory for asset and Ayon container
- root = "/Game/Ayon/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
- if asset:
- asset_name = "{}_{}".format(asset, name)
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
+ version = context.get('version')
+ # Check if version is hero version and use different name
+ if not version.get("name") and version.get('type') == "hero_version":
+ name_version = f"{name}_hero"
else:
- asset_name = "{}".format(name)
+ name_version = f"{name}_v{version.get('name'):03d}"
tools = unreal.AssetToolsHelpers().get_asset_tools()
asset_dir, container_name = tools.create_unique_asset_name(
- "{}/{}/{}".format(root, asset, name), suffix="")
+ f"{self.root}/{asset}/{name_version}", suffix="")
container_name += suffix
- unreal.EditorAssetLibrary.make_directory(asset_dir)
-
frame_start = context.get('asset').get('data').get('frameStart')
frame_end = context.get('asset').get('data').get('frameEnd')
@@ -111,30 +143,16 @@ class PointCacheAlembicLoader(plugin.Loader):
if frame_start == frame_end:
frame_end += 1
- path = self.filepath_from_context(context)
- task = self.get_task(
- path, asset_dir, asset_name, False, frame_start, frame_end)
+ if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
+ path = self.filepath_from_context(context)
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
+ self.import_and_containerize(
+ path, asset_dir, asset_name, container_name,
+ frame_start, frame_end)
- # Create Asset Container
- unreal_pipeline.create_container(
- container=container_name, path=asset_dir)
-
- data = {
- "schema": "ayon:container-2.0",
- "id": AYON_CONTAINER_ID,
- "asset": asset,
- "namespace": asset_dir,
- "container_name": container_name,
- "asset_name": asset_name,
- "loader": str(self.__class__.__name__),
- "representation": context["representation"]["_id"],
- "parent": context["representation"]["parent"],
- "family": context["representation"]["context"]["family"]
- }
- unreal_pipeline.imprint(
- "{}/{}".format(asset_dir, container_name), data)
+ self.imprint(
+ asset, asset_dir, container_name, asset_name,
+ context["representation"], frame_start, frame_end)
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
@@ -146,27 +164,43 @@ class PointCacheAlembicLoader(plugin.Loader):
return asset_content
def update(self, container, representation):
- name = container["asset_name"]
- source_path = get_representation_path(representation)
- destination_path = container["namespace"]
- representation["context"]
+ context = representation.get("context", {})
- task = self.get_task(source_path, destination_path, name, False)
- # do import fbx and replace existing data
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+ unreal.log_warning(context)
- container_path = "{}/{}".format(container["namespace"],
- container["objectName"])
- # update metadata
- unreal_pipeline.imprint(
- container_path,
- {
- "representation": str(representation["_id"]),
- "parent": str(representation["parent"])
- })
+ if not context:
+ raise RuntimeError("No context found in representation")
+
+ # Create directory for asset and Ayon container
+ asset = context.get('asset')
+ name = context.get('subset')
+ suffix = "_CON"
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
+ version = context.get('version')
+ # Check if version is hero version and use different name
+ name_version = f"{name}_v{version:03d}" if version else f"{name}_hero"
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ f"{self.root}/{asset}/{name_version}", suffix="")
+
+ container_name += suffix
+
+ frame_start = int(container.get("frame_start"))
+ frame_end = int(container.get("frame_end"))
+
+ if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
+ path = get_representation_path(representation)
+
+ self.import_and_containerize(
+ path, asset_dir, asset_name, container_name,
+ frame_start, frame_end)
+
+ self.imprint(
+ asset, asset_dir, container_name, asset_name, representation,
+ frame_start, frame_end)
asset_content = unreal.EditorAssetLibrary.list_assets(
- destination_path, recursive=True, include_folder=True
+ asset_dir, recursive=True, include_folder=False
)
for a in asset_content:
diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py
index 9285602b64..8ebd9a82b6 100644
--- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py
+++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py
@@ -7,7 +7,11 @@ from openpype.pipeline import (
AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
-from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+from openpype.hosts.unreal.api.pipeline import (
+ AYON_ASSET_DIR,
+ create_container,
+ imprint,
+)
import unreal # noqa
@@ -20,10 +24,12 @@ class SkeletalMeshAlembicLoader(plugin.Loader):
icon = "cube"
color = "orange"
- def get_task(self, filename, asset_dir, asset_name, replace):
+ root = AYON_ASSET_DIR
+
+ @staticmethod
+ def get_task(filename, asset_dir, asset_name, replace, default_conversion):
task = unreal.AssetImportTask()
options = unreal.AbcImportSettings()
- sm_settings = unreal.AbcStaticMeshSettings()
conversion_settings = unreal.AbcConversionSettings(
preset=unreal.AbcConversionPreset.CUSTOM,
flip_u=False, flip_v=False,
@@ -37,72 +43,38 @@ class SkeletalMeshAlembicLoader(plugin.Loader):
task.set_editor_property('automated', True)
task.set_editor_property('save', True)
- # set import options here
- # Unreal 4.24 ignores the settings. It works with Unreal 4.26
options.set_editor_property(
'import_type', unreal.AlembicImportType.SKELETAL)
- options.static_mesh_settings = sm_settings
- options.conversion_settings = conversion_settings
+ if not default_conversion:
+ conversion_settings = unreal.AbcConversionSettings(
+ preset=unreal.AbcConversionPreset.CUSTOM,
+ flip_u=False, flip_v=False,
+ rotation=[0.0, 0.0, 0.0],
+ scale=[1.0, 1.0, 1.0])
+ options.conversion_settings = conversion_settings
+
task.options = options
return task
- def load(self, context, name, namespace, data):
- """Load and containerise representation into Content Browser.
+ def import_and_containerize(
+ self, filepath, asset_dir, asset_name, container_name,
+ default_conversion=False
+ ):
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
- This is two step process. First, import FBX to temporary path and
- then call `containerise()` on it - this moves all content to new
- directory and then it will create AssetContainer there and imprint it
- with metadata. This will mark this path as container.
+ task = self.get_task(
+ filepath, asset_dir, asset_name, False, default_conversion)
- Args:
- context (dict): application context
- name (str): subset name
- namespace (str): in Unreal this is basically path to container.
- This is not passed here, so namespace is set
- by `containerise()` because only then we know
- real path.
- data (dict): Those would be data to be imprinted. This is not used
- now, data are imprinted by `containerise()`.
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
- Returns:
- list(str): list of container content
- """
-
- # Create directory for asset and ayon container
- root = "/Game/Ayon/Assets"
- asset = context.get('asset').get('name')
- suffix = "_CON"
- if asset:
- asset_name = "{}_{}".format(asset, name)
- else:
- asset_name = "{}".format(name)
- version = context.get('version')
- # Check if version is hero version and use different name
- if not version.get("name") and version.get('type') == "hero_version":
- name_version = f"{name}_hero"
- else:
- name_version = f"{name}_v{version.get('name'):03d}"
-
- tools = unreal.AssetToolsHelpers().get_asset_tools()
- asset_dir, container_name = tools.create_unique_asset_name(
- f"{root}/{asset}/{name_version}", suffix="")
-
- container_name += suffix
-
- if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
- unreal.EditorAssetLibrary.make_directory(asset_dir)
-
- path = self.filepath_from_context(context)
- task = self.get_task(path, asset_dir, asset_name, False)
-
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
-
- # Create Asset Container
- unreal_pipeline.create_container(
- container=container_name, path=asset_dir)
+ # Create Asset Container
+ create_container(container=container_name, path=asset_dir)
+ def imprint(
+ self, asset, asset_dir, container_name, asset_name, representation
+ ):
data = {
"schema": "ayon:container-2.0",
"id": AYON_CONTAINER_ID,
@@ -111,12 +83,57 @@ class SkeletalMeshAlembicLoader(plugin.Loader):
"container_name": container_name,
"asset_name": asset_name,
"loader": str(self.__class__.__name__),
- "representation": context["representation"]["_id"],
- "parent": context["representation"]["parent"],
- "family": context["representation"]["context"]["family"]
+ "representation": representation["_id"],
+ "parent": representation["parent"],
+ "family": representation["context"]["family"]
}
- unreal_pipeline.imprint(
- f"{asset_dir}/{container_name}", data)
+ imprint(f"{asset_dir}/{container_name}", data)
+
+ def load(self, context, name, namespace, options):
+ """Load and containerise representation into Content Browser.
+
+ Args:
+ context (dict): application context
+ name (str): subset name
+ namespace (str): in Unreal this is basically path to container.
+ This is not passed here, so namespace is set
+ by `containerise()` because only then we know
+ real path.
+ data (dict): Those would be data to be imprinted.
+
+ Returns:
+ list(str): list of container content
+ """
+ # Create directory for asset and ayon container
+ asset = context.get('asset').get('name')
+ suffix = "_CON"
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
+ version = context.get('version')
+ # Check if version is hero version and use different name
+ if not version.get("name") and version.get('type') == "hero_version":
+ name_version = f"{name}_hero"
+ else:
+ name_version = f"{name}_v{version.get('name'):03d}"
+
+ default_conversion = False
+ if options.get("default_conversion"):
+ default_conversion = options.get("default_conversion")
+
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ f"{self.root}/{asset}/{name_version}", suffix="")
+
+ container_name += suffix
+
+ if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
+ path = self.filepath_from_context(context)
+
+ self.import_and_containerize(path, asset_dir, asset_name,
+ container_name, default_conversion)
+
+ self.imprint(
+ asset, asset_dir, container_name, asset_name,
+ context["representation"])
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
@@ -128,26 +145,36 @@ class SkeletalMeshAlembicLoader(plugin.Loader):
return asset_content
def update(self, container, representation):
- name = container["asset_name"]
- source_path = get_representation_path(representation)
- destination_path = container["namespace"]
+ context = representation.get("context", {})
- task = self.get_task(source_path, destination_path, name, True)
+ if not context:
+ raise RuntimeError("No context found in representation")
- # do import fbx and replace existing data
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
- container_path = "{}/{}".format(container["namespace"],
- container["objectName"])
- # update metadata
- unreal_pipeline.imprint(
- container_path,
- {
- "representation": str(representation["_id"]),
- "parent": str(representation["parent"])
- })
+ # Create directory for asset and Ayon container
+ asset = context.get('asset')
+ name = context.get('subset')
+ suffix = "_CON"
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
+ version = context.get('version')
+ # Check if version is hero version and use different name
+ name_version = f"{name}_v{version:03d}" if version else f"{name}_hero"
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ f"{self.root}/{asset}/{name_version}", suffix="")
+
+ container_name += suffix
+
+ if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
+ path = get_representation_path(representation)
+
+ self.import_and_containerize(path, asset_dir, asset_name,
+ container_name)
+
+ self.imprint(
+ asset, asset_dir, container_name, asset_name, representation)
asset_content = unreal.EditorAssetLibrary.list_assets(
- destination_path, recursive=True, include_folder=True
+ asset_dir, recursive=True, include_folder=False
)
for a in asset_content:
diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py
index 9aa0e4d1a8..a5a8730732 100644
--- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py
+++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py
@@ -7,7 +7,11 @@ from openpype.pipeline import (
AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
-from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+from openpype.hosts.unreal.api.pipeline import (
+ AYON_ASSET_DIR,
+ create_container,
+ imprint,
+)
import unreal # noqa
@@ -20,14 +24,79 @@ class SkeletalMeshFBXLoader(plugin.Loader):
icon = "cube"
color = "orange"
+ root = AYON_ASSET_DIR
+
+ @staticmethod
+ def get_task(filename, asset_dir, asset_name, replace):
+ task = unreal.AssetImportTask()
+ options = unreal.FbxImportUI()
+
+ task.set_editor_property('filename', filename)
+ task.set_editor_property('destination_path', asset_dir)
+ task.set_editor_property('destination_name', asset_name)
+ task.set_editor_property('replace_existing', replace)
+ task.set_editor_property('automated', True)
+ task.set_editor_property('save', True)
+
+ options.set_editor_property(
+ 'automated_import_should_detect_type', False)
+ options.set_editor_property('import_as_skeletal', True)
+ options.set_editor_property('import_animations', False)
+ options.set_editor_property('import_mesh', True)
+ options.set_editor_property('import_materials', False)
+ options.set_editor_property('import_textures', False)
+ options.set_editor_property('skeleton', None)
+ options.set_editor_property('create_physics_asset', False)
+
+ options.set_editor_property(
+ 'mesh_type_to_import',
+ unreal.FBXImportType.FBXIT_SKELETAL_MESH)
+
+ options.skeletal_mesh_import_data.set_editor_property(
+ 'import_content_type',
+ unreal.FBXImportContentType.FBXICT_ALL)
+
+ options.skeletal_mesh_import_data.set_editor_property(
+ 'normal_import_method',
+ unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS)
+
+ task.options = options
+
+ return task
+
+ def import_and_containerize(
+ self, filepath, asset_dir, asset_name, container_name
+ ):
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
+
+ task = self.get_task(
+ filepath, asset_dir, asset_name, False)
+
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+
+ # Create Asset Container
+ create_container(container=container_name, path=asset_dir)
+
+ def imprint(
+ self, asset, asset_dir, container_name, asset_name, representation
+ ):
+ data = {
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
+ "asset": asset,
+ "namespace": asset_dir,
+ "container_name": container_name,
+ "asset_name": asset_name,
+ "loader": str(self.__class__.__name__),
+ "representation": representation["_id"],
+ "parent": representation["parent"],
+ "family": representation["context"]["family"]
+ }
+ imprint(f"{asset_dir}/{container_name}", data)
+
def load(self, context, name, namespace, options):
"""Load and containerise representation into Content Browser.
- This is a two step process. First, import FBX to temporary path and
- then call `containerise()` on it - this moves all content to new
- directory and then it will create AssetContainer there and imprint it
- with metadata. This will mark this path as container.
-
Args:
context (dict): application context
name (str): subset name
@@ -35,23 +104,15 @@ class SkeletalMeshFBXLoader(plugin.Loader):
This is not passed here, so namespace is set
by `containerise()` because only then we know
real path.
- options (dict): Those would be data to be imprinted. This is not
- used now, data are imprinted by `containerise()`.
+ data (dict): Those would be data to be imprinted.
Returns:
list(str): list of container content
-
"""
# Create directory for asset and Ayon container
- root = "/Game/Ayon/Assets"
- if options and options.get("asset_dir"):
- root = options["asset_dir"]
asset = context.get('asset').get('name')
suffix = "_CON"
- if asset:
- asset_name = "{}_{}".format(asset, name)
- else:
- asset_name = "{}".format(name)
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
version = context.get('version')
# Check if version is hero version and use different name
if not version.get("name") and version.get('type') == "hero_version":
@@ -61,67 +122,20 @@ class SkeletalMeshFBXLoader(plugin.Loader):
tools = unreal.AssetToolsHelpers().get_asset_tools()
asset_dir, container_name = tools.create_unique_asset_name(
- f"{root}/{asset}/{name_version}", suffix="")
+ f"{self.root}/{asset}/{name_version}", suffix=""
+ )
container_name += suffix
if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
- unreal.EditorAssetLibrary.make_directory(asset_dir)
-
- task = unreal.AssetImportTask()
-
path = self.filepath_from_context(context)
- task.set_editor_property('filename', path)
- task.set_editor_property('destination_path', asset_dir)
- task.set_editor_property('destination_name', asset_name)
- task.set_editor_property('replace_existing', False)
- task.set_editor_property('automated', True)
- task.set_editor_property('save', False)
- # set import options here
- options = unreal.FbxImportUI()
- options.set_editor_property('import_as_skeletal', True)
- options.set_editor_property('import_animations', False)
- options.set_editor_property('import_mesh', True)
- options.set_editor_property('import_materials', False)
- options.set_editor_property('import_textures', False)
- options.set_editor_property('skeleton', None)
- options.set_editor_property('create_physics_asset', False)
+ self.import_and_containerize(
+ path, asset_dir, asset_name, container_name)
- options.set_editor_property(
- 'mesh_type_to_import',
- unreal.FBXImportType.FBXIT_SKELETAL_MESH)
-
- options.skeletal_mesh_import_data.set_editor_property(
- 'import_content_type',
- unreal.FBXImportContentType.FBXICT_ALL)
- # set to import normals, otherwise Unreal will compute them
- # and it will take a long time, depending on the size of the mesh
- options.skeletal_mesh_import_data.set_editor_property(
- 'normal_import_method',
- unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS)
-
- task.options = options
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
-
- # Create Asset Container
- unreal_pipeline.create_container(
- container=container_name, path=asset_dir)
-
- data = {
- "schema": "ayon:container-2.0",
- "id": AYON_CONTAINER_ID,
- "asset": asset,
- "namespace": asset_dir,
- "container_name": container_name,
- "asset_name": asset_name,
- "loader": str(self.__class__.__name__),
- "representation": context["representation"]["_id"],
- "parent": context["representation"]["parent"],
- "family": context["representation"]["context"]["family"]
- }
- unreal_pipeline.imprint(
- f"{asset_dir}/{container_name}", data)
+ self.imprint(
+ asset, asset_dir, container_name, asset_name,
+ context["representation"])
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
@@ -133,58 +147,36 @@ class SkeletalMeshFBXLoader(plugin.Loader):
return asset_content
def update(self, container, representation):
- name = container["asset_name"]
- source_path = get_representation_path(representation)
- destination_path = container["namespace"]
+ context = representation.get("context", {})
- task = unreal.AssetImportTask()
+ if not context:
+ raise RuntimeError("No context found in representation")
- task.set_editor_property('filename', source_path)
- task.set_editor_property('destination_path', destination_path)
- task.set_editor_property('destination_name', name)
- task.set_editor_property('replace_existing', True)
- task.set_editor_property('automated', True)
- task.set_editor_property('save', True)
+ # Create directory for asset and Ayon container
+ asset = context.get('asset')
+ name = context.get('subset')
+ suffix = "_CON"
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
+ version = context.get('version')
+ # Check if version is hero version and use different name
+ name_version = f"{name}_v{version:03d}" if version else f"{name}_hero"
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ f"{self.root}/{asset}/{name_version}", suffix="")
- # set import options here
- options = unreal.FbxImportUI()
- options.set_editor_property('import_as_skeletal', True)
- options.set_editor_property('import_animations', False)
- options.set_editor_property('import_mesh', True)
- options.set_editor_property('import_materials', True)
- options.set_editor_property('import_textures', True)
- options.set_editor_property('skeleton', None)
- options.set_editor_property('create_physics_asset', False)
+ container_name += suffix
- options.set_editor_property('mesh_type_to_import',
- unreal.FBXImportType.FBXIT_SKELETAL_MESH)
+ if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
+ path = get_representation_path(representation)
- options.skeletal_mesh_import_data.set_editor_property(
- 'import_content_type',
- unreal.FBXImportContentType.FBXICT_ALL
- )
- # set to import normals, otherwise Unreal will compute them
- # and it will take a long time, depending on the size of the mesh
- options.skeletal_mesh_import_data.set_editor_property(
- 'normal_import_method',
- unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
- )
+ self.import_and_containerize(
+ path, asset_dir, asset_name, container_name)
- task.options = options
- # do import fbx and replace existing data
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
- container_path = "{}/{}".format(container["namespace"],
- container["objectName"])
- # update metadata
- unreal_pipeline.imprint(
- container_path,
- {
- "representation": str(representation["_id"]),
- "parent": str(representation["parent"])
- })
+ self.imprint(
+ asset, asset_dir, container_name, asset_name, representation)
asset_content = unreal.EditorAssetLibrary.list_assets(
- destination_path, recursive=True, include_folder=True
+ asset_dir, recursive=True, include_folder=False
)
for a in asset_content:
diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py
index bb13692f9e..019a95a9bf 100644
--- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py
+++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py
@@ -7,7 +7,11 @@ from openpype.pipeline import (
AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
-from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+from openpype.hosts.unreal.api.pipeline import (
+ AYON_ASSET_DIR,
+ create_container,
+ imprint,
+)
import unreal # noqa
@@ -20,6 +24,8 @@ class StaticMeshAlembicLoader(plugin.Loader):
icon = "cube"
color = "orange"
+ root = AYON_ASSET_DIR
+
@staticmethod
def get_task(filename, asset_dir, asset_name, replace, default_conversion):
task = unreal.AssetImportTask()
@@ -53,14 +59,40 @@ class StaticMeshAlembicLoader(plugin.Loader):
return task
+ def import_and_containerize(
+ self, filepath, asset_dir, asset_name, container_name,
+ default_conversion=False
+ ):
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
+
+ task = self.get_task(
+ filepath, asset_dir, asset_name, False, default_conversion)
+
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+
+ # Create Asset Container
+ create_container(container=container_name, path=asset_dir)
+
+ def imprint(
+ self, asset, asset_dir, container_name, asset_name, representation
+ ):
+ data = {
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
+ "asset": asset,
+ "namespace": asset_dir,
+ "container_name": container_name,
+ "asset_name": asset_name,
+ "loader": str(self.__class__.__name__),
+ "representation": representation["_id"],
+ "parent": representation["parent"],
+ "family": representation["context"]["family"]
+ }
+ imprint(f"{asset_dir}/{container_name}", data)
+
def load(self, context, name, namespace, options):
"""Load and containerise representation into Content Browser.
- This is two step process. First, import FBX to temporary path and
- then call `containerise()` on it - this moves all content to new
- directory and then it will create AssetContainer there and imprint it
- with metadata. This will mark this path as container.
-
Args:
context (dict): application context
name (str): subset name
@@ -68,15 +100,12 @@ class StaticMeshAlembicLoader(plugin.Loader):
This is not passed here, so namespace is set
by `containerise()` because only then we know
real path.
- data (dict): Those would be data to be imprinted. This is not used
- now, data are imprinted by `containerise()`.
+ data (dict): Those would be data to be imprinted.
Returns:
list(str): list of container content
-
"""
# Create directory for asset and Ayon container
- root = "/Game/Ayon/Assets"
asset = context.get('asset').get('name')
suffix = "_CON"
asset_name = f"{asset}_{name}" if asset else f"{name}"
@@ -93,39 +122,22 @@ class StaticMeshAlembicLoader(plugin.Loader):
tools = unreal.AssetToolsHelpers().get_asset_tools()
asset_dir, container_name = tools.create_unique_asset_name(
- f"{root}/{asset}/{name_version}", suffix="")
+ f"{self.root}/{asset}/{name_version}", suffix="")
container_name += suffix
if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
- unreal.EditorAssetLibrary.make_directory(asset_dir)
-
path = self.filepath_from_context(context)
- task = self.get_task(
- path, asset_dir, asset_name, False, default_conversion)
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
+ self.import_and_containerize(path, asset_dir, asset_name,
+ container_name, default_conversion)
- # Create Asset Container
- unreal_pipeline.create_container(
- container=container_name, path=asset_dir)
-
- data = {
- "schema": "ayon:container-2.0",
- "id": AYON_CONTAINER_ID,
- "asset": asset,
- "namespace": asset_dir,
- "container_name": container_name,
- "asset_name": asset_name,
- "loader": str(self.__class__.__name__),
- "representation": context["representation"]["_id"],
- "parent": context["representation"]["parent"],
- "family": context["representation"]["context"]["family"]
- }
- unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data)
+ self.imprint(
+ asset, asset_dir, container_name, asset_name,
+ context["representation"])
asset_content = unreal.EditorAssetLibrary.list_assets(
- asset_dir, recursive=True, include_folder=True
+ asset_dir, recursive=True, include_folder=False
)
for a in asset_content:
@@ -134,27 +146,36 @@ class StaticMeshAlembicLoader(plugin.Loader):
return asset_content
def update(self, container, representation):
- name = container["asset_name"]
- source_path = get_representation_path(representation)
- destination_path = container["namespace"]
+ context = representation.get("context", {})
- task = self.get_task(source_path, destination_path, name, True, False)
+ if not context:
+ raise RuntimeError("No context found in representation")
- # do import fbx and replace existing data
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+ # Create directory for asset and Ayon container
+ asset = context.get('asset')
+ name = context.get('subset')
+ suffix = "_CON"
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
+ version = context.get('version')
+ # Check if version is hero version and use different name
+ name_version = f"{name}_v{version:03d}" if version else f"{name}_hero"
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ f"{self.root}/{asset}/{name_version}", suffix="")
- container_path = "{}/{}".format(container["namespace"],
- container["objectName"])
- # update metadata
- unreal_pipeline.imprint(
- container_path,
- {
- "representation": str(representation["_id"]),
- "parent": str(representation["parent"])
- })
+ container_name += suffix
+
+ if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
+ path = get_representation_path(representation)
+
+ self.import_and_containerize(path, asset_dir, asset_name,
+ container_name)
+
+ self.imprint(
+ asset, asset_dir, container_name, asset_name, representation)
asset_content = unreal.EditorAssetLibrary.list_assets(
- destination_path, recursive=True, include_folder=True
+ asset_dir, recursive=True, include_folder=False
)
for a in asset_content:
diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py
index ffc68d8375..66088d793c 100644
--- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py
+++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py
@@ -7,7 +7,11 @@ from openpype.pipeline import (
AYON_CONTAINER_ID
)
from openpype.hosts.unreal.api import plugin
-from openpype.hosts.unreal.api import pipeline as unreal_pipeline
+from openpype.hosts.unreal.api.pipeline import (
+ AYON_ASSET_DIR,
+ create_container,
+ imprint,
+)
import unreal # noqa
@@ -20,6 +24,8 @@ class StaticMeshFBXLoader(plugin.Loader):
icon = "cube"
color = "orange"
+ root = AYON_ASSET_DIR
+
@staticmethod
def get_task(filename, asset_dir, asset_name, replace):
task = unreal.AssetImportTask()
@@ -46,14 +52,39 @@ class StaticMeshFBXLoader(plugin.Loader):
return task
+ def import_and_containerize(
+ self, filepath, asset_dir, asset_name, container_name
+ ):
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
+
+ task = self.get_task(
+ filepath, asset_dir, asset_name, False)
+
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+
+ # Create Asset Container
+ create_container(container=container_name, path=asset_dir)
+
+ def imprint(
+ self, asset, asset_dir, container_name, asset_name, representation
+ ):
+ data = {
+ "schema": "ayon:container-2.0",
+ "id": AYON_CONTAINER_ID,
+ "asset": asset,
+ "namespace": asset_dir,
+ "container_name": container_name,
+ "asset_name": asset_name,
+ "loader": str(self.__class__.__name__),
+ "representation": representation["_id"],
+ "parent": representation["parent"],
+ "family": representation["context"]["family"]
+ }
+ imprint(f"{asset_dir}/{container_name}", data)
+
def load(self, context, name, namespace, options):
"""Load and containerise representation into Content Browser.
- This is two step process. First, import FBX to temporary path and
- then call `containerise()` on it - this moves all content to new
- directory and then it will create AssetContainer there and imprint it
- with metadata. This will mark this path as container.
-
Args:
context (dict): application context
name (str): subset name
@@ -61,23 +92,15 @@ class StaticMeshFBXLoader(plugin.Loader):
This is not passed here, so namespace is set
by `containerise()` because only then we know
real path.
- options (dict): Those would be data to be imprinted. This is not
- used now, data are imprinted by `containerise()`.
+ options (dict): Those would be data to be imprinted.
Returns:
list(str): list of container content
"""
-
# Create directory for asset and Ayon container
- root = "/Game/Ayon/Assets"
- if options and options.get("asset_dir"):
- root = options["asset_dir"]
asset = context.get('asset').get('name')
suffix = "_CON"
- if asset:
- asset_name = "{}_{}".format(asset, name)
- else:
- asset_name = "{}".format(name)
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
version = context.get('version')
# Check if version is hero version and use different name
if not version.get("name") and version.get('type') == "hero_version":
@@ -87,35 +110,20 @@ class StaticMeshFBXLoader(plugin.Loader):
tools = unreal.AssetToolsHelpers().get_asset_tools()
asset_dir, container_name = tools.create_unique_asset_name(
- f"{root}/{asset}/{name_version}", suffix=""
+ f"{self.root}/{asset}/{name_version}", suffix=""
)
container_name += suffix
- unreal.EditorAssetLibrary.make_directory(asset_dir)
+ if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
+ path = self.filepath_from_context(context)
- path = self.filepath_from_context(context)
- task = self.get_task(path, asset_dir, asset_name, False)
+ self.import_and_containerize(
+ path, asset_dir, asset_name, container_name)
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
-
- # Create Asset Container
- unreal_pipeline.create_container(
- container=container_name, path=asset_dir)
-
- data = {
- "schema": "ayon:container-2.0",
- "id": AYON_CONTAINER_ID,
- "asset": asset,
- "namespace": asset_dir,
- "container_name": container_name,
- "asset_name": asset_name,
- "loader": str(self.__class__.__name__),
- "representation": context["representation"]["_id"],
- "parent": context["representation"]["parent"],
- "family": context["representation"]["context"]["family"]
- }
- unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data)
+ self.imprint(
+ asset, asset_dir, container_name, asset_name,
+ context["representation"])
asset_content = unreal.EditorAssetLibrary.list_assets(
asset_dir, recursive=True, include_folder=True
@@ -127,27 +135,36 @@ class StaticMeshFBXLoader(plugin.Loader):
return asset_content
def update(self, container, representation):
- name = container["asset_name"]
- source_path = get_representation_path(representation)
- destination_path = container["namespace"]
+ context = representation.get("context", {})
- task = self.get_task(source_path, destination_path, name, True)
+ if not context:
+ raise RuntimeError("No context found in representation")
- # do import fbx and replace existing data
- unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+ # Create directory for asset and Ayon container
+ asset = context.get('asset')
+ name = context.get('subset')
+ suffix = "_CON"
+ asset_name = f"{asset}_{name}" if asset else f"{name}"
+ version = context.get('version')
+ # Check if version is hero version and use different name
+ name_version = f"{name}_v{version:03d}" if version else f"{name}_hero"
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ f"{self.root}/{asset}/{name_version}", suffix="")
- container_path = "{}/{}".format(container["namespace"],
- container["objectName"])
- # update metadata
- unreal_pipeline.imprint(
- container_path,
- {
- "representation": str(representation["_id"]),
- "parent": str(representation["parent"])
- })
+ container_name += suffix
+
+ if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir):
+ path = get_representation_path(representation)
+
+ self.import_and_containerize(
+ path, asset_dir, asset_name, container_name)
+
+ self.imprint(
+ asset, asset_dir, container_name, asset_name, representation)
asset_content = unreal.EditorAssetLibrary.list_assets(
- destination_path, recursive=True, include_folder=True
+ asset_dir, recursive=True, include_folder=False
)
for a in asset_content:
diff --git a/openpype/hosts/unreal/plugins/load/load_uasset.py b/openpype/hosts/unreal/plugins/load/load_uasset.py
index 88aaac41e8..dfd92d2fe5 100644
--- a/openpype/hosts/unreal/plugins/load/load_uasset.py
+++ b/openpype/hosts/unreal/plugins/load/load_uasset.py
@@ -41,7 +41,7 @@ class UAssetLoader(plugin.Loader):
"""
# Create directory for asset and Ayon container
- root = "/Game/Ayon/Assets"
+ root = unreal_pipeline.AYON_ASSET_DIR
asset = context.get('asset').get('name')
suffix = "_CON"
asset_name = f"{asset}_{name}" if asset else f"{name}"
diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py
index 22f5029bac..780ed7c484 100644
--- a/openpype/hosts/unreal/plugins/load/load_yeticache.py
+++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py
@@ -86,7 +86,7 @@ class YetiLoader(plugin.Loader):
raise RuntimeError("Groom plugin is not activated.")
# Create directory for asset and Ayon container
- root = "/Game/Ayon/Assets"
+ root = unreal_pipeline.AYON_ASSET_DIR
asset = context.get('asset').get('name')
suffix = "_CON"
asset_name = f"{asset}_{name}" if asset else f"{name}"
diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py
index 96485d5a2d..06acbf0992 100644
--- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py
+++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py
@@ -3,6 +3,7 @@ import os
import re
import pyblish.api
+from openpype.pipeline.publish import PublishValidationError
class ValidateSequenceFrames(pyblish.api.InstancePlugin):
@@ -39,8 +40,22 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin):
collections, remainder = clique.assemble(
repr["files"], minimum_items=1, patterns=patterns)
- assert not remainder, "Must not have remainder"
- assert len(collections) == 1, "Must detect single collection"
+ if remainder:
+ raise PublishValidationError(
+ "Some files have been found outside a sequence. "
+ f"Invalid files: {remainder}")
+ if not collections:
+ raise PublishValidationError(
+ "We have been unable to find a sequence in the "
+ "files. Please ensure the files are named "
+ "appropriately. "
+ f"Files: {repr_files}")
+ if len(collections) > 1:
+ raise PublishValidationError(
+ "Multiple collections detected. There should be a single "
+ "collection per representation. "
+ f"Collections identified: {collections}")
+
collection = collections[0]
frames = list(collection.indexes)
@@ -53,8 +68,12 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin):
data["clipOut"])
if current_range != required_range:
- raise ValueError(f"Invalid frame range: {current_range} - "
- f"expected: {required_range}")
+ raise PublishValidationError(
+ f"Invalid frame range: {current_range} - "
+ f"expected: {required_range}")
missing = collection.holes().indexes
- assert not missing, "Missing frames: %s" % (missing,)
+ if missing:
+ raise PublishValidationError(
+ "Missing frames have been detected. "
+ f"Missing frames: {missing}")
diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py
index 1416255083..6bb67ef260 100644
--- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py
+++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py
@@ -156,8 +156,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin):
self.log.debug("frameEnd:: {}".format(
instance.data["frameEnd"]))
except Exception:
- self.log.warning("Unable to count frames "
- "duration {}".format(no_of_frames))
+ self.log.warning("Unable to count frames duration.")
instance.data["handleStart"] = asset_doc["data"]["handleStart"]
instance.data["handleEnd"] = asset_doc["data"]["handleEnd"]
diff --git a/openpype/modules/base.py b/openpype/modules/base.py
index a3c21718b9..e8b85d0e93 100644
--- a/openpype/modules/base.py
+++ b/openpype/modules/base.py
@@ -31,13 +31,13 @@ from openpype.settings.lib import (
get_studio_system_settings_overrides,
load_json_file
)
+from openpype.settings.ayon_settings import is_dev_mode_enabled
from openpype.lib import (
Logger,
import_filepath,
import_module_from_dirpath,
)
-from openpype.lib.openpype_version import is_staging_enabled
from .interfaces import (
OpenPypeInterface,
@@ -66,6 +66,7 @@ IGNORED_FILENAMES_IN_AYON = {
"shotgrid",
"sync_server",
"slack",
+ "kitsu",
}
@@ -317,21 +318,10 @@ def load_modules(force=False):
time.sleep(0.1)
-def _get_ayon_addons_information():
- """Receive information about addons to use from server.
-
- Todos:
- Actually ask server for the information.
- Allow project name as optional argument to be able to query information
- about used addons for specific project.
- Returns:
- List[Dict[str, Any]]: List of addon information to use.
- """
-
- output = []
+def _get_ayon_bundle_data():
bundle_name = os.getenv("AYON_BUNDLE_NAME")
bundles = ayon_api.get_bundles()["bundles"]
- final_bundle = next(
+ return next(
(
bundle
for bundle in bundles
@@ -339,10 +329,22 @@ def _get_ayon_addons_information():
),
None
)
- if final_bundle is None:
- return output
- bundle_addons = final_bundle["addons"]
+
+def _get_ayon_addons_information(bundle_info):
+ """Receive information about addons to use from server.
+
+ Todos:
+ Actually ask server for the information.
+ Allow project name as optional argument to be able to query information
+ about used addons for specific project.
+
+ Returns:
+ List[Dict[str, Any]]: List of addon information to use.
+ """
+
+ output = []
+ bundle_addons = bundle_info["addons"]
addons = ayon_api.get_addons_info()["addons"]
for addon in addons:
name = addon["name"]
@@ -378,38 +380,73 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
v3_addons_to_skip = []
- addons_info = _get_ayon_addons_information()
+ bundle_info = _get_ayon_bundle_data()
+ addons_info = _get_ayon_addons_information(bundle_info)
if not addons_info:
return v3_addons_to_skip
+
addons_dir = os.environ.get("AYON_ADDONS_DIR")
if not addons_dir:
addons_dir = os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
"addons"
)
- if not os.path.exists(addons_dir):
+
+ dev_mode_enabled = is_dev_mode_enabled()
+ dev_addons_info = {}
+ if dev_mode_enabled:
+ # Get dev addons info only when dev mode is enabled
+ dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info)
+
+ addons_dir_exists = os.path.exists(addons_dir)
+ if not addons_dir_exists:
log.warning("Addons directory does not exists. Path \"{}\"".format(
addons_dir
))
- return v3_addons_to_skip
for addon_info in addons_info:
addon_name = addon_info["name"]
addon_version = addon_info["version"]
- folder_name = "{}_{}".format(addon_name, addon_version)
- addon_dir = os.path.join(addons_dir, folder_name)
- if not os.path.exists(addon_dir):
- log.debug((
- "No localized client code found for addon {} {}."
- ).format(addon_name, addon_version))
+ dev_addon_info = dev_addons_info.get(addon_name, {})
+ use_dev_path = dev_addon_info.get("enabled", False)
+
+ addon_dir = None
+ if use_dev_path:
+ addon_dir = dev_addon_info["path"]
+ if not addon_dir or not os.path.exists(addon_dir):
+ log.warning((
+ "Dev addon {} {} path does not exists. Path \"{}\""
+ ).format(addon_name, addon_version, addon_dir))
+ continue
+
+ elif addons_dir_exists:
+ folder_name = "{}_{}".format(addon_name, addon_version)
+ addon_dir = os.path.join(addons_dir, folder_name)
+ if not os.path.exists(addon_dir):
+ log.debug((
+ "No localized client code found for addon {} {}."
+ ).format(addon_name, addon_version))
+ continue
+
+ if not addon_dir:
continue
sys.path.insert(0, addon_dir)
imported_modules = []
for name in os.listdir(addon_dir):
+ # Ignore of files is implemented to be able to run code from code
+ # where usually is more files than just the addon
+ # Ignore start and setup scripts
+ if name in ("setup.py", "start.py"):
+ continue
+
path = os.path.join(addon_dir, name)
basename, ext = os.path.splitext(name)
+ # Ignore folders/files with dot in name
+ # - dot names cannot be imported in Python
+ if "." in basename:
+ continue
is_dir = os.path.isdir(path)
is_py_file = ext.lower() == ".py"
if not is_py_file and not is_dir:
diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py
index 8f21a920be..6f885c578a 100644
--- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py
@@ -65,9 +65,11 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline):
job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S")
# Deadline requires integers in frame range
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
frames = "{start}-{end}x{step}".format(
- start=int(instance.data["frameStart"]),
- end=int(instance.data["frameEnd"]),
+ start=int(start),
+ end=int(end),
step=int(instance.data["byFrameStep"]),
)
job_info.Frames = frames
diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py
index 0295c2b760..0e57c54959 100644
--- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py
@@ -48,6 +48,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
use_gpu = False
env_allowed_keys = []
env_search_replace_values = {}
+ workfile_dependency = True
@classmethod
def get_attribute_defs(cls):
@@ -83,6 +84,11 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
"suspend_publish",
default=False,
label="Suspend publish"
+ ),
+ BoolDef(
+ "workfile_dependency",
+ default=True,
+ label="Workfile Dependency"
)
]
@@ -313,6 +319,13 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
"AuxFiles": []
}
+ # Add workfile dependency.
+ workfile_dependency = instance.data["attributeValues"].get(
+ "workfile_dependency", self.workfile_dependency
+ )
+ if workfile_dependency:
+ payload["JobInfo"].update({"AssetDependency0": script_path})
+
# TODO: rewrite for baking with sequences
if baking_submission:
payload["JobInfo"].update({
diff --git a/openpype/modules/timers_manager/plugins/publish/start_timer.py b/openpype/modules/timers_manager/plugins/publish/start_timer.py
index 6408327ca1..19a67292f5 100644
--- a/openpype/modules/timers_manager/plugins/publish/start_timer.py
+++ b/openpype/modules/timers_manager/plugins/publish/start_timer.py
@@ -6,8 +6,6 @@ Requires:
import pyblish.api
-from openpype.pipeline import legacy_io
-
class StartTimer(pyblish.api.ContextPlugin):
label = "Start Timer"
@@ -25,9 +23,9 @@ class StartTimer(pyblish.api.ContextPlugin):
self.log.debug("Publish is not affecting running timers.")
return
- project_name = legacy_io.active_project()
- asset_name = legacy_io.Session.get("AVALON_ASSET")
- task_name = legacy_io.Session.get("AVALON_TASK")
+ project_name = context.data["projectName"]
+ asset_name = context.data.get("asset")
+ task_name = context.data.get("task")
if not project_name or not asset_name or not task_name:
self.log.info((
"Current context does not contain all"
diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py
index 2800050496..9f720f6ae9 100644
--- a/openpype/pipeline/colorspace.py
+++ b/openpype/pipeline/colorspace.py
@@ -1,4 +1,3 @@
-from copy import deepcopy
import re
import os
import json
@@ -7,6 +6,7 @@ import functools
import platform
import tempfile
import warnings
+from copy import deepcopy
from openpype import PACKAGE_DIR
from openpype.settings import get_project_settings
@@ -356,7 +356,10 @@ def parse_colorspace_from_filepath(
"Must provide `config_path` if `colorspaces` is not provided."
)
- colorspaces = colorspaces or get_ocio_config_colorspaces(config_path)
+ colorspaces = (
+ colorspaces
+ or get_ocio_config_colorspaces(config_path)["colorspaces"]
+ )
underscored_colorspaces = {
key.replace(" ", "_"): key for key in colorspaces
if " " in key
@@ -393,7 +396,7 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name):
Returns:
bool: True if exists
"""
- colorspaces = get_ocio_config_colorspaces(config_path)
+ colorspaces = get_ocio_config_colorspaces(config_path)["colorspaces"]
if colorspace_name not in colorspaces:
raise KeyError(
"Missing colorspace '{}' in config file '{}'".format(
@@ -530,6 +533,157 @@ def get_ocio_config_colorspaces(config_path):
return CachedData.ocio_config_colorspaces[config_path]
+def convert_colorspace_enumerator_item(
+ colorspace_enum_item,
+ config_items
+):
+ """Convert colorspace enumerator item to dictionary
+
+ Args:
+ colorspace_item (str): colorspace and family in couple
+ config_items (dict[str,dict]): colorspace data
+
+ Returns:
+ dict: colorspace data
+ """
+ if "::" not in colorspace_enum_item:
+ return None
+
+ # split string with `::` separator and set first as key and second as value
+ item_type, item_name = colorspace_enum_item.split("::")
+
+ item_data = None
+ if item_type == "aliases":
+ # loop through all colorspaces and find matching alias
+ for name, _data in config_items.get("colorspaces", {}).items():
+ if item_name in _data.get("aliases", []):
+ item_data = deepcopy(_data)
+ item_data.update({
+ "name": name,
+ "type": "colorspace"
+ })
+ break
+ else:
+ # find matching colorspace item found in labeled_colorspaces
+ item_data = config_items.get(item_type, {}).get(item_name)
+ if item_data:
+ item_data = deepcopy(item_data)
+ item_data.update({
+ "name": item_name,
+ "type": item_type
+ })
+
+ # raise exception if item is not found
+ if not item_data:
+ message_config_keys = ", ".join(
+ "'{}':{}".format(
+ key,
+ set(config_items.get(key, {}).keys())
+ ) for key in config_items.keys()
+ )
+ raise KeyError(
+ "Missing colorspace item '{}' in config data: [{}]".format(
+ colorspace_enum_item, message_config_keys
+ )
+ )
+
+ return item_data
+
+
+def get_colorspaces_enumerator_items(
+ config_items,
+ include_aliases=False,
+ include_looks=False,
+ include_roles=False,
+ include_display_views=False
+):
+ """Get all colorspace data with labels
+
+ Wrapper function for aggregating all names and its families.
+ Families can be used for building menu and submenus in gui.
+
+ Args:
+ config_items (dict[str,dict]): colorspace data coming from
+ `get_ocio_config_colorspaces` function
+ include_aliases (bool): include aliases in result
+ include_looks (bool): include looks in result
+ include_roles (bool): include roles in result
+
+ Returns:
+ list[tuple[str,str]]: colorspace and family in couple
+ """
+ labeled_colorspaces = []
+ aliases = set()
+ colorspaces = set()
+ looks = set()
+ roles = set()
+ display_views = set()
+ for items_type, colorspace_items in config_items.items():
+ if items_type == "colorspaces":
+ for color_name, color_data in colorspace_items.items():
+ if color_data.get("aliases"):
+ aliases.update([
+ (
+ "aliases::{}".format(alias_name),
+ "[alias] {} ({})".format(alias_name, color_name)
+ )
+ for alias_name in color_data["aliases"]
+ ])
+ colorspaces.add((
+ "{}::{}".format(items_type, color_name),
+ "[colorspace] {}".format(color_name)
+ ))
+
+ elif items_type == "looks":
+ looks.update([
+ (
+ "{}::{}".format(items_type, name),
+ "[look] {} ({})".format(name, role_data["process_space"])
+ )
+ for name, role_data in colorspace_items.items()
+ ])
+
+ elif items_type == "displays_views":
+ display_views.update([
+ (
+ "{}::{}".format(items_type, name),
+ "[view (display)] {}".format(name)
+ )
+ for name, _ in colorspace_items.items()
+ ])
+
+ elif items_type == "roles":
+ roles.update([
+ (
+ "{}::{}".format(items_type, name),
+ "[role] {} ({})".format(name, role_data["colorspace"])
+ )
+ for name, role_data in colorspace_items.items()
+ ])
+
+ if roles and include_roles:
+ roles = sorted(roles, key=lambda x: x[0])
+ labeled_colorspaces.extend(roles)
+
+ # add colorspaces as second so it is not first in menu
+ colorspaces = sorted(colorspaces, key=lambda x: x[0])
+ labeled_colorspaces.extend(colorspaces)
+
+ if aliases and include_aliases:
+ aliases = sorted(aliases, key=lambda x: x[0])
+ labeled_colorspaces.extend(aliases)
+
+ if looks and include_looks:
+ looks = sorted(looks, key=lambda x: x[0])
+ labeled_colorspaces.extend(looks)
+
+ if display_views and include_display_views:
+ display_views = sorted(display_views, key=lambda x: x[0])
+ labeled_colorspaces.extend(display_views)
+
+ return labeled_colorspaces
+
+
# TODO: remove this in future - backward compatibility
@deprecated("_get_wrapped_with_subprocess")
def get_colorspace_data_subprocess(config_path):
diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py
index f9e3f86652..25f03ddd3b 100644
--- a/openpype/pipeline/create/context.py
+++ b/openpype/pipeline/create/context.py
@@ -758,7 +758,7 @@ class PublishAttributes:
yield name
def mark_as_stored(self):
- self._origin_data = copy.deepcopy(self._data)
+ self._origin_data = copy.deepcopy(self.data_to_store())
def data_to_store(self):
"""Convert attribute values to "data to store"."""
@@ -912,6 +912,12 @@ class CreatedInstance:
# Create a copy of passed data to avoid changing them on the fly
data = copy.deepcopy(data or {})
+
+ # Pop dictionary values that will be converted to objects to be able
+ # catch changes
+ orig_creator_attributes = data.pop("creator_attributes", None) or {}
+ orig_publish_attributes = data.pop("publish_attributes", None) or {}
+
# Store original value of passed data
self._orig_data = copy.deepcopy(data)
@@ -919,10 +925,6 @@ class CreatedInstance:
data.pop("family", None)
data.pop("subset", None)
- # Pop dictionary values that will be converted to objects to be able
- # catch changes
- orig_creator_attributes = data.pop("creator_attributes", None) or {}
- orig_publish_attributes = data.pop("publish_attributes", None) or {}
# QUESTION Does it make sense to have data stored as ordered dict?
self._data = collections.OrderedDict()
@@ -1039,7 +1041,10 @@ class CreatedInstance:
@property
def origin_data(self):
- return copy.deepcopy(self._orig_data)
+ output = copy.deepcopy(self._orig_data)
+ output["creator_attributes"] = self.creator_attributes.origin_data
+ output["publish_attributes"] = self.publish_attributes.origin_data
+ return output
@property
def creator_identifier(self):
@@ -1095,7 +1100,7 @@ class CreatedInstance:
def changes(self):
"""Calculate and return changes."""
- return TrackChangesItem(self._orig_data, self.data_to_store())
+ return TrackChangesItem(self.origin_data, self.data_to_store())
def mark_as_stored(self):
"""Should be called when instance data are stored.
@@ -1211,7 +1216,7 @@ class CreatedInstance:
publish_attributes = self.publish_attributes.serialize_attributes()
return {
"data": self.data_to_store(),
- "orig_data": copy.deepcopy(self._orig_data),
+ "orig_data": self.origin_data,
"creator_attr_defs": creator_attr_defs,
"publish_attributes": publish_attributes,
"creator_label": self._creator_label,
@@ -1251,7 +1256,7 @@ class CreatedInstance:
creator_identifier=creator_identifier,
creator_label=creator_label,
group_label=group_label,
- creator_attributes=creator_attr_defs
+ creator_attr_defs=creator_attr_defs
)
obj._orig_data = serialized_data["orig_data"]
obj.publish_attributes.deserialize_attributes(publish_attributes)
@@ -2331,6 +2336,10 @@ class CreateContext:
identifier, label, exc_info, add_traceback
)
)
+ else:
+ for update_data in update_list:
+ instance = update_data.instance
+ instance.mark_as_stored()
if failed_info:
raise CreatorsSaveFailed(failed_info)
diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py
index 8a26402bd8..764532cadb 100644
--- a/openpype/pipeline/publish/abstract_collect_render.py
+++ b/openpype/pipeline/publish/abstract_collect_render.py
@@ -11,7 +11,6 @@ import six
import pyblish.api
-from openpype.pipeline import legacy_io
from .publish_plugins import AbstractMetaContextPlugin
@@ -31,7 +30,7 @@ class RenderInstance(object):
label = attr.ib() # label to show in GUI
subset = attr.ib() # subset name
task = attr.ib() # task name
- asset = attr.ib() # asset name (AVALON_ASSET)
+ asset = attr.ib() # asset name
attachTo = attr.ib() # subset name to attach render to
setMembers = attr.ib() # list of nodes/members producing render output
publish = attr.ib() # bool, True to publish instance
@@ -129,7 +128,6 @@ class AbstractCollectRender(pyblish.api.ContextPlugin):
"""Constructor."""
super(AbstractCollectRender, self).__init__(*args, **kwargs)
self._file_path = None
- self._asset = legacy_io.Session["AVALON_ASSET"]
self._context = None
def process(self, context):
diff --git a/openpype/pipeline/publish/constants.py b/openpype/pipeline/publish/constants.py
index dcd3445200..92e3fb089f 100644
--- a/openpype/pipeline/publish/constants.py
+++ b/openpype/pipeline/publish/constants.py
@@ -5,3 +5,7 @@ ValidatePipelineOrder = pyblish.api.ValidatorOrder + 0.05
ValidateContentsOrder = pyblish.api.ValidatorOrder + 0.1
ValidateSceneOrder = pyblish.api.ValidatorOrder + 0.2
ValidateMeshOrder = pyblish.api.ValidatorOrder + 0.3
+
+DEFAULT_PUBLISH_TEMPLATE = "publish"
+DEFAULT_HERO_PUBLISH_TEMPLATE = "hero"
+TRANSIENT_DIR_TEMPLATE = "transient"
diff --git a/openpype/pipeline/publish/contants.py b/openpype/pipeline/publish/contants.py
deleted file mode 100644
index c5296afe9a..0000000000
--- a/openpype/pipeline/publish/contants.py
+++ /dev/null
@@ -1,3 +0,0 @@
-DEFAULT_PUBLISH_TEMPLATE = "publish"
-DEFAULT_HERO_PUBLISH_TEMPLATE = "hero"
-TRANSIENT_DIR_TEMPLATE = "transient"
diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py
index 4d9443f635..4ea2f932f1 100644
--- a/openpype/pipeline/publish/lib.py
+++ b/openpype/pipeline/publish/lib.py
@@ -25,7 +25,7 @@ from openpype.pipeline import (
)
from openpype.pipeline.plugin_discover import DiscoverResult
-from .contants import (
+from .constants import (
DEFAULT_PUBLISH_TEMPLATE,
DEFAULT_HERO_PUBLISH_TEMPLATE,
TRANSIENT_DIR_TEMPLATE
diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py
index b218a34868..99bdb12543 100644
--- a/openpype/pipeline/workfile/workfile_template_builder.py
+++ b/openpype/pipeline/workfile/workfile_template_builder.py
@@ -1472,8 +1472,15 @@ class PlaceholderLoadMixin(object):
context_filters=context_filters
))
+ def _before_placeholder_load(self, placeholder):
+ """Can be overridden. It's called before placeholder representations
+ are loaded.
+ """
+
+ pass
+
def _before_repre_load(self, placeholder, representation):
- """Can be overriden. Is called before representation is loaded."""
+ """Can be overridden. It's called before representation is loaded."""
pass
@@ -1506,7 +1513,7 @@ class PlaceholderLoadMixin(object):
return output
def populate_load_placeholder(self, placeholder, ignore_repre_ids=None):
- """Load placeholder is goind to load matching representations.
+ """Load placeholder is going to load matching representations.
Note:
Ignore repre ids is to avoid loading the same representation again
@@ -1528,7 +1535,7 @@ class PlaceholderLoadMixin(object):
# TODO check loader existence
loader_name = placeholder.data["loader"]
- loader_args = placeholder.data["loader_args"]
+ loader_args = self.parse_loader_args(placeholder.data["loader_args"])
placeholder_representations = self._get_representations(placeholder)
@@ -1550,6 +1557,11 @@ class PlaceholderLoadMixin(object):
self.project_name, filtered_representations
)
loaders_by_name = self.builder.get_loaders_by_name()
+ self._before_placeholder_load(
+ placeholder
+ )
+
+ failed = False
for repre_load_context in repre_load_contexts.values():
representation = repre_load_context["representation"]
repre_context = representation["context"]
@@ -1562,24 +1574,24 @@ class PlaceholderLoadMixin(object):
repre_context["subset"],
repre_context["asset"],
loader_name,
- loader_args
+ placeholder.data["loader_args"],
)
)
try:
container = load_with_repre_context(
loaders_by_name[loader_name],
repre_load_context,
- options=self.parse_loader_args(loader_args)
+ options=loader_args
)
except Exception:
- failed = True
self.load_failed(placeholder, representation)
-
+ failed = True
else:
- failed = False
self.load_succeed(placeholder, container)
- self.post_placeholder_process(placeholder, failed)
+
+ # Run post placeholder process after load of all representations
+ self.post_placeholder_process(placeholder, failed)
if failed:
self.log.debug(
@@ -1599,10 +1611,7 @@ class PlaceholderLoadMixin(object):
placeholder.load_succeed(container)
def post_placeholder_process(self, placeholder, failed):
- """Cleanup placeholder after load of single representation.
-
- Can be called multiple times during placeholder item populating and is
- called even if loading failed.
+ """Cleanup placeholder after load of its corresponding representations.
Args:
placeholder (PlaceholderItem): Item which was just used to load
@@ -1801,10 +1810,7 @@ class PlaceholderCreateMixin(object):
placeholder.create_succeed(creator_instance)
def post_placeholder_process(self, placeholder, failed):
- """Cleanup placeholder after load of single representation.
-
- Can be called multiple times during placeholder item populating and is
- called even if loading failed.
+ """Cleanup placeholder after load of its corresponding representations.
Args:
placeholder (PlaceholderItem): Item which was just used to load
diff --git a/openpype/plugins/load/push_to_library.py b/openpype/plugins/load/push_to_library.py
index dd7291e686..5befc5eb9d 100644
--- a/openpype/plugins/load/push_to_library.py
+++ b/openpype/plugins/load/push_to_library.py
@@ -1,6 +1,6 @@
import os
-from openpype import PACKAGE_DIR
+from openpype import PACKAGE_DIR, AYON_SERVER_ENABLED
from openpype.lib import get_openpype_execute_args, run_detached_process
from openpype.pipeline import load
from openpype.pipeline.load import LoadError
@@ -32,12 +32,22 @@ class PushToLibraryProject(load.SubsetLoaderPlugin):
raise LoadError("Please select only one item")
context = tuple(filtered_contexts)[0]
- push_tool_script_path = os.path.join(
- PACKAGE_DIR,
- "tools",
- "push_to_project",
- "app.py"
- )
+
+ if AYON_SERVER_ENABLED:
+ push_tool_script_path = os.path.join(
+ PACKAGE_DIR,
+ "tools",
+ "ayon_push_to_project",
+ "main.py"
+ )
+ else:
+ push_tool_script_path = os.path.join(
+ PACKAGE_DIR,
+ "tools",
+ "push_to_project",
+ "app.py"
+ )
+
project_doc = context["project"]
version_doc = context["version"]
project_name = project_doc["name"]
diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py
index 57a612c5ae..cfb4d63c1b 100644
--- a/openpype/plugins/publish/collect_resources_path.py
+++ b/openpype/plugins/publish/collect_resources_path.py
@@ -63,7 +63,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
"staticMesh",
"skeletalMesh",
"xgen",
- "yeticacheUE"
+ "yeticacheUE",
+ "tycache"
]
def process(self, instance):
diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py
index ce24dad1b5..5bb51a3049 100644
--- a/openpype/plugins/publish/integrate.py
+++ b/openpype/plugins/publish/integrate.py
@@ -107,6 +107,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
"rig",
"plate",
"look",
+ "ociolook",
"audio",
"yetiRig",
"yeticache",
@@ -140,7 +141,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
"online",
"uasset",
"blendScene",
- "yeticacheUE"
+ "yeticacheUE",
+ "tycache"
]
default_template_name = "publish"
diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py
index b8671f517a..b33d1bf023 100644
--- a/openpype/resources/__init__.py
+++ b/openpype/resources/__init__.py
@@ -55,6 +55,9 @@ def get_openpype_staging_icon_filepath():
def get_openpype_icon_filepath(staging=None):
+ if AYON_SERVER_ENABLED and os.getenv("AYON_USE_DEV") == "1":
+ return get_resource("icons", "AYON_icon_dev.png")
+
if staging is None:
staging = is_running_staging()
@@ -68,7 +71,9 @@ def get_openpype_splash_filepath(staging=None):
staging = is_running_staging()
if AYON_SERVER_ENABLED:
- if staging:
+ if os.getenv("AYON_USE_DEV") == "1":
+ splash_file_name = "AYON_splash_dev.png"
+ elif staging:
splash_file_name = "AYON_splash_staging.png"
else:
splash_file_name = "AYON_splash.png"
diff --git a/openpype/resources/icons/AYON_icon_dev.png b/openpype/resources/icons/AYON_icon_dev.png
new file mode 100644
index 0000000000..3e867ef372
Binary files /dev/null and b/openpype/resources/icons/AYON_icon_dev.png differ
diff --git a/openpype/resources/icons/AYON_splash_dev.png b/openpype/resources/icons/AYON_splash_dev.png
new file mode 100644
index 0000000000..3b366e755d
Binary files /dev/null and b/openpype/resources/icons/AYON_splash_dev.png differ
diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py
index c362670126..fa231cd047 100644
--- a/openpype/scripts/ocio_wrapper.py
+++ b/openpype/scripts/ocio_wrapper.py
@@ -106,11 +106,47 @@ def _get_colorspace_data(config_path):
config = ocio.Config().CreateFromFile(str(config_path))
- return {
- c_.getName(): c_.getFamily()
- for c_ in config.getColorSpaces()
+ colorspace_data = {
+ "roles": {},
+ "colorspaces": {
+ color.getName(): {
+ "family": color.getFamily(),
+ "categories": list(color.getCategories()),
+ "aliases": list(color.getAliases()),
+ "equalitygroup": color.getEqualityGroup(),
+ }
+ for color in config.getColorSpaces()
+ },
+ "displays_views": {
+ f"{view} ({display})": {
+ "display": display,
+ "view": view
+
+ }
+ for display in config.getDisplays()
+ for view in config.getViews(display)
+ },
+ "looks": {}
}
+ # add looks
+ looks = config.getLooks()
+ if looks:
+ colorspace_data["looks"] = {
+ look.getName(): {"process_space": look.getProcessSpace()}
+ for look in looks
+ }
+
+ # add roles
+ roles = config.getRoles()
+ if roles:
+ colorspace_data["roles"] = {
+ role: {"colorspace": colorspace}
+ for (role, colorspace) in roles
+ }
+
+ return colorspace_data
+
@config.command(
name="get_views",
diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py
index 3ccb18111a..8d4683490b 100644
--- a/openpype/settings/ayon_settings.py
+++ b/openpype/settings/ayon_settings.py
@@ -290,6 +290,16 @@ def _convert_modules_system(
modules_settings[key] = value
+def is_dev_mode_enabled():
+ """Dev mode is enabled in AYON.
+
+ Returns:
+ bool: True if dev mode is enabled.
+ """
+
+ return os.getenv("AYON_USE_DEV") == "1"
+
+
def convert_system_settings(ayon_settings, default_settings, addon_versions):
default_settings = copy.deepcopy(default_settings)
output = {
@@ -819,9 +829,43 @@ def _convert_nuke_project_settings(ayon_settings, output):
# NOTE 'monitorOutLut' is maybe not yet in v3 (ut should be)
_convert_host_imageio(ayon_nuke)
ayon_imageio = ayon_nuke["imageio"]
- for item in ayon_imageio["nodes"]["requiredNodes"]:
+
+ # workfile
+ imageio_workfile = ayon_imageio["workfile"]
+ workfile_keys_mapping = (
+ ("color_management", "colorManagement"),
+ ("native_ocio_config", "OCIO_config"),
+ ("working_space", "workingSpaceLUT"),
+ ("thumbnail_space", "monitorLut"),
+ )
+ for src, dst in workfile_keys_mapping:
+ if (
+ src in imageio_workfile
+ and dst not in imageio_workfile
+ ):
+ imageio_workfile[dst] = imageio_workfile.pop(src)
+
+ # regex inputs
+ if "regex_inputs" in ayon_imageio:
+ ayon_imageio["regexInputs"] = ayon_imageio.pop("regex_inputs")
+
+ # nodes
+ ayon_imageio_nodes = ayon_imageio["nodes"]
+ if "required_nodes" in ayon_imageio_nodes:
+ ayon_imageio_nodes["requiredNodes"] = (
+ ayon_imageio_nodes.pop("required_nodes"))
+ if "override_nodes" in ayon_imageio_nodes:
+ ayon_imageio_nodes["overrideNodes"] = (
+ ayon_imageio_nodes.pop("override_nodes"))
+
+ for item in ayon_imageio_nodes["requiredNodes"]:
+ if "nuke_node_class" in item:
+ item["nukeNodeClass"] = item.pop("nuke_node_class")
item["knobs"] = _convert_nuke_knobs(item["knobs"])
- for item in ayon_imageio["nodes"]["overrideNodes"]:
+
+ for item in ayon_imageio_nodes["overrideNodes"]:
+ if "nuke_node_class" in item:
+ item["nukeNodeClass"] = item.pop("nuke_node_class")
item["knobs"] = _convert_nuke_knobs(item["knobs"])
output["nuke"] = ayon_nuke
@@ -1400,15 +1444,39 @@ class _AyonSettingsCache:
if _AyonSettingsCache.variant is None:
from openpype.lib.openpype_version import is_staging_enabled
- _AyonSettingsCache.variant = (
- "staging" if is_staging_enabled() else "production"
- )
+ variant = "production"
+ if is_dev_mode_enabled():
+ variant = cls._get_dev_mode_settings_variant()
+ elif is_staging_enabled():
+ variant = "staging"
+ _AyonSettingsCache.variant = variant
return _AyonSettingsCache.variant
@classmethod
def _get_bundle_name(cls):
return os.environ["AYON_BUNDLE_NAME"]
+ @classmethod
+ def _get_dev_mode_settings_variant(cls):
+ """Develop mode settings variant.
+
+ Returns:
+ str: Name of settings variant.
+ """
+
+ bundles = ayon_api.get_bundles()
+ user = ayon_api.get_user()
+ username = user["name"]
+ for bundle in bundles:
+ if (
+ bundle.get("isDev")
+ and bundle.get("activeUser") == username
+ ):
+ return bundle["name"]
+ # Return fake variant - distribution logic will tell user that he
+ # does not have set any dev bundle
+ return "dev"
+
@classmethod
def get_value_by_project(cls, project_name):
cache_item = _AyonSettingsCache.cache_by_project_name[project_name]
diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json
index e5e535bf19..5766a09100 100644
--- a/openpype/settings/defaults/project_anatomy/templates.json
+++ b/openpype/settings/defaults/project_anatomy/templates.json
@@ -53,6 +53,11 @@
"file": "{originalBasename}<.{@frame}><_{udim}>.{ext}",
"path": "{@folder}/{@file}"
},
+ "tycache": {
+ "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}",
+ "file": "{originalBasename}.{ext}",
+ "path": "{@folder}/{@file}"
+ },
"source": {
"folder": "{root[work]}/{originalDirname}",
"file": "{originalBasename}.{ext}",
@@ -66,6 +71,7 @@
"simpleUnrealTextureHero": "Simple Unreal Texture - Hero",
"simpleUnrealTexture": "Simple Unreal Texture",
"online": "online",
+ "tycache": "tycache",
"source": "source",
"transient": "transient"
}
diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json
index f3eb31174f..7fb8c333a6 100644
--- a/openpype/settings/defaults/project_settings/blender.json
+++ b/openpype/settings/defaults/project_settings/blender.json
@@ -89,10 +89,10 @@
"optional": true,
"active": false
},
- "ExtractABC": {
+ "ExtractModelABC": {
"enabled": true,
"optional": true,
- "active": false
+ "active": true
},
"ExtractBlendAnimation": {
"enabled": true,
diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json
index 0ee7d6127d..ab24727db5 100644
--- a/openpype/settings/defaults/project_settings/fusion.json
+++ b/openpype/settings/defaults/project_settings/fusion.json
@@ -27,5 +27,12 @@
"farm_rendering"
]
}
+ },
+ "publish": {
+ "ValidateSaverResolution": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ }
}
}
diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json
index 06a595d1c5..9ccf5cae05 100644
--- a/openpype/settings/defaults/project_settings/global.json
+++ b/openpype/settings/defaults/project_settings/global.json
@@ -546,6 +546,17 @@
"task_types": [],
"task_names": [],
"template_name": "online"
+ },
+ {
+ "families": [
+ "tycache"
+ ],
+ "hosts": [
+ "max"
+ ],
+ "task_types": [],
+ "task_names": [],
+ "template_name": "tycache"
}
],
"hero_template_name_profiles": [
diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json
index 9d8142d751..87983620ec 100644
--- a/openpype/settings/defaults/project_settings/houdini.json
+++ b/openpype/settings/defaults/project_settings/houdini.json
@@ -25,6 +25,12 @@
},
"shelves": [],
"create": {
+ "CreateAlembicCamera": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
"CreateArnoldAss": {
"enabled": true,
"default_variants": [
@@ -32,6 +38,66 @@
],
"ext": ".ass"
},
+ "CreateArnoldRop": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreateCompositeSequence": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreateHDA": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreateKarmaROP": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreateMantraROP": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreatePointCache": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreateBGEO": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreateRedshiftProxy": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreateRedshiftROP": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
+ "CreateReview": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ]
+ },
"CreateStaticMesh": {
"enabled": true,
"default_variants": [
@@ -45,31 +111,13 @@
"UCX"
]
},
- "CreateAlembicCamera": {
+ "CreateUSD": {
"enabled": true,
"default_variants": [
"Main"
]
},
- "CreateCompositeSequence": {
- "enabled": true,
- "default_variants": [
- "Main"
- ]
- },
- "CreatePointCache": {
- "enabled": true,
- "default_variants": [
- "Main"
- ]
- },
- "CreateRedshiftROP": {
- "enabled": true,
- "default_variants": [
- "Main"
- ]
- },
- "CreateRemotePublish": {
+ "CreateUSDRender": {
"enabled": true,
"default_variants": [
"Main"
@@ -81,32 +129,42 @@
"Main"
]
},
- "CreateUSD": {
- "enabled": false,
- "default_variants": [
- "Main"
- ]
- },
- "CreateUSDModel": {
- "enabled": false,
- "default_variants": [
- "Main"
- ]
- },
- "USDCreateShadingWorkspace": {
- "enabled": false,
- "default_variants": [
- "Main"
- ]
- },
- "CreateUSDRender": {
- "enabled": false,
+ "CreateVrayROP": {
+ "enabled": true,
"default_variants": [
"Main"
]
}
},
"publish": {
+ "CollectRopFrameRange": {
+ "use_asset_handles": true
+ },
+ "ValidateContainers": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
+ "ValidateMeshIsStatic": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
+ "ValidateReviewColorspace": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
+ "ValidateSubsetName": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
+ "ValidateUnrealStaticMeshName": {
+ "enabled": false,
+ "optional": true,
+ "active": true
+ },
"ValidateWorkfilePaths": {
"enabled": true,
"optional": true,
@@ -118,31 +176,6 @@
"$HIP",
"$JOB"
]
- },
- "ValidateReviewColorspace": {
- "enabled": true,
- "optional": true,
- "active": true
- },
- "ValidateContainers": {
- "enabled": true,
- "optional": true,
- "active": true
- },
- "ValidateSubsetName": {
- "enabled": true,
- "optional": true,
- "active": true
- },
- "ValidateMeshIsStatic": {
- "enabled": true,
- "optional": true,
- "active": true
- },
- "ValidateUnrealStaticMeshName": {
- "enabled": false,
- "optional": true,
- "active": true
}
}
}
diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json
index 3b69ef54fd..1cadedd797 100644
--- a/openpype/settings/defaults/project_settings/nuke.json
+++ b/openpype/settings/defaults/project_settings/nuke.json
@@ -374,7 +374,7 @@
"optional": true,
"active": true
},
- "ValidateScript": {
+ "ValidateScriptAttributes": {
"enabled": true,
"optional": true,
"active": true
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json
index 656c50dd98..342411f8a5 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json
@@ -84,6 +84,24 @@
]
}
]
+ },
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "publish",
+ "label": "Publish plugins",
+ "children": [
+ {
+ "type": "schema_template",
+ "name": "template_publish_plugin",
+ "template_data": [
+ {
+ "key": "ValidateSaverResolution",
+ "label": "Validate Saver Resolution"
+ }
+ ]
+ }
+ ]
}
]
}
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json
index 7f1a8a915b..b84c663e6c 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json
@@ -181,12 +181,12 @@
"name": "template_publish_plugin",
"template_data": [
{
- "key": "ExtractFBX",
- "label": "Extract FBX (model and rig)"
+ "key": "ExtractModelABC",
+ "label": "Extract ABC (model)"
},
{
- "key": "ExtractABC",
- "label": "Extract ABC (model and pointcache)"
+ "key": "ExtractFBX",
+ "label": "Extract FBX (model and rig)"
},
{
"key": "ExtractBlendAnimation",
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json
index cd8c260124..f37738c4ec 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json
@@ -4,6 +4,16 @@
"key": "create",
"label": "Creator plugins",
"children": [
+ {
+ "type": "schema_template",
+ "name": "template_create_plugin",
+ "template_data": [
+ {
+ "key": "CreateAlembicCamera",
+ "label": "Create Alembic Camera"
+ }
+ ]
+ },
{
"type": "dict",
"collapsible": true,
@@ -39,6 +49,52 @@
]
},
+ {
+ "type": "schema_template",
+ "name": "template_create_plugin",
+ "template_data": [
+ {
+ "key": "CreateArnoldRop",
+ "label": "Create Arnold ROP"
+ },
+ {
+ "key": "CreateCompositeSequence",
+ "label": "Create Composite (Image Sequence)"
+ },
+ {
+ "key": "CreateHDA",
+ "label": "Create Houdini Digital Asset"
+ },
+ {
+ "key": "CreateKarmaROP",
+ "label": "Create Karma ROP"
+ },
+ {
+ "key": "CreateMantraROP",
+ "label": "Create Mantra ROP"
+ },
+ {
+ "key": "CreatePointCache",
+ "label": "Create PointCache (Abc)"
+ },
+ {
+ "key": "CreateBGEO",
+ "label": "Create PointCache (Bgeo)"
+ },
+ {
+ "key": "CreateRedshiftProxy",
+ "label": "Create Redshift Proxy"
+ },
+ {
+ "key": "CreateRedshiftROP",
+ "label": "Create Redshift ROP"
+ },
+ {
+ "key": "CreateReview",
+ "label": "Create Review"
+ }
+ ]
+ },
{
"type": "dict",
"collapsible": true,
@@ -75,44 +131,20 @@
"name": "template_create_plugin",
"template_data": [
{
- "key": "CreateAlembicCamera",
- "label": "Create Alembic Camera"
+ "key": "CreateUSD",
+ "label": "Create USD (experimental)"
},
{
- "key": "CreateCompositeSequence",
- "label": "Create Composite (Image Sequence)"
- },
- {
- "key": "CreatePointCache",
- "label": "Create Point Cache"
- },
- {
- "key": "CreateRedshiftROP",
- "label": "Create Redshift ROP"
- },
- {
- "key": "CreateRemotePublish",
- "label": "Create Remote Publish"
+ "key": "CreateUSDRender",
+ "label": "Create USD render (experimental)"
},
{
"key": "CreateVDBCache",
"label": "Create VDB Cache"
},
{
- "key": "CreateUSD",
- "label": "Create USD"
- },
- {
- "key": "CreateUSDModel",
- "label": "Create USD Model"
- },
- {
- "key": "USDCreateShadingWorkspace",
- "label": "Create USD Shading Workspace"
- },
- {
- "key": "CreateUSDRender",
- "label": "Create USD Render"
+ "key": "CreateVrayROP",
+ "label": "Create VRay ROP"
}
]
}
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
index d5f70b0312..0de9f21c9f 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
@@ -4,6 +4,57 @@
"key": "publish",
"label": "Publish plugins",
"children": [
+ {
+ "type":"label",
+ "label":"Collectors"
+ },
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "CollectRopFrameRange",
+ "label": "Collect Rop Frame Range",
+ "children": [
+ {
+ "type": "label",
+ "label": "Disable this if you want the publisher to ignore start and end handles specified in the asset data for publish instances"
+ },
+ {
+ "type": "boolean",
+ "key": "use_asset_handles",
+ "label": "Use asset handles"
+ }
+ ]
+ },
+ {
+ "type": "label",
+ "label": "Validators"
+ },
+ {
+ "type": "schema_template",
+ "name": "template_publish_plugin",
+ "template_data": [
+ {
+ "key": "ValidateContainers",
+ "label": "Validate Containers"
+ },
+ {
+ "key": "ValidateMeshIsStatic",
+ "label": "Validate Mesh is Static"
+ },
+ {
+ "key": "ValidateReviewColorspace",
+ "label": "Validate Review Colorspace"
+ },
+ {
+ "key": "ValidateSubsetName",
+ "label": "Validate Subset Name"
+ },
+ {
+ "key": "ValidateUnrealStaticMeshName",
+ "label": "Validate Unreal Static Mesh Name"
+ }
+ ]
+ },
{
"type": "dict",
"collapsible": true,
@@ -35,32 +86,6 @@
"object_type": "text"
}
]
- },
- {
- "type": "schema_template",
- "name": "template_publish_plugin",
- "template_data": [
- {
- "key": "ValidateReviewColorspace",
- "label": "Validate Review Colorspace"
- },
- {
- "key": "ValidateContainers",
- "label": "ValidateContainers"
- },
- {
- "key": "ValidateSubsetName",
- "label": "Validate Subset Name"
- },
- {
- "key": "ValidateMeshIsStatic",
- "label": "Validate Mesh is Static"
- },
- {
- "key": "ValidateUnrealStaticMeshName",
- "label": "Validate Unreal Static Mesh Name"
- }
- ]
}
]
}
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json
index bab9b604b4..35d768843d 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json
@@ -40,6 +40,10 @@
"object_type": {
"type": "dict",
"children": [
+ {
+ "type": "label",
+ "label": "Name and Script Path are mandatory."
+ },
{
"type": "text",
"key": "label",
@@ -68,4 +72,4 @@
}
]
}
-}
\ No newline at end of file
+}
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json
index 9e012e560f..e0cd086119 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json
@@ -113,8 +113,8 @@
"label": "Validate Gizmo (Group)"
},
{
- "key": "ValidateScript",
- "label": "Validate script settings"
+ "key": "ValidateScriptAttributes",
+ "label": "Validate workfile attributes"
}
]
},
diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py
index d9c55f4a64..8957f2b19d 100644
--- a/openpype/tools/attribute_defs/widgets.py
+++ b/openpype/tools/attribute_defs/widgets.py
@@ -251,6 +251,30 @@ class LabelAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
+class ClickableLineEdit(QtWidgets.QLineEdit):
+ clicked = QtCore.Signal()
+
+ def __init__(self, text, parent):
+ super(ClickableLineEdit, self).__init__(parent)
+ self.setText(text)
+ self.setReadOnly(True)
+
+ self._mouse_pressed = False
+
+ def mousePressEvent(self, event):
+ if event.button() == QtCore.Qt.LeftButton:
+ self._mouse_pressed = True
+ super(ClickableLineEdit, self).mousePressEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ if self._mouse_pressed:
+ self._mouse_pressed = False
+ if self.rect().contains(event.pos()):
+ self.clicked.emit()
+
+ super(ClickableLineEdit, self).mouseReleaseEvent(event)
+
+
class NumberAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
decimals = self.attr_def.decimals
@@ -270,20 +294,38 @@ class NumberAttrWidget(_BaseAttrDefWidget):
input_widget.setButtonSymbols(
QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons
)
+ input_line_edit = input_widget.lineEdit()
+ input_widget.installEventFilter(self)
+
+ multisel_widget = ClickableLineEdit("< Multiselection >", self)
+ multisel_widget.setVisible(False)
input_widget.valueChanged.connect(self._on_value_change)
+ multisel_widget.clicked.connect(self._on_multi_click)
self._input_widget = input_widget
+ self._input_line_edit = input_line_edit
+ self._multisel_widget = multisel_widget
+ self._last_multivalue = None
+ self._multivalue = False
self.main_layout.addWidget(input_widget, 0)
+ self.main_layout.addWidget(multisel_widget, 0)
- def _on_value_change(self, new_value):
- self.value_changed.emit(new_value, self.attr_def.id)
+ def eventFilter(self, obj, event):
+ if (
+ self._multivalue
+ and obj is self._input_widget
+ and event.type() == QtCore.QEvent.FocusOut
+ ):
+ self._set_multiselection_visible(True)
+ return False
def current_value(self):
return self._input_widget.value()
def set_value(self, value, multivalue=False):
+ self._last_multivalue = None
if multivalue:
set_value = set(value)
if None in set_value:
@@ -291,13 +333,47 @@ class NumberAttrWidget(_BaseAttrDefWidget):
set_value.add(self.attr_def.default)
if len(set_value) > 1:
- self._input_widget.setSpecialValueText("Multiselection")
+ self._last_multivalue = next(iter(set_value), None)
+ self._set_multiselection_visible(True)
+ self._multivalue = True
return
value = tuple(set_value)[0]
+ self._multivalue = False
+ self._set_multiselection_visible(False)
+
if self.current_value != value:
self._input_widget.setValue(value)
+ def _on_value_change(self, new_value):
+ self._multivalue = False
+ self.value_changed.emit(new_value, self.attr_def.id)
+
+ def _on_multi_click(self):
+ self._set_multiselection_visible(False, True)
+
+ def _set_multiselection_visible(self, visible, change_focus=False):
+ self._input_widget.setVisible(not visible)
+ self._multisel_widget.setVisible(visible)
+ if visible:
+ return
+
+ # Change value once user clicked on the input field
+ if self._last_multivalue is None:
+ value = self.attr_def.default
+ else:
+ value = self._last_multivalue
+ self._input_widget.blockSignals(True)
+ self._input_widget.setValue(value)
+ self._input_widget.blockSignals(False)
+ if not change_focus:
+ return
+ # Change focus to input field and move cursor to the end
+ self._input_widget.setFocus(QtCore.Qt.MouseFocusReason)
+ self._input_line_edit.setCursorPosition(
+ len(self._input_line_edit.text())
+ )
+
class TextAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py
index 3edb04e9eb..177335a933 100644
--- a/openpype/tools/ayon_loader/models/actions.py
+++ b/openpype/tools/ayon_loader/models/actions.py
@@ -447,11 +447,12 @@ class LoaderActionsModel:
project_doc["code"] = project_doc["data"]["code"]
for version_doc in version_docs:
+ version_id = version_doc["_id"]
product_id = version_doc["parent"]
product_doc = product_docs_by_id[product_id]
folder_id = product_doc["parent"]
folder_doc = folder_docs_by_id[folder_id]
- version_context_by_id[product_id] = {
+ version_context_by_id[version_id] = {
"project": project_doc,
"asset": folder_doc,
"subset": product_doc,
diff --git a/openpype/tools/ayon_push_to_project/__init__.py b/openpype/tools/ayon_push_to_project/__init__.py
new file mode 100644
index 0000000000..83df110c96
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/__init__.py
@@ -0,0 +1,6 @@
+from .control import PushToContextController
+
+
+__all__ = (
+ "PushToContextController",
+)
diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py
new file mode 100644
index 0000000000..0a19136701
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/control.py
@@ -0,0 +1,344 @@
+import threading
+
+from openpype.client import (
+ get_asset_by_id,
+ get_subset_by_id,
+ get_version_by_id,
+ get_representations,
+)
+from openpype.settings import get_project_settings
+from openpype.lib import prepare_template_data
+from openpype.lib.events import QueuedEventSystem
+from openpype.pipeline.create import get_subset_name_template
+from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel
+
+from .models import (
+ PushToProjectSelectionModel,
+ UserPublishValuesModel,
+ IntegrateModel,
+)
+
+
+class PushToContextController:
+ def __init__(self, project_name=None, version_id=None):
+ self._event_system = self._create_event_system()
+
+ self._projects_model = ProjectsModel(self)
+ self._hierarchy_model = HierarchyModel(self)
+ self._integrate_model = IntegrateModel(self)
+
+ self._selection_model = PushToProjectSelectionModel(self)
+ self._user_values = UserPublishValuesModel(self)
+
+ self._src_project_name = None
+ self._src_version_id = None
+ self._src_asset_doc = None
+ self._src_subset_doc = None
+ self._src_version_doc = None
+ self._src_label = None
+
+ self._submission_enabled = False
+ self._process_thread = None
+ self._process_item_id = None
+
+ self.set_source(project_name, version_id)
+
+ # Events system
+ def emit_event(self, topic, data=None, source=None):
+ """Use implemented event system to trigger event."""
+
+ if data is None:
+ data = {}
+ self._event_system.emit(topic, data, source)
+
+ def register_event_callback(self, topic, callback):
+ self._event_system.add_callback(topic, callback)
+
+ def set_source(self, project_name, version_id):
+ """Set source project and version.
+
+ Args:
+ project_name (Union[str, None]): Source project name.
+ version_id (Union[str, None]): Source version id.
+ """
+
+ if (
+ project_name == self._src_project_name
+ and version_id == self._src_version_id
+ ):
+ return
+
+ self._src_project_name = project_name
+ self._src_version_id = version_id
+ self._src_label = None
+ asset_doc = None
+ subset_doc = None
+ version_doc = None
+ if project_name and version_id:
+ version_doc = get_version_by_id(project_name, version_id)
+
+ if version_doc:
+ subset_doc = get_subset_by_id(project_name, version_doc["parent"])
+
+ if subset_doc:
+ asset_doc = get_asset_by_id(project_name, subset_doc["parent"])
+
+ self._src_asset_doc = asset_doc
+ self._src_subset_doc = subset_doc
+ self._src_version_doc = version_doc
+ if asset_doc:
+ self._user_values.set_new_folder_name(asset_doc["name"])
+ variant = self._get_src_variant()
+ if variant:
+ self._user_values.set_variant(variant)
+
+ comment = version_doc["data"].get("comment")
+ if comment:
+ self._user_values.set_comment(comment)
+
+ self._emit_event(
+ "source.changed",
+ {
+ "project_name": project_name,
+ "version_id": version_id
+ }
+ )
+
+ def get_source_label(self):
+ """Get source label.
+
+ Returns:
+ str: Label describing source project and version as path.
+ """
+
+ if self._src_label is None:
+ self._src_label = self._prepare_source_label()
+ return self._src_label
+
+ def get_project_items(self, sender=None):
+ return self._projects_model.get_project_items(sender)
+
+ def get_folder_items(self, project_name, sender=None):
+ return self._hierarchy_model.get_folder_items(project_name, sender)
+
+ def get_task_items(self, project_name, folder_id, sender=None):
+ return self._hierarchy_model.get_task_items(
+ project_name, folder_id, sender
+ )
+
+ def get_user_values(self):
+ return self._user_values.get_data()
+
+ def set_user_value_folder_name(self, folder_name):
+ self._user_values.set_new_folder_name(folder_name)
+ self._invalidate()
+
+ def set_user_value_variant(self, variant):
+ self._user_values.set_variant(variant)
+ self._invalidate()
+
+ def set_user_value_comment(self, comment):
+ self._user_values.set_comment(comment)
+ self._invalidate()
+
+ def set_selected_project(self, project_name):
+ self._selection_model.set_selected_project(project_name)
+ self._invalidate()
+
+ def set_selected_folder(self, folder_id):
+ self._selection_model.set_selected_folder(folder_id)
+ self._invalidate()
+
+ def set_selected_task(self, task_id, task_name):
+ self._selection_model.set_selected_task(task_id, task_name)
+
+ def get_process_item_status(self, item_id):
+ return self._integrate_model.get_item_status(item_id)
+
+ # Processing methods
+ def submit(self, wait=True):
+ if not self._submission_enabled:
+ return
+
+ if self._process_thread is not None:
+ return
+
+ item_id = self._integrate_model.create_process_item(
+ self._src_project_name,
+ self._src_version_id,
+ self._selection_model.get_selected_project_name(),
+ self._selection_model.get_selected_folder_id(),
+ self._selection_model.get_selected_task_name(),
+ self._user_values.variant,
+ comment=self._user_values.comment,
+ new_folder_name=self._user_values.new_folder_name,
+ dst_version=1
+ )
+
+ self._process_item_id = item_id
+ self._emit_event("submit.started")
+ if wait:
+ self._submit_callback()
+ self._process_item_id = None
+ return item_id
+
+ thread = threading.Thread(target=self._submit_callback)
+ self._process_thread = thread
+ thread.start()
+ return item_id
+
+ def wait_for_process_thread(self):
+ if self._process_thread is None:
+ return
+ self._process_thread.join()
+ self._process_thread = None
+
+ def _prepare_source_label(self):
+ if not self._src_project_name or not self._src_version_id:
+ return "Source is not defined"
+
+ asset_doc = self._src_asset_doc
+ if not asset_doc:
+ return "Source is invalid"
+
+ folder_path_parts = list(asset_doc["data"]["parents"])
+ folder_path_parts.append(asset_doc["name"])
+ folder_path = "/".join(folder_path_parts)
+ subset_doc = self._src_subset_doc
+ version_doc = self._src_version_doc
+ return "Source: {}/{}/{}/v{:0>3}".format(
+ self._src_project_name,
+ folder_path,
+ subset_doc["name"],
+ version_doc["name"]
+ )
+
+ def _get_task_info_from_repre_docs(self, asset_doc, repre_docs):
+ asset_tasks = asset_doc["data"].get("tasks") or {}
+ found_comb = []
+ for repre_doc in repre_docs:
+ context = repre_doc["context"]
+ task_info = context.get("task")
+ if task_info is None:
+ continue
+
+ task_name = None
+ task_type = None
+ if isinstance(task_info, str):
+ task_name = task_info
+ asset_task_info = asset_tasks.get(task_info) or {}
+ task_type = asset_task_info.get("type")
+
+ elif isinstance(task_info, dict):
+ task_name = task_info.get("name")
+ task_type = task_info.get("type")
+
+ if task_name and task_type:
+ return task_name, task_type
+
+ if task_name:
+ found_comb.append((task_name, task_type))
+
+ for task_name, task_type in found_comb:
+ return task_name, task_type
+ return None, None
+
+ def _get_src_variant(self):
+ project_name = self._src_project_name
+ version_doc = self._src_version_doc
+ asset_doc = self._src_asset_doc
+ repre_docs = get_representations(
+ project_name, version_ids=[version_doc["_id"]]
+ )
+ task_name, task_type = self._get_task_info_from_repre_docs(
+ asset_doc, repre_docs
+ )
+
+ project_settings = get_project_settings(project_name)
+ subset_doc = self._src_subset_doc
+ family = subset_doc["data"].get("family")
+ if not family:
+ family = subset_doc["data"]["families"][0]
+ template = get_subset_name_template(
+ self._src_project_name,
+ family,
+ task_name,
+ task_type,
+ None,
+ project_settings=project_settings
+ )
+ template_low = template.lower()
+ variant_placeholder = "{variant}"
+ if (
+ variant_placeholder not in template_low
+ or (not task_name and "{task" in template_low)
+ ):
+ return ""
+
+ idx = template_low.index(variant_placeholder)
+ template_s = template[:idx]
+ template_e = template[idx + len(variant_placeholder):]
+ fill_data = prepare_template_data({
+ "family": family,
+ "task": task_name
+ })
+ try:
+ subset_s = template_s.format(**fill_data)
+ subset_e = template_e.format(**fill_data)
+ except Exception as exc:
+ print("Failed format", exc)
+ return ""
+
+ subset_name = self._src_subset_doc["name"]
+ if (
+ (subset_s and not subset_name.startswith(subset_s))
+ or (subset_e and not subset_name.endswith(subset_e))
+ ):
+ return ""
+
+ if subset_s:
+ subset_name = subset_name[len(subset_s):]
+ if subset_e:
+ subset_name = subset_name[:len(subset_e)]
+ return subset_name
+
+ def _check_submit_validations(self):
+ if not self._user_values.is_valid:
+ return False
+
+ if not self._selection_model.get_selected_project_name():
+ return False
+
+ if (
+ not self._user_values.new_folder_name
+ and not self._selection_model.get_selected_folder_id()
+ ):
+ return False
+ return True
+
+ def _invalidate(self):
+ submission_enabled = self._check_submit_validations()
+ if submission_enabled == self._submission_enabled:
+ return
+ self._submission_enabled = submission_enabled
+ self._emit_event(
+ "submission.enabled.changed",
+ {"enabled": submission_enabled}
+ )
+
+ def _submit_callback(self):
+ process_item_id = self._process_item_id
+ if process_item_id is None:
+ return
+ self._integrate_model.integrate_item(process_item_id)
+ self._emit_event("submit.finished", {})
+ if process_item_id == self._process_item_id:
+ self._process_item_id = None
+
+ def _emit_event(self, topic, data=None):
+ if data is None:
+ data = {}
+ self.emit_event(topic, data, "controller")
+
+ def _create_event_system(self):
+ return QueuedEventSystem()
diff --git a/openpype/tools/ayon_push_to_project/main.py b/openpype/tools/ayon_push_to_project/main.py
new file mode 100644
index 0000000000..e36940e488
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/main.py
@@ -0,0 +1,32 @@
+import click
+
+from openpype.tools.utils import get_openpype_qt_app
+from openpype.tools.ayon_push_to_project.ui import PushToContextSelectWindow
+
+
+def main_show(project_name, version_id):
+ app = get_openpype_qt_app()
+
+ window = PushToContextSelectWindow()
+ window.show()
+ window.set_source(project_name, version_id)
+
+ app.exec_()
+
+
+@click.command()
+@click.option("--project", help="Source project name")
+@click.option("--version", help="Source version id")
+def main(project, version):
+ """Run PushToProject tool to integrate version in different project.
+
+ Args:
+ project (str): Source project name.
+ version (str): Version id.
+ """
+
+ main_show(project, version)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py
new file mode 100644
index 0000000000..99355b4296
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/models/__init__.py
@@ -0,0 +1,10 @@
+from .selection import PushToProjectSelectionModel
+from .user_values import UserPublishValuesModel
+from .integrate import IntegrateModel
+
+
+__all__ = (
+ "PushToProjectSelectionModel",
+ "UserPublishValuesModel",
+ "IntegrateModel",
+)
diff --git a/openpype/tools/ayon_push_to_project/models/integrate.py b/openpype/tools/ayon_push_to_project/models/integrate.py
new file mode 100644
index 0000000000..976d8cb4f0
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/models/integrate.py
@@ -0,0 +1,1214 @@
+import os
+import re
+import copy
+import socket
+import itertools
+import datetime
+import sys
+import traceback
+import uuid
+
+from bson.objectid import ObjectId
+
+from openpype.client import (
+ get_project,
+ get_assets,
+ get_asset_by_id,
+ get_subset_by_id,
+ get_subset_by_name,
+ get_version_by_id,
+ get_last_version_by_subset_id,
+ get_version_by_name,
+ get_representations,
+)
+from openpype.client.operations import (
+ OperationsSession,
+ new_asset_document,
+ new_subset_document,
+ new_version_doc,
+ new_representation_doc,
+ prepare_version_update_data,
+ prepare_representation_update_data,
+)
+from openpype.modules import ModulesManager
+from openpype.lib import (
+ StringTemplate,
+ get_openpype_username,
+ get_formatted_current_time,
+ source_hash,
+)
+
+from openpype.lib.file_transaction import FileTransaction
+from openpype.settings import get_project_settings
+from openpype.pipeline import Anatomy
+from openpype.pipeline.version_start import get_versioning_start
+from openpype.pipeline.template_data import get_template_data
+from openpype.pipeline.publish import get_publish_template_name
+from openpype.pipeline.create import get_subset_name
+
+UNKNOWN = object()
+
+
+class PushToProjectError(Exception):
+ pass
+
+
+class FileItem(object):
+ def __init__(self, path):
+ self.path = path
+
+ @property
+ def is_valid_file(self):
+ return os.path.exists(self.path) and os.path.isfile(self.path)
+
+
+class SourceFile(FileItem):
+ def __init__(self, path, frame=None, udim=None):
+ super(SourceFile, self).__init__(path)
+ self.frame = frame
+ self.udim = udim
+
+ def __repr__(self):
+ subparts = [self.__class__.__name__]
+ if self.frame is not None:
+ subparts.append("frame: {}".format(self.frame))
+ if self.udim is not None:
+ subparts.append("UDIM: {}".format(self.udim))
+
+ return "<{}> '{}'".format(" - ".join(subparts), self.path)
+
+
+class ResourceFile(FileItem):
+ def __init__(self, path, relative_path):
+ super(ResourceFile, self).__init__(path)
+ self.relative_path = relative_path
+
+ def __repr__(self):
+ return "<{}> '{}'".format(self.__class__.__name__, self.relative_path)
+
+ @property
+ def is_valid_file(self):
+ if not self.relative_path:
+ return False
+ return super(ResourceFile, self).is_valid_file
+
+
+class ProjectPushItem:
+ def __init__(
+ self,
+ src_project_name,
+ src_version_id,
+ dst_project_name,
+ dst_folder_id,
+ dst_task_name,
+ variant,
+ comment,
+ new_folder_name,
+ dst_version,
+ item_id=None,
+ ):
+ if not item_id:
+ item_id = uuid.uuid4().hex
+ self.src_project_name = src_project_name
+ self.src_version_id = src_version_id
+ self.dst_project_name = dst_project_name
+ self.dst_folder_id = dst_folder_id
+ self.dst_task_name = dst_task_name
+ self.dst_version = dst_version
+ self.variant = variant
+ self.new_folder_name = new_folder_name
+ self.comment = comment or ""
+ self.item_id = item_id
+ self._repr_value = None
+
+ @property
+ def _repr(self):
+ if not self._repr_value:
+ self._repr_value = "|".join([
+ self.src_project_name,
+ self.src_version_id,
+ self.dst_project_name,
+ str(self.dst_folder_id),
+ str(self.new_folder_name),
+ str(self.dst_task_name),
+ str(self.dst_version)
+ ])
+ return self._repr_value
+
+ def __repr__(self):
+ return "<{} - {}>".format(self.__class__.__name__, self._repr)
+
+ def to_data(self):
+ return {
+ "src_project_name": self.src_project_name,
+ "src_version_id": self.src_version_id,
+ "dst_project_name": self.dst_project_name,
+ "dst_folder_id": self.dst_folder_id,
+ "dst_task_name": self.dst_task_name,
+ "dst_version": self.dst_version,
+ "variant": self.variant,
+ "comment": self.comment,
+ "new_folder_name": self.new_folder_name,
+ "item_id": self.item_id,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ return cls(**data)
+
+
+class StatusMessage:
+ def __init__(self, message, level):
+ self.message = message
+ self.level = level
+
+ def __str__(self):
+ return "{}: {}".format(self.level.upper(), self.message)
+
+ def __repr__(self):
+ return "<{} - {}> {}".format(
+ self.__class__.__name__, self.level.upper, self.message
+ )
+
+
+class ProjectPushItemStatus:
+ def __init__(
+ self,
+ started=False,
+ failed=False,
+ finished=False,
+ fail_reason=None,
+ full_traceback=None
+ ):
+ self.started = started
+ self.failed = failed
+ self.finished = finished
+ self.fail_reason = fail_reason
+ self.full_traceback = full_traceback
+
+ def set_failed(self, fail_reason, exc_info=None):
+ """Set status as failed.
+
+ Attribute 'fail_reason' can change automatically based on passed value.
+ Reason is unset if 'failed' is 'False' and is set do default reason if
+ is set to 'True' and reason is not set.
+
+ Args:
+ fail_reason (str): Reason why failed.
+ exc_info(tuple): Exception info.
+ """
+
+ failed = True
+ if not fail_reason and not exc_info:
+ failed = False
+
+ full_traceback = None
+ if exc_info is not None:
+ full_traceback = "".join(traceback.format_exception(*exc_info))
+ if not fail_reason:
+ fail_reason = "Failed without specified reason"
+
+ self.failed = failed
+ self.fail_reason = fail_reason or None
+ self.full_traceback = full_traceback
+
+ def to_data(self):
+ return {
+ "started": self.started,
+ "failed": self.failed,
+ "finished": self.finished,
+ "fail_reason": self.fail_reason,
+ "full_traceback": self.full_traceback,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ return cls(**data)
+
+
+class ProjectPushRepreItem:
+ """Representation item.
+
+ Representation item based on representation document and project roots.
+
+ Representation document may have reference to:
+ - source files: Files defined with publish template
+ - resource files: Files that should be in publish directory
+ but filenames are not template based.
+
+ Args:
+ repre_doc (Dict[str, Ant]): Representation document.
+ roots (Dict[str, str]): Project roots (based on project anatomy).
+ """
+
+ def __init__(self, repre_doc, roots):
+ self._repre_doc = repre_doc
+ self._roots = roots
+ self._src_files = None
+ self._resource_files = None
+ self._frame = UNKNOWN
+
+ @property
+ def repre_doc(self):
+ return self._repre_doc
+
+ @property
+ def src_files(self):
+ if self._src_files is None:
+ self.get_source_files()
+ return self._src_files
+
+ @property
+ def resource_files(self):
+ if self._resource_files is None:
+ self.get_source_files()
+ return self._resource_files
+
+ @staticmethod
+ def _clean_path(path):
+ new_value = path.replace("\\", "/")
+ while "//" in new_value:
+ new_value = new_value.replace("//", "/")
+ return new_value
+
+ @staticmethod
+ def _get_relative_path(path, src_dirpath):
+ dirpath, basename = os.path.split(path)
+ if not dirpath.lower().startswith(src_dirpath.lower()):
+ return None
+
+ relative_dir = dirpath[len(src_dirpath):].lstrip("/")
+ if relative_dir:
+ relative_path = "/".join([relative_dir, basename])
+ else:
+ relative_path = basename
+ return relative_path
+
+ @property
+ def frame(self):
+ """First frame of representation files.
+
+ This value will be in representation document context if is sequence.
+
+ Returns:
+ Union[int, None]: First frame in representation files based on
+ source files or None if frame is not part of filename.
+ """
+
+ if self._frame is UNKNOWN:
+ frame = None
+ for src_file in self.src_files:
+ src_frame = src_file.frame
+ if (
+ src_frame is not None
+ and (frame is None or src_frame < frame)
+ ):
+ frame = src_frame
+ self._frame = frame
+ return self._frame
+
+ @staticmethod
+ def validate_source_files(src_files, resource_files):
+ if not src_files:
+ raise AssertionError((
+ "Couldn't figure out source files from representation."
+ " Found resource files {}"
+ ).format(", ".join(str(i) for i in resource_files)))
+
+ invalid_items = [
+ item
+ for item in itertools.chain(src_files, resource_files)
+ if not item.is_valid_file
+ ]
+ if invalid_items:
+ raise AssertionError((
+ "Source files that were not found on disk: {}"
+ ).format(", ".join(str(i) for i in invalid_items)))
+
+ def get_source_files(self):
+ if self._src_files is not None:
+ return self._src_files, self._resource_files
+
+ repre_context = self._repre_doc["context"]
+ if "frame" in repre_context or "udim" in repre_context:
+ src_files, resource_files = self._get_source_files_with_frames()
+ else:
+ src_files, resource_files = self._get_source_files()
+
+ self.validate_source_files(src_files, resource_files)
+
+ self._src_files = src_files
+ self._resource_files = resource_files
+ return self._src_files, self._resource_files
+
+ def _get_source_files_with_frames(self):
+ frame_placeholder = "__frame__"
+ udim_placeholder = "__udim__"
+ src_files = []
+ resource_files = []
+ template = self._repre_doc["data"]["template"]
+ # Remove padding from 'udim' and 'frame' formatting keys
+ # - "{frame:0>4}" -> "{frame}"
+ for key in ("udim", "frame"):
+ sub_part = "{" + key + "[^}]*}"
+ replacement = "{{{}}}".format(key)
+ template = re.sub(sub_part, replacement, template)
+
+ repre_context = self._repre_doc["context"]
+ fill_repre_context = copy.deepcopy(repre_context)
+ if "frame" in fill_repre_context:
+ fill_repre_context["frame"] = frame_placeholder
+
+ if "udim" in fill_repre_context:
+ fill_repre_context["udim"] = udim_placeholder
+
+ fill_roots = fill_repre_context["root"]
+ for root_name in tuple(fill_roots.keys()):
+ fill_roots[root_name] = "{{root[{}]}}".format(root_name)
+ repre_path = StringTemplate.format_template(
+ template, fill_repre_context)
+ repre_path = self._clean_path(repre_path)
+ src_dirpath, src_basename = os.path.split(repre_path)
+ src_basename = (
+ re.escape(src_basename)
+ .replace(frame_placeholder, "(?P[0-9]+)")
+ .replace(udim_placeholder, "(?P[0-9]+)")
+ )
+ src_basename_regex = re.compile("^{}$".format(src_basename))
+ for file_info in self._repre_doc["files"]:
+ filepath_template = self._clean_path(file_info["path"])
+ filepath = self._clean_path(
+ filepath_template.format(root=self._roots)
+ )
+ dirpath, basename = os.path.split(filepath_template)
+ if (
+ dirpath.lower() != src_dirpath.lower()
+ or not src_basename_regex.match(basename)
+ ):
+ relative_path = self._get_relative_path(filepath, src_dirpath)
+ resource_files.append(ResourceFile(filepath, relative_path))
+ continue
+
+ filepath = os.path.join(src_dirpath, basename)
+ frame = None
+ udim = None
+ for item in src_basename_regex.finditer(basename):
+ group_name = item.lastgroup
+ value = item.group(group_name)
+ if group_name == "frame":
+ frame = int(value)
+ elif group_name == "udim":
+ udim = value
+
+ src_files.append(SourceFile(filepath, frame, udim))
+
+ return src_files, resource_files
+
+ def _get_source_files(self):
+ src_files = []
+ resource_files = []
+ template = self._repre_doc["data"]["template"]
+ repre_context = self._repre_doc["context"]
+ fill_repre_context = copy.deepcopy(repre_context)
+ fill_roots = fill_repre_context["root"]
+ for root_name in tuple(fill_roots.keys()):
+ fill_roots[root_name] = "{{root[{}]}}".format(root_name)
+ repre_path = StringTemplate.format_template(template,
+ fill_repre_context)
+ repre_path = self._clean_path(repre_path)
+ src_dirpath = os.path.dirname(repre_path)
+ for file_info in self._repre_doc["files"]:
+ filepath_template = self._clean_path(file_info["path"])
+ filepath = self._clean_path(
+ filepath_template.format(root=self._roots))
+
+ if filepath_template.lower() == repre_path.lower():
+ src_files.append(
+ SourceFile(repre_path.format(root=self._roots))
+ )
+ else:
+ relative_path = self._get_relative_path(
+ filepath_template, src_dirpath
+ )
+ resource_files.append(
+ ResourceFile(filepath, relative_path)
+ )
+ return src_files, resource_files
+
+
+class ProjectPushItemProcess:
+ """
+ Args:
+ model (IntegrateModel): Model which is processing item.
+ item (ProjectPushItem): Item which is being processed.
+ """
+
+ # TODO where to get host?!!!
+ host_name = "republisher"
+
+ def __init__(self, model, item):
+ self._model = model
+ self._item = item
+
+ self._src_asset_doc = None
+ self._src_subset_doc = None
+ self._src_version_doc = None
+ self._src_repre_items = None
+
+ self._project_doc = None
+ self._anatomy = None
+ self._asset_doc = None
+ self._created_asset_doc = None
+ self._task_info = None
+ self._subset_doc = None
+ self._version_doc = None
+
+ self._family = None
+ self._subset_name = None
+
+ self._project_settings = None
+ self._template_name = None
+
+ self._status = ProjectPushItemStatus()
+ self._operations = OperationsSession()
+ self._file_transaction = FileTransaction()
+
+ self._messages = []
+
+ @property
+ def item_id(self):
+ return self._item.item_id
+
+ @property
+ def started(self):
+ return self._status.started
+
+ def get_status_data(self):
+ return self._status.to_data()
+
+ def integrate(self):
+ self._status.started = True
+ try:
+ self._log_info("Process started")
+ self._fill_source_variables()
+ self._log_info("Source entities were found")
+ self._fill_destination_project()
+ self._log_info("Destination project was found")
+ self._fill_or_create_destination_asset()
+ self._log_info("Destination asset was determined")
+ self._determine_family()
+ self._determine_publish_template_name()
+ self._determine_subset_name()
+ self._make_sure_subset_exists()
+ self._make_sure_version_exists()
+ self._log_info("Prerequirements were prepared")
+ self._integrate_representations()
+ self._log_info("Integration finished")
+
+ except PushToProjectError as exc:
+ if not self._status.failed:
+ self._status.set_failed(str(exc))
+
+ except Exception as exc:
+ _exc, _value, _tb = sys.exc_info()
+ self._status.set_failed(
+ "Unhandled error happened: {}".format(str(exc)),
+ (_exc, _value, _tb)
+ )
+
+ finally:
+ self._status.finished = True
+ self._emit_event(
+ "push.finished.changed",
+ {
+ "finished": True,
+ "item_id": self.item_id,
+ }
+ )
+
+ def _emit_event(self, topic, data):
+ self._model.emit_event(topic, data)
+
+ # Loggin helpers
+ # TODO better logging
+ def _add_message(self, message, level):
+ message_obj = StatusMessage(message, level)
+ self._messages.append(message_obj)
+ self._emit_event(
+ "push.message.added",
+ {
+ "message": message,
+ "level": level,
+ "item_id": self.item_id,
+ }
+ )
+ print(message_obj)
+ return message_obj
+
+ def _log_debug(self, message):
+ return self._add_message(message, "debug")
+
+ def _log_info(self, message):
+ return self._add_message(message, "info")
+
+ def _log_warning(self, message):
+ return self._add_message(message, "warning")
+
+ def _log_error(self, message):
+ return self._add_message(message, "error")
+
+ def _log_critical(self, message):
+ return self._add_message(message, "critical")
+
+ def _fill_source_variables(self):
+ src_project_name = self._item.src_project_name
+ src_version_id = self._item.src_version_id
+
+ project_doc = get_project(src_project_name)
+ if not project_doc:
+ self._status.set_failed(
+ f"Source project \"{src_project_name}\" was not found"
+ )
+
+ self._emit_event(
+ "push.failed.changed",
+ {"item_id": self.item_id}
+ )
+ raise PushToProjectError(self._status.fail_reason)
+
+ self._log_debug(f"Project '{src_project_name}' found")
+
+ version_doc = get_version_by_id(src_project_name, src_version_id)
+ if not version_doc:
+ self._status.set_failed((
+ f"Source version with id \"{src_version_id}\""
+ f" was not found in project \"{src_project_name}\""
+ ))
+ raise PushToProjectError(self._status.fail_reason)
+
+ subset_id = version_doc["parent"]
+ subset_doc = get_subset_by_id(src_project_name, subset_id)
+ if not subset_doc:
+ self._status.set_failed((
+ f"Could find subset with id \"{subset_id}\""
+ f" in project \"{src_project_name}\""
+ ))
+ raise PushToProjectError(self._status.fail_reason)
+
+ asset_id = subset_doc["parent"]
+ asset_doc = get_asset_by_id(src_project_name, asset_id)
+ if not asset_doc:
+ self._status.set_failed((
+ f"Could find asset with id \"{asset_id}\""
+ f" in project \"{src_project_name}\""
+ ))
+ raise PushToProjectError(self._status.fail_reason)
+
+ anatomy = Anatomy(src_project_name)
+
+ repre_docs = get_representations(
+ src_project_name,
+ version_ids=[src_version_id]
+ )
+ repre_items = [
+ ProjectPushRepreItem(repre_doc, anatomy.roots)
+ for repre_doc in repre_docs
+ ]
+ self._log_debug((
+ f"Found {len(repre_items)} representations on"
+ f" version {src_version_id} in project '{src_project_name}'"
+ ))
+ if not repre_items:
+ self._status.set_failed(
+ "Source version does not have representations"
+ f" (Version id: {src_version_id})"
+ )
+ raise PushToProjectError(self._status.fail_reason)
+
+ self._src_asset_doc = asset_doc
+ self._src_subset_doc = subset_doc
+ self._src_version_doc = version_doc
+ self._src_repre_items = repre_items
+
+ def _fill_destination_project(self):
+ # --- Destination entities ---
+ dst_project_name = self._item.dst_project_name
+ # Validate project existence
+ dst_project_doc = get_project(dst_project_name)
+ if not dst_project_doc:
+ self._status.set_failed(
+ f"Destination project '{dst_project_name}' was not found"
+ )
+ raise PushToProjectError(self._status.fail_reason)
+
+ self._log_debug(
+ f"Destination project '{dst_project_name}' found"
+ )
+ self._project_doc = dst_project_doc
+ self._anatomy = Anatomy(dst_project_name)
+ self._project_settings = get_project_settings(
+ self._item.dst_project_name
+ )
+
+ def _create_asset(
+ self,
+ src_asset_doc,
+ project_doc,
+ parent_asset_doc,
+ asset_name
+ ):
+ parent_id = None
+ parents = []
+ tools = []
+ if parent_asset_doc:
+ parent_id = parent_asset_doc["_id"]
+ parents = list(parent_asset_doc["data"]["parents"])
+ parents.append(parent_asset_doc["name"])
+ _tools = parent_asset_doc["data"].get("tools_env")
+ if _tools:
+ tools = list(_tools)
+
+ asset_name_low = asset_name.lower()
+ other_asset_docs = get_assets(
+ project_doc["name"], fields=["_id", "name", "data.visualParent"]
+ )
+ for other_asset_doc in other_asset_docs:
+ other_name = other_asset_doc["name"]
+ other_parent_id = other_asset_doc["data"].get("visualParent")
+ if other_name.lower() != asset_name_low:
+ continue
+
+ if other_parent_id != parent_id:
+ self._status.set_failed((
+ f"Asset with name \"{other_name}\" already"
+ " exists in different hierarchy."
+ ))
+ raise PushToProjectError(self._status.fail_reason)
+
+ self._log_debug((
+ f"Found already existing asset with name \"{other_name}\""
+ f" which match requested name \"{asset_name}\""
+ ))
+ return get_asset_by_id(project_doc["name"], other_asset_doc["_id"])
+
+ data_keys = (
+ "clipIn",
+ "clipOut",
+ "frameStart",
+ "frameEnd",
+ "handleStart",
+ "handleEnd",
+ "resolutionWidth",
+ "resolutionHeight",
+ "fps",
+ "pixelAspect",
+ )
+ asset_data = {
+ "visualParent": parent_id,
+ "parents": parents,
+ "tasks": {},
+ "tools_env": tools
+ }
+ src_asset_data = src_asset_doc["data"]
+ for key in data_keys:
+ if key in src_asset_data:
+ asset_data[key] = src_asset_data[key]
+
+ asset_doc = new_asset_document(
+ asset_name,
+ project_doc["_id"],
+ parent_id,
+ parents,
+ data=asset_data
+ )
+ self._operations.create_entity(
+ project_doc["name"],
+ asset_doc["type"],
+ asset_doc
+ )
+ self._log_info(
+ f"Creating new asset with name \"{asset_name}\""
+ )
+ self._created_asset_doc = asset_doc
+ return asset_doc
+
+ def _fill_or_create_destination_asset(self):
+ dst_project_name = self._item.dst_project_name
+ dst_folder_id = self._item.dst_folder_id
+ dst_task_name = self._item.dst_task_name
+ new_folder_name = self._item.new_folder_name
+ if not dst_folder_id and not new_folder_name:
+ self._status.set_failed(
+ "Push item does not have defined destination asset"
+ )
+ raise PushToProjectError(self._status.fail_reason)
+
+ # Get asset document
+ parent_asset_doc = None
+ if dst_folder_id:
+ parent_asset_doc = get_asset_by_id(
+ self._item.dst_project_name, self._item.dst_folder_id
+ )
+ if not parent_asset_doc:
+ self._status.set_failed(
+ f"Could find asset with id \"{dst_folder_id}\""
+ f" in project \"{dst_project_name}\""
+ )
+ raise PushToProjectError(self._status.fail_reason)
+
+ if not new_folder_name:
+ asset_doc = parent_asset_doc
+ else:
+ asset_doc = self._create_asset(
+ self._src_asset_doc,
+ self._project_doc,
+ parent_asset_doc,
+ new_folder_name
+ )
+ self._asset_doc = asset_doc
+ if not dst_task_name:
+ self._task_info = {}
+ return
+
+ asset_path_parts = list(asset_doc["data"]["parents"])
+ asset_path_parts.append(asset_doc["name"])
+ asset_path = "/".join(asset_path_parts)
+ asset_tasks = asset_doc.get("data", {}).get("tasks") or {}
+ task_info = asset_tasks.get(dst_task_name)
+ if not task_info:
+ self._status.set_failed(
+ f"Could find task with name \"{dst_task_name}\""
+ f" on asset \"{asset_path}\""
+ f" in project \"{dst_project_name}\""
+ )
+ raise PushToProjectError(self._status.fail_reason)
+
+ # Create copy of task info to avoid changing data in asset document
+ task_info = copy.deepcopy(task_info)
+ task_info["name"] = dst_task_name
+ # Fill rest of task information based on task type
+ task_type = task_info["type"]
+ task_type_info = self._project_doc["config"]["tasks"].get(
+ task_type, {})
+ task_info.update(task_type_info)
+ self._task_info = task_info
+
+ def _determine_family(self):
+ subset_doc = self._src_subset_doc
+ family = subset_doc["data"].get("family")
+ families = subset_doc["data"].get("families")
+ if not family and families:
+ family = families[0]
+
+ if not family:
+ self._status.set_failed(
+ "Couldn't figure out family from source subset"
+ )
+ raise PushToProjectError(self._status.fail_reason)
+
+ self._log_debug(
+ f"Publishing family is '{family}' (Based on source subset)"
+ )
+ self._family = family
+
+ def _determine_publish_template_name(self):
+ template_name = get_publish_template_name(
+ self._item.dst_project_name,
+ self.host_name,
+ self._family,
+ self._task_info.get("name"),
+ self._task_info.get("type"),
+ project_settings=self._project_settings
+ )
+ self._log_debug(
+ f"Using template '{template_name}' for integration"
+ )
+ self._template_name = template_name
+
+ def _determine_subset_name(self):
+ family = self._family
+ asset_doc = self._asset_doc
+ task_info = self._task_info
+ subset_name = get_subset_name(
+ family,
+ self._item.variant,
+ task_info.get("name"),
+ asset_doc,
+ project_name=self._item.dst_project_name,
+ host_name=self.host_name,
+ project_settings=self._project_settings
+ )
+ self._log_info(
+ f"Push will be integrating to subset with name '{subset_name}'"
+ )
+ self._subset_name = subset_name
+
+ def _make_sure_subset_exists(self):
+ project_name = self._item.dst_project_name
+ asset_id = self._asset_doc["_id"]
+ subset_name = self._subset_name
+ family = self._family
+ subset_doc = get_subset_by_name(project_name, subset_name, asset_id)
+ if subset_doc:
+ self._subset_doc = subset_doc
+ return subset_doc
+
+ data = {
+ "families": [family]
+ }
+ subset_doc = new_subset_document(
+ subset_name, family, asset_id, data
+ )
+ self._operations.create_entity(project_name, "subset", subset_doc)
+ self._subset_doc = subset_doc
+
+ def _make_sure_version_exists(self):
+ """Make sure version document exits in database."""
+
+ project_name = self._item.dst_project_name
+ version = self._item.dst_version
+ src_version_doc = self._src_version_doc
+ subset_doc = self._subset_doc
+ subset_id = subset_doc["_id"]
+ src_data = src_version_doc["data"]
+ families = subset_doc["data"].get("families")
+ if not families:
+ families = [subset_doc["data"]["family"]]
+
+ version_data = {
+ "families": list(families),
+ "fps": src_data.get("fps"),
+ "source": src_data.get("source"),
+ "machine": socket.gethostname(),
+ "comment": self._item.comment or "",
+ "author": get_openpype_username(),
+ "time": get_formatted_current_time(),
+ }
+ if version is None:
+ last_version_doc = get_last_version_by_subset_id(
+ project_name, subset_id
+ )
+ if last_version_doc:
+ version = int(last_version_doc["name"]) + 1
+ else:
+ version = get_versioning_start(
+ project_name,
+ self.host_name,
+ task_name=self._task_info["name"],
+ task_type=self._task_info["type"],
+ family=families[0],
+ subset=subset_doc["name"]
+ )
+
+ existing_version_doc = get_version_by_name(
+ project_name, version, subset_id
+ )
+ # Update existing version
+ if existing_version_doc:
+ version_doc = new_version_doc(
+ version, subset_id, version_data, existing_version_doc["_id"]
+ )
+ update_data = prepare_version_update_data(
+ existing_version_doc, version_doc
+ )
+ if update_data:
+ self._operations.update_entity(
+ project_name,
+ "version",
+ existing_version_doc["_id"],
+ update_data
+ )
+ self._version_doc = version_doc
+
+ return
+
+ version_doc = new_version_doc(
+ version, subset_id, version_data
+ )
+ self._operations.create_entity(project_name, "version", version_doc)
+
+ self._version_doc = version_doc
+
+ def _integrate_representations(self):
+ try:
+ self._real_integrate_representations()
+ except Exception:
+ self._operations.clear()
+ self._file_transaction.rollback()
+ raise
+
+ def _real_integrate_representations(self):
+ version_doc = self._version_doc
+ version_id = version_doc["_id"]
+ existing_repres = get_representations(
+ self._item.dst_project_name,
+ version_ids=[version_id]
+ )
+ existing_repres_by_low_name = {
+ repre_doc["name"].lower(): repre_doc
+ for repre_doc in existing_repres
+ }
+ template_name = self._template_name
+ anatomy = self._anatomy
+ formatting_data = get_template_data(
+ self._project_doc,
+ self._asset_doc,
+ self._task_info.get("name"),
+ self.host_name
+ )
+ formatting_data.update({
+ "subset": self._subset_name,
+ "family": self._family,
+ "version": version_doc["name"]
+ })
+
+ path_template = anatomy.templates[template_name]["path"].replace(
+ "\\", "/"
+ )
+ file_template = StringTemplate(
+ anatomy.templates[template_name]["file"]
+ )
+ self._log_info("Preparing files to transfer")
+ processed_repre_items = self._prepare_file_transactions(
+ anatomy, template_name, formatting_data, file_template
+ )
+ self._file_transaction.process()
+ self._log_info("Preparing database changes")
+ self._prepare_database_operations(
+ version_id,
+ processed_repre_items,
+ path_template,
+ existing_repres_by_low_name
+ )
+ self._log_info("Finalization")
+ self._operations.commit()
+ self._file_transaction.finalize()
+
+ def _prepare_file_transactions(
+ self, anatomy, template_name, formatting_data, file_template
+ ):
+ processed_repre_items = []
+ for repre_item in self._src_repre_items:
+ repre_doc = repre_item.repre_doc
+ repre_name = repre_doc["name"]
+ repre_format_data = copy.deepcopy(formatting_data)
+ repre_format_data["representation"] = repre_name
+ for src_file in repre_item.src_files:
+ ext = os.path.splitext(src_file.path)[-1]
+ repre_format_data["ext"] = ext[1:]
+ break
+
+ # Re-use 'output' from source representation
+ repre_output_name = repre_doc["context"].get("output")
+ if repre_output_name is not None:
+ repre_format_data["output"] = repre_output_name
+
+ template_obj = anatomy.templates_obj[template_name]["folder"]
+ folder_path = template_obj.format_strict(formatting_data)
+ repre_context = folder_path.used_values
+ folder_path_rootless = folder_path.rootless
+ repre_filepaths = []
+ published_path = None
+ for src_file in repre_item.src_files:
+ file_data = copy.deepcopy(repre_format_data)
+ frame = src_file.frame
+ if frame is not None:
+ file_data["frame"] = frame
+
+ udim = src_file.udim
+ if udim is not None:
+ file_data["udim"] = udim
+
+ filename = file_template.format_strict(file_data)
+ dst_filepath = os.path.normpath(
+ os.path.join(folder_path, filename)
+ )
+ dst_rootless_path = os.path.normpath(
+ os.path.join(folder_path_rootless, filename)
+ )
+ if published_path is None or frame == repre_item.frame:
+ published_path = dst_filepath
+ repre_context.update(filename.used_values)
+
+ repre_filepaths.append((dst_filepath, dst_rootless_path))
+ self._file_transaction.add(src_file.path, dst_filepath)
+
+ for resource_file in repre_item.resource_files:
+ dst_filepath = os.path.normpath(
+ os.path.join(folder_path, resource_file.relative_path)
+ )
+ dst_rootless_path = os.path.normpath(
+ os.path.join(
+ folder_path_rootless, resource_file.relative_path
+ )
+ )
+ repre_filepaths.append((dst_filepath, dst_rootless_path))
+ self._file_transaction.add(resource_file.path, dst_filepath)
+ processed_repre_items.append(
+ (repre_item, repre_filepaths, repre_context, published_path)
+ )
+ return processed_repre_items
+
+ def _prepare_database_operations(
+ self,
+ version_id,
+ processed_repre_items,
+ path_template,
+ existing_repres_by_low_name
+ ):
+ modules_manager = ModulesManager()
+ sync_server_module = modules_manager.get("sync_server")
+ if sync_server_module is None or not sync_server_module.enabled:
+ sites = [{
+ "name": "studio",
+ "created_dt": datetime.datetime.now()
+ }]
+ else:
+ sites = sync_server_module.compute_resource_sync_sites(
+ project_name=self._item.dst_project_name
+ )
+
+ added_repre_names = set()
+ for item in processed_repre_items:
+ (repre_item, repre_filepaths, repre_context, published_path) = item
+ repre_name = repre_item.repre_doc["name"]
+ added_repre_names.add(repre_name.lower())
+ new_repre_data = {
+ "path": published_path,
+ "template": path_template
+ }
+ new_repre_files = []
+ for (path, rootless_path) in repre_filepaths:
+ new_repre_files.append({
+ "_id": ObjectId(),
+ "path": rootless_path,
+ "size": os.path.getsize(path),
+ "hash": source_hash(path),
+ "sites": sites
+ })
+
+ existing_repre = existing_repres_by_low_name.get(
+ repre_name.lower()
+ )
+ entity_id = None
+ if existing_repre:
+ entity_id = existing_repre["_id"]
+ new_repre_doc = new_representation_doc(
+ repre_name,
+ version_id,
+ repre_context,
+ data=new_repre_data,
+ entity_id=entity_id
+ )
+ new_repre_doc["files"] = new_repre_files
+ if not existing_repre:
+ self._operations.create_entity(
+ self._item.dst_project_name,
+ new_repre_doc["type"],
+ new_repre_doc
+ )
+ else:
+ update_data = prepare_representation_update_data(
+ existing_repre, new_repre_doc
+ )
+ if update_data:
+ self._operations.update_entity(
+ self._item.dst_project_name,
+ new_repre_doc["type"],
+ new_repre_doc["_id"],
+ update_data
+ )
+
+ existing_repre_names = set(existing_repres_by_low_name.keys())
+ for repre_name in (existing_repre_names - added_repre_names):
+ repre_doc = existing_repres_by_low_name[repre_name]
+ self._operations.update_entity(
+ self._item.dst_project_name,
+ repre_doc["type"],
+ repre_doc["_id"],
+ {"type": "archived_representation"}
+ )
+
+
+class IntegrateModel:
+ def __init__(self, controller):
+ self._controller = controller
+ self._process_items = {}
+
+ def reset(self):
+ self._process_items = {}
+
+ def emit_event(self, topic, data=None, source=None):
+ self._controller.emit_event(topic, data, source)
+
+ def create_process_item(
+ self,
+ src_project_name,
+ src_version_id,
+ dst_project_name,
+ dst_folder_id,
+ dst_task_name,
+ variant,
+ comment,
+ new_folder_name,
+ dst_version,
+ ):
+ """Create new item for integration.
+
+ Args:
+ src_project_name (str): Source project name.
+ src_version_id (str): Source version id.
+ dst_project_name (str): Destination project name.
+ dst_folder_id (str): Destination folder id.
+ dst_task_name (str): Destination task name.
+ variant (str): Variant name.
+ comment (Union[str, None]): Comment.
+ new_folder_name (Union[str, None]): New folder name.
+ dst_version (int): Destination version number.
+
+ Returns:
+ str: Item id. The id can be used to trigger integration or get
+ status information.
+ """
+
+ item = ProjectPushItem(
+ src_project_name,
+ src_version_id,
+ dst_project_name,
+ dst_folder_id,
+ dst_task_name,
+ variant,
+ comment=comment,
+ new_folder_name=new_folder_name,
+ dst_version=dst_version
+ )
+ process_item = ProjectPushItemProcess(self, item)
+ self._process_items[item.item_id] = process_item
+ return item.item_id
+
+ def integrate_item(self, item_id):
+ """Start integration of item.
+
+ Args:
+ item_id (str): Item id which should be integrated.
+ """
+
+ item = self._process_items.get(item_id)
+ if item is None or item.started:
+ return
+ item.integrate()
+
+ def get_item_status(self, item_id):
+ """Status of an item.
+
+ Args:
+ item_id (str): Item id for which status should be returned.
+
+ Returns:
+ dict[str, Any]: Status data.
+ """
+
+ item = self._process_items.get(item_id)
+ if item is not None:
+ return item.get_status_data()
+ return None
diff --git a/openpype/tools/ayon_push_to_project/models/selection.py b/openpype/tools/ayon_push_to_project/models/selection.py
new file mode 100644
index 0000000000..19f1c6d37d
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/models/selection.py
@@ -0,0 +1,72 @@
+class PushToProjectSelectionModel(object):
+ """Model handling selection changes.
+
+ Triggering events:
+ - "selection.project.changed"
+ - "selection.folder.changed"
+ - "selection.task.changed"
+ """
+
+ event_source = "push-to-project.selection.model"
+
+ def __init__(self, controller):
+ self._controller = controller
+
+ self._project_name = None
+ self._folder_id = None
+ self._task_name = None
+ self._task_id = None
+
+ def get_selected_project_name(self):
+ return self._project_name
+
+ def set_selected_project(self, project_name):
+ if project_name == self._project_name:
+ return
+
+ self._project_name = project_name
+ self._controller.emit_event(
+ "selection.project.changed",
+ {"project_name": project_name},
+ self.event_source
+ )
+
+ def get_selected_folder_id(self):
+ return self._folder_id
+
+ def set_selected_folder(self, folder_id):
+ if folder_id == self._folder_id:
+ return
+
+ self._folder_id = folder_id
+ self._controller.emit_event(
+ "selection.folder.changed",
+ {
+ "project_name": self._project_name,
+ "folder_id": folder_id,
+ },
+ self.event_source
+ )
+
+ def get_selected_task_name(self):
+ return self._task_name
+
+ def get_selected_task_id(self):
+ return self._task_id
+
+ def set_selected_task(self, task_id, task_name):
+ if task_id == self._task_id:
+ return
+
+ self._task_name = task_name
+ self._task_id = task_id
+ self._controller.emit_event(
+ "selection.task.changed",
+ {
+ "project_name": self._project_name,
+ "folder_id": self._folder_id,
+ "task_name": task_name,
+ "task_id": task_id,
+ },
+ self.event_source
+ )
diff --git a/openpype/tools/ayon_push_to_project/models/user_values.py b/openpype/tools/ayon_push_to_project/models/user_values.py
new file mode 100644
index 0000000000..2a4faeb136
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/models/user_values.py
@@ -0,0 +1,110 @@
+import re
+
+from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS
+
+
+class UserPublishValuesModel:
+ """Helper object to validate values required for push to different project.
+
+ Args:
+ controller (PushToContextController): Event system to catch
+ and emit events.
+ """
+
+ folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$")
+ variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS))
+
+ def __init__(self, controller):
+ self._controller = controller
+ self._new_folder_name = None
+ self._variant = None
+ self._comment = None
+ self._is_variant_valid = False
+ self._is_new_folder_name_valid = False
+
+ self.set_new_folder_name("")
+ self.set_variant("")
+ self.set_comment("")
+
+ @property
+ def new_folder_name(self):
+ return self._new_folder_name
+
+ @property
+ def variant(self):
+ return self._variant
+
+ @property
+ def comment(self):
+ return self._comment
+
+ @property
+ def is_variant_valid(self):
+ return self._is_variant_valid
+
+ @property
+ def is_new_folder_name_valid(self):
+ return self._is_new_folder_name_valid
+
+ @property
+ def is_valid(self):
+ return self.is_variant_valid and self.is_new_folder_name_valid
+
+ def get_data(self):
+ return {
+ "new_folder_name": self._new_folder_name,
+ "variant": self._variant,
+ "comment": self._comment,
+ "is_variant_valid": self._is_variant_valid,
+ "is_new_folder_name_valid": self._is_new_folder_name_valid,
+ "is_valid": self.is_valid
+ }
+
+ def set_variant(self, variant):
+ if variant == self._variant:
+ return
+
+ self._variant = variant
+ is_valid = False
+ if variant:
+ is_valid = self.variant_regex.match(variant) is not None
+ self._is_variant_valid = is_valid
+
+ self._controller.emit_event(
+ "variant.changed",
+ {
+ "variant": variant,
+ "is_valid": self._is_variant_valid,
+ },
+ "user_values"
+ )
+
+ def set_new_folder_name(self, folder_name):
+ if self._new_folder_name == folder_name:
+ return
+
+ self._new_folder_name = folder_name
+ is_valid = True
+ if folder_name:
+ is_valid = (
+ self.folder_name_regex.match(folder_name) is not None
+ )
+ self._is_new_folder_name_valid = is_valid
+ self._controller.emit_event(
+ "new_folder_name.changed",
+ {
+ "new_folder_name": self._new_folder_name,
+ "is_valid": self._is_new_folder_name_valid,
+ },
+ "user_values"
+ )
+
+ def set_comment(self, comment):
+ if comment == self._comment:
+ return
+ self._comment = comment
+ self._controller.emit_event(
+ "comment.changed",
+ {"comment": comment},
+ "user_values"
+ )
diff --git a/openpype/tools/ayon_push_to_project/ui/__init__.py b/openpype/tools/ayon_push_to_project/ui/__init__.py
new file mode 100644
index 0000000000..1e86475530
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/ui/__init__.py
@@ -0,0 +1,6 @@
+from .window import PushToContextSelectWindow
+
+
+__all__ = (
+ "PushToContextSelectWindow",
+)
diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py
new file mode 100644
index 0000000000..535c01c643
--- /dev/null
+++ b/openpype/tools/ayon_push_to_project/ui/window.py
@@ -0,0 +1,432 @@
+from qtpy import QtWidgets, QtGui, QtCore
+
+from openpype.style import load_stylesheet, get_app_icon_path
+from openpype.tools.utils import (
+ PlaceholderLineEdit,
+ SeparatorWidget,
+ set_style_property,
+)
+from openpype.tools.ayon_utils.widgets import (
+ ProjectsCombobox,
+ FoldersWidget,
+ TasksWidget,
+)
+from openpype.tools.ayon_push_to_project.control import (
+ PushToContextController,
+)
+
+
+class PushToContextSelectWindow(QtWidgets.QWidget):
+ def __init__(self, controller=None):
+ super(PushToContextSelectWindow, self).__init__()
+ if controller is None:
+ controller = PushToContextController()
+ self._controller = controller
+
+ self.setWindowTitle("Push to project (select context)")
+ self.setWindowIcon(QtGui.QIcon(get_app_icon_path()))
+
+ main_context_widget = QtWidgets.QWidget(self)
+
+ header_widget = QtWidgets.QWidget(main_context_widget)
+
+ header_label = QtWidgets.QLabel(
+ controller.get_source_label(),
+ header_widget
+ )
+
+ header_layout = QtWidgets.QHBoxLayout(header_widget)
+ header_layout.setContentsMargins(0, 0, 0, 0)
+ header_layout.addWidget(header_label)
+
+ main_splitter = QtWidgets.QSplitter(
+ QtCore.Qt.Horizontal, main_context_widget
+ )
+
+ context_widget = QtWidgets.QWidget(main_splitter)
+
+ projects_combobox = ProjectsCombobox(controller, context_widget)
+ projects_combobox.set_select_item_visible(True)
+ projects_combobox.set_standard_filter_enabled(True)
+
+ context_splitter = QtWidgets.QSplitter(
+ QtCore.Qt.Vertical, context_widget
+ )
+
+ folders_widget = FoldersWidget(controller, context_splitter)
+ folders_widget.set_deselectable(True)
+ tasks_widget = TasksWidget(controller, context_splitter)
+
+ context_splitter.addWidget(folders_widget)
+ context_splitter.addWidget(tasks_widget)
+
+ context_layout = QtWidgets.QVBoxLayout(context_widget)
+ context_layout.setContentsMargins(0, 0, 0, 0)
+ context_layout.addWidget(projects_combobox, 0)
+ context_layout.addWidget(context_splitter, 1)
+
+ # --- Inputs widget ---
+ inputs_widget = QtWidgets.QWidget(main_splitter)
+
+ folder_name_input = PlaceholderLineEdit(inputs_widget)
+ folder_name_input.setPlaceholderText("< Name of new folder >")
+ folder_name_input.setObjectName("ValidatedLineEdit")
+
+ variant_input = PlaceholderLineEdit(inputs_widget)
+ variant_input.setPlaceholderText("< Variant >")
+ variant_input.setObjectName("ValidatedLineEdit")
+
+ comment_input = PlaceholderLineEdit(inputs_widget)
+ comment_input.setPlaceholderText("< Publish comment >")
+
+ inputs_layout = QtWidgets.QFormLayout(inputs_widget)
+ inputs_layout.setContentsMargins(0, 0, 0, 0)
+ inputs_layout.addRow("New folder name", folder_name_input)
+ inputs_layout.addRow("Variant", variant_input)
+ inputs_layout.addRow("Comment", comment_input)
+
+ main_splitter.addWidget(context_widget)
+ main_splitter.addWidget(inputs_widget)
+
+ # --- Buttons widget ---
+ btns_widget = QtWidgets.QWidget(self)
+ cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget)
+ publish_btn = QtWidgets.QPushButton("Publish", btns_widget)
+
+ btns_layout = QtWidgets.QHBoxLayout(btns_widget)
+ btns_layout.setContentsMargins(0, 0, 0, 0)
+ btns_layout.addStretch(1)
+ btns_layout.addWidget(cancel_btn, 0)
+ btns_layout.addWidget(publish_btn, 0)
+
+ sep_1 = SeparatorWidget(parent=main_context_widget)
+ sep_2 = SeparatorWidget(parent=main_context_widget)
+ main_context_layout = QtWidgets.QVBoxLayout(main_context_widget)
+ main_context_layout.addWidget(header_widget, 0)
+ main_context_layout.addWidget(sep_1, 0)
+ main_context_layout.addWidget(main_splitter, 1)
+ main_context_layout.addWidget(sep_2, 0)
+ main_context_layout.addWidget(btns_widget, 0)
+
+ # NOTE This was added in hurry
+ # - should be reorganized and changed styles
+ overlay_widget = QtWidgets.QFrame(self)
+ overlay_widget.setObjectName("OverlayFrame")
+
+ overlay_label = QtWidgets.QLabel(overlay_widget)
+ overlay_label.setAlignment(QtCore.Qt.AlignCenter)
+
+ overlay_btns_widget = QtWidgets.QWidget(overlay_widget)
+ overlay_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
+
+ # Add try again button (requires changes in controller)
+ overlay_try_btn = QtWidgets.QPushButton(
+ "Try again", overlay_btns_widget
+ )
+ overlay_close_btn = QtWidgets.QPushButton(
+ "Close", overlay_btns_widget
+ )
+
+ overlay_btns_layout = QtWidgets.QHBoxLayout(overlay_btns_widget)
+ overlay_btns_layout.addStretch(1)
+ overlay_btns_layout.addWidget(overlay_try_btn, 0)
+ overlay_btns_layout.addWidget(overlay_close_btn, 0)
+ overlay_btns_layout.addStretch(1)
+
+ overlay_layout = QtWidgets.QVBoxLayout(overlay_widget)
+ overlay_layout.addWidget(overlay_label, 0)
+ overlay_layout.addWidget(overlay_btns_widget, 0)
+ overlay_layout.setAlignment(QtCore.Qt.AlignCenter)
+
+ main_layout = QtWidgets.QStackedLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(main_context_widget)
+ main_layout.addWidget(overlay_widget)
+ main_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
+ main_layout.setCurrentWidget(main_context_widget)
+
+ show_timer = QtCore.QTimer()
+ show_timer.setInterval(0)
+
+ main_thread_timer = QtCore.QTimer()
+ main_thread_timer.setInterval(10)
+
+ user_input_changed_timer = QtCore.QTimer()
+ user_input_changed_timer.setInterval(200)
+ user_input_changed_timer.setSingleShot(True)
+
+ main_thread_timer.timeout.connect(self._on_main_thread_timer)
+ show_timer.timeout.connect(self._on_show_timer)
+ user_input_changed_timer.timeout.connect(self._on_user_input_timer)
+ folder_name_input.textChanged.connect(self._on_new_asset_change)
+ variant_input.textChanged.connect(self._on_variant_change)
+ comment_input.textChanged.connect(self._on_comment_change)
+
+ publish_btn.clicked.connect(self._on_select_click)
+ cancel_btn.clicked.connect(self._on_close_click)
+ overlay_close_btn.clicked.connect(self._on_close_click)
+ overlay_try_btn.clicked.connect(self._on_try_again_click)
+
+ controller.register_event_callback(
+ "new_folder_name.changed",
+ self._on_controller_new_asset_change
+ )
+ controller.register_event_callback(
+ "variant.changed", self._on_controller_variant_change
+ )
+ controller.register_event_callback(
+ "comment.changed", self._on_controller_comment_change
+ )
+ controller.register_event_callback(
+ "submission.enabled.changed", self._on_submission_change
+ )
+ controller.register_event_callback(
+ "source.changed", self._on_controller_source_change
+ )
+ controller.register_event_callback(
+ "submit.started", self._on_controller_submit_start
+ )
+ controller.register_event_callback(
+ "submit.finished", self._on_controller_submit_end
+ )
+ controller.register_event_callback(
+ "push.message.added", self._on_push_message
+ )
+
+ self._main_layout = main_layout
+
+ self._main_context_widget = main_context_widget
+
+ self._header_label = header_label
+ self._main_splitter = main_splitter
+
+ self._projects_combobox = projects_combobox
+ self._folders_widget = folders_widget
+ self._tasks_widget = tasks_widget
+
+ self._variant_input = variant_input
+ self._folder_name_input = folder_name_input
+ self._comment_input = comment_input
+
+ self._publish_btn = publish_btn
+
+ self._overlay_widget = overlay_widget
+ self._overlay_close_btn = overlay_close_btn
+ self._overlay_try_btn = overlay_try_btn
+ self._overlay_label = overlay_label
+
+ self._user_input_changed_timer = user_input_changed_timer
+ # Store current value on input text change
+ # The value is unset when is passed to controller
+ # The goal is to have controll over changes happened during user change
+ # in UI and controller auto-changes
+ self._variant_input_text = None
+ self._new_folder_name_input_text = None
+ self._comment_input_text = None
+
+ self._first_show = True
+ self._show_timer = show_timer
+ self._show_counter = 0
+
+ self._main_thread_timer = main_thread_timer
+ self._main_thread_timer_can_stop = True
+ self._last_submit_message = None
+ self._process_item_id = None
+
+ self._variant_is_valid = None
+ self._folder_is_valid = None
+
+ publish_btn.setEnabled(False)
+ overlay_close_btn.setVisible(False)
+ overlay_try_btn.setVisible(False)
+
+ # Support of public api function of controller
+ def set_source(self, project_name, version_id):
+ """Set source project and version.
+
+ Call the method on controller.
+
+ Args:
+ project_name (Union[str, None]): Name of project.
+ version_id (Union[str, None]): Version id.
+ """
+
+ self._controller.set_source(project_name, version_id)
+
+ def showEvent(self, event):
+ super(PushToContextSelectWindow, self).showEvent(event)
+ if self._first_show:
+ self._first_show = False
+ self._on_first_show()
+
+ def refresh(self):
+ user_values = self._controller.get_user_values()
+ new_folder_name = user_values["new_folder_name"]
+ variant = user_values["variant"]
+ self._folder_name_input.setText(new_folder_name or "")
+ self._variant_input.setText(variant or "")
+ self._invalidate_variant(user_values["is_variant_valid"])
+ self._invalidate_new_folder_name(
+ new_folder_name, user_values["is_new_folder_name_valid"]
+ )
+
+ self._projects_combobox.refresh()
+
+ def _on_first_show(self):
+ width = 740
+ height = 640
+ inputs_width = 360
+ self.setStyleSheet(load_stylesheet())
+ self.resize(width, height)
+ self._main_splitter.setSizes([width - inputs_width, inputs_width])
+ self._show_timer.start()
+
+ def _on_show_timer(self):
+ if self._show_counter < 3:
+ self._show_counter += 1
+ return
+ self._show_timer.stop()
+
+ self._show_counter = 0
+
+ self.refresh()
+
+ def _on_new_asset_change(self, text):
+ self._new_folder_name_input_text = text
+ self._user_input_changed_timer.start()
+
+ def _on_variant_change(self, text):
+ self._variant_input_text = text
+ self._user_input_changed_timer.start()
+
+ def _on_comment_change(self, text):
+ self._comment_input_text = text
+ self._user_input_changed_timer.start()
+
+ def _on_user_input_timer(self):
+ folder_name = self._new_folder_name_input_text
+ if folder_name is not None:
+ self._new_folder_name_input_text = None
+ self._controller.set_user_value_folder_name(folder_name)
+
+ variant = self._variant_input_text
+ if variant is not None:
+ self._variant_input_text = None
+ self._controller.set_user_value_variant(variant)
+
+ comment = self._comment_input_text
+ if comment is not None:
+ self._comment_input_text = None
+ self._controller.set_user_value_comment(comment)
+
+ def _on_controller_new_asset_change(self, event):
+ folder_name = event["new_folder_name"]
+ if (
+ self._new_folder_name_input_text is None
+ and folder_name != self._folder_name_input.text()
+ ):
+ self._folder_name_input.setText(folder_name)
+
+ self._invalidate_new_folder_name(folder_name, event["is_valid"])
+
+ def _on_controller_variant_change(self, event):
+ is_valid = event["is_valid"]
+ variant = event["variant"]
+ if (
+ self._variant_input_text is None
+ and variant != self._variant_input.text()
+ ):
+ self._variant_input.setText(variant)
+
+ self._invalidate_variant(is_valid)
+
+ def _on_controller_comment_change(self, event):
+ comment = event["comment"]
+ if (
+ self._comment_input_text is None
+ and comment != self._comment_input.text()
+ ):
+ self._comment_input.setText(comment)
+
+ def _on_controller_source_change(self):
+ self._header_label.setText(self._controller.get_source_label())
+
+ def _invalidate_new_folder_name(self, folder_name, is_valid):
+ self._tasks_widget.setVisible(not folder_name)
+ if self._folder_is_valid is is_valid:
+ return
+ self._folder_is_valid = is_valid
+ state = ""
+ if folder_name:
+ if is_valid is True:
+ state = "valid"
+ elif is_valid is False:
+ state = "invalid"
+ set_style_property(
+ self._folder_name_input, "state", state
+ )
+
+ def _invalidate_variant(self, is_valid):
+ if self._variant_is_valid is is_valid:
+ return
+ self._variant_is_valid = is_valid
+ state = "valid" if is_valid else "invalid"
+ set_style_property(self._variant_input, "state", state)
+
+ def _on_submission_change(self, event):
+ self._publish_btn.setEnabled(event["enabled"])
+
+ def _on_close_click(self):
+ self.close()
+
+ def _on_select_click(self):
+ self._process_item_id = self._controller.submit(wait=False)
+
+ def _on_try_again_click(self):
+ self._process_item_id = None
+ self._last_submit_message = None
+
+ self._overlay_close_btn.setVisible(False)
+ self._overlay_try_btn.setVisible(False)
+ self._main_layout.setCurrentWidget(self._main_context_widget)
+
+ def _on_main_thread_timer(self):
+ if self._last_submit_message:
+ self._overlay_label.setText(self._last_submit_message)
+ self._last_submit_message = None
+
+ process_status = self._controller.get_process_item_status(
+ self._process_item_id
+ )
+ push_failed = process_status["failed"]
+ fail_traceback = process_status["full_traceback"]
+ if self._main_thread_timer_can_stop:
+ self._main_thread_timer.stop()
+ self._overlay_close_btn.setVisible(True)
+ if push_failed and not fail_traceback:
+ self._overlay_try_btn.setVisible(True)
+
+ if push_failed:
+ message = "Push Failed:\n{}".format(process_status["fail_reason"])
+ if fail_traceback:
+ message += "\n{}".format(fail_traceback)
+ self._overlay_label.setText(message)
+ set_style_property(self._overlay_close_btn, "state", "error")
+
+ if self._main_thread_timer_can_stop:
+ # Join thread in controller
+ self._controller.wait_for_process_thread()
+ # Reset process item to None
+ self._process_item_id = None
+
+ def _on_controller_submit_start(self):
+ self._main_thread_timer_can_stop = False
+ self._main_thread_timer.start()
+ self._main_layout.setCurrentWidget(self._overlay_widget)
+ self._overlay_label.setText("Submittion started")
+
+ def _on_controller_submit_end(self):
+ self._main_thread_timer_can_stop = True
+
+ def _on_push_message(self, event):
+ self._last_submit_message = event["message"]
diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py
index e59b319459..3a8e90f933 100644
--- a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py
+++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py
@@ -334,7 +334,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
def _on_mouse_double_click(self, event):
if event.button() == QtCore.Qt.LeftButton:
- self.save_as_requested.emit()
+ self.open_current_requested.emit()
def _on_context_menu(self, point):
index = self._view.indexAt(point)
diff --git a/openpype/tools/push_to_project/control_integrate.py b/openpype/tools/push_to_project/control_integrate.py
index a822339ccf..9f083d8eb7 100644
--- a/openpype/tools/push_to_project/control_integrate.py
+++ b/openpype/tools/push_to_project/control_integrate.py
@@ -1051,6 +1051,11 @@ class ProjectPushItemProcess:
repre_format_data["ext"] = ext[1:]
break
+ # Re-use 'output' from source representation
+ repre_output_name = repre_doc["context"].get("output")
+ if repre_output_name is not None:
+ repre_format_data["output"] = repre_output_name
+
template_obj = anatomy.templates_obj[template_name]["folder"]
folder_path = template_obj.format_strict(formatting_data)
repre_context = folder_path.used_values
diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py
index 22e137d6e5..9f89d3d59e 100644
--- a/openpype/vendor/python/common/ayon_api/_api.py
+++ b/openpype/vendor/python/common/ayon_api/_api.py
@@ -602,12 +602,12 @@ def delete_installer(*args, **kwargs):
def download_installer(*args, **kwargs):
con = get_server_api_connection()
- con.download_installer(*args, **kwargs)
+ return con.download_installer(*args, **kwargs)
def upload_installer(*args, **kwargs):
con = get_server_api_connection()
- con.upload_installer(*args, **kwargs)
+ return con.upload_installer(*args, **kwargs)
# Dependency packages
@@ -753,12 +753,12 @@ def get_secrets(*args, **kwargs):
def get_secret(*args, **kwargs):
con = get_server_api_connection()
- return con.delete_secret(*args, **kwargs)
+ return con.get_secret(*args, **kwargs)
def save_secret(*args, **kwargs):
con = get_server_api_connection()
- return con.delete_secret(*args, **kwargs)
+ return con.save_secret(*args, **kwargs)
def delete_secret(*args, **kwargs):
@@ -978,12 +978,14 @@ def delete_project(project_name):
def get_thumbnail_by_id(project_name, thumbnail_id):
con = get_server_api_connection()
- con.get_thumbnail_by_id(project_name, thumbnail_id)
+ return con.get_thumbnail_by_id(project_name, thumbnail_id)
def get_thumbnail(project_name, entity_type, entity_id, thumbnail_id=None):
con = get_server_api_connection()
- con.get_thumbnail(project_name, entity_type, entity_id, thumbnail_id)
+ return con.get_thumbnail(
+ project_name, entity_type, entity_id, thumbnail_id
+ )
def get_folder_thumbnail(project_name, folder_id, thumbnail_id=None):
diff --git a/openpype/vendor/python/common/ayon_api/graphql_queries.py b/openpype/vendor/python/common/ayon_api/graphql_queries.py
index 2435fc8a17..cedb3ed2ac 100644
--- a/openpype/vendor/python/common/ayon_api/graphql_queries.py
+++ b/openpype/vendor/python/common/ayon_api/graphql_queries.py
@@ -144,6 +144,7 @@ def product_types_query(fields):
query_queue.append((k, v, field))
return query
+
def project_product_types_query(fields):
query = GraphQlQuery("ProjectProductTypes")
project_query = query.add_field("project")
@@ -175,6 +176,8 @@ def folders_graphql_query(fields):
parent_folder_ids_var = query.add_variable("parentFolderIds", "[String!]")
folder_paths_var = query.add_variable("folderPaths", "[String!]")
folder_names_var = query.add_variable("folderNames", "[String!]")
+ folder_types_var = query.add_variable("folderTypes", "[String!]")
+ statuses_var = query.add_variable("folderStatuses", "[String!]")
has_products_var = query.add_variable("folderHasProducts", "Boolean!")
project_field = query.add_field("project")
@@ -185,6 +188,8 @@ def folders_graphql_query(fields):
folders_field.set_filter("parentIds", parent_folder_ids_var)
folders_field.set_filter("names", folder_names_var)
folders_field.set_filter("paths", folder_paths_var)
+ folders_field.set_filter("folderTypes", folder_types_var)
+ folders_field.set_filter("statuses", statuses_var)
folders_field.set_filter("hasProducts", has_products_var)
nested_fields = fields_to_dict(fields)
diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py
index 511a239a83..3bac59c192 100644
--- a/openpype/vendor/python/common/ayon_api/server_api.py
+++ b/openpype/vendor/python/common/ayon_api/server_api.py
@@ -75,6 +75,7 @@ from .utils import (
TransferProgress,
create_dependency_package_basename,
ThumbnailContent,
+ get_default_timeout,
)
PatternType = type(re.compile(""))
@@ -351,7 +352,6 @@ class ServerAPI(object):
timeout (Optional[float]): Timeout for requests.
max_retries (Optional[int]): Number of retries for requests.
"""
- _default_timeout = 10.0
_default_max_retries = 3
def __init__(
@@ -500,20 +500,13 @@ class ServerAPI(object):
def get_default_timeout(cls):
"""Default value for requests timeout.
- First looks for environment variable SERVER_TIMEOUT_ENV_KEY which
- can affect timeout value. If not available then use class
- attribute '_default_timeout'.
+ Utils function 'get_default_timeout' is used by default.
Returns:
float: Timeout value in seconds.
"""
- try:
- return float(os.environ.get(SERVER_TIMEOUT_ENV_KEY))
- except (ValueError, TypeError):
- pass
-
- return cls._default_timeout
+ return get_default_timeout()
@classmethod
def get_default_max_retries(cls):
@@ -662,13 +655,10 @@ class ServerAPI(object):
as default variant.
Args:
- variant (Literal['production', 'staging']): Settings variant name.
+ variant (str): Settings variant name. It is possible to use
+ 'production', 'staging' or name of dev bundle.
"""
- if variant not in ("production", "staging"):
- raise ValueError((
- "Invalid variant name {}. Expected 'production' or 'staging'"
- ).format(variant))
self._default_settings_variant = variant
default_settings_variant = property(
@@ -938,8 +928,8 @@ class ServerAPI(object):
int(re_match.group("major")),
int(re_match.group("minor")),
int(re_match.group("patch")),
- re_match.group("prerelease"),
- re_match.group("buildmetadata")
+ re_match.group("prerelease") or "",
+ re_match.group("buildmetadata") or "",
)
return self._server_version_tuple
@@ -1140,31 +1130,41 @@ class ServerAPI(object):
response = None
new_response = None
- for _ in range(max_retries):
+ for retry_idx in reversed(range(max_retries)):
try:
response = function(url, **kwargs)
break
except ConnectionRefusedError:
+ if retry_idx == 0:
+ self.log.warning(
+ "Connection error happened.", exc_info=True
+ )
+
# Server may be restarting
new_response = RestApiResponse(
None,
{"detail": "Unable to connect the server. Connection refused"}
)
+
except requests.exceptions.Timeout:
# Connection timed out
new_response = RestApiResponse(
None,
{"detail": "Connection timed out."}
)
+
except requests.exceptions.ConnectionError:
- # Other connection error (ssl, etc) - does not make sense to
- # try call server again
+ # Log warning only on last attempt
+ if retry_idx == 0:
+ self.log.warning(
+ "Connection error happened.", exc_info=True
+ )
+
new_response = RestApiResponse(
None,
{"detail": "Unable to connect the server. Connection error"}
)
- break
time.sleep(0.1)
@@ -1349,7 +1349,9 @@ class ServerAPI(object):
status=None,
description=None,
summary=None,
- payload=None
+ payload=None,
+ progress=None,
+ retries=None
):
kwargs = {
key: value
@@ -1360,9 +1362,27 @@ class ServerAPI(object):
("description", description),
("summary", summary),
("payload", payload),
+ ("progress", progress),
+ ("retries", retries),
)
if value is not None
}
+ # 'progress' and 'retries' are available since 0.5.x server version
+ major, minor, _, _, _ = self.server_version_tuple
+ if (major, minor) < (0, 5):
+ args = []
+ if progress is not None:
+ args.append("progress")
+ if retries is not None:
+ args.append("retries")
+ fields = ", ".join("'{}'".format(f) for f in args)
+ ending = "s" if len(args) > 1 else ""
+ raise ValueError((
+ "Your server version '{}' does not support update"
+ " of {} field{} on event. The fields are supported since"
+ " server version '0.5'."
+ ).format(self.get_server_version(), fields, ending))
+
response = self.patch(
"events/{}".format(event_id),
**kwargs
@@ -1434,6 +1454,7 @@ class ServerAPI(object):
description=None,
sequential=None,
events_filter=None,
+ max_retries=None,
):
"""Enroll job based on events.
@@ -1475,8 +1496,12 @@ class ServerAPI(object):
in target event.
sequential (Optional[bool]): The source topic must be processed
in sequence.
- events_filter (Optional[ayon_server.sqlfilter.Filter]): A dict-like
- with conditions to filter the source event.
+ events_filter (Optional[dict[str, Any]]): Filtering conditions
+ to filter the source event. For more technical specifications
+ look to server backed 'ayon_server.sqlfilter.Filter'.
+ TODO: Add example of filters.
+ max_retries (Optional[int]): How many times can be event retried.
+ Default value is based on server (3 at the time of this PR).
Returns:
Union[None, dict[str, Any]]: None if there is no event matching
@@ -1487,6 +1512,7 @@ class ServerAPI(object):
"sourceTopic": source_topic,
"targetTopic": target_topic,
"sender": sender,
+ "maxRetries": max_retries,
}
if sequential is not None:
kwargs["sequential"] = sequential
@@ -2236,6 +2262,34 @@ class ServerAPI(object):
response.raise_for_status("Failed to create/update dependency")
return response.data
+ def _get_dependency_package_route(
+ self, filename=None, platform_name=None
+ ):
+ major, minor, patch, _, _ = self.server_version_tuple
+ if (major, minor, patch) <= (0, 2, 0):
+ # Backwards compatibility for AYON server 0.2.0 and lower
+ self.log.warning((
+ "Using deprecated dependency package route."
+ " Please update your AYON server to version 0.2.1 or higher."
+ " Backwards compatibility for this route will be removed"
+ " in future releases of ayon-python-api."
+ ))
+ if platform_name is None:
+ platform_name = platform.system().lower()
+ base = "dependencies"
+ if not filename:
+ return base
+ return "{}/{}/{}".format(base, filename, platform_name)
+
+ if (major, minor) <= (0, 3):
+ endpoint = "desktop/dependency_packages"
+ else:
+ endpoint = "desktop/dependencyPackages"
+
+ if filename:
+ return "{}/{}".format(endpoint, filename)
+ return endpoint
+
def get_dependency_packages(self):
"""Information about dependency packages on server.
@@ -2263,33 +2317,11 @@ class ServerAPI(object):
server.
"""
- endpoint = "desktop/dependencyPackages"
- major, minor, _, _, _ = self.server_version_tuple
- if major == 0 and minor <= 3:
- endpoint = "desktop/dependency_packages"
-
+ endpoint = self._get_dependency_package_route()
result = self.get(endpoint)
result.raise_for_status()
return result.data
- def _get_dependency_package_route(
- self, filename=None, platform_name=None
- ):
- major, minor, patch, _, _ = self.server_version_tuple
- if major == 0 and (minor > 2 or (minor == 2 and patch >= 1)):
- base = "desktop/dependency_packages"
- if not filename:
- return base
- return "{}/{}".format(base, filename)
-
- # Backwards compatibility for AYON server 0.2.0 and lower
- if platform_name is None:
- platform_name = platform.system().lower()
- base = "dependencies"
- if not filename:
- return base
- return "{}/{}/{}".format(base, filename, platform_name)
-
def create_dependency_package(
self,
filename,
@@ -3515,7 +3547,9 @@ class ServerAPI(object):
folder_ids=None,
folder_paths=None,
folder_names=None,
+ folder_types=None,
parent_ids=None,
+ statuses=None,
active=True,
fields=None,
own_attributes=False
@@ -3536,8 +3570,12 @@ class ServerAPI(object):
for filtering.
folder_names (Optional[Iterable[str]]): Folder names used
for filtering.
+ folder_types (Optional[Iterable[str]]): Folder types used
+ for filtering.
parent_ids (Optional[Iterable[str]]): Ids of folder parents.
Use 'None' if folder is direct child of project.
+ statuses (Optional[Iterable[str]]): Folder statuses used
+ for filtering.
active (Optional[bool]): Filter active/inactive folders.
Both are returned if is set to None.
fields (Optional[Iterable[str]]): Fields to be queried for
@@ -3574,6 +3612,18 @@ class ServerAPI(object):
return
filters["folderNames"] = list(folder_names)
+ if folder_types is not None:
+ folder_types = set(folder_types)
+ if not folder_types:
+ return
+ filters["folderTypes"] = list(folder_types)
+
+ if statuses is not None:
+ statuses = set(statuses)
+ if not statuses:
+ return
+ filters["folderStatuses"] = list(statuses)
+
if parent_ids is not None:
parent_ids = set(parent_ids)
if not parent_ids:
@@ -4312,9 +4362,6 @@ class ServerAPI(object):
fields.remove("attrib")
fields |= self.get_attributes_fields_for_type("version")
- if active is not None:
- fields.add("active")
-
# Make sure fields have minimum required fields
fields |= {"id", "version"}
@@ -4323,6 +4370,9 @@ class ServerAPI(object):
use_rest = True
fields = {"id"}
+ if active is not None:
+ fields.add("active")
+
if own_attributes:
fields.add("ownAttrib")
@@ -5845,19 +5895,22 @@ class ServerAPI(object):
"""Helper method to get links from server for entity types.
Example output:
- [
- {
- "id": "59a212c0d2e211eda0e20242ac120002",
- "linkType": "reference",
- "description": "reference link between folders",
- "projectName": "my_project",
- "author": "frantadmin",
- "entityId": "b1df109676db11ed8e8c6c9466b19aa8",
- "entityType": "folder",
- "direction": "out"
- },
+ {
+ "59a212c0d2e211eda0e20242ac120001": [
+ {
+ "id": "59a212c0d2e211eda0e20242ac120002",
+ "linkType": "reference",
+ "description": "reference link between folders",
+ "projectName": "my_project",
+ "author": "frantadmin",
+ "entityId": "b1df109676db11ed8e8c6c9466b19aa8",
+ "entityType": "folder",
+ "direction": "out"
+ },
+ ...
+ ],
...
- ]
+ }
Args:
project_name (str): Project where links are.
diff --git a/openpype/vendor/python/common/ayon_api/utils.py b/openpype/vendor/python/common/ayon_api/utils.py
index 314d13faec..502d24f713 100644
--- a/openpype/vendor/python/common/ayon_api/utils.py
+++ b/openpype/vendor/python/common/ayon_api/utils.py
@@ -1,3 +1,4 @@
+import os
import re
import datetime
import uuid
@@ -15,6 +16,7 @@ except ImportError:
import requests
import unidecode
+from .constants import SERVER_TIMEOUT_ENV_KEY
from .exceptions import UrlError
REMOVED_VALUE = object()
@@ -27,6 +29,23 @@ RepresentationParents = collections.namedtuple(
)
+def get_default_timeout():
+ """Default value for requests timeout.
+
+ First looks for environment variable SERVER_TIMEOUT_ENV_KEY which
+ can affect timeout value. If not available then use 10.0 s.
+
+ Returns:
+ float: Timeout value in seconds.
+ """
+
+ try:
+ return float(os.environ.get(SERVER_TIMEOUT_ENV_KEY))
+ except (ValueError, TypeError):
+ pass
+ return 10.0
+
+
class ThumbnailContent:
"""Wrapper for thumbnail content.
@@ -231,30 +250,36 @@ def _try_parse_url(url):
return None
-def _try_connect_to_server(url):
+def _try_connect_to_server(url, timeout=None):
+ if timeout is None:
+ timeout = get_default_timeout()
try:
# TODO add validation if the url lead to Ayon server
- # - thiw won't validate if the url lead to 'google.com'
- requests.get(url)
+ # - this won't validate if the url lead to 'google.com'
+ requests.get(url, timeout=timeout)
except BaseException:
return False
return True
-def login_to_server(url, username, password):
+def login_to_server(url, username, password, timeout=None):
"""Use login to the server to receive token.
Args:
url (str): Server url.
username (str): User's username.
password (str): User's password.
+ timeout (Optional[float]): Timeout for request. Value from
+ 'get_default_timeout' is used if not specified.
Returns:
Union[str, None]: User's token if login was successfull.
Otherwise 'None'.
"""
+ if timeout is None:
+ timeout = get_default_timeout()
headers = {"Content-Type": "application/json"}
response = requests.post(
"{}/api/auth/login".format(url),
@@ -262,7 +287,8 @@ def login_to_server(url, username, password):
json={
"name": username,
"password": password
- }
+ },
+ timeout=timeout,
)
token = None
# 200 - success
@@ -273,47 +299,67 @@ def login_to_server(url, username, password):
return token
-def logout_from_server(url, token):
+def logout_from_server(url, token, timeout=None):
"""Logout from server and throw token away.
Args:
url (str): Url from which should be logged out.
token (str): Token which should be used to log out.
+ timeout (Optional[float]): Timeout for request. Value from
+ 'get_default_timeout' is used if not specified.
"""
+ if timeout is None:
+ timeout = get_default_timeout()
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer {}".format(token)
}
requests.post(
url + "/api/auth/logout",
- headers=headers
+ headers=headers,
+ timeout=timeout,
)
-def is_token_valid(url, token):
+def is_token_valid(url, token, timeout=None):
"""Check if token is valid.
+ Token can be a user token or service api key.
+
Args:
url (str): Server url.
token (str): User's token.
+ timeout (Optional[float]): Timeout for request. Value from
+ 'get_default_timeout' is used if not specified.
Returns:
bool: True if token is valid.
"""
- headers = {
+ if timeout is None:
+ timeout = get_default_timeout()
+
+ base_headers = {
"Content-Type": "application/json",
- "Authorization": "Bearer {}".format(token)
}
- response = requests.get(
- "{}/api/users/me".format(url),
- headers=headers
- )
- return response.status_code == 200
+ for header_value in (
+ {"Authorization": "Bearer {}".format(token)},
+ {"X-Api-Key": token},
+ ):
+ headers = base_headers.copy()
+ headers.update(header_value)
+ response = requests.get(
+ "{}/api/users/me".format(url),
+ headers=headers,
+ timeout=timeout,
+ )
+ if response.status_code == 200:
+ return True
+ return False
-def validate_url(url):
+def validate_url(url, timeout=None):
"""Validate url if is valid and server is available.
Validation checks if can be parsed as url and contains scheme.
@@ -334,6 +380,7 @@ def validate_url(url):
Args:
url (str): Server url.
+ timeout (Optional[int]): Timeout in seconds for connection to server.
Returns:
Url which was used to connect to server.
@@ -369,10 +416,10 @@ def validate_url(url):
# - this will trigger UrlError if both will crash
if not parsed_url.scheme:
new_url = "https://" + modified_url
- if _try_connect_to_server(new_url):
+ if _try_connect_to_server(new_url, timeout=timeout):
return new_url
- if _try_connect_to_server(modified_url):
+ if _try_connect_to_server(modified_url, timeout=timeout):
return modified_url
hints = []
diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py
index f3826a6407..ac4f32997f 100644
--- a/openpype/vendor/python/common/ayon_api/version.py
+++ b/openpype/vendor/python/common/ayon_api/version.py
@@ -1,2 +1,2 @@
"""Package declaring Python API for Ayon server."""
-__version__ = "0.4.1"
+__version__ = "0.5.1"
diff --git a/openpype/version.py b/openpype/version.py
index 6f740d0c78..d6839c9b70 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.17.3-nightly.2"
+__version__ = "3.17.5-nightly.1"
diff --git a/pyproject.toml b/pyproject.toml
index ad93b70c0f..633dafece1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.17.2" # OpenPype
+version = "3.17.4" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py
index 5e047b7013..27dc0b232f 100644
--- a/server_addon/blender/server/settings/publish_plugins.py
+++ b/server_addon/blender/server/settings/publish_plugins.py
@@ -103,7 +103,7 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=ValidatePluginModel,
title="Extract FBX"
)
- ExtractABC: ValidatePluginModel = Field(
+ ExtractModelABC: ValidatePluginModel = Field(
default_factory=ValidatePluginModel,
title="Extract ABC"
)
@@ -197,10 +197,10 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = {
"optional": True,
"active": False
},
- "ExtractABC": {
+ "ExtractModelABC": {
"enabled": True,
"optional": True,
- "active": False
+ "active": True
},
"ExtractBlendAnimation": {
"enabled": True,
diff --git a/server_addon/core/server/settings/main.py b/server_addon/core/server/settings/main.py
index ca8f7e63ed..433d0ef2f0 100644
--- a/server_addon/core/server/settings/main.py
+++ b/server_addon/core/server/settings/main.py
@@ -12,6 +12,27 @@ from .publish_plugins import PublishPuginsModel, DEFAULT_PUBLISH_VALUES
from .tools import GlobalToolsModel, DEFAULT_TOOLS_VALUES
+class DiskMappingItemModel(BaseSettingsModel):
+ _layout = "expanded"
+ source: str = Field("", title="Source")
+ destination: str = Field("", title="Destination")
+
+
+class DiskMappingModel(BaseSettingsModel):
+ windows: list[DiskMappingItemModel] = Field(
+ title="Windows",
+ default_factory=list,
+ )
+ linux: list[DiskMappingItemModel] = Field(
+ title="Linux",
+ default_factory=list,
+ )
+ darwin: list[DiskMappingItemModel] = Field(
+ title="MacOS",
+ default_factory=list,
+ )
+
+
class ImageIOFileRuleModel(BaseSettingsModel):
name: str = Field("", title="Rule name")
pattern: str = Field("", title="Regex pattern")
@@ -97,6 +118,10 @@ class CoreSettings(BaseSettingsModel):
widget="textarea",
scope=["studio"],
)
+ disk_mapping: DiskMappingModel = Field(
+ default_factory=DiskMappingModel,
+ title="Disk mapping",
+ )
tools: GlobalToolsModel = Field(
default_factory=GlobalToolsModel,
title="Tools"
diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py
index 7befc795e4..d7c7b367b7 100644
--- a/server_addon/core/server/settings/tools.py
+++ b/server_addon/core/server/settings/tools.py
@@ -487,6 +487,17 @@ DEFAULT_TOOLS_VALUES = {
"task_types": [],
"task_names": [],
"template_name": "publish_online"
+ },
+ {
+ "families": [
+ "tycache"
+ ],
+ "hosts": [
+ "max"
+ ],
+ "task_types": [],
+ "task_names": [],
+ "template_name": "publish_tycache"
}
],
"hero_template_name_profiles": [
diff --git a/server_addon/core/server/version.py b/server_addon/core/server/version.py
index b3f4756216..ae7362549b 100644
--- a/server_addon/core/server/version.py
+++ b/server_addon/core/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.2"
+__version__ = "0.1.3"
diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py
index 61dbd5c8d9..fe8d278321 100644
--- a/server_addon/create_ayon_addons.py
+++ b/server_addon/create_ayon_addons.py
@@ -3,6 +3,7 @@ import sys
import re
import json
import shutil
+import argparse
import zipfile
import platform
import collections
@@ -184,9 +185,12 @@ def create_openpype_package(
addon_output_dir = output_dir / "openpype" / addon_version
private_dir = addon_output_dir / "private"
+ if addon_output_dir.exists():
+ shutil.rmtree(str(addon_output_dir))
+
# Make sure dir exists
- addon_output_dir.mkdir(parents=True)
- private_dir.mkdir(parents=True)
+ addon_output_dir.mkdir(parents=True, exist_ok=True)
+ private_dir.mkdir(parents=True, exist_ok=True)
# Copy version
shutil.copy(str(version_path), str(addon_output_dir))
@@ -205,7 +209,8 @@ def create_openpype_package(
"shotgrid",
"sync_server",
"example_addons",
- "slack"
+ "slack",
+ "kitsu",
]
# Subdirs that won't be added to output zip file
ignored_subpaths = [
@@ -268,22 +273,37 @@ def create_addon_package(
)
-def main(create_zip=True, keep_source=False):
+def main(
+ output_dir=None,
+ skip_zip=True,
+ keep_source=False,
+ clear_output_dir=False,
+ addons=None,
+):
current_dir = Path(os.path.dirname(os.path.abspath(__file__)))
root_dir = current_dir.parent
- output_dir = current_dir / "packages"
- print("Package creation started...")
+ create_zip = not skip_zip
- # Make sure package dir is empty
- if output_dir.exists():
+ if output_dir:
+ output_dir = Path(output_dir)
+ else:
+ output_dir = current_dir / "packages"
+
+ if output_dir.exists() and clear_output_dir:
shutil.rmtree(str(output_dir))
- # Make sure output dir is created
- output_dir.mkdir(parents=True)
+ print("Package creation started...")
+ print(f"Output directory: {output_dir}")
+
+ # Make sure output dir is created
+ output_dir.mkdir(parents=True, exist_ok=True)
for addon_dir in current_dir.iterdir():
if not addon_dir.is_dir():
continue
+ if addons and addon_dir.name not in addons:
+ continue
+
server_dir = addon_dir / "server"
if not server_dir.exists():
continue
@@ -303,6 +323,54 @@ def main(create_zip=True, keep_source=False):
if __name__ == "__main__":
- create_zip = "--skip-zip" not in sys.argv
- keep_sources = "--keep-sources" in sys.argv
- main(create_zip, keep_sources)
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--skip-zip",
+ dest="skip_zip",
+ action="store_true",
+ help=(
+ "Skip zipping server package and create only"
+ " server folder structure."
+ )
+ )
+ parser.add_argument(
+ "--keep-sources",
+ dest="keep_sources",
+ action="store_true",
+ help=(
+ "Keep folder structure when server package is created."
+ )
+ )
+ parser.add_argument(
+ "-o", "--output",
+ dest="output_dir",
+ default=None,
+ help=(
+ "Directory path where package will be created"
+ " (Will be purged if already exists!)"
+ )
+ )
+ parser.add_argument(
+ "-c", "--clear-output-dir",
+ dest="clear_output_dir",
+ action="store_true",
+ help=(
+ "Clear output directory before package creation."
+ )
+ )
+ parser.add_argument(
+ "-a",
+ "--addon",
+ dest="addons",
+ action="append",
+ help="Limit addon creation to given addon name",
+ )
+
+ args = parser.parse_args(sys.argv[1:])
+ main(
+ args.output_dir,
+ args.skip_zip,
+ args.keep_sources,
+ args.clear_output_dir,
+ args.addons,
+ )
diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/create.py
similarity index 54%
rename from server_addon/houdini/server/settings/publish_plugins.py
rename to server_addon/houdini/server/settings/create.py
index 58240b0205..81b871e83f 100644
--- a/server_addon/houdini/server/settings/publish_plugins.py
+++ b/server_addon/houdini/server/settings/create.py
@@ -1,5 +1,4 @@
from pydantic import Field
-
from ayon_server.settings import BaseSettingsModel
@@ -35,52 +34,110 @@ class CreateStaticMeshModel(BaseSettingsModel):
class CreatePluginsModel(BaseSettingsModel):
- CreateArnoldAss: CreateArnoldAssModel = Field(
- default_factory=CreateArnoldAssModel,
- title="Create Alembic Camera")
- # "-" is not compatible in the new model
- CreateStaticMesh: CreateStaticMeshModel = Field(
- default_factory=CreateStaticMeshModel,
- title="Create Static Mesh"
- )
CreateAlembicCamera: CreatorModel = Field(
default_factory=CreatorModel,
title="Create Alembic Camera")
+ CreateArnoldAss: CreateArnoldAssModel = Field(
+ default_factory=CreateArnoldAssModel,
+ title="Create Arnold Ass")
+ CreateArnoldRop: CreatorModel = Field(
+ default_factory=CreatorModel,
+ title="Create Arnold ROP")
CreateCompositeSequence: CreatorModel = Field(
default_factory=CreatorModel,
- title="Create Composite Sequence")
+ title="Create Composite (Image Sequence)")
+ CreateHDA: CreatorModel = Field(
+ default_factory=CreatorModel,
+ title="Create Houdini Digital Asset")
+ CreateKarmaROP: CreatorModel = Field(
+ default_factory=CreatorModel,
+ title="Create Karma ROP")
+ CreateMantraROP: CreatorModel = Field(
+ default_factory=CreatorModel,
+ title="Create Mantra ROP")
CreatePointCache: CreatorModel = Field(
default_factory=CreatorModel,
- title="Create Point Cache")
+ title="Create PointCache (Abc)")
+ CreateBGEO: CreatorModel = Field(
+ default_factory=CreatorModel,
+ title="Create PointCache (Bgeo)")
+ CreateRedshiftProxy: CreatorModel = Field(
+ default_factory=CreatorModel,
+ title="Create Redshift Proxy")
CreateRedshiftROP: CreatorModel = Field(
default_factory=CreatorModel,
- title="Create RedshiftROP")
- CreateRemotePublish: CreatorModel = Field(
+ title="Create Redshift ROP")
+ CreateReview: CreatorModel = Field(
default_factory=CreatorModel,
- title="Create Remote Publish")
+ title="Create Review")
+ # "-" is not compatible in the new model
+ CreateStaticMesh: CreateStaticMeshModel = Field(
+ default_factory=CreateStaticMeshModel,
+ title="Create Static Mesh")
+ CreateUSD: CreatorModel = Field(
+ default_factory=CreatorModel,
+ title="Create USD (experimental)")
+ CreateUSDRender: CreatorModel = Field(
+ default_factory=CreatorModel,
+ title="Create USD render (experimental)")
CreateVDBCache: CreatorModel = Field(
default_factory=CreatorModel,
title="Create VDB Cache")
- CreateUSD: CreatorModel = Field(
+ CreateVrayROP: CreatorModel = Field(
default_factory=CreatorModel,
- title="Create USD")
- CreateUSDModel: CreatorModel = Field(
- default_factory=CreatorModel,
- title="Create USD model")
- USDCreateShadingWorkspace: CreatorModel = Field(
- default_factory=CreatorModel,
- title="Create USD shading workspace")
- CreateUSDRender: CreatorModel = Field(
- default_factory=CreatorModel,
- title="Create USD render")
+ title="Create VRay ROP")
DEFAULT_HOUDINI_CREATE_SETTINGS = {
+ "CreateAlembicCamera": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
"CreateArnoldAss": {
"enabled": True,
"default_variants": ["Main"],
"ext": ".ass"
},
+ "CreateArnoldRop": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateCompositeSequence": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateHDA": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateKarmaROP": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateMantraROP": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreatePointCache": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateBGEO": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateRedshiftProxy": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateRedshiftROP": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateReview": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
"CreateStaticMesh": {
"enabled": True,
"default_variants": [
@@ -94,126 +151,20 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = {
"UCX"
]
},
- "CreateAlembicCamera": {
- "enabled": True,
- "default_variants": ["Main"]
- },
- "CreateCompositeSequence": {
- "enabled": True,
- "default_variants": ["Main"]
- },
- "CreatePointCache": {
- "enabled": True,
- "default_variants": ["Main"]
- },
- "CreateRedshiftROP": {
- "enabled": True,
- "default_variants": ["Main"]
- },
- "CreateRemotePublish": {
- "enabled": True,
- "default_variants": ["Main"]
- },
- "CreateVDBCache": {
- "enabled": True,
- "default_variants": ["Main"]
- },
"CreateUSD": {
"enabled": False,
"default_variants": ["Main"]
},
- "CreateUSDModel": {
- "enabled": False,
- "default_variants": ["Main"]
- },
- "USDCreateShadingWorkspace": {
- "enabled": False,
- "default_variants": ["Main"]
- },
"CreateUSDRender": {
"enabled": False,
"default_variants": ["Main"]
},
-}
-
-
-# Publish Plugins
-class ValidateWorkfilePathsModel(BaseSettingsModel):
- enabled: bool = Field(title="Enabled")
- optional: bool = Field(title="Optional")
- node_types: list[str] = Field(
- default_factory=list,
- title="Node Types"
- )
- prohibited_vars: list[str] = Field(
- default_factory=list,
- title="Prohibited Variables"
- )
-
-
-class BasicValidateModel(BaseSettingsModel):
- enabled: bool = Field(title="Enabled")
- optional: bool = Field(title="Optional")
- active: bool = Field(title="Active")
-
-
-class PublishPluginsModel(BaseSettingsModel):
- ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field(
- default_factory=ValidateWorkfilePathsModel,
- title="Validate workfile paths settings.")
- ValidateReviewColorspace: BasicValidateModel = Field(
- default_factory=BasicValidateModel,
- title="Validate Review Colorspace.")
- ValidateContainers: BasicValidateModel = Field(
- default_factory=BasicValidateModel,
- title="Validate Latest Containers.")
- ValidateSubsetName: BasicValidateModel = Field(
- default_factory=BasicValidateModel,
- title="Validate Subset Name.")
- ValidateMeshIsStatic: BasicValidateModel = Field(
- default_factory=BasicValidateModel,
- title="Validate Mesh is Static.")
- ValidateUnrealStaticMeshName: BasicValidateModel = Field(
- default_factory=BasicValidateModel,
- title="Validate Unreal Static Mesh Name.")
-
-
-DEFAULT_HOUDINI_PUBLISH_SETTINGS = {
- "ValidateWorkfilePaths": {
- "enabled": True,
- "optional": True,
- "node_types": [
- "file",
- "alembic"
- ],
- "prohibited_vars": [
- "$HIP",
- "$JOB"
- ]
- },
- "ValidateReviewColorspace": {
- "enabled": True,
- "optional": True,
- "active": True
- },
- "ValidateContainers": {
- "enabled": True,
- "optional": True,
- "active": True
- },
- "ValidateSubsetName": {
- "enabled": True,
- "optional": True,
- "active": True
- },
- "ValidateMeshIsStatic": {
- "enabled": True,
- "optional": True,
- "active": True
- },
- "ValidateUnrealStaticMeshName": {
- "enabled": False,
- "optional": True,
- "active": True
- }
+ "CreateVDBCache": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
+ "CreateVrayROP": {
+ "enabled": True,
+ "default_variants": ["Main"]
+ },
}
diff --git a/server_addon/houdini/server/settings/main.py b/server_addon/houdini/server/settings/main.py
index 0c2e160c87..9cfec54f22 100644
--- a/server_addon/houdini/server/settings/main.py
+++ b/server_addon/houdini/server/settings/main.py
@@ -1,57 +1,19 @@
from pydantic import Field
-from ayon_server.settings import (
- BaseSettingsModel,
- MultiplatformPathModel,
- MultiplatformPathListModel,
-)
+from ayon_server.settings import BaseSettingsModel
from .general import (
GeneralSettingsModel,
DEFAULT_GENERAL_SETTINGS
)
from .imageio import HoudiniImageIOModel
-from .publish_plugins import (
- PublishPluginsModel,
+from .shelves import ShelvesModel
+from .create import (
CreatePluginsModel,
- DEFAULT_HOUDINI_PUBLISH_SETTINGS,
DEFAULT_HOUDINI_CREATE_SETTINGS
)
-
-
-class ShelfToolsModel(BaseSettingsModel):
- name: str = Field(title="Name")
- help: str = Field(title="Help text")
- script: MultiplatformPathModel = Field(
- default_factory=MultiplatformPathModel,
- title="Script Path "
- )
- icon: MultiplatformPathModel = Field(
- default_factory=MultiplatformPathModel,
- title="Icon Path "
- )
-
-
-class ShelfDefinitionModel(BaseSettingsModel):
- _layout = "expanded"
- shelf_name: str = Field(title="Shelf name")
- tools_list: list[ShelfToolsModel] = Field(
- default_factory=list,
- title="Shelf Tools"
- )
-
-
-class ShelvesModel(BaseSettingsModel):
- _layout = "expanded"
- shelf_set_name: str = Field(title="Shelfs set name")
-
- shelf_set_source_path: MultiplatformPathListModel = Field(
- default_factory=MultiplatformPathListModel,
- title="Shelf Set Path (optional)"
- )
-
- shelf_definition: list[ShelfDefinitionModel] = Field(
- default_factory=list,
- title="Shelf Definitions"
- )
+from .publish import (
+ PublishPluginsModel,
+ DEFAULT_HOUDINI_PUBLISH_SETTINGS,
+)
class HoudiniSettings(BaseSettingsModel):
@@ -65,18 +27,16 @@ class HoudiniSettings(BaseSettingsModel):
)
shelves: list[ShelvesModel] = Field(
default_factory=list,
- title="Houdini Scripts Shelves",
+ title="Shelves Manager",
)
-
- publish: PublishPluginsModel = Field(
- default_factory=PublishPluginsModel,
- title="Publish Plugins",
- )
-
create: CreatePluginsModel = Field(
default_factory=CreatePluginsModel,
title="Creator Plugins",
)
+ publish: PublishPluginsModel = Field(
+ default_factory=PublishPluginsModel,
+ title="Publish Plugins",
+ )
DEFAULT_VALUES = {
diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py
new file mode 100644
index 0000000000..6615e34ca5
--- /dev/null
+++ b/server_addon/houdini/server/settings/publish.py
@@ -0,0 +1,103 @@
+from pydantic import Field
+from ayon_server.settings import BaseSettingsModel
+
+
+# Publish Plugins
+class CollectRopFrameRangeModel(BaseSettingsModel):
+ """Collect Frame Range
+ Disable this if you want the publisher to
+ ignore start and end handles specified in the
+ asset data for publish instances
+ """
+ use_asset_handles: bool = Field(
+ title="Use asset handles")
+
+
+class ValidateWorkfilePathsModel(BaseSettingsModel):
+ enabled: bool = Field(title="Enabled")
+ optional: bool = Field(title="Optional")
+ node_types: list[str] = Field(
+ default_factory=list,
+ title="Node Types"
+ )
+ prohibited_vars: list[str] = Field(
+ default_factory=list,
+ title="Prohibited Variables"
+ )
+
+
+class BasicValidateModel(BaseSettingsModel):
+ enabled: bool = Field(title="Enabled")
+ optional: bool = Field(title="Optional")
+ active: bool = Field(title="Active")
+
+
+class PublishPluginsModel(BaseSettingsModel):
+ CollectRopFrameRange: CollectRopFrameRangeModel = Field(
+ default_factory=CollectRopFrameRangeModel,
+ title="Collect Rop Frame Range.",
+ section="Collectors"
+ )
+ ValidateContainers: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Latest Containers.",
+ section="Validators")
+ ValidateMeshIsStatic: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Mesh is Static.")
+ ValidateReviewColorspace: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Review Colorspace.")
+ ValidateSubsetName: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Subset Name.")
+ ValidateUnrealStaticMeshName: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Unreal Static Mesh Name.")
+ ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field(
+ default_factory=ValidateWorkfilePathsModel,
+ title="Validate workfile paths settings.")
+
+
+DEFAULT_HOUDINI_PUBLISH_SETTINGS = {
+ "CollectRopFrameRange": {
+ "use_asset_handles": True
+ },
+ "ValidateContainers": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
+ "ValidateMeshIsStatic": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
+ "ValidateReviewColorspace": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
+ "ValidateSubsetName": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
+ "ValidateUnrealStaticMeshName": {
+ "enabled": False,
+ "optional": True,
+ "active": True
+ },
+ "ValidateWorkfilePaths": {
+ "enabled": True,
+ "optional": True,
+ "node_types": [
+ "file",
+ "alembic"
+ ],
+ "prohibited_vars": [
+ "$HIP",
+ "$JOB"
+ ]
+ }
+}
diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py
new file mode 100644
index 0000000000..8d0512bdeb
--- /dev/null
+++ b/server_addon/houdini/server/settings/shelves.py
@@ -0,0 +1,37 @@
+from pydantic import Field
+from ayon_server.settings import (
+ BaseSettingsModel,
+ MultiplatformPathModel
+)
+
+
+class ShelfToolsModel(BaseSettingsModel):
+ """Name and Script Path are mandatory."""
+ label: str = Field(title="Name")
+ script: str = Field(title="Script Path")
+ icon: str = Field("", title="Icon Path")
+ help: str = Field("", title="Help text")
+
+
+class ShelfDefinitionModel(BaseSettingsModel):
+ _layout = "expanded"
+ shelf_name: str = Field(title="Shelf name")
+ tools_list: list[ShelfToolsModel] = Field(
+ default_factory=list,
+ title="Shelf Tools"
+ )
+
+
+class ShelvesModel(BaseSettingsModel):
+ _layout = "expanded"
+ shelf_set_name: str = Field("", title="Shelfs set name")
+
+ shelf_set_source_path: MultiplatformPathModel = Field(
+ default_factory=MultiplatformPathModel,
+ title="Shelf Set Path (optional)"
+ )
+
+ shelf_definition: list[ShelfDefinitionModel] = Field(
+ default_factory=list,
+ title="Shelf Definitions"
+ )
diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py
index 1276d0254f..01ef12070d 100644
--- a/server_addon/houdini/server/version.py
+++ b/server_addon/houdini/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.5"
+__version__ = "0.2.6"
diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py
index 811b12104b..15ccd4e89a 100644
--- a/server_addon/nuke/server/settings/imageio.py
+++ b/server_addon/nuke/server/settings/imageio.py
@@ -14,10 +14,30 @@ class NodesModel(BaseSettingsModel):
default_factory=list,
title="Used in plugins"
)
- nukeNodeClass: str = Field(
+ nuke_node_class: str = Field(
title="Nuke Node Class",
)
+
+class RequiredNodesModel(NodesModel):
+ knobs: list[KnobModel] = Field(
+ default_factory=list,
+ title="Knobs",
+ )
+
+ @validator("knobs")
+ def ensure_unique_names(cls, value):
+ """Ensure name fields within the lists have unique names."""
+ ensure_unique_names(value)
+ return value
+
+
+class OverrideNodesModel(NodesModel):
+ subsets: list[str] = Field(
+ default_factory=list,
+ title="Subsets"
+ )
+
knobs: list[KnobModel] = Field(
default_factory=list,
title="Knobs",
@@ -31,13 +51,11 @@ class NodesModel(BaseSettingsModel):
class NodesSetting(BaseSettingsModel):
- # TODO: rename `requiredNodes` to `required_nodes`
- requiredNodes: list[NodesModel] = Field(
+ required_nodes: list[RequiredNodesModel] = Field(
title="Plugin required",
default_factory=list
)
- # TODO: rename `overrideNodes` to `override_nodes`
- overrideNodes: list[NodesModel] = Field(
+ override_nodes: list[OverrideNodesModel] = Field(
title="Plugin's node overrides",
default_factory=list
)
@@ -46,38 +64,40 @@ class NodesSetting(BaseSettingsModel):
def ocio_configs_switcher_enum():
return [
{"value": "nuke-default", "label": "nuke-default"},
- {"value": "spi-vfx", "label": "spi-vfx"},
- {"value": "spi-anim", "label": "spi-anim"},
- {"value": "aces_0.1.1", "label": "aces_0.1.1"},
- {"value": "aces_0.7.1", "label": "aces_0.7.1"},
- {"value": "aces_1.0.1", "label": "aces_1.0.1"},
- {"value": "aces_1.0.3", "label": "aces_1.0.3"},
- {"value": "aces_1.1", "label": "aces_1.1"},
- {"value": "aces_1.2", "label": "aces_1.2"},
- {"value": "aces_1.3", "label": "aces_1.3"},
- {"value": "custom", "label": "custom"}
+ {"value": "spi-vfx", "label": "spi-vfx (11)"},
+ {"value": "spi-anim", "label": "spi-anim (11)"},
+ {"value": "aces_0.1.1", "label": "aces_0.1.1 (11)"},
+ {"value": "aces_0.7.1", "label": "aces_0.7.1 (11)"},
+ {"value": "aces_1.0.1", "label": "aces_1.0.1 (11)"},
+ {"value": "aces_1.0.3", "label": "aces_1.0.3 (11, 12)"},
+ {"value": "aces_1.1", "label": "aces_1.1 (12, 13)"},
+ {"value": "aces_1.2", "label": "aces_1.2 (13, 14)"},
+ {"value": "studio-config-v1.0.0_aces-v1.3_ocio-v2.1",
+ "label": "studio-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)"},
+ {"value": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1",
+ "label": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)"},
]
class WorkfileColorspaceSettings(BaseSettingsModel):
"""Nuke workfile colorspace preset. """
- colorManagement: Literal["Nuke", "OCIO"] = Field(
- title="Color Management"
+ color_management: Literal["Nuke", "OCIO"] = Field(
+ title="Color Management Workflow"
)
- OCIO_config: str = Field(
- title="OpenColorIO Config",
- description="Switch between OCIO configs",
+ native_ocio_config: str = Field(
+ title="Native OpenColorIO Config",
+ description="Switch between native OCIO configs",
enum_resolver=ocio_configs_switcher_enum,
conditionalEnum=True
)
- workingSpaceLUT: str = Field(
+ working_space: str = Field(
title="Working Space"
)
- monitorLut: str = Field(
- title="Monitor"
+ thumbnail_space: str = Field(
+ title="Thumbnail Space"
)
@@ -182,12 +202,10 @@ class ImageIOSettings(BaseSettingsModel):
title="Nodes"
)
"""# TODO: enhance settings with host api:
- - [ ] old settings are using `regexInputs` key but we
- need to rename to `regex_inputs`
- [ ] no need for `inputs` middle part. It can stay
directly on `regex_inputs`
"""
- regexInputs: RegexInputsModel = Field(
+ regex_inputs: RegexInputsModel = Field(
default_factory=RegexInputsModel,
title="Assign colorspace to read nodes via rules"
)
@@ -201,18 +219,18 @@ DEFAULT_IMAGEIO_SETTINGS = {
"viewerProcess": "rec709"
},
"workfile": {
- "colorManagement": "Nuke",
- "OCIO_config": "nuke-default",
- "workingSpaceLUT": "linear",
- "monitorLut": "sRGB",
+ "color_management": "Nuke",
+ "native_ocio_config": "nuke-default",
+ "working_space": "linear",
+ "thumbnail_space": "sRGB",
},
"nodes": {
- "requiredNodes": [
+ "required_nodes": [
{
"plugins": [
"CreateWriteRender"
],
- "nukeNodeClass": "Write",
+ "nuke_node_class": "Write",
"knobs": [
{
"type": "text",
@@ -264,7 +282,7 @@ DEFAULT_IMAGEIO_SETTINGS = {
"plugins": [
"CreateWritePrerender"
],
- "nukeNodeClass": "Write",
+ "nuke_node_class": "Write",
"knobs": [
{
"type": "text",
@@ -316,7 +334,7 @@ DEFAULT_IMAGEIO_SETTINGS = {
"plugins": [
"CreateWriteImage"
],
- "nukeNodeClass": "Write",
+ "nuke_node_class": "Write",
"knobs": [
{
"type": "text",
@@ -360,9 +378,9 @@ DEFAULT_IMAGEIO_SETTINGS = {
]
}
],
- "overrideNodes": []
+ "override_nodes": []
},
- "regexInputs": {
+ "regex_inputs": {
"inputs": [
{
"regex": "(beauty).*(?=.exr)",
diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py
index 692b2bd240..81663fa5aa 100644
--- a/server_addon/nuke/server/settings/publish_plugins.py
+++ b/server_addon/nuke/server/settings/publish_plugins.py
@@ -155,8 +155,10 @@ class IntermediateOutputModel(BaseSettingsModel):
title="Filter", default_factory=BakingStreamFilterModel)
read_raw: bool = Field(title="Read raw switch")
viewer_process_override: str = Field(title="Viewer process override")
- bake_viewer_process: bool = Field(title="Bake view process")
- bake_viewer_input_process: bool = Field(title="Bake viewer input process")
+ bake_viewer_process: bool = Field(title="Bake viewer process")
+ bake_viewer_input_process: bool = Field(
+ title="Bake viewer input process node (LUT)"
+ )
reformat_nodes_config: ReformatNodesConfigModel = Field(
default_factory=ReformatNodesConfigModel,
title="Reformat Nodes")
@@ -261,8 +263,8 @@ class PublishPuginsModel(BaseSettingsModel):
title="Validate Backdrop",
default_factory=OptionalPluginModel
)
- ValidateScript: OptionalPluginModel = Field(
- title="Validate Script",
+ ValidateScriptAttributes: OptionalPluginModel = Field(
+ title="Validate workfile attributes",
default_factory=OptionalPluginModel
)
ExtractThumbnail: ExtractThumbnailModel = Field(
@@ -343,7 +345,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = {
"optional": True,
"active": True
},
- "ValidateScript": {
+ "ValidateScriptAttributes": {
"enabled": True,
"optional": True,
"active": True
@@ -407,12 +409,12 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = {
"text": "Lanczos6"
},
{
- "type": "bool",
+ "type": "boolean",
"name": "black_outside",
"boolean": True
},
{
- "type": "bool",
+ "type": "boolean",
"name": "pbb",
"boolean": False
}
@@ -427,7 +429,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = {
"enabled": False
},
"ExtractReviewDataMov": {
- "enabled": True,
+ "enabled": False,
"viewer_lut_raw": False,
"outputs": [
{
@@ -463,12 +465,12 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = {
"text": "Lanczos6"
},
{
- "type": "bool",
+ "type": "boolean",
"name": "black_outside",
"boolean": True
},
{
- "type": "bool",
+ "type": "boolean",
"name": "pbb",
"boolean": False
}
@@ -518,12 +520,12 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = {
"text": "Lanczos6"
},
{
- "type": "bool",
+ "type": "boolean",
"name": "black_outside",
"boolean": True
},
{
- "type": "bool",
+ "type": "boolean",
"name": "pbb",
"boolean": False
}
diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py
index ae7362549b..bbab0242f6 100644
--- a/server_addon/nuke/server/version.py
+++ b/server_addon/nuke/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.3"
+__version__ = "0.1.4"
diff --git a/server_addon/openpype/client/pyproject.toml b/server_addon/openpype/client/pyproject.toml
index 6d5ac92ca7..40da8f6716 100644
--- a/server_addon/openpype/client/pyproject.toml
+++ b/server_addon/openpype/client/pyproject.toml
@@ -8,7 +8,6 @@ aiohttp_json_rpc = "*" # TVPaint server
aiohttp-middlewares = "^2.0.0"
wsrpc_aiohttp = "^3.1.1" # websocket server
clique = "1.6.*"
-shotgun_api3 = {git = "https://github.com/shotgunsoftware/python-api.git", rev = "v3.3.3"}
gazu = "^0.9.3"
google-api-python-client = "^1.12.8" # sync server google support (should be separate?)
jsonschema = "^2.6.0"
diff --git a/tests/unit/openpype/pipeline/publish/test_publish_plugins.py b/tests/unit/openpype/pipeline/publish/test_publish_plugins.py
index aace8cf7e3..1f7f551237 100644
--- a/tests/unit/openpype/pipeline/publish/test_publish_plugins.py
+++ b/tests/unit/openpype/pipeline/publish/test_publish_plugins.py
@@ -37,7 +37,7 @@ class TestPipelinePublishPlugins(TestPipeline):
# files are the same as those used in `test_pipeline_colorspace`
TEST_FILES = [
(
- "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh",
+ "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA",
"test_pipeline_colorspace.zip",
""
)
@@ -123,8 +123,7 @@ class TestPipelinePublishPlugins(TestPipeline):
def test_get_colorspace_settings(self, context, config_path_asset):
expected_config_template = (
- "{root[work]}/{project[name]}"
- "/{hierarchy}/{asset}/config/aces.ocio"
+ "{root[work]}/{project[name]}/config/aces.ocio"
)
expected_file_rules = {
"comp_review": {
@@ -177,16 +176,16 @@ class TestPipelinePublishPlugins(TestPipeline):
# load plugin function for testing
plugin = publish_plugins.ColormanagedPyblishPluginMixin()
plugin.log = log
+ context.data["imageioSettings"] = (config_data_nuke, file_rules_nuke)
plugin.set_representation_colorspace(
- representation_nuke, context,
- colorspace_settings=(config_data_nuke, file_rules_nuke)
+ representation_nuke, context
)
# load plugin function for testing
plugin = publish_plugins.ColormanagedPyblishPluginMixin()
plugin.log = log
+ context.data["imageioSettings"] = (config_data_hiero, file_rules_hiero)
plugin.set_representation_colorspace(
- representation_hiero, context,
- colorspace_settings=(config_data_hiero, file_rules_hiero)
+ representation_hiero, context
)
colorspace_data_nuke = representation_nuke.get("colorspaceData")
diff --git a/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py b/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py
new file mode 100644
index 0000000000..56ac2a5d28
--- /dev/null
+++ b/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py
@@ -0,0 +1,118 @@
+import unittest
+from openpype.pipeline.colorspace import convert_colorspace_enumerator_item
+
+
+class TestConvertColorspaceEnumeratorItem(unittest.TestCase):
+ def setUp(self):
+ self.config_items = {
+ "colorspaces": {
+ "sRGB": {
+ "aliases": ["sRGB_1"],
+ "family": "colorspace",
+ "categories": ["colors"],
+ "equalitygroup": "equalitygroup",
+ },
+ "Rec.709": {
+ "aliases": ["rec709_1", "rec709_2"],
+ },
+ },
+ "looks": {
+ "sRGB_to_Rec.709": {
+ "process_space": "sRGB",
+ },
+ },
+ "displays_views": {
+ "sRGB (ACES)": {
+ "view": "sRGB",
+ "display": "ACES",
+ },
+ "Rec.709 (ACES)": {
+ "view": "Rec.709",
+ "display": "ACES",
+ },
+ },
+ "roles": {
+ "compositing_linear": {
+ "colorspace": "linear",
+ },
+ },
+ }
+
+ def test_valid_item(self):
+ colorspace_item_data = convert_colorspace_enumerator_item(
+ "colorspaces::sRGB", self.config_items)
+ self.assertEqual(
+ colorspace_item_data,
+ {
+ "name": "sRGB",
+ "type": "colorspaces",
+ "aliases": ["sRGB_1"],
+ "family": "colorspace",
+ "categories": ["colors"],
+ "equalitygroup": "equalitygroup"
+ }
+ )
+
+ alias_item_data = convert_colorspace_enumerator_item(
+ "aliases::rec709_1", self.config_items)
+ self.assertEqual(
+ alias_item_data,
+ {
+ "aliases": ["rec709_1", "rec709_2"],
+ "name": "Rec.709",
+ "type": "colorspace"
+ }
+ )
+
+ display_view_item_data = convert_colorspace_enumerator_item(
+ "displays_views::sRGB (ACES)", self.config_items)
+ self.assertEqual(
+ display_view_item_data,
+ {
+ "type": "displays_views",
+ "name": "sRGB (ACES)",
+ "view": "sRGB",
+ "display": "ACES"
+ }
+ )
+
+ role_item_data = convert_colorspace_enumerator_item(
+ "roles::compositing_linear", self.config_items)
+ self.assertEqual(
+ role_item_data,
+ {
+ "name": "compositing_linear",
+ "type": "roles",
+ "colorspace": "linear"
+ }
+ )
+
+ look_item_data = convert_colorspace_enumerator_item(
+ "looks::sRGB_to_Rec.709", self.config_items)
+ self.assertEqual(
+ look_item_data,
+ {
+ "type": "looks",
+ "name": "sRGB_to_Rec.709",
+ "process_space": "sRGB"
+ }
+ )
+
+ def test_invalid_item(self):
+ config_items = {
+ "RGB": {
+ "sRGB": {"red": 255, "green": 255, "blue": 255},
+ "AdobeRGB": {"red": 255, "green": 255, "blue": 255},
+ }
+ }
+ with self.assertRaises(KeyError):
+ convert_colorspace_enumerator_item("RGB::invalid", config_items)
+
+ def test_missing_config_data(self):
+ config_items = {}
+ with self.assertRaises(KeyError):
+ convert_colorspace_enumerator_item("RGB::sRGB", config_items)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py b/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py
new file mode 100644
index 0000000000..c221712d70
--- /dev/null
+++ b/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py
@@ -0,0 +1,121 @@
+import unittest
+
+from openpype.pipeline.colorspace import get_colorspaces_enumerator_items
+
+
+class TestGetColorspacesEnumeratorItems(unittest.TestCase):
+ def setUp(self):
+ self.config_items = {
+ "colorspaces": {
+ "sRGB": {
+ "aliases": ["sRGB_1"],
+ },
+ "Rec.709": {
+ "aliases": ["rec709_1", "rec709_2"],
+ },
+ },
+ "looks": {
+ "sRGB_to_Rec.709": {
+ "process_space": "sRGB",
+ },
+ },
+ "displays_views": {
+ "sRGB (ACES)": {
+ "view": "sRGB",
+ "display": "ACES",
+ },
+ "Rec.709 (ACES)": {
+ "view": "Rec.709",
+ "display": "ACES",
+ },
+ },
+ "roles": {
+ "compositing_linear": {
+ "colorspace": "linear",
+ },
+ },
+ }
+
+ def test_colorspaces(self):
+ result = get_colorspaces_enumerator_items(self.config_items)
+ expected = [
+ ("colorspaces::Rec.709", "[colorspace] Rec.709"),
+ ("colorspaces::sRGB", "[colorspace] sRGB"),
+ ]
+ self.assertEqual(result, expected)
+
+ def test_aliases(self):
+ result = get_colorspaces_enumerator_items(
+ self.config_items, include_aliases=True)
+ expected = [
+ ("colorspaces::Rec.709", "[colorspace] Rec.709"),
+ ("colorspaces::sRGB", "[colorspace] sRGB"),
+ ("aliases::rec709_1", "[alias] rec709_1 (Rec.709)"),
+ ("aliases::rec709_2", "[alias] rec709_2 (Rec.709)"),
+ ("aliases::sRGB_1", "[alias] sRGB_1 (sRGB)"),
+ ]
+ self.assertEqual(result, expected)
+
+ def test_looks(self):
+ result = get_colorspaces_enumerator_items(
+ self.config_items, include_looks=True)
+ expected = [
+ ("colorspaces::Rec.709", "[colorspace] Rec.709"),
+ ("colorspaces::sRGB", "[colorspace] sRGB"),
+ ("looks::sRGB_to_Rec.709", "[look] sRGB_to_Rec.709 (sRGB)"),
+ ]
+ self.assertEqual(result, expected)
+
+ def test_display_views(self):
+ result = get_colorspaces_enumerator_items(
+ self.config_items, include_display_views=True)
+ expected = [
+ ("colorspaces::Rec.709", "[colorspace] Rec.709"),
+ ("colorspaces::sRGB", "[colorspace] sRGB"),
+ ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), # noqa: E501
+ ("displays_views::sRGB (ACES)", "[view (display)] sRGB (ACES)"),
+
+ ]
+ self.assertEqual(result, expected)
+
+ def test_roles(self):
+ result = get_colorspaces_enumerator_items(
+ self.config_items, include_roles=True)
+ expected = [
+ ("roles::compositing_linear", "[role] compositing_linear (linear)"), # noqa: E501
+ ("colorspaces::Rec.709", "[colorspace] Rec.709"),
+ ("colorspaces::sRGB", "[colorspace] sRGB"),
+ ]
+ self.assertEqual(result, expected)
+
+ def test_all(self):
+ message_config_keys = ", ".join(
+ "'{}':{}".format(
+ key,
+ set(self.config_items.get(key, {}).keys())
+ ) for key in self.config_items.keys()
+ )
+ print("Testing with config: [{}]".format(message_config_keys))
+ result = get_colorspaces_enumerator_items(
+ self.config_items,
+ include_aliases=True,
+ include_looks=True,
+ include_roles=True,
+ include_display_views=True,
+ )
+ expected = [
+ ("roles::compositing_linear", "[role] compositing_linear (linear)"), # noqa: E501
+ ("colorspaces::Rec.709", "[colorspace] Rec.709"),
+ ("colorspaces::sRGB", "[colorspace] sRGB"),
+ ("aliases::rec709_1", "[alias] rec709_1 (Rec.709)"),
+ ("aliases::rec709_2", "[alias] rec709_2 (Rec.709)"),
+ ("aliases::sRGB_1", "[alias] sRGB_1 (sRGB)"),
+ ("looks::sRGB_to_Rec.709", "[look] sRGB_to_Rec.709 (sRGB)"),
+ ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), # noqa: E501
+ ("displays_views::sRGB (ACES)", "[view (display)] sRGB (ACES)"),
+ ]
+ self.assertEqual(result, expected)
+
+
+if __name__ == "__main__":
+ unittest.main()