diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index fd3455ac76..fd6999604a 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,20 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
+ - 3.18.5
+ - 3.18.5-nightly.3
+ - 3.18.5-nightly.2
+ - 3.18.5-nightly.1
+ - 3.18.4
+ - 3.18.4-nightly.1
+ - 3.18.3
+ - 3.18.3-nightly.2
+ - 3.18.3-nightly.1
+ - 3.18.2
+ - 3.18.2-nightly.6
+ - 3.18.2-nightly.5
+ - 3.18.2-nightly.4
+ - 3.18.2-nightly.3
- 3.18.2-nightly.2
- 3.18.2-nightly.1
- 3.18.1
@@ -121,20 +135,6 @@ body:
- 3.15.8-nightly.3
- 3.15.8-nightly.2
- 3.15.8-nightly.1
- - 3.15.7
- - 3.15.7-nightly.3
- - 3.15.7-nightly.2
- - 3.15.7-nightly.1
- - 3.15.6
- - 3.15.6-nightly.3
- - 3.15.6-nightly.2
- - 3.15.6-nightly.1
- - 3.15.5
- - 3.15.5-nightly.2
- - 3.15.5-nightly.1
- - 3.15.4
- - 3.15.4-nightly.3
- - 3.15.4-nightly.2
validations:
required: true
- type: dropdown
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f309d904eb..14f0bc469f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,939 @@
# Changelog
+## [3.18.5](https://github.com/ynput/OpenPype/tree/3.18.5)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.4...3.18.5)
+
+### **🚀 Enhancements**
+
+
+
+Chore: Add addons dir only if exists #6140
+
+Do not add addons directory path for addons discovery if does not exists.
+
+
+___
+
+
+
+
+
+Hiero: Effect Categories - OP-7397 #6143
+
+This PR introduces `Effect Categories` for the Hiero settings. This allows studios to split effect stacks into meaningful subsets.
+
+
+___
+
+
+
+
+
+Nuke: Render Workfile Attributes #6146
+
+`Workfile Dependency` default value can now be controlled from project settings.`Use Published Workfile` makes using published workfiles for rendering optional.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Attributes are locked after publishing if they are locked in Camera Family #6073
+
+This PR is to make sure unlock attributes only during the bake context, make sure attributes are relocked after to preserve the lock state of the original node being baked.
+
+
+___
+
+
+
+
+
+Missing nuke family Windows arguments #6131
+
+Default Windows arguments for launching the Nuke family was missing.
+
+
+___
+
+
+
+
+
+AYON: Fix the bug on the limit group not being set correctly in Maya Deadline Setting #6139
+
+This PR is to bug-fix the limit groups from maya deadline settings errored out when the user tries to edit the setting.
+
+
+___
+
+
+
+
+
+Chore: Transcoding extensions add missing '.tif' extension #6142
+
+Image extensions in transcoding helper was missing `.tif` extension and had `.tiff` twice.
+
+
+___
+
+
+
+
+
+Blender: Use the new API for override context #6145
+
+Blender 4.0 disabled the old API to override context. This API updates the code to use the new API.
+
+
+___
+
+
+
+
+
+BugFix: Include Model in FBX Loader in Houdini #6150
+
+A quick bugfig where we can't load fbx exported from blender. The bug was reported here.
+
+
+___
+
+
+
+
+
+Blender: Restore actions to objects after update #6153
+
+Restore the actions assigned to objects after updating assets from blend files.
+
+
+___
+
+
+
+
+
+Chore: Collect template data with hierarchy context #6154
+
+Fixed queue loop where is used wrong variable to pop items from queue.
+
+
+___
+
+
+
+
+
+OP-6382 - Thumbnail Integration Problem #6156
+
+This ticket alerted to 3 different cases of integration issues;
+- [x] Using the Tray Publisher with the same image format (extension) for representation and review representation.
+- [x] Clash on publish file path from output definitions in `ExtractOIIOTranscode`.
+- [x] Clash on publish file from thumbnail in `ExtractThumbnail`There might be an issue with this fix, if a studio does not use the `{output}` token in their `render` anatomy template. But thinking if they have customized it, they will be responsible to maintain these edge cases.
+
+
+___
+
+
+
+
+
+Max: Bugfix saving camera scene errored out when creating render instance with multi-camera option turned off #6163
+
+This PR is to make sure the integrator of saving camera scene turned off and the render submitted successfully when multi-camera options being turned off in 3dsmax
+
+
+___
+
+
+
+
+
+Chore: Fix duplicated project name on create project structure #6166
+
+Small fix in project folders. It is not used same variable name to change values which breaks values on any next loop.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Maya: Remove duplicate plugin #6157
+
+The two plugins below are doing the same work, so we can remove the one focused solely on lookdev.https://github.com/ynput/OpenPype/blob/develop/openpype/hosts/maya/plugins/publish/validate_look_members_unique.pyhttps://github.com/ynput/OpenPype/blob/develop/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py
+
+
+___
+
+
+
+
+
+Publish report viewer: Report items sorting #6092
+
+Proposal of items sorting in Publish report viewer tool. Items are sorted by report creation time. Creation time is also added to publish report data when saved from publisher tool.
+
+
+___
+
+
+
+
+
+Maya: Extended error message #6161
+
+Added more details to message
+
+
+___
+
+
+
+
+
+Fusion: Added settings for Fusion creators to legacy OP #6162
+
+Added missing OP variant of setting for new Fusion creator.
+
+
+___
+
+
+
+
+
+
+## [3.18.4](https://github.com/ynput/OpenPype/tree/3.18.4)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.3...3.18.4)
+
+### **🚀 Enhancements**
+
+
+
+multiple render camera supports for 3dsmax #5124
+
+Supports for rendering with multiple cameras in 3dsmax
+- [x] Add Batch Render Layers functions
+- [x] Rewrite lib.rendersetting and lib.renderproduct
+- [x] Add multi-camera options in creator.
+- [x] Collector with batch render-layer when multi-camera enabled.
+- [x] Add instance plugin for saving scene files with different cameras respectively by using subprocess
+- [x] Refactor submit_max_deadline
+- [x] Check with metadata.json in submit publish job
+
+
+___
+
+
+
+
+
+Fusion: new creator for image product type #6057
+
+In many DCC `render` product type is expected to be sequence of files. This PR adds new explicit creator for `image` product type which is focused on single frame image. Workflows for both product types might be a bit different, this gives artists more granularity to choose better workflow.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Account and ignore free image planes. #5993
+
+Free image planes do not have the `->` path separator, so we need to account for that.
+
+
+___
+
+
+
+
+
+Blender: Fix long names for instances #6070
+
+Changed naming for instances to use only final part of the `folderPath`.
+
+
+___
+
+
+
+
+
+Traypublisher & Chore: Instance version on follow workfile version #6117
+
+If `follow_workfile_version` is enabled but context does not have filled workfile version, a version on instance is used instead.
+
+
+___
+
+
+
+
+
+Substance Painter: Thumbnail errors with PBR Texture Set #6127
+
+When publishing with PBR Metallic Roughness as Output Template, Emissive Map errors out because of the missing channel in the material and the map can't be generated in Substance Painter. This PR is to make sure `imagestance.data["publish"] = False` so that the related "empty" texture instance would be skipped to generate the output.
+
+
+___
+
+
+
+
+
+Transcoding: Fix reading image sequences through oiiotool #6129
+
+When transcoding image sequences, the second image onwards includes the invalid xml line of `Reading path/to/file.exr` of the oiiotool output.This is most likely not the best solution, but it fixes the issue and illustrates the problem.Error:
+```
+ERROR:pyblish.plugin:Traceback (most recent call last):
+ File "C:\Users\tokejepsen\AppData\Local\Ynput\AYON\dependency_packages\ayon_2310271602_windows.zip\dependencies\pyblish\plugin.py", line 527, in __explicit_process
+ runner(*args)
+ File "C:\Users\tokejepsen\OpenPype\openpype\plugins\publish\extract_color_transcode.py", line 152, in process
+ File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 1136, in convert_colorspace
+ input_info = get_oiio_info_for_input(input_path, logger=logger)
+ File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 124, in get_oiio_info_for_input
+ output.append(parse_oiio_xml_output(xml_text, logger=logger))
+ File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 276, in parse_oiio_xml_output
+ tree = xml.etree.ElementTree.fromstring(xml_string)
+ File "xml\etree\ElementTree.py", line 1347, in XML
+xml.etree.ElementTree.ParseError: syntax error: line 1, column 0
+Traceback (most recent call last):
+ File "C:\Users\tokejepsen\AppData\Local\Ynput\AYON\dependency_packages\ayon_2310271602_windows.zip\dependencies\pyblish\plugin.py", line 527, in __explicit_process
+ runner(*args)
+ File "", line 152, in process
+ File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 1136, in convert_colorspace
+ input_info = get_oiio_info_for_input(input_path, logger=logger)
+ File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 124, in get_oiio_info_for_input
+ output.append(parse_oiio_xml_output(xml_text, logger=logger))
+ File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 276, in parse_oiio_xml_output
+ tree = xml.etree.ElementTree.fromstring(xml_string)
+ File "xml\etree\ElementTree.py", line 1347, in XML
+xml.etree.ElementTree.ParseError: syntax error: line 1, column 0
+```
+
+
+
+___
+
+
+
+
+
+AYON: Remove 'IntegrateHeroVersion' conversion #6130
+
+Remove settings conversion for `IntegrateHeroVersion`.
+
+
+___
+
+
+
+
+
+Chore tools: Make sure style object is not garbage collected #6136
+
+Minor fix in tool utils to make sure style C++ object is not garbage collected when not stored into variable.
+
+
+___
+
+
+
+
+
+
+## [3.18.3](https://github.com/ynput/OpenPype/tree/3.18.3)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.2...3.18.3)
+
+### **🚀 Enhancements**
+
+
+
+Maya: Apply initial viewport shader for Redshift Proxy after loading #6102
+
+When the published redshift proxy is being loaded, the shader of the proxy is missing. This is different from the manual load through creating redshift proxy for files. This PR is to assign the default lambert to the redshift proxy, which replicates the same approach when the user manually loads the proxy with filepath.
+
+
+___
+
+
+
+
+
+General: We should keep current subset version when we switch only the representation type #4629
+
+When we switch only the representation type of subsets, we should not get the representation from the last version of the subset.
+
+
+___
+
+
+
+
+
+Houdini: Add loader for redshift proxy family #5948
+
+Loader for Redshift Proxy in Houdini (Thanks for @BigRoy contribution)
+
+
+___
+
+
+
+
+
+AfterEffects: exposing Deadline pools fields in Publisher UI #6079
+
+Deadline pools might be adhoc set by an artist during publishing. AfterEffects implementation wasn't providing this.
+
+
+___
+
+
+
+
+
+Chore: Event callbacks can have order #6080
+
+Event callbacks can have order in which are called, and fixed issue with getting function name and file when using `partial` function as callback.
+
+
+___
+
+
+
+
+
+AYON: OpenPype addon defines runtime dependencies #6095
+
+Moved runtime dependencies from ayon-launcher to openpype addon.
+
+
+___
+
+
+
+
+
+Max: User's setting for scene unit scale #6097
+
+Options for users to set the default scene unit scale for their scenes.AYONLegacy OP
+
+
+___
+
+
+
+
+
+Chore: Remove deprecated templates profiles #6103
+
+Remove deprecated usage of template profiles from settings.
+
+
+___
+
+
+
+
+
+Publisher: Window is not always on top #6107
+
+Goal of this PR is to avoid using `WindowStaysOnTopHint` which causes issues, especially in cases when DCC shows a popup dialog that is behind the window, in that case both Publisher and DCC are frozen and there is nothing to do.
+
+
+___
+
+
+
+
+
+Houdini: add split job export support for Redshift ROP #6108
+
+This is adding support for splitting of export and render jobs for Redshift as is already implemented for Vray, Mantra and Arnold.
+
+
+___
+
+
+
+
+
+Fusion: automatic installation of PySide2 #6111
+
+This PR adds hook which tries to check if PySide2 is installed in Python used by Fusion and if not, it tries to install it automatically.
+
+
+___
+
+
+
+
+
+AYON: OpenPype addon dependencies #6113
+
+Added `click` and `six` to requirements of openpype addon, and removed `Qt.py` requirement, which is not used anywhere.
+
+
+___
+
+
+
+
+
+Chore: Thumbnail representation has 'outputName' #6114
+
+Add thumbnail output name to thumbnail representation to prevent same output filename during integration.
+
+
+___
+
+
+
+
+
+Kitsu: Clear credentials is safe #6116
+
+Do not remove not existing keyring items.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: bug fix the playblast without textures #5942
+
+Bug fix the texture not being displayed when users enable texture placement in the OP/AYON setting
+
+
+___
+
+
+
+
+
+Blender: Workfile instance update fix #6048
+
+Make sure workfile instance has always available 'instance_node' in transient data.
+
+
+___
+
+
+
+
+
+Publisher: Fix issue with parenting of widgets #6106
+
+Don't use publisher window parent (usually main DCC window) as parent for report widget.
+
+
+___
+
+
+
+
+
+:wrench: fix and update pydocstyle configuration #6109
+
+Fix pydocstyle configuration and move it to `pyproject.toml`
+
+
+___
+
+
+
+
+
+Nuke: Create camera node with the latest camera node class in Nuke 14 #6118
+
+Creating instance fails for certain cameras, and it seems to only exist in Nuke 14. The reason of causing that contributes to the new camera node class `Camera4` while the camera creator is working with the `Camera2` class.
+
+
+___
+
+
+
+
+
+Site Sync: small fixes in Loader #6119
+
+Resolves issue:
+- local and studio icons were same, they should be different
+- `TypeError: string indices must be integers` error when downloading/uploading workfiles
+
+
+___
+
+
+
+
+
+Chore: Template data for editorial publishing #6120
+
+Template data for editorial publishing are filled during `CollectInstanceAnatomyData`. The structure for editorial is determined, as it's required for ExtractHierarchy AYON/OpenPype plugins.
+
+
+___
+
+
+
+
+
+SceneInventory: Fix site sync icon conversion #6123
+
+Use 'get_qt_icon' to convert icon definitions from site sync.
+
+
+___
+
+
+
+
+
+
+## [3.18.2](https://github.com/ynput/OpenPype/tree/3.18.2)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.1...3.18.2)
+
+### **🚀 Enhancements**
+
+
+
+Testing: Release Maya/Deadline job from pending when testing. #5988
+
+When testing we wont put the Deadline jobs into pending with dependencies, so the worker can start as soon as possible.
+
+
+___
+
+
+
+
+
+Max: Tweaks on Extractions for the exporters #5814
+
+With this PR
+- Suspend Refresh would be introduced in abc & obj extractors for optimization.
+- Allow users to choose the custom attributes to be included in abc exports
+
+
+___
+
+
+
+
+
+Maya: Optional preserve references. #5994
+
+Optional preserve references when publishing Maya scenes.
+
+
+___
+
+
+
+
+
+AYON ftrack: Expect 'ayon' group in custom attributes #6066
+
+Expect `ayon` group as one of options to get custom attributes.
+
+
+___
+
+
+
+
+
+AYON Chore: Remove dependencies related to separated addons #6074
+
+Removed dependencies from openpype client pyproject.toml that are already defined by addons which require them.
+
+
+___
+
+
+
+
+
+Editorial & chore: Stop using pathlib2 #6075
+
+Do not use `pathlib2` which is Python 2 backport for `pathlib` module in python 3.
+
+
+___
+
+
+
+
+
+Traypublisher: Correct validator label #6084
+
+Use correct label for Validate filepaths.
+
+
+___
+
+
+
+
+
+Nuke: Extract Review Intermediate disabled when both Extract Review Mov and Extract Review Intermediate disabled in setting #6089
+
+Report in Discord https://discord.com/channels/517362899170230292/563751989075378201/1187874498234556477
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Bug fix the file from texture node not being collected correctly in Yeti Rig #5990
+
+Fix the bug of collect Yeti Rig not being able to get the file parameter(s) from the texture node(s), resulting to the failure of publishing the textures to the resource directory.
+
+
+___
+
+
+
+
+
+Bug: fix AYON settings for Maya workspace #6069
+
+This is changing bug in default AYON setting for Maya workspace, where missing semicolumn caused workspace not being set. This is also syncing default workspace settings to OpenPype
+
+
+___
+
+
+
+
+
+Refactor colorspace handling in CollectColorspace plugin #6033
+
+Traypublisher is now capable set available colorspaces or roles to publishing images sequence or video. This is fix of new implementation where we allowed to use roles in the enumerator selector.
+
+
+___
+
+
+
+
+
+Bugfix: Houdini render split bugs #6037
+
+This PR is a follow up PR to https://github.com/ynput/OpenPype/pull/5420This PR does:
+- refactor `get_output_parameter` to what is used to be.
+- fix a bug with split render
+- rename `exportJob` flag to `split_render`
+
+
+___
+
+
+
+
+
+Fusion: fix for single frame rendering #6056
+
+Fixes publishes of single frame of `render` product type.
+
+
+___
+
+
+
+
+
+Photoshop: fix layer publish thumbnail missing in loader #6061
+
+Thumbnails from any products (either `review` nor separate layer instances) weren't stored in Ayon.This resulted in not showing them in Loader and Server UI. After this PR thumbnails should be shown in the Loader and on the Server (`http://YOUR_AYON_HOSTNAME:5000/projects/YOUR_PROJECT/browser`).
+
+
+___
+
+
+
+
+
+AYON Chore: Do not use thumbnailSource for thumbnail integration #6063
+
+Do not use `thumbnailSource` for thumbnail integration.
+
+
+___
+
+
+
+
+
+Photoshop: fix creation of .mov #6064
+
+Generation of .mov file with 1 frame per published layer was failing.
+
+
+___
+
+
+
+
+
+Photoshop: fix Collect Color Coded settings #6065
+
+Fix for wrong default value for `Collect Color Coded Instances` Settings
+
+
+___
+
+
+
+
+
+Bug: Fix Publisher parent window in Nuke #6067
+
+Fixing issue where publisher parent window wasn't set because wrong use of version constant.
+
+
+___
+
+
+
+
+
+Python console widget: Save registry fix #6076
+
+Do not save registry until there is something to save.
+
+
+___
+
+
+
+
+
+Ftrack: update asset names for multiple reviewable items #6077
+
+Multiple reviewable assetVersion components with better grouping to asset version name.
+
+
+___
+
+
+
+
+
+Ftrack: DJV action fixes #6098
+
+Fix bugs in DJV ftrack action.
+
+
+___
+
+
+
+
+
+AYON Workfiles tool: Fix arrow to timezone typo #6099
+
+Fix parenthesis typo with arrow local timezone function.
+
+
+___
+
+
+
+### **🔀 Refactored code**
+
+
+
+Chore: Update folder-favorite icon to ayon icon #5718
+
+Updates old "Pype-2.0-era" (from ancient greece times) to AYON logo equivalent.I believe it's only used in Nuke.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Chore: Maya / Nuke remove publish gui filters from settings #5570
+
+- Remove Publish GUI Filters from Nuke settings
+- Remove Publish GUI Filters from Maya settings
+
+
+___
+
+
+
+
+
+Fusion: Project/User option for output format (create_saver) #6045
+
+Adds "Output Image Format" option which can be set via project settings and overwritten by users in "Create" menu. This replaces the current behaviour of being hardcoded to "exr". Replacing the need for people to manually edit the saver path if they require a different extension.
+
+
+___
+
+
+
+
+
+Fusion: Output Image Format Updating Instances (create_saver) #6060
+
+Adds the ability to update Saver image output format if changed in the Publish UI.~~Adds an optional validator that compares "Output Image Format" in the Publish menu against the one currently found on the saver. It then offers a repair action to update the output extension on the saver.~~
+
+
+___
+
+
+
+
+
+Tests: Fix representation count for AE legacy test #6072
+
+
+___
+
+
+
+
+
+
## [3.18.1](https://github.com/ynput/OpenPype/tree/3.18.1)
diff --git a/openpype/client/server/entity_links.py b/openpype/client/server/entity_links.py
index 368dcdcb9d..7fb9fbde6f 100644
--- a/openpype/client/server/entity_links.py
+++ b/openpype/client/server/entity_links.py
@@ -124,23 +124,24 @@ def get_linked_representation_id(
if not versions_to_check:
break
- links = con.get_versions_links(
+ versions_links = con.get_versions_links(
project_name,
versions_to_check,
link_types=link_types,
link_direction="out")
versions_to_check = set()
- for link in links:
- # Care only about version links
- if link["entityType"] != "version":
- continue
- entity_id = link["entityId"]
- # Skip already found linked version ids
- if entity_id in linked_version_ids:
- continue
- linked_version_ids.add(entity_id)
- versions_to_check.add(entity_id)
+ for links in versions_links.values():
+ for link in links:
+ # Care only about version links
+ if link["entityType"] != "version":
+ continue
+ entity_id = link["entityId"]
+ # Skip already found linked version ids
+ if entity_id in linked_version_ids:
+ continue
+ linked_version_ids.add(entity_id)
+ versions_to_check.add(entity_id)
linked_version_ids.remove(version_id)
if not linked_version_ids:
diff --git a/openpype/hosts/blender/api/capture.py b/openpype/hosts/blender/api/capture.py
index bad6831143..e5e6041563 100644
--- a/openpype/hosts/blender/api/capture.py
+++ b/openpype/hosts/blender/api/capture.py
@@ -127,8 +127,9 @@ def isolate_objects(window, objects):
context = create_blender_context(selected=objects, window=window)
- bpy.ops.view3d.view_axis(context, type="FRONT")
- bpy.ops.view3d.localview(context)
+ with bpy.context.temp_override(**context):
+ bpy.ops.view3d.view_axis(type="FRONT")
+ bpy.ops.view3d.localview()
deselect_all()
@@ -270,10 +271,12 @@ def _independent_window():
"""Create capture-window context."""
context = create_blender_context()
current_windows = set(bpy.context.window_manager.windows)
- bpy.ops.wm.window_new(context)
- window = list(set(bpy.context.window_manager.windows) - current_windows)[0]
- context["window"] = window
- try:
- yield window
- finally:
- bpy.ops.wm.window_close(context)
+ with bpy.context.temp_override(**context):
+ bpy.ops.wm.window_new()
+ window = list(
+ set(bpy.context.window_manager.windows) - current_windows)[0]
+ context["window"] = window
+ try:
+ yield window
+ finally:
+ bpy.ops.wm.window_close()
diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py
index 568d8f6695..b1ff3e4a09 100644
--- a/openpype/hosts/blender/api/plugin.py
+++ b/openpype/hosts/blender/api/plugin.py
@@ -36,6 +36,12 @@ def prepare_scene_name(
if namespace:
name = f"{name}_{namespace}"
name = f"{name}_{subset}"
+
+ # Blender name for a collection or object cannot be longer than 63
+ # characters. If the name is longer, it will raise an error.
+ if len(name) > 63:
+ raise ValueError(f"Scene name '{name}' would be too long.")
+
return name
@@ -226,7 +232,7 @@ class BaseCreator(Creator):
# Create asset group
if AYON_SERVER_ENABLED:
- asset_name = instance_data["folderPath"]
+ asset_name = instance_data["folderPath"].split("/")[-1]
else:
asset_name = instance_data["asset"]
@@ -305,12 +311,16 @@ class BaseCreator(Creator):
)
return
- # Rename the instance node in the scene if subset or asset changed
+ # Rename the instance node in the scene if subset or asset changed.
+ # Do not rename the instance if the family is workfile, as the
+ # workfile instance is included in the AVALON_CONTAINER collection.
if (
"subset" in changes.changed_keys
or asset_name_key in changes.changed_keys
- ):
+ ) and created_instance.family != "workfile":
asset_name = data[asset_name_key]
+ if AYON_SERVER_ENABLED:
+ asset_name = asset_name.split("/")[-1]
name = prepare_scene_name(
asset=asset_name, subset=data["subset"]
)
diff --git a/openpype/hosts/blender/plugins/create/create_workfile.py b/openpype/hosts/blender/plugins/create/create_workfile.py
index ceec3e0552..6b168f4c84 100644
--- a/openpype/hosts/blender/plugins/create/create_workfile.py
+++ b/openpype/hosts/blender/plugins/create/create_workfile.py
@@ -25,7 +25,7 @@ class CreateWorkfile(BaseCreator, AutoCreator):
def create(self):
"""Create workfile instances."""
- existing_instance = next(
+ workfile_instance = next(
(
instance for instance in self.create_context.instances
if instance.creator_identifier == self.identifier
@@ -39,14 +39,14 @@ class CreateWorkfile(BaseCreator, AutoCreator):
host_name = self.create_context.host_name
existing_asset_name = None
- if existing_instance is not None:
+ if workfile_instance is not None:
if AYON_SERVER_ENABLED:
- existing_asset_name = existing_instance.get("folderPath")
+ existing_asset_name = workfile_instance.get("folderPath")
if existing_asset_name is None:
- existing_asset_name = existing_instance["asset"]
+ existing_asset_name = workfile_instance["asset"]
- if not existing_instance:
+ if not workfile_instance:
asset_doc = get_asset_by_name(project_name, asset_name)
subset_name = self.get_subset_name(
task_name, task_name, asset_doc, project_name, host_name
@@ -66,19 +66,18 @@ class CreateWorkfile(BaseCreator, AutoCreator):
asset_doc,
project_name,
host_name,
- existing_instance,
+ workfile_instance,
)
)
self.log.info("Auto-creating workfile instance...")
- current_instance = CreatedInstance(
+ workfile_instance = CreatedInstance(
self.family, subset_name, data, self
)
- instance_node = bpy.data.collections.get(AVALON_CONTAINERS, {})
- current_instance.transient_data["instance_node"] = instance_node
- self._add_instance_to_context(current_instance)
+ self._add_instance_to_context(workfile_instance)
+
elif (
existing_asset_name != asset_name
- or existing_instance["task"] != task_name
+ or workfile_instance["task"] != task_name
):
# Update instance context if it's different
asset_doc = get_asset_by_name(project_name, asset_name)
@@ -86,12 +85,17 @@ class CreateWorkfile(BaseCreator, AutoCreator):
task_name, task_name, asset_doc, project_name, host_name
)
if AYON_SERVER_ENABLED:
- existing_instance["folderPath"] = asset_name
+ workfile_instance["folderPath"] = asset_name
else:
- existing_instance["asset"] = asset_name
+ workfile_instance["asset"] = asset_name
- existing_instance["task"] = task_name
- existing_instance["subset"] = subset_name
+ workfile_instance["task"] = task_name
+ workfile_instance["subset"] = subset_name
+
+ instance_node = bpy.data.collections.get(AVALON_CONTAINERS)
+ if not instance_node:
+ instance_node = bpy.data.collections.new(name=AVALON_CONTAINERS)
+ workfile_instance.transient_data["instance_node"] = instance_node
def collect_instances(self):
diff --git a/openpype/hosts/blender/plugins/load/load_animation.py b/openpype/hosts/blender/plugins/load/load_animation.py
index 3e7f808903..fd087553f0 100644
--- a/openpype/hosts/blender/plugins/load/load_animation.py
+++ b/openpype/hosts/blender/plugins/load/load_animation.py
@@ -61,5 +61,10 @@ class BlendAnimationLoader(plugin.AssetLoader):
bpy.data.objects.remove(container)
- library = bpy.data.libraries.get(bpy.path.basename(libpath))
+ filename = bpy.path.basename(libpath)
+ # Blender has a limit of 63 characters for any data name.
+ # If the filename is longer, it will be truncated.
+ if len(filename) > 63:
+ filename = filename[:63]
+ library = bpy.data.libraries.get(filename)
bpy.data.libraries.remove(library)
diff --git a/openpype/hosts/blender/plugins/load/load_audio.py b/openpype/hosts/blender/plugins/load/load_audio.py
index 1e5bd39a32..367fff03f0 100644
--- a/openpype/hosts/blender/plugins/load/load_audio.py
+++ b/openpype/hosts/blender/plugins/load/load_audio.py
@@ -67,7 +67,8 @@ class AudioLoader(plugin.AssetLoader):
oc = bpy.context.copy()
oc["area"] = window_manager.windows[-1].screen.areas[0]
- bpy.ops.sequencer.sound_strip_add(oc, filepath=libpath, frame_start=1)
+ with bpy.context.temp_override(**oc):
+ bpy.ops.sequencer.sound_strip_add(filepath=libpath, frame_start=1)
window_manager.windows[-1].screen.areas[0].type = old_type
@@ -156,17 +157,18 @@ class AudioLoader(plugin.AssetLoader):
oc = bpy.context.copy()
oc["area"] = window_manager.windows[-1].screen.areas[0]
- # We deselect all sequencer strips, and then select the one we
- # need to remove.
- bpy.ops.sequencer.select_all(oc, action='DESELECT')
- scene = bpy.context.scene
- scene.sequence_editor.sequences_all[old_audio].select = True
+ with bpy.context.temp_override(**oc):
+ # We deselect all sequencer strips, and then select the one we
+ # need to remove.
+ bpy.ops.sequencer.select_all(action='DESELECT')
+ scene = bpy.context.scene
+ scene.sequence_editor.sequences_all[old_audio].select = True
- bpy.ops.sequencer.delete(oc)
- bpy.data.sounds.remove(bpy.data.sounds[old_audio])
+ bpy.ops.sequencer.delete()
+ bpy.data.sounds.remove(bpy.data.sounds[old_audio])
- bpy.ops.sequencer.sound_strip_add(
- oc, filepath=str(libpath), frame_start=1)
+ bpy.ops.sequencer.sound_strip_add(
+ filepath=str(libpath), frame_start=1)
window_manager.windows[-1].screen.areas[0].type = old_type
@@ -205,12 +207,13 @@ class AudioLoader(plugin.AssetLoader):
oc = bpy.context.copy()
oc["area"] = window_manager.windows[-1].screen.areas[0]
- # We deselect all sequencer strips, and then select the one we
- # need to remove.
- bpy.ops.sequencer.select_all(oc, action='DESELECT')
- bpy.context.scene.sequence_editor.sequences_all[audio].select = True
-
- bpy.ops.sequencer.delete(oc)
+ with bpy.context.temp_override(**oc):
+ # We deselect all sequencer strips, and then select the one we
+ # need to remove.
+ bpy.ops.sequencer.select_all(action='DESELECT')
+ scene = bpy.context.scene
+ scene.sequence_editor.sequences_all[audio].select = True
+ bpy.ops.sequencer.delete()
window_manager.windows[-1].screen.areas[0].type = old_type
diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py
index f437e66795..1a84f5afbb 100644
--- a/openpype/hosts/blender/plugins/load/load_blend.py
+++ b/openpype/hosts/blender/plugins/load/load_blend.py
@@ -102,11 +102,15 @@ class BlendLoader(plugin.AssetLoader):
# Link all the container children to the collection
for obj in container.children_recursive:
- print(obj)
bpy.context.scene.collection.objects.link(obj)
# Remove the library from the blend file
- library = bpy.data.libraries.get(bpy.path.basename(libpath))
+ filepath = bpy.path.basename(libpath)
+ # Blender has a limit of 63 characters for any data name.
+ # If the filepath is longer, it will be truncated.
+ if len(filepath) > 63:
+ filepath = filepath[:63]
+ library = bpy.data.libraries.get(filepath)
bpy.data.libraries.remove(library)
return container, members
@@ -189,8 +193,20 @@ class BlendLoader(plugin.AssetLoader):
transform = asset_group.matrix_basis.copy()
old_data = dict(asset_group.get(AVALON_PROPERTY))
+ old_members = old_data.get("members", [])
parent = asset_group.parent
+ actions = {}
+ objects_with_anim = [
+ obj for obj in asset_group.children_recursive
+ if obj.animation_data]
+ for obj in objects_with_anim:
+ # Check if the object has an action and, if so, add it to a dict
+ # so we can restore it later. Save and restore the action only
+ # if it wasn't originally loaded from the current asset.
+ if obj.animation_data.action not in old_members:
+ actions[obj.name] = obj.animation_data.action
+
self.exec_remove(container)
asset_group, members = self._process_data(libpath, group_name)
@@ -201,6 +217,13 @@ class BlendLoader(plugin.AssetLoader):
asset_group.matrix_basis = transform
asset_group.parent = parent
+ # Restore the actions
+ for obj in asset_group.children_recursive:
+ if obj.name in actions:
+ if not obj.animation_data:
+ obj.animation_data_create()
+ obj.animation_data.action = actions[obj.name]
+
# Restore the old data, but reset memebers, as they don't exist anymore
# This avoids a crash, because the memory addresses of those members
# are not valid anymore
diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py
index 6cc7f39d03..fba0245af1 100644
--- a/openpype/hosts/blender/plugins/load/load_blendscene.py
+++ b/openpype/hosts/blender/plugins/load/load_blendscene.py
@@ -60,7 +60,12 @@ class BlendSceneLoader(plugin.AssetLoader):
bpy.context.scene.collection.children.link(container)
# Remove the library from the blend file
- library = bpy.data.libraries.get(bpy.path.basename(libpath))
+ filepath = bpy.path.basename(libpath)
+ # Blender has a limit of 63 characters for any data name.
+ # If the filepath is longer, it will be truncated.
+ if len(filepath) > 63:
+ filepath = filepath[:63]
+ library = bpy.data.libraries.get(filepath)
bpy.data.libraries.remove(library)
return container, members
diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
index 6ef9b29693..2bf75aa05e 100644
--- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
+++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py
@@ -55,13 +55,13 @@ class ExtractAnimationABC(
context = plugin.create_blender_context(
active=asset_group, selected=selected)
- # We export the abc
- bpy.ops.wm.alembic_export(
- context,
- filepath=filepath,
- selected=True,
- flatten=False
- )
+ with bpy.context.temp_override(**context):
+ # We export the abc
+ bpy.ops.wm.alembic_export(
+ filepath=filepath,
+ selected=True,
+ flatten=False
+ )
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 ee046b7d11..f904b79ddb 100644
--- a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py
+++ b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py
@@ -50,19 +50,19 @@ class ExtractCamera(publish.Extractor, publish.OptionalPyblishPluginMixin):
scale_length = bpy.context.scene.unit_settings.scale_length
bpy.context.scene.unit_settings.scale_length = 0.01
- # We export the fbx
- bpy.ops.export_scene.fbx(
- context,
- filepath=filepath,
- use_active_collection=False,
- use_selection=True,
- bake_anim_use_nla_strips=False,
- bake_anim_use_all_actions=False,
- add_leaf_bones=False,
- armature_nodetype='ROOT',
- object_types={'CAMERA'},
- bake_anim_simplify_factor=0.0
- )
+ with bpy.context.temp_override(**context):
+ # We export the fbx
+ bpy.ops.export_scene.fbx(
+ filepath=filepath,
+ use_active_collection=False,
+ use_selection=True,
+ bake_anim_use_nla_strips=False,
+ bake_anim_use_all_actions=False,
+ add_leaf_bones=False,
+ armature_nodetype='ROOT',
+ object_types={'CAMERA'},
+ bake_anim_simplify_factor=0.0
+ )
bpy.context.scene.unit_settings.scale_length = scale_length
diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py
index 4ae6501f7d..aed6df1d3d 100644
--- a/openpype/hosts/blender/plugins/publish/extract_fbx.py
+++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py
@@ -57,15 +57,15 @@ class ExtractFBX(publish.Extractor, publish.OptionalPyblishPluginMixin):
scale_length = bpy.context.scene.unit_settings.scale_length
bpy.context.scene.unit_settings.scale_length = 0.01
- # We export the fbx
- bpy.ops.export_scene.fbx(
- context,
- filepath=filepath,
- use_active_collection=False,
- use_selection=True,
- mesh_smooth_type='FACE',
- add_leaf_bones=False
- )
+ with bpy.context.temp_override(**context):
+ # We export the fbx
+ bpy.ops.export_scene.fbx(
+ filepath=filepath,
+ use_active_collection=False,
+ use_selection=True,
+ mesh_smooth_type='FACE',
+ add_leaf_bones=False
+ )
bpy.context.scene.unit_settings.scale_length = scale_length
diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py
index 4fc8230a1b..1cb8dac0cf 100644
--- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py
+++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py
@@ -153,17 +153,20 @@ class ExtractAnimationFBX(
override = plugin.create_blender_context(
active=root, selected=[root, armature])
- bpy.ops.export_scene.fbx(
- override,
- filepath=filepath,
- use_active_collection=False,
- use_selection=True,
- bake_anim_use_nla_strips=False,
- bake_anim_use_all_actions=False,
- add_leaf_bones=False,
- armature_nodetype='ROOT',
- object_types={'EMPTY', 'ARMATURE'}
- )
+
+ with bpy.context.temp_override(**override):
+ # We export the fbx
+ bpy.ops.export_scene.fbx(
+ filepath=filepath,
+ use_active_collection=False,
+ use_selection=True,
+ bake_anim_use_nla_strips=False,
+ bake_anim_use_all_actions=False,
+ add_leaf_bones=False,
+ armature_nodetype='ROOT',
+ object_types={'EMPTY', 'ARMATURE'}
+ )
+
armature.name = armature_name
asset_group.name = asset_group_name
root.select_set(True)
diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py
index 3e8978c8d3..383c3bdcc5 100644
--- a/openpype/hosts/blender/plugins/publish/extract_layout.py
+++ b/openpype/hosts/blender/plugins/publish/extract_layout.py
@@ -80,17 +80,18 @@ class ExtractLayout(publish.Extractor, publish.OptionalPyblishPluginMixin):
override = plugin.create_blender_context(
active=asset, selected=[asset, obj])
- bpy.ops.export_scene.fbx(
- override,
- filepath=filepath,
- use_active_collection=False,
- use_selection=True,
- bake_anim_use_nla_strips=False,
- bake_anim_use_all_actions=False,
- add_leaf_bones=False,
- armature_nodetype='ROOT',
- object_types={'EMPTY', 'ARMATURE'}
- )
+ with bpy.context.temp_override(**override):
+ # We export the fbx
+ bpy.ops.export_scene.fbx(
+ filepath=filepath,
+ use_active_collection=False,
+ use_selection=True,
+ bake_anim_use_nla_strips=False,
+ bake_anim_use_all_actions=False,
+ add_leaf_bones=False,
+ armature_nodetype='ROOT',
+ object_types={'EMPTY', 'ARMATURE'}
+ )
obj.name = armature_name
asset.name = asset_group_name
asset.select_set(False)
diff --git a/openpype/hosts/fusion/api/plugin.py b/openpype/hosts/fusion/api/plugin.py
new file mode 100644
index 0000000000..63a74fbdb5
--- /dev/null
+++ b/openpype/hosts/fusion/api/plugin.py
@@ -0,0 +1,221 @@
+from copy import deepcopy
+import os
+
+from openpype.hosts.fusion.api import (
+ get_current_comp,
+ comp_lock_and_undo_chunk,
+)
+
+from openpype.lib import (
+ BoolDef,
+ EnumDef,
+)
+from openpype.pipeline import (
+ legacy_io,
+ Creator,
+ CreatedInstance
+)
+
+
+class GenericCreateSaver(Creator):
+ default_variants = ["Main", "Mask"]
+ description = "Fusion Saver to generate image sequence"
+ icon = "fa5.eye"
+
+ instance_attributes = [
+ "reviewable"
+ ]
+
+ settings_category = "fusion"
+
+ image_format = "exr"
+
+ # TODO: This should be renamed together with Nuke so it is aligned
+ temp_rendering_path_template = (
+ "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}")
+
+ def create(self, subset_name, instance_data, pre_create_data):
+ self.pass_pre_attributes_to_instance(instance_data, pre_create_data)
+
+ instance = CreatedInstance(
+ family=self.family,
+ subset_name=subset_name,
+ data=instance_data,
+ creator=self,
+ )
+ data = instance.data_to_store()
+ comp = get_current_comp()
+ with comp_lock_and_undo_chunk(comp):
+ args = (-32768, -32768) # Magical position numbers
+ saver = comp.AddTool("Saver", *args)
+
+ self._update_tool_with_data(saver, data=data)
+
+ # Register the CreatedInstance
+ self._imprint(saver, data)
+
+ # Insert the transient data
+ instance.transient_data["tool"] = saver
+
+ self._add_instance_to_context(instance)
+
+ return instance
+
+ def collect_instances(self):
+ comp = get_current_comp()
+ tools = comp.GetToolList(False, "Saver").values()
+ for tool in tools:
+ data = self.get_managed_tool_data(tool)
+ if not data:
+ continue
+
+ # Add instance
+ created_instance = CreatedInstance.from_existing(data, self)
+
+ # Collect transient data
+ created_instance.transient_data["tool"] = tool
+
+ self._add_instance_to_context(created_instance)
+
+ def update_instances(self, update_list):
+ for created_inst, _changes in update_list:
+ new_data = created_inst.data_to_store()
+ tool = created_inst.transient_data["tool"]
+ self._update_tool_with_data(tool, new_data)
+ self._imprint(tool, new_data)
+
+ def remove_instances(self, instances):
+ for instance in instances:
+ # Remove the tool from the scene
+
+ tool = instance.transient_data["tool"]
+ if tool:
+ tool.Delete()
+
+ # Remove the collected CreatedInstance to remove from UI directly
+ self._remove_instance_from_context(instance)
+
+ def _imprint(self, tool, data):
+ # Save all data in a "openpype.{key}" = value data
+
+ # Instance id is the tool's name so we don't need to imprint as data
+ data.pop("instance_id", None)
+
+ active = data.pop("active", None)
+ if active is not None:
+ # Use active value to set the passthrough state
+ tool.SetAttrs({"TOOLB_PassThrough": not active})
+
+ for key, value in data.items():
+ tool.SetData(f"openpype.{key}", value)
+
+ def _update_tool_with_data(self, tool, data):
+ """Update tool node name and output path based on subset data"""
+ if "subset" not in data:
+ return
+
+ original_subset = tool.GetData("openpype.subset")
+ original_format = tool.GetData(
+ "openpype.creator_attributes.image_format"
+ )
+
+ subset = data["subset"]
+ if (
+ original_subset != subset
+ or original_format != data["creator_attributes"]["image_format"]
+ ):
+ self._configure_saver_tool(data, tool, subset)
+
+ def _configure_saver_tool(self, data, tool, subset):
+ formatting_data = deepcopy(data)
+
+ # get frame padding from anatomy templates
+ frame_padding = self.project_anatomy.templates["frame_padding"]
+
+ # get output format
+ ext = data["creator_attributes"]["image_format"]
+
+ # Subset change detected
+ workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"])
+ formatting_data.update({
+ "workdir": workdir,
+ "frame": "0" * frame_padding,
+ "ext": ext,
+ "product": {
+ "name": formatting_data["subset"],
+ "type": formatting_data["family"],
+ },
+ })
+
+ # build file path to render
+ filepath = self.temp_rendering_path_template.format(**formatting_data)
+
+ comp = get_current_comp()
+ tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath))
+
+ # Rename tool
+ if tool.Name != subset:
+ print(f"Renaming {tool.Name} -> {subset}")
+ tool.SetAttrs({"TOOLS_Name": subset})
+
+ def get_managed_tool_data(self, tool):
+ """Return data of the tool if it matches creator identifier"""
+ data = tool.GetData("openpype")
+ if not isinstance(data, dict):
+ return
+
+ required = {
+ "id": "pyblish.avalon.instance",
+ "creator_identifier": self.identifier,
+ }
+ for key, value in required.items():
+ if key not in data or data[key] != value:
+ return
+
+ # Get active state from the actual tool state
+ attrs = tool.GetAttrs()
+ passthrough = attrs["TOOLB_PassThrough"]
+ data["active"] = not passthrough
+
+ # Override publisher's UUID generation because tool names are
+ # already unique in Fusion in a comp
+ data["instance_id"] = tool.Name
+
+ return data
+
+ def get_instance_attr_defs(self):
+ """Settings for publish page"""
+ return self.get_pre_create_attr_defs()
+
+ def pass_pre_attributes_to_instance(self, instance_data, pre_create_data):
+ creator_attrs = instance_data["creator_attributes"] = {}
+ for pass_key in pre_create_data.keys():
+ creator_attrs[pass_key] = pre_create_data[pass_key]
+
+ def _get_render_target_enum(self):
+ rendering_targets = {
+ "local": "Local machine rendering",
+ "frames": "Use existing frames",
+ }
+ if "farm_rendering" in self.instance_attributes:
+ rendering_targets["farm"] = "Farm rendering"
+
+ return EnumDef(
+ "render_target", items=rendering_targets, label="Render target"
+ )
+
+ def _get_reviewable_bool(self):
+ return BoolDef(
+ "review",
+ default=("reviewable" in self.instance_attributes),
+ label="Review",
+ )
+
+ def _get_image_format_enum(self):
+ image_format_options = ["exr", "tga", "tif", "png", "jpg"]
+ return EnumDef(
+ "image_format",
+ items=image_format_options,
+ default=self.image_format,
+ label="Output Image Format",
+ )
diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py
index 576628e876..3da8968727 100644
--- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py
+++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py
@@ -64,5 +64,8 @@ class FusionPrelaunch(PreLaunchHook):
self.launch_context.env[py3_var] = py3_dir
+ # for hook installing PySide2
+ self.data["fusion_python3_home"] = py3_dir
+
self.log.info(f"Setting OPENPYPE_FUSION: {FUSION_HOST_DIR}")
self.launch_context.env["OPENPYPE_FUSION"] = FUSION_HOST_DIR
diff --git a/openpype/hosts/fusion/hooks/pre_pyside_install.py b/openpype/hosts/fusion/hooks/pre_pyside_install.py
new file mode 100644
index 0000000000..f98aeda233
--- /dev/null
+++ b/openpype/hosts/fusion/hooks/pre_pyside_install.py
@@ -0,0 +1,186 @@
+import os
+import subprocess
+import platform
+import uuid
+
+from openpype.lib.applications import PreLaunchHook, LaunchTypes
+
+
+class InstallPySideToFusion(PreLaunchHook):
+ """Automatically installs Qt binding to fusion's python packages.
+
+ Check if fusion has installed PySide2 and will try to install if not.
+
+ For pipeline implementation is required to have Qt binding installed in
+ fusion's python packages.
+ """
+
+ app_groups = {"fusion"}
+ order = 2
+ launch_types = {LaunchTypes.local}
+
+ def execute(self):
+ # Prelaunch hook is not crucial
+ try:
+ settings = self.data["project_settings"][self.host_name]
+ if not settings["hooks"]["InstallPySideToFusion"]["enabled"]:
+ return
+ self.inner_execute()
+ except Exception:
+ self.log.warning(
+ "Processing of {} crashed.".format(self.__class__.__name__),
+ exc_info=True
+ )
+
+ def inner_execute(self):
+ self.log.debug("Check for PySide2 installation.")
+
+ fusion_python3_home = self.data.get("fusion_python3_home")
+ if not fusion_python3_home:
+ self.log.warning("'fusion_python3_home' was not provided. "
+ "Installation of PySide2 not possible")
+ return
+
+ if platform.system().lower() == "windows":
+ exe_filenames = ["python.exe"]
+ else:
+ exe_filenames = ["python3", "python"]
+
+ for exe_filename in exe_filenames:
+ python_executable = os.path.join(fusion_python3_home, exe_filename)
+ if os.path.exists(python_executable):
+ break
+
+ if not os.path.exists(python_executable):
+ self.log.warning(
+ "Couldn't find python executable for fusion. {}".format(
+ python_executable
+ )
+ )
+ return
+
+ # Check if PySide2 is installed and skip if yes
+ if self._is_pyside_installed(python_executable):
+ self.log.debug("Fusion has already installed PySide2.")
+ return
+
+ self.log.debug("Installing PySide2.")
+ # Install PySide2 in fusion's python
+ if self._windows_require_permissions(
+ os.path.dirname(python_executable)):
+ result = self._install_pyside_windows(python_executable)
+ else:
+ result = self._install_pyside(python_executable)
+
+ if result:
+ self.log.info("Successfully installed PySide2 module to fusion.")
+ else:
+ self.log.warning("Failed to install PySide2 module to fusion.")
+
+ def _install_pyside_windows(self, python_executable):
+ """Install PySide2 python module to fusion's python.
+
+ Installation requires administration rights that's why it is required
+ to use "pywin32" module which can execute command's and ask for
+ administration rights.
+ """
+ try:
+ import win32api
+ import win32con
+ import win32process
+ import win32event
+ import pywintypes
+ from win32comext.shell.shell import ShellExecuteEx
+ from win32comext.shell import shellcon
+ except Exception:
+ self.log.warning("Couldn't import \"pywin32\" modules")
+ return False
+
+ try:
+ # Parameters
+ # - use "-m pip" as module pip to install PySide2 and argument
+ # "--ignore-installed" is to force install module to fusion's
+ # site-packages and make sure it is binary compatible
+ parameters = "-m pip install --ignore-installed PySide2"
+
+ # Execute command and ask for administrator's rights
+ process_info = ShellExecuteEx(
+ nShow=win32con.SW_SHOWNORMAL,
+ fMask=shellcon.SEE_MASK_NOCLOSEPROCESS,
+ lpVerb="runas",
+ lpFile=python_executable,
+ lpParameters=parameters,
+ lpDirectory=os.path.dirname(python_executable)
+ )
+ process_handle = process_info["hProcess"]
+ win32event.WaitForSingleObject(process_handle,
+ win32event.INFINITE)
+ returncode = win32process.GetExitCodeProcess(process_handle)
+ return returncode == 0
+ except pywintypes.error:
+ return False
+
+ def _install_pyside(self, python_executable):
+ """Install PySide2 python module to fusion's python."""
+ try:
+ # Parameters
+ # - use "-m pip" as module pip to install PySide2 and argument
+ # "--ignore-installed" is to force install module to fusion's
+ # site-packages and make sure it is binary compatible
+ env = dict(os.environ)
+ del env['PYTHONPATH']
+ args = [
+ python_executable,
+ "-m",
+ "pip",
+ "install",
+ "--ignore-installed",
+ "PySide2",
+ ]
+ process = subprocess.Popen(
+ args, stdout=subprocess.PIPE, universal_newlines=True,
+ env=env
+ )
+ process.communicate()
+ return process.returncode == 0
+ except PermissionError:
+ self.log.warning(
+ "Permission denied with command:"
+ "\"{}\".".format(" ".join(args))
+ )
+ except OSError as error:
+ self.log.warning(f"OS error has occurred: \"{error}\".")
+ except subprocess.SubprocessError:
+ pass
+
+ def _is_pyside_installed(self, python_executable):
+ """Check if PySide2 module is in fusion's pip list."""
+ args = [python_executable, "-c", "from qtpy import QtWidgets"]
+ process = subprocess.Popen(args,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ _, stderr = process.communicate()
+ stderr = stderr.decode()
+ if stderr:
+ return False
+ return True
+
+ def _windows_require_permissions(self, dirpath):
+ if platform.system().lower() != "windows":
+ return False
+
+ try:
+ # Attempt to create a temporary file in the folder
+ temp_file_path = os.path.join(dirpath, uuid.uuid4().hex)
+ with open(temp_file_path, "w"):
+ pass
+ os.remove(temp_file_path) # Clean up temporary file
+ return False
+
+ except PermissionError:
+ return True
+
+ except BaseException as exc:
+ print(("Failed to determine if root requires permissions."
+ "Unexpected error: {}").format(exc))
+ return False
diff --git a/openpype/hosts/fusion/plugins/create/create_image_saver.py b/openpype/hosts/fusion/plugins/create/create_image_saver.py
new file mode 100644
index 0000000000..490228d488
--- /dev/null
+++ b/openpype/hosts/fusion/plugins/create/create_image_saver.py
@@ -0,0 +1,64 @@
+from openpype.lib import NumberDef
+
+from openpype.hosts.fusion.api.plugin import GenericCreateSaver
+from openpype.hosts.fusion.api import get_current_comp
+
+
+class CreateImageSaver(GenericCreateSaver):
+ """Fusion Saver to generate single image.
+
+ Created to explicitly separate single ('image') or
+ multi frame('render) outputs.
+
+ This might be temporary creator until 'alias' functionality will be
+ implemented to limit creation of additional product types with similar, but
+ not the same workflows.
+ """
+ identifier = "io.openpype.creators.fusion.imagesaver"
+ label = "Image (saver)"
+ name = "image"
+ family = "image"
+ description = "Fusion Saver to generate image"
+
+ default_frame = 0
+
+ def get_detail_description(self):
+ return """Fusion Saver to generate single image.
+
+ This creator is expected for publishing of single frame `image` product
+ type.
+
+ Artist should provide frame number (integer) to specify which frame
+ should be published. It must be inside of global timeline frame range.
+
+ Supports local and deadline rendering.
+
+ Supports selection from predefined set of output file extensions:
+ - exr
+ - tga
+ - png
+ - tif
+ - jpg
+
+ Created to explicitly separate single frame ('image') or
+ multi frame ('render') outputs.
+ """
+
+ def get_pre_create_attr_defs(self):
+ """Settings for create page"""
+ attr_defs = [
+ self._get_render_target_enum(),
+ self._get_reviewable_bool(),
+ self._get_frame_int(),
+ self._get_image_format_enum(),
+ ]
+ return attr_defs
+
+ def _get_frame_int(self):
+ return NumberDef(
+ "frame",
+ default=self.default_frame,
+ label="Frame",
+ tooltip="Set frame to be rendered, must be inside of global "
+ "timeline range"
+ )
diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py
index 5870828b41..3a8ffe890b 100644
--- a/openpype/hosts/fusion/plugins/create/create_saver.py
+++ b/openpype/hosts/fusion/plugins/create/create_saver.py
@@ -1,187 +1,42 @@
-from copy import deepcopy
-import os
+from openpype.lib import EnumDef
-from openpype.hosts.fusion.api import (
- get_current_comp,
- comp_lock_and_undo_chunk,
-)
-
-from openpype.lib import (
- BoolDef,
- EnumDef,
-)
-from openpype.pipeline import (
- legacy_io,
- Creator as NewCreator,
- CreatedInstance,
- Anatomy,
-)
+from openpype.hosts.fusion.api.plugin import GenericCreateSaver
-class CreateSaver(NewCreator):
+class CreateSaver(GenericCreateSaver):
+ """Fusion Saver to generate image sequence of 'render' product type.
+
+ Original Saver creator targeted for 'render' product type. It uses
+ original not to descriptive name because of values in Settings.
+ """
identifier = "io.openpype.creators.fusion.saver"
label = "Render (saver)"
name = "render"
family = "render"
- default_variants = ["Main", "Mask"]
description = "Fusion Saver to generate image sequence"
- icon = "fa5.eye"
- instance_attributes = ["reviewable"]
- image_format = "exr"
+ default_frame_range_option = "asset_db"
- # TODO: This should be renamed together with Nuke so it is aligned
- temp_rendering_path_template = (
- "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}"
- )
+ def get_detail_description(self):
+ return """Fusion Saver to generate image sequence.
- def create(self, subset_name, instance_data, pre_create_data):
- self.pass_pre_attributes_to_instance(instance_data, pre_create_data)
+ This creator is expected for publishing of image sequences for 'render'
+ product type. (But can publish even single frame 'render'.)
- instance_data.update(
- {"id": "pyblish.avalon.instance", "subset": subset_name}
- )
+ Select what should be source of render range:
+ - "Current asset context" - values set on Asset in DB (Ftrack)
+ - "From render in/out" - from node itself
+ - "From composition timeline" - from timeline
- comp = get_current_comp()
- with comp_lock_and_undo_chunk(comp):
- args = (-32768, -32768) # Magical position numbers
- saver = comp.AddTool("Saver", *args)
+ Supports local and farm rendering.
- self._update_tool_with_data(saver, data=instance_data)
-
- # Register the CreatedInstance
- instance = CreatedInstance(
- family=self.family,
- subset_name=subset_name,
- data=instance_data,
- creator=self,
- )
- data = instance.data_to_store()
- self._imprint(saver, data)
-
- # Insert the transient data
- instance.transient_data["tool"] = saver
-
- self._add_instance_to_context(instance)
-
- return instance
-
- def collect_instances(self):
- comp = get_current_comp()
- tools = comp.GetToolList(False, "Saver").values()
- for tool in tools:
- data = self.get_managed_tool_data(tool)
- if not data:
- continue
-
- # Add instance
- created_instance = CreatedInstance.from_existing(data, self)
-
- # Collect transient data
- created_instance.transient_data["tool"] = tool
-
- self._add_instance_to_context(created_instance)
-
- def update_instances(self, update_list):
- for created_inst, _changes in update_list:
- new_data = created_inst.data_to_store()
- tool = created_inst.transient_data["tool"]
- self._update_tool_with_data(tool, new_data)
- self._imprint(tool, new_data)
-
- def remove_instances(self, instances):
- for instance in instances:
- # Remove the tool from the scene
-
- tool = instance.transient_data["tool"]
- if tool:
- tool.Delete()
-
- # Remove the collected CreatedInstance to remove from UI directly
- self._remove_instance_from_context(instance)
-
- def _imprint(self, tool, data):
- # Save all data in a "openpype.{key}" = value data
-
- # Instance id is the tool's name so we don't need to imprint as data
- data.pop("instance_id", None)
-
- active = data.pop("active", None)
- if active is not None:
- # Use active value to set the passthrough state
- tool.SetAttrs({"TOOLB_PassThrough": not active})
-
- for key, value in data.items():
- tool.SetData(f"openpype.{key}", value)
-
- def _update_tool_with_data(self, tool, data):
- """Update tool node name and output path based on subset data"""
- if "subset" not in data:
- return
-
- original_subset = tool.GetData("openpype.subset")
- original_format = tool.GetData(
- "openpype.creator_attributes.image_format"
- )
-
- subset = data["subset"]
- if (
- original_subset != subset
- or original_format != data["creator_attributes"]["image_format"]
- ):
- self._configure_saver_tool(data, tool, subset)
-
- def _configure_saver_tool(self, data, tool, subset):
- formatting_data = deepcopy(data)
-
- # get frame padding from anatomy templates
- anatomy = Anatomy()
- frame_padding = anatomy.templates["frame_padding"]
-
- # get output format
- ext = data["creator_attributes"]["image_format"]
-
- # Subset change detected
- workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"])
- formatting_data.update(
- {"workdir": workdir, "frame": "0" * frame_padding, "ext": ext}
- )
-
- # build file path to render
- filepath = self.temp_rendering_path_template.format(**formatting_data)
-
- comp = get_current_comp()
- tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath))
-
- # Rename tool
- if tool.Name != subset:
- print(f"Renaming {tool.Name} -> {subset}")
- tool.SetAttrs({"TOOLS_Name": subset})
-
- def get_managed_tool_data(self, tool):
- """Return data of the tool if it matches creator identifier"""
- data = tool.GetData("openpype")
- if not isinstance(data, dict):
- return
-
- required = {
- "id": "pyblish.avalon.instance",
- "creator_identifier": self.identifier,
- }
- for key, value in required.items():
- if key not in data or data[key] != value:
- return
-
- # Get active state from the actual tool state
- attrs = tool.GetAttrs()
- passthrough = attrs["TOOLB_PassThrough"]
- data["active"] = not passthrough
-
- # Override publisher's UUID generation because tool names are
- # already unique in Fusion in a comp
- data["instance_id"] = tool.Name
-
- return data
+ Supports selection from predefined set of output file extensions:
+ - exr
+ - tga
+ - png
+ - tif
+ - jpg
+ """
def get_pre_create_attr_defs(self):
"""Settings for create page"""
@@ -193,29 +48,6 @@ class CreateSaver(NewCreator):
]
return attr_defs
- def get_instance_attr_defs(self):
- """Settings for publish page"""
- return self.get_pre_create_attr_defs()
-
- def pass_pre_attributes_to_instance(self, instance_data, pre_create_data):
- creator_attrs = instance_data["creator_attributes"] = {}
- for pass_key in pre_create_data.keys():
- creator_attrs[pass_key] = pre_create_data[pass_key]
-
- # These functions below should be moved to another file
- # so it can be used by other plugins. plugin.py ?
- def _get_render_target_enum(self):
- rendering_targets = {
- "local": "Local machine rendering",
- "frames": "Use existing frames",
- }
- if "farm_rendering" in self.instance_attributes:
- rendering_targets["farm"] = "Farm rendering"
-
- return EnumDef(
- "render_target", items=rendering_targets, label="Render target"
- )
-
def _get_frame_range_enum(self):
frame_range_options = {
"asset_db": "Current asset context",
@@ -227,42 +59,5 @@ class CreateSaver(NewCreator):
"frame_range_source",
items=frame_range_options,
label="Frame range source",
- )
-
- def _get_reviewable_bool(self):
- return BoolDef(
- "review",
- default=("reviewable" in self.instance_attributes),
- label="Review",
- )
-
- def _get_image_format_enum(self):
- image_format_options = ["exr", "tga", "tif", "png", "jpg"]
- return EnumDef(
- "image_format",
- items=image_format_options,
- default=self.image_format,
- label="Output Image Format",
- )
-
- def apply_settings(self, project_settings):
- """Method called on initialization of plugin to apply settings."""
-
- # plugin settings
- plugin_settings = project_settings["fusion"]["create"][
- self.__class__.__name__
- ]
-
- # individual attributes
- self.instance_attributes = plugin_settings.get(
- "instance_attributes", self.instance_attributes
- )
- self.default_variants = plugin_settings.get(
- "default_variants", self.default_variants
- )
- self.temp_rendering_path_template = plugin_settings.get(
- "temp_rendering_path_template", self.temp_rendering_path_template
- )
- self.image_format = plugin_settings.get(
- "image_format", self.image_format
+ default=self.default_frame_range_option
)
diff --git a/openpype/hosts/fusion/plugins/publish/collect_inputs.py b/openpype/hosts/fusion/plugins/publish/collect_inputs.py
index a6628300db..f23e4d0268 100644
--- a/openpype/hosts/fusion/plugins/publish/collect_inputs.py
+++ b/openpype/hosts/fusion/plugins/publish/collect_inputs.py
@@ -95,7 +95,7 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin):
label = "Collect Inputs"
order = pyblish.api.CollectorOrder + 0.2
hosts = ["fusion"]
- families = ["render"]
+ families = ["render", "image"]
def process(self, instance):
diff --git a/openpype/hosts/fusion/plugins/publish/collect_instances.py b/openpype/hosts/fusion/plugins/publish/collect_instances.py
index 4d6da79b77..a0131248e8 100644
--- a/openpype/hosts/fusion/plugins/publish/collect_instances.py
+++ b/openpype/hosts/fusion/plugins/publish/collect_instances.py
@@ -57,6 +57,18 @@ class CollectInstanceData(pyblish.api.InstancePlugin):
start_with_handle = comp_start
end_with_handle = comp_end
+ frame = instance.data["creator_attributes"].get("frame")
+ # explicitly publishing only single frame
+ if frame is not None:
+ frame = int(frame)
+
+ start = frame
+ end = frame
+ handle_start = 0
+ handle_end = 0
+ start_with_handle = frame
+ end_with_handle = frame
+
# Include start and end render frame in label
subset = instance.data["subset"]
label = (
diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py
index a7daa0b64c..366eaa905c 100644
--- a/openpype/hosts/fusion/plugins/publish/collect_render.py
+++ b/openpype/hosts/fusion/plugins/publish/collect_render.py
@@ -50,7 +50,7 @@ class CollectFusionRender(
continue
family = inst.data["family"]
- if family != "render":
+ if family not in ["render", "image"]:
continue
task_name = context.data["task"]
@@ -59,7 +59,7 @@ class CollectFusionRender(
instance_families = inst.data.get("families", [])
subset_name = inst.data["subset"]
instance = FusionRenderInstance(
- family="render",
+ family=family,
tool=tool,
workfileComp=comp,
families=instance_families,
diff --git a/openpype/hosts/fusion/plugins/publish/save_scene.py b/openpype/hosts/fusion/plugins/publish/save_scene.py
index 0798e7c8b7..da9b6ce41f 100644
--- a/openpype/hosts/fusion/plugins/publish/save_scene.py
+++ b/openpype/hosts/fusion/plugins/publish/save_scene.py
@@ -7,7 +7,7 @@ class FusionSaveComp(pyblish.api.ContextPlugin):
label = "Save current file"
order = pyblish.api.ExtractorOrder - 0.49
hosts = ["fusion"]
- families = ["render", "workfile"]
+ families = ["render", "image", "workfile"]
def process(self, context):
diff --git a/openpype/hosts/fusion/plugins/publish/validate_background_depth.py b/openpype/hosts/fusion/plugins/publish/validate_background_depth.py
index 6908889eb4..e268f8adec 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_background_depth.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_background_depth.py
@@ -17,7 +17,7 @@ class ValidateBackgroundDepth(
order = pyblish.api.ValidatorOrder
label = "Validate Background Depth 32 bit"
hosts = ["fusion"]
- families = ["render"]
+ families = ["render", "image"]
optional = True
actions = [SelectInvalidAction, publish.RepairAction]
diff --git a/openpype/hosts/fusion/plugins/publish/validate_comp_saved.py b/openpype/hosts/fusion/plugins/publish/validate_comp_saved.py
index 748047e8cf..6e6d10e09a 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_comp_saved.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_comp_saved.py
@@ -9,7 +9,7 @@ class ValidateFusionCompSaved(pyblish.api.ContextPlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Comp Saved"
- families = ["render"]
+ families = ["render", "image"]
hosts = ["fusion"]
def process(self, context):
diff --git a/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py b/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py
index 35c92163eb..d5c618af58 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py
@@ -15,7 +15,7 @@ class ValidateCreateFolderChecked(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Create Folder Checked"
- families = ["render"]
+ families = ["render", "image"]
hosts = ["fusion"]
actions = [RepairAction, SelectInvalidAction]
diff --git a/openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py b/openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py
index 537e43c875..38cd578ff2 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py
@@ -17,7 +17,7 @@ class ValidateFilenameHasExtension(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Filename Has Extension"
- families = ["render"]
+ families = ["render", "image"]
hosts = ["fusion"]
actions = [SelectInvalidAction]
diff --git a/openpype/hosts/fusion/plugins/publish/validate_image_frame.py b/openpype/hosts/fusion/plugins/publish/validate_image_frame.py
new file mode 100644
index 0000000000..734203f31c
--- /dev/null
+++ b/openpype/hosts/fusion/plugins/publish/validate_image_frame.py
@@ -0,0 +1,27 @@
+import pyblish.api
+
+from openpype.pipeline import PublishValidationError
+
+
+class ValidateImageFrame(pyblish.api.InstancePlugin):
+ """Validates that `image` product type contains only single frame."""
+
+ order = pyblish.api.ValidatorOrder
+ label = "Validate Image Frame"
+ families = ["image"]
+ hosts = ["fusion"]
+
+ def process(self, instance):
+ render_start = instance.data["frameStartHandle"]
+ render_end = instance.data["frameEndHandle"]
+ too_many_frames = (isinstance(instance.data["expectedFiles"], list)
+ and len(instance.data["expectedFiles"]) > 1)
+
+ if render_end - render_start > 0 or too_many_frames:
+ desc = ("Trying to render multiple frames. 'image' product type "
+ "is meant for single frame. Please use 'render' creator.")
+ raise PublishValidationError(
+ title="Frame range outside of comp range",
+ message=desc,
+ description=desc
+ )
diff --git a/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py b/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py
index 06cd0ca186..edf219e752 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py
@@ -7,8 +7,8 @@ class ValidateInstanceFrameRange(pyblish.api.InstancePlugin):
"""Validate instance frame range is within comp's global render range."""
order = pyblish.api.ValidatorOrder
- label = "Validate Filename Has Extension"
- families = ["render"]
+ label = "Validate Frame Range"
+ families = ["render", "image"]
hosts = ["fusion"]
def process(self, instance):
diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py b/openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py
index faf2102a8b..0103e990fb 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py
@@ -13,7 +13,7 @@ class ValidateSaverHasInput(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Saver Has Input"
- families = ["render"]
+ families = ["render", "image"]
hosts = ["fusion"]
actions = [SelectInvalidAction]
diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py b/openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py
index 9004976dc5..6019bee93a 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py
@@ -9,7 +9,7 @@ class ValidateSaverPassthrough(pyblish.api.ContextPlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Saver Passthrough"
- families = ["render"]
+ families = ["render", "image"]
hosts = ["fusion"]
actions = [SelectInvalidAction]
diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py
index efa7295d11..f6aba170c0 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py
@@ -64,7 +64,7 @@ class ValidateSaverResolution(
order = pyblish.api.ValidatorOrder
label = "Validate Asset Resolution"
- families = ["render"]
+ families = ["render", "image"]
hosts = ["fusion"]
optional = True
actions = [SelectInvalidAction]
diff --git a/openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py b/openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py
index 5b6ceb2fdb..d1693ef3dc 100644
--- a/openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py
+++ b/openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py
@@ -11,7 +11,7 @@ class ValidateUniqueSubsets(pyblish.api.ContextPlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Unique Subsets"
- families = ["render"]
+ families = ["render", "image"]
hosts = ["fusion"]
actions = [SelectInvalidAction]
diff --git a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py
index fcb1ab27a0..d7f646ebc9 100644
--- a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py
+++ b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py
@@ -9,6 +9,8 @@ class CollectClipEffects(pyblish.api.InstancePlugin):
label = "Collect Clip Effects Instances"
families = ["clip"]
+ effect_categories = []
+
def process(self, instance):
family = "effect"
effects = {}
@@ -70,29 +72,62 @@ class CollectClipEffects(pyblish.api.InstancePlugin):
subset_split.insert(0, "effect")
- name = "".join(subset_split)
+ effect_categories = {
+ x["name"]: x["effect_classes"] for x in self.effect_categories
+ }
- # create new instance and inherit data
- data = {}
- for key, value in instance.data.items():
- if "clipEffectItems" in key:
+ category_by_effect = {"": ""}
+ for key, values in effect_categories.items():
+ for cls in values:
+ category_by_effect[cls] = key
+
+ effects_categorized = {k: {} for k in effect_categories.keys()}
+ effects_categorized[""] = {}
+ for key, value in effects.items():
+ if key == "assignTo":
continue
- data[key] = value
- # change names
- data["subset"] = name
- data["family"] = family
- data["families"] = [family]
- data["name"] = data["subset"] + "_" + data["asset"]
- data["label"] = "{} - {}".format(
- data['asset'], data["subset"]
- )
- data["effects"] = effects
+ # Some classes can have a number in them. Like Text2.
+ found_cls = ""
+ for cls in category_by_effect.keys():
+ if cls in value["class"]:
+ found_cls = cls
- # create new instance
- _instance = instance.context.create_instance(**data)
- self.log.info("Created instance `{}`".format(_instance))
- self.log.debug("instance.data `{}`".format(_instance.data))
+ effects_categorized[category_by_effect[found_cls]][key] = value
+
+ categories = list(effects_categorized.keys())
+ for category in categories:
+ if not effects_categorized[category]:
+ effects_categorized.pop(category)
+ continue
+
+ effects_categorized[category]["assignTo"] = effects["assignTo"]
+
+ for category, effects in effects_categorized.items():
+ name = "".join(subset_split)
+ name += category.capitalize()
+
+ # create new instance and inherit data
+ data = {}
+ for key, value in instance.data.items():
+ if "clipEffectItems" in key:
+ continue
+ data[key] = value
+
+ # change names
+ data["subset"] = name
+ data["family"] = family
+ data["families"] = [family]
+ data["name"] = data["subset"] + "_" + data["asset"]
+ data["label"] = "{} - {}".format(
+ data['asset'], data["subset"]
+ )
+ data["effects"] = effects
+
+ # create new instance
+ _instance = instance.context.create_instance(**data)
+ self.log.info("Created instance `{}`".format(_instance))
+ self.log.debug("instance.data `{}`".format(_instance.data))
def test_overlap(self, effect_t_in, effect_t_out):
covering_exp = bool(
diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py
index 614052431f..edd50f10c1 100644
--- a/openpype/hosts/houdini/api/lib.py
+++ b/openpype/hosts/houdini/api/lib.py
@@ -121,62 +121,6 @@ def get_id_required_nodes():
return list(nodes)
-def get_export_parameter(node):
- """Return the export output parameter of the given node
-
- Example:
- root = hou.node("/obj")
- my_alembic_node = root.createNode("alembic")
- get_output_parameter(my_alembic_node)
- # Result: "output"
-
- Args:
- node(hou.Node): node instance
-
- Returns:
- hou.Parm
-
- """
- node_type = node.type().description()
-
- # Ensures the proper Take is selected for each ROP to retrieve the correct
- # ifd
- try:
- rop_take = hou.takes.findTake(node.parm("take").eval())
- if rop_take is not None:
- hou.takes.setCurrentTake(rop_take)
- except AttributeError:
- # hou object doesn't always have the 'takes' attribute
- pass
-
- if node_type == "Mantra" and node.parm("soho_outputmode").eval():
- return node.parm("soho_diskfile")
- elif node_type == "Alfred":
- return node.parm("alf_diskfile")
- elif (node_type == "RenderMan" or node_type == "RenderMan RIS"):
- pre_ris22 = node.parm("rib_outputmode") and \
- node.parm("rib_outputmode").eval()
- ris22 = node.parm("diskfile") and node.parm("diskfile").eval()
- if pre_ris22 or ris22:
- return node.parm("soho_diskfile")
- elif node_type == "Redshift" and node.parm("RS_archive_enable").eval():
- return node.parm("RS_archive_file")
- elif node_type == "Wedge" and node.parm("driver").eval():
- return get_export_parameter(node.node(node.parm("driver").eval()))
- elif node_type == "Arnold":
- return node.parm("ar_ass_file")
- elif node_type == "Alembic" and node.parm("use_sop_path").eval():
- return node.parm("sop_path")
- elif node_type == "Shotgun Mantra" and node.parm("soho_outputmode").eval():
- return node.parm("sgtk_soho_diskfile")
- elif node_type == "Shotgun Alembic" and node.parm("use_sop_path").eval():
- return node.parm("sop_path")
- elif node.type().nameWithCategory() == "Driver/vray_renderer":
- return node.parm("render_export_filepath")
-
- raise TypeError("Node type '%s' not supported" % node_type)
-
-
def get_output_parameter(node):
"""Return the render output parameter of the given node
@@ -184,41 +128,59 @@ def get_output_parameter(node):
root = hou.node("/obj")
my_alembic_node = root.createNode("alembic")
get_output_parameter(my_alembic_node)
- # Result: "output"
+ >>> "filename"
+
+ Notes:
+ I'm using node.type().name() to get on par with the creators,
+ Because the return value of `node.type().name()` is the
+ same string value used in creators
+ e.g. instance_data.update({"node_type": "alembic"})
+
+ Rop nodes in different network categories have
+ the same output parameter.
+ So, I took that into consideration as a hint for
+ future development.
Args:
node(hou.Node): node instance
Returns:
hou.Parm
-
"""
- node_type = node.type().description()
- category = node.type().category().name()
+
+ node_type = node.type().name()
# Figure out which type of node is being rendered
- if node_type == "Geometry" or node_type == "Filmbox FBX" or \
- (node_type == "ROP Output Driver" and category == "Sop"):
- return node.parm("sopoutput")
- elif node_type == "Composite":
- return node.parm("copoutput")
- elif node_type == "opengl":
- return node.parm("picture")
+ if node_type in {"alembic", "rop_alembic"}:
+ return node.parm("filename")
elif node_type == "arnold":
- if node.evalParm("ar_ass_export_enable"):
+ if node_type.evalParm("ar_ass_export_enable"):
return node.parm("ar_ass_file")
- elif node_type == "Redshift_Proxy_Output":
- return node.parm("RS_archive_file")
- elif node_type == "ifd":
+ return node.parm("ar_picture")
+ elif node_type in {
+ "geometry",
+ "rop_geometry",
+ "filmboxfbx",
+ "rop_fbx"
+ }:
+ return node.parm("sopoutput")
+ elif node_type == "comp":
+ return node.parm("copoutput")
+ elif node_type in {"karma", "opengl"}:
+ return node.parm("picture")
+ elif node_type == "ifd": # Mantra
if node.evalParm("soho_outputmode"):
return node.parm("soho_diskfile")
- elif node_type == "Octane":
- return node.parm("HO_img_fileName")
- elif node_type == "Fetch":
- inner_node = node.node(node.parm("source").eval())
- if inner_node:
- return get_output_parameter(inner_node)
- elif node.type().nameWithCategory() == "Driver/vray_renderer":
+ return node.parm("vm_picture")
+ elif node_type == "Redshift_Proxy_Output":
+ return node.parm("RS_archive_file")
+ elif node_type == "Redshift_ROP":
+ return node.parm("RS_outputFileNamePrefix")
+ elif node_type in {"usd", "usd_rop", "usdexport"}:
+ return node.parm("lopoutput")
+ elif node_type in {"usdrender", "usdrender_rop"}:
+ return node.parm("outputimage")
+ elif node_type == "vray_renderer":
return node.parm("SettingsOutput_img_file_path")
raise TypeError("Node type '%s' not supported" % node_type)
diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py
index 1b8826a932..9d1c7bc90d 100644
--- a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py
+++ b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py
@@ -15,6 +15,9 @@ class CreateRedshiftROP(plugin.HoudiniCreator):
icon = "magic"
ext = "exr"
+ # Default to split export and render jobs
+ split_render = True
+
def create(self, subset_name, instance_data, pre_create_data):
instance_data.pop("active", None)
@@ -36,12 +39,15 @@ class CreateRedshiftROP(plugin.HoudiniCreator):
# Also create the linked Redshift IPR Rop
try:
ipr_rop = instance_node.parent().createNode(
- "Redshift_IPR", node_name=basename + "_IPR"
+ "Redshift_IPR", node_name=f"{basename}_IPR"
)
- except hou.OperationFailed:
+ except hou.OperationFailed as e:
raise plugin.OpenPypeCreatorError(
- ("Cannot create Redshift node. Is Redshift "
- "installed and enabled?"))
+ (
+ "Cannot create Redshift node. Is Redshift "
+ "installed and enabled?"
+ )
+ ) from e
# Move it to directly under the Redshift ROP
ipr_rop.setPosition(instance_node.position() + hou.Vector2(0, -1))
@@ -74,8 +80,15 @@ class CreateRedshiftROP(plugin.HoudiniCreator):
for node in self.selected_nodes:
if node.type().name() == "cam":
camera = node.path()
- parms.update({
- "RS_renderCamera": camera or ""})
+ parms["RS_renderCamera"] = camera or ""
+
+ export_dir = hou.text.expandString("$HIP/pyblish/rs/")
+ rs_filepath = f"{export_dir}{subset_name}/{subset_name}.$F4.rs"
+ parms["RS_archive_file"] = rs_filepath
+
+ if pre_create_data.get("split_render", self.split_render):
+ parms["RS_archive_enable"] = 1
+
instance_node.setParms(parms)
# Lock some Avalon attributes
@@ -102,6 +115,9 @@ class CreateRedshiftROP(plugin.HoudiniCreator):
BoolDef("farm",
label="Submitting to Farm",
default=True),
+ BoolDef("split_render",
+ label="Split export and render jobs",
+ default=self.split_render),
EnumDef("image_format",
image_format_enum,
default=self.ext,
diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py
index cac22d62d4..894ac62b3e 100644
--- a/openpype/hosts/houdini/plugins/load/load_fbx.py
+++ b/openpype/hosts/houdini/plugins/load/load_fbx.py
@@ -16,8 +16,9 @@ class FbxLoader(load.LoaderPlugin):
order = -10
- families = ["staticMesh", "fbx"]
- representations = ["fbx"]
+ families = ["*"]
+ representations = ["*"]
+ extensions = {"fbx"}
def load(self, context, name=None, namespace=None, data=None):
diff --git a/openpype/hosts/houdini/plugins/load/load_redshift_proxy.py b/openpype/hosts/houdini/plugins/load/load_redshift_proxy.py
new file mode 100644
index 0000000000..efd7c6d0ca
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/load/load_redshift_proxy.py
@@ -0,0 +1,112 @@
+import os
+import re
+from openpype.pipeline import (
+ load,
+ get_representation_path,
+)
+from openpype.hosts.houdini.api import pipeline
+from openpype.pipeline.load import LoadError
+
+import hou
+
+
+class RedshiftProxyLoader(load.LoaderPlugin):
+ """Load Redshift Proxy"""
+
+ families = ["redshiftproxy"]
+ label = "Load Redshift Proxy"
+ representations = ["rs"]
+ order = -10
+ icon = "code-fork"
+ color = "orange"
+
+ def load(self, context, name=None, namespace=None, data=None):
+
+ # Get the root node
+ obj = hou.node("/obj")
+
+ # Define node name
+ namespace = namespace if namespace else context["asset"]["name"]
+ node_name = "{}_{}".format(namespace, name) if namespace else name
+
+ # Create a new geo node
+ container = obj.createNode("geo", node_name=node_name)
+
+ # Check whether the Redshift parameters exist - if not, then likely
+ # redshift is not set up or initialized correctly
+ if not container.parm("RS_objprop_proxy_enable"):
+ container.destroy()
+ raise LoadError("Unable to initialize geo node with Redshift "
+ "attributes. Make sure you have the Redshift "
+ "plug-in set up correctly for Houdini.")
+
+ # Enable by default
+ container.setParms({
+ "RS_objprop_proxy_enable": True,
+ "RS_objprop_proxy_file": self.format_path(
+ self.filepath_from_context(context),
+ context["representation"])
+ })
+
+ # Remove the file node, it only loads static meshes
+ # Houdini 17 has removed the file node from the geo node
+ file_node = container.node("file1")
+ if file_node:
+ file_node.destroy()
+
+ # Add this stub node inside so it previews ok
+ proxy_sop = container.createNode("redshift_proxySOP",
+ node_name=node_name)
+ proxy_sop.setDisplayFlag(True)
+
+ nodes = [container, proxy_sop]
+
+ self[:] = nodes
+
+ return pipeline.containerise(
+ node_name,
+ namespace,
+ nodes,
+ context,
+ self.__class__.__name__,
+ suffix="",
+ )
+
+ def update(self, container, representation):
+
+ # Update the file path
+ file_path = get_representation_path(representation)
+
+ node = container["node"]
+ node.setParms({
+ "RS_objprop_proxy_file": self.format_path(
+ file_path, representation)
+ })
+
+ # Update attribute
+ node.setParms({"representation": str(representation["_id"])})
+
+ def remove(self, container):
+
+ node = container["node"]
+ node.destroy()
+
+ @staticmethod
+ def format_path(path, representation):
+ """Format file path correctly for single redshift proxy
+ or redshift proxy sequence."""
+ if not os.path.exists(path):
+ raise RuntimeError("Path does not exist: %s" % path)
+
+ is_sequence = bool(representation["context"].get("frame"))
+ # The path is either a single file or sequence in a folder.
+ if is_sequence:
+ filename = re.sub(r"(.*)\.(\d+)\.(rs.*)", "\\1.$F4.\\3", path)
+ filename = os.path.join(path, filename)
+ else:
+ filename = path
+
+ filename = os.path.normpath(filename)
+ filename = filename.replace("\\", "/")
+
+ return filename
diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py
index c7da8397dc..ffc2a526a3 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py
@@ -41,11 +41,11 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin):
render_products = []
# Store whether we are splitting the render job (export + render)
- export_job = bool(rop.parm("ar_ass_export_enable").eval())
- instance.data["exportJob"] = export_job
+ split_render = bool(rop.parm("ar_ass_export_enable").eval())
+ instance.data["splitRender"] = split_render
export_prefix = None
export_products = []
- if export_job:
+ if split_render:
export_prefix = evalParmNoFrame(
rop, "ar_ass_file", pad_character="0"
)
diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py
index bc71576174..64ef20f4e7 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py
@@ -45,11 +45,11 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin):
render_products = []
# Store whether we are splitting the render job (export + render)
- export_job = bool(rop.parm("soho_outputmode").eval())
- instance.data["exportJob"] = export_job
+ split_render = bool(rop.parm("soho_outputmode").eval())
+ instance.data["splitRender"] = split_render
export_prefix = None
export_products = []
- if export_job:
+ if split_render:
export_prefix = evalParmNoFrame(
rop, "soho_diskfile", pad_character="0"
)
diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py
index 0acddab011..aec7e07fbc 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py
@@ -31,7 +31,6 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin):
families = ["redshift_rop"]
def process(self, instance):
-
rop = hou.node(instance.data.get("instance_node"))
# Collect chunkSize
@@ -43,13 +42,29 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin):
default_prefix = evalParmNoFrame(rop, "RS_outputFileNamePrefix")
beauty_suffix = rop.evalParm("RS_outputBeautyAOVSuffix")
- render_products = []
+ # Store whether we are splitting the render job (export + render)
+ split_render = bool(rop.parm("RS_archive_enable").eval())
+ instance.data["splitRender"] = split_render
+ export_products = []
+ if split_render:
+ export_prefix = evalParmNoFrame(
+ rop, "RS_archive_file", pad_character="0"
+ )
+ beauty_export_product = self.get_render_product_name(
+ prefix=export_prefix,
+ suffix=None)
+ export_products.append(beauty_export_product)
+ self.log.debug(
+ "Found export product: {}".format(beauty_export_product)
+ )
+ instance.data["ifdFile"] = beauty_export_product
+ instance.data["exportFiles"] = list(export_products)
# Default beauty AOV
beauty_product = self.get_render_product_name(
prefix=default_prefix, suffix=beauty_suffix
)
- render_products.append(beauty_product)
+ render_products = [beauty_product]
files_by_aov = {
"_": self.generate_expected_files(instance,
beauty_product)}
@@ -59,11 +74,11 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin):
i = index + 1
# Skip disabled AOVs
- if not rop.evalParm("RS_aovEnable_%s" % i):
+ if not rop.evalParm(f"RS_aovEnable_{i}"):
continue
- aov_suffix = rop.evalParm("RS_aovSuffix_%s" % i)
- aov_prefix = evalParmNoFrame(rop, "RS_aovCustomPrefix_%s" % i)
+ aov_suffix = rop.evalParm(f"RS_aovSuffix_{i}")
+ aov_prefix = evalParmNoFrame(rop, f"RS_aovCustomPrefix_{i}")
if not aov_prefix:
aov_prefix = default_prefix
@@ -85,7 +100,7 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin):
instance.data["attachTo"] = [] # stub required data
if "expectedFiles" not in instance.data:
- instance.data["expectedFiles"] = list()
+ instance.data["expectedFiles"] = []
instance.data["expectedFiles"].append(files_by_aov)
# update the colorspace data
diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
index a1f4554726..ad4fdb0da5 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py
@@ -46,11 +46,11 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin):
# TODO: add render elements if render element
# Store whether we are splitting the render job in an export + render
- export_job = rop.parm("render_export_mode").eval() == "2"
- instance.data["exportJob"] = export_job
+ split_render = rop.parm("render_export_mode").eval() == "2"
+ instance.data["splitRender"] = split_render
export_prefix = None
export_products = []
- if export_job:
+ if split_render:
export_prefix = evalParmNoFrame(
rop, "render_export_filepath", pad_character="0"
)
diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py
index 8531233bb2..e2d8d9c55f 100644
--- a/openpype/hosts/max/api/lib.py
+++ b/openpype/hosts/max/api/lib.py
@@ -294,6 +294,37 @@ def reset_frame_range(fps: bool = True):
frame_range["frameStartHandle"], frame_range["frameEndHandle"])
+def reset_unit_scale():
+ """Apply the unit scale setting to 3dsMax
+ """
+ project_name = get_current_project_name()
+ settings = get_project_settings(project_name).get("max")
+ scene_scale = settings.get("unit_scale_settings",
+ {}).get("scene_unit_scale")
+ if scene_scale:
+ rt.units.DisplayType = rt.Name("Metric")
+ rt.units.MetricType = rt.Name(scene_scale)
+ else:
+ rt.units.DisplayType = rt.Name("Generic")
+
+
+def convert_unit_scale():
+ """Convert system unit scale in 3dsMax
+ for fbx export
+
+ Returns:
+ str: unit scale
+ """
+ unit_scale_dict = {
+ "millimeters": "mm",
+ "centimeters": "cm",
+ "meters": "m",
+ "kilometers": "km"
+ }
+ current_unit_scale = rt.Execute("units.MetricType as string")
+ return unit_scale_dict[current_unit_scale]
+
+
def set_context_setting():
"""Apply the project settings from the project definition
@@ -310,6 +341,7 @@ def set_context_setting():
reset_scene_resolution()
reset_frame_range()
reset_colorspace()
+ reset_unit_scale()
def get_max_version():
diff --git a/openpype/hosts/max/api/lib_renderproducts.py b/openpype/hosts/max/api/lib_renderproducts.py
index 90608737c2..eaf5015ba8 100644
--- a/openpype/hosts/max/api/lib_renderproducts.py
+++ b/openpype/hosts/max/api/lib_renderproducts.py
@@ -37,6 +37,95 @@ class RenderProducts(object):
)
}
+ def get_multiple_beauty(self, outputs, cameras):
+ beauty_output_frames = dict()
+ for output, camera in zip(outputs, cameras):
+ filename, ext = os.path.splitext(output)
+ filename = filename.replace(".", "")
+ ext = ext.replace(".", "")
+ start_frame = int(rt.rendStart)
+ end_frame = int(rt.rendEnd) + 1
+ new_beauty = self.get_expected_beauty(
+ filename, start_frame, end_frame, ext
+ )
+ beauty_output = ({
+ f"{camera}_beauty": new_beauty
+ })
+ beauty_output_frames.update(beauty_output)
+ return beauty_output_frames
+
+ def get_multiple_aovs(self, outputs, cameras):
+ renderer_class = get_current_renderer()
+ renderer = str(renderer_class).split(":")[0]
+ aovs_frames = {}
+ for output, camera in zip(outputs, cameras):
+ filename, ext = os.path.splitext(output)
+ filename = filename.replace(".", "")
+ ext = ext.replace(".", "")
+ start_frame = int(rt.rendStart)
+ end_frame = int(rt.rendEnd) + 1
+
+ if renderer in [
+ "ART_Renderer",
+ "V_Ray_6_Hotfix_3",
+ "V_Ray_GPU_6_Hotfix_3",
+ "Default_Scanline_Renderer",
+ "Quicksilver_Hardware_Renderer",
+ ]:
+ render_name = self.get_render_elements_name()
+ if render_name:
+ for name in render_name:
+ aovs_frames.update({
+ f"{camera}_{name}": self.get_expected_aovs(
+ filename, name, start_frame,
+ end_frame, ext)
+ })
+ elif renderer == "Redshift_Renderer":
+ render_name = self.get_render_elements_name()
+ if render_name:
+ rs_aov_files = rt.Execute("renderers.current.separateAovFiles") # noqa
+ # this doesn't work, always returns False
+ # rs_AovFiles = rt.RedShift_Renderer().separateAovFiles
+ if ext == "exr" and not rs_aov_files:
+ for name in render_name:
+ if name == "RsCryptomatte":
+ aovs_frames.update({
+ f"{camera}_{name}": self.get_expected_aovs(
+ filename, name, start_frame,
+ end_frame, ext)
+ })
+ else:
+ for name in render_name:
+ aovs_frames.update({
+ f"{camera}_{name}": self.get_expected_aovs(
+ filename, name, start_frame,
+ end_frame, ext)
+ })
+ elif renderer == "Arnold":
+ render_name = self.get_arnold_product_name()
+ if render_name:
+ for name in render_name:
+ aovs_frames.update({
+ f"{camera}_{name}": self.get_expected_arnold_product( # noqa
+ filename, name, start_frame,
+ end_frame, ext)
+ })
+ elif renderer in [
+ "V_Ray_6_Hotfix_3",
+ "V_Ray_GPU_6_Hotfix_3"
+ ]:
+ if ext != "exr":
+ render_name = self.get_render_elements_name()
+ if render_name:
+ for name in render_name:
+ aovs_frames.update({
+ f"{camera}_{name}": self.get_expected_aovs(
+ filename, name, start_frame,
+ end_frame, ext)
+ })
+
+ return aovs_frames
+
def get_aovs(self, container):
render_dir = os.path.dirname(rt.rendOutputFilename)
@@ -63,7 +152,7 @@ class RenderProducts(object):
if render_name:
for name in render_name:
render_dict.update({
- name: self.get_expected_render_elements(
+ name: self.get_expected_aovs(
output_file, name, start_frame,
end_frame, img_fmt)
})
@@ -77,14 +166,14 @@ class RenderProducts(object):
for name in render_name:
if name == "RsCryptomatte":
render_dict.update({
- name: self.get_expected_render_elements(
+ name: self.get_expected_aovs(
output_file, name, start_frame,
end_frame, img_fmt)
})
else:
for name in render_name:
render_dict.update({
- name: self.get_expected_render_elements(
+ name: self.get_expected_aovs(
output_file, name, start_frame,
end_frame, img_fmt)
})
@@ -95,7 +184,8 @@ class RenderProducts(object):
for name in render_name:
render_dict.update({
name: self.get_expected_arnold_product(
- output_file, name, start_frame, end_frame, img_fmt)
+ output_file, name, start_frame,
+ end_frame, img_fmt)
})
elif renderer in [
"V_Ray_6_Hotfix_3",
@@ -106,7 +196,7 @@ class RenderProducts(object):
if render_name:
for name in render_name:
render_dict.update({
- name: self.get_expected_render_elements(
+ name: self.get_expected_aovs(
output_file, name, start_frame,
end_frame, img_fmt) # noqa
})
@@ -169,8 +259,8 @@ class RenderProducts(object):
return render_name
- def get_expected_render_elements(self, folder, name,
- start_frame, end_frame, fmt):
+ def get_expected_aovs(self, folder, name,
+ start_frame, end_frame, fmt):
"""Get all the expected render element output files. """
render_elements = []
for f in range(start_frame, end_frame):
diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py
index 26e176aa8d..be50e296eb 100644
--- a/openpype/hosts/max/api/lib_rendersettings.py
+++ b/openpype/hosts/max/api/lib_rendersettings.py
@@ -74,13 +74,13 @@ class RenderSettings(object):
output = os.path.join(output_dir, container)
try:
aov_separator = self._aov_chars[(
- self._project_settings["maya"]
+ self._project_settings["max"]
["RenderSettings"]
["aov_separator"]
)]
except KeyError:
aov_separator = "."
- output_filename = "{0}..{1}".format(output, img_fmt)
+ output_filename = f"{output}..{img_fmt}"
output_filename = output_filename.replace("{aov_separator}",
aov_separator)
rt.rendOutputFilename = output_filename
@@ -146,13 +146,13 @@ class RenderSettings(object):
for i in range(render_elem_num):
renderlayer_name = render_elem.GetRenderElement(i)
target, renderpass = str(renderlayer_name).split(":")
- aov_name = "{0}_{1}..{2}".format(dir, renderpass, ext)
+ aov_name = f"{dir}_{renderpass}..{ext}"
render_elem.SetRenderElementFileName(i, aov_name)
def get_render_output(self, container, output_dir):
output = os.path.join(output_dir, container)
img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa
- output_filename = "{0}..{1}".format(output, img_fmt)
+ output_filename = f"{output}..{img_fmt}"
return output_filename
def get_render_element(self):
@@ -167,3 +167,61 @@ class RenderSettings(object):
orig_render_elem.append(render_element)
return orig_render_elem
+
+ def get_batch_render_elements(self, container,
+ output_dir, camera):
+ render_element_list = list()
+ output = os.path.join(output_dir, container)
+ render_elem = rt.maxOps.GetCurRenderElementMgr()
+ render_elem_num = render_elem.NumRenderElements()
+ if render_elem_num < 0:
+ return
+ img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa
+
+ for i in range(render_elem_num):
+ renderlayer_name = render_elem.GetRenderElement(i)
+ target, renderpass = str(renderlayer_name).split(":")
+ aov_name = f"{output}_{camera}_{renderpass}..{img_fmt}"
+ render_element_list.append(aov_name)
+ return render_element_list
+
+ def get_batch_render_output(self, camera):
+ target_layer_no = rt.batchRenderMgr.FindView(camera)
+ target_layer = rt.batchRenderMgr.GetView(target_layer_no)
+ return target_layer.outputFilename
+
+ def batch_render_elements(self, camera):
+ target_layer_no = rt.batchRenderMgr.FindView(camera)
+ target_layer = rt.batchRenderMgr.GetView(target_layer_no)
+ outputfilename = target_layer.outputFilename
+ directory = os.path.dirname(outputfilename)
+ render_elem = rt.maxOps.GetCurRenderElementMgr()
+ render_elem_num = render_elem.NumRenderElements()
+ if render_elem_num < 0:
+ return
+ ext = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa
+
+ for i in range(render_elem_num):
+ renderlayer_name = render_elem.GetRenderElement(i)
+ target, renderpass = str(renderlayer_name).split(":")
+ aov_name = f"{directory}_{camera}_{renderpass}..{ext}"
+ render_elem.SetRenderElementFileName(i, aov_name)
+
+ def batch_render_layer(self, container,
+ output_dir, cameras):
+ outputs = list()
+ output = os.path.join(output_dir, container)
+ img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa
+ for cam in cameras:
+ camera = rt.getNodeByName(cam)
+ layer_no = rt.batchRenderMgr.FindView(cam)
+ renderlayer = None
+ if layer_no == 0:
+ renderlayer = rt.batchRenderMgr.CreateView(camera)
+ else:
+ renderlayer = rt.batchRenderMgr.GetView(layer_no)
+ # use camera name as renderlayer name
+ renderlayer.name = cam
+ renderlayer.outputFilename = f"{output}_{cam}..{img_fmt}"
+ outputs.append(renderlayer.outputFilename)
+ return outputs
diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py
index caaa3e3730..9bdb6bd7ce 100644
--- a/openpype/hosts/max/api/menu.py
+++ b/openpype/hosts/max/api/menu.py
@@ -124,6 +124,10 @@ class OpenPypeMenu(object):
colorspace_action.triggered.connect(self.colorspace_callback)
openpype_menu.addAction(colorspace_action)
+ unit_scale_action = QtWidgets.QAction("Set Unit Scale", openpype_menu)
+ unit_scale_action.triggered.connect(self.unit_scale_callback)
+ openpype_menu.addAction(unit_scale_action)
+
return openpype_menu
def load_callback(self):
@@ -157,3 +161,7 @@ class OpenPypeMenu(object):
def colorspace_callback(self):
"""Callback to reset colorspace"""
return lib.reset_colorspace()
+
+ def unit_scale_callback(self):
+ """Callback to reset unit scale"""
+ return lib.reset_unit_scale()
diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py
index 9cc3c8da8a..617334753a 100644
--- a/openpype/hosts/max/plugins/create/create_render.py
+++ b/openpype/hosts/max/plugins/create/create_render.py
@@ -2,6 +2,7 @@
"""Creator plugin for creating camera."""
import os
from openpype.hosts.max.api import plugin
+from openpype.lib import BoolDef
from openpype.hosts.max.api.lib_rendersettings import RenderSettings
@@ -17,15 +18,33 @@ class CreateRender(plugin.MaxCreator):
file = rt.maxFileName
filename, _ = os.path.splitext(file)
instance_data["AssetName"] = filename
+ instance_data["multiCamera"] = pre_create_data.get("multi_cam")
+ num_of_renderlayer = rt.batchRenderMgr.numViews
+ if num_of_renderlayer > 0:
+ rt.batchRenderMgr.DeleteView(num_of_renderlayer)
instance = super(CreateRender, self).create(
subset_name,
instance_data,
pre_create_data)
+
container_name = instance.data.get("instance_node")
- sel_obj = self.selected_nodes
- if sel_obj:
- # set viewport camera for rendering(mandatory for deadline)
- RenderSettings(self.project_settings).set_render_camera(sel_obj)
# set output paths for rendering(mandatory for deadline)
RenderSettings().render_output(container_name)
+ # TODO: create multiple camera options
+ if self.selected_nodes:
+ selected_nodes_name = []
+ for sel in self.selected_nodes:
+ name = sel.name
+ selected_nodes_name.append(name)
+ RenderSettings().batch_render_layer(
+ container_name, filename,
+ selected_nodes_name)
+
+ def get_pre_create_attr_defs(self):
+ attrs = super(CreateRender, self).get_pre_create_attr_defs()
+ return attrs + [
+ BoolDef("multi_cam",
+ label="Multiple Cameras Submission",
+ default=False),
+ ]
diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py
index 38194a0735..8abffa5ab6 100644
--- a/openpype/hosts/max/plugins/publish/collect_render.py
+++ b/openpype/hosts/max/plugins/publish/collect_render.py
@@ -4,8 +4,10 @@ import os
import pyblish.api
from pymxs import runtime as rt
+from openpype.pipeline.publish import KnownPublishError
from openpype.hosts.max.api import colorspace
from openpype.hosts.max.api.lib import get_max_version, get_current_renderer
+from openpype.hosts.max.api.lib_rendersettings import RenderSettings
from openpype.hosts.max.api.lib_renderproducts import RenderProducts
@@ -23,7 +25,6 @@ class CollectRender(pyblish.api.InstancePlugin):
file = rt.maxFileName
current_file = os.path.join(folder, file)
filepath = current_file.replace("\\", "/")
-
context.data['currentFile'] = current_file
files_by_aov = RenderProducts().get_beauty(instance.name)
@@ -39,6 +40,28 @@ class CollectRender(pyblish.api.InstancePlugin):
instance.data["cameras"] = [camera.name] if camera else None # noqa
+ if instance.data.get("multiCamera"):
+ cameras = instance.data.get("members")
+ if not cameras:
+ raise KnownPublishError("There should be at least"
+ " one renderable camera in container")
+ sel_cam = [
+ c.name for c in cameras
+ if rt.classOf(c) in rt.Camera.classes]
+ container_name = instance.data.get("instance_node")
+ render_dir = os.path.dirname(rt.rendOutputFilename)
+ outputs = RenderSettings().batch_render_layer(
+ container_name, render_dir, sel_cam
+ )
+
+ instance.data["cameras"] = sel_cam
+
+ files_by_aov = RenderProducts().get_multiple_beauty(
+ outputs, sel_cam)
+ aovs = RenderProducts().get_multiple_aovs(
+ outputs, sel_cam)
+ files_by_aov.update(aovs)
+
if "expectedFiles" not in instance.data:
instance.data["expectedFiles"] = list()
instance.data["files"] = list()
diff --git a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py
deleted file mode 100644
index 4b5631b05f..0000000000
--- a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import os
-
-import pyblish.api
-from pymxs import runtime as rt
-
-from openpype.hosts.max.api import maintained_selection
-from openpype.pipeline import OptionalPyblishPluginMixin, publish
-
-
-class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin):
- """Extract Camera with FbxExporter."""
-
- order = pyblish.api.ExtractorOrder - 0.2
- label = "Extract Fbx Camera"
- hosts = ["max"]
- families = ["camera"]
- optional = True
-
- def process(self, instance):
- if not self.is_active(instance.data):
- return
-
- stagingdir = self.staging_dir(instance)
- filename = "{name}.fbx".format(**instance.data)
-
- filepath = os.path.join(stagingdir, filename)
- rt.FBXExporterSetParam("Animation", True)
- rt.FBXExporterSetParam("Cameras", True)
- rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
- rt.FBXExporterSetParam("UpAxis", "Y")
- rt.FBXExporterSetParam("Preserveinstances", True)
-
- with maintained_selection():
- # select and export
- node_list = instance.data["members"]
- rt.Select(node_list)
- rt.ExportFile(
- filepath,
- rt.Name("noPrompt"),
- selectedOnly=True,
- using=rt.FBXEXP,
- )
-
- self.log.info("Performing Extraction ...")
- if "representations" not in instance.data:
- instance.data["representations"] = []
-
- representation = {
- "name": "fbx",
- "ext": "fbx",
- "files": filename,
- "stagingDir": stagingdir,
- }
- instance.data["representations"].append(representation)
- self.log.info(f"Extracted instance '{instance.name}' to: {filepath}")
diff --git a/openpype/hosts/max/plugins/publish/extract_model_fbx.py b/openpype/hosts/max/plugins/publish/extract_fbx.py
similarity index 67%
rename from openpype/hosts/max/plugins/publish/extract_model_fbx.py
rename to openpype/hosts/max/plugins/publish/extract_fbx.py
index 6c42fd5364..7454cd08d1 100644
--- a/openpype/hosts/max/plugins/publish/extract_model_fbx.py
+++ b/openpype/hosts/max/plugins/publish/extract_fbx.py
@@ -3,6 +3,7 @@ import pyblish.api
from openpype.pipeline import publish, OptionalPyblishPluginMixin
from pymxs import runtime as rt
from openpype.hosts.max.api import maintained_selection
+from openpype.hosts.max.api.lib import convert_unit_scale
class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin):
@@ -23,14 +24,7 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin):
stagingdir = self.staging_dir(instance)
filename = "{name}.fbx".format(**instance.data)
filepath = os.path.join(stagingdir, filename)
-
- rt.FBXExporterSetParam("Animation", False)
- rt.FBXExporterSetParam("Cameras", False)
- rt.FBXExporterSetParam("Lights", False)
- rt.FBXExporterSetParam("PointCache", False)
- rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
- rt.FBXExporterSetParam("UpAxis", "Y")
- rt.FBXExporterSetParam("Preserveinstances", True)
+ self._set_fbx_attributes()
with maintained_selection():
# select and export
@@ -56,3 +50,34 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin):
self.log.info(
"Extracted instance '%s' to: %s" % (instance.name, filepath)
)
+
+ def _set_fbx_attributes(self):
+ unit_scale = convert_unit_scale()
+ rt.FBXExporterSetParam("Animation", False)
+ rt.FBXExporterSetParam("Cameras", False)
+ rt.FBXExporterSetParam("Lights", False)
+ rt.FBXExporterSetParam("PointCache", False)
+ rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
+ rt.FBXExporterSetParam("UpAxis", "Y")
+ rt.FBXExporterSetParam("Preserveinstances", True)
+ if unit_scale:
+ rt.FBXExporterSetParam("ConvertUnit", unit_scale)
+
+
+class ExtractCameraFbx(ExtractModelFbx):
+ """Extract Camera with FbxExporter."""
+
+ order = pyblish.api.ExtractorOrder - 0.2
+ label = "Extract Fbx Camera"
+ families = ["camera"]
+ optional = True
+
+ def _set_fbx_attributes(self):
+ unit_scale = convert_unit_scale()
+ rt.FBXExporterSetParam("Animation", True)
+ rt.FBXExporterSetParam("Cameras", True)
+ rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
+ rt.FBXExporterSetParam("UpAxis", "Y")
+ rt.FBXExporterSetParam("Preserveinstances", True)
+ if unit_scale:
+ rt.FBXExporterSetParam("ConvertUnit", unit_scale)
diff --git a/openpype/hosts/max/plugins/publish/save_scenes_for_cameras.py b/openpype/hosts/max/plugins/publish/save_scenes_for_cameras.py
new file mode 100644
index 0000000000..f089bf663c
--- /dev/null
+++ b/openpype/hosts/max/plugins/publish/save_scenes_for_cameras.py
@@ -0,0 +1,105 @@
+import pyblish.api
+import os
+import sys
+import tempfile
+
+from pymxs import runtime as rt
+from openpype.lib import run_subprocess
+from openpype.hosts.max.api.lib_rendersettings import RenderSettings
+from openpype.hosts.max.api.lib_renderproducts import RenderProducts
+
+
+class SaveScenesForCamera(pyblish.api.InstancePlugin):
+ """Save scene files for multiple cameras without
+ editing the original scene before deadline submission
+
+ """
+
+ label = "Save Scene files for cameras"
+ order = pyblish.api.ExtractorOrder - 0.48
+ hosts = ["max"]
+ families = ["maxrender"]
+
+ def process(self, instance):
+ if not instance.data.get("multiCamera"):
+ self.log.debug(
+ "Multi Camera disabled. "
+ "Skipping to save scene files for cameras")
+ return
+ current_folder = rt.maxFilePath
+ current_filename = rt.maxFileName
+ current_filepath = os.path.join(current_folder, current_filename)
+ camera_scene_files = []
+ scripts = []
+ filename, ext = os.path.splitext(current_filename)
+ fmt = RenderProducts().image_format()
+ cameras = instance.data.get("cameras")
+ if not cameras:
+ return
+ new_folder = f"{current_folder}_{filename}"
+ os.makedirs(new_folder, exist_ok=True)
+ for camera in cameras:
+ new_output = RenderSettings().get_batch_render_output(camera) # noqa
+ new_output = new_output.replace("\\", "/")
+ new_filename = f"{filename}_{camera}{ext}"
+ new_filepath = os.path.join(new_folder, new_filename)
+ new_filepath = new_filepath.replace("\\", "/")
+ camera_scene_files.append(new_filepath)
+ RenderSettings().batch_render_elements(camera)
+ rt.rendOutputFilename = new_output
+ rt.saveMaxFile(current_filepath)
+ script = ("""
+from pymxs import runtime as rt
+import os
+filename = "{filename}"
+new_filepath = "{new_filepath}"
+new_output = "{new_output}"
+camera = "{camera}"
+rt.rendOutputFilename = new_output
+directory = os.path.dirname(rt.rendOutputFilename)
+directory = os.path.join(directory, filename)
+render_elem = rt.maxOps.GetCurRenderElementMgr()
+render_elem_num = render_elem.NumRenderElements()
+if render_elem_num > 0:
+ ext = "{ext}"
+ for i in range(render_elem_num):
+ renderlayer_name = render_elem.GetRenderElement(i)
+ target, renderpass = str(renderlayer_name).split(":")
+ aov_name = f"{{directory}}_{camera}_{{renderpass}}..{ext}"
+ render_elem.SetRenderElementFileName(i, aov_name)
+rt.saveMaxFile(new_filepath)
+ """).format(filename=instance.name,
+ new_filepath=new_filepath,
+ new_output=new_output,
+ camera=camera,
+ ext=fmt)
+ scripts.append(script)
+
+ maxbatch_exe = os.path.join(
+ os.path.dirname(sys.executable), "3dsmaxbatch")
+ maxbatch_exe = maxbatch_exe.replace("\\", "/")
+ if sys.platform == "windows":
+ maxbatch_exe += ".exe"
+ maxbatch_exe = os.path.normpath(maxbatch_exe)
+ with tempfile.TemporaryDirectory() as tmp_dir_name:
+ tmp_script_path = os.path.join(
+ tmp_dir_name, "extract_scene_files.py")
+ self.log.info("Using script file: {}".format(tmp_script_path))
+
+ with open(tmp_script_path, "wt") as tmp:
+ for script in scripts:
+ tmp.write(script + "\n")
+
+ try:
+ current_filepath = current_filepath.replace("\\", "/")
+ tmp_script_path = tmp_script_path.replace("\\", "/")
+ run_subprocess([maxbatch_exe, tmp_script_path,
+ "-sceneFile", current_filepath])
+ except RuntimeError:
+ self.log.debug("Checking the scene files existing")
+
+ for camera_scene in camera_scene_files:
+ if not os.path.exists(camera_scene):
+ self.log.error("Camera scene files not existed yet!")
+ raise RuntimeError("MaxBatch.exe doesn't run as expected")
+ self.log.debug(f"Found Camera scene:{camera_scene}")
diff --git a/openpype/hosts/maya/api/exitstack.py b/openpype/hosts/maya/api/exitstack.py
new file mode 100644
index 0000000000..d151ee16d7
--- /dev/null
+++ b/openpype/hosts/maya/api/exitstack.py
@@ -0,0 +1,139 @@
+"""Backwards compatible implementation of ExitStack for Python 2.
+
+ExitStack contextmanager was implemented with Python 3.3.
+As long as we supportPython 2 hosts we can use this backwards
+compatible implementation to support bothPython 2 and Python 3.
+
+Instead of using ExitStack from contextlib, use it from this module:
+
+>>> from openpype.hosts.maya.api.exitstack import ExitStack
+
+It will provide the appropriate ExitStack implementation for the current
+running Python version.
+
+"""
+# TODO: Remove the entire script once dropping Python 2 support.
+import contextlib
+if getattr(contextlib, "nested", None):
+ from contextlib import ExitStack # noqa
+else:
+ import sys
+ from collections import deque
+
+ class ExitStack(object):
+
+ """Context manager for dynamic management of a stack of exit callbacks
+
+ For example:
+
+ with ExitStack() as stack:
+ files = [stack.enter_context(open(fname))
+ for fname in filenames]
+ # All opened files will automatically be closed at the end of
+ # the with statement, even if attempts to open files later
+ # in the list raise an exception
+
+ """
+ def __init__(self):
+ self._exit_callbacks = deque()
+
+ def pop_all(self):
+ """Preserve the context stack by transferring
+ it to a new instance"""
+ new_stack = type(self)()
+ new_stack._exit_callbacks = self._exit_callbacks
+ self._exit_callbacks = deque()
+ return new_stack
+
+ def _push_cm_exit(self, cm, cm_exit):
+ """Helper to correctly register callbacks
+ to __exit__ methods"""
+ def _exit_wrapper(*exc_details):
+ return cm_exit(cm, *exc_details)
+ _exit_wrapper.__self__ = cm
+ self.push(_exit_wrapper)
+
+ def push(self, exit):
+ """Registers a callback with the standard __exit__ method signature
+
+ Can suppress exceptions the same way __exit__ methods can.
+
+ Also accepts any object with an __exit__ method (registering a call
+ to the method instead of the object itself)
+ """
+ # We use an unbound method rather than a bound method to follow
+ # the standard lookup behaviour for special methods
+ _cb_type = type(exit)
+ try:
+ exit_method = _cb_type.__exit__
+ except AttributeError:
+ # Not a context manager, so assume its a callable
+ self._exit_callbacks.append(exit)
+ else:
+ self._push_cm_exit(exit, exit_method)
+ return exit # Allow use as a decorator
+
+ def callback(self, callback, *args, **kwds):
+ """Registers an arbitrary callback and arguments.
+
+ Cannot suppress exceptions.
+ """
+ def _exit_wrapper(exc_type, exc, tb):
+ callback(*args, **kwds)
+ # We changed the signature, so using @wraps is not appropriate, but
+ # setting __wrapped__ may still help with introspection
+ _exit_wrapper.__wrapped__ = callback
+ self.push(_exit_wrapper)
+ return callback # Allow use as a decorator
+
+ def enter_context(self, cm):
+ """Enters the supplied context manager
+
+ If successful, also pushes its __exit__ method as a callback and
+ returns the result of the __enter__ method.
+ """
+ # We look up the special methods on the type to
+ # match the with statement
+ _cm_type = type(cm)
+ _exit = _cm_type.__exit__
+ result = _cm_type.__enter__(cm)
+ self._push_cm_exit(cm, _exit)
+ return result
+
+ def close(self):
+ """Immediately unwind the context stack"""
+ self.__exit__(None, None, None)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc_details):
+ # We manipulate the exception state so it behaves as though
+ # we were actually nesting multiple with statements
+ frame_exc = sys.exc_info()[1]
+
+ def _fix_exception_context(new_exc, old_exc):
+ while 1:
+ exc_context = new_exc.__context__
+ if exc_context in (None, frame_exc):
+ break
+ new_exc = exc_context
+ new_exc.__context__ = old_exc
+
+ # Callbacks are invoked in LIFO order to match the behaviour of
+ # nested context managers
+ suppressed_exc = False
+ while self._exit_callbacks:
+ cb = self._exit_callbacks.pop()
+ try:
+ if cb(*exc_details):
+ suppressed_exc = True
+ exc_details = (None, None, None)
+ except Exception:
+ new_exc_details = sys.exc_info()
+ # simulate the stack of exceptions by setting the context
+ _fix_exception_context(new_exc_details[1], exc_details[1])
+ if not self._exit_callbacks:
+ raise
+ exc_details = new_exc_details
+ return suppressed_exc
diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py
index af726409d4..da34896c3f 100644
--- a/openpype/hosts/maya/api/lib.py
+++ b/openpype/hosts/maya/api/lib.py
@@ -1,6 +1,7 @@
"""Standalone helper functions"""
import os
+import copy
from pprint import pformat
import sys
import uuid
@@ -9,6 +10,8 @@ import re
import json
import logging
import contextlib
+import capture
+from .exitstack import ExitStack
from collections import OrderedDict, defaultdict
from math import ceil
from six import string_types
@@ -172,6 +175,216 @@ def maintained_selection():
cmds.select(clear=True)
+def reload_all_udim_tile_previews():
+ """Regenerate all UDIM tile preview in texture file"""
+ for texture_file in cmds.ls(type="file"):
+ if cmds.getAttr("{}.uvTilingMode".format(texture_file)) > 0:
+ cmds.ogs(regenerateUVTilePreview=texture_file)
+
+
+@contextlib.contextmanager
+def panel_camera(panel, camera):
+ """Set modelPanel's camera during the context.
+
+ Arguments:
+ panel (str): modelPanel name.
+ camera (str): camera name.
+
+ """
+ original_camera = cmds.modelPanel(panel, query=True, camera=True)
+ try:
+ cmds.modelPanel(panel, edit=True, camera=camera)
+ yield
+ finally:
+ cmds.modelPanel(panel, edit=True, camera=original_camera)
+
+
+def render_capture_preset(preset):
+ """Capture playblast with a preset.
+
+ To generate the preset use `generate_capture_preset`.
+
+ Args:
+ preset (dict): preset options
+
+ Returns:
+ str: Output path of `capture.capture`
+ """
+
+ # Force a refresh at the start of the timeline
+ # TODO (Question): Why do we need to do this? What bug does it solve?
+ # Is this for simulations?
+ cmds.refresh(force=True)
+ refresh_frame_int = int(cmds.playbackOptions(query=True, minTime=True))
+ cmds.currentTime(refresh_frame_int - 1, edit=True)
+ cmds.currentTime(refresh_frame_int, edit=True)
+ log.debug(
+ "Using preset: {}".format(
+ json.dumps(preset, indent=4, sort_keys=True)
+ )
+ )
+ preset = copy.deepcopy(preset)
+ # not supported by `capture` so we pop it off of the preset
+ reload_textures = preset["viewport_options"].pop("loadTextures", False)
+ panel = preset.pop("panel")
+ with ExitStack() as stack:
+ stack.enter_context(maintained_time())
+ stack.enter_context(panel_camera(panel, preset["camera"]))
+ stack.enter_context(viewport_default_options(panel, preset))
+ if reload_textures:
+ # Force immediate texture loading when to ensure
+ # all textures have loaded before the playblast starts
+ stack.enter_context(material_loading_mode(mode="immediate"))
+ # Regenerate all UDIM tiles previews
+ reload_all_udim_tile_previews()
+ path = capture.capture(log=self.log, **preset)
+
+ return path
+
+
+def generate_capture_preset(instance, camera, path,
+ start=None, end=None, capture_preset=None):
+ """Function for getting all the data of preset options for
+ playblast capturing
+
+ Args:
+ instance (pyblish.api.Instance): instance
+ camera (str): review camera
+ path (str): filepath
+ start (int): frameStart
+ end (int): frameEnd
+ capture_preset (dict): capture preset
+
+ Returns:
+ dict: Resulting preset
+ """
+ preset = load_capture_preset(data=capture_preset)
+
+ preset["camera"] = camera
+ preset["start_frame"] = start
+ preset["end_frame"] = end
+ preset["filename"] = path
+ preset["overwrite"] = True
+ preset["panel"] = instance.data["panel"]
+
+ # Disable viewer since we use the rendering logic for publishing
+ # We don't want to open the generated playblast in a viewer directly.
+ preset["viewer"] = False
+
+ # "isolate_view" will already have been applied at creation, so we'll
+ # ignore it here.
+ preset.pop("isolate_view")
+
+ # Set resolution variables from capture presets
+ width_preset = capture_preset["Resolution"]["width"]
+ height_preset = capture_preset["Resolution"]["height"]
+
+ # Set resolution variables from asset values
+ asset_data = instance.data["assetEntity"]["data"]
+ asset_width = asset_data.get("resolutionWidth")
+ asset_height = asset_data.get("resolutionHeight")
+ review_instance_width = instance.data.get("review_width")
+ review_instance_height = instance.data.get("review_height")
+
+ # Use resolution from instance if review width/height is set
+ # Otherwise use the resolution from preset if it has non-zero values
+ # Otherwise fall back to asset width x height
+ # Else define no width, then `capture.capture` will use render resolution
+ if review_instance_width and review_instance_height:
+ preset["width"] = review_instance_width
+ preset["height"] = review_instance_height
+ elif width_preset and height_preset:
+ preset["width"] = width_preset
+ preset["height"] = height_preset
+ elif asset_width and asset_height:
+ preset["width"] = asset_width
+ preset["height"] = asset_height
+
+ # Isolate view is requested by having objects in the set besides a
+ # camera. If there is only 1 member it'll be the camera because we
+ # validate to have 1 camera only.
+ if instance.data["isolate"] and len(instance.data["setMembers"]) > 1:
+ preset["isolate"] = instance.data["setMembers"]
+
+ # Override camera options
+ # Enforce persisting camera depth of field
+ camera_options = preset.setdefault("camera_options", {})
+ camera_options["depthOfField"] = cmds.getAttr(
+ "{0}.depthOfField".format(camera)
+ )
+
+ # Use Pan/Zoom from instance data instead of from preset
+ preset.pop("pan_zoom", None)
+ camera_options["panZoomEnabled"] = instance.data["panZoom"]
+
+ # Override viewport options by instance data
+ viewport_options = preset.setdefault("viewport_options", {})
+ viewport_options["displayLights"] = instance.data["displayLights"]
+ viewport_options["imagePlane"] = instance.data.get("imagePlane", True)
+
+ # Override transparency if requested.
+ transparency = instance.data.get("transparency", 0)
+ if transparency != 0:
+ preset["viewport2_options"]["transparencyAlgorithm"] = transparency
+
+ # Update preset with current panel setting
+ # if override_viewport_options is turned off
+ if not capture_preset["Viewport Options"]["override_viewport_options"]:
+ panel_preset = capture.parse_view(preset["panel"])
+ panel_preset.pop("camera")
+ preset.update(panel_preset)
+
+ return preset
+
+
+@contextlib.contextmanager
+def viewport_default_options(panel, preset):
+ """Context manager used by `render_capture_preset`.
+
+ We need to explicitly enable some viewport changes so the viewport is
+ refreshed ahead of playblasting.
+
+ """
+ # TODO: Clarify in the docstring WHY we need to set it ahead of
+ # playblasting. What issues does it solve?
+ viewport_defaults = {}
+ try:
+ keys = [
+ "useDefaultMaterial",
+ "wireframeOnShaded",
+ "xray",
+ "jointXray",
+ "backfaceCulling",
+ "textures"
+ ]
+ for key in keys:
+ viewport_defaults[key] = cmds.modelEditor(
+ panel, query=True, **{key: True}
+ )
+ if preset["viewport_options"].get(key):
+ cmds.modelEditor(
+ panel, edit=True, **{key: True}
+ )
+ yield
+ finally:
+ # Restoring viewport options.
+ if viewport_defaults:
+ cmds.modelEditor(
+ panel, edit=True, **viewport_defaults
+ )
+
+
+@contextlib.contextmanager
+def material_loading_mode(mode="immediate"):
+ """Set material loading mode during context"""
+ original = cmds.displayPref(query=True, materialLoadingMode=True)
+ cmds.displayPref(materialLoadingMode=mode)
+ try:
+ yield
+ finally:
+ cmds.displayPref(materialLoadingMode=original)
+
+
def get_namespace(node):
"""Return namespace of given node"""
node_name = node.rsplit("|", 1)[-1]
@@ -2565,9 +2778,37 @@ def bake_to_world_space(nodes,
list: The newly created and baked node names.
"""
+ @contextlib.contextmanager
+ def _unlock_attr(attr):
+ """Unlock attribute during context if it is locked"""
+ if not cmds.getAttr(attr, lock=True):
+ # If not locked, do nothing
+ yield
+ return
+ try:
+ cmds.setAttr(attr, lock=False)
+ yield
+ finally:
+ cmds.setAttr(attr, lock=True)
def _get_attrs(node):
- """Workaround for buggy shape attribute listing with listAttr"""
+ """Workaround for buggy shape attribute listing with listAttr
+
+ This will only return keyable settable attributes that have an
+ incoming connections (those that have a reason to be baked).
+
+ Technically this *may* fail to return attributes driven by complex
+ expressions for which maya makes no connections, e.g. doing actual
+ `setAttr` calls in expressions.
+
+ Arguments:
+ node (str): The node to list attributes for.
+
+ Returns:
+ list: Keyable attributes with incoming connections.
+ The attribute may be locked.
+
+ """
attrs = cmds.listAttr(node,
write=True,
scalar=True,
@@ -2592,14 +2833,14 @@ def bake_to_world_space(nodes,
return valid_attrs
- transform_attrs = set(["t", "r", "s",
- "tx", "ty", "tz",
- "rx", "ry", "rz",
- "sx", "sy", "sz"])
+ transform_attrs = {"t", "r", "s",
+ "tx", "ty", "tz",
+ "rx", "ry", "rz",
+ "sx", "sy", "sz"}
world_space_nodes = []
- with delete_after() as delete_bin:
-
+ with ExitStack() as stack:
+ delete_bin = stack.enter_context(delete_after())
# Create the duplicate nodes that are in world-space connected to
# the originals
for node in nodes:
@@ -2611,23 +2852,26 @@ def bake_to_world_space(nodes,
name=new_name,
renameChildren=True)[0] # noqa
- # Connect all attributes on the node except for transform
- # attributes
- attrs = _get_attrs(node)
- attrs = set(attrs) - transform_attrs if attrs else []
+ # Parent new node to world
+ if cmds.listRelatives(new_node, parent=True):
+ new_node = cmds.parent(new_node, world=True)[0]
+ # Temporarily unlock and passthrough connect all attributes
+ # so we can bake them over time
+ # Skip transform attributes because we will constrain them later
+ attrs = set(_get_attrs(node)) - transform_attrs
for attr in attrs:
- orig_node_attr = '{0}.{1}'.format(node, attr)
- new_node_attr = '{0}.{1}'.format(new_node, attr)
-
- # unlock to avoid connection errors
- cmds.setAttr(new_node_attr, lock=False)
+ orig_node_attr = "{}.{}".format(node, attr)
+ new_node_attr = "{}.{}".format(new_node, attr)
+ # unlock during context to avoid connection errors
+ stack.enter_context(_unlock_attr(new_node_attr))
cmds.connectAttr(orig_node_attr,
new_node_attr,
force=True)
- # If shapes are also baked then connect those keyable attributes
+ # If shapes are also baked then also temporarily unlock and
+ # passthrough connect all shape attributes for baking
if shape:
children_shapes = cmds.listRelatives(new_node,
children=True,
@@ -2642,25 +2886,19 @@ def bake_to_world_space(nodes,
children_shapes):
attrs = _get_attrs(orig_shape)
for attr in attrs:
- orig_node_attr = '{0}.{1}'.format(orig_shape, attr)
- new_node_attr = '{0}.{1}'.format(new_shape, attr)
-
- # unlock to avoid connection errors
- cmds.setAttr(new_node_attr, lock=False)
+ orig_node_attr = "{}.{}".format(orig_shape, attr)
+ new_node_attr = "{}.{}".format(new_shape, attr)
+ # unlock during context to avoid connection errors
+ stack.enter_context(_unlock_attr(new_node_attr))
cmds.connectAttr(orig_node_attr,
new_node_attr,
force=True)
- # Parent to world
- if cmds.listRelatives(new_node, parent=True):
- new_node = cmds.parent(new_node, world=True)[0]
-
- # Unlock transform attributes so constraint can be created
+ # Constraint transforms
for attr in transform_attrs:
- cmds.setAttr('{0}.{1}'.format(new_node, attr), lock=False)
-
- # Constraints
+ transform_attr = "{}.{}".format(new_node, attr)
+ stack.enter_context(_unlock_attr(transform_attr))
delete_bin.extend(cmds.parentConstraint(node, new_node, mo=False))
delete_bin.extend(cmds.scaleConstraint(node, new_node, mo=False))
@@ -2677,7 +2915,7 @@ def bake_to_world_space(nodes,
return world_space_nodes
-def load_capture_preset(data=None):
+def load_capture_preset(data):
"""Convert OpenPype Extract Playblast settings to `capture` arguments
Input data is the settings from:
@@ -2691,8 +2929,6 @@ def load_capture_preset(data=None):
"""
- import capture
-
options = dict()
viewport_options = dict()
viewport2_options = dict()
diff --git a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py
index b3fbfb2ed9..40385f34d6 100644
--- a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py
+++ b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py
@@ -137,6 +137,11 @@ class RedshiftProxyLoader(load.LoaderPlugin):
cmds.connectAttr("{}.outMesh".format(rs_mesh),
"{}.inMesh".format(mesh_shape))
+ # TODO: use the assigned shading group as shaders if existed
+ # assign default shader to redshift proxy
+ if cmds.ls("initialShadingGroup", type="shadingEngine"):
+ cmds.sets(mesh_shape, forceElement="initialShadingGroup")
+
group_node = cmds.group(empty=True, name="{}_GRP".format(name))
mesh_transform = cmds.listRelatives(mesh_shape,
parent=True, fullPath=True)
diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py b/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py
index df761cde13..f82f7b69cd 100644
--- a/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py
+++ b/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py
@@ -6,6 +6,7 @@ from maya import cmds
import pyblish.api
from openpype.hosts.maya.api import lib
+from openpype.pipeline.publish import KnownPublishError
SETTINGS = {"renderDensity",
@@ -116,7 +117,6 @@ class CollectYetiRig(pyblish.api.InstancePlugin):
resources = []
image_search_paths = cmds.getAttr("{}.imageSearchPath".format(node))
- texture_filenames = []
if image_search_paths:
# TODO: Somehow this uses OS environment path separator, `:` vs `;`
@@ -127,9 +127,16 @@ class CollectYetiRig(pyblish.api.InstancePlugin):
# find all ${TOKEN} tokens and replace them with $TOKEN env. variable
image_search_paths = self._replace_tokens(image_search_paths)
- # List all related textures
- texture_filenames = cmds.pgYetiCommand(node, listTextures=True)
- self.log.debug("Found %i texture(s)" % len(texture_filenames))
+ # List all related textures
+ texture_nodes = cmds.pgYetiGraph(
+ node, listNodes=True, type="texture")
+ texture_filenames = [
+ cmds.pgYetiGraph(
+ node, node=texture_node,
+ param="file_name", getParamValue=True)
+ for texture_node in texture_nodes
+ ]
+ self.log.debug("Found %i texture(s)" % len(texture_filenames))
# Get all reference nodes
reference_nodes = cmds.pgYetiGraph(node,
@@ -137,11 +144,6 @@ class CollectYetiRig(pyblish.api.InstancePlugin):
type="reference")
self.log.debug("Found %i reference node(s)" % len(reference_nodes))
- if texture_filenames and not image_search_paths:
- raise ValueError("pgYetiMaya node '%s' is missing the path to the "
- "files in the 'imageSearchPath "
- "atttribute'" % node)
-
# Collect all texture files
# find all ${TOKEN} tokens and replace them with $TOKEN env. variable
texture_filenames = self._replace_tokens(texture_filenames)
@@ -161,7 +163,7 @@ class CollectYetiRig(pyblish.api.InstancePlugin):
break
if not files:
- self.log.warning(
+ raise KnownPublishError(
"No texture found for: %s "
"(searched: %s)" % (texture, image_search_paths))
diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py
index 38cf00bbdd..f67e9db14f 100644
--- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py
+++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py
@@ -265,13 +265,16 @@ def transfer_image_planes(source_cameras, target_cameras,
try:
for source_camera, target_camera in zip(source_cameras,
target_cameras):
- image_planes = cmds.listConnections(source_camera,
+ image_plane_plug = "{}.imagePlane".format(source_camera)
+ image_planes = cmds.listConnections(image_plane_plug,
+ source=True,
+ destination=False,
type="imagePlane") or []
# Split of the parent path they are attached - we want
- # the image plane node name.
+ # the image plane node name if attached to a camera.
# TODO: Does this still mean the image plane name is unique?
- image_planes = [x.split("->", 1)[1] for x in image_planes]
+ image_planes = [x.split("->", 1)[-1] for x in image_planes]
if not image_planes:
continue
@@ -282,7 +285,7 @@ def transfer_image_planes(source_cameras, target_cameras,
if source_camera == target_camera:
continue
_attach_image_plane(target_camera, image_plane)
- else: # explicitly dettaching image planes
+ else: # explicitly detach image planes
cmds.imagePlane(image_plane, edit=True, detach=True)
originals[source_camera].append(image_plane)
yield
diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py
index ab170fe48c..a4f313bdf9 100644
--- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py
+++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py
@@ -6,9 +6,11 @@ from maya import cmds
from openpype.hosts.maya.api.lib import maintained_selection
from openpype.pipeline import AVALON_CONTAINER_ID, publish
+from openpype.pipeline.publish import OpenPypePyblishPluginMixin
+from openpype.lib import BoolDef
-class ExtractMayaSceneRaw(publish.Extractor):
+class ExtractMayaSceneRaw(publish.Extractor, OpenPypePyblishPluginMixin):
"""Extract as Maya Scene (raw).
This will preserve all references, construction history, etc.
@@ -23,6 +25,22 @@ class ExtractMayaSceneRaw(publish.Extractor):
"camerarig"]
scene_type = "ma"
+ @classmethod
+ def get_attribute_defs(cls):
+ return [
+ BoolDef(
+ "preserve_references",
+ label="Preserve References",
+ tooltip=(
+ "When enabled references will still be references "
+ "in the published file.\nWhen disabled the references "
+ "are imported into the published file generating a "
+ "file without references."
+ ),
+ default=True
+ )
+ ]
+
def process(self, instance):
"""Plugin entry point."""
ext_mapping = (
@@ -64,13 +82,18 @@ class ExtractMayaSceneRaw(publish.Extractor):
# Perform extraction
self.log.debug("Performing extraction ...")
+ attribute_values = self.get_attr_values_from_data(
+ instance.data
+ )
with maintained_selection():
cmds.select(selection, noExpand=True)
cmds.file(path,
force=True,
typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501
exportSelected=True,
- preserveReferences=True,
+ preserveReferences=attribute_values[
+ "preserve_references"
+ ],
constructionHistory=True,
shader=True,
constraints=True,
diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py
index cfab239da3..507229a7b3 100644
--- a/openpype/hosts/maya/plugins/publish/extract_playblast.py
+++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py
@@ -1,9 +1,6 @@
import os
-import json
-import contextlib
import clique
-import capture
from openpype.pipeline import publish
from openpype.hosts.maya.api import lib
@@ -11,16 +8,6 @@ from openpype.hosts.maya.api import lib
from maya import cmds
-@contextlib.contextmanager
-def panel_camera(panel, camera):
- original_camera = cmds.modelPanel(panel, query=True, camera=True)
- try:
- cmds.modelPanel(panel, edit=True, camera=camera)
- yield
- finally:
- cmds.modelPanel(panel, edit=True, camera=original_camera)
-
-
class ExtractPlayblast(publish.Extractor):
"""Extract viewport playblast.
@@ -36,19 +23,8 @@ class ExtractPlayblast(publish.Extractor):
capture_preset = {}
profiles = None
- def _capture(self, preset):
- if os.environ.get("OPENPYPE_DEBUG") == "1":
- self.log.debug(
- "Using preset: {}".format(
- json.dumps(preset, indent=4, sort_keys=True)
- )
- )
-
- path = capture.capture(log=self.log, **preset)
- self.log.debug("playblast path {}".format(path))
-
def process(self, instance):
- self.log.debug("Extracting capture..")
+ self.log.debug("Extracting playblast..")
# get scene fps
fps = instance.data.get("fps") or instance.context.data.get("fps")
@@ -63,10 +39,6 @@ class ExtractPlayblast(publish.Extractor):
end = cmds.playbackOptions(query=True, animationEndTime=True)
self.log.debug("start: {}, end: {}".format(start, end))
-
- # get cameras
- camera = instance.data["review_camera"]
-
task_data = instance.data["anatomyData"].get("task", {})
capture_preset = lib.get_capture_preset(
task_data.get("name"),
@@ -75,174 +47,35 @@ class ExtractPlayblast(publish.Extractor):
instance.context.data["project_settings"],
self.log
)
-
- preset = lib.load_capture_preset(data=capture_preset)
-
- # "isolate_view" will already have been applied at creation, so we'll
- # ignore it here.
- preset.pop("isolate_view")
-
- # Set resolution variables from capture presets
- width_preset = capture_preset["Resolution"]["width"]
- height_preset = capture_preset["Resolution"]["height"]
-
- # Set resolution variables from asset values
- asset_data = instance.data["assetEntity"]["data"]
- asset_width = asset_data.get("resolutionWidth")
- asset_height = asset_data.get("resolutionHeight")
- review_instance_width = instance.data.get("review_width")
- review_instance_height = instance.data.get("review_height")
- preset["camera"] = camera
-
- # Tests if project resolution is set,
- # if it is a value other than zero, that value is
- # used, if not then the asset resolution is
- # used
- if review_instance_width and review_instance_height:
- preset["width"] = review_instance_width
- preset["height"] = review_instance_height
- elif width_preset and height_preset:
- preset["width"] = width_preset
- preset["height"] = height_preset
- elif asset_width and asset_height:
- preset["width"] = asset_width
- preset["height"] = asset_height
- preset["start_frame"] = start
- preset["end_frame"] = end
-
- # Enforce persisting camera depth of field
- camera_options = preset.setdefault("camera_options", {})
- camera_options["depthOfField"] = cmds.getAttr(
- "{0}.depthOfField".format(camera))
-
stagingdir = self.staging_dir(instance)
- filename = "{0}".format(instance.name)
+ filename = instance.name
path = os.path.join(stagingdir, filename)
-
self.log.debug("Outputting images to %s" % path)
+ # get cameras
+ camera = instance.data["review_camera"]
+ preset = lib.generate_capture_preset(
+ instance, camera, path,
+ start=start, end=end,
+ capture_preset=capture_preset)
+ lib.render_capture_preset(preset)
- preset["filename"] = path
- preset["overwrite"] = True
-
- cmds.refresh(force=True)
-
- refreshFrameInt = int(cmds.playbackOptions(q=True, minTime=True))
- cmds.currentTime(refreshFrameInt - 1, edit=True)
- cmds.currentTime(refreshFrameInt, edit=True)
-
- # Use displayLights setting from instance
- key = "displayLights"
- preset["viewport_options"][key] = instance.data[key]
-
- # Override transparency if requested.
- transparency = instance.data.get("transparency", 0)
- if transparency != 0:
- preset["viewport2_options"]["transparencyAlgorithm"] = transparency
-
- # Isolate view is requested by having objects in the set besides a
- # camera. If there is only 1 member it'll be the camera because we
- # validate to have 1 camera only.
- if instance.data["isolate"] and len(instance.data["setMembers"]) > 1:
- preset["isolate"] = instance.data["setMembers"]
-
- # Show/Hide image planes on request.
- image_plane = instance.data.get("imagePlane", True)
- if "viewport_options" in preset:
- preset["viewport_options"]["imagePlane"] = image_plane
- else:
- preset["viewport_options"] = {"imagePlane": image_plane}
-
- # Disable Pan/Zoom.
- pan_zoom = cmds.getAttr("{}.panZoomEnabled".format(preset["camera"]))
- preset.pop("pan_zoom", None)
- preset["camera_options"]["panZoomEnabled"] = instance.data["panZoom"]
-
- # Need to explicitly enable some viewport changes so the viewport is
- # refreshed ahead of playblasting.
- keys = [
- "useDefaultMaterial",
- "wireframeOnShaded",
- "xray",
- "jointXray",
- "backfaceCulling"
- ]
- viewport_defaults = {}
- for key in keys:
- viewport_defaults[key] = cmds.modelEditor(
- instance.data["panel"], query=True, **{key: True}
- )
- if preset["viewport_options"][key]:
- cmds.modelEditor(
- instance.data["panel"], edit=True, **{key: True}
- )
-
- override_viewport_options = (
- capture_preset["Viewport Options"]["override_viewport_options"]
- )
-
- # Force viewer to False in call to capture because we have our own
- # viewer opening call to allow a signal to trigger between
- # playblast and viewer
- preset["viewer"] = False
-
- # Update preset with current panel setting
- # if override_viewport_options is turned off
- if not override_viewport_options:
- panel_preset = capture.parse_view(instance.data["panel"])
- panel_preset.pop("camera")
- preset.update(panel_preset)
-
- # Need to ensure Python 2 compatibility.
- # TODO: Remove once dropping Python 2.
- if getattr(contextlib, "nested", None):
- # Python 3 compatibility.
- with contextlib.nested(
- lib.maintained_time(),
- panel_camera(instance.data["panel"], preset["camera"])
- ):
- self._capture(preset)
- else:
- # Python 2 compatibility.
- with contextlib.ExitStack() as stack:
- stack.enter_context(lib.maintained_time())
- stack.enter_context(
- panel_camera(instance.data["panel"], preset["camera"])
- )
-
- self._capture(preset)
-
- # Restoring viewport options.
- if viewport_defaults:
- cmds.modelEditor(
- instance.data["panel"], edit=True, **viewport_defaults
- )
-
- try:
- cmds.setAttr(
- "{}.panZoomEnabled".format(preset["camera"]), pan_zoom)
- except RuntimeError:
- self.log.warning("Cannot restore Pan/Zoom settings.")
-
+ # Find playblast sequence
collected_files = os.listdir(stagingdir)
patterns = [clique.PATTERNS["frames"]]
collections, remainder = clique.assemble(collected_files,
minimum_items=1,
patterns=patterns)
- filename = preset.get("filename", "%TEMP%")
- self.log.debug("filename {}".format(filename))
+ self.log.debug("Searching playblast collection for: %s", path)
frame_collection = None
for collection in collections:
filebase = collection.format("{head}").rstrip(".")
- self.log.debug("collection head {}".format(filebase))
- if filebase in filename:
+ self.log.debug("Checking collection head: %s", filebase)
+ if filebase in path:
frame_collection = collection
self.log.debug(
- "we found collection of interest {}".format(
- str(frame_collection)))
-
- if "representations" not in instance.data:
- instance.data["representations"] = []
+ "Found playblast collection: %s", frame_collection
+ )
tags = ["review"]
if not instance.data.get("keepImages"):
@@ -256,6 +89,9 @@ class ExtractPlayblast(publish.Extractor):
if len(collected_files) == 1:
collected_files = collected_files[0]
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
representation = {
"name": capture_preset["Codec"]["compression"],
"ext": capture_preset["Codec"]["compression"],
diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py
index c0be3d77db..28362b355c 100644
--- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py
+++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py
@@ -1,15 +1,10 @@
import os
import glob
import tempfile
-import json
-
-import capture
from openpype.pipeline import publish
from openpype.hosts.maya.api import lib
-from maya import cmds
-
class ExtractThumbnail(publish.Extractor):
"""Extract viewport thumbnail.
@@ -24,7 +19,7 @@ class ExtractThumbnail(publish.Extractor):
families = ["review"]
def process(self, instance):
- self.log.debug("Extracting capture..")
+ self.log.debug("Extracting thumbnail..")
camera = instance.data["review_camera"]
@@ -37,20 +32,24 @@ class ExtractThumbnail(publish.Extractor):
self.log
)
- preset = lib.load_capture_preset(data=capture_preset)
-
- # "isolate_view" will already have been applied at creation, so we'll
- # ignore it here.
- preset.pop("isolate_view")
-
- override_viewport_options = (
- capture_preset["Viewport Options"]["override_viewport_options"]
+ # Create temp directory for thumbnail
+ # - this is to avoid "override" of source file
+ dst_staging = tempfile.mkdtemp(prefix="pyblish_tmp_thumbnail")
+ self.log.debug(
+ "Create temp directory {} for thumbnail".format(dst_staging)
)
+ # Store new staging to cleanup paths
+ filename = instance.name
+ path = os.path.join(dst_staging, filename)
- preset["camera"] = camera
- preset["start_frame"] = instance.data["frameStart"]
- preset["end_frame"] = instance.data["frameStart"]
- preset["camera_options"] = {
+ self.log.debug("Outputting images to %s" % path)
+
+ preset = lib.generate_capture_preset(
+ instance, camera, path,
+ start=1, end=1,
+ capture_preset=capture_preset)
+
+ preset["camera_options"].update({
"displayGateMask": False,
"displayResolution": False,
"displayFilmGate": False,
@@ -60,101 +59,10 @@ class ExtractThumbnail(publish.Extractor):
"displayFilmPivot": False,
"displayFilmOrigin": False,
"overscan": 1.0,
- "depthOfField": cmds.getAttr("{0}.depthOfField".format(camera)),
- }
- # Set resolution variables from capture presets
- width_preset = capture_preset["Resolution"]["width"]
- height_preset = capture_preset["Resolution"]["height"]
- # Set resolution variables from asset values
- asset_data = instance.data["assetEntity"]["data"]
- asset_width = asset_data.get("resolutionWidth")
- asset_height = asset_data.get("resolutionHeight")
- review_instance_width = instance.data.get("review_width")
- review_instance_height = instance.data.get("review_height")
- # Tests if project resolution is set,
- # if it is a value other than zero, that value is
- # used, if not then the asset resolution is
- # used
- if review_instance_width and review_instance_height:
- preset["width"] = review_instance_width
- preset["height"] = review_instance_height
- elif width_preset and height_preset:
- preset["width"] = width_preset
- preset["height"] = height_preset
- elif asset_width and asset_height:
- preset["width"] = asset_width
- preset["height"] = asset_height
+ })
+ path = lib.render_capture_preset(preset)
- # Create temp directory for thumbnail
- # - this is to avoid "override" of source file
- dst_staging = tempfile.mkdtemp(prefix="pyblish_tmp_")
- self.log.debug(
- "Create temp directory {} for thumbnail".format(dst_staging)
- )
- # Store new staging to cleanup paths
- filename = "{0}".format(instance.name)
- path = os.path.join(dst_staging, filename)
-
- self.log.debug("Outputting images to %s" % path)
-
- preset["filename"] = path
- preset["overwrite"] = True
-
- cmds.refresh(force=True)
-
- refreshFrameInt = int(cmds.playbackOptions(q=True, minTime=True))
- cmds.currentTime(refreshFrameInt - 1, edit=True)
- cmds.currentTime(refreshFrameInt, edit=True)
-
- # Use displayLights setting from instance
- key = "displayLights"
- preset["viewport_options"][key] = instance.data[key]
-
- # Override transparency if requested.
- transparency = instance.data.get("transparency", 0)
- if transparency != 0:
- preset["viewport2_options"]["transparencyAlgorithm"] = transparency
-
- # Isolate view is requested by having objects in the set besides a
- # camera. If there is only 1 member it'll be the camera because we
- # validate to have 1 camera only.
- if instance.data["isolate"] and len(instance.data["setMembers"]) > 1:
- preset["isolate"] = instance.data["setMembers"]
-
- # Show or Hide Image Plane
- image_plane = instance.data.get("imagePlane", True)
- if "viewport_options" in preset:
- preset["viewport_options"]["imagePlane"] = image_plane
- else:
- preset["viewport_options"] = {"imagePlane": image_plane}
-
- # Disable Pan/Zoom.
- preset.pop("pan_zoom", None)
- preset["camera_options"]["panZoomEnabled"] = instance.data["panZoom"]
-
- with lib.maintained_time():
- # Force viewer to False in call to capture because we have our own
- # viewer opening call to allow a signal to trigger between
- # playblast and viewer
- preset["viewer"] = False
-
- # Update preset with current panel setting
- # if override_viewport_options is turned off
- panel = cmds.getPanel(withFocus=True) or ""
- if not override_viewport_options and "modelPanel" in panel:
- panel_preset = capture.parse_active_view()
- preset.update(panel_preset)
- cmds.setFocus(panel)
-
- if os.environ.get("OPENPYPE_DEBUG") == "1":
- self.log.debug(
- "Using preset: {}".format(
- json.dumps(preset, indent=4, sort_keys=True)
- )
- )
-
- path = capture.capture(**preset)
- playblast = self._fix_playblast_output_path(path)
+ playblast = self._fix_playblast_output_path(path)
_, thumbnail = os.path.split(playblast)
diff --git a/openpype/hosts/maya/plugins/publish/validate_look_members_unique.py b/openpype/hosts/maya/plugins/publish/validate_look_members_unique.py
deleted file mode 100644
index 4e01b55249..0000000000
--- a/openpype/hosts/maya/plugins/publish/validate_look_members_unique.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from collections import defaultdict
-
-import pyblish.api
-
-import openpype.hosts.maya.api.action
-from openpype.pipeline.publish import (
- PublishValidationError, ValidatePipelineOrder)
-
-
-class ValidateUniqueRelationshipMembers(pyblish.api.InstancePlugin):
- """Validate the relational nodes of the look data to ensure every node is
- unique.
-
- This ensures the all member ids are unique. Every node id must be from
- a single node in the scene.
-
- That means there's only ever one of a specific node inside the look to be
- published. For example if you'd have a loaded 3x the same tree and by
- accident you're trying to publish them all together in a single look that
- would be invalid, because they are the same tree. It should be included
- inside the look instance only once.
-
- """
-
- order = ValidatePipelineOrder
- label = 'Look members unique'
- hosts = ['maya']
- families = ['look']
-
- actions = [openpype.hosts.maya.api.action.SelectInvalidAction,
- openpype.hosts.maya.api.action.GenerateUUIDsOnInvalidAction]
-
- def process(self, instance):
- """Process all meshes"""
-
- invalid = self.get_invalid(instance)
- if invalid:
- raise PublishValidationError(
- ("Members found without non-unique IDs: "
- "{0}").format(invalid))
-
- @staticmethod
- def get_invalid(instance):
- """
- Check all the relationship members of the objectSets
-
- Example of the lookData relationships:
- {"uuid": 59b2bb27bda2cb2776206dd8:79ab0a63ffdf,
- "members":[{"uuid": 59b2bb27bda2cb2776206dd8:1b158cc7496e,
- "name": |model_GRP|body_GES|body_GESShape}
- ...,
- ...]}
-
- Args:
- instance:
-
- Returns:
-
- """
-
- # Get all members from the sets
- id_nodes = defaultdict(set)
- relationships = instance.data["lookData"]["relationships"]
-
- for relationship in relationships.values():
- for member in relationship['members']:
- node_id = member["uuid"]
- node = member["name"]
- id_nodes[node_id].add(node)
-
- # Check if any id has more than 1 node
- invalid = []
- for nodes in id_nodes.values():
- if len(nodes) > 1:
- invalid.extend(nodes)
-
- return invalid
diff --git a/openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py b/openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py
index b48d67e416..ddcbab8931 100644
--- a/openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py
+++ b/openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py
@@ -44,4 +44,8 @@ class ValidateSceneSetWorkspace(pyblish.api.ContextPlugin):
if not is_subdir(scene_name, root_dir):
raise PublishValidationError(
- "Maya workspace is not set correctly.")
+ "Maya workspace is not set correctly.\n\n"
+ f"Current workfile `{scene_name}` is not inside the "
+ "current Maya project root directory `{root_dir}`.\n\n"
+ "Please use Workfile app to re-save."
+ )
diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py
index 88c587faf6..7ba53caead 100644
--- a/openpype/hosts/nuke/api/lib.py
+++ b/openpype/hosts/nuke/api/lib.py
@@ -3483,3 +3483,19 @@ def get_filenames_without_hash(filename, frame_start, frame_end):
new_filename = filename_without_hashes.format(frame)
filenames.append(new_filename)
return filenames
+
+
+def create_camera_node_by_version():
+ """Function to create the camera with the latest node class
+ For Nuke version 14.0 or later, the Camera4 camera node class
+ would be used
+ For the version before, the Camera2 camera node class
+ would be used
+ Returns:
+ Node: camera node
+ """
+ nuke_number_version = nuke.NUKE_VERSION_MAJOR
+ if nuke_number_version >= 14:
+ return nuke.createNode("Camera4")
+ else:
+ return nuke.createNode("Camera2")
diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py
index 12562a6b6f..c2fc684c21 100644
--- a/openpype/hosts/nuke/api/pipeline.py
+++ b/openpype/hosts/nuke/api/pipeline.py
@@ -259,9 +259,7 @@ def _install_menu():
menu.addCommand(
"Create...",
lambda: host_tools.show_publisher(
- parent=(
- main_window if nuke.NUKE_VERSION_MAJOR >= 14 else None
- ),
+ parent=main_window,
tab="create"
)
)
@@ -270,9 +268,7 @@ def _install_menu():
menu.addCommand(
"Publish...",
lambda: host_tools.show_publisher(
- parent=(
- main_window if nuke.NUKE_VERSION_MAJOR >= 14 else None
- ),
+ parent=main_window,
tab="publish"
)
)
diff --git a/openpype/hosts/nuke/api/utils.py b/openpype/hosts/nuke/api/utils.py
index 7b02585892..a7df1dee71 100644
--- a/openpype/hosts/nuke/api/utils.py
+++ b/openpype/hosts/nuke/api/utils.py
@@ -12,7 +12,7 @@ def set_context_favorites(favorites=None):
favorites (dict): couples of {name:path}
"""
favorites = favorites or {}
- icon_path = resources.get_resource("icons", "folder-favorite3.png")
+ icon_path = resources.get_resource("icons", "folder-favorite.png")
for name, path in favorites.items():
nuke.addFavoriteDir(
name,
diff --git a/openpype/hosts/nuke/plugins/create/create_camera.py b/openpype/hosts/nuke/plugins/create/create_camera.py
index b84280b11b..be9c69213e 100644
--- a/openpype/hosts/nuke/plugins/create/create_camera.py
+++ b/openpype/hosts/nuke/plugins/create/create_camera.py
@@ -4,6 +4,9 @@ from openpype.hosts.nuke.api import (
NukeCreatorError,
maintained_selection
)
+from openpype.hosts.nuke.api.lib import (
+ create_camera_node_by_version
+)
class CreateCamera(NukeCreator):
@@ -32,7 +35,7 @@ class CreateCamera(NukeCreator):
"Creator error: Select only camera node type")
created_node = self.selected_nodes[0]
else:
- created_node = nuke.createNode("Camera2")
+ created_node = create_camera_node_by_version()
created_node["tile_color"].setValue(
int(self.node_color, 16))
diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py
index 3ee166eb56..a02a807206 100644
--- a/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py
+++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py
@@ -34,6 +34,11 @@ class ExtractReviewIntermediates(publish.Extractor):
nuke_publish = project_settings["nuke"]["publish"]
deprecated_setting = nuke_publish["ExtractReviewDataMov"]
current_setting = nuke_publish.get("ExtractReviewIntermediates")
+ if not deprecated_setting["enabled"] and (
+ not current_setting["enabled"]
+ ):
+ cls.enabled = False
+
if deprecated_setting["enabled"]:
# Use deprecated settings if they are still enabled
cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"]
diff --git a/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py b/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py
index b57cf4c5a2..252683b6c8 100644
--- a/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py
+++ b/openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py
@@ -80,6 +80,7 @@ class ValidateOutputMaps(pyblish.api.InstancePlugin):
self.log.warning(f"Disabling texture instance: "
f"{image_instance}")
image_instance.data["active"] = False
+ image_instance.data["publish"] = False
image_instance.data["integrate"] = False
representation.setdefault("tags", []).append("delete")
continue
diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py
index 3fa3c3b8c8..d6e35f4d75 100644
--- a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py
+++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py
@@ -216,6 +216,11 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin):
instance.data["thumbnailSource"] = first_filepath
review_representation["tags"].append("review")
+
+ # Adding "review" to representation name since it can clash with main
+ # representation if they share the same extension.
+ review_representation["outputName"] = "review"
+
self.log.debug("Representation {} was marked for review. {}".format(
review_representation["name"], review_path
))
diff --git a/openpype/lib/events.py b/openpype/lib/events.py
index 496b765a05..774790b80a 100644
--- a/openpype/lib/events.py
+++ b/openpype/lib/events.py
@@ -16,6 +16,113 @@ class MissingEventSystem(Exception):
pass
+def _get_func_ref(func):
+ if inspect.ismethod(func):
+ return WeakMethod(func)
+ return weakref.ref(func)
+
+
+def _get_func_info(func):
+ path = ""
+ if func is None:
+ return "", path
+
+ if hasattr(func, "__name__"):
+ name = func.__name__
+ else:
+ name = str(func)
+
+ # Get path to file and fallback to '' if fails
+ # NOTE This was added because of 'partial' functions which is handled,
+ # but who knows what else can cause this to fail?
+ try:
+ path = os.path.abspath(inspect.getfile(func))
+ except TypeError:
+ pass
+
+ return name, path
+
+
+class weakref_partial:
+ """Partial function with weak reference to the wrapped function.
+
+ Can be used as 'functools.partial' but it will store weak reference to
+ function. That means that the function must be reference counted
+ to avoid garbage collecting the function itself.
+
+ When the referenced functions is garbage collected then calling the
+ weakref partial (no matter the args/kwargs passed) will do nothing.
+ It will fail silently, returning `None`. The `is_valid()` method can
+ be used to detect whether the reference is still valid.
+
+ Is useful for object methods. In that case the callback is
+ deregistered when object is destroyed.
+
+ Warnings:
+ Values passed as *args and **kwargs are stored strongly in memory.
+ That may "keep alive" objects that should be already destroyed.
+ It is recommended to pass only immutable objects like 'str',
+ 'bool', 'int' etc.
+
+ Args:
+ func (Callable): Function to wrap.
+ *args: Arguments passed to the wrapped function.
+ **kwargs: Keyword arguments passed to the wrapped function.
+ """
+
+ def __init__(self, func, *args, **kwargs):
+ self._func_ref = _get_func_ref(func)
+ self._args = args
+ self._kwargs = kwargs
+
+ def __call__(self, *args, **kwargs):
+ func = self._func_ref()
+ if func is None:
+ return
+
+ new_args = tuple(list(self._args) + list(args))
+ new_kwargs = dict(self._kwargs)
+ new_kwargs.update(kwargs)
+ return func(*new_args, **new_kwargs)
+
+ def get_func(self):
+ """Get wrapped function.
+
+ Returns:
+ Union[Callable, None]: Wrapped function or None if it was
+ destroyed.
+ """
+
+ return self._func_ref()
+
+ def is_valid(self):
+ """Check if wrapped function is still valid.
+
+ Returns:
+ bool: Is wrapped function still valid.
+ """
+
+ return self._func_ref() is not None
+
+ def validate_signature(self, *args, **kwargs):
+ """Validate if passed arguments are supported by wrapped function.
+
+ Returns:
+ bool: Are passed arguments supported by wrapped function.
+ """
+
+ func = self._func_ref()
+ if func is None:
+ return False
+
+ new_args = tuple(list(self._args) + list(args))
+ new_kwargs = dict(self._kwargs)
+ new_kwargs.update(kwargs)
+ return is_func_signature_supported(
+ func, *new_args, **new_kwargs
+ )
+
+
class EventCallback(object):
"""Callback registered to a topic.
@@ -34,20 +141,37 @@ class EventCallback(object):
or none arguments. When 1 argument is expected then the processed 'Event'
object is passed in.
- The registered callbacks don't keep function in memory so it is not
- possible to store lambda function as callback.
+ The callbacks are validated against their reference counter, that is
+ achieved using 'weakref' module. That means that the callback must
+ be stored in memory somewhere. e.g. lambda functions are not
+ supported as valid callback.
+
+ You can use 'weakref_partial' functions. In that case is partial object
+ stored in the callback object and reference counter is checked for
+ the wrapped function.
Args:
- topic(str): Topic which will be listened.
- func(func): Callback to a topic.
+ topic (str): Topic which will be listened.
+ func (Callable): Callback to a topic.
+ order (Union[int, None]): Order of callback. Lower number means higher
+ priority.
Raises:
TypeError: When passed function is not a callable object.
"""
- def __init__(self, topic, func):
+ def __init__(self, topic, func, order):
+ if not callable(func):
+ raise TypeError((
+ "Registered callback is not callable. \"{}\""
+ ).format(str(func)))
+
+ self._validate_order(order)
+
self._log = None
self._topic = topic
+ self._order = order
+ self._enabled = True
# Replace '*' with any character regex and escape rest of text
# - when callback is registered for '*' topic it will receive all
# events
@@ -63,37 +187,38 @@ class EventCallback(object):
topic_regex = re.compile(topic_regex_str)
self._topic_regex = topic_regex
- # Convert callback into references
- # - deleted functions won't cause crashes
- if inspect.ismethod(func):
- func_ref = WeakMethod(func)
- elif callable(func):
- func_ref = weakref.ref(func)
+ # Callback function prep
+ if isinstance(func, weakref_partial):
+ partial_func = func
+ (name, path) = _get_func_info(func.get_func())
+ func_ref = None
+ expect_args = partial_func.validate_signature("fake")
+ expect_kwargs = partial_func.validate_signature(event="fake")
+
else:
- raise TypeError((
- "Registered callback is not callable. \"{}\""
- ).format(str(func)))
+ partial_func = None
+ (name, path) = _get_func_info(func)
+ # Convert callback into references
+ # - deleted functions won't cause crashes
+ func_ref = _get_func_ref(func)
- # Collect function name and path to file for logging
- func_name = func.__name__
- func_path = os.path.abspath(inspect.getfile(func))
-
- # Get expected arguments from function spec
- # - positional arguments are always preferred
- expect_args = is_func_signature_supported(func, "fake")
- expect_kwargs = is_func_signature_supported(func, event="fake")
+ # Get expected arguments from function spec
+ # - positional arguments are always preferred
+ expect_args = is_func_signature_supported(func, "fake")
+ expect_kwargs = is_func_signature_supported(func, event="fake")
self._func_ref = func_ref
- self._func_name = func_name
- self._func_path = func_path
+ self._partial_func = partial_func
+ self._ref_is_valid = True
self._expect_args = expect_args
self._expect_kwargs = expect_kwargs
- self._ref_valid = func_ref is not None
- self._enabled = True
+
+ self._name = name
+ self._path = path
def __repr__(self):
return "< {} - {} > {}".format(
- self.__class__.__name__, self._func_name, self._func_path
+ self.__class__.__name__, self._name, self._path
)
@property
@@ -104,32 +229,83 @@ class EventCallback(object):
@property
def is_ref_valid(self):
- return self._ref_valid
+ """
+
+ Returns:
+ bool: Is reference to callback valid.
+ """
+
+ self._validate_ref()
+ return self._ref_is_valid
def validate_ref(self):
- if not self._ref_valid:
- return
+ """Validate if reference to callback is valid.
- callback = self._func_ref()
- if not callback:
- self._ref_valid = False
+ Deprecated:
+ Reference is always live checkd with 'is_ref_valid'.
+ """
+
+ # Trigger validate by getting 'is_valid'
+ _ = self.is_ref_valid
@property
def enabled(self):
- """Is callback enabled."""
+ """Is callback enabled.
+
+ Returns:
+ bool: Is callback enabled.
+ """
+
return self._enabled
def set_enabled(self, enabled):
- """Change if callback is enabled."""
+ """Change if callback is enabled.
+
+ Args:
+ enabled (bool): Change enabled state of the callback.
+ """
+
self._enabled = enabled
def deregister(self):
"""Calling this function will cause that callback will be removed."""
- # Fake reference
- self._ref_valid = False
+
+ self._ref_is_valid = False
+ self._partial_func = None
+ self._func_ref = None
+
+ def get_order(self):
+ """Get callback order.
+
+ Returns:
+ Union[int, None]: Callback order.
+ """
+
+ return self._order
+
+ def set_order(self, order):
+ """Change callback order.
+
+ Args:
+ order (Union[int, None]): Order of callback. Lower number means
+ higher priority.
+ """
+
+ self._validate_order(order)
+ self._order = order
+
+ order = property(get_order, set_order)
def topic_matches(self, topic):
- """Check if event topic matches callback's topic."""
+ """Check if event topic matches callback's topic.
+
+ Args:
+ topic (str): Topic name.
+
+ Returns:
+ bool: Topic matches callback's topic.
+ """
+
return self._topic_regex.match(topic)
def process_event(self, event):
@@ -139,36 +315,69 @@ class EventCallback(object):
event(Event): Event that was triggered.
"""
- # Skip if callback is not enabled or has invalid reference
- if not self._ref_valid or not self._enabled:
+ # Skip if callback is not enabled
+ if not self._enabled:
return
- # Get reference
- callback = self._func_ref()
- # Check if reference is valid or callback's topic matches the event
- if not callback:
- # Change state if is invalid so the callback is removed
- self._ref_valid = False
+ # Get reference and skip if is not available
+ callback = self._get_callback()
+ if callback is None:
+ return
- elif self.topic_matches(event.topic):
- # Try execute callback
- try:
- if self._expect_args:
- callback(event)
+ if not self.topic_matches(event.topic):
+ return
- elif self._expect_kwargs:
- callback(event=event)
+ # Try to execute callback
+ try:
+ if self._expect_args:
+ callback(event)
- else:
- callback()
+ elif self._expect_kwargs:
+ callback(event=event)
- except Exception:
- self.log.warning(
- "Failed to execute event callback {}".format(
- str(repr(self))
- ),
- exc_info=True
- )
+ else:
+ callback()
+
+ except Exception:
+ self.log.warning(
+ "Failed to execute event callback {}".format(
+ str(repr(self))
+ ),
+ exc_info=True
+ )
+
+ def _validate_order(self, order):
+ if isinstance(order, int):
+ return
+
+ raise TypeError(
+ "Expected type 'int' got '{}'.".format(str(type(order)))
+ )
+
+ def _get_callback(self):
+ if self._partial_func is not None:
+ return self._partial_func
+
+ if self._func_ref is not None:
+ return self._func_ref()
+ return None
+
+ def _validate_ref(self):
+ if self._ref_is_valid is False:
+ return
+
+ if self._func_ref is not None:
+ self._ref_is_valid = self._func_ref() is not None
+
+ elif self._partial_func is not None:
+ self._ref_is_valid = self._partial_func.is_valid()
+
+ else:
+ self._ref_is_valid = False
+
+ if not self._ref_is_valid:
+ self._func_ref = None
+ self._partial_func = None
# Inherit from 'object' for Python 2 hosts
@@ -282,30 +491,39 @@ class Event(object):
class EventSystem(object):
"""Encapsulate event handling into an object.
- System wraps registered callbacks and triggered events into single object
- so it is possible to create mutltiple independent systems that have their
+ System wraps registered callbacks and triggered events into single object,
+ so it is possible to create multiple independent systems that have their
topics and callbacks.
-
+ Callbacks are stored by order of their registration, but it is possible to
+ manually define order of callbacks using 'order' argument within
+ 'add_callback'.
"""
+ default_order = 100
+
def __init__(self):
self._registered_callbacks = []
- def add_callback(self, topic, callback):
+ def add_callback(self, topic, callback, order=None):
"""Register callback in event system.
Args:
topic (str): Topic for EventCallback.
- callback (Callable): Function or method that will be called
- when topic is triggered.
+ callback (Union[Callable, weakref_partial]): Function or method
+ that will be called when topic is triggered.
+ order (Optional[int]): Order of callback. Lower number means
+ higher priority.
Returns:
EventCallback: Created callback object which can be used to
stop listening.
"""
- callback = EventCallback(topic, callback)
+ if order is None:
+ order = self.default_order
+
+ callback = EventCallback(topic, callback, order)
self._registered_callbacks.append(callback)
return callback
@@ -341,22 +559,6 @@ class EventSystem(object):
event.emit()
return event
- def _process_event(self, event):
- """Process event topic and trigger callbacks.
-
- Args:
- event (Event): Prepared event with topic and data.
- """
-
- invalid_callbacks = []
- for callback in self._registered_callbacks:
- callback.process_event(event)
- if not callback.is_ref_valid:
- invalid_callbacks.append(callback)
-
- for callback in invalid_callbacks:
- self._registered_callbacks.remove(callback)
-
def emit_event(self, event):
"""Emit event object.
@@ -366,6 +568,21 @@ class EventSystem(object):
self._process_event(event)
+ def _process_event(self, event):
+ """Process event topic and trigger callbacks.
+
+ Args:
+ event (Event): Prepared event with topic and data.
+ """
+
+ callbacks = tuple(sorted(
+ self._registered_callbacks, key=lambda x: x.order
+ ))
+ for callback in callbacks:
+ callback.process_event(event)
+ if not callback.is_ref_valid:
+ self._registered_callbacks.remove(callback)
+
class QueuedEventSystem(EventSystem):
"""Events are automatically processed in queue.
diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py
index bedf19562d..4f9eb7f667 100644
--- a/openpype/lib/python_module_tools.py
+++ b/openpype/lib/python_module_tools.py
@@ -269,7 +269,7 @@ def is_func_signature_supported(func, *args, **kwargs):
True
Args:
- func (function): A function where the signature should be tested.
+ func (Callable): A function where the signature should be tested.
*args (Any): Positional arguments for function signature.
**kwargs (Any): Keyword arguments for function signature.
diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py
index 316dedbd3d..c8ddbde061 100644
--- a/openpype/lib/transcoding.py
+++ b/openpype/lib/transcoding.py
@@ -44,17 +44,17 @@ XML_CHAR_REF_REGEX_HEX = re.compile(r"?[0-9a-fA-F]+;")
ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$")
IMAGE_EXTENSIONS = {
- ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", ".cal",
- ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", ".fits",
- ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", ".icer",
- ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2",
- ".jng", ".jpeg", ".jpeg-ls", ".jpeg", ".2000", ".jpg", ".xr",
- ".jpeg", ".xt", ".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd",
- ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf",
- ".pictor", ".png", ".psd", ".psb", ".psp", ".qtvr", ".ras",
- ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", ".tiff/ep",
- ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xbm", ".xcf",
- ".xpm", ".xwd"
+ ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave",
+ ".cal", ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr",
+ ".fits", ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc",
+ ".icer", ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2",
+ ".jng", ".jpeg", ".jpeg-ls", ".jpeg-hdr", ".2000", ".jpg",
+ ".kra", ".logluv", ".mng", ".miff", ".nrrd", ".ora",
+ ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf",
+ ".pictor", ".png", ".psd", ".psb", ".psp", ".qtvr",
+ ".ras", ".rgbe", ".sgi", ".tga",
+ ".tif", ".tiff", ".tiff/ep", ".tiff/it", ".ufo", ".ufp",
+ ".wbmp", ".webp", ".xr", ".xt", ".xbm", ".xcf", ".xpm", ".xwd"
}
VIDEO_EXTENSIONS = {
@@ -110,8 +110,9 @@ def get_oiio_info_for_input(filepath, logger=None, subimages=False):
if line == "":
subimages_lines.append(lines)
lines = []
+ xml_started = False
- if not xml_started:
+ if not subimages_lines:
raise ValueError(
"Failed to read input file \"{}\".\nOutput:\n{}".format(
filepath, output
diff --git a/openpype/modules/base.py b/openpype/modules/base.py
index c69bff7002..cb64816cc9 100644
--- a/openpype/modules/base.py
+++ b/openpype/modules/base.py
@@ -542,7 +542,8 @@ def _load_modules():
module_dirs.insert(0, current_dir)
addons_dir = os.path.join(os.path.dirname(current_dir), "addons")
- module_dirs.append(addons_dir)
+ if os.path.exists(addons_dir):
+ module_dirs.append(addons_dir)
ignored_host_names = set(IGNORED_HOSTS_IN_AYON)
ignored_current_dir_filenames = set(IGNORED_DEFAULT_FILENAMES)
diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py
index dbf8a32f96..b0d5d7f222 100644
--- a/openpype/modules/deadline/abstract_submit_deadline.py
+++ b/openpype/modules/deadline/abstract_submit_deadline.py
@@ -464,7 +464,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin,
self.log.info("Submitted job to Deadline: {}.".format(job_id))
# TODO: Find a way that's more generic and not render type specific
- if "exportJob" in instance.data:
+ if instance.data.get("splitRender"):
self.log.info("Splitting export and render in two jobs")
self.log.info("Export job id: %s", job_id)
render_job_info = self.get_job_info(dependency_job_ids=[job_id])
diff --git a/openpype/modules/deadline/plugins/publish/collect_pools.py b/openpype/modules/deadline/plugins/publish/collect_pools.py
index a25b149f11..9ee079b892 100644
--- a/openpype/modules/deadline/plugins/publish/collect_pools.py
+++ b/openpype/modules/deadline/plugins/publish/collect_pools.py
@@ -1,7 +1,4 @@
# -*- coding: utf-8 -*-
-"""Collect Deadline pools. Choose default one from Settings
-
-"""
import pyblish.api
from openpype.lib import TextDef
from openpype.pipeline.publish import OpenPypePyblishPluginMixin
@@ -9,11 +6,35 @@ from openpype.pipeline.publish import OpenPypePyblishPluginMixin
class CollectDeadlinePools(pyblish.api.InstancePlugin,
OpenPypePyblishPluginMixin):
- """Collect pools from instance if present, from Setting otherwise."""
+ """Collect pools from instance or Publisher attributes, from Setting
+ otherwise.
+
+ Pools are used to control which DL workers could render the job.
+
+ Pools might be set:
+ - directly on the instance (set directly in DCC)
+ - from Publisher attributes
+ - from defaults from Settings.
+
+ Publisher attributes could be shown even for instances that should be
+ rendered locally as visibility is driven by product type of the instance
+ (which will be `render` most likely).
+ (Might be resolved in the future and class attribute 'families' should
+ be cleaned up.)
+
+ """
order = pyblish.api.CollectorOrder + 0.420
label = "Collect Deadline Pools"
- families = ["rendering",
+ hosts = ["aftereffects",
+ "fusion",
+ "harmony"
+ "nuke",
+ "maya",
+ "max"]
+
+ families = ["render",
+ "rendering",
"render.farm",
"renderFarm",
"renderlayer",
@@ -30,7 +51,6 @@ class CollectDeadlinePools(pyblish.api.InstancePlugin,
cls.secondary_pool = settings.get("secondary_pool", None)
def process(self, instance):
-
attr_values = self.get_attr_values_from_data(instance.data)
if not instance.data.get("primaryPool"):
instance.data["primaryPool"] = (
@@ -60,8 +80,12 @@ class CollectDeadlinePools(pyblish.api.InstancePlugin,
return [
TextDef("primaryPool",
label="Primary Pool",
- default=cls.primary_pool),
+ default=cls.primary_pool,
+ tooltip="Deadline primary pool, "
+ "applicable for farm rendering"),
TextDef("secondaryPool",
label="Secondary Pool",
- default=cls.secondary_pool)
+ default=cls.secondary_pool,
+ tooltip="Deadline secondary pool, "
+ "applicable for farm rendering")
]
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 0c75f632cb..bf7fb45a8b 100644
--- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py
@@ -15,6 +15,7 @@ from openpype.lib import (
NumberDef
)
+
@attr.s
class DeadlinePluginInfo():
SceneFile = attr.ib(default=None)
@@ -41,6 +42,12 @@ class VrayRenderPluginInfo():
SeparateFilesPerFrame = attr.ib(default=True)
+@attr.s
+class RedshiftRenderPluginInfo():
+ SceneFile = attr.ib(default=None)
+ Version = attr.ib(default=None)
+
+
class HoudiniSubmitDeadline(
abstract_submit_deadline.AbstractSubmitDeadline,
OpenPypePyblishPluginMixin
@@ -124,7 +131,7 @@ class HoudiniSubmitDeadline(
# Whether Deadline render submission is being split in two
# (extract + render)
- split_render_job = instance.data["exportJob"]
+ split_render_job = instance.data.get("splitRender")
# If there's some dependency job ids we can assume this is a render job
# and not an export job
@@ -132,18 +139,21 @@ class HoudiniSubmitDeadline(
if dependency_job_ids:
is_export_job = False
+ job_type = "[RENDER]"
if split_render_job and not is_export_job:
# Convert from family to Deadline plugin name
# i.e., arnold_rop -> Arnold
plugin = instance.data["family"].replace("_rop", "").capitalize()
else:
plugin = "Houdini"
+ if split_render_job:
+ job_type = "[EXPORT IFD]"
job_info = DeadlineJobInfo(Plugin=plugin)
filepath = context.data["currentFile"]
filename = os.path.basename(filepath)
- job_info.Name = "{} - {}".format(filename, instance.name)
+ job_info.Name = "{} - {} {}".format(filename, instance.name, job_type)
job_info.BatchName = filename
job_info.UserName = context.data.get(
@@ -259,6 +269,25 @@ class HoudiniSubmitDeadline(
plugin_info = VrayRenderPluginInfo(
InputFilename=instance.data["ifdFile"],
)
+ elif family == "redshift_rop":
+ plugin_info = RedshiftRenderPluginInfo(
+ SceneFile=instance.data["ifdFile"]
+ )
+ # Note: To use different versions of Redshift on Deadline
+ # set the `REDSHIFT_VERSION` env variable in the Tools
+ # settings in the AYON Application plugin. You will also
+ # need to set that version in `Redshift.param` file
+ # of the Redshift Deadline plugin:
+ # [Redshift_Executable_*]
+ # where * is the version number.
+ if os.getenv("REDSHIFT_VERSION"):
+ plugin_info.Version = os.getenv("REDSHIFT_VERSION")
+ else:
+ self.log.warning((
+ "REDSHIFT_VERSION env variable is not set"
+ " - using version configured in Deadline"
+ ))
+
else:
self.log.error(
"Family '%s' not supported yet to split render job",
diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py
index 23d4183132..f06bd4dbe6 100644
--- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py
@@ -15,6 +15,12 @@ from openpype.pipeline import (
from openpype.pipeline.publish.lib import (
replace_with_published_scene_path
)
+from openpype.pipeline.publish import KnownPublishError
+from openpype.hosts.max.api.lib import (
+ get_current_renderer,
+ get_multipass_setting
+)
+from openpype.hosts.max.api.lib_rendersettings import RenderSettings
from openpype_modules.deadline import abstract_submit_deadline
from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo
from openpype.lib import is_running_from_build
@@ -54,7 +60,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
cls.priority)
cls.chuck_size = settings.get("chunk_size", cls.chunk_size)
cls.group = settings.get("group", cls.group)
-
+ # TODO: multiple camera instance, separate job infos
def get_job_info(self):
job_info = DeadlineJobInfo(Plugin="3dsmax")
@@ -71,7 +77,6 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
src_filepath = context.data["currentFile"]
src_filename = os.path.basename(src_filepath)
-
job_info.Name = "%s - %s" % (src_filename, instance.name)
job_info.BatchName = src_filename
job_info.Plugin = instance.data["plugin"]
@@ -134,11 +139,11 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
# Add list of expected files to job
# ---------------------------------
- exp = instance.data.get("expectedFiles")
-
- for filepath in self._iter_expected_files(exp):
- job_info.OutputDirectory += os.path.dirname(filepath)
- job_info.OutputFilename += os.path.basename(filepath)
+ if not instance.data.get("multiCamera"):
+ exp = instance.data.get("expectedFiles")
+ for filepath in self._iter_expected_files(exp):
+ job_info.OutputDirectory += os.path.dirname(filepath)
+ job_info.OutputFilename += os.path.basename(filepath)
return job_info
@@ -163,11 +168,11 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
def process_submission(self):
instance = self._instance
- filepath = self.scene_path
+ filepath = instance.context.data["currentFile"]
files = instance.data["expectedFiles"]
if not files:
- raise RuntimeError("No Render Elements found!")
+ raise KnownPublishError("No Render Elements found!")
first_file = next(self._iter_expected_files(files))
output_dir = os.path.dirname(first_file)
instance.data["outputDir"] = output_dir
@@ -181,9 +186,17 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
self.log.debug("Submitting 3dsMax render..")
project_settings = instance.context.data["project_settings"]
- payload = self._use_published_name(payload_data, project_settings)
- job_info, plugin_info = payload
- self.submit(self.assemble_payload(job_info, plugin_info))
+ if instance.data.get("multiCamera"):
+ self.log.debug("Submitting jobs for multiple cameras..")
+ payload = self._use_published_name_for_multiples(
+ payload_data, project_settings)
+ job_infos, plugin_infos = payload
+ for job_info, plugin_info in zip(job_infos, plugin_infos):
+ self.submit(self.assemble_payload(job_info, plugin_info))
+ else:
+ payload = self._use_published_name(payload_data, project_settings)
+ job_info, plugin_info = payload
+ self.submit(self.assemble_payload(job_info, plugin_info))
def _use_published_name(self, data, project_settings):
# Not all hosts can import these modules.
@@ -206,7 +219,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
files = instance.data.get("expectedFiles")
if not files:
- raise RuntimeError("No render elements found")
+ raise KnownPublishError("No render elements found")
first_file = next(self._iter_expected_files(files))
old_output_dir = os.path.dirname(first_file)
output_beauty = RenderSettings().get_render_output(instance.name,
@@ -218,6 +231,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
plugin_data["RenderOutput"] = beauty_name
# as 3dsmax has version with different languages
plugin_data["Language"] = "ENU"
+
renderer_class = get_current_renderer()
renderer = str(renderer_class).split(":")[0]
@@ -249,6 +263,120 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
return job_info, plugin_info
+ def get_job_info_through_camera(self, camera):
+ """Get the job parameters for deadline submission when
+ multi-camera is enabled.
+ Args:
+ infos(dict): a dictionary with job info.
+ """
+ instance = self._instance
+ context = instance.context
+ job_info = copy.deepcopy(self.job_info)
+ exp = instance.data.get("expectedFiles")
+
+ src_filepath = context.data["currentFile"]
+ src_filename = os.path.basename(src_filepath)
+ job_info.Name = "%s - %s - %s" % (
+ src_filename, instance.name, camera)
+ for filepath in self._iter_expected_files(exp):
+ if camera not in filepath:
+ continue
+ job_info.OutputDirectory += os.path.dirname(filepath)
+ job_info.OutputFilename += os.path.basename(filepath)
+
+ return job_info
+ # set the output filepath with the relative camera
+
+ def get_plugin_info_through_camera(self, camera):
+ """Get the plugin parameters for deadline submission when
+ multi-camera is enabled.
+ Args:
+ infos(dict): a dictionary with plugin info.
+ """
+ instance = self._instance
+ # set the target camera
+ plugin_info = copy.deepcopy(self.plugin_info)
+
+ plugin_data = {}
+ # set the output filepath with the relative camera
+ if instance.data.get("multiCamera"):
+ scene_filepath = instance.context.data["currentFile"]
+ scene_filename = os.path.basename(scene_filepath)
+ scene_directory = os.path.dirname(scene_filepath)
+ current_filename, ext = os.path.splitext(scene_filename)
+ camera_scene_name = f"{current_filename}_{camera}{ext}"
+ camera_scene_filepath = os.path.join(
+ scene_directory, f"_{current_filename}", camera_scene_name)
+ plugin_data["SceneFile"] = camera_scene_filepath
+
+ files = instance.data.get("expectedFiles")
+ if not files:
+ raise KnownPublishError("No render elements found")
+ first_file = next(self._iter_expected_files(files))
+ old_output_dir = os.path.dirname(first_file)
+ rgb_output = RenderSettings().get_batch_render_output(camera) # noqa
+ rgb_bname = os.path.basename(rgb_output)
+ dir = os.path.dirname(first_file)
+ beauty_name = f"{dir}/{rgb_bname}"
+ beauty_name = beauty_name.replace("\\", "/")
+ plugin_info["RenderOutput"] = beauty_name
+ renderer_class = get_current_renderer()
+
+ renderer = str(renderer_class).split(":")[0]
+ if renderer in [
+ "ART_Renderer",
+ "Redshift_Renderer",
+ "V_Ray_6_Hotfix_3",
+ "V_Ray_GPU_6_Hotfix_3",
+ "Default_Scanline_Renderer",
+ "Quicksilver_Hardware_Renderer",
+ ]:
+ render_elem_list = RenderSettings().get_batch_render_elements(
+ instance.name, old_output_dir, camera
+ )
+ for i, element in enumerate(render_elem_list):
+ if camera in element:
+ elem_bname = os.path.basename(element)
+ new_elem = f"{dir}/{elem_bname}"
+ new_elem = new_elem.replace("/", "\\")
+ plugin_info["RenderElementOutputFilename%d" % i] = new_elem # noqa
+
+ if camera:
+ # set the default camera and target camera
+ # (weird parameters from max)
+ plugin_data["Camera"] = camera
+ plugin_data["Camera1"] = camera
+ plugin_data["Camera0"] = None
+
+ plugin_info.update(plugin_data)
+ return plugin_info
+
+ def _use_published_name_for_multiples(self, data, project_settings):
+ """Process the parameters submission for deadline when
+ user enables multi-cameras option.
+ Args:
+ job_info_list (list): A list of multiple job infos
+ plugin_info_list (list): A list of multiple plugin infos
+ """
+ job_info_list = []
+ plugin_info_list = []
+ instance = self._instance
+ cameras = instance.data.get("cameras", [])
+ plugin_data = {}
+ multipass = get_multipass_setting(project_settings)
+ if multipass:
+ plugin_data["DisableMultipass"] = 0
+ else:
+ plugin_data["DisableMultipass"] = 1
+ for cam in cameras:
+ job_info = self.get_job_info_through_camera(cam)
+ plugin_info = self.get_plugin_info_through_camera(cam)
+ plugin_info.update(plugin_data)
+ job_info_list.append(job_info)
+ plugin_info_list.append(plugin_info)
+
+ return job_info_list, plugin_info_list
+
def from_published_scene(self, replace_in_path=True):
instance = self._instance
if instance.data["renderer"] == "Redshift_Renderer":
diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
index 26a605a744..5591db151a 100644
--- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
@@ -231,7 +231,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1"
# Adding file dependencies.
- if self.asset_dependencies:
+ if not bool(os.environ.get("IS_TEST")) and self.asset_dependencies:
dependencies = instance.context.data["fileDependencies"]
for dependency in dependencies:
job_info.AssetDependency += dependency
@@ -570,7 +570,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
job_info = copy.deepcopy(self.job_info)
- if self.asset_dependencies:
+ if not bool(os.environ.get("IS_TEST")) and self.asset_dependencies:
# Asset dependency to wait for at least the scene file to sync.
job_info.AssetDependency += self.scene_path
diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py
index d03416ca00..746b009255 100644
--- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py
@@ -47,6 +47,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
env_allowed_keys = []
env_search_replace_values = {}
workfile_dependency = True
+ use_published_workfile = True
@classmethod
def get_attribute_defs(cls):
@@ -85,8 +86,13 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
),
BoolDef(
"workfile_dependency",
- default=True,
+ default=cls.workfile_dependency,
label="Workfile Dependency"
+ ),
+ BoolDef(
+ "use_published_workfile",
+ default=cls.use_published_workfile,
+ label="Use Published Workfile"
)
]
@@ -125,20 +131,11 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
render_path = instance.data['path']
script_path = context.data["currentFile"]
- for item_ in context:
- if "workfile" in item_.data["family"]:
- template_data = item_.data.get("anatomyData")
- rep = item_.data.get("representations")[0].get("name")
- template_data["representation"] = rep
- template_data["ext"] = rep
- template_data["comment"] = None
- anatomy_filled = context.data["anatomy"].format(template_data)
- template_filled = anatomy_filled["publish"]["path"]
- script_path = os.path.normpath(template_filled)
-
- self.log.info(
- "Using published scene for render {}".format(script_path)
- )
+ use_published_workfile = instance.data["attributeValues"].get(
+ "use_published_workfile", self.use_published_workfile
+ )
+ if use_published_workfile:
+ script_path = self._get_published_workfile_path(context)
# only add main rendering job if target is not frames_farm
r_job_response_json = None
@@ -197,6 +194,44 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
families.insert(0, "prerender")
instance.data["families"] = families
+ def _get_published_workfile_path(self, context):
+ """This method is temporary while the class is not inherited from
+ AbstractSubmitDeadline"""
+ for instance in context:
+ if (
+ instance.data["family"] != "workfile"
+ # Disabled instances won't be integrated
+ or instance.data("publish") is False
+ ):
+ continue
+ template_data = instance.data["anatomyData"]
+ # Expect workfile instance has only one representation
+ representation = instance.data["representations"][0]
+ # Get workfile extension
+ repre_file = representation["files"]
+ self.log.info(repre_file)
+ ext = os.path.splitext(repre_file)[1].lstrip(".")
+
+ # Fill template data
+ template_data["representation"] = representation["name"]
+ template_data["ext"] = ext
+ template_data["comment"] = None
+
+ anatomy = context.data["anatomy"]
+ # WARNING Hardcoded template name 'publish' > may not be used
+ template_obj = anatomy.templates_obj["publish"]["path"]
+
+ template_filled = template_obj.format(template_data)
+ script_path = os.path.normpath(template_filled)
+ self.log.info(
+ "Using published scene for render {}".format(
+ script_path
+ )
+ )
+ return script_path
+
+ return None
+
def payload_submit(
self,
instance,
diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py
index f5866576e6..82971daee5 100644
--- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py
+++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py
@@ -83,7 +83,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
"""
- label = "Submit image sequence jobs to Deadline"
+ label = "Submit Image Publishing job to Deadline"
order = pyblish.api.IntegratorOrder + 0.2
icon = "tractor"
@@ -287,7 +287,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
job_index)] = assembly_id # noqa: E501
job_index += 1
elif instance.data.get("bakingSubmissionJobs"):
- self.log.info("Adding baking submission jobs as dependencies...")
+ self.log.info(
+ "Adding baking submission jobs as dependencies..."
+ )
job_index = 0
for assembly_id in instance.data["bakingSubmissionJobs"]:
payload["JobInfo"]["JobDependency{}".format(
@@ -571,8 +573,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
._____.
'''
- render_job = instance.data.pop("deadlineSubmissionJob", None)
+ render_job = instance.data.pop("deadlineSubmissionJob", None)
if not render_job and instance.data.get("tileRendering") is False:
raise AssertionError(("Cannot continue without valid "
"Deadline submission."))
diff --git a/openpype/modules/ftrack/event_handlers_user/action_djvview.py b/openpype/modules/ftrack/event_handlers_user/action_djvview.py
index 334519b4bb..cc37faacf2 100644
--- a/openpype/modules/ftrack/event_handlers_user/action_djvview.py
+++ b/openpype/modules/ftrack/event_handlers_user/action_djvview.py
@@ -13,7 +13,7 @@ class DJVViewAction(BaseAction):
description = "DJV View Launcher"
icon = statics_icon("app_icons", "djvView.png")
- type = 'Application'
+ type = "Application"
allowed_types = [
"cin", "dpx", "avi", "dv", "gif", "flv", "mkv", "mov", "mpg", "mpeg",
@@ -60,7 +60,7 @@ class DJVViewAction(BaseAction):
return False
def interface(self, session, entities, event):
- if event['data'].get('values', {}):
+ if event["data"].get("values", {}):
return
entity = entities[0]
@@ -70,32 +70,32 @@ class DJVViewAction(BaseAction):
if entity_type == "assetversion":
if (
entity[
- 'components'
- ][0]['file_type'][1:] in self.allowed_types
+ "components"
+ ][0]["file_type"][1:] in self.allowed_types
):
versions.append(entity)
else:
master_entity = entity
if entity_type == "task":
- master_entity = entity['parent']
+ master_entity = entity["parent"]
- for asset in master_entity['assets']:
- for version in asset['versions']:
+ for asset in master_entity["assets"]:
+ for version in asset["versions"]:
# Get only AssetVersion of selected task
if (
entity_type == "task" and
- version['task']['id'] != entity['id']
+ version["task"]["id"] != entity["id"]
):
continue
# Get only components with allowed type
- filetype = version['components'][0]['file_type']
+ filetype = version["components"][0]["file_type"]
if filetype[1:] in self.allowed_types:
versions.append(version)
if len(versions) < 1:
return {
- 'success': False,
- 'message': 'There are no Asset Versions to open.'
+ "success": False,
+ "message": "There are no Asset Versions to open."
}
# TODO sort them (somehow?)
@@ -134,68 +134,68 @@ class DJVViewAction(BaseAction):
last_available = None
select_value = None
for version in versions:
- for component in version['components']:
+ for component in version["components"]:
label = base_label.format(
- str(version['version']).zfill(3),
- version['asset']['type']['name'],
- component['name']
+ str(version["version"]).zfill(3),
+ version["asset"]["type"]["name"],
+ component["name"]
)
try:
location = component[
- 'component_locations'
- ][0]['location']
+ "component_locations"
+ ][0]["location"]
file_path = location.get_filesystem_path(component)
except Exception:
file_path = component[
- 'component_locations'
- ][0]['resource_identifier']
+ "component_locations"
+ ][0]["resource_identifier"]
if os.path.isdir(os.path.dirname(file_path)):
last_available = file_path
- if component['name'] == default_component:
+ if component["name"] == default_component:
select_value = file_path
version_items.append(
- {'label': label, 'value': file_path}
+ {"label": label, "value": file_path}
)
if len(version_items) == 0:
return {
- 'success': False,
- 'message': (
- 'There are no Asset Versions with accessible path.'
+ "success": False,
+ "message": (
+ "There are no Asset Versions with accessible path."
)
}
item = {
- 'label': 'Items to view',
- 'type': 'enumerator',
- 'name': 'path',
- 'data': sorted(
+ "label": "Items to view",
+ "type": "enumerator",
+ "name": "path",
+ "data": sorted(
version_items,
- key=itemgetter('label'),
+ key=itemgetter("label"),
reverse=True
)
}
if select_value is not None:
- item['value'] = select_value
+ item["value"] = select_value
else:
- item['value'] = last_available
+ item["value"] = last_available
items.append(item)
- return {'items': items}
+ return {"items": items}
def launch(self, session, entities, event):
"""Callback method for DJVView action."""
# Launching application
- event_data = event["data"]
- if "values" not in event_data:
+ event_values = event["data"].get("values")
+ if not event_values:
return
- djv_app_name = event_data["djv_app_name"]
- app = self.applicaion_manager.applications.get(djv_app_name)
+ djv_app_name = event_values["djv_app_name"]
+ app = self.application_manager.applications.get(djv_app_name)
executable = None
if app is not None:
executable = app.find_executable()
@@ -206,18 +206,21 @@ class DJVViewAction(BaseAction):
"message": "Couldn't find DJV executable."
}
- filpath = os.path.normpath(event_data["values"]["path"])
+ filpath = os.path.normpath(event_values["path"])
cmd = [
# DJV path
- executable,
+ str(executable),
# PATH TO COMPONENT
filpath
]
try:
# Run DJV with these commands
- subprocess.Popen(cmd)
+ _process = subprocess.Popen(cmd)
+ # Keep process in memory for some time
+ time.sleep(0.1)
+
except FileNotFoundError:
return {
"success": False,
diff --git a/openpype/modules/kitsu/utils/credentials.py b/openpype/modules/kitsu/utils/credentials.py
index 941343cc8d..c471b56907 100644
--- a/openpype/modules/kitsu/utils/credentials.py
+++ b/openpype/modules/kitsu/utils/credentials.py
@@ -64,8 +64,10 @@ def clear_credentials():
user_registry = OpenPypeSecureRegistry("kitsu_user")
# Set local settings
- user_registry.delete_item("login")
- user_registry.delete_item("password")
+ if user_registry.get_item("login", None) is not None:
+ user_registry.delete_item("login")
+ if user_registry.get_item("password", None) is not None:
+ user_registry.delete_item("password")
def save_credentials(login: str, password: str):
@@ -92,8 +94,9 @@ def load_credentials() -> Tuple[str, str]:
# Get user registry
user_registry = OpenPypeSecureRegistry("kitsu_user")
- return user_registry.get_item("login", None), user_registry.get_item(
- "password", None
+ return (
+ user_registry.get_item("login", None),
+ user_registry.get_item("password", None)
)
diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py
index 54ff2627e1..975fdd31cc 100644
--- a/openpype/pipeline/farm/pyblish_functions.py
+++ b/openpype/pipeline/farm/pyblish_functions.py
@@ -582,16 +582,17 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
group_name = subset
# if there are multiple cameras, we need to add camera name
- if isinstance(col, (list, tuple)):
- cam = [c for c in cameras if c in col[0]]
- else:
- # in case of single frame
- cam = [c for c in cameras if c in col]
- if cam:
- if aov:
- subset_name = '{}_{}_{}'.format(group_name, cam, aov)
- else:
- subset_name = '{}_{}'.format(group_name, cam)
+ expected_filepath = col[0] if isinstance(col, (list, tuple)) else col
+ cams = [cam for cam in cameras if cam in expected_filepath]
+ if cams:
+ for cam in cams:
+ if aov:
+ if not aov.startswith(cam):
+ subset_name = '{}_{}_{}'.format(group_name, cam, aov)
+ else:
+ subset_name = "{}_{}".format(group_name, aov)
+ else:
+ subset_name = '{}_{}'.format(group_name, cam)
else:
if aov:
subset_name = '{}_{}'.format(group_name, aov)
diff --git a/openpype/pipeline/project_folders.py b/openpype/pipeline/project_folders.py
index 1bcba5c320..608344ce03 100644
--- a/openpype/pipeline/project_folders.py
+++ b/openpype/pipeline/project_folders.py
@@ -28,13 +28,20 @@ def concatenate_splitted_paths(split_paths, anatomy):
# backward compatibility
if "__project_root__" in path_items:
for root, root_path in anatomy.roots.items():
- if not os.path.exists(str(root_path)):
- log.debug("Root {} path path {} not exist on \
- computer!".format(root, root_path))
+ if not root_path or not os.path.exists(str(root_path)):
+ log.debug(
+ "Root {} path path {} not exist on computer!".format(
+ root, root_path
+ )
+ )
continue
- clean_items = ["{{root[{}]}}".format(root),
- r"{project[name]}"] + clean_items[1:]
- output.append(os.path.normpath(os.path.sep.join(clean_items)))
+
+ root_items = [
+ "{{root[{}]}}".format(root),
+ "{project[name]}"
+ ]
+ root_items.extend(clean_items[1:])
+ output.append(os.path.normpath(os.path.sep.join(root_items)))
continue
output.append(os.path.normpath(os.path.sep.join(clean_items)))
diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py
index 4ea2f932f1..40cb94e2bf 100644
--- a/openpype/pipeline/publish/lib.py
+++ b/openpype/pipeline/publish/lib.py
@@ -58,41 +58,13 @@ def get_template_name_profiles(
if not project_settings:
project_settings = get_project_settings(project_name)
- profiles = (
+ return copy.deepcopy(
project_settings
["global"]
["tools"]
["publish"]
["template_name_profiles"]
)
- if profiles:
- return copy.deepcopy(profiles)
-
- # Use legacy approach for cases new settings are not filled yet for the
- # project
- legacy_profiles = (
- project_settings
- ["global"]
- ["publish"]
- ["IntegrateAssetNew"]
- ["template_name_profiles"]
- )
- if legacy_profiles:
- if not logger:
- logger = Logger.get_logger("get_template_name_profiles")
-
- logger.warning((
- "Project \"{}\" is using legacy access to publish template."
- " It is recommended to move settings to new location"
- " 'project_settings/global/tools/publish/template_name_profiles'."
- ).format(project_name))
-
- # Replace "tasks" key with "task_names"
- profiles = []
- for profile in copy.deepcopy(legacy_profiles):
- profile["task_names"] = profile.pop("tasks", [])
- profiles.append(profile)
- return profiles
def get_hero_template_name_profiles(
@@ -121,36 +93,13 @@ def get_hero_template_name_profiles(
if not project_settings:
project_settings = get_project_settings(project_name)
- profiles = (
+ return copy.deepcopy(
project_settings
["global"]
["tools"]
["publish"]
["hero_template_name_profiles"]
)
- if profiles:
- return copy.deepcopy(profiles)
-
- # Use legacy approach for cases new settings are not filled yet for the
- # project
- legacy_profiles = copy.deepcopy(
- project_settings
- ["global"]
- ["publish"]
- ["IntegrateHeroVersion"]
- ["template_name_profiles"]
- )
- if legacy_profiles:
- if not logger:
- logger = Logger.get_logger("get_hero_template_name_profiles")
-
- logger.warning((
- "Project \"{}\" is using legacy access to hero publish template."
- " It is recommended to move settings to new location"
- " 'project_settings/global/tools/publish/"
- "hero_template_name_profiles'."
- ).format(project_name))
- return legacy_profiles
def get_publish_template_name(
diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py
index 9dc833061a..3096d22518 100644
--- a/openpype/pipeline/workfile/workfile_template_builder.py
+++ b/openpype/pipeline/workfile/workfile_template_builder.py
@@ -1971,7 +1971,6 @@ class PlaceholderCreateMixin(object):
if not placeholder.data.get("keep_placeholder", True):
self.delete_placeholder(placeholder)
-
def create_failed(self, placeholder, creator_data):
if hasattr(placeholder, "create_failed"):
placeholder.create_failed(creator_data)
@@ -2036,7 +2035,7 @@ class CreatePlaceholderItem(PlaceholderItem):
self._failed_created_publish_instances = []
def get_errors(self):
- if not self._failed_representations:
+ if not self._failed_created_publish_instances:
return []
message = (
"Failed to create {} instance using Creator {}"
diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py
index 1b4b44e40e..b1b7ecd138 100644
--- a/openpype/plugins/publish/collect_anatomy_instance_data.py
+++ b/openpype/plugins/publish/collect_anatomy_instance_data.py
@@ -190,48 +190,25 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
project_task_types = project_doc["config"]["tasks"]
for instance in context:
- asset_doc = instance.data.get("assetEntity")
- anatomy_updates = {
+ anatomy_data = copy.deepcopy(context.data["anatomyData"])
+ anatomy_data.update({
"family": instance.data["family"],
"subset": instance.data["subset"],
- }
- if asset_doc:
- parents = asset_doc["data"].get("parents") or list()
- parent_name = project_doc["name"]
- if parents:
- parent_name = parents[-1]
+ })
- hierarchy = "/".join(parents)
- anatomy_updates.update({
- "asset": asset_doc["name"],
- "hierarchy": hierarchy,
- "parent": parent_name,
- "folder": {
- "name": asset_doc["name"],
- },
- })
-
- # Task
- task_type = None
- task_name = instance.data.get("task")
- if task_name:
- asset_tasks = asset_doc["data"]["tasks"]
- task_type = asset_tasks.get(task_name, {}).get("type")
- task_code = (
- project_task_types
- .get(task_type, {})
- .get("short_name")
- )
- anatomy_updates["task"] = {
- "name": task_name,
- "type": task_type,
- "short": task_code
- }
+ self._fill_asset_data(instance, project_doc, anatomy_data)
+ self._fill_task_data(instance, project_task_types, anatomy_data)
# Define version
+ version_number = None
if self.follow_workfile_version:
- version_number = context.data('version')
- else:
+ version_number = context.data("version")
+
+ # Even if 'follow_workfile_version' is enabled, it may not be set
+ # because workfile version was not collected to 'context.data'
+ # - that can happen e.g. in 'traypublisher' or other hosts without
+ # a workfile
+ if version_number is None:
version_number = instance.data.get("version")
# use latest version (+1) if already any exist
@@ -242,6 +219,9 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# If version is not specified for instance or context
if version_number is None:
+ task_data = anatomy_data.get("task") or {}
+ task_name = task_data.get("name")
+ task_type = task_data.get("type")
version_number = get_versioning_start(
context.data["projectName"],
instance.context.data["hostName"],
@@ -250,29 +230,26 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
family=instance.data["family"],
subset=instance.data["subset"]
)
- anatomy_updates["version"] = version_number
+ anatomy_data["version"] = version_number
# Additional data
resolution_width = instance.data.get("resolutionWidth")
if resolution_width:
- anatomy_updates["resolution_width"] = resolution_width
+ anatomy_data["resolution_width"] = resolution_width
resolution_height = instance.data.get("resolutionHeight")
if resolution_height:
- anatomy_updates["resolution_height"] = resolution_height
+ anatomy_data["resolution_height"] = resolution_height
pixel_aspect = instance.data.get("pixelAspect")
if pixel_aspect:
- anatomy_updates["pixel_aspect"] = float(
+ anatomy_data["pixel_aspect"] = float(
"{:0.2f}".format(float(pixel_aspect))
)
fps = instance.data.get("fps")
if fps:
- anatomy_updates["fps"] = float("{:0.2f}".format(float(fps)))
-
- anatomy_data = copy.deepcopy(context.data["anatomyData"])
- anatomy_data.update(anatomy_updates)
+ anatomy_data["fps"] = float("{:0.2f}".format(float(fps)))
# Store anatomy data
instance.data["projectEntity"] = project_doc
@@ -288,3 +265,157 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
instance_name,
json.dumps(anatomy_data, indent=4)
))
+
+ def _fill_asset_data(self, instance, project_doc, anatomy_data):
+ # QUESTION should we make sure that all asset data are poped if asset
+ # data cannot be found?
+ # - 'asset', 'hierarchy', 'parent', 'folder'
+ asset_doc = instance.data.get("assetEntity")
+ if asset_doc:
+ parents = asset_doc["data"].get("parents") or list()
+ parent_name = project_doc["name"]
+ if parents:
+ parent_name = parents[-1]
+
+ hierarchy = "/".join(parents)
+ anatomy_data.update({
+ "asset": asset_doc["name"],
+ "hierarchy": hierarchy,
+ "parent": parent_name,
+ "folder": {
+ "name": asset_doc["name"],
+ },
+ })
+ return
+
+ if instance.data.get("newAssetPublishing"):
+ hierarchy = instance.data["hierarchy"]
+ anatomy_data["hierarchy"] = hierarchy
+
+ parent_name = project_doc["name"]
+ if hierarchy:
+ parent_name = hierarchy.split("/")[-1]
+
+ asset_name = instance.data["asset"].split("/")[-1]
+ anatomy_data.update({
+ "asset": asset_name,
+ "hierarchy": hierarchy,
+ "parent": parent_name,
+ "folder": {
+ "name": asset_name,
+ },
+ })
+
+ def _fill_task_data(self, instance, project_task_types, anatomy_data):
+ # QUESTION should we make sure that all task data are poped if task
+ # data cannot be resolved?
+ # - 'task'
+
+ # Skip if there is no task
+ task_name = instance.data.get("task")
+ if not task_name:
+ return
+
+ # Find task data based on asset entity
+ asset_doc = instance.data.get("assetEntity")
+ task_data = self._get_task_data_from_asset(
+ asset_doc, task_name, project_task_types
+ )
+ if task_data:
+ # Fill task data
+ # - if we're in editorial, make sure the task type is filled
+ if (
+ not instance.data.get("newAssetPublishing")
+ or task_data["type"]
+ ):
+ anatomy_data["task"] = task_data
+ return
+
+ # New hierarchy is not created, so we can only skip rest of the logic
+ if not instance.data.get("newAssetPublishing"):
+ return
+
+ # Try to find task data based on hierarchy context and asset name
+ hierarchy_context = instance.context.data.get("hierarchyContext")
+ asset_name = instance.data.get("asset")
+ if not hierarchy_context or not asset_name:
+ return
+
+ project_name = instance.context.data["projectName"]
+ # OpenPype approach vs AYON approach
+ if "/" not in asset_name:
+ tasks_info = self._find_tasks_info_in_hierarchy(
+ hierarchy_context, asset_name
+ )
+ else:
+ current_data = hierarchy_context.get(project_name, {})
+ for key in asset_name.split("/"):
+ if key:
+ current_data = current_data.get("childs", {}).get(key, {})
+ tasks_info = current_data.get("tasks", {})
+
+ task_info = tasks_info.get(task_name, {})
+ task_type = task_info.get("type")
+ task_code = (
+ project_task_types
+ .get(task_type, {})
+ .get("short_name")
+ )
+ anatomy_data["task"] = {
+ "name": task_name,
+ "type": task_type,
+ "short": task_code
+ }
+
+ def _get_task_data_from_asset(
+ self, asset_doc, task_name, project_task_types
+ ):
+ """
+
+ Args:
+ asset_doc (Union[dict[str, Any], None]): Asset document.
+ task_name (Union[str, None]): Task name.
+ project_task_types (dict[str, dict[str, Any]]): Project task
+ types.
+
+ Returns:
+ Union[dict[str, str], None]: Task data or None if not found.
+ """
+
+ if not asset_doc or not task_name:
+ return None
+
+ asset_tasks = asset_doc["data"]["tasks"]
+ task_type = asset_tasks.get(task_name, {}).get("type")
+ task_code = (
+ project_task_types
+ .get(task_type, {})
+ .get("short_name")
+ )
+ return {
+ "name": task_name,
+ "type": task_type,
+ "short": task_code
+ }
+
+ def _find_tasks_info_in_hierarchy(self, hierarchy_context, asset_name):
+ """Find tasks info for an asset in editorial hierarchy.
+
+ Args:
+ hierarchy_context (dict[str, Any]): Editorial hierarchy context.
+ asset_name (str): Asset name.
+
+ Returns:
+ dict[str, dict[str, Any]]: Tasks info by name.
+ """
+
+ hierarchy_queue = collections.deque()
+ hierarchy_queue.append(copy.deepcopy(hierarchy_context))
+ while hierarchy_queue:
+ item = hierarchy_queue.popleft()
+ if asset_name in item:
+ return item[asset_name].get("tasks") or {}
+
+ for subitem in item.values():
+ hierarchy_queue.extend(subitem.get("childs") or [])
+ return {}
diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py
index c8b67a3d05..6a871124f1 100644
--- a/openpype/plugins/publish/collect_resources_path.py
+++ b/openpype/plugins/publish/collect_resources_path.py
@@ -79,19 +79,6 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
"representation": "TEMP"
})
- # Add fill keys for editorial publishing creating new entity
- # TODO handle in editorial plugin
- if instance.data.get("newAssetPublishing"):
- if "hierarchy" not in template_data:
- template_data["hierarchy"] = instance.data["hierarchy"]
-
- if "asset" not in template_data:
- asset_name = instance.data["asset"].split("/")[-1]
- template_data["asset"] = asset_name
- template_data["folder"] = {
- "name": asset_name
- }
-
publish_templates = anatomy.templates_obj["publish"]
if "folder" in publish_templates:
publish_folder = publish_templates["folder"].format_strict(
diff --git a/openpype/plugins/publish/extract_color_transcode.py b/openpype/plugins/publish/extract_color_transcode.py
index faacb7af2e..922df469fe 100644
--- a/openpype/plugins/publish/extract_color_transcode.py
+++ b/openpype/plugins/publish/extract_color_transcode.py
@@ -189,6 +189,13 @@ class ExtractOIIOTranscode(publish.Extractor):
if len(new_repre["files"]) == 1:
new_repre["files"] = new_repre["files"][0]
+ # If the source representation has "review" tag, but its not
+ # part of the output defintion tags, then both the
+ # representations will be transcoded in ExtractReview and
+ # their outputs will clash in integration.
+ if "review" in repre.get("tags", []):
+ added_review = True
+
new_representations.append(new_repre)
added_representations = True
diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py
index b601a3fc29..9e84daca30 100644
--- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py
+++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py
@@ -30,8 +30,7 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin):
if not AYON_SERVER_ENABLED:
return
- hierarchy_context = context.data.get("hierarchyContext")
- if not hierarchy_context:
+ if not context.data.get("hierarchyContext"):
self.log.debug("Skipping ExtractHierarchyToAYON")
return
diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py
index 2b4ea0529a..10eb261482 100644
--- a/openpype/plugins/publish/extract_thumbnail.py
+++ b/openpype/plugins/publish/extract_thumbnail.py
@@ -231,7 +231,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"files": jpeg_file,
"stagingDir": dst_staging,
"thumbnail": True,
- "tags": new_repre_tags
+ "tags": new_repre_tags,
+ # If source image is jpg then there can be clash when
+ # integrating to making the output name explicit.
+ "outputName": "thumbnail"
}
# adding representation
diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py
index 401a5d615d..33cbf6d9bf 100644
--- a/openpype/plugins/publish/extract_thumbnail_from_source.py
+++ b/openpype/plugins/publish/extract_thumbnail_from_source.py
@@ -65,7 +65,8 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
"files": dst_filename,
"stagingDir": dst_staging,
"thumbnail": True,
- "tags": ["thumbnail"]
+ "tags": ["thumbnail"],
+ "outputName": "thumbnail",
}
# adding representation
diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py
index 9f0f7fe7f3..59dc6b5c64 100644
--- a/openpype/plugins/publish/integrate_hero_version.py
+++ b/openpype/plugins/publish/integrate_hero_version.py
@@ -54,7 +54,6 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin):
# permissions error on files (files were used or user didn't have perms)
# *but all other plugins must be sucessfully completed
- template_name_profiles = []
_default_template_name = "hero"
def process(self, instance):
diff --git a/openpype/resources/icons/folder-favorite.png b/openpype/resources/icons/folder-favorite.png
index 198b289e9e..65f04d8c86 100644
Binary files a/openpype/resources/icons/folder-favorite.png and b/openpype/resources/icons/folder-favorite.png differ
diff --git a/openpype/resources/icons/folder-favorite2.png b/openpype/resources/icons/folder-favorite2.png
deleted file mode 100644
index 91bc3f0fbe..0000000000
Binary files a/openpype/resources/icons/folder-favorite2.png and /dev/null differ
diff --git a/openpype/resources/icons/folder-favorite3.png b/openpype/resources/icons/folder-favorite3.png
deleted file mode 100644
index ce1e6d7171..0000000000
Binary files a/openpype/resources/icons/folder-favorite3.png and /dev/null differ
diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py
index 9d3366961f..4948f2431c 100644
--- a/openpype/settings/ayon_settings.py
+++ b/openpype/settings/ayon_settings.py
@@ -1219,6 +1219,8 @@ def _convert_global_project_settings(ayon_settings, output, default_settings):
for profile in extract_oiio_transcode_profiles:
new_outputs = {}
name_counter = {}
+ if "product_names" in profile:
+ profile["subsets"] = profile.pop("product_names")
for profile_output in profile["outputs"]:
if "name" in profile_output:
name = profile_output.pop("name")
@@ -1274,12 +1276,6 @@ def _convert_global_project_settings(ayon_settings, output, default_settings):
for extract_burnin_def in extract_burnin_defs
}
- ayon_integrate_hero = ayon_publish["IntegrateHeroVersion"]
- for profile in ayon_integrate_hero["template_name_profiles"]:
- if "product_types" not in profile:
- break
- profile["families"] = profile.pop("product_types")
-
if "IntegrateProductGroup" in ayon_publish:
subset_group = ayon_publish.pop("IntegrateProductGroup")
subset_group_profiles = subset_group.pop("product_grouping_profiles")
diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json
index a19464a5c1..b02cfa8207 100644
--- a/openpype/settings/defaults/project_settings/deadline.json
+++ b/openpype/settings/defaults/project_settings/deadline.json
@@ -65,6 +65,8 @@
"group": "",
"department": "",
"use_gpu": true,
+ "workfile_dependency": true,
+ "use_published_workfile": true,
"env_allowed_keys": [],
"env_search_replace_values": {},
"limit_groups": {}
diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json
index 0edcae060a..f890f94b6f 100644
--- a/openpype/settings/defaults/project_settings/fusion.json
+++ b/openpype/settings/defaults/project_settings/fusion.json
@@ -15,6 +15,11 @@
"copy_status": false,
"force_sync": false
},
+ "hooks": {
+ "InstallPySideToFusion": {
+ "enabled": true
+ }
+ },
"create": {
"CreateSaver": {
"temp_rendering_path_template": "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}",
@@ -26,7 +31,21 @@
"reviewable",
"farm_rendering"
],
- "image_format": "exr"
+ "image_format": "exr",
+ "default_frame_range_option": "asset_db"
+ },
+ "CreateImageSaver": {
+ "temp_rendering_path_template": "{workdir}/renders/fusion/{subset}/{subset}.{ext}",
+ "default_variants": [
+ "Main",
+ "Mask"
+ ],
+ "instance_attributes": [
+ "reviewable",
+ "farm_rendering"
+ ],
+ "image_format": "exr",
+ "default_frame": 0
}
},
"publish": {
diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json
index 19c9d10496..d1610610dc 100644
--- a/openpype/settings/defaults/project_settings/max.json
+++ b/openpype/settings/defaults/project_settings/max.json
@@ -1,4 +1,8 @@
{
+ "unit_scale_settings": {
+ "enabled": true,
+ "scene_unit_scale": "Meters"
+ },
"imageio": {
"activate_host_color_management": true,
"ocio_config": {
diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json
index 2efa0383cf..615000183d 100644
--- a/openpype/settings/defaults/project_settings/maya.json
+++ b/openpype/settings/defaults/project_settings/maya.json
@@ -1289,6 +1289,7 @@
"twoSidedLighting": true,
"lineAAEnable": true,
"multiSample": 8,
+ "loadTextures": false,
"useDefaultMaterial": false,
"wireframeOnShaded": false,
"xray": false,
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json
index 1aea778e32..42dea33ef9 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json
@@ -362,6 +362,16 @@
"key": "use_gpu",
"label": "Use GPU"
},
+ {
+ "type": "boolean",
+ "key": "workfile_dependency",
+ "label": "Workfile Dependency"
+ },
+ {
+ "type": "boolean",
+ "key": "use_published_workfile",
+ "label": "Use Published Workfile"
+ },
{
"type": "list",
"key": "env_allowed_keys",
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 5177d8bc7c..84d1efae78 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json
@@ -41,6 +41,29 @@
}
]
},
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "hooks",
+ "label": "Hooks",
+ "children": [
+ {
+ "type": "dict",
+ "collapsible": true,
+ "checkbox_key": "enabled",
+ "key": "InstallPySideToFusion",
+ "label": "Install PySide2",
+ "is_group": true,
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ }
+ ]
+ }
+ ]
+ },
{
"type": "dict",
"collapsible": true,
@@ -51,7 +74,7 @@
"type": "dict",
"collapsible": true,
"key": "CreateSaver",
- "label": "Create Saver",
+ "label": "Create Render Saver",
"is_group": true,
"children": [
{
@@ -93,6 +116,71 @@
{"tif": "tif"},
{"jpg": "jpg"}
]
+ },
+ {
+ "key": "default_frame_range_option",
+ "label": "Default frame range source",
+ "type": "enum",
+ "multiselect": false,
+ "enum_items": [
+ {"asset_db": "Current asset context"},
+ {"render_range": "From render in/out"},
+ {"comp_range": "From composition timeline"}
+ ]
+ }
+ ]
+ },
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "CreateImageSaver",
+ "label": "Create Image Saver",
+ "is_group": true,
+ "children": [
+ {
+ "type": "text",
+ "key": "temp_rendering_path_template",
+ "label": "Temporary rendering path template"
+ },
+ {
+ "type": "list",
+ "key": "default_variants",
+ "label": "Default variants",
+ "object_type": {
+ "type": "text"
+ }
+ },
+ {
+ "key": "instance_attributes",
+ "label": "Instance attributes",
+ "type": "enum",
+ "multiselection": true,
+ "enum_items": [
+ {
+ "reviewable": "Reviewable"
+ },
+ {
+ "farm_rendering": "Farm rendering"
+ }
+ ]
+ },
+ {
+ "key": "image_format",
+ "label": "Output Image Format",
+ "type": "enum",
+ "multiselect": false,
+ "enum_items": [
+ {"exr": "exr"},
+ {"tga": "tga"},
+ {"png": "png"},
+ {"tif": "tif"},
+ {"jpg": "jpg"}
+ ]
+ },
+ {
+ "type": "number",
+ "key": "default_frame",
+ "label": "Default rendered frame"
}
]
}
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json
index 78cca357a3..e4d4d40ce7 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json
@@ -5,6 +5,34 @@
"label": "Max",
"is_file": true,
"children": [
+ {
+ "key": "unit_scale_settings",
+ "type": "dict",
+ "label": "Set Unit Scale",
+ "collapsible": true,
+ "is_group": true,
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "key": "scene_unit_scale",
+ "label": "Scene Unit Scale",
+ "type": "enum",
+ "multiselection": false,
+ "defaults": "exr",
+ "enum_items": [
+ {"Millimeters": "mm"},
+ {"Centimeters": "cm"},
+ {"Meters": "m"},
+ {"Kilometers": "km"}
+ ]
+ }
+ ]
+ },
{
"key": "imageio",
"type": "dict",
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json
index ac2d9e190d..64f292a140 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json
@@ -1023,49 +1023,6 @@
{
"type": "label",
"label": "NOTE: Hero publish template profiles settings were moved to Tools/Publish/Hero template name profiles. Please move values there."
- },
- {
- "type": "list",
- "key": "template_name_profiles",
- "label": "Template name profiles (DEPRECATED)",
- "use_label_wrap": true,
- "object_type": {
- "type": "dict",
- "children": [
- {
- "key": "families",
- "label": "Families",
- "type": "list",
- "object_type": "text"
- },
- {
- "type": "hosts-enum",
- "key": "hosts",
- "label": "Hosts",
- "multiselection": true
- },
- {
- "key": "task_types",
- "label": "Task types",
- "type": "task-types-enum"
- },
- {
- "key": "task_names",
- "label": "Task names",
- "type": "list",
- "object_type": "text"
- },
- {
- "type": "separator"
- },
- {
- "type": "text",
- "key": "template_name",
- "label": "Template name",
- "tooltip": "Name of template from Anatomy templates"
- }
- ]
- }
}
]
},
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json
index d90527ac8c..76ad9a3ba2 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json
@@ -236,6 +236,11 @@
{
"type": "splitter"
},
+ {
+ "type": "boolean",
+ "key": "loadTextures",
+ "label": "Load Textures"
+ },
{
"type": "boolean",
"key": "useDefaultMaterial",
@@ -908,6 +913,12 @@
{
"type": "splitter"
},
+ {
+ "type": "boolean",
+ "key": "loadTextures",
+ "label": "Load Textures",
+ "default": false
+ },
{
"type": "boolean",
"key": "useDefaultMaterial",
diff --git a/openpype/tools/ayon_loader/models/site_sync.py b/openpype/tools/ayon_loader/models/site_sync.py
index 90852b6954..4b7ddee481 100644
--- a/openpype/tools/ayon_loader/models/site_sync.py
+++ b/openpype/tools/ayon_loader/models/site_sync.py
@@ -140,12 +140,10 @@ class SiteSyncModel:
Union[dict[str, Any], None]: Site icon definition.
"""
- if not project_name:
+ if not project_name or not self.is_site_sync_enabled(project_name):
return None
-
active_site = self.get_active_site(project_name)
- provider = self._get_provider_for_site(project_name, active_site)
- return self._get_provider_icon(provider)
+ return self._get_site_icon_def(project_name, active_site)
def get_remote_site_icon_def(self, project_name):
"""Remote site icon definition.
@@ -160,7 +158,14 @@ class SiteSyncModel:
if not project_name or not self.is_site_sync_enabled(project_name):
return None
remote_site = self.get_remote_site(project_name)
- provider = self._get_provider_for_site(project_name, remote_site)
+ return self._get_site_icon_def(project_name, remote_site)
+
+ def _get_site_icon_def(self, project_name, site_name):
+ # use different icon for studio even if provider is 'local_drive'
+ if site_name == self._site_sync_addon.DEFAULT_SITE:
+ provider = "studio"
+ else:
+ provider = self._get_provider_for_site(project_name, site_name)
return self._get_provider_icon(provider)
def get_version_sync_availability(self, project_name, version_ids):
diff --git a/openpype/tools/ayon_sceneinventory/control.py b/openpype/tools/ayon_sceneinventory/control.py
index 6111d7e43b..3b063ff72e 100644
--- a/openpype/tools/ayon_sceneinventory/control.py
+++ b/openpype/tools/ayon_sceneinventory/control.py
@@ -84,9 +84,9 @@ class SceneInventoryController:
def get_containers(self):
host = self._host
if isinstance(host, ILoadHost):
- return host.get_containers()
+ return list(host.get_containers())
elif hasattr(host, "ls"):
- return host.ls()
+ return list(host.ls())
return []
# Site Sync methods
diff --git a/openpype/tools/ayon_sceneinventory/model.py b/openpype/tools/ayon_sceneinventory/model.py
index 16924b0a7e..f4450f0ac3 100644
--- a/openpype/tools/ayon_sceneinventory/model.py
+++ b/openpype/tools/ayon_sceneinventory/model.py
@@ -23,6 +23,7 @@ from openpype.pipeline import (
)
from openpype.style import get_default_entity_icon_color
from openpype.tools.utils.models import TreeModel, Item
+from openpype.tools.ayon_utils.widgets import get_qt_icon
def walk_hierarchy(node):
@@ -71,8 +72,8 @@ class InventoryModel(TreeModel):
site_icons = self._controller.get_site_provider_icons()
self._site_icons = {
- provider: QtGui.QIcon(icon_path)
- for provider, icon_path in site_icons.items()
+ provider: get_qt_icon(icon_def)
+ for provider, icon_def in site_icons.items()
}
def outdated(self, item):
diff --git a/openpype/tools/ayon_sceneinventory/models/site_sync.py b/openpype/tools/ayon_sceneinventory/models/site_sync.py
index 1297137cb0..bd65ad1778 100644
--- a/openpype/tools/ayon_sceneinventory/models/site_sync.py
+++ b/openpype/tools/ayon_sceneinventory/models/site_sync.py
@@ -42,8 +42,8 @@ class SiteSyncModel:
if not self.is_sync_server_enabled():
return {}
- site_sync = self._get_sync_server_module()
- return site_sync.get_site_icons()
+ site_sync_addon = self._get_sync_server_module()
+ return site_sync_addon.get_site_icons()
def get_sites_information(self):
return {
@@ -150,23 +150,23 @@ class SiteSyncModel:
return self._remote_site_provider
def _cache_sites(self):
- site_sync = self._get_sync_server_module()
active_site = None
remote_site = None
active_site_provider = None
remote_site_provider = None
- if site_sync is not None:
+ if self.is_sync_server_enabled():
+ site_sync = self._get_sync_server_module()
project_name = self._controller.get_current_project_name()
active_site = site_sync.get_active_site(project_name)
remote_site = site_sync.get_remote_site(project_name)
active_site_provider = "studio"
remote_site_provider = "studio"
if active_site != "studio":
- active_site_provider = site_sync.get_active_provider(
+ active_site_provider = site_sync.get_provider_for_site(
project_name, active_site
)
if remote_site != "studio":
- remote_site_provider = site_sync.get_active_provider(
+ remote_site_provider = site_sync.get_provider_for_site(
project_name, remote_site
)
diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py
index d74a8e164d..f9f910ac8a 100644
--- a/openpype/tools/ayon_workfiles/models/workfiles.py
+++ b/openpype/tools/ayon_workfiles/models/workfiles.py
@@ -606,7 +606,7 @@ class PublishWorkfilesModel:
print("Failed to format workfile path: {}".format(exc))
dirpath, filename = os.path.split(workfile_path)
- created_at = arrow.get(repre_entity["createdAt"].to("local"))
+ created_at = arrow.get(repre_entity["createdAt"]).to("local")
return FileItem(
dirpath,
filename,
diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py
index b02d83e4f6..47e374edf2 100644
--- a/openpype/tools/publisher/control.py
+++ b/openpype/tools/publisher/control.py
@@ -10,6 +10,7 @@ import inspect
from abc import ABCMeta, abstractmethod
import six
+import arrow
import pyblish.api
from openpype import AYON_SERVER_ENABLED
@@ -285,6 +286,8 @@ class PublishReportMaker:
def get_report(self, publish_plugins=None):
"""Report data with all details of current state."""
+
+ now = arrow.utcnow().to("local")
instances_details = {}
for instance in self._all_instances_by_id.values():
instances_details[instance.id] = self._extract_instance_data(
@@ -334,7 +337,8 @@ class PublishReportMaker:
"context": self._extract_context_data(self._current_context),
"crashed_file_paths": crashed_file_paths,
"id": uuid.uuid4().hex,
- "report_version": "1.0.0"
+ "created_at": now.isoformat(),
+ "report_version": "1.0.1",
}
def _extract_context_data(self, context):
diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py
index 663a67ac70..460a269f1a 100644
--- a/openpype/tools/publisher/publish_report_viewer/model.py
+++ b/openpype/tools/publisher/publish_report_viewer/model.py
@@ -26,14 +26,14 @@ class InstancesModel(QtGui.QStandardItemModel):
return self._items_by_id
def set_report(self, report_item):
- self.clear()
+ root_item = self.invisibleRootItem()
+ if root_item.rowCount() > 0:
+ root_item.removeRows(0, root_item.rowCount())
self._items_by_id.clear()
self._plugin_items_by_id.clear()
if not report_item:
return
- root_item = self.invisibleRootItem()
-
families = set(report_item.instance_items_by_family.keys())
families.remove(None)
all_families = list(sorted(families))
@@ -125,14 +125,14 @@ class PluginsModel(QtGui.QStandardItemModel):
return self._items_by_id
def set_report(self, report_item):
- self.clear()
+ root_item = self.invisibleRootItem()
+ if root_item.rowCount() > 0:
+ root_item.removeRows(0, root_item.rowCount())
self._items_by_id.clear()
self._plugin_items_by_id.clear()
if not report_item:
return
- root_item = self.invisibleRootItem()
-
labels_iter = iter(self.order_label_mapping)
cur_order, cur_label = next(labels_iter)
cur_plugin_items = []
diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py
index dc4ad70934..f9c8c05802 100644
--- a/openpype/tools/publisher/publish_report_viewer/window.py
+++ b/openpype/tools/publisher/publish_report_viewer/window.py
@@ -4,6 +4,7 @@ import six
import uuid
import appdirs
+import arrow
from qtpy import QtWidgets, QtCore, QtGui
from openpype import style
@@ -25,6 +26,7 @@ else:
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
+ITEM_CREATED_AT_ROLE = QtCore.Qt.UserRole + 2
def get_reports_dir():
@@ -47,47 +49,77 @@ class PublishReportItem:
"""Report item representing one file in report directory."""
def __init__(self, content):
- item_id = content.get("id")
- changed = False
- if not item_id:
- item_id = str(uuid.uuid4())
- changed = True
- content["id"] = item_id
+ changed = self._fix_content(content)
- if not content.get("report_version"):
- changed = True
- content["report_version"] = "0.0.1"
-
- report_path = os.path.join(get_reports_dir(), item_id)
+ report_path = os.path.join(get_reports_dir(), content["id"])
file_modified = None
if os.path.exists(report_path):
file_modified = os.path.getmtime(report_path)
+
+ created_at_obj = arrow.get(content["created_at"]).to("local")
+ created_at = created_at_obj.float_timestamp
+
self.content = content
self.report_path = report_path
self.file_modified = file_modified
+ self.created_at = float(created_at)
self._loaded_label = content.get("label")
self._changed = changed
self.publish_report = PublishReport(content)
@property
def version(self):
+ """Publish report version.
+
+ Returns:
+ str: Publish report version.
+ """
return self.content["report_version"]
@property
def id(self):
+ """Publish report id.
+
+ Returns:
+ str: Publish report id.
+ """
+
return self.content["id"]
def get_label(self):
+ """Publish report label.
+
+ Returns:
+ str: Publish report label showed in UI.
+ """
+
return self.content.get("label") or "Unfilled label"
def set_label(self, label):
+ """Set publish report label.
+
+ Args:
+ label (str): New publish report label.
+ """
+
if not label:
self.content.pop("label", None)
self.content["label"] = label
label = property(get_label, set_label)
+ @property
+ def loaded_label(self):
+ return self._loaded_label
+
+ def mark_as_changed(self):
+ """Mark report as changed."""
+
+ self._changed = True
+
def save(self):
+ """Save publish report to file."""
+
save = False
if (
self._changed
@@ -109,6 +141,15 @@ class PublishReportItem:
@classmethod
def from_filepath(cls, filepath):
+ """Create report item from file.
+
+ Args:
+ filepath (str): Path to report file. Content must be json.
+
+ Returns:
+ PublishReportItem: Report item.
+ """
+
if not os.path.exists(filepath):
return None
@@ -116,15 +157,25 @@ class PublishReportItem:
with open(filepath, "r") as stream:
content = json.load(stream)
- return cls(content)
+ file_modified = os.path.getmtime(filepath)
+ changed = cls._fix_content(content, file_modified=file_modified)
+ obj = cls(content)
+ if changed:
+ obj.mark_as_changed()
+ return obj
+
except Exception:
return None
def remove_file(self):
+ """Remove report file."""
+
if os.path.exists(self.report_path):
os.remove(self.report_path)
def update_file_content(self):
+ """Update report content in file."""
+
if not os.path.exists(self.report_path):
return
@@ -148,9 +199,57 @@ class PublishReportItem:
self.content = content
self.file_modified = file_modified
+ @classmethod
+ def _fix_content(cls, content, file_modified=None):
+ """Fix content for backward compatibility of older report items.
+
+ Args:
+ content (dict[str, Any]): Report content.
+ file_modified (Optional[float]): File modification time.
+
+ Returns:
+ bool: True if content was changed, False otherwise.
+ """
+
+ # Fix created_at key
+ changed = cls._fix_created_at(content, file_modified)
+
+ # NOTE backward compatibility for 'id' and 'report_version' is from
+ # 28.10.2022 https://github.com/ynput/OpenPype/pull/4040
+ # We can probably safely remove it
+
+ # Fix missing 'id'
+ item_id = content.get("id")
+ if not item_id:
+ item_id = str(uuid.uuid4())
+ changed = True
+ content["id"] = item_id
+
+ # Fix missing 'report_version'
+ if not content.get("report_version"):
+ changed = True
+ content["report_version"] = "0.0.1"
+ return changed
+
+ @classmethod
+ def _fix_created_at(cls, content, file_modified):
+ # Key 'create_at' was added in report version 1.0.1
+ created_at = content.get("created_at")
+ if created_at:
+ return False
+
+ # Auto fix 'created_at', use file modification time if it is not set
+ # or current time if modification could not be received.
+ if file_modified is not None:
+ created_at_obj = arrow.Arrow.fromtimestamp(file_modified)
+ else:
+ created_at_obj = arrow.utcnow()
+ content["created_at"] = created_at_obj.to("local").isoformat()
+ return True
+
class PublisherReportHandler:
- """Class handling storing publish report tool."""
+ """Class handling storing publish report items."""
def __init__(self):
self._reports = None
@@ -173,14 +272,23 @@ class PublisherReportHandler:
continue
filepath = os.path.join(report_dir, filename)
item = PublishReportItem.from_filepath(filepath)
- reports.append(item)
- reports_by_id[item.id] = item
+ if item is not None:
+ reports.append(item)
+ reports_by_id[item.id] = item
self._reports = reports
self._reports_by_id = reports_by_id
return reports
- def remove_report_items(self, item_id):
+ def remove_report_item(self, item_id):
+ """Remove report item by id.
+
+ Remove from cache and also remove the file with the content.
+
+ Args:
+ item_id (str): Report item id.
+ """
+
item = self._reports_by_id.get(item_id)
if item:
try:
@@ -191,9 +299,16 @@ class PublisherReportHandler:
class LoadedFilesModel(QtGui.QStandardItemModel):
+ header_labels = ("Reports", "Created")
+
def __init__(self, *args, **kwargs):
super(LoadedFilesModel, self).__init__(*args, **kwargs)
+ # Column count must be set before setting header data
+ self.setColumnCount(len(self.header_labels))
+ for col, label in enumerate(self.header_labels):
+ self.setHeaderData(col, QtCore.Qt.Horizontal, label)
+
self._items_by_id = {}
self._report_items_by_id = {}
@@ -202,10 +317,14 @@ class LoadedFilesModel(QtGui.QStandardItemModel):
self._loading_registry = False
def refresh(self):
- self._handler.reset()
+ root_item = self.invisibleRootItem()
+ if root_item.rowCount() > 0:
+ root_item.removeRows(0, root_item.rowCount())
self._items_by_id = {}
self._report_items_by_id = {}
+ self._handler.reset()
+
new_items = []
for report_item in self._handler.list_reports():
item = self._create_item(report_item)
@@ -217,26 +336,26 @@ class LoadedFilesModel(QtGui.QStandardItemModel):
root_item = self.invisibleRootItem()
root_item.appendRows(new_items)
- def headerData(self, section, orientation, role):
- if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
- if section == 0:
- return "Exports"
- if section == 1:
- return "Modified"
- return ""
- super(LoadedFilesModel, self).headerData(section, orientation, role)
-
def data(self, index, role=None):
if role is None:
role = QtCore.Qt.DisplayRole
col = index.column()
+ if col == 1:
+ if role in (
+ QtCore.Qt.DisplayRole, QtCore.Qt.InitialSortOrderRole
+ ):
+ role = ITEM_CREATED_AT_ROLE
+
if col != 0:
index = self.index(index.row(), 0, index.parent())
return super(LoadedFilesModel, self).data(index, role)
- def setData(self, index, value, role):
+ def setData(self, index, value, role=None):
+ if role is None:
+ role = QtCore.Qt.EditRole
+
if role == QtCore.Qt.EditRole:
item_id = index.data(ITEM_ID_ROLE)
report_item = self._report_items_by_id.get(item_id)
@@ -247,6 +366,12 @@ class LoadedFilesModel(QtGui.QStandardItemModel):
return super(LoadedFilesModel, self).setData(index, value, role)
+ def flags(self, index):
+ # Allow editable flag only for first column
+ if index.column() > 0:
+ return QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled
+ return super(LoadedFilesModel, self).flags(index)
+
def _create_item(self, report_item):
if report_item.id in self._items_by_id:
return None
@@ -254,6 +379,7 @@ class LoadedFilesModel(QtGui.QStandardItemModel):
item = QtGui.QStandardItem(report_item.label)
item.setColumnCount(self.columnCount())
item.setData(report_item.id, ITEM_ID_ROLE)
+ item.setData(report_item.created_at, ITEM_CREATED_AT_ROLE)
return item
@@ -278,16 +404,16 @@ class LoadedFilesModel(QtGui.QStandardItemModel):
new_items = []
for normalized_path in filtered_paths:
- try:
- with open(normalized_path, "r") as stream:
- data = json.load(stream)
- report_item = PublishReportItem(data)
- except Exception:
- # TODO handle errors
+ report_item = PublishReportItem.from_filepath(normalized_path)
+ if report_item is None:
continue
- label = data.get("label")
- if not label:
+ # Skip already added report items
+ # QUESTION: Should we replace existing or skip the item?
+ if report_item.id in self._items_by_id:
+ continue
+
+ if not report_item.loaded_label:
report_item.label = (
os.path.splitext(os.path.basename(filepath))[0]
)
@@ -306,15 +432,13 @@ class LoadedFilesModel(QtGui.QStandardItemModel):
root_item.appendRows(new_items)
def remove_item_by_id(self, item_id):
- report_item = self._report_items_by_id.get(item_id)
- if not report_item:
- return
+ self._handler.remove_report_item(item_id)
- self._handler.remove_report_items(item_id)
- item = self._items_by_id.get(item_id)
-
- parent = self.invisibleRootItem()
- parent.removeRow(item.row())
+ self._report_items_by_id.pop(item_id, None)
+ item = self._items_by_id.pop(item_id, None)
+ if item is not None:
+ parent = self.invisibleRootItem()
+ parent.removeRow(item.row())
def get_report_by_id(self, item_id):
report_item = self._report_items_by_id.get(item_id)
@@ -335,13 +459,18 @@ class LoadedFilesView(QtWidgets.QTreeView):
)
self.setIndentation(0)
self.setAlternatingRowColors(True)
+ self.setSortingEnabled(True)
model = LoadedFilesModel()
- self.setModel(model)
+ proxy_model = QtCore.QSortFilterProxyModel()
+ proxy_model.setSourceModel(model)
+ self.setModel(proxy_model)
time_delegate = PrettyTimeDelegate()
self.setItemDelegateForColumn(1, time_delegate)
+ self.sortByColumn(1, QtCore.Qt.AscendingOrder)
+
remove_btn = IconButton(self)
remove_icon_path = resources.get_icon_path("delete")
loaded_remove_image = QtGui.QImage(remove_icon_path)
@@ -356,6 +485,7 @@ class LoadedFilesView(QtWidgets.QTreeView):
)
self._model = model
+ self._proxy_model = proxy_model
self._time_delegate = time_delegate
self._remove_btn = remove_btn
@@ -403,7 +533,8 @@ class LoadedFilesView(QtWidgets.QTreeView):
if index.isValid():
return
- index = self._model.index(0, 0)
+ model = self.model()
+ index = model.index(0, 0)
if index.isValid():
self.setCurrentIndex(index)
diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py
index 3504b419b4..37b958c1c7 100644
--- a/openpype/tools/publisher/widgets/screenshot_widget.py
+++ b/openpype/tools/publisher/widgets/screenshot_widget.py
@@ -18,10 +18,11 @@ class ScreenMarquee(QtWidgets.QDialog):
super(ScreenMarquee, self).__init__(parent=parent)
self.setWindowFlags(
- QtCore.Qt.FramelessWindowHint
+ QtCore.Qt.Window
+ | QtCore.Qt.FramelessWindowHint
| QtCore.Qt.WindowStaysOnTopHint
| QtCore.Qt.CustomizeWindowHint
- | QtCore.Qt.Tool)
+ )
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setCursor(QtCore.Qt.CrossCursor)
self.setMouseTracking(True)
@@ -210,6 +211,9 @@ class ScreenMarquee(QtWidgets.QDialog):
"""
tool = cls()
+ # Activate so Escape event is not ignored.
+ tool.setWindowState(QtCore.Qt.WindowActive)
+ # Exec dialog and return captured pixmap.
tool.exec_()
return tool.get_captured_pixmap()
diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py
index b3138c3f45..5dd6998b24 100644
--- a/openpype/tools/publisher/window.py
+++ b/openpype/tools/publisher/window.py
@@ -42,7 +42,7 @@ from .widgets import (
)
-class PublisherWindow(QtWidgets.QDialog):
+class PublisherWindow(QtWidgets.QWidget):
"""Main window of publisher."""
default_width = 1300
default_height = 800
@@ -50,7 +50,7 @@ class PublisherWindow(QtWidgets.QDialog):
publish_footer_spacer = 2
def __init__(self, parent=None, controller=None, reset_on_show=None):
- super(PublisherWindow, self).__init__(parent)
+ super(PublisherWindow, self).__init__()
self.setObjectName("PublishWindow")
@@ -64,17 +64,12 @@ class PublisherWindow(QtWidgets.QDialog):
if reset_on_show is None:
reset_on_show = True
- if parent is None:
- on_top_flag = QtCore.Qt.WindowStaysOnTopHint
- else:
- on_top_flag = QtCore.Qt.Dialog
-
self.setWindowFlags(
- QtCore.Qt.WindowTitleHint
+ QtCore.Qt.Window
+ | QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowMaximizeButtonHint
| QtCore.Qt.WindowMinimizeButtonHint
| QtCore.Qt.WindowCloseButtonHint
- | on_top_flag
)
if controller is None:
@@ -189,7 +184,7 @@ class PublisherWindow(QtWidgets.QDialog):
controller, content_stacked_widget
)
- report_widget = ReportPageWidget(controller, parent)
+ report_widget = ReportPageWidget(controller, content_stacked_widget)
# Details - Publish details
publish_details_widget = PublishReportViewerWidget(
@@ -299,6 +294,12 @@ class PublisherWindow(QtWidgets.QDialog):
controller.event_system.add_callback(
"publish.process.stopped", self._on_publish_stop
)
+ controller.event_system.add_callback(
+ "publish.process.instance.changed", self._on_instance_change
+ )
+ controller.event_system.add_callback(
+ "publish.process.plugin.changed", self._on_plugin_change
+ )
controller.event_system.add_callback(
"show.card.message", self._on_overlay_message
)
@@ -557,6 +558,18 @@ class PublisherWindow(QtWidgets.QDialog):
self._reset_on_show = False
self.reset()
+ def _make_sure_on_top(self):
+ """Raise window to top and activate it.
+
+ This may not work for some DCCs without Qt.
+ """
+
+ if not self._window_is_visible:
+ self.show()
+
+ self.setWindowState(QtCore.Qt.WindowActive)
+ self.raise_()
+
def _checks_before_save(self, explicit_save):
"""Save of changes may trigger some issues.
@@ -869,6 +882,12 @@ class PublisherWindow(QtWidgets.QDialog):
if self._is_on_create_tab():
self._go_to_publish_tab()
+ def _on_instance_change(self):
+ self._make_sure_on_top()
+
+ def _on_plugin_change(self):
+ self._make_sure_on_top()
+
def _on_publish_validated_change(self, event):
if event["value"]:
self._validate_btn.setEnabled(False)
@@ -879,6 +898,7 @@ class PublisherWindow(QtWidgets.QDialog):
self._comment_input.setText("")
def _on_publish_stop(self):
+ self._make_sure_on_top()
self._set_publish_overlay_visibility(False)
self._reset_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py
index ce2272df57..150e369678 100644
--- a/openpype/tools/sceneinventory/switch_dialog.py
+++ b/openpype/tools/sceneinventory/switch_dialog.py
@@ -1230,12 +1230,12 @@ class SwitchAssetDialog(QtWidgets.QDialog):
version_ids = list()
- version_docs_by_parent_id = {}
+ version_docs_by_parent_id_and_name = collections.defaultdict(dict)
for version_doc in version_docs:
parent_id = version_doc["parent"]
- if parent_id not in version_docs_by_parent_id:
- version_ids.append(version_doc["_id"])
- version_docs_by_parent_id[parent_id] = version_doc
+ version_ids.append(version_doc["_id"])
+ name = version_doc["name"]
+ version_docs_by_parent_id_and_name[parent_id][name] = version_doc
hero_version_docs_by_parent_id = {}
for hero_version_doc in hero_version_docs:
@@ -1293,13 +1293,32 @@ class SwitchAssetDialog(QtWidgets.QDialog):
repre_doc = _repres.get(container_repre_name)
if not repre_doc:
- version_doc = version_docs_by_parent_id[subset_id]
- version_id = version_doc["_id"]
- repres_by_name = repre_docs_by_parent_id_by_name[version_id]
- if selected_representation:
- repre_doc = repres_by_name[selected_representation]
+ version_docs_by_name = version_docs_by_parent_id_and_name[
+ subset_id
+ ]
+
+ # If asset or subset are selected for switching, we use latest
+ # version else we try to keep the current container version.
+ if (
+ selected_asset not in (None, container_asset_name)
+ or selected_subset not in (None, container_subset_name)
+ ):
+ version_name = max(version_docs_by_name)
else:
- repre_doc = repres_by_name[container_repre_name]
+ version_name = container_version["name"]
+
+ version_doc = version_docs_by_name[version_name]
+ version_id = version_doc["_id"]
+ repres_docs_by_name = repre_docs_by_parent_id_by_name[
+ version_id
+ ]
+
+ if selected_representation:
+ repres_name = selected_representation
+ else:
+ repres_name = container_repre_name
+
+ repre_doc = repres_docs_by_name[repres_name]
error = None
try:
diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py
index 723e71e7aa..365caaafd9 100644
--- a/openpype/tools/utils/lib.py
+++ b/openpype/tools/utils/lib.py
@@ -91,7 +91,8 @@ def set_style_property(widget, property_name, property_value):
if cur_value == property_value:
return
widget.setProperty(property_name, property_value)
- widget.style().polish(widget)
+ style = widget.style()
+ style.polish(widget)
def paint_image_with_color(image, color):
diff --git a/openpype/version.py b/openpype/version.py
index c4ff4dde95..ddfb3ebeeb 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.18.2-nightly.2"
+__version__ = "3.18.5"
diff --git a/pyproject.toml b/pyproject.toml
index e64018498f..24172aa77f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.18.1" # OpenPype
+version = "3.18.5" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
@@ -181,3 +181,8 @@ reportMissingTypeStubs = false
[tool.poetry.extras]
docs = ["Sphinx", "furo", "sphinxcontrib-napoleon"]
+
+[tool.pydocstyle]
+inherit = false
+convetion = "google"
+match = "(?!test_).*\\.py"
diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json
index 35f1b4cfbb..b0b12b2003 100644
--- a/server_addon/applications/server/applications.json
+++ b/server_addon/applications/server/applications.json
@@ -307,9 +307,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--nukeassist"],
"darwin": [],
- "linux": []
+ "linux": ["--nukeassist"]
},
"environment": "{}",
"use_python_2": false
@@ -329,9 +329,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--nukeassist"],
"darwin": [],
- "linux": []
+ "linux": ["--nukeassist"]
},
"environment": "{}",
"use_python_2": false
@@ -351,9 +351,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--nukeassist"],
"darwin": [],
- "linux": []
+ "linux": ["--nukeassist"]
},
"environment": "{}",
"use_python_2": false
@@ -382,9 +382,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--nukex"],
"darwin": [],
- "linux": []
+ "linux": ["--nukex"]
},
"environment": "{}",
"use_python_2": false
@@ -404,9 +404,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--nukex"],
"darwin": [],
- "linux": []
+ "linux": ["--nukex"]
},
"environment": "{}",
"use_python_2": false
@@ -426,9 +426,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--nukex"],
"darwin": [],
- "linux": []
+ "linux": ["--nukex"]
},
"environment": "{}",
"use_python_2": false
@@ -457,9 +457,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--studio"],
"darwin": [],
- "linux": []
+ "linux": ["--studio"]
},
"environment": "{}",
"use_python_2": false
@@ -479,9 +479,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--studio"],
"darwin": [],
- "linux": []
+ "linux": ["--studio"]
},
"environment": "{}",
"use_python_2": false
@@ -501,9 +501,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--studio"],
"darwin": [],
- "linux": []
+ "linux": ["--studio"]
},
"environment": "{}",
"use_python_2": false
@@ -532,9 +532,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--hiero"],
"darwin": [],
- "linux": []
+ "linux": ["--hiero"]
},
"environment": "{}",
"use_python_2": false
@@ -554,9 +554,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--hiero"],
"darwin": [],
- "linux": []
+ "linux": ["--hiero"]
},
"environment": "{}",
"use_python_2": false
@@ -576,9 +576,9 @@
]
},
"arguments": {
- "windows": [],
+ "windows": ["--hiero"],
"darwin": [],
- "linux": []
+ "linux": ["--hiero"]
},
"environment": "{}",
"use_python_2": false
diff --git a/server_addon/applications/server/version.py b/server_addon/applications/server/version.py
index ae7362549b..bbab0242f6 100644
--- a/server_addon/applications/server/version.py
+++ b/server_addon/applications/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.3"
+__version__ = "0.1.4"
diff --git a/server_addon/core/server/settings/publish_plugins.py b/server_addon/core/server/settings/publish_plugins.py
index ef52416369..0c9b9c96ef 100644
--- a/server_addon/core/server/settings/publish_plugins.py
+++ b/server_addon/core/server/settings/publish_plugins.py
@@ -697,13 +697,6 @@ class IntegrateHeroVersionModel(BaseSettingsModel):
optional: bool = Field(False, title="Optional")
active: bool = Field(True, title="Active")
families: list[str] = Field(default_factory=list, title="Families")
- # TODO remove when removed from client code
- template_name_profiles: list[IntegrateHeroTemplateNameProfileModel] = (
- Field(
- default_factory=list,
- title="Template name profiles"
- )
- )
class CleanUpModel(BaseSettingsModel):
@@ -1049,19 +1042,6 @@ DEFAULT_PUBLISH_VALUES = {
"layout",
"mayaScene",
"simpleUnrealTexture"
- ],
- "template_name_profiles": [
- {
- "product_types": [
- "simpleUnrealTexture"
- ],
- "hosts": [
- "standalonepublisher"
- ],
- "task_types": [],
- "task_names": [],
- "template_name": "simpleUnrealTextureHero"
- }
]
},
"CleanUp": {
diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py
index a989f3ad9d..dc2cd7591f 100644
--- a/server_addon/deadline/server/settings/publish_plugins.py
+++ b/server_addon/deadline/server/settings/publish_plugins.py
@@ -87,7 +87,7 @@ class MayaSubmitDeadlineModel(BaseSettingsModel):
title="Disable Strict Error Check profiles"
)
- @validator("limit", "scene_patches")
+ @validator("scene_patches")
def validate_unique_names(cls, value):
ensure_unique_names(value)
return value
@@ -161,6 +161,8 @@ class NukeSubmitDeadlineModel(BaseSettingsModel):
group: str = Field(title="Group")
department: str = Field(title="Department")
use_gpu: bool = Field(title="Use GPU")
+ workfile_dependency: bool = Field(title="Workfile Dependency")
+ use_published_workfile: bool = Field(title="Use Published Workfile")
env_allowed_keys: list[str] = Field(
default_factory=list,
@@ -382,6 +384,8 @@ DEFAULT_DEADLINE_PLUGINS_SETTINGS = {
"group": "",
"department": "",
"use_gpu": True,
+ "workfile_dependency": True,
+ "use_published_workfile": True,
"env_allowed_keys": [],
"env_search_replace_values": [],
"limit_groups": []
diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py
index 1276d0254f..9cb17e7976 100644
--- a/server_addon/deadline/server/version.py
+++ b/server_addon/deadline/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.5"
+__version__ = "0.1.8"
diff --git a/server_addon/fusion/server/settings.py b/server_addon/fusion/server/settings.py
index 1bc12773d2..bf295f3064 100644
--- a/server_addon/fusion/server/settings.py
+++ b/server_addon/fusion/server/settings.py
@@ -35,6 +35,14 @@ def _image_format_enum():
]
+def _frame_range_options_enum():
+ return [
+ {"value": "asset_db", "label": "Current asset context"},
+ {"value": "render_range", "label": "From render in/out"},
+ {"value": "comp_range", "label": "From composition timeline"},
+ ]
+
+
class CreateSaverPluginModel(BaseSettingsModel):
_isGroup = True
temp_rendering_path_template: str = Field(
@@ -49,16 +57,49 @@ class CreateSaverPluginModel(BaseSettingsModel):
enum_resolver=_create_saver_instance_attributes_enum,
title="Instance attributes"
)
- image_format: str = Field(
- enum_resolver=_image_format_enum,
- title="Output Image Format"
+ output_formats: list[str] = Field(
+ default_factory=list,
+ title="Output formats"
)
+class HookOptionalModel(BaseSettingsModel):
+ enabled: bool = Field(
+ True,
+ title="Enabled"
+ )
+
+
+class HooksModel(BaseSettingsModel):
+ InstallPySideToFusion: HookOptionalModel = Field(
+ default_factory=HookOptionalModel,
+ title="Install PySide2"
+ )
+
+
+class CreateSaverModel(CreateSaverPluginModel):
+ default_frame_range_option: str = Field(
+ default="asset_db",
+ enum_resolver=_frame_range_options_enum,
+ title="Default frame range source"
+ )
+
+
+class CreateImageSaverModel(CreateSaverPluginModel):
+ default_frame: int = Field(
+ 0,
+ title="Default rendered frame"
+ )
class CreatPluginsModel(BaseSettingsModel):
- CreateSaver: CreateSaverPluginModel = Field(
- default_factory=CreateSaverPluginModel,
- title="Create Saver"
+ CreateSaver: CreateSaverModel = Field(
+ default_factory=CreateSaverModel,
+ title="Create Saver",
+ description="Creator for render product type (eg. sequence)"
+ )
+ CreateImageSaver: CreateImageSaverModel = Field(
+ default_factory=CreateImageSaverModel,
+ title="Create Image Saver",
+ description="Creator for image product type (eg. single)"
)
@@ -71,6 +112,10 @@ class FusionSettings(BaseSettingsModel):
default_factory=CopyFusionSettingsModel,
title="Local Fusion profile settings"
)
+ hooks: HooksModel = Field(
+ default_factory=HooksModel,
+ title="Hooks"
+ )
create: CreatPluginsModel = Field(
default_factory=CreatPluginsModel,
title="Creator plugins"
@@ -93,6 +138,11 @@ DEFAULT_VALUES = {
"copy_status": False,
"force_sync": False
},
+ "hooks": {
+ "InstallPySideToFusion": {
+ "enabled": True
+ }
+ },
"create": {
"CreateSaver": {
"temp_rendering_path_template": "{workdir}/renders/fusion/{product[name]}/{product[name]}.{frame}.{ext}",
@@ -104,7 +154,21 @@ DEFAULT_VALUES = {
"reviewable",
"farm_rendering"
],
- "image_format": "exr"
+ "image_format": "exr",
+ "default_frame_range_option": "asset_db"
+ },
+ "CreateImageSaver": {
+ "temp_rendering_path_template": "{workdir}/renders/fusion/{product[name]}/{product[name]}.{ext}",
+ "default_variants": [
+ "Main",
+ "Mask"
+ ],
+ "instance_attributes": [
+ "reviewable",
+ "farm_rendering"
+ ],
+ "image_format": "exr",
+ "default_frame": 0
}
}
}
diff --git a/server_addon/fusion/server/version.py b/server_addon/fusion/server/version.py
index 485f44ac21..ae7362549b 100644
--- a/server_addon/fusion/server/version.py
+++ b/server_addon/fusion/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.1"
+__version__ = "0.1.3"
diff --git a/server_addon/hiero/server/settings/publish_plugins.py b/server_addon/hiero/server/settings/publish_plugins.py
index a85e62724b..f3d1e21fe4 100644
--- a/server_addon/hiero/server/settings/publish_plugins.py
+++ b/server_addon/hiero/server/settings/publish_plugins.py
@@ -1,5 +1,7 @@
-from pydantic import Field
-from ayon_server.settings import BaseSettingsModel
+from pydantic import Field, validator
+from ayon_server.settings import (
+ BaseSettingsModel, ensure_unique_names, normalize_name
+)
class CollectInstanceVersionModel(BaseSettingsModel):
@@ -9,6 +11,30 @@ class CollectInstanceVersionModel(BaseSettingsModel):
)
+class CollectClipEffectsDefModel(BaseSettingsModel):
+ _layout = "expanded"
+ name: str = Field("", title="Name")
+ effect_classes: list[str] = Field(
+ default_factory=list, title="Effect Classes"
+ )
+
+ @validator("name")
+ def validate_name(cls, value):
+ """Ensure name does not contain weird characters"""
+ return normalize_name(value)
+
+
+class CollectClipEffectsModel(BaseSettingsModel):
+ effect_categories: list[CollectClipEffectsDefModel] = Field(
+ default_factory=list, title="Effect Categories"
+ )
+
+ @validator("effect_categories")
+ def validate_unique_outputs(cls, value):
+ ensure_unique_names(value)
+ return value
+
+
class ExtractReviewCutUpVideoModel(BaseSettingsModel):
enabled: bool = Field(
True,
@@ -25,6 +51,10 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=CollectInstanceVersionModel,
title="Collect Instance Version"
)
+ CollectClipEffects: CollectClipEffectsModel = Field(
+ default_factory=CollectClipEffectsModel,
+ title="Collect Clip Effects"
+ )
"""# TODO: enhance settings with host api:
Rename class name and plugin name
to match title (it makes more sense)
@@ -44,5 +74,8 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = {
"tags_addition": [
"review"
]
+ },
+ "CollectClipEffectsModel": {
+ "effect_categories": []
}
}
diff --git a/server_addon/hiero/server/version.py b/server_addon/hiero/server/version.py
index 485f44ac21..b3f4756216 100644
--- a/server_addon/hiero/server/version.py
+++ b/server_addon/hiero/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.1"
+__version__ = "0.1.2"
diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py
index 6232f7ab18..5635676f6b 100644
--- a/server_addon/houdini/server/version.py
+++ b/server_addon/houdini/server/version.py
@@ -1 +1 @@
-__version__ = "0.2.10"
+__version__ = "0.2.11"
diff --git a/server_addon/max/server/settings/main.py b/server_addon/max/server/settings/main.py
index ea6a11915a..cad6024cf7 100644
--- a/server_addon/max/server/settings/main.py
+++ b/server_addon/max/server/settings/main.py
@@ -12,6 +12,25 @@ from .publishers import (
)
+def unit_scale_enum():
+ """Return enumerator for scene unit scale."""
+ return [
+ {"label": "mm", "value": "Millimeters"},
+ {"label": "cm", "value": "Centimeters"},
+ {"label": "m", "value": "Meters"},
+ {"label": "km", "value": "Kilometers"}
+ ]
+
+
+class UnitScaleSettings(BaseSettingsModel):
+ enabled: bool = Field(True, title="Enabled")
+ scene_unit_scale: str = Field(
+ "Centimeters",
+ title="Scene Unit Scale",
+ enum_resolver=unit_scale_enum
+ )
+
+
class PRTAttributesModel(BaseSettingsModel):
_layout = "compact"
name: str = Field(title="Name")
@@ -24,6 +43,10 @@ class PointCloudSettings(BaseSettingsModel):
class MaxSettings(BaseSettingsModel):
+ unit_scale_settings: UnitScaleSettings = Field(
+ default_factory=UnitScaleSettings,
+ title="Set Unit Scale"
+ )
imageio: ImageIOSettings = Field(
default_factory=ImageIOSettings,
title="Color Management (ImageIO)"
@@ -46,6 +69,10 @@ class MaxSettings(BaseSettingsModel):
DEFAULT_VALUES = {
+ "unit_scale_settings": {
+ "enabled": True,
+ "scene_unit_scale": "Centimeters"
+ },
"RenderSettings": DEFAULT_RENDER_SETTINGS,
"CreateReview": DEFAULT_CREATE_REVIEW_SETTINGS,
"PointCloud": {
diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py
index ae7362549b..bbab0242f6 100644
--- a/server_addon/max/server/version.py
+++ b/server_addon/max/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.3"
+__version__ = "0.1.4"
diff --git a/server_addon/maya/server/settings/publish_playblast.py b/server_addon/maya/server/settings/publish_playblast.py
index acfcaf5988..0abc9f7110 100644
--- a/server_addon/maya/server/settings/publish_playblast.py
+++ b/server_addon/maya/server/settings/publish_playblast.py
@@ -108,6 +108,7 @@ class ViewportOptionsSetting(BaseSettingsModel):
True, title="Enable Anti-Aliasing", section="Anti-Aliasing"
)
multiSample: int = Field(8, title="Anti Aliasing Samples")
+ loadTextures: bool = Field(False, title="Load Textures")
useDefaultMaterial: bool = Field(False, title="Use Default Material")
wireframeOnShaded: bool = Field(False, title="Wireframe On Shaded")
xray: bool = Field(False, title="X-Ray")
@@ -302,6 +303,7 @@ DEFAULT_PLAYBLAST_SETTING = {
"twoSidedLighting": True,
"lineAAEnable": True,
"multiSample": 8,
+ "loadTextures": False,
"useDefaultMaterial": False,
"wireframeOnShaded": False,
"xray": False,
diff --git a/server_addon/openpype/client/pyproject.toml b/server_addon/openpype/client/pyproject.toml
index 318ceb0185..b5978f0498 100644
--- a/server_addon/openpype/client/pyproject.toml
+++ b/server_addon/openpype/client/pyproject.toml
@@ -7,12 +7,18 @@ python = ">=3.9.1,<3.10"
aiohttp_json_rpc = "*" # TVPaint server
aiohttp-middlewares = "^2.0.0"
wsrpc_aiohttp = "^3.1.1" # websocket server
+Click = "^8"
clique = "1.6.*"
jsonschema = "^2.6.0"
pymongo = "^3.11.2"
log4mongo = "^1.7"
pyblish-base = "^1.8.11"
pynput = "^1.7.2" # Timers manager - TODO remove
-"Qt.py" = "^1.3.3"
-qtawesome = "0.7.3"
speedcopy = "^2.1"
+six = "^1.15"
+qtawesome = "0.7.3"
+
+[ayon.runtimeDependencies]
+OpenTimelineIO = "0.14.1"
+opencolorio = "2.2.1"
+Pillow = "9.5.0"
diff --git a/setup.cfg b/setup.cfg
index ead9b25164..f0f754fb24 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -16,10 +16,6 @@ max-complexity = 30
[pylint.'MESSAGES CONTROL']
disable = no-member
-[pydocstyle]
-convention = google
-ignore = D107
-
[coverage:run]
branch = True
omit = /tests
diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma
index 2cc87c2f48..8b90e987de 100644
--- a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma
+++ b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma
@@ -185,7 +185,7 @@ createNode objectSet -n "modelMain";
addAttr -ci true -sn "attrPrefix" -ln "attrPrefix" -dt "string";
addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string";
addAttr -ci true -sn "creator_attributes" -ln "creator_attributes" -dt "string";
- addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
+ addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
-dt "string";
setAttr ".ihi" 0;
setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:7364ea6776c9";
@@ -296,7 +296,7 @@ createNode objectSet -n "workfileMain";
addAttr -ci true -sn "task" -ln "task" -dt "string";
addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string";
addAttr -ci true -sn "creator_attributes" -ln "creator_attributes" -dt "string";
- addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
+ addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
-dt "string";
setAttr ".ihi" 0;
setAttr ".hio" yes;
diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v002.ma b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v002.ma
index 6bd334466a..f2906058cf 100644
--- a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v002.ma
+++ b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v002.ma
@@ -185,7 +185,7 @@ createNode objectSet -n "modelMain";
addAttr -ci true -sn "attrPrefix" -ln "attrPrefix" -dt "string";
addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string";
addAttr -ci true -sn "creator_attributes" -ln "creator_attributes" -dt "string";
- addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
+ addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
-dt "string";
setAttr ".ihi" 0;
setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:7364ea6776c9";
@@ -296,7 +296,7 @@ createNode objectSet -n "workfileMain";
addAttr -ci true -sn "task" -ln "task" -dt "string";
addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string";
addAttr -ci true -sn "creator_attributes" -ln "creator_attributes" -dt "string";
- addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
+ addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
-dt "string";
setAttr ".ihi" 0;
setAttr ".hio" yes;
diff --git a/tests/integration/hosts/maya/test_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma b/tests/integration/hosts/maya/test_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma
index 2cc87c2f48..8b90e987de 100644
--- a/tests/integration/hosts/maya/test_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma
+++ b/tests/integration/hosts/maya/test_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma
@@ -185,7 +185,7 @@ createNode objectSet -n "modelMain";
addAttr -ci true -sn "attrPrefix" -ln "attrPrefix" -dt "string";
addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string";
addAttr -ci true -sn "creator_attributes" -ln "creator_attributes" -dt "string";
- addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
+ addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
-dt "string";
setAttr ".ihi" 0;
setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:7364ea6776c9";
@@ -296,7 +296,7 @@ createNode objectSet -n "workfileMain";
addAttr -ci true -sn "task" -ln "task" -dt "string";
addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string";
addAttr -ci true -sn "creator_attributes" -ln "creator_attributes" -dt "string";
- addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
+ addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys"
-dt "string";
setAttr ".ihi" 0;
setAttr ".hio" yes;
diff --git a/tests/unit/openpype/lib/test_event_system.py b/tests/unit/openpype/lib/test_event_system.py
index aa3f929065..b0a011d83e 100644
--- a/tests/unit/openpype/lib/test_event_system.py
+++ b/tests/unit/openpype/lib/test_event_system.py
@@ -1,4 +1,9 @@
-from openpype.lib.events import EventSystem, QueuedEventSystem
+from functools import partial
+from openpype.lib.events import (
+ EventSystem,
+ QueuedEventSystem,
+ weakref_partial,
+)
def test_default_event_system():
@@ -81,3 +86,93 @@ def test_manual_event_system_queue():
assert output == expected_output, (
"Callbacks were not called in correct order")
+
+
+def test_unordered_events():
+ """
+ Validate if callbacks are triggered in order of their register.
+ """
+
+ result = []
+
+ def function_a():
+ result.append("A")
+
+ def function_b():
+ result.append("B")
+
+ def function_c():
+ result.append("C")
+
+ # Without order
+ event_system = QueuedEventSystem()
+ event_system.add_callback("test", function_a)
+ event_system.add_callback("test", function_b)
+ event_system.add_callback("test", function_c)
+ event_system.emit("test", {}, "test")
+
+ assert result == ["A", "B", "C"]
+
+
+def test_ordered_events():
+ """
+ Validate if callbacks are triggered by their order and order
+ of their register.
+ """
+ result = []
+
+ def function_a():
+ result.append("A")
+
+ def function_b():
+ result.append("B")
+
+ def function_c():
+ result.append("C")
+
+ def function_d():
+ result.append("D")
+
+ def function_e():
+ result.append("E")
+
+ def function_f():
+ result.append("F")
+
+ # Without order
+ event_system = QueuedEventSystem()
+ event_system.add_callback("test", function_a)
+ event_system.add_callback("test", function_b, order=-10)
+ event_system.add_callback("test", function_c, order=200)
+ event_system.add_callback("test", function_d, order=150)
+ event_system.add_callback("test", function_e)
+ event_system.add_callback("test", function_f, order=200)
+ event_system.emit("test", {}, "test")
+
+ assert result == ["B", "A", "E", "D", "C", "F"]
+
+
+def test_events_partial_callbacks():
+ """
+ Validate if partial callbacks are triggered.
+ """
+
+ result = []
+
+ def function(name):
+ result.append(name)
+
+ def function_regular():
+ result.append("regular")
+
+ event_system = QueuedEventSystem()
+ event_system.add_callback("test", function_regular)
+ event_system.add_callback("test", partial(function, "foo"))
+ event_system.add_callback("test", weakref_partial(function, "bar"))
+ event_system.emit("test", {}, "test")
+
+ # Delete function should also make partial callbacks invalid
+ del function
+ event_system.emit("test", {}, "test")
+
+ assert result == ["regular", "bar", "regular"]