diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 84f954c71b..f827d275a6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,51 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.7-nightly.6 + - 3.17.7-nightly.5 + - 3.17.7-nightly.4 + - 3.17.7-nightly.3 + - 3.17.7-nightly.2 + - 3.17.7-nightly.1 + - 3.17.6 + - 3.17.6-nightly.3 + - 3.17.6-nightly.2 + - 3.17.6-nightly.1 + - 3.17.5 + - 3.17.5-nightly.3 + - 3.17.5-nightly.2 + - 3.17.5-nightly.1 + - 3.17.4 + - 3.17.4-nightly.2 + - 3.17.4-nightly.1 + - 3.17.3 + - 3.17.3-nightly.2 + - 3.17.3-nightly.1 + - 3.17.2 + - 3.17.2-nightly.4 + - 3.17.2-nightly.3 + - 3.17.2-nightly.2 + - 3.17.2-nightly.1 + - 3.17.1 + - 3.17.1-nightly.3 + - 3.17.1-nightly.2 + - 3.17.1-nightly.1 + - 3.17.0 + - 3.16.7 + - 3.16.7-nightly.2 + - 3.16.7-nightly.1 + - 3.16.6 + - 3.16.6-nightly.1 + - 3.16.5 + - 3.16.5-nightly.5 + - 3.16.5-nightly.4 + - 3.16.5-nightly.3 + - 3.16.5-nightly.2 + - 3.16.5-nightly.1 + - 3.16.4 + - 3.16.4-nightly.3 + - 3.16.4-nightly.2 + - 3.16.4-nightly.1 - 3.16.3 - 3.16.3-nightly.5 - 3.16.3-nightly.4 @@ -90,51 +135,6 @@ body: - 3.15.3-nightly.2 - 3.15.3-nightly.1 - 3.15.2 - - 3.15.2-nightly.6 - - 3.15.2-nightly.5 - - 3.15.2-nightly.4 - - 3.15.2-nightly.3 - - 3.15.2-nightly.2 - - 3.15.2-nightly.1 - - 3.15.1 - - 3.15.1-nightly.6 - - 3.15.1-nightly.5 - - 3.15.1-nightly.4 - - 3.15.1-nightly.3 - - 3.15.1-nightly.2 - - 3.15.1-nightly.1 - - 3.15.0 - - 3.15.0-nightly.1 - - 3.14.11-nightly.4 - - 3.14.11-nightly.3 - - 3.14.11-nightly.2 - - 3.14.11-nightly.1 - - 3.14.10 - - 3.14.10-nightly.9 - - 3.14.10-nightly.8 - - 3.14.10-nightly.7 - - 3.14.10-nightly.6 - - 3.14.10-nightly.5 - - 3.14.10-nightly.4 - - 3.14.10-nightly.3 - - 3.14.10-nightly.2 - - 3.14.10-nightly.1 - - 3.14.9 - - 3.14.9-nightly.5 - - 3.14.9-nightly.4 - - 3.14.9-nightly.3 - - 3.14.9-nightly.2 - - 3.14.9-nightly.1 - - 3.14.8 - - 3.14.8-nightly.4 - - 3.14.8-nightly.3 - - 3.14.8-nightly.2 - - 3.14.8-nightly.1 - - 3.14.7 - - 3.14.7-nightly.8 - - 3.14.7-nightly.7 - - 3.14.7-nightly.6 - - 3.14.7-nightly.5 validations: required: true - type: dropdown diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d6a0d99d..5909c26f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,3809 @@ # Changelog +## [3.17.6](https://github.com/ynput/OpenPype/tree/3.17.6) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.5...3.17.6) + +### **πŸš€ Enhancements** + + +
+Testing: Validate Maya Logs #5775 + +This PR adds testing of the logs within Maya such as Python and Pyblish errors.The reason why we need to touch so many files outside of Maya is because of the pyblish errors below; +``` +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "collect_otio_frame_ranges" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "collect_otio_frame_ranges" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "collect_otio_review" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "collect_otio_review" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "collect_otio_subset_resources" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "collect_otio_subset_resources" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "extract_otio_audio_tracks" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "extract_otio_audio_tracks" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "extract_otio_file" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "extract_otio_file" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "extract_otio_review" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "extract_otio_review" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "extract_otio_trimming_video" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "extract_otio_trimming_video" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_blender_deadline" (No module named 'bpy') +# Error: pyblish.plugin : Skipped: "submit_blender_deadline" (No module named 'bpy') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_houdini_remote_publish" (No module named 'hou') +# Error: pyblish.plugin : Skipped: "submit_houdini_remote_publish" (No module named 'hou') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_houdini_render_deadline" (No module named 'hou') +# Error: pyblish.plugin : Skipped: "submit_houdini_render_deadline" (No module named 'hou') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_max_deadline" (No module named 'pymxs') +# Error: pyblish.plugin : Skipped: "submit_max_deadline" (No module named 'pymxs') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_nuke_deadline" (No module named 'nuke') +# Error: pyblish.plugin : Skipped: "submit_nuke_deadline" (No module named 'nuke') # +``` +We also needed to `stdout` and `stderr` from the launched application to capture the output.Split from #5644.Dependent on #5734 + + +___ + +
+ + +
+Maya: Render Settings cleanup remove global `RENDER_ATTRS` #5801 + +Remove global `lib.RENDER_ATTRS` and implement a `RenderSettings.get_padding_attr(renderer)` method instead. + + +___ + +
+ + +
+Testing: Ingest expected files and input workfile #5840 + +This ingests the Maya workfile from the Drive storage. Have changed the format to MayaAscii so its easier to see what changes are happening in a PR. This meant changing the expected files and database entries as well. + + +___ + +
+ + +
+Chore: Create plugin auto-apply settings #5908 + +Create plugins can auto-apply settings. + + +___ + +
+ + +
+Resolve: Add save current file button + "Save" shortcut when menu is active #5691 + +Adds a "Save current file" to the OpenPype menu.Also adds a "Save" shortcut key sequence (CTRL+S on Windows) to the button, so that clicking CTRL+S when the menu is active will save the current workfile. However this of course does not work if the menu does not receive the key press event (e.g. when Resolve UI is active instead)Resolves #5684 + + +___ + +
+ + +
+Reference USD file as maya native geometry #5781 + +Add MayaUsdReferenceLoader to reference USD as Maya native geometry using `mayaUSDImport` file translator. + + +___ + +
+ + +
+Max: Bug fix on wrong aspect ratio and viewport not being maximized during context in review family #5839 + +This PR will fix the bug on wrong aspect ratio and viewport not being maximized when creating preview animationBesides, the support of tga image format and the options for AA quality are implemented in this PR + + +___ + +
+ + +
+Blender: Incorporate blender "Collections" into Publish/Load #5841 + +Allow `blendScene` family to include collections. + + +___ + +
+ + +
+Max: Allows user preset the setting of preview animation in OP/AYON Setting #5859 + +Allows user preset the setting of preview animation in OP/AYON Setting for review family. +- [x] Openpype +- [x] AYON + + +___ + +
+ + +
+Publisher: Center publisher window on first show #5877 + +Move publisher window to center of a screen on first show. + + +___ + +
+ + +
+Publisher: Instance context changes confirm works #5881 + +Confirmation of context changes in publisher on existing instances does not cause glitches. + + +___ + +
+ + +
+AYON workfiles tools: Revisit workfiles tool #5897 + +Revisited workfiles tool for AYON mode to reuse common models and widgets. + + +___ + +
+ + +
+Nuke: updated colorspace settings #5906 + +Updating nuke colorspace settings into more convenient way with usage of ocio config roles rather then particular colorspace names. This way we should not have troubles to switch between linear Rec709 or ACES configs without any additional settings changes. + + +___ + +
+ + +
+Blender: Refactor to new publisher #5910 + +Refactor Blender integration to use the new publisher + + +___ + +
+ + +
+Enhancement: Some publish logs cosmetics #5917 + +General logging message tweaks: +- Sort some lists of folder/filenames so they appear sorted in the logs +- Fix some grammar / typos +- In some cases provide slightly more information in a log + + +___ + +
+ + +
+Blender: Better name of 'asset_name' function #5927 + +Renamed function `asset_name` to `prepare_scene_name`. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: Bug fix the fbx animation export errored out when the skeletonAnim set is empty #5875 + +Resolve this bug discordIf the skeletonAnim SET is empty and fbx animation collect, the fbx animation extractor would skip the fbx extraction + + +___ + +
+ + +
+Bugfix: fix few typos in houdini's and Maya's Ayon settings #5882 + +Fixing few typos +- [x] Maya unreal static mesh +- [x] Houdini static mesh +- [x] Houdini collect asset handles + + +___ + +
+ + +
+Bugfix: Ayon Deadline env vars + error message on no executable found #5815 + +Fix some Ayon x Deadline issues as came up in this topic: +- missing Environment Variables issue explained here for `deadlinePlugin.RunProcess` for the AYON _extract environments_ call. +- wrong error formatting described here with a `;` between each character like this: `Ayon executable was not found in the semicolon separated list "C;:;/;P;r;o;g;r;a;m; ;F;i;l;e;s;/;Y;n;p;u;t;/;A;Y;O;N; ;1;.;0;.;0;-;b;e;t;a;.;5;/;a;y;o;n;_;c;o;n;s;o;l;e;.;e;x;e". The path to the render executable can be configured from the Plugin Configuration in the Deadline Monitor.` + + +___ + +
+ + +
+AYON: Fix bundles access in settings #5856 + +Fixed access to bundles data in settings to define correct develop variant. + + +___ + +
+ + +
+AYON 3dsMax settings: 'ValidateAttributes' settings converte only if available #5878 + +Convert `ValidateAttributes` settings only if are available in AYON settings. + + +___ + +
+ + +
+AYON: Fix TrayPublisher editorial settings #5880 + +Fixing Traypublisher settings for adding task in simple editorial. + + +___ + +
+ + +
+TrayPublisher: editorial frame range check not needed #5884 + +Validator for frame ranges is not needed during editorial publishing since entity data are not yet in database. + + +___ + +
+ + +
+Update houdini license validator #5886 + +As reported in this community commentHoudini USD publishing is only restricted in Houdini apprentice. + + +___ + +
+ + +
+Blender: Fix blend extraction and packed images #5888 + +Fixed a with blend extractor and packed images. + + +___ + +
+ + +
+AYON: Initialize connection with all information #5890 + +Create global AYON api connection with all informations all the time. + + +___ + +
+ + +
+AYON: Scene inventory tool without site sync #5896 + +Skip 'get_site_icons' if site sync addon is disabled. + + +___ + +
+ + +
+Publish report tool: Fix PySide6 #5898 + +Use constants from classes instead of objects. + + +___ + +
+ + +
+fusion: removing hardcoded template name for saver #5907 + +Fusion is not hardcoded for `render` anatomy template only anymore. This was blocking AYON deployment. + + +___ + +
+ + + + +## [3.17.5](https://github.com/ynput/OpenPype/tree/3.17.5) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.4...3.17.5) + +### **πŸ†• New features** + + +
+Fusion: Add USD loader #4896 + +Add an OpenPype managed USD loader (`uLoader`) for Fusion. + + +___ + +
+ + +
+Fusion: Resolution validator #5325 + +Added a resolution validator.The code is from my old PR (https://github.com/ynput/OpenPype/pull/4921) that I closed because the PR also contained a frame range validator that no longer is needed. + + +___ + +
+ + +
+Context Selection tool: Refactor Context tool (for AYON) #5766 + +Context selection tool has AYON variant. + + +___ + +
+ + +
+AYON: Use AYON username for user in template data #5842 + +Use ayon username for template data in AYON mode. + + +___ + +
+ + +
+Testing: app_group flag #5869 + +`app_group` command flag. This is for changing which flavour of the host to launch. In the case of Maya, you can launch Maya and MayaPy, but it can be used for the Nuke family as well.Split from #5644 + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Enhancement: Fusion fix saver creation + minor Blender/Fusion logging tweaks #5558 + +- Blender change logs to `debug` level in preparation for new publisher artist facing reports (note that it currently still uses the old publisher) +- Fusion: Create Saver fix redeclaration of default_variants +- Fusion: Fix saver being created in incorrect state without saving directly after create +- Fusion: Allow reset frame range on render family +- Fusion: Tweak logging level for artist-facing report + + +___ + +
+ + +
+Resolve: load clip to timeline at set time #5665 + +It is possible to load clip to correct place on timeline. + + +___ + +
+ + +
+Nuke: Optional Deadline workfile dependency. #5732 + +Adds option to add the workfile as dependency for the Deadline job.Think it used to have something like this, but it disappeared. Usecase is for remote workflow where the Nuke script needs to be synced before the job can start. + + +___ + +
+ + +
+Enhancement/houdini rearrange ayon houdini settings files #5748 + +Rearranging Houdini Settings to be more readable, easier to edit, update settings (include all families/product types)This PR is mainly for Ayon Settings to have more organized files. For Openpype, I'll make sure that each Houdini setting in Ayon has an equivalent in Openpype. +- [x] update Ayon settings, fix typos and remove deprecated settings. +- [x] Sync with Openpype +- [x] Test in Openpype +- [x] Test in Ayon + + +___ + +
+ + +
+Chore: updating create ayon addon script #5822 + +Adding developers environment options. + + +___ + +
+ + +
+Max: Implement Validator for Properties/Attributes Value Check #5824 + +Add optional validator which can check if the property attributes are valid in Max + + +___ + +
+ + +
+Nuke: Remove unused 'get_render_path' function #5826 + +Remove unused function `get_render_path` from nuke integration. + + +___ + +
+ + +
+Chore: Limit current context template data function #5845 + +Current implementation of `get_current_context_template_data` does return the same values as base template data function `get_template_data`. + + +___ + +
+ + +
+Max: Make sure Collect Render not ignoring instance asset #5847 + +- Make sure Collect Render is not always using asset from context. +- Make sure Scene version being collected +- Clean up unnecessary uses of code in the collector. + + +___ + +
+ + +
+Ftrack: Events are not processed if project is not available in OpenPype #5853 + +Events that happened on project which is not in OpenPype is not processed. + + +___ + +
+ + +
+Nuke: Add Nuke 11.0 as default setting #5855 + +Found I needed Nuke 11.0 in the default settings to help with unit testing. + + +___ + +
+ + +
+TVPaint: Code cleanup #5857 + +Removed unused import. Use `AYON` label in ayon mode. Removed unused data in publish context `"previous_context"`. + + +___ + +
+ + +
+AYON settings: Use correct label for follow workfile version #5874 + +Follow workfile version label was marked as Collect Anatomy Instance Data label. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Nuke: Fix workfile template builder so representations get loaded next to each other #5061 + +Refactor when the cleanup of the placeholder happens for the cases where multiple representations are loaded by a single placeholder.The existing code didn't take into account the case where a template placeholder can load multiple representations so it was trying to do the cleanup of the placeholder node and the re-arrangement of the imported nodes too early. I assume this was designed only for the cases where a single representation can load multiple nodes. + + +___ + +
+ + +
+Nuke: Dont update node name on update #5704 + +When updating `Image` containers the code is trying to set the name of the node. This results in a warning message from Nuke shown below;Suggesting to not change the node name when updating. + + +___ + +
+ + +
+UIDefLabel can be unique #5827 + +`UILabelDef` have implemented comparison and uniqueness. + + +___ + +
+ + +
+AYON: Skip kitsu module when creating ayon addons #5828 + +Create AYON packages is skipping kitsu module in creation of modules/addons and kitsu module is not loaded from modules on start. The addon already has it's repository https://github.com/ynput/ayon-kitsu. + + +___ + +
+ + +
+Bugfix: Collect Rendered Files only collecting first instance #5832 + +Collect all instances from the metadata file - don't return on first instance iteration. + + +___ + +
+ + +
+Houdini: set frame range for the created composite ROP #5833 + +Quick bug fix for created composite ROP, set its frame range to the frame range of the playbar. + + +___ + +
+ + +
+Fix registering launcher actions from OpenPypeModules #5843 + +Fix typo `actions_dir` -> `path` to fix register launcher actions fromm OpenPypeModule + + +___ + +
+ + +
+Bugfix in houdini shelves manager and beautify settings #5844 + +This PR fixes the problem in this PR https://github.com/ynput/OpenPype/issues/5457 by using the right function to load a pre-made houdini `.shelf` fileAlso, it beautifies houdini shelves settings to provide better guidance for users which helps with other issue https://github.com/ynput/OpenPype/issues/5458 , Rather adding default shelf and set names, I'll educate users how to use the tool correctly.Users now are able to select between the two options.| OpenPype | Ayon || -- | -- || | | + + +___ + +
+ + +
+Blender: Fix missing Grease Pencils in review #5848 + +Fix Grease Pencil missing in review when isolating objects. + + +___ + +
+ + +
+Blender: Fix Render Settings in Ayon #5849 + +Fix Render Settings in Ayon for Blender. + + +___ + +
+ + +
+Bugfix: houdini tab menu working as expected #5850 + +This PR:Tab menu name changes to Ayon when using ayon get_network_categories is checked in all creator plugins. | Product | Network Category | | -- | -- | | Alembic camera | rop, obj | | Arnold Ass | rop | | Arnold ROP | rop | | Bgeo | rop, sop | | composite sequence | cop2, rop | | hda | obj | | Karma ROP | rop | | Mantra ROP | rop | | ABC | rop, sop | | RS proxy | rop, sop| | RS ROP | rop | | Review | rop | | Static mesh | rop, obj, sop | | USD | lop, rop | | USD Render | rop | | VDB | rop, obj, sop | | V Ray | rop | + + +___ + +
+ + +
+Bigfix: Houdini skip frame_range_validator if node has no 'trange' parameter #5851 + +I faced a bug when publishing HDA instance as it has no `trange` parameter. As this PR title says : skip frame_range_validator if node has no 'trange' parameter + + +___ + +
+ + +
+Bugfix: houdini image sequence loading and missing frames #5852 + +I made this PR in to fix issues mentioned here https://github.com/ynput/OpenPype/pull/5833#issuecomment-1789207727in short: +- image load doesn't work +- publisher only publish one frame + + +___ + +
+ + +
+Nuke: loaders' containers updating as nodes #5854 + +Nuke loaded containers are updating correctly even they have been duplicating of originally loaded nodes. This had previously been removed duplicated nodes. + + +___ + +
+ + +
+deadline: settings are not blocking extension input #5864 + +Settings are not blocking user input. + + +___ + +
+ + +
+Blender: Fix loading of blend layouts #5866 + +Fix a problem with loading blend layouts. + + +___ + +
+ + +
+AYON: Launcher refresh issues #5867 + +Fixed refresh of projects issue in launcher tool. And renamed Qt models to contain `Qt` in their name (it was really hard to find out where were used). It is not possible to click on disabled item in launcher's projects view. + + +___ + +
+ + +
+Fix the Wrong key words for tycache workfile template settings in AYON #5870 + +Fix the wrong key words for the tycache workfile template settings in AYON(i.e. Instead of families, product_types should be used) + + +___ + +
+ + +
+AYON tools: Handle empty icon definition #5876 + +Ignore if passed icon definition is `None`. + + +___ + +
+ +### **πŸ”€ Refactored code** + + +
+Houdini: Remove on instance toggled callback #5860 + +Remove on instance toggled callback which isn't relevant to the new publisher + + +___ + +
+ + +
+Chore: Remove unused `instanceToggled` callbacks #5862 + +The `instanceToggled` callbacks should be irrelevant for new publisher. + + +___ + +
+ + + + +## [3.17.4](https://github.com/ynput/OpenPype/tree/3.17.4) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.3...3.17.4) + +### **πŸ†• New features** + + +
+Add Support for Husk-AYON Integration #5816 + +This draft pull request introduces support for integrating Husk with AYON within the OpenPype repository. + + +___ + +
+ + +
+Push to project tool: Prepare push to project tool for AYON #5770 + +Cloned Push to project tool for AYON and modified it. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Max: tycache family support #5624 + +Tycache family supports for Tyflow Plugin in Max + + +___ + +
+ + +
+Unreal: Changed behaviour for updating assets #5670 + +Changed how assets are updated in Unreal. + + +___ + +
+ + +
+Unreal: Improved error reporting for Sequence Frame Validator #5730 + +Improved error reporting for Sequence Frame Validator. + + +___ + +
+ + +
+Max: Setting tweaks on Review Family #5744 + +- Bug fix of not being able to publish the preferred visual style when creating preview animation +- Exposes the parameters after creating instance +- Add the Quality settings and viewport texture settings for preview animation +- add use selection for create review + + +___ + +
+ + +
+Max: Add families with frame range extractions back to the frame range validator #5757 + +In 3dsMax, there are some instances which exports the files in frame range but not being added to the optional frame range validator. In this PR, these instances would have the optional frame range validators to allow users to check if frame range aligns with the context data from DB.The following families have been added to have optional frame range validator: +- maxrender +- review +- camera +- redshift proxy +- pointcache +- point cloud(tyFlow PRT) + + +___ + +
+ + +
+TimersManager: Use available data to get context info #5804 + +Get context information from pyblish context data instead of using `legacy_io`. + + +___ + +
+ + +
+Chore: Removed unused variable from `AbstractCollectRender` #5805 + +Removed unused `_asset` variable from `RenderInstance`. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Bugfix/houdini: wrong frame calculation with handles #5698 + +This PR make collect plugins to consider `handleStart` and `handleEnd` when collecting frame range it affects three parts: +- get frame range in collect plugins +- expected file in render plugins +- submit houdini job deadline plugin + + +___ + +
+ + +
+Nuke: ayon server settings improvements #5746 + +Nuke settings were not aligned with OpenPype settings. Also labels needed to be improved. + + +___ + +
+ + +
+Blender: Fix pointcache family and fix alembic extractor #5747 + +Fixed `pointcache` family and fixed behaviour of the alembic extractor. + + +___ + +
+ + +
+AYON: Remove 'shotgun_api3' from dependencies #5803 + +Removed `shotgun_api3` dependency from openpype dependencies for AYON launcher. The dependency is already defined in shotgrid addon and change of version causes clashes. + + +___ + +
+ + +
+Chore: Fix typo in filename #5807 + +Move content of `contants.py` into `constants.py`. + + +___ + +
+ + +
+Chore: Create context respects instance changes #5809 + +Fix issue with unrespected change propagation in `CreateContext`. All successfully saved instances are marked as saved so they have no changes. Origin data of an instance are explicitly not handled directly by the object but by the attribute wrappers. + + +___ + +
+ + +
+Blender: Fix tools handling in AYON mode #5811 + +Skip logic in `before_window_show` in blender when in AYON mode. Most of the stuff called there happes on show automatically. + + +___ + +
+ + +
+Blender: Include Grease Pencil in review and thumbnails #5812 + +Include Grease Pencil in review and thumbnails. + + +___ + +
+ + +
+Workfiles tool AYON: Fix double click of workfile #5813 + +Fix double click on workfiles in workfiles tool to open the file. + + +___ + +
+ + +
+Webpublisher: removal of usage of no_of_frames in error message #5819 + +If it throws exception, `no_of_frames` value wont be available, so it doesn't make sense to log it. + + +___ + +
+ + +
+Attribute Defs: Hide multivalue widget in Number by default #5821 + +Fixed default look of `NumberAttrWidget` by hiding its multiselection widget. + + +___ + +
+ +### **Merged pull requests** + + +
+Corrected a typo in Readme.md (Top -> To) #5800 + + +___ + +
+ + +
+Photoshop: Removed redundant copy of extension.zxp #5802 + +`extension.zxp` shouldn't be inside of extension folder. + + +___ + +
+ + + + +## [3.17.3](https://github.com/ynput/OpenPype/tree/3.17.3) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.2...3.17.3) + +### **πŸ†• New features** + + +
+Maya: Multi-shot Layout Creator #5710 + +New Multi-shot Layout creator is a way of automating creation of the new Layout instances in Maya, associated with correct shots, frame ranges and Camera Sequencer in Maya. + + +___ + +
+ + +
+Colorspace: ociolook file product type workflow #5541 + +Traypublisher support for publishing of colorspace look files (ociolook) which are json files holding any LUT files. This new product is available for loading in Nuke host at the moment.Added colorspace selector to publisher attribute with better labeling. We are supporting also Roles and Alias (only v2 configs). + + +___ + +
+ + +
+Scene Inventory tool: Refactor Scene Inventory tool (for AYON) #5758 + +Modified scene inventory tool for AYON. The main difference is in how project name is defined and replacement of assets combobox with folders dialog. + + +___ + +
+ + +
+AYON: Support dev bundles #5783 + +Modules can be loaded in AYON dev mode from different location. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Testing: Ingest Maya userSetup #5734 + +Suggesting to ingest `userSetup.py` startup script for easier collaboration and transparency of testing. + + +___ + +
+ + +
+Fusion: Work with pathmaps #5329 + +Path maps are a big part of our Fusion workflow. We map the project folder to a path map within Fusion so all loaders and savers point to the path map variable. This way any computer on any OS can open any comp no matter where the project folder is located. + + +___ + +
+ + +
+Maya: Add Maya 2024 and remove pre 2022. #5674 + +Adding Maya 2024 as default application variant.Removing Maya 2020 and older, as these are not supported anymore. + + +___ + +
+ + +
+Enhancement: Houdini: Allow using template keys in Houdini shelves manager #5727 + +Allow using Template keys in Houdini shelves manager. + + +___ + +
+ + +
+Houdini: Fix Show in usdview loader action #5737 + +Fix the "Show in USD View" loader to show up in Houdini + + +___ + +
+ + +
+Nuke: validator of asset context with repair actions #5749 + +Instance nodes with different context of asset and task can be now validated and repaired via repair action. + + +___ + +
+ + +
+AYON: Tools enhancements #5753 + +Few enhancements and tweaks of AYON related tools. + + +___ + +
+ + +
+Max: Tweaks on ValidateMaxContents #5759 + +This PR provides enhancements on ValidateMaxContent as follow: +- Rename `ValidateMaxContents` to `ValidateContainers` +- Add related families which are required to pass the validation(All families except `Render` as the render instance is the one which only allows empty container) + + +___ + +
+ + +
+Enhancement: Nuke refactor `SelectInvalidAction` #5762 + +Refactor `SelectInvalidAction` to behave like other action for other host, create `SelectInstanceNodeAction` as dedicated action to select the instance node for a failed plugin. +- Note: Selecting Instance Node will still select the instance node even if the user has currently 'fixed' the problem. + + +___ + +
+ + +
+Enhancement: Tweak logging for Nuke for artist facing reports #5763 + +Tweak logs that are not artist-facing to debug level + in some cases clarify what the logged value is. + + +___ + +
+ + +
+AYON Settings: Disk mapping #5786 + +Added disk mapping settings to core addon settings. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: add colorspace argument to redshiftTextureProcessor #5645 + +In color managed Maya, texture processing during Look Extraction wasn't passing texture colorspaces set on textures to `redshiftTextureProcessor` tool. This in effect caused this tool to produce non-zero exit code (even though the texture was converted into wrong colorspace) and therefor crash of the extractor. This PR is passing colorspace to that tool if color management is enabled. + + +___ + +
+ + +
+Maya: don't call `cmds.ogs()` in headless mode #5769 + +`cmds.ogs()` is a call that will crash if Maya is running in headless mode (mayabatch, mayapy). This is handling that case. + + +___ + +
+ + +
+Resolve: inventory management fix #5673 + +Loaded Timeline item containers are now updating correctly and version management is working as it suppose to. +- [x] updating loaded timeline items +- [x] Removing of loaded timeline items + + +___ + +
+ + +
+Blender: Remove 'update_hierarchy' #5756 + +Remove `update_hierarchy` function which is causing crashes in scene inventory tool. + + +___ + +
+ + +
+Max: bug fix on the settings in pointcloud family #5768 + +Bug fix on the settings being errored out in validate point cloud(see links:https://github.com/ynput/OpenPype/pull/5759#pullrequestreview-1676681705) and passibly in point cloud extractor. + + +___ + +
+ + +
+AYON settings: Fix default factory of tools #5773 + +Fix default factory of application tools. + + +___ + +
+ + +
+Fusion: added missing OPENPYPE_VERSION #5776 + +Fusion submission to Deadline was missing OPENPYPE_VERSION env var when submitting from build (not source code directly). This missing env var might break rendering on DL if path to OP executable (openpype_console.exe) is not set explicitly and might cause an issue when different versions of OP are deployed.This PR adds this environment variable. + + +___ + +
+ + +
+Ftrack: Skip tasks when looking for asset equivalent entity #5777 + +Skip tasks when looking for asset equivalent entity. + + +___ + +
+ + +
+Nuke: loading gizmos fixes #5779 + +Gizmo product is not offered in Loader as plugin. It is also updating as expected. + + +___ + +
+ + +
+General: thumbnail extractor as last extractor #5780 + +Fixing issue with the order of the `ExtractOIIOTranscode` and `ExtractThumbnail` plugins. The problem was that the `ExtractThumbnail` plugin was processed before the `ExtractOIIOTranscode` plugin. As a result, the `ExtractThumbnail` plugin did not inherit the `review` tag into the representation data. This caused the `ExtractThumbnail` plugin to fail in processing and creating thumbnails. + + +___ + +
+ + +
+Bug: fix key in application json #5787 + +In PR #5705 `maya` was wrongly used instead of `mayapy`, breaking AYON defaults in AYON Application Addon. + + +___ + +
+ + +
+'NumberAttrWidget' shows 'Multiselection' label on multiselection #5792 + +Attribute definition widget 'NumberAttrWidget' shows `< Multiselection >` label on multiselection. + + +___ + +
+ + +
+Publisher: Selection change by enabled checkbox on instance update attributes #5793 + +Change of instance by clicking on enabled checkbox will actually update attributes on right side to match the selection. + + +___ + +
+ + +
+Houdini: Remove `setParms` call since it's responsibility of `self.imprint` to set the values #5796 + +Revert a recent change made in #5621 due to this comment. However the change is faulty as can be seen mentioned here + + +___ + +
+ + +
+AYON loader: Fix SubsetLoader functionality #5799 + +Fix SubsetLoader plugin processing in AYON loader tool. + + +___ + +
+ +### **Merged pull requests** + + +
+Houdini: Add self publish button #5621 + +This PR allows single publishing by adding a publish button to created rop nodes in HoudiniAdmins are much welcomed to enable it from houdini general settingsPublish Button also includes all input publish instances. in this screen shot the alembic instance is ignored because the switch is turned off + + +___ + +
+ + +
+Nuke: fixing UNC support for OCIO path #5771 + +UNC paths were broken on windows for custom OCIO path and this is solving the issue with removed double slash at start of path + + +___ + +
+ + + + +## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.1...3.17.2) + +### **πŸ†• New features** + + +
+Maya: Add MayaPy application. #5705 + +This adds mayapy to the application to be launched from a task. + + +___ + +
+ + +
+Feature: Copy resources when downloading last workfile #4944 + +When the last published workfile is downloaded as a prelaunch hook, all resource files referenced in the workfile representation are copied to the `resources` folder, which is inside the local workfile folder. + + +___ + +
+ + +
+Blender: Deadline support #5438 + +Add Deadline support for Blender. + + +___ + +
+ + +
+Fusion: implement toggle to use Deadline plugin FusionCmd #5678 + +Fusion 17 doesn't work in DL 10.3, but FusionCmd does. It might be probably better option as headless variant.Fusion plugin seems to be closing and reopening application when worker is running on artist machine, not so with FusionCmdAdded configuration to Project Settings for admin to select appropriate Deadline plugin: + + +___ + +
+ + +
+Loader tool: Refactor loader tool (for AYON) #5729 + +Refactored loader tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. The tool is also replacing library loader. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Maya: implement matchmove publishing #5445 + +Add possibility to export multiple cameras in single `matchmove` family instance, both in `abc` and `ma`.Exposed flag 'Keep image planes' to control export of image planes. + + +___ + +
+ + +
+Maya: Add optional Fbx extractors in Rig and Animation family #5589 + +This PR allows user to export control rigs(optionally with mesh) and animated rig in fbx optionally by attaching the rig objects to the two newly introduced sets. + + +___ + +
+ + +
+Maya: Optional Resolution Validator for Render #5693 + +Adding optional resolution validator for maya in render family, similar to the one in Max.It checks if the resolution in render setting aligns with that in setting from the db. + + +___ + +
+ + +
+Use host's node uniqueness for instance id in new publisher #5490 + +Instead of writing `instance_id` as parm or attributes on the publish instances we can, for some hosts, just rely on a unique name or path within the scene to refer to that particular instance. By doing so we fix #4820 because upon duplicating such a publish instance using the host's (DCC) functionality the uniqueness for the duplicate is then already ensured instead of attributes remaining exact same value as where to were duplicated from, making `instance_id` a non-unique value. + + +___ + +
+ + +
+Max: Implementation of OCIO configuration #5499 + +Resolve #5473 Implementation of OCIO configuration for Max 2024 regarding to the update of Max 2024 + + +___ + +
+ + +
+Nuke: Multiple format supports for ExtractReviewDataMov #5623 + +This PR would fix the bug of the plugin `ExtractReviewDataMov` not being able to support extensions other than `mov`. The plugin is also renamed to `ExtractReviewDataBakingStreams` as i provides multiple format supoort. + + +___ + +
+ + +
+Bugfix: houdini switching context doesnt update variables #5651 + +Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task.Using template keys is supported but formatting keys capitalization variants is not, e.g. {Asset} and {ASSET} won't workDisabling Update Houdini vars on context change feature will leave all Houdini vars unmanaged and thus no context update changes will occur.Also, this PR adds a new button in menu to update vars on demand. + + +___ + +
+ + +
+Publisher: Fix report maker memory leak + optimize lookups using set #5667 + +Fixes a memory leak where resetting publisher does not clear the stored plugins for the Publish Report Maker.Also changes the stored plugins to a `set` to optimize the lookup speeds. + + +___ + +
+ + +
+Add openpype_mongo command flag for testing. #5676 + +Instead of changing the environment, this command flag allows for changing the database. + + +___ + +
+ + +
+Nuke: minor docstring and code tweaks for ExtractReviewMov #5695 + +Code and docstring tweaks on https://github.com/ynput/OpenPype/pull/5623 + + +___ + +
+ + +
+AYON: Small settings fixes #5699 + +Small changes/fixes related to AYON settings. All foundry apps variant `13-0` has label `13.0`. Key `"ExtractReviewIntermediates"` is not mandatory in settings. + + +___ + +
+ + +
+Blender: Alembic Animation loader #5711 + +Implemented loading Alembic Animations in Blender. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: Missing "data" field and enabling of audio #5618 + +When updating audio containers, the field "data" was missing and the audio node was not enabled on the timeline. + + +___ + +
+ + +
+Maya: Bug in validate Plug-in Path Attribute #5687 + +Overwriting list with string is causing `TypeError: string indices must be integers` in subsequent iterations, crashing the validator plugin. + + +___ + +
+ + +
+General: Avoid fallback if value is 0 for handle start/end #5652 + +There's a bug on the `pyblish_functions.get_time_data_from_instance_or_context` where if `handleStart` or `handleEnd` on the instance are set to value 0 it's falling back to grabbing the handles from the instance context. Instead, the logic should be that it only falls back to the `instance.context` if the key doesn't exist.This change was only affecting me on the `handleStart`/`handleEnd` and it's unlikely it could cause issues on `frameStart`, `frameEnd` or `fps` but regardless, the `get` logic is wrong. + + +___ + +
+ + +
+Fusion: added missing env vars to Deadline submission #5659 + +Environment variables discerning type of job was missing. Without this injection of environment variables won't start. + + +___ + +
+ + +
+Nuke: workfile version synchronization settings fixed #5662 + +Settings for synchronizing workfile version to published products is fixed. + + +___ + +
+ + +
+AYON Workfiles Tool: Open workfile changes context #5671 + +Change context when workfile is opened. + + +___ + +
+ + +
+Blender: Fix remove/update in new layout instance #5679 + +Fixes an error that occurs when removing or updating an asset in a new layout instance. + + +___ + +
+ + +
+AYON Launcher tool: Fix refresh btn #5685 + +Refresh button does propagate refreshed content properly. Folders and tasks are cached for 60 seconds instead of 10 seconds. Auto-refresh in launcher will refresh only actions and related data which is project and project settings. + + +___ + +
+ + +
+Deadline: handle all valid paths in RenderExecutable #5694 + +This commit enhances the path resolution mechanism in the RenderExecutable function of the Ayon plugin. Previously, the function only considered paths starting with a tilde (~), ignoring other valid paths listed in exe_list. This limitation led to an empty expanded_paths list when none of the paths in exe_list started with a tilde, causing the function to fail in finding the Ayon executable.With this fix, the RenderExecutable function now correctly processes and includes all valid paths from exe_list, improving its reliability and preventing unnecessary errors related to Ayon executable location. + + +___ + +
+ + +
+AYON Launcher tool: Fix skip last workfile boolean #5700 + +Skip last workfile boolean works as expected. + + +___ + +
+ + +
+Chore: Explore here action can work without task #5703 + +Explore here action does not crash when task is not selected, and change error message a little. + + +___ + +
+ + +
+Testing: Inject mongo_url argument earlier #5706 + +Fix for https://github.com/ynput/OpenPype/pull/5676The Mongo url is used earlier in the execution. + + +___ + +
+ + +
+Blender: Add support to auto-install PySide2 in blender 4 #5723 + +Change version regex to support blender 4 subfolder. + + +___ + +
+ + +
+Fix: Hardcoded main site and wrongly copied workfile #5733 + +Fixing these two issues: +- Hardcoded main site -> Replaced by `anatomy.fill_root`. +- Workfiles can sometimes be copied while they shouldn't. + + +___ + +
+ + +
+Bugfix: ServerDeleteOperation asset -> folder conversion typo #5735 + +Fix ServerDeleteOperation asset -> folder conversion typo + + +___ + +
+ + +
+Nuke: loaders are filtering correctly #5739 + +Variable name for filtering by extensions were not correct - it suppose to be plural. It is fixed now and filtering is working as suppose to. + + +___ + +
+ + +
+Nuke: failing multiple thumbnails integration #5741 + +This handles the situation when `ExtractReviewIntermediates` (previously `ExtractReviewDataMov`) has multiple outputs, including thumbnails that need to be integrated. Previously, integrating the thumbnail representation was causing an issue in the integration process. However, we have now resolved this issue by no longer integrating thumbnails as loadable representations.NOW default is that thumbnail representation are NOT integrated (eg. they will not show up in DB > couldn't be Loaded in Loader) and no `_thumb.jpg` will be left in `render` (most likely) publish folder.IF there would be need to override this behavior, please use `project_settings/global/publish/PreIntegrateThumbnails` + + +___ + +
+ + +
+AYON Settings: Fix global overrides #5745 + +The `output` dictionary that gets passed into `ayon_settings._convert_global_project_settings` gets replaced when converting the settings for `ExtractOIIOTranscode`. This results in `global` not being in the output dictionary and thus the defaults being used and not the project overrides. + + +___ + +
+ + +
+Chore: AYON query functions arguments #5752 + +Fixed how `archived` argument is handled in get subsets/assets function. + + +___ + +
+ +### **πŸ”€ Refactored code** + + +
+Publisher: Refactor Report Maker plugin data storage to be a dict by plugin.id #5668 + +Refactor Report Maker plugin data storage to be a dict by `plugin.id`Also fixes `_current_plugin_data` type on `__init__` + + +___ + +
+ + +
+Chore: Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost #5701 + +Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost + + +___ + +
+ +### **Merged pull requests** + + +
+Chore: Maya reduce get project settings calls #5669 + +Re-use system settings / project settings where we can instead of requerying. + + +___ + +
+ + +
+Extended error message when getting subset name #5649 + +Each Creator is using `get_subset_name` functions which collects context data and fills configured template with placeholders.If any key is missing in the template, non descriptive error is thrown.This should provide more verbose message: + + +___ + +
+ + +
+Tests: Remove checks for env var #5696 + +Env var will be filled in `env_var` fixture, here it is too early to check + + +___ + +
+ + + + +## [3.17.1](https://github.com/ynput/OpenPype/tree/3.17.1) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.0...3.17.1) + +### **πŸ†• New features** + + +
+Unreal: Yeti support #5643 + +Implemented Yeti support for Unreal. + + +___ + +
+ + +
+Houdini: Add Static Mesh product-type (family) #5481 + +This PR adds support to publish Unreal Static Mesh in Houdini as FBXQuick recap +- [x] Add UE Static Mesh Creator +- [x] Dynamic subset name like in Maya +- [x] Collect Static Mesh Type +- [x] Update collect output node +- [x] Validate FBX output node +- [x] Validate mesh is static +- [x] Validate Unreal Static Mesh Name +- [x] Validate Subset Name +- [x] FBX Extractor +- [x] FBX Loader +- [x] Update OP Settings +- [x] Update AYON Settings + + +___ + +
+ + +
+Launcher tool: Refactor launcher tool (for AYON) #5612 + +Refactored launcher tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Maya: Use custom staging dir function for Maya renders - OP-5265 #5186 + +Check for custom staging dir when setting the renders output folder in Maya. + + +___ + +
+ + +
+Colorspace: updating file path detection methods #5273 + +Support for OCIO v2 file rules integrated into the available color management API + + +___ + +
+ + +
+Chore: add default isort config #5572 + +Add default configuration for isort tool + + +___ + +
+ + +
+Deadline: set PATH environment in deadline jobs by GlobalJobPreLoad #5622 + +This PR makes `GlobalJobPreLoad` to set `PATH` environment in deadline jobs so that we don't have to use the full executable path for deadline to launch the dcc app. This trick should save us adding logic to pass houdini patch version and modifying Houdini deadline plugin. This trick should work with other DCCs + + +___ + +
+ + +
+nuke: extract review data mov read node with expression #5635 + +Some productions might have set default values for read nodes, those settings are not colliding anymore now. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: Support new publisher for colorsets validation. #5630 + +Fix `validate_color_sets` for the new publisher.In current `develop` the repair option does not appear due to wrong error raising. + + +___ + +
+ + +
+Houdini: Camera Loader fix mismatch for Maya cameras #5584 + +This PR adds +- A workaround to match Maya render mask in Houdini +- `SetCameraResolution` inventory action +- set camera resolution when loading or updating camera + + +___ + +
+ + +
+Nuke: fix set colorspace on writes #5634 + +Colorspace is set correctly to any write node created from publisher. + + +___ + +
+ + +
+TVPaint: Fix review family extraction #5637 + +Extractor marks representation of review instance with review tag. + + +___ + +
+ + +
+AYON settings: Extract OIIO transcode settings #5639 + +Output definitions of Extract OIIO transcode have name to match OpenPype settings, and the settings are converted to dictionary in settings conversion. + + +___ + +
+ + +
+AYON: Fix task type short name conversion #5641 + +Convert AYON task type short name for OpenPype correctly. + + +___ + +
+ + +
+colorspace: missing `allowed_exts` fix #5646 + +Colorspace module is not failing due to missing `allowed_exts` attribute. + + +___ + +
+ + +
+Photoshop: remove trailing underscore in subset name #5647 + +If {layer} placeholder is at the end of subset name template and not used (for example in `auto_image` where separating it by layer doesn't make any sense) trailing '_' was kept. This updates cleaning logic and extracts it as it might be similar in regular `image` instance. + + +___ + +
+ + +
+traypublisher: missing `assetEntity` in context data #5648 + +Issue with missing `assetEnity` key in context data is not problem anymore. + + +___ + +
+ + +
+AYON: Workfiles tool save button works #5653 + +Fix save as button in workfiles tool.(It is mystery why this stopped to work??) + + +___ + +
+ + +
+Max: bug fix delete items from container #5658 + +Fix the bug shown when clicking "Delete Items from Container" and selecting nothing and press ok. + + +___ + +
+ +### **πŸ”€ Refactored code** + + +
+Chore: Remove unused functions from Fusion integration #5617 + +Cleanup unused code from Fusion integration + + +___ + +
+ +### **Merged pull requests** + + +
+Increase timout for deadline test #5654 + +DL picks up jobs quite slow, so bump up delay. + + +___ + +
+ + + + +## [3.17.0](https://github.com/ynput/OpenPype/tree/3.17.0) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.7...3.17.0) + +### **πŸš€ Enhancements** + + +
+Chore: Remove schema from OpenPype root #5355 + +Remove unused schema directory in root of repository which was moved inside openpype/pipeline/schema. + + +___ + +
+ + +
+Igniter: Allow custom Qt scale factor rounding policy #5554 + +Do not force `PassThrough` rounding policy if different policy is defined via env variable. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Chore: Lower urllib3 to support older OpenSSL #5538 + +Lowered `urllib3` to `1.26.16` to support older OpenSSL. + + +___ + +
+ + +
+Chore: Do not try to add schema to zip files #5557 + +Do not add `schema` folder to zip file. This fixes issue cause by https://github.com/ynput/OpenPype/pull/5355 . + + +___ + +
+ + +
+Chore: Lower click dependency version #5629 + +Lower click version to support older versions of python. + + +___ + +
+ +### **Merged pull requests** + + +
+Bump certifi from 2023.5.7 to 2023.7.22 #5351 + +Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.5.7 to 2023.7.22. +
+Commits + +
+
+ + +[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=certifi&package-manager=pip&previous-version=2023.5.7&new-version=2023.7.22)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +You can trigger a rebase of this PR by commenting `@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually +- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) +You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts). + +
+ +> **Note** +> Automatic rebases have been disabled on this pull request as it has been open for over 30 days. + +___ + +
+ + + + +## [3.16.7](https://github.com/ynput/OpenPype/tree/3.16.7) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.6...3.16.7) + +### **πŸ†• New features** + + +
+Maya: Extract active view as thumbnail when no thumbnail set #5426 + +This sets the Maya instance's thumbnail to the current active view if no thumbnail was set yet. + + +___ + +
+ + +
+Maya: Implement USD publish and load using native `mayaUsdPlugin` #5573 + +Implement Creator and Loaders for extraction and loading of USD files using Maya's own `mayaUsdPlugin`.Also adds support to load a `usd` file into an Arnold Standin (`aiStandin`) and assigning looks to it. + + +___ + +
+ + +
+AYON: Ignore separated modules #5619 + +Do not load already separated modules from default directory. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Maya: Reduce amount of code for Collect Looks #5253 + +- Refactor `get_file_node_files` because popping from `paths` by index should have been done in reversed order anyway. It's now changed to not need popping at all. +- Removed unused `RENDERER_NODE_TYPES` and if-branch which collected `node_attrs` list which was unused + collected members which was also done outside of the if branch and thus generated no extra data. +- Collected all materials from look set attributes at once instead of multiple queries +- Collected all file nodes in history from a single query instead of per type +- Restructured assignment of `instance.data["resources"]` to be more readable +- Cached `PXR_NODES` only ones (Note: plugin load is checked on discovery of the collect look plugin) instead of querying plugin load and its nodes per file node per attribute +- Removed some debug logs or combined some messages + + +___ + +
+ + +
+AYON: Mark deprecated settings in Maya #5627 + +Added deprecated info to docstrings of maya colormanagement settings.Resolves: https://github.com/ynput/OpenPype/issues/5556 + + +___ + +
+ + +
+Max: switching versions of maxScene maintain parentage/links with the loaders #5424 + +When using scene inventory to manage or update the version of the loading objects, the linked modifiers or parentage of the objects would be kept.Meanwhile, loaded objects from all loaders no longer parented to the container with OP Data. + + +___ + +
+ + +
+3ds max: small tweaks to obj extractor and model publishing flow #5605 + +There migh be situation where OBJ Extractor passes without failure, but no obj file is produced. This is adding simple check directly into the extractor to catch it earlier then in the integration phase. Also switched `Validate USD Plugin` to optional, because it was always run no matter if the Extract USD was enabled or not, hindering testing (and publishing). + + +___ + +
+ + +
+TVPaint: Plugin can be reopened #5610 + +TVPaint plugin can be reopened. + + +___ + +
+ + +
+Maya: Remove context prompt #5632 + +More of a plea than a PR, but could we please remove the context prompt in Maya when switching tasks? + + +___ + +
+ + +
+General: Create a desktop icon is checked #5636 + +In OP Installer `Create a desktop icon` is checked by default. +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: Extract look is not AYON compatible - OP-5375 #5341 + +The textures that would use hardlinking are going through texture processors. Currently all texture processors are hardcoded to copy texture instead of respecting the settings of forcing to copy.The texture processors were last modified 4 months ago, so effectively all clients that are on any pipeline updated in the last 4 months wont be utilizing hardlinking at all, since the hardcoded texture processors will copy texture no matter the OS.This opts for completely disabling the hardlinking feature, while we figure out what to do about it. + + +___ + +
+ + +
+Maya: Multiverse USD Override inherit from correct new style creator #5566 + +Fix Creator for Multiverse USD Override by inheriting from correct new style creator class type + + +___ + +
+ + +
+Max: Bug Fix Alembic Loaders with Ornatrix #5434 + +Bugfix the alembic loader with both ornatrix alembic and max alembic supportsAdd the ornatrix alembic loaders for loading the alembic with Ornatrix-related modifiers. + + +___ + +
+ + +
+AYON: Avoid creation of duplicated links #5593 + +Handle cases when an existing link should be recreated and do not create the same link multitple times during single publishing. + + +___ + +
+ + +
+Extract Review: Multilayer specification for ffmpeg #5613 + +Extract review is specifying layer name when exr is multilayer. + + +___ + +
+ + +
+Fussion: added support for Fusion 17 #5614 + +Fusion 17 still uses Python 3.6 which causes issues with some our delivered libraries. Vendorized necessary set for Python 3.6 + + +___ + +
+ + +
+Publisher: Fix screenshot widget #5615 + +Use correct super method name.EDITED:Removed fade animation which is not triggered at some cases, e.g. in Nuke the animation does not start. I do expect that is caused by `exec_` on the dialog, which blocks event processing to the animation, even when I've added the window as parent it still didn't trigger registered callback.Modified how the "empty" space is not filled by using paths instead of clear mode on painter. Added render hints to add antialiasing. + + +___ + +
+ + +
+Photoshop: auto_images without alpha will not fail #5620 + +ExtractReview caused issue on `auto_image` instance without alpha channel, this fixes it. + + +___ + +
+ + +
+Fix - _id key used instead of id in get_last_version_by_subset_name #5626 + +Just 'id' is not returned because value in fields. Caused KeyError. + + +___ + +
+ + +
+Bugfix: create symlinks for ssl libs on Centos 7 #5633 + +Docker build was missing `libssl.1.1.so` and `libcrypto.1.1.so` symlinks needed by the executable itself, because Python is now explicitly built with OpenSSL 1.1.1 + + +___ + +
+ +### **πŸ“ƒ Documentation** + + +
+Documentation/local settings #5102 + +I completed the "Working with local settings" page. I updated the screenshot, wrote an explanation for each empty category, and if available, linked the more detailed pages already existing. I also added the "Environments" category. + + +___ + +
+ + + + +## [3.16.6](https://github.com/ynput/OpenPype/tree/3.16.6) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.5...3.16.6) + +### **πŸ†• New features** + + +
+Workfiles tool: Refactor workfiles tool (for AYON) #5550 + +Refactored workfiles tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. + + +___ + +
+ + +
+AfterEffects: added validator for missing files in FootageItems #5590 + +Published composition in AE could contain multiple FootageItems as a layers. If FootageItem contains imported file and it doesn't exist, render triggered by Publish process will silently fail and no output is generated. This could cause failure later in the process with unclear reason. (In `ExtractReview`).This PR adds validation to protect from this. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Maya: Yeti Cache Include viewport preview settings from source #5561 + +When publishing and loading yeti caches persist the display output and preview colors + settings to ensure consistency in the view + + +___ + +
+ + +
+Houdini: validate colorspace in review rop #5322 + +Adding a validator that checks if 'OCIO Colorspace' parameter on review rop was set to a valid value.It is a step towards managing colorspace in review ropvalid values are the ones in the dropdown menuthis validator also provides some helper actions This PR is related to #4836 and #4833 + + +___ + +
+ + +
+Colorspace: adding abstraction of publishing related functions #5497 + +The functionality of Colorspace has been abstracted for greater usability. + + +___ + +
+ + +
+Nuke: removing redundant workfile colorspace attributes #5580 + +Nuke root workfile colorspace data type knobs are long time configured automatically via config roles or the default values are also working well. Therefore there is no need for pipeline managed knobs. + + +___ + +
+ + +
+Ftrack: Less verbose logs for Ftrack integration in artist facing logs #5596 + +- Reduce artist-facing logs for component integration for Ftrack +- Avoid "Comment is not set" log in artist facing report for Kitsu and Ftrack +- Remove info log about `ffprobe` inspecting a file (changed to debug log) +- interesting to see however that it ffprobes the same jpeg twice - but maybe once for thumbnail? + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: Fix rig validators for new out_SET and controls_SET names #5595 + +Fix usage of `out_SET` and `controls_SET` since #5310 because they can now be prefixed by the subset name. + + +___ + +
+ + +
+TrayPublisher: set default frame values to sequential data #5530 + +We are inheriting default frame handles and fps data either from project or setting them to 0. This is just for case a production will decide not to injest the sequential representations with asset based metadata. + + +___ + +
+ + +
+Publisher: Screenshot opacity value fix #5576 + +Fix opacity value. + + +___ + +
+ + +
+AfterEffects: fix imports of image sequences #5581 + +#4602 broke imports of image sequences. + + +___ + +
+ + +
+AYON: Fix representation context conversion #5591 + +Do not fix `"folder"` key in representation context until it is needed. + + +___ + +
+ + +
+ayon-nuke: default factory to lists #5594 + +Default factory were missing in settings schemas for complicated objects like lists and it was causing settings to be failing saving. + + +___ + +
+ + +
+Maya: Fix look assigner showing no asset if 'not found' representations are present #5597 + +Fix Maya Look assigner failing to show any content if it finds an invalid container for which it can't find the asset in the current project. (This can happen when e.g. loading something from a library project).There was logic already to avoid this but there was a bug where it used variable `_id` which did not exist and likely had to be `asset_id`.I've fixed that and improved the logged message a bit, e.g.: +``` +// Warning: openpype.hosts.maya.tools.mayalookassigner.commands : Id found on 22 nodes for which no asset is found database, skipping '641d78ec85c3c5b102e836b0' +``` +Example not found representation in Loader:The issue isn't necessarily related to NOT FOUND representations but in essence boils down to finding nodes with asset ids that do not exist in the current project which could very well just be local meshes in your scene.**Note:**I've excluded logging the nodes themselves because that tends to be a very long list of nodes. Only downside to removing that is that it's unclear which nodes are related to that `id`. If there are any ideas on how to still provide a concise informational message about that that'd be great so I could add it. Things I had considered: +- Report the containers, issue here is that it's about asset ids on nodes which don't HAVE to be in containers - it could be local geometry +- Report the namespaces, issue here is that it could be nodes without namespaces (plus potentially not about ALL nodes in a namespace) +- Report the short names of the nodes; it's shorter and readable but still likely a lot of nodes.@tokejepsen @LiborBatek any other ideas? + + +___ + +
+ + +
+Photoshop: fixed blank Flatten image #5600 + +Flatten image is simplified publishing approach where all visible layers are "flatten" and published together. This image could be used as a reference etc.This is implemented by auto creator which wasn't updated after first publish. This would result in missing newly created layers after `auto_image` instance was created. + + +___ + +
+ + +
+Blender: Remove Hardcoded Subset Name for Reviews #5603 + +Fixes hardcoded subset name for Reviews in Blender. + + +___ + +
+ + +
+TVPaint: Fix tool callbacks #5608 + +Do not wait for callback to finish. + + +___ + +
+ +### **πŸ”€ Refactored code** + + +
+Chore: Remove unused variables and cleanup #5588 + +Removing some unused variables. In some cases the unused variables _seemed like they should've been used - maybe?_ so please **double check the code whether it doesn't hint to an already existing bug**.Also tweaked some other small bugs in code + tweaked logging levels. + + +___ + +
+ +### **Merged pull requests** + + +
+Chore: Loader log deprecation warning for 'fname' attribute #5587 + +Since https://github.com/ynput/OpenPype/pull/4602 the `fname` attribute on the `LoaderPlugin` should've been deprecated and set for removal over time. However, no deprecation warning was logged whatsoever and thus one usage appears to have sneaked in (fixed with this PR) and a new one tried to sneak in with a recent PR + + +___ + +
+ + + + +## [3.16.5](https://github.com/ynput/OpenPype/tree/3.16.5) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.4...3.16.5) + +### **πŸ†• New features** + + +
+Attribute Definitions: Multiselection enum def #5547 + +Added `multiselection` option to `EnumDef`. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Farm: adding target collector #5494 + +Enhancing farm publishing workflow. + + +___ + +
+ + +
+Maya: Optimize validate plug-in path attributes #5522 + +- Optimize query (use `cmds.ls` once) +- Add Select Invalid action +- Improve validation report +- Avoid "Unknown object type" errors + + +___ + +
+ + +
+Maya: Remove Validate Instance Attributes plug-in #5525 + +Remove Validate Instance Attributes plug-in. + + +___ + +
+ + +
+Enhancement: Tweak logging for artist facing reports #5537 + +Tweak the logging of publishing for global, deadline, maya and a fusion plugin to have a cleaner artist-facing report. +- Fix context being reported correctly from CollectContext +- Fix ValidateMeshArnoldAttributes: fix when arnold is not loaded, fix applying settings, fix for when ai attributes do not exist + + +___ + +
+ + +
+AYON: Update settings #5544 + +Updated settings in AYON addons and conversion of AYON settings in OpenPype. + + +___ + +
+ + +
+Chore: Removed Ass export script #5560 + +Removed Arnold render script, which was obsolete and unused. + + +___ + +
+ + +
+Nuke: Allow for knob values to be validated against multiple values. #5042 + +Knob values can now be validated against multiple values, so you can allow write nodes to be `exr` and `png`, or `16-bit` and `32-bit`. + + +___ + +
+ + +
+Enhancement: Cosmetics for Higher version of publish already exists validation error #5190 + +Fix double spaces in message.Example output **after** the PR: + + +___ + +
+ + +
+Nuke: publish existing frames on farm #5409 + +This PR proposes adding a fourth option in Nuke render publish called "Use Existing Frames - Farm". This would be useful when the farm is busy or when the artist lacks enough farm licenses. Additionally, some artists prefer rendering on the farm but still want to check frames before publishing.By adding the "Use Existing Frames - Farm" option, artists will have more flexibility and control over their render publishing process. This enhancement will streamline the workflow and improve efficiency for Nuke users. + + +___ + +
+ + +
+Unreal: Create project in temp location and move to final when done #5476 + +Create Unreal project in local temporary folder and when done, move it to final destination. + + +___ + +
+ + +
+TrayPublisher: adding audio product type into default presets #5489 + +Adding Audio product type into default presets so anybody can publish audio to their shots. + + +___ + +
+ + +
+Global: avoiding cleanup of flagged representation #5502 + +Publishing folder can be flagged as persistent at representation level. + + +___ + +
+ + +
+General: missing tag could raise error #5511 + +- avoiding potential situation where missing Tag key could raise error + + +___ + +
+ + +
+Chore: Queued event system #5514 + +Implemented event system with more expected behavior of event system. If an event is triggered during other event callback, it is not processed immediately but waits until all callbacks of previous events are done. The event system also allows to not trigger events directly once `emit_event` is called which gives option to process events in custom loops. + + +___ + +
+ + +
+Publisher: Tweak log message to provide plugin name after "Plugin" #5521 + +Fix logged message for settings automatically applied to plugin attributes + + +___ + +
+ + +
+Houdini: Improve VDB Selection #5523 + +Improves VDB selection if selection is `SopNode`: return the selected sop nodeif selection is `ObjNode`: get the output node with the minimum 'outputidx' or the node with display flag + + +___ + +
+ + +
+Maya: Refactor/tweak Validate Instance In same Context plug-in #5526 + +- Chore/Refactor: Re-use existing select invalid and repair actions +- Enhancement: provide more elaborate PublishValidationError report +- Bugfix: fix "optional" support by using `OptionalPyblishPluginMixin` base class. + + +___ + +
+ + +
+Enhancement: Update houdini main menu #5527 + +This PR adds two updates: +- dynamic main menu +- dynamic asset name and task + + +___ + +
+ + +
+Houdini: Reset FPS when clicking Set Frame Range #5528 + +_Similar to Maya,_ Make `Set Frame Range` resets FPS, issue https://github.com/ynput/OpenPype/issues/5516 + + +___ + +
+ + +
+Enhancement: Deadline plugins optimize, cleanup and fix optional support for validate deadline pools #5531 + +- Fix optional support of validate deadline pools +- Query deadline webservice only once per URL for verification, and once for available deadline pools instead of for every instance +- Use `deadlineUrl` in `instance.data` when validating pools if it is set. +- Code cleanup: Re-use existing `requests_get` implementation + + +___ + +
+ + +
+Chore: PowerShell script for docker build #5535 + +Added PowerShell script to run docker build. + + +___ + +
+ + +
+AYON: Deadline expand userpaths in executables list #5540 + +Expande `~` paths in executables list. + + +___ + +
+ + +
+Chore: Use correct git url #5542 + +Fixed github url in README.md. + + +___ + +
+ + +
+Chore: Create plugin does not expect system settings #5553 + +System settings are not passed to initialization of create plugin initialization (and `apply_settings`). + + +___ + +
+ + +
+Chore: Allow custom Qt scale factor rounding policy #5555 + +Do not force `PassThrough` rounding policy if different policy is defined via env variable. + + +___ + +
+ + +
+Houdini: Fix outdated containers pop-up on opening last workfile on launch #5567 + +Fix Houdini not showing outdated containers pop-up on scene open when launching with last workfile argument + + +___ + +
+ + +
+Houdini: Improve errors e.g. raise PublishValidationError or cosmetics #5568 + +Improve errors e.g. raise PublishValidationError or cosmeticsThis also fixes the Increment Current File plug-in since due to an invalid import it was previously broken + + +___ + +
+ + +
+Fusion: Code updates #5569 + +Update fusion code which contains obsolete code. Removed `switch_ui.py` script from fusion with related script in scripts. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+Maya: Validate Shape Zero fix repair action + provide informational artist-facing report #5524 + +Refactor to PublishValidationError to allow the RepairAction to work + provide informational report message + + +___ + +
+ + +
+Maya: Fix attribute definitions for `CreateYetiCache` #5574 + +Fix attribute definitions for `CreateYetiCache` + + +___ + +
+ + +
+Max: Optional Renderable Camera Validator for Render Instance #5286 + +Optional validation to check on renderable camera being set up correctly for deadline submission.If not being set up correctly, it wont pass the validation and user can perform repair actions. + + +___ + +
+ + +
+Max: Adding custom modifiers back to the loaded objects #5378 + +The custom parameters OpenpypeData doesn't show in the loaded container when it is being loaded through the loader. + + +___ + +
+ + +
+Houdini: Use default_variant to Houdini Node TAB Creator #5421 + +Use the default variant of the creator plugins on the interactive creator from the TAB node search instead of hard-coding it to `Main`. + + +___ + +
+ + +
+Nuke: adding inherited colorspace from instance #5454 + +Thumbnails are extracted with inherited colorspace collected from rendering write node. + + +___ + +
+ + +
+Add kitsu credentials to deadline publish job #5455 + +This PR hopefully fixes this issue #5440 + + +___ + +
+ + +
+AYON: Fill entities during editorial #5475 + +Fill entities and update template data on instances during extract AYON hierarchy. + + +___ + +
+ + +
+Ftrack: Fix version 0 when integrating to Ftrack - OP-6595 #5477 + +Fix publishing version 0 to Ftrack. + + +___ + +
+ + +
+OCIO: windows unc path support in Nuke and Hiero #5479 + +Hiero and Nuke is not supporting windows unc path formatting in OCIO environment variable. + + +___ + +
+ + +
+Deadline: Added super call to init #5480 + +DL 10.3 requires plugin inheriting from DeadlinePlugin to call super's **init** explicitly. + + +___ + +
+ + +
+Nuke: fixing thumbnail and monitor out root attributes #5483 + +Nuke Root Colorspace settings for Thumbnail and Monitor Out schema was gradually changed between version 12, 13, 14 and we needed to address those changes individually for particular version. + + +___ + +
+ + +
+Nuke: fixing missing `instance_id` error #5484 + +Workfiles with Instances created in old publisher workflow were rising error during converting method since they were missing `instance_id` key introduced in new publisher workflow. + + +___ + +
+ + +
+Nuke: existing frames validator is repairing render target #5486 + +Nuke is now correctly repairing render target after the existing frames validator finds missing frames and repair action is used. + + +___ + +
+ + +
+added UE to extract burnins families #5487 + +This PR fixes missing burnins in reviewables when rendering from UE. +___ + +
+ + +
+Harmony: refresh code for current Deadline #5493 + +- Added support in Deadline Plug-in for new versions of Harmony, in particular version 21 and 22. +- Remove review=False flag on render instance +- Add farm=True flag on render instance +- Fix is_in_tests function call in Harmony Deadline submission plugin +- Force HarmonyOpenPype.py Deadline Python plug-in to py3 +- Fix cosmetics/hound in HarmonyOpenPype.py Deadline Python plug-in + + +___ + +
+ + +
+Publisher: Fix multiselection value #5505 + +Selection of multiple instances in Publisher does not cause that all instances change all publish attributes to the same value. + + +___ + +
+ + +
+Publisher: Avoid warnings on thumbnails if source image also has alpha channel #5510 + +Avoids the following warning from `ExtractThumbnailFromSource`: +``` +// pyblish.ExtractThumbnailFromSource : oiiotool WARNING: -o : Can't save 4 channels to jpeg... saving only R,G,B +``` + + + +___ + +
+ + +
+Update ayon-python-api #5512 + +Update ayon python api and related callbacks. + + +___ + +
+ + +
+Max: Fixing the bug of falling back to use workfile for Arnold or any renderers except Redshift #5520 + +Fix the bug of falling back to use workfile for Arnold + + +___ + +
+ + +
+General: Fix Validate Publish Dir Validator #5534 + +Nonsensical "family" key was used instead of real value (as 'render' etc.) which would result in wrong translation of intermediate family names.Updated docstring. + + +___ + +
+ + +
+have the addons loading respect a custom AYON_ADDONS_DIR #5539 + +When using a custom AYON_ADDONS_DIR environment variable that variable is used in the launcher correctly and downloads and extracts addons to there, however when running Ayon does not respect this environment variable + + +___ + +
+ + +
+Deadline: files on representation cannot be single item list #5545 + +Further logic expects that single item files will be only 'string' not 'list' (eg. repre["files"] = "abc.exr" not repre["files"] = ["abc.exr"].This would cause an issue in ExtractReview later.This could happen if DL rendered single frame file with different frame value. + + +___ + +
+ + +
+Webpublisher: better encode list values for click #5546 + +Targets could be a list, original implementation pushed it as a separate items, it must be added as `--targets webpulish --targets filepublish`.`wepublish_routes` handles triggering from UI, changes in `publish_functions` handle triggering from cmd (for tests, api access). + + +___ + +
+ + +
+Houdini: Introduce imprint function for correct version in hda loader #5548 + +Resolve #5478 + + +___ + +
+ + +
+AYON: Fill entities during editorial (2) #5549 + +Fix changes made in https://github.com/ynput/OpenPype/pull/5475. + + +___ + +
+ + +
+Max: OP Data updates in Loaders #5563 + +Fix the bug on the loaders not being able to load the objects when iterating key and values with the dict.Max prefers list over the list in dict. + + +___ + +
+ + +
+Create Plugins: Better check of overriden '__init__' method #5571 + +Create plugins do not log warning messages about each create plugin because of wrong `__init__` method check. + + +___ + +
+ +### **Merged pull requests** + + +
+Tests: fix unit tests #5533 + +Fixed failing tests.Updated Unreal's validator to match removed general one which had a couple of issues fixed. + + +___ + +
+ + + + +## [3.16.4](https://github.com/ynput/OpenPype/tree/3.16.4) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.3...3.16.4) + +### **πŸ†• New features** + + +
+Feature: Download last published workfile specify version #4998 + +Setting `workfile_version` key to hook's `self.launch_context.data` allow you to specify the workfile version you want sync service to download if none is matched locally. This is helpful if the last version hasn't been correctly published/synchronized, and you want to recover the previous one (or some you'd like).Version could be set in two ways: +- OP's absolute version, matching the `version` index in DB. +- Relative version in reverse order from the last one: `-2`, `-3`...I don't know where I should write documentation about that. + + +___ + +
+ +### **πŸš€ Enhancements** + + +
+Maya: allow not creation of group for Import loaders #5427 + +This PR enhances previous one. All ReferenceLoaders could not wrap imported products into explicit group.Also `Import` Loaders have same options. Control for this is separate in Settings, eg. Reference might wrap loaded items in group, `Import` might not. + + +___ + +
+ + +
+3dsMax: Settings for Ayon #5388 + +Max Addon Setting for Ayon + + +___ + +
+ + +
+General: Navigation to Folder from Launcher #5404 + +Adds an action in launcher to open the directory of the asset. + + +___ + +
+ + +
+Chore: Default variant in create plugin #5429 + +Attribute `default_variant` on create plugins always returns string and if default variant is not filled other ways how to get one are implemented. + + +___ + +
+ + +
+Publisher: Thumbnail widget enhancements #5439 + +Thumbnails widget in Publisher has new 3 options to choose from: Paste (from clipboard), Take screenshot and Browse. Clear button and new options are not visible by default, user must expand options button to show them. + + +___ + +
+ + +
+AYON: Update ayon api to '0.3.5' #5460 + +Updated ayon-python-api to 0.3.5. + + +___ + +
+ +### **πŸ› Bug fixes** + + +
+AYON: Apply unknown ayon settings first #5435 + +Settings of custom addons are available in converted settings. + + +___ + +
+ + +
+Maya: Fix wrong subset name of render family in deadline #5442 + +New Publisher is creating different subset names than previously which resulted in duplication of `render` string in final subset name of `render` family published on Deadline.This PR solves that, it also fixes issues with legacy instances from old publisher, it matches the subset name as was before.This solves same issue in Max implementation. + + +___ + +
+ + +
+Maya: Fix setting of version to workfile instance #5452 + +If there are multiple instances of renderlayer published, previous logic resulted in unpredictable rewrite of instance family to 'workfile' if `Sync render version with workfile` was on. + + +___ + +
+ + +
+Maya: Context plugin shouldn't be tied to family #5464 + +`Maya Current File` collector was tied to `workfile` unnecessary. It should run even if `workile` instance is not being published. + + +___ + +
+ + +
+Unreal: Fix loading hero version for static and skeletal meshes #5393 + +Fixed a problem with loading hero versions for static ans skeletal meshes. + + +___ + +
+ + +
+TVPaint: Fix 'repeat' behavior #5412 + +Calculation of frames for repeat behavior is working correctly. + + +___ + +
+ + +
+AYON: Thumbnails cache and api prep #5437 + +Moved thumbnails cache from ayon python api to OpenPype and prepare AYON thumbnail resolver for new api functions. Current implementation should work with old and new ayon-python-api. + + +___ + +
+ + +
+Nuke: Name of the Read Node should be updated correctly when switching versions or assets. #5444 + +Bug fixing of the read node's name not being updated correctly when setting version or switching asset. + + +___ + +
+ + +
+Farm publishing: asymmetric handles fixed #5446 + +Handles are now set correctly on farm published product version if asymmetric were set to shot attributes. + + +___ + +
+ + +
+Scene Inventory: Provider icons fix #5450 + +Fix how provider icons are accessed in scene inventory. + + +___ + +
+ + +
+Fix typo on Deadline OP plugin name #5453 + +Surprised that no one has hit this bug yet... but it seems like there was a typo on the name of the OP Deadline plugin when submitting jobs to it. + + +___ + +
+ + +
+AYON: Fix version attributes update #5472 + +Fixed updates of attribs in AYON mode. + + +___ + +
+ +### **Merged pull requests** + + +
+Added missing defaults for import_loader #5447 + + +___ + +
+ + +
+Bug: Local settings don't open on 3.14.7 #5220 + +### Before posting a new ticket, have you looked through the documentation to find an answer? + +Yes I have + +### Have you looked through the existing tickets to find any related issues ? + +Not yet + +### Author of the bug + +@FadyFS + +### Version + +3.15.11-nightly.3 + +### What platform you are running OpenPype on? + +Linux / Centos + +### Current Behavior: + +the previous behavior (bug) : +![image](https://github.com/quadproduction/OpenPype/assets/135602303/09bff9d5-3f8b-4339-a1e5-30c04ade828c) + + +### Expected Behavior: + +![image](https://github.com/quadproduction/OpenPype/assets/135602303/c505a103-7965-4796-bcdf-73bcc48a469b) + + +### What type of bug is it ? + +Happened only once in a particular configuration + +### Which project / workfile / asset / ... + +open settings with 3.14.7 + +### Steps To Reproduce: + +1. Run openpype on the 3.15.11-nightly.3 version +2. Open settings in 3.14.7 version + +### Relevant log output: + +_No response_ + +### Additional context: + +_No response_ + +___ + +
+ + +
+Tests: Add automated targets for tests #5443 + +Without it plugins with 'automated' targets won't be triggered (eg `CloseAE` etc.) + + +___ + +
+ + + + ## [3.16.3](https://github.com/ynput/OpenPype/tree/3.16.3) diff --git a/Dockerfile.centos7 b/Dockerfile.centos7 index 9217140f20..ab1d3f8253 100644 --- a/Dockerfile.centos7 +++ b/Dockerfile.centos7 @@ -109,6 +109,8 @@ RUN source $HOME/.bashrc \ RUN cp /usr/lib64/libffi* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/openssl11/libssl* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/openssl11/libcrypto* ./build/exe.linux-x86_64-3.9/lib \ + && ln -sr ./build/exe.linux-x86_64-3.9/lib/libssl.so ./build/exe.linux-x86_64-3.9/lib/libssl.1.1.so \ + && ln -sr ./build/exe.linux-x86_64-3.9/lib/libcrypto.so ./build/exe.linux-x86_64-3.9/lib/libcrypto.1.1.so \ && cp /root/.pyenv/versions/${OPENPYPE_PYTHON_VERSION}/lib/libpython* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/libxcb* ./build/exe.linux-x86_64-3.9/vendor/python/PySide2/Qt/lib diff --git a/README.md b/README.md index 6caed8061c..ed3e058002 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ development tools like [CMake](https://cmake.org/) and [Visual Studio](https://v #### Clone repository: ```sh -git clone --recurse-submodules git@github.com:Pypeclub/OpenPype.git +git clone --recurse-submodules git@github.com:ynput/OpenPype.git ``` #### To build OpenPype: @@ -144,6 +144,10 @@ sudo ./tools/docker_build.sh centos7 If all is successful, you'll find built OpenPype in `./build/` folder. +Docker build can be also started from Windows machine, just use `./tools/docker_build.ps1` instead of shell script. + +This could be used even for building linux build (with argument `centos7` or `debian`) + #### Manual build You will need [Python >= 3.9](https://www.python.org/downloads/) and [git](https://git-scm.com/downloads). You'll also need [curl](https://curl.se) on systems that doesn't have one preinstalled. @@ -275,7 +279,7 @@ arguments and it will create zip file that OpenPype can use. Building documentation ---------------------- -Top build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation +To build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation from current sources in `.\docs\build`. **Note that it needs existing virtual environment.** diff --git a/igniter/__init__.py b/igniter/__init__.py index 16ffb940f6..085a825860 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -34,7 +34,11 @@ def _get_qt_app(): if attr is not None: QtWidgets.QApplication.setAttribute(attr) - if hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy"): + policy = os.getenv("QT_SCALE_FACTOR_ROUNDING_POLICY") + if ( + hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy") + and not policy + ): QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 408764e1a8..e7b440f812 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -589,7 +589,7 @@ class BootstrapRepos: self.registry = OpenPypeSettingsRegistry() self.zip_filter = [".pyc", "__pycache__"] self.openpype_filter = [ - "openpype", "schema", "LICENSE" + "openpype", "LICENSE" ] # dummy progress reporter diff --git a/inno_setup.iss b/inno_setup.iss index 418bedbd4d..d9a41d22ee 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -36,7 +36,7 @@ WizardStyle=modern Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" [InstallDelete] ; clean everything in previous installation folder @@ -53,4 +53,3 @@ Name: "{autodesktop}\{#MyAppName} {#AppVer}"; Filename: "{app}\openpype_gui.exe" [Run] Filename: "{app}\openpype_gui.exe"; Description: "{cm:LaunchProgram,OpenPype}"; Flags: nowait postinstall skipifsilent - diff --git a/openpype/cli.py b/openpype/cli.py index 0df277fb0a..f0fe550a1f 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -282,6 +282,9 @@ def run(script): "--app_variant", help="Provide specific app variant for test, empty for latest", default=None) +@click.option("--app_group", + help="Provide specific app group for test, empty for default", + default=None) @click.option("-t", "--timeout", help="Provide specific timeout value for test case", @@ -290,11 +293,15 @@ def run(script): "--setup_only", help="Only create dbs, do not run tests", default=None) +@click.option("--mongo_url", + help="MongoDB for testing.", + default=None) def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, - timeout, setup_only): + timeout, setup_only, mongo_url, app_group): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, - persist, app_variant, timeout, setup_only) + persist, app_variant, timeout, setup_only, + mongo_url, app_group) @main.command(help="DEPRECATED - run sync server") diff --git a/openpype/client/__init__.py b/openpype/client/__init__.py index 7831afd8ad..ba36d940e3 100644 --- a/openpype/client/__init__.py +++ b/openpype/client/__init__.py @@ -1,6 +1,7 @@ from .mongo import ( OpenPypeMongoConnection, ) +from .server.utils import get_ayon_server_api_connection from .entities import ( get_projects, @@ -43,6 +44,8 @@ from .entities import ( get_thumbnail_id_from_source, get_workfile_info, + + get_asset_name_identifier, ) from .entity_links import ( @@ -59,6 +62,8 @@ from .operations import ( __all__ = ( "OpenPypeMongoConnection", + "get_ayon_server_api_connection", + "get_projects", "get_project", "get_whole_project", @@ -105,4 +110,6 @@ __all__ = ( "get_linked_representation_id", "create_project", + + "get_asset_name_identifier", ) diff --git a/openpype/client/entities.py b/openpype/client/entities.py index 5d9654c611..cbaa943743 100644 --- a/openpype/client/entities.py +++ b/openpype/client/entities.py @@ -4,3 +4,22 @@ if not AYON_SERVER_ENABLED: from .mongo.entities import * else: from .server.entities import * + + +def get_asset_name_identifier(asset_doc): + """Get asset name identifier by asset document. + + This function is added because of AYON implementation where name + identifier is not just a name but full path. + + Asset document must have "name" key, and "data.parents" when in AYON mode. + + Args: + asset_doc (dict[str, Any]): Asset document. + """ + + if not AYON_SERVER_ENABLED: + return asset_doc["name"] + parents = list(asset_doc["data"]["parents"]) + parents.append(asset_doc["name"]) + return "/" + "/".join(parents) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index 42df337b6d..51af99e722 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -138,16 +138,22 @@ def _template_replacements_to_v3(template): ) -def _convert_template_item(template): - # Others won't have 'directory' - if "directory" not in template: - return - folder = _template_replacements_to_v3(template.pop("directory")) - template["folder"] = folder - template["file"] = _template_replacements_to_v3(template["file"]) - template["path"] = "/".join( - (folder, template["file"]) - ) +def _convert_template_item(template_item): + for key, value in tuple(template_item.items()): + template_item[key] = _template_replacements_to_v3(value) + + # Change 'directory' to 'folder' + if "directory" in template_item: + template_item["folder"] = template_item.pop("directory") + + if ( + "path" not in template_item + and "file" in template_item + and "folder" in template_item + ): + template_item["path"] = "/".join( + (template_item["folder"], template_item["file"]) + ) def _fill_template_category(templates, cat_templates, cat_key): @@ -212,10 +218,27 @@ def convert_v4_project_to_v3(project): _convert_template_item(template) new_others_templates[name] = template + staging_templates = templates.pop("staging", None) + # Key 'staging_directories' is legacy key that changed + # to 'staging_dir' + _legacy_staging_templates = templates.pop("staging_directories", None) + if staging_templates is None: + staging_templates = _legacy_staging_templates + + if staging_templates is None: + staging_templates = {} + + # Prefix all staging template names with 'staging_' prefix + # and add them to 'others' + for name, template in staging_templates.items(): + _convert_template_item(template) + new_name = "staging_{}".format(name) + new_others_templates[new_name] = template + for key in ( "work", "publish", - "hero" + "hero", ): cat_templates = templates.pop(key) _fill_template_category(templates, cat_templates, key) @@ -235,6 +258,8 @@ def convert_v4_project_to_v3(project): new_task_types = {} for task_type in task_types: name = task_type.pop("name") + # Change 'shortName' to 'short_name' + task_type["short_name"] = task_type.pop("shortName", None) new_task_types[name] = task_type config["tasks"] = new_task_types @@ -663,10 +688,13 @@ def convert_v4_representation_to_v3(representation): if isinstance(context, six.string_types): context = json.loads(context) - if "folder" in context: - _c_folder = context.pop("folder") + if "asset" not in context and "folder" in context: + _c_folder = context["folder"] context["asset"] = _c_folder["name"] + elif "asset" in context and "folder" not in context: + context["folder"] = {"name": context["asset"]} + if "product" in context: _c_product = context.pop("product") context["family"] = _c_product["type"] @@ -959,9 +987,11 @@ def convert_create_representation_to_v4(representation, con): converted_representation["files"] = new_files context = representation["context"] - context["folder"] = { - "name": context.pop("asset", None) - } + if "folder" not in context: + context["folder"] = { + "name": context.get("asset") + } + context["product"] = { "type": context.pop("family", None), "name": context.pop("subset", None), @@ -1074,7 +1104,7 @@ def convert_update_folder_to_v4(project_name, asset_id, update_data, con): parent_id = None tasks = None new_data = {} - attribs = {} + attribs = full_update_data.pop("attrib", {}) if "type" in update_data: new_update_data["active"] = update_data["type"] == "asset" @@ -1113,6 +1143,9 @@ def convert_update_folder_to_v4(project_name, asset_id, update_data, con): print("Folder has new data: {}".format(new_data)) new_update_data["data"] = new_data + if attribs: + new_update_data["attrib"] = attribs + if has_task_changes: raise ValueError("Task changes of folder are not implemented") @@ -1126,7 +1159,7 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con): full_update_data = _from_flat_dict(update_data) data = full_update_data.get("data") new_data = {} - attribs = {} + attribs = full_update_data.pop("attrib", {}) if data: if "family" in data: family = data.pop("family") @@ -1148,9 +1181,6 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con): elif value is not REMOVED_VALUE: new_data[key] = value - if attribs: - new_update_data["attribs"] = attribs - if "name" in update_data: new_update_data["name"] = update_data["name"] @@ -1165,6 +1195,9 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con): new_update_data["folderId"] = update_data["parent"] flat_data = _to_flat_dict(new_update_data) + if attribs: + flat_data["attrib"] = attribs + if new_data: print("Subset has new data: {}".format(new_data)) flat_data["data"] = new_data @@ -1179,7 +1212,7 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con): full_update_data = _from_flat_dict(update_data) data = full_update_data.get("data") new_data = {} - attribs = {} + attribs = full_update_data.pop("attrib", {}) if data: if "author" in data: new_update_data["author"] = data.pop("author") @@ -1196,9 +1229,6 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con): elif value is not REMOVED_VALUE: new_data[key] = value - if attribs: - new_update_data["attribs"] = attribs - if "name" in update_data: new_update_data["version"] = update_data["name"] @@ -1213,6 +1243,9 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con): new_update_data["productId"] = update_data["parent"] flat_data = _to_flat_dict(new_update_data) + if attribs: + flat_data["attrib"] = attribs + if new_data: print("Version has new data: {}".format(new_data)) flat_data["data"] = new_data @@ -1252,7 +1285,7 @@ def convert_update_representation_to_v4( data = full_update_data.get("data") new_data = {} - attribs = {} + attribs = full_update_data.pop("attrib", {}) if data: for key, value in data.items(): if key in folder_attributes: @@ -1282,7 +1315,7 @@ def convert_update_representation_to_v4( if "context" in update_data: context = update_data["context"] - if "asset" in context: + if "folder" not in context and "asset" in context: context["folder"] = {"name": context.pop("asset")} if "family" in context or "subset" in context: @@ -1309,6 +1342,9 @@ def convert_update_representation_to_v4( new_update_data["files"] = new_files flat_data = _to_flat_dict(new_update_data) + if attribs: + flat_data["attrib"] = attribs + if new_data: print("Representation has new data: {}".format(new_data)) flat_data["data"] = new_data diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 9579f13add..75b5dc2cdd 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -1,9 +1,8 @@ import collections -from ayon_api import get_server_api_connection - from openpype.client.mongo.operations import CURRENT_THUMBNAIL_SCHEMA +from .utils import get_ayon_server_api_connection from .openpype_comp import get_folders_with_tasks from .conversion_utils import ( project_fields_v3_to_v4, @@ -37,7 +36,7 @@ def get_projects(active=True, inactive=False, library=None, fields=None): elif inactive: active = False - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = project_fields_v3_to_v4(fields, con) for project in con.get_projects(active, library, fields=fields): yield convert_v4_project_to_v3(project) @@ -45,7 +44,7 @@ def get_projects(active=True, inactive=False, library=None, fields=None): def get_project(project_name, active=True, inactive=False, fields=None): # Skip if both are disabled - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = project_fields_v3_to_v4(fields, con) return convert_v4_project_to_v3( con.get_project(project_name, fields=fields) @@ -66,7 +65,7 @@ def _get_subsets( fields=None ): # Convert fields and add minimum required fields - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = subset_fields_v3_to_v4(fields, con) if fields is not None: for key in ( @@ -75,18 +74,18 @@ def _get_subsets( ): fields.add(key) - active = None + active = True if archived: - active = False + active = None for subset in con.get_products( project_name, - subset_ids, - subset_names, - folder_ids, - names_by_folder_ids, - active, - fields + product_ids=subset_ids, + product_names=subset_names, + folder_ids=folder_ids, + names_by_folder_ids=names_by_folder_ids, + active=active, + fields=fields, ): yield convert_v4_subset_to_v3(subset) @@ -102,7 +101,7 @@ def _get_versions( active=None, fields=None ): - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = version_fields_v3_to_v4(fields, con) @@ -114,23 +113,23 @@ def _get_versions( queried_versions = con.get_versions( project_name, - version_ids, - subset_ids, - versions, - hero, - standard, - latest, + version_ids=version_ids, + product_ids=subset_ids, + versions=versions, + hero=hero, + standard=standard, + latest=latest, active=active, fields=fields ) - versions = [] + version_entities = [] hero_versions = [] for version in queried_versions: if version["version"] < 0: hero_versions.append(version) else: - versions.append(convert_v4_version_to_v3(version)) + version_entities.append(convert_v4_version_to_v3(version)) if hero_versions: subset_ids = set() @@ -160,9 +159,9 @@ def _get_versions( break conv_hero = convert_v4_version_to_v3(hero_version) conv_hero["version_id"] = version_id - versions.append(conv_hero) + version_entities.append(conv_hero) - return versions + return version_entities def get_asset_by_id(project_name, asset_id, fields=None): @@ -183,6 +182,19 @@ def get_asset_by_name(project_name, asset_name, fields=None): return None +def _folders_query(project_name, con, fields, **kwargs): + if fields is None or "tasks" in fields: + folders = get_folders_with_tasks( + con, project_name, fields=fields, **kwargs + ) + + else: + folders = con.get_folders(project_name, fields=fields, **kwargs) + + for folder in folders: + yield folder + + def get_assets( project_name, asset_ids=None, @@ -196,26 +208,45 @@ def get_assets( active = True if archived: - active = False + active = None - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = folder_fields_v3_to_v4(fields, con) kwargs = dict( folder_ids=asset_ids, - folder_names=asset_names, parent_ids=parent_ids, active=active, - fields=fields ) + if not asset_names: + for folder in _folders_query(project_name, con, fields, **kwargs): + yield convert_v4_folder_to_v3(folder, project_name) + return - if fields is None or "tasks" in fields: - folders = get_folders_with_tasks(con, project_name, **kwargs) + new_asset_names = set() + folder_paths = set() + for name in asset_names: + if "/" in name: + folder_paths.add(name) + else: + new_asset_names.add(name) - else: - folders = con.get_folders(project_name, **kwargs) + yielded_ids = set() + if folder_paths: + for folder in _folders_query( + project_name, con, fields, folder_paths=folder_paths, **kwargs + ): + yielded_ids.add(folder["id"]) + yield convert_v4_folder_to_v3(folder, project_name) - for folder in folders: - yield convert_v4_folder_to_v3(folder, project_name) + if not new_asset_names: + return + + for folder in _folders_query( + project_name, con, fields, folder_names=new_asset_names, **kwargs + ): + if folder["id"] not in yielded_ids: + yielded_ids.add(folder["id"]) + yield convert_v4_folder_to_v3(folder, project_name) def get_archived_assets( @@ -236,7 +267,7 @@ def get_archived_assets( def get_asset_ids_with_subsets(project_name, asset_ids=None): - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.get_folder_ids_with_products(project_name, asset_ids) @@ -282,7 +313,7 @@ def get_subsets( def get_subset_families(project_name, subset_ids=None): - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.get_product_type_names(project_name, subset_ids) @@ -422,7 +453,7 @@ def get_last_version_by_subset_name( if not subset: return None return get_last_version_by_subset_id( - project_name, subset["id"], fields=fields + project_name, subset["_id"], fields=fields ) @@ -430,7 +461,7 @@ def get_output_link_versions(project_name, version_id, fields=None): if not version_id: return [] - con = get_server_api_connection() + con = get_ayon_server_api_connection() version_links = con.get_version_links( project_name, version_id, link_direction="out") @@ -446,7 +477,7 @@ def get_output_link_versions(project_name, version_id, fields=None): def version_is_latest(project_name, version_id): - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.version_is_latest(project_name, version_id) @@ -501,18 +532,18 @@ def get_representations( else: active = None - con = get_server_api_connection() + con = get_ayon_server_api_connection() fields = representation_fields_v3_to_v4(fields, con) if fields and active is not None: fields.add("active") representations = con.get_representations( project_name, - representation_ids, - representation_names, - version_ids, - names_by_version_ids, - active, + representation_ids=representation_ids, + representation_names=representation_names, + version_ids=version_ids, + names_by_version_ids=names_by_version_ids, + active=active, fields=fields ) for representation in representations: @@ -535,7 +566,7 @@ def get_representations_parents(project_name, representations): repre["_id"] for repre in representations } - con = get_server_api_connection() + con = get_ayon_server_api_connection() parents_by_repre_id = con.get_representations_parents(project_name, repre_ids) folder_ids = set() @@ -677,7 +708,7 @@ def get_workfile_info( if not asset_id or not task_name or not filename: return None - con = get_server_api_connection() + con = get_ayon_server_api_connection() task = con.get_task_by_name( project_name, asset_id, task_name, fields=["id", "name", "folderId"] ) diff --git a/openpype/client/server/entity_links.py b/openpype/client/server/entity_links.py index d8395aabe7..368dcdcb9d 100644 --- a/openpype/client/server/entity_links.py +++ b/openpype/client/server/entity_links.py @@ -1,6 +1,4 @@ -import ayon_api -from ayon_api import get_folder_links, get_versions_links - +from .utils import get_ayon_server_api_connection from .entities import get_assets, get_representation_by_id @@ -28,7 +26,8 @@ def get_linked_asset_ids(project_name, asset_doc=None, asset_id=None): if not asset_id: asset_id = asset_doc["_id"] - links = get_folder_links(project_name, asset_id, link_direction="in") + con = get_ayon_server_api_connection() + links = con.get_folder_links(project_name, asset_id, link_direction="in") return [ link["entityId"] for link in links @@ -115,6 +114,7 @@ def get_linked_representation_id( if link_type: link_types = [link_type] + con = get_ayon_server_api_connection() # Store already found version ids to avoid recursion, and also to store # output -> Don't forget to remove 'version_id' at the end!!! linked_version_ids = {version_id} @@ -124,7 +124,7 @@ def get_linked_representation_id( if not versions_to_check: break - links = get_versions_links( + links = con.get_versions_links( project_name, versions_to_check, link_types=link_types, @@ -145,8 +145,8 @@ def get_linked_representation_id( linked_version_ids.remove(version_id) if not linked_version_ids: return [] - - representations = ayon_api.get_representations( + con = get_ayon_server_api_connection() + representations = con.get_representations( project_name, version_ids=linked_version_ids, fields=["id"]) diff --git a/openpype/client/server/openpype_comp.py b/openpype/client/server/openpype_comp.py index a123fe3167..71a141e913 100644 --- a/openpype/client/server/openpype_comp.py +++ b/openpype/client/server/openpype_comp.py @@ -1,4 +1,7 @@ import collections +import json + +import six from ayon_api.graphql import GraphQlQuery, FIELD_VALUE, fields_to_dict from .constants import DEFAULT_FOLDER_FIELDS @@ -84,12 +87,12 @@ def get_folders_with_tasks( for folder. All possible folder fields are returned if 'None' is passed. - Returns: - List[Dict[str, Any]]: Queried folder entities. + Yields: + Dict[str, Any]: Queried folder entities. """ if not project_name: - return [] + return filters = { "projectName": project_name @@ -97,25 +100,25 @@ def get_folders_with_tasks( if folder_ids is not None: folder_ids = set(folder_ids) if not folder_ids: - return [] + return filters["folderIds"] = list(folder_ids) if folder_paths is not None: folder_paths = set(folder_paths) if not folder_paths: - return [] + return filters["folderPaths"] = list(folder_paths) if folder_names is not None: folder_names = set(folder_names) if not folder_names: - return [] + return filters["folderNames"] = list(folder_names) if parent_ids is not None: parent_ids = set(parent_ids) if not parent_ids: - return [] + return if None in parent_ids: # Replace 'None' with '"root"' which is used during GraphQl # query for parent ids filter for folders without folder @@ -147,10 +150,10 @@ def get_folders_with_tasks( parsed_data = query.query(con) folders = parsed_data["project"]["folders"] - if active is None: - return folders - return [ - folder - for folder in folders - if folder["active"] is active - ] + for folder in folders: + if active is not None and folder["active"] is not active: + continue + folder_data = folder.get("data") + if isinstance(folder_data, six.string_types): + folder["data"] = json.loads(folder_data) + yield folder diff --git a/openpype/client/server/operations.py b/openpype/client/server/operations.py index eeb55784e1..eddc1eaf60 100644 --- a/openpype/client/server/operations.py +++ b/openpype/client/server/operations.py @@ -5,7 +5,6 @@ import uuid import datetime from bson.objectid import ObjectId -from ayon_api import get_server_api_connection from openpype.client.operations_base import ( REMOVED_VALUE, @@ -41,7 +40,7 @@ from .conversion_utils import ( convert_update_representation_to_v4, convert_update_workfile_info_to_v4, ) -from .utils import create_entity_id +from .utils import create_entity_id, get_ayon_server_api_connection def _create_or_convert_to_id(entity_id=None): @@ -422,7 +421,7 @@ def failed_json_default(value): class ServerCreateOperation(CreateOperation): - """Opeartion to create an entity. + """Operation to create an entity. Args: project_name (str): On which project operation will happen. @@ -634,7 +633,7 @@ class ServerUpdateOperation(UpdateOperation): class ServerDeleteOperation(DeleteOperation): - """Opeartion to delete an entity. + """Operation to delete an entity. Args: project_name (str): On which project operation will happen. @@ -647,7 +646,7 @@ class ServerDeleteOperation(DeleteOperation): self._session = session if entity_type == "asset": - entity_type == "folder" + entity_type = "folder" elif entity_type == "hero_version": entity_type = "version" @@ -680,7 +679,7 @@ class OperationsSession(BaseOperationsSession): def __init__(self, con=None, *args, **kwargs): super(OperationsSession, self).__init__(*args, **kwargs) if con is None: - con = get_server_api_connection() + con = get_ayon_server_api_connection() self._con = con self._project_cache = {} self._nested_operations = collections.defaultdict(list) @@ -858,7 +857,7 @@ def create_project( """ if con is None: - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.create_project( project_name, @@ -870,12 +869,12 @@ def create_project( def delete_project(project_name, con=None): if con is None: - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.delete_project(project_name) def create_thumbnail(project_name, src_filepath, thumbnail_id=None, con=None): if con is None: - con = get_server_api_connection() + con = get_ayon_server_api_connection() return con.create_thumbnail(project_name, src_filepath, thumbnail_id) diff --git a/openpype/client/server/utils.py b/openpype/client/server/utils.py index ed128cfad9..a9dcf539bd 100644 --- a/openpype/client/server/utils.py +++ b/openpype/client/server/utils.py @@ -1,8 +1,33 @@ +import os import uuid +import ayon_api + from openpype.client.operations_base import REMOVED_VALUE +class _GlobalCache: + initialized = False + + +def get_ayon_server_api_connection(): + if _GlobalCache.initialized: + con = ayon_api.get_server_api_connection() + else: + from openpype.lib.local_settings import get_local_site_id + + _GlobalCache.initialized = True + site_id = get_local_site_id() + version = os.getenv("AYON_VERSION") + if ayon_api.is_connection_created(): + con = ayon_api.get_server_api_connection() + con.set_site_id(site_id) + con.set_client_version(version) + else: + con = ayon_api.create_connection(site_id, version) + return con + + def create_entity_id(): return uuid.uuid1().hex diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 1418bc210b..6e255ae82a 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -27,6 +27,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "tvpaint", "substancepainter", "aftereffects", + "wrap" } launch_types = {LaunchTypes.local} diff --git a/openpype/hooks/pre_copy_template_workfile.py b/openpype/hooks/pre_copy_template_workfile.py index 2203ff4396..4d91d83c95 100644 --- a/openpype/hooks/pre_copy_template_workfile.py +++ b/openpype/hooks/pre_copy_template_workfile.py @@ -19,7 +19,8 @@ class CopyTemplateWorkfile(PreLaunchHook): # Before `AddLastWorkfileToLaunchArgs` order = 0 - app_groups = {"blender", "photoshop", "tvpaint", "aftereffects"} + app_groups = {"blender", "photoshop", "tvpaint", "aftereffects", + "wrap"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_new_console_apps.py similarity index 82% rename from openpype/hooks/pre_foundry_apps.py rename to openpype/hooks/pre_new_console_apps.py index 7536df4c16..9727b4fb78 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_new_console_apps.py @@ -2,7 +2,7 @@ import subprocess from openpype.lib.applications import PreLaunchHook, LaunchTypes -class LaunchFoundryAppsWindows(PreLaunchHook): +class LaunchNewConsoleApps(PreLaunchHook): """Foundry applications have specific way how to launch them. Nuke is executed "like" python process so it is required to pass @@ -13,13 +13,15 @@ class LaunchFoundryAppsWindows(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = {"nuke", "nukeassist", "nukex", "hiero", "nukestudio"} + app_groups = { + "nuke", "nukeassist", "nukex", "hiero", "nukestudio", "mayapy" + } platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): # Change `creationflags` to CREATE_NEW_CONSOLE - # - on Windows nuke will create new window using its console + # - on Windows some apps will create new window using its console # Set `stdout` and `stderr` to None so new created console does not # have redirected output to DEVNULL in build self.launch_context.kwargs.update({ diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index 1307ed9f76..e695cf3fe8 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -13,7 +13,7 @@ class OCIOEnvHook(PreLaunchHook): "fusion", "blender", "aftereffects", - "max", + "3dsmax", "houdini", "maya", "nuke", @@ -45,6 +45,9 @@ class OCIOEnvHook(PreLaunchHook): if config_data: ocio_path = config_data["path"] + if self.host_name in ["nuke", "hiero"]: + ocio_path = ocio_path.replace("\\", "/") + self.log.info( f"Setting OCIO environment to config path: {ocio_path}") diff --git a/openpype/host/host.py b/openpype/host/host.py index 630fb873a8..afe06d1f55 100644 --- a/openpype/host/host.py +++ b/openpype/host/host.py @@ -170,7 +170,7 @@ class HostBase(object): if project_name: items.append(project_name) if asset_name: - items.append(asset_name) + items.append(asset_name.lstrip("/")) if task_name: items.append(task_name) if items: diff --git a/openpype/hosts/aftereffects/api/README.md b/openpype/hosts/aftereffects/api/README.md index 790c9f859a..9c4bad3689 100644 --- a/openpype/hosts/aftereffects/api/README.md +++ b/openpype/hosts/aftereffects/api/README.md @@ -1,6 +1,6 @@ # AfterEffects Integration -Requirements: This extension requires use of Javascript engine, which is +Requirements: This extension requires use of Javascript engine, which is available since CC 16.0. Please check your File>Project Settings>Expressions>Expressions Engine @@ -13,26 +13,28 @@ The After Effects integration requires two components to work; `extension` and ` To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). ``` -ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp +ExManCmd /install {path to addon}/api/extension.zxp ``` OR download [Anastasiy’s Extension Manager](https://install.anastasiy.com/) +`{path to addon}` will be most likely in your AppData (on Windows, in your user data folder in Linux and MacOS.) + ### Server The easiest way to get the server and After Effects launch is with: ``` -python -c ^"import avalon.photoshop;avalon.aftereffects.launch(""c:\Program Files\Adobe\Adobe After Effects 2020\Support Files\AfterFX.exe"")^" +python -c ^"import openpype.hosts.photoshop;openpype.hosts..aftereffects.launch(""c:\Program Files\Adobe\Adobe After Effects 2020\Support Files\AfterFX.exe"")^" ``` `avalon.aftereffects.launch` launches the application and server, and also closes the server when After Effects exists. ## Usage -The After Effects extension can be found under `Window > Extensions > OpenPype`. Once launched you should be presented with a panel like this: +The After Effects extension can be found under `Window > Extensions > AYON`. Once launched you should be presented with a panel like this: -![Avalon Panel](panel.PNG "Avalon Panel") +![Ayon Panel](panel.png "Ayon Panel") ## Developing @@ -43,8 +45,8 @@ When developing the extension you can load it [unsigned](https://github.com/Adob When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). ``` -ZXPSignCmd -selfSignedCert NA NA Avalon Avalon-After-Effects avalon extension.p12 -ZXPSignCmd -sign {path to avalon-core}\avalon\aftereffects\extension {path to avalon-core}\avalon\aftereffects\extension.zxp extension.p12 avalon +ZXPSignCmd -selfSignedCert NA NA Ayon Avalon-After-Effects Ayon extension.p12 +ZXPSignCmd -sign {path to addon}/api/extension {path to addon}/api/extension.zxp extension.p12 Ayon ``` ### Plugin Examples @@ -52,14 +54,14 @@ ZXPSignCmd -sign {path to avalon-core}\avalon\aftereffects\extension {path to av These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). Expected deployed extension location on default Windows: -`c:\Program Files (x86)\Common Files\Adobe\CEP\extensions\com.openpype.AE.panel` +`c:\Program Files (x86)\Common Files\Adobe\CEP\extensions\io.ynput.AE.panel` For easier debugging of Javascript: https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 Add (optional) --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome then localhost:8092 -Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 +Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 ## Resources - https://javascript-tools-guide.readthedocs.io/introduction/index.html - https://github.com/Adobe-CEP/Getting-Started-guides diff --git a/openpype/hosts/aftereffects/api/extension.zxp b/openpype/hosts/aftereffects/api/extension.zxp index 358e9740d3..104a5c9e99 100644 Binary files a/openpype/hosts/aftereffects/api/extension.zxp and b/openpype/hosts/aftereffects/api/extension.zxp differ diff --git a/openpype/hosts/aftereffects/api/extension/.debug b/openpype/hosts/aftereffects/api/extension/.debug index b06ec515dd..20a6713ab2 100644 --- a/openpype/hosts/aftereffects/api/extension/.debug +++ b/openpype/hosts/aftereffects/api/extension/.debug @@ -1,32 +1,31 @@ - + - + - + - + - + - + - + - + - + - + - \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml index 0057758320..cf6ba67f44 100644 --- a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml +++ b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml @@ -1,8 +1,8 @@ - + - + @@ -10,22 +10,22 @@ - + - + - - + + - + - - + + - + @@ -38,7 +38,7 @@ - + ./index.html @@ -49,7 +49,7 @@ Panel - OpenPype + AYON 200 @@ -63,17 +63,17 @@ 550 400 --> - + - ./icons/iconNormal.png + ./icons/ayon_logo.png ./icons/iconRollover.png ./icons/iconDisabled.png ./icons/iconDarkNormal.png ./icons/iconDarkRollover.png - + - \ No newline at end of file + diff --git a/openpype/hosts/aftereffects/api/extension/icons/ayon_logo.png b/openpype/hosts/aftereffects/api/extension/icons/ayon_logo.png new file mode 100644 index 0000000000..3a96f8e2b4 Binary files /dev/null and b/openpype/hosts/aftereffects/api/extension/icons/ayon_logo.png differ diff --git a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx index bc443930df..c00844e637 100644 --- a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx +++ b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx @@ -215,6 +215,8 @@ function _getItem(item, comps, folders, footages){ * Refactor */ var item_type = ''; + var path = ''; + var containing_comps = []; if (item instanceof FolderItem){ item_type = 'folder'; if (!folders){ @@ -222,10 +224,18 @@ function _getItem(item, comps, folders, footages){ } } if (item instanceof FootageItem){ - item_type = 'footage'; if (!footages){ return "{}"; } + item_type = 'footage'; + if (item.file){ + path = item.file.fsName; + } + if (item.usedIn){ + for (j = 0; j < item.usedIn.length; ++j){ + containing_comps.push(item.usedIn[j].id); + } + } } if (item instanceof CompItem){ item_type = 'comp'; @@ -236,7 +246,9 @@ function _getItem(item, comps, folders, footages){ var item = {"name": item.name, "id": item.id, - "type": item_type}; + "type": item_type, + "path": path, + "containing_comps": containing_comps}; return JSON.stringify(item); } diff --git a/openpype/hosts/aftereffects/api/panel.png b/openpype/hosts/aftereffects/api/panel.png new file mode 100644 index 0000000000..d05ed35428 Binary files /dev/null and b/openpype/hosts/aftereffects/api/panel.png differ diff --git a/openpype/hosts/aftereffects/api/panel_failure.PNG b/openpype/hosts/aftereffects/api/panel_failure.PNG deleted file mode 100644 index 67afc4e212..0000000000 Binary files a/openpype/hosts/aftereffects/api/panel_failure.PNG and /dev/null differ diff --git a/openpype/hosts/aftereffects/api/panel_failure.png b/openpype/hosts/aftereffects/api/panel_failure.png new file mode 100644 index 0000000000..6e52a77d22 Binary files /dev/null and b/openpype/hosts/aftereffects/api/panel_failure.png differ diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 8fc7a70dd8..e059f7c272 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -74,11 +74,6 @@ class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) register_event_callback("application.launched", application_launch) @@ -186,11 +181,6 @@ def application_launch(): check_inventory() -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - def ls(): """Yields containers from active AfterEffects document. diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index f5b96fa63a..18f530e272 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -37,6 +37,9 @@ class AEItem(object): height = attr.ib(default=None) is_placeholder = attr.ib(default=False) uuid = attr.ib(default=False) + path = attr.ib(default=False) # path to FootageItem to validate + # list of composition Footage is in + containing_comps = attr.ib(factory=list) class AfterEffectsServerStub(): @@ -704,7 +707,10 @@ class AfterEffectsServerStub(): d.get("instance_id"), d.get("width"), d.get("height"), - d.get("is_placeholder")) + d.get("is_placeholder"), + d.get("uuid"), + d.get("path"), + d.get("containing_comps"),) ret.append(item) return ret diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index dcf424b44f..fbe600ae68 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -164,7 +164,7 @@ class RenderCreator(Creator): api.get_stub().rename_item(comp_id, new_comp_name) - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["aftereffects"]["create"]["RenderCreator"] ) diff --git a/openpype/hosts/aftereffects/plugins/create/workfile_creator.py b/openpype/hosts/aftereffects/plugins/create/workfile_creator.py index 2e7b9d4a7e..5dc3d6592d 100644 --- a/openpype/hosts/aftereffects/plugins/create/workfile_creator.py +++ b/openpype/hosts/aftereffects/plugins/create/workfile_creator.py @@ -1,3 +1,4 @@ +from openpype import AYON_SERVER_ENABLED import openpype.hosts.aftereffects.api as api from openpype.client import get_asset_by_name from openpype.pipeline import ( @@ -43,6 +44,14 @@ class AEWorkfileCreator(AutoCreator): task_name = context.get_current_task_name() host_name = context.host_name + existing_asset_name = None + if existing_instance is not None: + if AYON_SERVER_ENABLED: + existing_asset_name = existing_instance.get("folderPath") + + if existing_asset_name is None: + existing_asset_name = existing_instance["asset"] + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -50,10 +59,13 @@ class AEWorkfileCreator(AutoCreator): project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None @@ -68,7 +80,7 @@ class AEWorkfileCreator(AutoCreator): new_instance.data_to_store()) elif ( - existing_instance["asset"] != asset_name + existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -76,6 +88,10 @@ class AEWorkfileCreator(AutoCreator): self.default_variant, task_name, asset_doc, project_name, host_name ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/aftereffects/plugins/load/load_file.py b/openpype/hosts/aftereffects/plugins/load/load_file.py index def7c927ab..8d52aac546 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_file.py +++ b/openpype/hosts/aftereffects/plugins/load/load_file.py @@ -31,13 +31,8 @@ class FileLoader(api.AfterEffectsLoader): path = self.filepath_from_context(context) - repr_cont = context["representation"]["context"] - if "#" not in path: - frame = repr_cont.get("frame") - if frame: - padding = len(frame) - path = path.replace(frame, "#" * padding) - import_options['sequence'] = True + if len(context["representation"]["files"]) > 1: + import_options['sequence'] = True if not path: repr_id = context["representation"]["_id"] diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index aa46461915..49874d6cff 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -138,7 +138,6 @@ class CollectAERender(publish.AbstractCollectRender): fam = "render.farm" if fam not in instance.families: instance.families.append(fam) - instance.toBeRenderedOn = "deadline" instance.renderer = "aerender" instance.farm = True # to skip integrate if "review" in instance.families: diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index dc557f67fc..58d2757840 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -1,6 +1,8 @@ import os import pyblish.api + +from openpype.client import get_asset_name_identifier from openpype.pipeline.create import get_subset_name @@ -48,9 +50,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin): asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] + asset_name = get_asset_name_identifier(asset_entity) + instance_data = { "active": True, - "asset": asset_entity["name"], + "asset": asset_name, "task": task, "frameStart": context.data['frameStart'], "frameEnd": context.data['frameEnd'], diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml new file mode 100644 index 0000000000..01c8966015 --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml @@ -0,0 +1,14 @@ + + + +Footage item missing + +## Footage item missing + + FootageItem `{name}` contains missing `{path}`. Render will not produce any frames and AE will stop react to any integration +### How to repair? + +Remove `{name}` or provide missing file. + + + diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py b/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py new file mode 100644 index 0000000000..40a08a2c3f --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""Validate presence of footage items in composition +Requires: +""" +import os + +import pyblish.api + +from openpype.pipeline import ( + PublishXmlValidationError +) +from openpype.hosts.aftereffects.api import get_stub + + +class ValidateFootageItems(pyblish.api.InstancePlugin): + """ + Validates if FootageItems contained in composition exist. + + AE fails silently and doesn't render anything if footage item file is + missing. This will result in nonresponsiveness of AE UI as it expects + reaction from user, but it will not provide dialog. + This validator tries to check existence of the files. + It will not protect from missing frame in multiframes though + (as AE api doesn't provide this information and it cannot be told how many + frames should be there easily). Missing frame is replaced by placeholder. + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Footage Items" + families = ["render.farm", "render.local", "render"] + hosts = ["aftereffects"] + optional = True + + def process(self, instance): + """Plugin entry point.""" + + comp_id = instance.data["comp_id"] + for footage_item in get_stub().get_items(comps=False, folders=False, + footages=True): + self.log.info(footage_item) + if comp_id not in footage_item.containing_comps: + continue + + path = footage_item.path + if path and not os.path.exists(path): + msg = f"File {path} not found." + formatting = {"name": footage_item.name, "path": path} + raise PublishXmlValidationError(self, msg, + formatting_data=formatting) diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index 75a11affde..ce2b444997 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -10,6 +10,7 @@ from .pipeline import ( ls, publish, containerise, + BlenderHost, ) from .plugin import ( @@ -38,6 +39,8 @@ from .lib import ( from .capture import capture +from .render_lib import prepare_rendering + __all__ = [ "install", @@ -45,6 +48,7 @@ __all__ = [ "ls", "publish", "containerise", + "BlenderHost", "Creator", "Loader", @@ -66,4 +70,5 @@ __all__ = [ "get_selection", "capture", # "unique_name", + "prepare_rendering", ] diff --git a/openpype/hosts/blender/api/capture.py b/openpype/hosts/blender/api/capture.py index 849f8ee629..bad6831143 100644 --- a/openpype/hosts/blender/api/capture.py +++ b/openpype/hosts/blender/api/capture.py @@ -148,13 +148,14 @@ def applied_view(window, camera, isolate=None, options=None): area.ui_type = "VIEW_3D" - meshes = [obj for obj in window.scene.objects if obj.type == "MESH"] + types = {"MESH", "GPENCIL"} + objects = [obj for obj in window.scene.objects if obj.type in types] if camera == "AUTO": space.region_3d.view_perspective = "ORTHO" - isolate_objects(window, isolate or meshes) + isolate_objects(window, isolate or objects) else: - isolate_objects(window, isolate or meshes) + isolate_objects(window, isolate or objects) space.camera = window.scene.objects.get(camera) space.region_3d.view_perspective = "CAMERA" diff --git a/openpype/hosts/blender/api/colorspace.py b/openpype/hosts/blender/api/colorspace.py new file mode 100644 index 0000000000..4521612b7d --- /dev/null +++ b/openpype/hosts/blender/api/colorspace.py @@ -0,0 +1,51 @@ +import attr + +import bpy + + +@attr.s +class LayerMetadata(object): + """Data class for Render Layer metadata.""" + frameStart = attr.ib() + frameEnd = attr.ib() + + +@attr.s +class RenderProduct(object): + """ + Getting Colorspace as Specific Render Product Parameter for submitting + publish job. + """ + colorspace = attr.ib() # colorspace + view = attr.ib() # OCIO view transform + productName = attr.ib(default=None) + + +class ARenderProduct(object): + def __init__(self): + """Constructor.""" + # Initialize + self.layer_data = self._get_layer_data() + self.layer_data.products = self.get_render_products() + + def _get_layer_data(self): + scene = bpy.context.scene + + return LayerMetadata( + frameStart=int(scene.frame_start), + frameEnd=int(scene.frame_end), + ) + + def get_render_products(self): + """To be implemented by renderer class. + This should return a list of RenderProducts. + Returns: + list: List of RenderProduct + """ + return [ + RenderProduct( + colorspace="sRGB", + view="ACES 1.0", + productName="" + ) + ] diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index 9bb560c364..e80ed61bc8 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -188,7 +188,7 @@ def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): # Support values evaluated at imprint value = value() - if not isinstance(value, (int, float, bool, str, list)): + if not isinstance(value, (int, float, bool, str, list, dict)): raise TypeError(f"Unsupported type: {type(value)}") imprint_data[key] = value @@ -266,9 +266,59 @@ def read(node: bpy.types.bpy_struct_meta_idprop): return data -def get_selection() -> List[bpy.types.Object]: - """Return the selected objects from the current scene.""" - return [obj for obj in bpy.context.scene.objects if obj.select_get()] +def get_selected_collections(): + """ + Returns a list of the currently selected collections in the outliner. + + Raises: + RuntimeError: If the outliner cannot be found in the main Blender + window. + + Returns: + list: A list of `bpy.types.Collection` objects that are currently + selected in the outliner. + """ + window = bpy.context.window or bpy.context.window_manager.windows[0] + + try: + area = next( + area for area in window.screen.areas + if area.type == 'OUTLINER') + region = next( + region for region in area.regions + if region.type == 'WINDOW') + except StopIteration as e: + raise RuntimeError("Could not find outliner. An outliner space " + "must be in the main Blender window.") from e + + with bpy.context.temp_override( + window=window, + area=area, + region=region, + screen=window.screen + ): + ids = bpy.context.selected_ids + + return [id for id in ids if isinstance(id, bpy.types.Collection)] + + +def get_selection(include_collections: bool = False) -> List[bpy.types.Object]: + """ + Returns a list of selected objects in the current Blender scene. + + Args: + include_collections (bool, optional): Whether to include selected + collections in the result. Defaults to False. + + Returns: + List[bpy.types.Object]: A list of selected objects. + """ + selection = [obj for obj in bpy.context.scene.objects if obj.select_get()] + + if include_collections: + selection.extend(get_selected_collections()) + + return selection @contextlib.contextmanager diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py index 62d7987b47..f4d96e563a 100644 --- a/openpype/hosts/blender/api/ops.py +++ b/openpype/hosts/blender/api/ops.py @@ -16,6 +16,7 @@ import bpy import bpy.utils.previews from openpype import style +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import get_current_asset_name, get_current_task_name from openpype.tools.utils import host_tools @@ -30,6 +31,14 @@ PREVIEW_COLLECTIONS: Dict = dict() TIMER_INTERVAL: float = 0.01 if platform.system() == "Windows" else 0.1 +def execute_function_in_main_thread(f): + """Decorator to move a function call into main thread items""" + def wrapper(*args, **kwargs): + mti = MainThreadItem(f, *args, **kwargs) + execute_in_main_thread(mti) + return wrapper + + class BlenderApplication(QtWidgets.QApplication): _instance = None blender_windows = {} @@ -237,8 +246,24 @@ class LaunchQtApp(bpy.types.Operator): self.before_window_show() + def pull_to_front(window): + """Pull window forward to screen. + + If Window is minimized this will un-minimize, then it can be raised + and activated to the front. + """ + window.setWindowState( + (window.windowState() & ~QtCore.Qt.WindowMinimized) | + QtCore.Qt.WindowActive + ) + window.raise_() + window.activateWindow() + if isinstance(self._window, ModuleType): self._window.show() + pull_to_front(self._window) + + # Pull window to the front window = None if hasattr(self._window, "window"): window = self._window.window @@ -253,6 +278,7 @@ class LaunchQtApp(bpy.types.Operator): on_top_flags = origin_flags | QtCore.Qt.WindowStaysOnTopHint self._window.setWindowFlags(on_top_flags) self._window.show() + pull_to_front(self._window) # if on_top_flags != origin_flags: # self._window.setWindowFlags(origin_flags) @@ -274,6 +300,10 @@ class LaunchCreator(LaunchQtApp): def before_window_show(self): self._window.refresh() + def execute(self, context): + host_tools.show_publisher(tab="create") + return {"FINISHED"} + class LaunchLoader(LaunchQtApp): """Launch Avalon Loader.""" @@ -283,6 +313,8 @@ class LaunchLoader(LaunchQtApp): _tool_name = "loader" def before_window_show(self): + if AYON_SERVER_ENABLED: + return self._window.set_context( {"asset": get_current_asset_name()}, refresh=True @@ -296,7 +328,7 @@ class LaunchPublisher(LaunchQtApp): bl_label = "Publish..." def execute(self, context): - host_tools.show_publish() + host_tools.show_publisher(tab="publish") return {"FINISHED"} @@ -308,6 +340,8 @@ class LaunchManager(LaunchQtApp): _tool_name = "sceneinventory" def before_window_show(self): + if AYON_SERVER_ENABLED: + return self._window.refresh() @@ -319,6 +353,8 @@ class LaunchLibrary(LaunchQtApp): _tool_name = "libraryloader" def before_window_show(self): + if AYON_SERVER_ENABLED: + return self._window.refresh() @@ -331,13 +367,16 @@ class LaunchWorkFiles(LaunchQtApp): def execute(self, context): result = super().execute(context) - self._window.set_context({ - "asset": get_current_asset_name(), - "task": get_current_task_name() - }) + if not AYON_SERVER_ENABLED: + self._window.set_context({ + "asset": get_current_asset_name(), + "task": get_current_task_name() + }) return result def before_window_show(self): + if AYON_SERVER_ENABLED: + return self._window.root = str(Path( os.environ.get("AVALON_WORKDIR", ""), os.environ.get("AVALON_SCENEDIR", ""), @@ -406,7 +445,6 @@ class TOPBAR_MT_avalon(bpy.types.Menu): layout.operator(SetResolution.bl_idname, text="Set Resolution") layout.separator() layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...") - # TODO (jasper): maybe add 'Reload Pipeline' def draw_avalon_menu(self, context): diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 29339a512c..b386dd49d3 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -10,6 +10,12 @@ from . import ops import pyblish.api +from openpype.host import ( + HostBase, + IWorkfileHost, + IPublishHost, + ILoadHost +) from openpype.client import get_asset_by_name from openpype.pipeline import ( schema, @@ -29,6 +35,14 @@ from openpype.lib import ( ) import openpype.hosts.blender from openpype.settings import get_project_settings +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root, +) HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) @@ -47,6 +61,101 @@ IS_HEADLESS = bpy.app.background log = Logger.get_logger(__name__) +class BlenderHost(HostBase, IWorkfileHost, IPublishHost, ILoadHost): + name = "blender" + + def install(self): + """Override install method from HostBase. + Install Blender host functionality.""" + install() + + def get_containers(self) -> Iterator: + """List containers from active Blender scene.""" + return ls() + + def get_workfile_extensions(self) -> List[str]: + """Override get_workfile_extensions method from IWorkfileHost. + Get workfile possible extensions. + + Returns: + List[str]: Workfile extensions. + """ + return file_extensions() + + def save_workfile(self, dst_path: str = None): + """Override save_workfile method from IWorkfileHost. + Save currently opened workfile. + + Args: + dst_path (str): Where the current scene should be saved. Or use + current path if `None` is passed. + """ + save_file(dst_path if dst_path else bpy.data.filepath) + + def open_workfile(self, filepath: str): + """Override open_workfile method from IWorkfileHost. + Open workfile at specified filepath in the host. + + Args: + filepath (str): Path to workfile. + """ + open_file(filepath) + + def get_current_workfile(self) -> str: + """Override get_current_workfile method from IWorkfileHost. + Retrieve currently opened workfile path. + + Returns: + str: Path to currently opened workfile. + """ + return current_file() + + def workfile_has_unsaved_changes(self) -> bool: + """Override wokfile_has_unsaved_changes method from IWorkfileHost. + Returns True if opened workfile has no unsaved changes. + + Returns: + bool: True if scene is saved and False if it has unsaved + modifications. + """ + return has_unsaved_changes() + + def work_root(self, session) -> str: + """Override work_root method from IWorkfileHost. + Modify workdir per host. + + Args: + session (dict): Session context data. + + Returns: + str: Path to new workdir. + """ + return work_root(session) + + def get_context_data(self) -> dict: + """Override abstract method from IPublishHost. + Get global data related to creation-publishing from workfile. + + Returns: + dict: Context data stored using 'update_context_data'. + """ + property = bpy.context.scene.get(AVALON_PROPERTY) + if property: + return property.to_dict() + return {} + + def update_context_data(self, data: dict, changes: dict): + """Override abstract method from IPublishHost. + Store global context data to workfile. + + Args: + data (dict): New data as are. + changes (dict): Only data that has been changed. Each value has + tuple with '(, )' value. + """ + bpy.context.scene[AVALON_PROPERTY] = data + + def pype_excepthook_handler(*args): traceback.print_exception(*args) @@ -460,36 +569,6 @@ def ls() -> Iterator: yield parse_container(container) -def update_hierarchy(containers): - """Hierarchical container support - - This is the function to support Scene Inventory to draw hierarchical - view for containers. - - We need both parent and children to visualize the graph. - - """ - - all_containers = set(ls()) # lookup set - - for container in containers: - # Find parent - # FIXME (jasperge): re-evaluate this. How would it be possible - # to 'nest' assets? Collections can have several parents, for - # now assume it has only 1 parent - parent = [ - coll for coll in bpy.data.collections if container in coll.children - ] - for node in parent: - if node in all_containers: - container["parent"] = node - break - - log.debug("Container: %s", container) - - yield container - - def publish(): """Shorthand to publish from within host.""" diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index fb87d08cce..568d8f6695 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -1,28 +1,34 @@ """Shared functionality for pipeline plugins for Blender.""" +import itertools from pathlib import Path from typing import Dict, List, Optional import bpy +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import ( - LegacyCreator, + Creator, + CreatedInstance, LoaderPlugin, ) -from .pipeline import AVALON_CONTAINERS +from openpype.lib import BoolDef + +from .pipeline import ( + AVALON_CONTAINERS, + AVALON_INSTANCES, + AVALON_PROPERTY, +) from .ops import ( MainThreadItem, execute_in_main_thread ) -from .lib import ( - imprint, - get_selection -) +from .lib import imprint VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"] -def asset_name( +def prepare_scene_name( asset: str, subset: str, namespace: Optional[str] = None ) -> str: """Return a consistent name for an asset.""" @@ -40,9 +46,16 @@ def get_unique_number( avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: return "01" - asset_groups = avalon_container.all_objects - - container_names = [c.name for c in asset_groups if c.type == 'EMPTY'] + # Check the names of both object and collection containers + obj_asset_groups = avalon_container.objects + obj_group_names = { + c.name for c in obj_asset_groups + if c.type == 'EMPTY' and c.get(AVALON_PROPERTY)} + coll_asset_groups = avalon_container.children + coll_group_names = { + c.name for c in coll_asset_groups + if c.get(AVALON_PROPERTY)} + container_names = obj_group_names.union(coll_group_names) count = 1 name = f"{asset}_{count:0>2}_{subset}" while name in container_names: @@ -134,20 +147,224 @@ def deselect_all(): bpy.context.view_layer.objects.active = active -class Creator(LegacyCreator): - """Base class for Creator plug-ins.""" +class BaseCreator(Creator): + """Base class for Blender Creator plug-ins.""" defaults = ['Main'] - def process(self): - collection = bpy.data.collections.new(name=self.data["subset"]) - bpy.context.scene.collection.children.link(collection) - imprint(collection, self.data) + create_as_asset_group = False - if (self.options or {}).get("useSelection"): - for obj in get_selection(): - collection.objects.link(obj) + @staticmethod + def cache_subsets(shared_data): + """Cache instances for Creators shared data. - return collection + Create `blender_cached_subsets` key when needed in shared data and + fill it with all collected instances from the scene under its + respective creator identifiers. + + If legacy instances are detected in the scene, create + `blender_cached_legacy_subsets` key and fill it with + all legacy subsets from this family as a value. # key or value? + + Args: + shared_data(Dict[str, Any]): Shared data. + + Return: + Dict[str, Any]: Shared data with cached subsets. + """ + if not shared_data.get('blender_cached_subsets'): + cache = {} + cache_legacy = {} + + avalon_instances = bpy.data.collections.get(AVALON_INSTANCES) + avalon_instance_objs = ( + avalon_instances.objects if avalon_instances else [] + ) + + for obj_or_col in itertools.chain( + avalon_instance_objs, + bpy.data.collections + ): + avalon_prop = obj_or_col.get(AVALON_PROPERTY, {}) + if not avalon_prop: + continue + + if avalon_prop.get('id') != 'pyblish.avalon.instance': + continue + + creator_id = avalon_prop.get('creator_identifier') + if creator_id: + # Creator instance + cache.setdefault(creator_id, []).append(obj_or_col) + else: + family = avalon_prop.get('family') + if family: + # Legacy creator instance + cache_legacy.setdefault(family, []).append(obj_or_col) + + shared_data["blender_cached_subsets"] = cache + shared_data["blender_cached_legacy_subsets"] = cache_legacy + + return shared_data + + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + """Override abstract method from Creator. + Create new instance and store it. + + Args: + subset_name(str): Subset name of created instance. + instance_data(dict): Instance base data. + pre_create_data(dict): Data based on pre creation attributes. + Those may affect how creator works. + """ + # Get Instance Container or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create asset group + if AYON_SERVER_ENABLED: + asset_name = instance_data["folderPath"] + else: + asset_name = instance_data["asset"] + + name = prepare_scene_name(asset_name, subset_name) + if self.create_as_asset_group: + # Create instance as empty + instance_node = bpy.data.objects.new(name=name, object_data=None) + instance_node.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(instance_node) + else: + # Create instance collection + instance_node = bpy.data.collections.new(name=name) + instances.children.link(instance_node) + + self.set_instance_data(subset_name, instance_data) + + instance = CreatedInstance( + self.family, subset_name, instance_data, self + ) + instance.transient_data["instance_node"] = instance_node + self._add_instance_to_context(instance) + + imprint(instance_node, instance_data) + + return instance_node + + def collect_instances(self): + """Override abstract method from BaseCreator. + Collect existing instances related to this creator plugin.""" + + # Cache subsets in shared data + self.cache_subsets(self.collection_shared_data) + + # Get cached subsets + cached_subsets = self.collection_shared_data.get( + "blender_cached_subsets" + ) + if not cached_subsets: + return + + # Process only instances that were created by this creator + for instance_node in cached_subsets.get(self.identifier, []): + property = instance_node.get(AVALON_PROPERTY) + # Create instance object from existing data + instance = CreatedInstance.from_existing( + instance_data=property.to_dict(), + creator=self + ) + instance.transient_data["instance_node"] = instance_node + + # Add instance to create context + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + """Override abstract method from BaseCreator. + Store changes of existing instances so they can be recollected. + + Args: + update_list(List[UpdateData]): Changed instances + and their changes, as a list of tuples. + """ + + if AYON_SERVER_ENABLED: + asset_name_key = "folderPath" + else: + asset_name_key = "asset" + + for created_instance, changes in update_list: + data = created_instance.data_to_store() + node = created_instance.transient_data["instance_node"] + if not node: + # We can't update if we don't know the node + self.log.error( + f"Unable to update instance {created_instance} " + f"without instance node." + ) + return + + # Rename the instance node in the scene if subset or asset changed + if ( + "subset" in changes.changed_keys + or asset_name_key in changes.changed_keys + ): + asset_name = data[asset_name_key] + name = prepare_scene_name( + asset=asset_name, subset=data["subset"] + ) + node.name = name + + imprint(node, data) + + def remove_instances(self, instances: List[CreatedInstance]): + + for instance in instances: + node = instance.transient_data["instance_node"] + + if isinstance(node, bpy.types.Collection): + for children in node.children_recursive: + if isinstance(children, bpy.types.Collection): + bpy.data.collections.remove(children) + else: + bpy.data.objects.remove(children) + + bpy.data.collections.remove(node) + elif isinstance(node, bpy.types.Object): + bpy.data.objects.remove(node) + + self._remove_instance_from_context(instance) + + def set_instance_data( + self, + subset_name: str, + instance_data: dict + ): + """Fill instance data with required items. + + Args: + subset_name(str): Subset name of created instance. + instance_data(dict): Instance base data. + instance_node(bpy.types.ID): Instance node in blender scene. + """ + if not instance_data: + instance_data = {} + + instance_data.update( + { + "id": "pyblish.avalon.instance", + "creator_identifier": self.identifier, + "subset": subset_name, + } + ) + + def get_pre_create_attr_defs(self): + return [ + BoolDef("use_selection", + label="Use selection", + default=True) + ] class Loader(LoaderPlugin): @@ -241,7 +458,7 @@ class AssetLoader(LoaderPlugin): namespace: Use pre-defined namespace options: Additional settings dictionary """ - # TODO (jasper): make it possible to add the asset several times by + # TODO: make it possible to add the asset several times by # just re-using the collection filepath = self.filepath_from_context(context) assert Path(filepath).exists(), f"{filepath} doesn't exist." @@ -252,7 +469,7 @@ class AssetLoader(LoaderPlugin): asset, subset ) namespace = namespace or f"{asset}_{unique_number}" - name = name or asset_name( + name = name or prepare_scene_name( asset, subset, unique_number ) @@ -281,7 +498,9 @@ class AssetLoader(LoaderPlugin): # asset = context["asset"]["name"] # subset = context["subset"]["name"] - # instance_name = asset_name(asset, subset, unique_number) + '_CON' + # instance_name = prepare_scene_name( + # asset, subset, unique_number + # ) + '_CON' # return self._get_instance_collection(instance_name, nodes) diff --git a/openpype/hosts/blender/api/render_lib.py b/openpype/hosts/blender/api/render_lib.py new file mode 100644 index 0000000000..b437078ad8 --- /dev/null +++ b/openpype/hosts/blender/api/render_lib.py @@ -0,0 +1,255 @@ +from pathlib import Path + +import bpy + +from openpype.settings import get_project_settings +from openpype.pipeline import get_current_project_name + + +def get_default_render_folder(settings): + """Get default render folder from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["default_render_image_folder"]) + + +def get_aov_separator(settings): + """Get aov separator from blender settings.""" + + aov_sep = (settings["blender"] + ["RenderSettings"] + ["aov_separator"]) + + if aov_sep == "dash": + return "-" + elif aov_sep == "underscore": + return "_" + elif aov_sep == "dot": + return "." + else: + raise ValueError(f"Invalid aov separator: {aov_sep}") + + +def get_image_format(settings): + """Get image format from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["image_format"]) + + +def get_multilayer(settings): + """Get multilayer from blender settings.""" + + return (settings["blender"] + ["RenderSettings"] + ["multilayer_exr"]) + + +def get_render_product(output_path, name, aov_sep): + """ + Generate the path to the render product. Blender interprets the `#` + as the frame number, when it renders. + + Args: + file_path (str): The path to the blender scene. + render_folder (str): The render folder set in settings. + file_name (str): The name of the blender scene. + instance (pyblish.api.Instance): The instance to publish. + ext (str): The image format to render. + """ + filepath = output_path / name.lstrip("/") + render_product = f"{filepath}{aov_sep}beauty.####" + render_product = render_product.replace("\\", "/") + + return render_product + + +def set_render_format(ext, multilayer): + # Set Blender to save the file with the right extension + bpy.context.scene.render.use_file_extension = True + + image_settings = bpy.context.scene.render.image_settings + + if ext == "exr": + image_settings.file_format = ( + "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") + elif ext == "bmp": + image_settings.file_format = "BMP" + elif ext == "rgb": + image_settings.file_format = "IRIS" + elif ext == "png": + image_settings.file_format = "PNG" + elif ext == "jpeg": + image_settings.file_format = "JPEG" + elif ext == "jp2": + image_settings.file_format = "JPEG2000" + elif ext == "tga": + image_settings.file_format = "TARGA" + elif ext == "tif": + image_settings.file_format = "TIFF" + + +def set_render_passes(settings): + aov_list = (settings["blender"] + ["RenderSettings"] + ["aov_list"]) + + custom_passes = (settings["blender"] + ["RenderSettings"] + ["custom_passes"]) + + vl = bpy.context.view_layer + + vl.use_pass_combined = "combined" in aov_list + vl.use_pass_z = "z" in aov_list + vl.use_pass_mist = "mist" in aov_list + vl.use_pass_normal = "normal" in aov_list + vl.use_pass_diffuse_direct = "diffuse_light" in aov_list + vl.use_pass_diffuse_color = "diffuse_color" in aov_list + vl.use_pass_glossy_direct = "specular_light" in aov_list + vl.use_pass_glossy_color = "specular_color" in aov_list + vl.eevee.use_pass_volume_direct = "volume_light" in aov_list + vl.use_pass_emit = "emission" in aov_list + vl.use_pass_environment = "environment" in aov_list + vl.use_pass_shadow = "shadow" in aov_list + vl.use_pass_ambient_occlusion = "ao" in aov_list + + cycles = vl.cycles + + cycles.denoising_store_passes = "denoising" in aov_list + cycles.use_pass_volume_direct = "volume_direct" in aov_list + cycles.use_pass_volume_indirect = "volume_indirect" in aov_list + + aovs_names = [aov.name for aov in vl.aovs] + for cp in custom_passes: + cp_name = cp[0] + if cp_name not in aovs_names: + aov = vl.aovs.add() + aov.name = cp_name + else: + aov = vl.aovs[cp_name] + aov.type = cp[1].get("type", "VALUE") + + return aov_list, custom_passes + + +def set_node_tree(output_path, name, aov_sep, ext, multilayer): + # Set the scene to use the compositor node tree to render + bpy.context.scene.use_nodes = True + + tree = bpy.context.scene.node_tree + + # Get the Render Layers node + rl_node = None + for node in tree.nodes: + if node.bl_idname == "CompositorNodeRLayers": + rl_node = node + break + + # If there's not a Render Layers node, we create it + if not rl_node: + rl_node = tree.nodes.new("CompositorNodeRLayers") + + # Get the enabled output sockets, that are the active passes for the + # render. + # We also exclude some layers. + exclude_sockets = ["Image", "Alpha", "Noisy Image"] + passes = [ + socket + for socket in rl_node.outputs + if socket.enabled and socket.name not in exclude_sockets + ] + + # Remove all output nodes + for node in tree.nodes: + if node.bl_idname == "CompositorNodeOutputFile": + tree.nodes.remove(node) + + # Create a new output node + output = tree.nodes.new("CompositorNodeOutputFile") + + image_settings = bpy.context.scene.render.image_settings + output.format.file_format = image_settings.file_format + + # In case of a multilayer exr, we don't need to use the output node, + # because the blender render already outputs a multilayer exr. + if ext == "exr" and multilayer: + output.layer_slots.clear() + return [] + + output.file_slots.clear() + output.base_path = str(output_path) + + aov_file_products = [] + + # For each active render pass, we add a new socket to the output node + # and link it + for render_pass in passes: + filepath = f"{name}{aov_sep}{render_pass.name}.####" + + output.file_slots.new(filepath) + + filename = str(output_path / filepath.lstrip("/")) + + aov_file_products.append((render_pass.name, filename)) + + node_input = output.inputs[-1] + + tree.links.new(render_pass, node_input) + + return aov_file_products + + +def imprint_render_settings(node, data): + RENDER_DATA = "render_data" + if not node.get(RENDER_DATA): + node[RENDER_DATA] = {} + for key, value in data.items(): + if value is None: + continue + node[RENDER_DATA][key] = value + + +def prepare_rendering(asset_group): + name = asset_group.name + + filepath = Path(bpy.data.filepath) + assert filepath, "Workfile not saved. Please save the file first." + + dirpath = filepath.parent + file_name = Path(filepath.name).stem + + project = get_current_project_name() + settings = get_project_settings(project) + + render_folder = get_default_render_folder(settings) + aov_sep = get_aov_separator(settings) + ext = get_image_format(settings) + multilayer = get_multilayer(settings) + + set_render_format(ext, multilayer) + aov_list, custom_passes = set_render_passes(settings) + + output_path = Path.joinpath(dirpath, render_folder, file_name) + + render_product = get_render_product(output_path, name, aov_sep) + aov_file_product = set_node_tree( + output_path, name, aov_sep, ext, multilayer) + + bpy.context.scene.render.filepath = render_product + + render_settings = { + "render_folder": render_folder, + "aov_separator": aov_sep, + "image_format": ext, + "multilayer_exr": multilayer, + "aov_list": aov_list, + "custom_passes": custom_passes, + "render_product": render_product, + "aov_file_product": aov_file_product, + "review": True, + } + + imprint_render_settings(asset_group, render_settings) diff --git a/openpype/hosts/blender/blender_addon/startup/init.py b/openpype/hosts/blender/blender_addon/startup/init.py index 8dbff8a91d..603691675d 100644 --- a/openpype/hosts/blender/blender_addon/startup/init.py +++ b/openpype/hosts/blender/blender_addon/startup/init.py @@ -1,9 +1,9 @@ from openpype.pipeline import install_host -from openpype.hosts.blender import api +from openpype.hosts.blender.api import BlenderHost def register(): - install_host(api) + install_host(BlenderHost()) def unregister(): diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py index 777e383215..2aa3a5e49a 100644 --- a/openpype/hosts/blender/hooks/pre_pyside_install.py +++ b/openpype/hosts/blender/hooks/pre_pyside_install.py @@ -31,7 +31,7 @@ class InstallPySideToBlender(PreLaunchHook): def inner_execute(self): # Get blender's python directory - version_regex = re.compile(r"^[2-3]\.[0-9]+$") + version_regex = re.compile(r"^[2-4]\.[0-9]+$") platform = system().lower() executable = self.launch_context.executable.executable_path diff --git a/openpype/hosts/blender/plugins/create/convert_legacy.py b/openpype/hosts/blender/plugins/create/convert_legacy.py new file mode 100644 index 0000000000..f05a6b1f5a --- /dev/null +++ b/openpype/hosts/blender/plugins/create/convert_legacy.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""Converter for legacy Houdini subsets.""" +from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin +from openpype.hosts.blender.api.lib import imprint + + +class BlenderLegacyConvertor(SubsetConvertorPlugin): + """Find and convert any legacy subsets in the scene. + + This Converter will find all legacy subsets in the scene and will + transform them to the current system. Since the old subsets doesn't + retain any information about their original creators, the only mapping + we can do is based on their families. + + Its limitation is that you can have multiple creators creating subset + of the same family and there is no way to handle it. This code should + nevertheless cover all creators that came with OpenPype. + + """ + identifier = "io.openpype.creators.blender.legacy" + family_to_id = { + "action": "io.openpype.creators.blender.action", + "camera": "io.openpype.creators.blender.camera", + "animation": "io.openpype.creators.blender.animation", + "blendScene": "io.openpype.creators.blender.blendscene", + "layout": "io.openpype.creators.blender.layout", + "model": "io.openpype.creators.blender.model", + "pointcache": "io.openpype.creators.blender.pointcache", + "render": "io.openpype.creators.blender.render", + "review": "io.openpype.creators.blender.review", + "rig": "io.openpype.creators.blender.rig", + } + + def __init__(self, *args, **kwargs): + super(BlenderLegacyConvertor, self).__init__(*args, **kwargs) + self.legacy_subsets = {} + + def find_instances(self): + """Find legacy subsets in the scene. + + Legacy subsets are the ones that doesn't have `creator_identifier` + parameter on them. + + This is using cached entries done in + :py:meth:`~BaseCreator.cache_subsets()` + + """ + self.legacy_subsets = self.collection_shared_data.get( + "blender_cached_legacy_subsets") + if not self.legacy_subsets: + return + self.add_convertor_item( + "Found {} incompatible subset{}".format( + len(self.legacy_subsets), + "s" if len(self.legacy_subsets) > 1 else "" + ) + ) + + def convert(self): + """Convert all legacy subsets to current. + + It is enough to add `creator_identifier` and `instance_node`. + + """ + if not self.legacy_subsets: + return + + for family, instance_nodes in self.legacy_subsets.items(): + if family in self.family_to_id: + for instance_node in instance_nodes: + creator_identifier = self.family_to_id[family] + self.log.info( + "Converting {} to {}".format(instance_node.name, + creator_identifier) + ) + imprint(instance_node, data={ + "creator_identifier": creator_identifier + }) diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 0203ba74c0..caaa72fe8d 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -2,30 +2,29 @@ import bpy -from openpype.pipeline import get_current_task_name -import openpype.hosts.blender.api.plugin -from openpype.hosts.blender.api import lib +from openpype.hosts.blender.api import lib, plugin -class CreateAction(openpype.hosts.blender.api.plugin.Creator): - """Action output for character rigs""" +class CreateAction(plugin.BaseCreator): + """Action output for character rigs.""" - name = "actionMain" + identifier = "io.openpype.creators.blender.action" label = "Action" family = "action" icon = "male" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # Run parent create method + collection = super().create( + subset_name, instance_data, pre_create_data + ) - asset = self.data["asset"] - subset = self.data["subset"] - name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) - self.data['task'] = get_current_task_name() - lib.imprint(collection, self.data) + # Get instance name + name = plugin.prepare_scene_name(instance_data["asset"], subset_name) - if (self.options or {}).get("useSelection"): + if pre_create_data.get("use_selection"): for obj in lib.get_selection(): if (obj.animation_data is not None and obj.animation_data.action is not None): diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index bc2840952b..3a91b2d5ff 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -1,51 +1,32 @@ """Create an animation asset.""" -import bpy - -from openpype.pipeline import get_current_task_name -from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin, lib -class CreateAnimation(plugin.Creator): - """Animation output for character rigs""" +class CreateAnimation(plugin.BaseCreator): + """Animation output for character rigs.""" - name = "animationMain" + identifier = "io.openpype.creators.blender.animation" label = "Animation" family = "animation" icon = "male" - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # Run parent create method + collection = super().create( + subset_name, instance_data, pre_create_data + ) - def _process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) - - # Create instance object - # name = self.name - # if not name: - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - # asset_group = bpy.data.objects.new(name=name, object_data=None) - # asset_group.empty_display_type = 'SINGLE_ARROW' - asset_group = bpy.data.collections.new(name=name) - instances.children.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) - - if (self.options or {}).get("useSelection"): + if pre_create_data.get("use_selection"): selected = lib.get_selection() for obj in selected: - asset_group.objects.link(obj) - elif (self.options or {}).get("asset_group"): - obj = (self.options or {}).get("asset_group") - asset_group.objects.link(obj) + collection.objects.link(obj) + elif pre_create_data.get("asset_group"): + # Use for Load Blend automated creation of animation instances + # upon loading rig files + obj = pre_create_data.get("asset_group") + collection.objects.link(obj) - return asset_group + return collection diff --git a/openpype/hosts/blender/plugins/create/create_blendScene.py b/openpype/hosts/blender/plugins/create/create_blendScene.py index 63bcf212ff..e1026282c0 100644 --- a/openpype/hosts/blender/plugins/create/create_blendScene.py +++ b/openpype/hosts/blender/plugins/create/create_blendScene.py @@ -2,50 +2,33 @@ import bpy -from openpype.pipeline import get_current_task_name -from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin, lib -class CreateBlendScene(plugin.Creator): - """Generic group of assets""" +class CreateBlendScene(plugin.BaseCreator): + """Generic group of assets.""" - name = "blendScene" + identifier = "io.openpype.creators.blender.blendscene" label = "Blender Scene" family = "blendScene" icon = "cubes" - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) + maintain_selection = False - def _process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): - # Create instance object - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - asset_group = bpy.data.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) + instance_node = super().create(subset_name, + instance_data, + pre_create_data) - # Add selected objects to instance - if (self.options or {}).get("useSelection"): - bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) + if pre_create_data.get("use_selection"): + selection = lib.get_selection(include_collections=True) + for data in selection: + if isinstance(data, bpy.types.Collection): + instance_node.children.link(data) + elif isinstance(data, bpy.types.Object): + instance_node.objects.link(data) - return asset_group + return instance_node diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 7a770a3e77..2e2e6cec22 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -2,62 +2,41 @@ import bpy -from openpype.pipeline import get_current_task_name -from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreateCamera(plugin.Creator): - """Polygonal static geometry""" +class CreateCamera(plugin.BaseCreator): + """Polygonal static geometry.""" - name = "cameraMain" + identifier = "io.openpype.creators.blender.camera" label = "Camera" family = "camera" icon = "video-camera" - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) + create_as_asset_group = True - def _process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): - # Create instance object - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) + asset_group = super().create(subset_name, + instance_data, + pre_create_data) - asset_group = bpy.data.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - print(f"self.data: {self.data}") - lib.imprint(asset_group, self.data) - - if (self.options or {}).get("useSelection"): - bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) + bpy.context.view_layer.objects.active = asset_group + if pre_create_data.get("use_selection"): + for obj in lib.get_selection(): + obj.parent = asset_group else: plugin.deselect_all() - camera = bpy.data.cameras.new(subset) - camera_obj = bpy.data.objects.new(subset, camera) + camera = bpy.data.cameras.new(subset_name) + camera_obj = bpy.data.objects.new(subset_name, camera) + instances = bpy.data.collections.get(AVALON_INSTANCES) instances.objects.link(camera_obj) - camera_obj.select_set(True) - asset_group.select_set(True) bpy.context.view_layer.objects.active = asset_group - bpy.ops.object.parent_set(keep_transform=True) + camera_obj.parent = asset_group return asset_group diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index 73ed683256..16d227e50e 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -2,50 +2,31 @@ import bpy -from openpype.pipeline import get_current_task_name -from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin, lib -class CreateLayout(plugin.Creator): - """Layout output for character rigs""" +class CreateLayout(plugin.BaseCreator): + """Layout output for character rigs.""" - name = "layoutMain" + identifier = "io.openpype.creators.blender.layout" label = "Layout" family = "layout" icon = "cubes" - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) + create_as_asset_group = True - def _process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): - # Create instance object - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - asset_group = bpy.data.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) + asset_group = super().create(subset_name, + instance_data, + pre_create_data) # Add selected objects to instance - if (self.options or {}).get("useSelection"): + if pre_create_data.get("use_selection"): bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) + for obj in lib.get_selection(): + obj.parent = asset_group return asset_group diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 51fc6683f6..2f3f61728b 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -2,50 +2,30 @@ import bpy -from openpype.pipeline import get_current_task_name -from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin, lib -class CreateModel(plugin.Creator): - """Polygonal static geometry""" +class CreateModel(plugin.BaseCreator): + """Polygonal static geometry.""" - name = "modelMain" + identifier = "io.openpype.creators.blender.model" label = "Model" family = "model" icon = "cube" - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) + create_as_asset_group = True - def _process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) - - # Create instance object - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - asset_group = bpy.data.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + asset_group = super().create(subset_name, + instance_data, + pre_create_data) # Add selected objects to instance - if (self.options or {}).get("useSelection"): + if pre_create_data.get("use_selection"): bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) + for obj in lib.get_selection(): + obj.parent = asset_group return asset_group diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 6220f68dc5..b3329bcb3b 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -1,31 +1,25 @@ """Create a pointcache asset.""" -import bpy - -from openpype.pipeline import get_current_task_name -import openpype.hosts.blender.api.plugin -from openpype.hosts.blender.api import lib +from openpype.hosts.blender.api import plugin, lib -class CreatePointcache(openpype.hosts.blender.api.plugin.Creator): - """Polygonal static geometry""" +class CreatePointcache(plugin.BaseCreator): + """Polygonal static geometry.""" - name = "pointcacheMain" + identifier = "io.openpype.creators.blender.pointcache" label = "Point Cache" family = "pointcache" icon = "gears" - def process(self): + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # Run parent create method + collection = super().create( + subset_name, instance_data, pre_create_data + ) - asset = self.data["asset"] - subset = self.data["subset"] - name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) - self.data['task'] = get_current_task_name() - lib.imprint(collection, self.data) - - if (self.options or {}).get("useSelection"): + if pre_create_data.get("use_selection"): objects = lib.get_selection() for obj in objects: collection.objects.link(obj) diff --git a/openpype/hosts/blender/plugins/create/create_render.py b/openpype/hosts/blender/plugins/create/create_render.py new file mode 100644 index 0000000000..7fb3e5eb00 --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_render.py @@ -0,0 +1,42 @@ +"""Create render.""" +import bpy + +from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.render_lib import prepare_rendering + + +class CreateRenderlayer(plugin.BaseCreator): + """Single baked camera.""" + + identifier = "io.openpype.creators.blender.render" + label = "Render" + family = "render" + icon = "eye" + + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + try: + # Run parent create method + collection = super().create( + subset_name, instance_data, pre_create_data + ) + + prepare_rendering(collection) + except Exception: + # Remove the instance if there was an error + bpy.data.collections.remove(collection) + raise + + # TODO: this is undesiderable, but it's the only way to be sure that + # the file is saved before the render starts. + # Blender, by design, doesn't set the file as dirty if modifications + # happen by script. So, when creating the instance and setting the + # render settings, the file is not marked as dirty. This means that + # there is the risk of sending to deadline a file without the right + # settings. Even the validator to check that the file is saved will + # detect the file as saved, even if it isn't. The only solution for + # now it is to force the file to be saved. + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + + return collection diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py index 914f249891..940bcbea22 100644 --- a/openpype/hosts/blender/plugins/create/create_review.py +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -1,47 +1,27 @@ """Create review.""" -import bpy - -from openpype.pipeline import get_current_task_name -from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin, lib -class CreateReview(plugin.Creator): - """Single baked camera""" +class CreateReview(plugin.BaseCreator): + """Single baked camera.""" - name = "reviewDefault" + identifier = "io.openpype.creators.blender.review" label = "Review" family = "review" icon = "video-camera" - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + # Run parent create method + collection = super().create( + subset_name, instance_data, pre_create_data + ) - def _process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) - - # Create instance object - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - asset_group = bpy.data.collections.new(name=name) - instances.children.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) - - if (self.options or {}).get("useSelection"): + if pre_create_data.get("use_selection"): selected = lib.get_selection() for obj in selected: - asset_group.objects.link(obj) - elif (self.options or {}).get("asset_group"): - obj = (self.options or {}).get("asset_group") - asset_group.objects.link(obj) + collection.objects.link(obj) - return asset_group + return collection diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 08cc46ee3e..d63b8d56ff 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -2,50 +2,30 @@ import bpy -from openpype.pipeline import get_current_task_name -from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin, lib -class CreateRig(plugin.Creator): - """Artist-friendly rig with controls to direct motion""" +class CreateRig(plugin.BaseCreator): + """Artist-friendly rig with controls to direct motion.""" - name = "rigMain" + identifier = "io.openpype.creators.blender.rig" label = "Rig" family = "rig" icon = "wheelchair" - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) + create_as_asset_group = True - def _process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) - - # Create instance object - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - asset_group = bpy.data.objects.new(name=name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - instances.objects.link(asset_group) - self.data['task'] = get_current_task_name() - lib.imprint(asset_group, self.data) + def create( + self, subset_name: str, instance_data: dict, pre_create_data: dict + ): + asset_group = super().create(subset_name, + instance_data, + pre_create_data) # Add selected objects to instance - if (self.options or {}).get("useSelection"): + if pre_create_data.get("use_selection"): bpy.context.view_layer.objects.active = asset_group - selected = lib.get_selection() - for obj in selected: - if obj.parent in selected: - obj.select_set(False) - continue - selected.append(asset_group) - bpy.ops.object.parent_set(keep_transform=True) + for obj in lib.get_selection(): + obj.parent = asset_group return asset_group diff --git a/openpype/hosts/blender/plugins/create/create_workfile.py b/openpype/hosts/blender/plugins/create/create_workfile.py new file mode 100644 index 0000000000..ceec3e0552 --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_workfile.py @@ -0,0 +1,121 @@ +import bpy + +from openpype import AYON_SERVER_ENABLED +from openpype.pipeline import CreatedInstance, AutoCreator +from openpype.client import get_asset_by_name +from openpype.hosts.blender.api.plugin import BaseCreator +from openpype.hosts.blender.api.pipeline import ( + AVALON_PROPERTY, + AVALON_CONTAINERS +) + + +class CreateWorkfile(BaseCreator, AutoCreator): + """Workfile auto-creator. + + The workfile instance stores its data on the `AVALON_CONTAINERS` collection + as custom attributes, because unlike other instances it doesn't have an + instance node of its own. + + """ + identifier = "io.openpype.creators.blender.workfile" + label = "Workfile" + family = "workfile" + icon = "fa5.file" + + def create(self): + """Create workfile instances.""" + existing_instance = next( + ( + instance for instance in self.create_context.instances + if instance.creator_identifier == self.identifier + ), + None, + ) + + project_name = self.project_name + asset_name = self.create_context.get_current_asset_name() + task_name = self.create_context.get_current_task_name() + host_name = self.create_context.host_name + + existing_asset_name = None + if existing_instance is not None: + if AYON_SERVER_ENABLED: + existing_asset_name = existing_instance.get("folderPath") + + if existing_asset_name is None: + existing_asset_name = existing_instance["asset"] + + if not existing_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 + ) + data = { + "task": task_name, + "variant": task_name, + } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name + data.update( + self.get_dynamic_data( + task_name, + task_name, + asset_doc, + project_name, + host_name, + existing_instance, + ) + ) + self.log.info("Auto-creating workfile instance...") + current_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) + elif ( + existing_asset_name != asset_name + or existing_instance["task"] != task_name + ): + # Update instance context if it's different + 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 + ) + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name + + existing_instance["task"] = task_name + existing_instance["subset"] = subset_name + + def collect_instances(self): + + instance_node = bpy.data.collections.get(AVALON_CONTAINERS) + if not instance_node: + return + + property = instance_node.get(AVALON_PROPERTY) + if not property: + return + + # Create instance object from existing data + instance = CreatedInstance.from_existing( + instance_data=property.to_dict(), + creator=self + ) + instance.transient_data["instance_node"] = instance_node + + # Add instance to create context + self._add_instance_to_context(instance) + + def remove_instances(self, instances): + for instance in instances: + node = instance.transient_data["instance_node"] + del node[AVALON_PROPERTY] + + self._remove_instance_from_context(instance) diff --git a/openpype/hosts/blender/plugins/load/import_workfile.py b/openpype/hosts/blender/plugins/load/import_workfile.py index 4f5016d422..331f6a8bdb 100644 --- a/openpype/hosts/blender/plugins/load/import_workfile.py +++ b/openpype/hosts/blender/plugins/load/import_workfile.py @@ -7,7 +7,7 @@ def append_workfile(context, fname, do_import): asset = context['asset']['name'] subset = context['subset']['name'] - group_name = plugin.asset_name(asset, subset) + group_name = plugin.prepare_scene_name(asset, subset) # We need to preserve the original names of the scenes, otherwise, # if there are duplicate names in the current workfile, the imported diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 292925c833..d7e82d1900 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -26,8 +26,7 @@ class CacheModelLoader(plugin.AssetLoader): Note: At least for now it only supports Alembic files. """ - - families = ["model", "pointcache"] + families = ["model", "pointcache", "animation"] representations = ["abc"] label = "Load Alembic" @@ -53,32 +52,43 @@ class CacheModelLoader(plugin.AssetLoader): def _process(self, libpath, asset_group, group_name): plugin.deselect_all() - collection = bpy.context.view_layer.active_layer_collection.collection - relative = bpy.context.preferences.filepaths.use_relative_paths bpy.ops.wm.alembic_import( filepath=libpath, relative_path=relative ) - parent = bpy.context.scene.collection - imported = lib.get_selection() - # Children must be linked before parents, - # otherwise the hierarchy will break + # Use first EMPTY without parent as container + container = next( + (obj for obj in imported + if obj.type == "EMPTY" and not obj.parent), + None + ) + objects = [] + if container: + nodes = list(container.children) - for obj in imported: - obj.parent = asset_group + for obj in nodes: + obj.parent = asset_group - for obj in imported: - objects.append(obj) - imported.extend(list(obj.children)) + bpy.data.objects.remove(container) - objects.reverse() + objects.extend(nodes) + for obj in nodes: + objects.extend(obj.children_recursive) + else: + for obj in imported: + obj.parent = asset_group + objects = imported for obj in objects: + # Unlink the object from all collections + collections = obj.users_collection + for collection in collections: + collection.objects.unlink(obj) name = obj.name obj.name = f"{group_name}:{name}" if obj.type != 'EMPTY': @@ -90,7 +100,7 @@ class CacheModelLoader(plugin.AssetLoader): material_slot.material.name = f"{group_name}:{name_mat}" if not obj.get(AVALON_PROPERTY): - obj[AVALON_PROPERTY] = dict() + obj[AVALON_PROPERTY] = {} avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) @@ -99,6 +109,18 @@ class CacheModelLoader(plugin.AssetLoader): return objects + def _link_objects(self, objects, collection, containers, asset_group): + # Link the imported objects to any collection where the asset group is + # linked to, except the AVALON_CONTAINERS collection + group_collections = [ + collection + for collection in asset_group.users_collection + if collection != containers] + + for obj in objects: + for collection in group_collections: + collection.objects.link(obj) + def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None @@ -115,23 +137,27 @@ class CacheModelLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" - avalon_containers = bpy.data.collections.get(AVALON_CONTAINERS) - if not avalon_containers: - avalon_containers = bpy.data.collections.new( - name=AVALON_CONTAINERS) - bpy.context.scene.collection.children.link(avalon_containers) + containers = bpy.data.collections.get(AVALON_CONTAINERS) + if not containers: + containers = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(containers) asset_group = bpy.data.objects.new(group_name, object_data=None) - avalon_containers.objects.link(asset_group) + asset_group.empty_display_type = 'SINGLE_ARROW' + containers.objects.link(asset_group) objects = self._process(libpath, asset_group, group_name) - bpy.context.scene.collection.objects.link(asset_group) + # Link the asset group to the active collection + collection = bpy.context.view_layer.active_layer_collection.collection + collection.objects.link(asset_group) + + self._link_objects(objects, asset_group, containers, asset_group) asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", @@ -207,7 +233,11 @@ class CacheModelLoader(plugin.AssetLoader): mat = asset_group.matrix_basis.copy() self._remove(asset_group) - self._process(str(libpath), asset_group, object_name) + objects = self._process(str(libpath), asset_group, object_name) + + containers = bpy.data.collections.get(AVALON_CONTAINERS) + self._link_objects(objects, asset_group, containers, asset_group) + asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) diff --git a/openpype/hosts/blender/plugins/load/load_action.py b/openpype/hosts/blender/plugins/load/load_action.py index 3447e67ebf..f7d32f92a5 100644 --- a/openpype/hosts/blender/plugins/load/load_action.py +++ b/openpype/hosts/blender/plugins/load/load_action.py @@ -7,7 +7,7 @@ from typing import Dict, List, Optional import bpy from openpype.pipeline import get_representation_path -import openpype.hosts.blender.api.plugin +from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( containerise_existing, AVALON_PROPERTY, @@ -16,7 +16,7 @@ from openpype.hosts.blender.api.pipeline import ( logger = logging.getLogger("openpype").getChild("blender").getChild("load_action") -class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): +class BlendActionLoader(plugin.AssetLoader): """Load action from a .blend file. Warning: @@ -46,8 +46,8 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] - lib_container = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - container_name = openpype.hosts.blender.api.plugin.asset_name( + lib_container = plugin.prepare_scene_name(asset, subset) + container_name = plugin.prepare_scene_name( asset, subset, namespace ) @@ -152,7 +152,7 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) - assert extension in openpype.hosts.blender.api.plugin.VALID_EXTENSIONS, ( + assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) diff --git a/openpype/hosts/blender/plugins/load/load_audio.py b/openpype/hosts/blender/plugins/load/load_audio.py index ac8f363316..1e5bd39a32 100644 --- a/openpype/hosts/blender/plugins/load/load_audio.py +++ b/openpype/hosts/blender/plugins/load/load_audio.py @@ -42,9 +42,9 @@ class AudioLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 99f291a5a7..f437e66795 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -4,11 +4,11 @@ from pathlib import Path import bpy from openpype.pipeline import ( - legacy_create, get_representation_path, AVALON_CONTAINER_ID, + registered_host ) -from openpype.pipeline.create import get_legacy_creator_by_name +from openpype.pipeline.create import CreateContext from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.lib import imprint from openpype.hosts.blender.api.pipeline import ( @@ -20,7 +20,7 @@ from openpype.hosts.blender.api.pipeline import ( class BlendLoader(plugin.AssetLoader): """Load assets from a .blend file.""" - families = ["model", "rig", "layout", "camera", "blendScene"] + families = ["model", "rig", "layout", "camera"] representations = ["blend"] label = "Append Blend" @@ -32,7 +32,7 @@ class BlendLoader(plugin.AssetLoader): empties = [obj for obj in objects if obj.type == 'EMPTY'] for empty in empties: - if empty.get(AVALON_PROPERTY): + if empty.get(AVALON_PROPERTY) and empty.parent is None: return empty return None @@ -57,19 +57,21 @@ class BlendLoader(plugin.AssetLoader): obj.get(AVALON_PROPERTY).get('family') == 'rig' ) ] + if not rigs: + return + + # Create animation instances for each rig + creator_identifier = "io.openpype.creators.blender.animation" + host = registered_host() + create_context = CreateContext(host) for rig in rigs: - creator_plugin = get_legacy_creator_by_name("CreateAnimation") - legacy_create( - creator_plugin, - name=rig.name.split(':')[-1] + "_animation", - asset=asset, - options={ - "useSelection": False, + create_context.create( + creator_identifier=creator_identifier, + variant=rig.name.split(':')[-1], + pre_create_data={ + "use_selection": False, "asset_group": rig - }, - data={ - "dependencies": representation } ) @@ -100,6 +102,7 @@ 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 @@ -119,7 +122,7 @@ class BlendLoader(plugin.AssetLoader): context: Full parenthood of representation to load options: Additional settings dictionary """ - libpath = self.fname + libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] @@ -130,9 +133,9 @@ class BlendLoader(plugin.AssetLoader): representation = str(context["representation"]["_id"]) - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) @@ -244,7 +247,7 @@ class BlendLoader(plugin.AssetLoader): for parent in parent_containers: parent.get(AVALON_PROPERTY)["members"] = list(filter( lambda i: i not in members, - parent.get(AVALON_PROPERTY)["members"])) + parent.get(AVALON_PROPERTY).get("members", []))) for attr in attrs: for data in getattr(bpy.data, attr): diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py new file mode 100644 index 0000000000..6cc7f39d03 --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_blendscene.py @@ -0,0 +1,221 @@ +from typing import Dict, List, Optional +from pathlib import Path + +import bpy + +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID, +) +from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.lib import imprint +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, +) + + +class BlendSceneLoader(plugin.AssetLoader): + """Load assets from a .blend file.""" + + families = ["blendScene"] + representations = ["blend"] + + label = "Append Blend" + icon = "code-fork" + color = "orange" + + @staticmethod + def _get_asset_container(collections): + for coll in collections: + parents = [c for c in collections if c.user_of_id(coll)] + if coll.get(AVALON_PROPERTY) and not parents: + return coll + + return None + + def _process_data(self, libpath, group_name, family): + # Append all the data from the .blend file + with bpy.data.libraries.load( + libpath, link=False, relative=False + ) as (data_from, data_to): + for attr in dir(data_to): + setattr(data_to, attr, getattr(data_from, attr)) + + members = [] + + # Rename the object to add the asset name + for attr in dir(data_to): + for data in getattr(data_to, attr): + data.name = f"{group_name}:{data.name}" + members.append(data) + + container = self._get_asset_container( + data_to.collections) + assert container, "No asset group found" + + container.name = group_name + + # Link the group to the scene + bpy.context.scene.collection.children.link(container) + + # Remove the library from the blend file + library = bpy.data.libraries.get(bpy.path.basename(libpath)) + bpy.data.libraries.remove(library) + + return container, members + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + libpath = self.filepath_from_context(context) + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + try: + family = context["representation"]["context"]["family"] + except ValueError: + family = "model" + + asset_name = plugin.prepare_scene_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + container, members = self._process_data(libpath, group_name, family) + + avalon_container.children.link(container) + + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": group_name, + "members": members, + } + + container[AVALON_PROPERTY] = data + + objects = [ + obj for obj in bpy.data.objects + if obj.name.startswith(f"{group_name}:") + ] + + self[:] = objects + return objects + + def exec_update(self, container: Dict, representation: Dict): + """ + Update the loaded asset. + """ + group_name = container["objectName"] + asset_group = bpy.data.collections.get(group_name) + libpath = Path(get_representation_path(representation)).as_posix() + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + + # Get the parents of the members of the asset group, so we can + # re-link them after the update. + # Also gets the transform for each object to reapply after the update. + collection_parents = {} + member_transforms = {} + members = asset_group.get(AVALON_PROPERTY).get("members", []) + loaded_collections = {c for c in bpy.data.collections if c in members} + loaded_collections.add(bpy.data.collections.get(AVALON_CONTAINERS)) + for member in members: + if isinstance(member, bpy.types.Object): + member_parents = set(member.users_collection) + member_transforms[member.name] = member.matrix_basis.copy() + elif isinstance(member, bpy.types.Collection): + member_parents = { + c for c in bpy.data.collections if c.user_of_id(member)} + else: + continue + + member_parents = member_parents.difference(loaded_collections) + if member_parents: + collection_parents[member.name] = list(member_parents) + + old_data = dict(asset_group.get(AVALON_PROPERTY)) + + self.exec_remove(container) + + family = container["family"] + asset_group, members = self._process_data(libpath, group_name, family) + + for member in members: + if member.name in collection_parents: + for parent in collection_parents[member.name]: + if isinstance(member, bpy.types.Object): + parent.objects.link(member) + elif isinstance(member, bpy.types.Collection): + parent.children.link(member) + if member.name in member_transforms and isinstance( + member, bpy.types.Object + ): + member.matrix_basis = member_transforms[member.name] + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + avalon_container.children.link(asset_group) + + # Restore the old data, but reset members, as they don't exist anymore + # This avoids a crash, because the memory addresses of those members + # are not valid anymore + old_data["members"] = [] + asset_group[AVALON_PROPERTY] = old_data + + new_data = { + "libpath": libpath, + "representation": str(representation["_id"]), + "parent": str(representation["parent"]), + "members": members, + } + + imprint(asset_group, new_data) + + def exec_remove(self, container: Dict) -> bool: + """ + Remove an existing container from a Blender scene. + """ + group_name = container["objectName"] + asset_group = bpy.data.collections.get(group_name) + + members = set(asset_group.get(AVALON_PROPERTY).get("members", [])) + + if members: + for attr_name in dir(bpy.data): + attr = getattr(bpy.data, attr_name) + if not isinstance(attr, bpy.types.bpy_prop_collection): + continue + + # ensure to make a list copy because we + # we remove members as we iterate + for data in list(attr): + if data not in members or data == asset_group: + continue + + attr.remove(data) + + bpy.data.collections.remove(asset_group) diff --git a/openpype/hosts/blender/plugins/load/load_camera_abc.py b/openpype/hosts/blender/plugins/load/load_camera_abc.py index e5afecff66..ecd6bb98f1 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_abc.py +++ b/openpype/hosts/blender/plugins/load/load_camera_abc.py @@ -87,9 +87,9 @@ class AbcCameraLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) @@ -100,7 +100,7 @@ class AbcCameraLoader(plugin.AssetLoader): asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) - objects = self._process(libpath, asset_group, group_name) + self._process(libpath, asset_group, group_name) objects = [] nodes = list(asset_group.children) diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py index b9d05dda0a..2d53d3e573 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -90,9 +90,9 @@ class FbxCameraLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) @@ -103,7 +103,7 @@ class FbxCameraLoader(plugin.AssetLoader): asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) - objects = self._process(libpath, asset_group, group_name) + self._process(libpath, asset_group, group_name) objects = [] nodes = list(asset_group.children) diff --git a/openpype/hosts/blender/plugins/load/load_fbx.py b/openpype/hosts/blender/plugins/load/load_fbx.py index e129ea6754..8fce53a5d5 100644 --- a/openpype/hosts/blender/plugins/load/load_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_fbx.py @@ -134,9 +134,9 @@ class FbxModelLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 81683b8de8..748ac619b6 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -123,6 +123,7 @@ class JsonLayoutLoader(plugin.AssetLoader): # raise ValueError("Creator plugin \"CreateCamera\" was " # "not found.") + # TODO: Refactor legacy create usage to new style creators # legacy_create( # creator_plugin, # name="camera", @@ -148,9 +149,9 @@ class JsonLayoutLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_look.py b/openpype/hosts/blender/plugins/load/load_look.py index c121f55633..8d3118d83b 100644 --- a/openpype/hosts/blender/plugins/load/load_look.py +++ b/openpype/hosts/blender/plugins/load/load_look.py @@ -96,14 +96,14 @@ class BlendLookLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - lib_container = plugin.asset_name( + lib_container = plugin.prepare_scene_name( asset, subset ) unique_number = plugin.get_unique_number( asset, subset ) namespace = namespace or f"{asset}_{unique_number}" - container_name = plugin.asset_name( + container_name = plugin.prepare_scene_name( asset, subset, unique_number ) diff --git a/openpype/hosts/blender/plugins/publish/collect_current_file.py b/openpype/hosts/blender/plugins/publish/collect_current_file.py index c2d8a96a18..91c88f2e28 100644 --- a/openpype/hosts/blender/plugins/publish/collect_current_file.py +++ b/openpype/hosts/blender/plugins/publish/collect_current_file.py @@ -1,72 +1,15 @@ -import os -import bpy - import pyblish.api -from openpype.pipeline import get_current_task_name, get_current_asset_name from openpype.hosts.blender.api import workio -class SaveWorkfiledAction(pyblish.api.Action): - """Save Workfile.""" - label = "Save Workfile" - on = "failed" - icon = "save" - - def process(self, context, plugin): - bpy.ops.wm.avalon_workfiles() - - class CollectBlenderCurrentFile(pyblish.api.ContextPlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.5 label = "Blender Current File" hosts = ["blender"] - actions = [SaveWorkfiledAction] def process(self, context): """Inject the current working file""" current_file = workio.current_file() - context.data["currentFile"] = current_file - - assert current_file, ( - "Current file is empty. Save the file before continuing." - ) - - folder, file = os.path.split(current_file) - filename, ext = os.path.splitext(file) - - task = get_current_task_name() - - data = {} - - # create instance - instance = context.create_instance(name=filename) - subset = "workfile" + task.capitalize() - - data.update({ - "subset": subset, - "asset": get_current_asset_name(), - "label": subset, - "publish": True, - "family": "workfile", - "families": ["workfile"], - "setMembers": [current_file], - "frameStart": bpy.context.scene.frame_start, - "frameEnd": bpy.context.scene.frame_end, - }) - - data["representations"] = [{ - "name": ext.lstrip("."), - "ext": ext.lstrip("."), - "files": file, - "stagingDir": folder, - }] - - instance.data.update(data) - - self.log.info("Collected instance: {}".format(file)) - self.log.info("Scene path: {}".format(current_file)) - self.log.info("staging Dir: {}".format(folder)) - self.log.info("subset: {}".format(subset)) diff --git a/openpype/hosts/blender/plugins/publish/collect_instance.py b/openpype/hosts/blender/plugins/publish/collect_instance.py new file mode 100644 index 0000000000..4685472213 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/collect_instance.py @@ -0,0 +1,43 @@ +import bpy + +import pyblish.api + +from openpype.pipeline.publish import KnownPublishError +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY + + +class CollectBlenderInstanceData(pyblish.api.InstancePlugin): + """Validator to verify that the instance is not empty""" + + order = pyblish.api.CollectorOrder + hosts = ["blender"] + families = ["model", "pointcache", "animation", "rig", "camera", "layout", + "blendScene"] + label = "Collect Instance" + + def process(self, instance): + instance_node = instance.data["transientData"]["instance_node"] + + # Collect members of the instance + members = [instance_node] + if isinstance(instance_node, bpy.types.Collection): + members.extend(instance_node.objects) + members.extend(instance_node.children) + + # Special case for animation instances, include armatures + if instance.data["family"] == "animation": + for obj in instance_node.objects: + if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): + members.extend( + child for child in obj.children + if child.type == 'ARMATURE' + ) + elif isinstance(instance_node, bpy.types.Object): + members.extend(instance_node.children_recursive) + else: + raise KnownPublishError( + f"Unsupported instance node type '{type(instance_node)}' " + f"for instance '{instance}'" + ) + + instance[:] = members diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py deleted file mode 100644 index bc4b5ab092..0000000000 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ /dev/null @@ -1,104 +0,0 @@ -import json -from typing import Generator - -import bpy - -import pyblish.api -from openpype.hosts.blender.api.pipeline import ( - AVALON_INSTANCES, - AVALON_PROPERTY, -) - - -class CollectInstances(pyblish.api.ContextPlugin): - """Collect the data of a model.""" - - hosts = ["blender"] - label = "Collect Instances" - order = pyblish.api.CollectorOrder - - @staticmethod - def get_asset_groups() -> Generator: - """Return all 'model' collections. - - Check if the family is 'model' and if it doesn't have the - representation set. If the representation is set, it is a loaded model - and we don't want to publish it. - """ - instances = bpy.data.collections.get(AVALON_INSTANCES) - for obj in instances.objects: - avalon_prop = obj.get(AVALON_PROPERTY) or dict() - if avalon_prop.get('id') == 'pyblish.avalon.instance': - yield obj - - @staticmethod - def get_collections() -> Generator: - """Return all 'model' collections. - - Check if the family is 'model' and if it doesn't have the - representation set. If the representation is set, it is a loaded model - and we don't want to publish it. - """ - for collection in bpy.data.collections: - avalon_prop = collection.get(AVALON_PROPERTY) or dict() - if avalon_prop.get('id') == 'pyblish.avalon.instance': - yield collection - - def process(self, context): - """Collect the models from the current Blender scene.""" - asset_groups = self.get_asset_groups() - collections = self.get_collections() - - for group in asset_groups: - avalon_prop = group[AVALON_PROPERTY] - asset = avalon_prop['asset'] - family = avalon_prop['family'] - subset = avalon_prop['subset'] - task = avalon_prop['task'] - name = f"{asset}_{subset}" - instance = context.create_instance( - name=name, - family=family, - families=[family], - subset=subset, - asset=asset, - task=task, - ) - objects = list(group.children) - members = set() - for obj in objects: - objects.extend(list(obj.children)) - members.add(obj) - members.add(group) - instance[:] = list(members) - self.log.debug(json.dumps(instance.data, indent=4)) - for obj in instance: - self.log.debug(obj) - - for collection in collections: - avalon_prop = collection[AVALON_PROPERTY] - asset = avalon_prop['asset'] - family = avalon_prop['family'] - subset = avalon_prop['subset'] - task = avalon_prop['task'] - name = f"{asset}_{subset}" - instance = context.create_instance( - name=name, - family=family, - families=[family], - subset=subset, - asset=asset, - task=task, - ) - members = list(collection.objects) - if family == "animation": - for obj in collection.objects: - if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): - for child in obj.children: - if child.type == 'ARMATURE': - members.append(child) - members.append(collection) - instance[:] = members - self.log.debug(json.dumps(instance.data, indent=4)) - for obj in instance: - self.log.debug(obj) diff --git a/openpype/hosts/blender/plugins/publish/collect_render.py b/openpype/hosts/blender/plugins/publish/collect_render.py new file mode 100644 index 0000000000..da02f99052 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/collect_render.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""Collect render data.""" + +import os +import re + +import bpy + +from openpype.hosts.blender.api import colorspace +import pyblish.api + + +class CollectBlenderRender(pyblish.api.InstancePlugin): + """Gather all publishable render instances.""" + + order = pyblish.api.CollectorOrder + 0.01 + hosts = ["blender"] + families = ["render"] + label = "Collect Render" + sync_workfile_version = False + + @staticmethod + def generate_expected_beauty( + render_product, frame_start, frame_end, frame_step, ext + ): + """ + Generate the expected files for the render product for the beauty + render. This returns a list of files that should be rendered. It + replaces the sequence of `#` with the frame number. + """ + path = os.path.dirname(render_product) + file = os.path.basename(render_product) + + expected_files = [] + + for frame in range(frame_start, frame_end + 1, frame_step): + frame_str = str(frame).rjust(4, "0") + filename = re.sub("#+", frame_str, file) + expected_file = f"{os.path.join(path, filename)}.{ext}" + expected_files.append(expected_file.replace("\\", "/")) + + return { + "beauty": expected_files + } + + @staticmethod + def generate_expected_aovs( + aov_file_product, frame_start, frame_end, frame_step, ext + ): + """ + Generate the expected files for the render product for the beauty + render. This returns a list of files that should be rendered. It + replaces the sequence of `#` with the frame number. + """ + expected_files = {} + + for aov_name, aov_file in aov_file_product: + path = os.path.dirname(aov_file) + file = os.path.basename(aov_file) + + aov_files = [] + + for frame in range(frame_start, frame_end + 1, frame_step): + frame_str = str(frame).rjust(4, "0") + filename = re.sub("#+", frame_str, file) + expected_file = f"{os.path.join(path, filename)}.{ext}" + aov_files.append(expected_file.replace("\\", "/")) + + expected_files[aov_name] = aov_files + + return expected_files + + def process(self, instance): + context = instance.context + + instance_node = instance.data["transientData"]["instance_node"] + render_data = instance_node.get("render_data") + + assert render_data, "No render data found." + + render_product = render_data.get("render_product") + aov_file_product = render_data.get("aov_file_product") + ext = render_data.get("image_format") + multilayer = render_data.get("multilayer_exr") + + frame_start = context.data["frameStart"] + frame_end = context.data["frameEnd"] + frame_handle_start = context.data["frameStartHandle"] + frame_handle_end = context.data["frameEndHandle"] + + expected_beauty = self.generate_expected_beauty( + render_product, int(frame_start), int(frame_end), + int(bpy.context.scene.frame_step), ext) + + expected_aovs = self.generate_expected_aovs( + aov_file_product, int(frame_start), int(frame_end), + int(bpy.context.scene.frame_step), ext) + + expected_files = expected_beauty | expected_aovs + + instance.data.update({ + "families": ["render", "render.farm"], + "frameStart": frame_start, + "frameEnd": frame_end, + "frameStartHandle": frame_handle_start, + "frameEndHandle": frame_handle_end, + "fps": context.data["fps"], + "byFrameStep": bpy.context.scene.frame_step, + "review": render_data.get("review", False), + "multipartExr": ext == "exr" and multilayer, + "farm": True, + "expectedFiles": [expected_files], + # OCIO not currently implemented in Blender, but the following + # settings are required by the schema, so it is hardcoded. + # TODO: Implement OCIO in Blender + "colorspaceConfig": "", + "colorspaceDisplay": "sRGB", + "colorspaceView": "ACES 1.0 SDR-video", + "renderProducts": colorspace.ARenderProduct(), + }) diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py index 6459927015..2c077398da 100644 --- a/openpype/hosts/blender/plugins/publish/collect_review.py +++ b/openpype/hosts/blender/plugins/publish/collect_review.py @@ -16,10 +16,12 @@ class CollectReview(pyblish.api.InstancePlugin): self.log.debug(f"instance: {instance}") + datablock = instance.data["transientData"]["instance_node"] + # get cameras cameras = [ obj - for obj in instance + for obj in datablock.all_objects if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA" ] @@ -31,23 +33,20 @@ class CollectReview(pyblish.api.InstancePlugin): focal_length = cameras[0].data.lens - # get isolate objects list from meshes instance members . + # get isolate objects list from meshes instance members. + types = {"MESH", "GPENCIL"} isolate_objects = [ obj for obj in instance - if isinstance(obj, bpy.types.Object) and obj.type == "MESH" + if isinstance(obj, bpy.types.Object) and obj.type in types ] if not instance.data.get("remove"): - - task = instance.context.data["task"] - # Store focal length in `burninDataMembers` burninData = instance.data.setdefault("burninDataMembers", {}) burninData["focalLength"] = focal_length instance.data.update({ - "subset": f"{task}Review", "review_camera": camera, "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], diff --git a/openpype/hosts/blender/plugins/publish/collect_workfile.py b/openpype/hosts/blender/plugins/publish/collect_workfile.py new file mode 100644 index 0000000000..6561c89605 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/collect_workfile.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from pyblish.api import InstancePlugin, CollectorOrder + + +class CollectWorkfile(InstancePlugin): + """Inject workfile data into its instance.""" + + order = CollectorOrder + label = "Collect Workfile" + hosts = ["blender"] + families = ["workfile"] + + def process(self, instance): + """Process collector.""" + + context = instance.context + filepath = Path(context.data["currentFile"]) + ext = filepath.suffix + + instance.data.update( + { + "setMembers": [filepath.as_posix()], + "frameStart": context.data.get("frameStart", 1), + "frameEnd": context.data.get("frameEnd", 1), + "handleStart": context.data.get("handleStart", 1), + "handledEnd": context.data.get("handleEnd", 1), + "representations": [ + { + "name": ext.lstrip("."), + "ext": ext.lstrip("."), + "files": filepath.name, + "stagingDir": filepath.parent, + } + ], + } + ) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index f4babc94d3..0e242e9d53 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -4,42 +4,42 @@ import bpy from openpype.pipeline import publish from openpype.hosts.blender.api import plugin -from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractABC(publish.Extractor): +class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as ABC.""" label = "Extract ABC" hosts = ["blender"] - families = ["model", "pointcache"] - optional = True + families = ["pointcache"] def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.abc" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.abc" filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() - selected = [] - active = None + asset_group = instance.data["transientData"]["instance_node"] + selected = [] for obj in instance: - obj.select_set(True) - selected.append(obj) - # Set as active the asset group - if obj.get(AVALON_PROPERTY): - active = obj + if isinstance(obj, bpy.types.Object): + obj.select_set(True) + selected.append(obj) context = plugin.create_blender_context( - active=active, selected=selected) + active=asset_group, selected=selected) with bpy.context.temp_override(**context): # We export the abc @@ -62,5 +62,14 @@ class ExtractABC(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, representation) + + +class ExtractModelABC(ExtractABC): + """Extract model as ABC.""" + + label = "Extract Model ABC" + hosts = ["blender"] + families = ["model"] + optional = True diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py index e141ccaa44..6ef9b29693 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py @@ -6,7 +6,10 @@ from openpype.pipeline import publish from openpype.hosts.blender.api import plugin -class ExtractAnimationABC(publish.Extractor): +class ExtractAnimationABC( + publish.Extractor, + publish.OptionalPyblishPluginMixin, +): """Extract as ABC.""" label = "Extract Animation ABC" @@ -15,20 +18,25 @@ class ExtractAnimationABC(publish.Extractor): optional = True def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.abc" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.abc" + filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() selected = [] - asset_group = None + asset_group = instance.data["transientData"]["instance_node"] objects = [] for obj in instance: @@ -68,5 +76,5 @@ class ExtractAnimationABC(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index d4f26b4f3c..94e87d537c 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -5,7 +5,7 @@ import bpy from openpype.pipeline import publish -class ExtractBlend(publish.Extractor): +class ExtractBlend(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract a blend file.""" label = "Extract Blend" @@ -14,30 +14,44 @@ class ExtractBlend(publish.Extractor): optional = True def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.blend" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.blend" filepath = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") data_blocks = set() - for obj in instance: - data_blocks.add(obj) + for data in instance: + data_blocks.add(data) # Pack used images in the blend files. - if obj.type == 'MESH': - for material_slot in obj.material_slots: - mat = material_slot.material - if mat and mat.use_nodes: - tree = mat.node_tree - if tree.type == 'SHADER': - for node in tree.nodes: - if node.bl_idname == 'ShaderNodeTexImage': - if node.image: - node.image.pack() + if not ( + isinstance(data, bpy.types.Object) and data.type == 'MESH' + ): + continue + for material_slot in data.material_slots: + mat = material_slot.material + if not (mat and mat.use_nodes): + continue + tree = mat.node_tree + if tree.type != 'SHADER': + continue + for node in tree.nodes: + if node.bl_idname != 'ShaderNodeTexImage': + continue + # Check if image is not packed already + # and pack it if not. + if node.image and node.image.packed_file is None: + node.image.pack() bpy.data.libraries.write(filepath, data_blocks) @@ -52,5 +66,5 @@ class ExtractBlend(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py index 477411b73d..11eb268271 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend_animation.py @@ -5,7 +5,10 @@ import bpy from openpype.pipeline import publish -class ExtractBlendAnimation(publish.Extractor): +class ExtractBlendAnimation( + publish.Extractor, + publish.OptionalPyblishPluginMixin, +): """Extract a blend file.""" label = "Extract Blend" @@ -14,14 +17,20 @@ class ExtractBlendAnimation(publish.Extractor): optional = True def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.blend" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.blend" filepath = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") data_blocks = set() @@ -50,5 +59,5 @@ class ExtractBlendAnimation(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py index a21a59b151..df68668eae 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractCameraABC(publish.Extractor): +class ExtractCameraABC(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract camera as ABC.""" label = "Extract Camera (ABC)" @@ -16,27 +16,23 @@ class ExtractCameraABC(publish.Extractor): optional = True def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.abc" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.abc" filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() - selected = [] - active = None - - asset_group = None - for obj in instance: - if obj.get(AVALON_PROPERTY): - asset_group = obj - break - assert asset_group, "No asset group found" + asset_group = instance.data["transientData"]["instance_node"] # Need to cast to list because children is a tuple selected = list(asset_group.children) @@ -69,5 +65,5 @@ class ExtractCameraABC(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py index 315994140e..ee046b7d11 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_fbx.py @@ -6,7 +6,7 @@ from openpype.pipeline import publish from openpype.hosts.blender.api import plugin -class ExtractCamera(publish.Extractor): +class ExtractCamera(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as the camera as FBX.""" label = "Extract Camera (FBX)" @@ -15,13 +15,19 @@ class ExtractCamera(publish.Extractor): optional = True def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.fbx" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.fbx" filepath = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() @@ -73,5 +79,5 @@ class ExtractCamera(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index 0ad797c226..4ae6501f7d 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -7,7 +7,7 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractFBX(publish.Extractor): +class ExtractFBX(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as FBX.""" label = "Extract FBX" @@ -16,24 +16,28 @@ class ExtractFBX(publish.Extractor): optional = True def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.fbx" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + filename = f"{instance_name}.fbx" filepath = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") plugin.deselect_all() - selected = [] - asset_group = None + asset_group = instance.data["transientData"]["instance_node"] + selected = [] for obj in instance: obj.select_set(True) selected.append(obj) - if obj.get(AVALON_PROPERTY): - asset_group = obj context = plugin.create_blender_context( active=asset_group, selected=selected) @@ -84,5 +88,5 @@ class ExtractFBX(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py index 062b42e99d..4fc8230a1b 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py @@ -10,7 +10,41 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractAnimationFBX(publish.Extractor): +def get_all_parents(obj): + """Get all recursive parents of object""" + result = [] + while True: + obj = obj.parent + if not obj: + break + result.append(obj) + return result + + +def get_highest_root(objects): + # Get the highest object that is also in the collection + included_objects = {obj.name_full for obj in objects} + num_parents_to_obj = {} + for obj in objects: + if isinstance(obj, bpy.types.Object): + parents = get_all_parents(obj) + # included parents + parents = [parent for parent in parents if + parent.name_full in included_objects] + if not parents: + # A node without parents must be a highest root + return obj + + num_parents_to_obj.setdefault(len(parents), obj) + + minimum_parent = min(num_parents_to_obj) + return num_parents_to_obj[minimum_parent] + + +class ExtractAnimationFBX( + publish.Extractor, + publish.OptionalPyblishPluginMixin, +): """Extract as animation.""" label = "Extract FBX" @@ -19,23 +53,43 @@ class ExtractAnimationFBX(publish.Extractor): optional = True def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") - # The first collection object in the instance is taken, as there - # should be only one that contains the asset group. - collection = [ - obj for obj in instance if type(obj) is bpy.types.Collection][0] + asset_group = instance.data["transientData"]["instance_node"] - # Again, the first object in the collection is taken , as there - # should be only the asset group in the collection. - asset_group = collection.objects[0] + # Get objects in this collection (but not in children collections) + # and for those objects include the children hierarchy + # TODO: Would it make more sense for the Collect Instance collector + # to also always retrieve all the children? + objects = set(asset_group.objects) - armature = [ - obj for obj in asset_group.children if obj.type == 'ARMATURE'][0] + # From the direct children of the collection find the 'root' node + # that we want to export - it is the 'highest' node in a hierarchy + root = get_highest_root(objects) + + for obj in list(objects): + objects.update(obj.children_recursive) + + # Find all armatures among the objects, assume to find only one + armatures = [obj for obj in objects if obj.type == "ARMATURE"] + if not armatures: + raise RuntimeError( + f"Unable to find ARMATURE in collection: " + f"{asset_group.name}" + ) + elif len(armatures) > 1: + self.log.warning( + "Found more than one ARMATURE, using " + f"only first of: {armatures}" + ) + armature = armatures[0] object_action_pairs = [] original_actions = [] @@ -44,9 +98,6 @@ class ExtractAnimationFBX(publish.Extractor): ending_frames = [] # For each armature, we make a copy of the current action - curr_action = None - copy_action = None - if armature.animation_data and armature.animation_data.action: curr_action = armature.animation_data.action copy_action = curr_action.copy() @@ -56,12 +107,20 @@ class ExtractAnimationFBX(publish.Extractor): starting_frames.append(curr_frame_range[0]) ending_frames.append(curr_frame_range[1]) else: - self.log.info("Object have no animation.") + self.log.info( + f"Armature '{armature.name}' has no animation, " + f"skipping FBX animation extraction for {instance}." + ) return asset_group_name = asset_group.name - asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name") + asset_name = asset_group.get(AVALON_PROPERTY).get("asset_name") + if asset_name: + # Rename for the export; this data is only present when loaded + # from a JSON Layout (layout family) + asset_group.name = asset_name + # Remove : from the armature name for the export armature_name = armature.name original_name = armature_name.split(':')[1] armature.name = original_name @@ -84,13 +143,16 @@ class ExtractAnimationFBX(publish.Extractor): for obj in bpy.data.objects: obj.select_set(False) - asset_group.select_set(True) + root.select_set(True) armature.select_set(True) - fbx_filename = f"{instance.name}_{armature.name}.fbx" + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + fbx_filename = f"{instance_name}_{armature.name}.fbx" filepath = os.path.join(stagingdir, fbx_filename) override = plugin.create_blender_context( - active=asset_group, selected=[asset_group, armature]) + active=root, selected=[root, armature]) bpy.ops.export_scene.fbx( override, filepath=filepath, @@ -104,7 +166,7 @@ class ExtractAnimationFBX(publish.Extractor): ) armature.name = armature_name asset_group.name = asset_group_name - asset_group.select_set(False) + root.select_set(True) armature.select_set(False) # We delete the baked action and set the original one back @@ -119,7 +181,7 @@ class ExtractAnimationFBX(publish.Extractor): pair[1].user_clear() bpy.data.actions.remove(pair[1]) - json_filename = f"{instance.name}.json" + json_filename = f"{instance_name}.json" json_path = os.path.join(stagingdir, json_filename) json_dict = { @@ -158,5 +220,5 @@ class ExtractAnimationFBX(publish.Extractor): instance.data["representations"].append(fbx_representation) instance.data["representations"].append(json_representation) - self.log.info("Extracted instance '{}' to: {}".format( - instance.name, fbx_representation)) + self.log.debug("Extracted instance '{}' to: {}".format( + instance.name, fbx_representation)) diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index f2d04f1178..3e8978c8d3 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -11,10 +11,10 @@ from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY -class ExtractLayout(publish.Extractor): +class ExtractLayout(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract a layout.""" - label = "Extract Layout" + label = "Extract Layout (JSON)" hosts = ["blender"] families = ["layout"] optional = True @@ -45,7 +45,7 @@ class ExtractLayout(publish.Extractor): starting_frames.append(curr_frame_range[0]) ending_frames.append(curr_frame_range[1]) else: - self.log.info("Object have no animation.") + self.log.info("Object has no animation.") continue asset_group_name = asset.name @@ -113,11 +113,14 @@ class ExtractLayout(publish.Extractor): return None, n def process(self, instance): + if not self.is_active(instance.data): + return + # Define extract output file path stagingdir = self.staging_dir(instance) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") if "representations" not in instance.data: instance.data["representations"] = [] @@ -125,13 +128,22 @@ class ExtractLayout(publish.Extractor): json_data = [] fbx_files = [] - asset_group = bpy.data.objects[str(instance)] + asset_group = instance.data["transientData"]["instance_node"] fbx_count = 0 project_name = instance.context.data["projectEntity"]["name"] for asset in asset_group.children: metadata = asset.get(AVALON_PROPERTY) + if not metadata: + # Avoid raising error directly if there's just invalid data + # inside the instance; better to log it to the artist + # TODO: This should actually be validated in a validator + self.log.warning( + f"Found content in layout that is not a loaded " + f"asset, skipping: {asset.name_full}" + ) + continue version_id = metadata["parent"] family = metadata["family"] @@ -212,7 +224,11 @@ class ExtractLayout(publish.Extractor): json_data.append(json_element) - json_filename = "{}.json".format(instance.name) + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + instance_name = f"{asset_name}_{subset}" + json_filename = f"{instance_name}.json" + json_path = os.path.join(stagingdir, json_filename) with open(json_path, "w+") as file: @@ -245,5 +261,5 @@ class ExtractLayout(publish.Extractor): } instance.data["representations"].append(fbx_representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, json_representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, json_representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_playblast.py b/openpype/hosts/blender/plugins/publish/extract_playblast.py index 196e75b8cc..a78aa14138 100644 --- a/openpype/hosts/blender/plugins/publish/extract_playblast.py +++ b/openpype/hosts/blender/plugins/publish/extract_playblast.py @@ -9,7 +9,7 @@ from openpype.hosts.blender.api import capture from openpype.hosts.blender.api.lib import maintained_time -class ExtractPlayblast(publish.Extractor): +class ExtractPlayblast(publish.Extractor, publish.OptionalPyblishPluginMixin): """ Extract viewport playblast. @@ -24,9 +24,8 @@ class ExtractPlayblast(publish.Extractor): order = pyblish.api.ExtractorOrder + 0.01 def process(self, instance): - self.log.info("Extracting capture..") - - self.log.info(instance.data) + if not self.is_active(instance.data): + return # get scene fps fps = instance.data.get("fps") @@ -34,14 +33,14 @@ class ExtractPlayblast(publish.Extractor): fps = bpy.context.scene.render.fps instance.data["fps"] = fps - self.log.info(f"fps: {fps}") + self.log.debug(f"fps: {fps}") # If start and end frames cannot be determined, # get them from Blender timeline. start = instance.data.get("frameStart", bpy.context.scene.frame_start) end = instance.data.get("frameEnd", bpy.context.scene.frame_end) - self.log.info(f"start: {start}, end: {end}") + self.log.debug(f"start: {start}, end: {end}") assert end > start, "Invalid time range !" # get cameras @@ -52,10 +51,13 @@ class ExtractPlayblast(publish.Extractor): # get output path stagingdir = self.staging_dir(instance) - filename = instance.name + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + filename = f"{asset_name}_{subset}" + path = os.path.join(stagingdir, filename) - self.log.info(f"Outputting images to {path}") + self.log.debug(f"Outputting images to {path}") project_settings = instance.context.data["project_settings"]["blender"] presets = project_settings["publish"]["ExtractPlayblast"]["presets"] @@ -100,7 +102,7 @@ class ExtractPlayblast(publish.Extractor): frame_collection = collections[0] - self.log.info(f"We found collection of interest {frame_collection}") + self.log.debug(f"Found collection of interest {frame_collection}") instance.data.setdefault("representations", []) diff --git a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py index 65c3627375..e593e0de27 100644 --- a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py @@ -24,13 +24,20 @@ class ExtractThumbnail(publish.Extractor): presets = {} def process(self, instance): - self.log.info("Extracting capture..") + self.log.debug("Extracting capture..") + + if instance.data.get("thumbnailSource"): + self.log.debug("Thumbnail source found, skipping...") + return stagingdir = self.staging_dir(instance) - filename = instance.name + asset_name = instance.data["assetEntity"]["name"] + subset = instance.data["subset"] + filename = f"{asset_name}_{subset}" + path = os.path.join(stagingdir, filename) - self.log.info(f"Outputting images to {path}") + self.log.debug(f"Outputting images to {path}") camera = instance.data.get("review_camera", "AUTO") start = instance.data.get("frameStart", bpy.context.scene.frame_start) @@ -61,7 +68,7 @@ class ExtractThumbnail(publish.Extractor): thumbnail = os.path.basename(self._fix_output_path(path)) - self.log.info(f"thumbnail: {thumbnail}") + self.log.debug(f"thumbnail: {thumbnail}") instance.data.setdefault("representations", []) diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index 27fa4baf28..9f8d20aedc 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -1,17 +1,24 @@ import pyblish.api +from openpype.pipeline.publish import OptionalPyblishPluginMixin from openpype.hosts.blender.api.workio import save_file -class IncrementWorkfileVersion(pyblish.api.ContextPlugin): +class IncrementWorkfileVersion( + pyblish.api.ContextPlugin, + OptionalPyblishPluginMixin +): """Increment current workfile version.""" order = pyblish.api.IntegratorOrder + 0.9 label = "Increment Workfile Version" optional = True hosts = ["blender"] - families = ["animation", "model", "rig", "action", "layout", "blendScene"] + families = ["animation", "model", "rig", "action", "layout", "blendScene", + "pointcache", "render.farm"] def process(self, context): + if not self.is_active(context.data): + return assert all(result["success"] for result in context.data["results"]), ( "Publishing not successful so version is not increased.") @@ -22,4 +29,4 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): save_file(filepath, copy=False) - self.log.info('Incrementing script version') + self.log.debug('Incrementing blender workfile version') diff --git a/openpype/hosts/blender/plugins/publish/integrate_animation.py b/openpype/hosts/blender/plugins/publish/integrate_animation.py index d9a85bc79b..623da9c585 100644 --- a/openpype/hosts/blender/plugins/publish/integrate_animation.py +++ b/openpype/hosts/blender/plugins/publish/integrate_animation.py @@ -1,9 +1,13 @@ import json import pyblish.api +from openpype.pipeline.publish import OptionalPyblishPluginMixin -class IntegrateAnimation(pyblish.api.InstancePlugin): +class IntegrateAnimation( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin, +): """Generate a JSON file for animation.""" label = "Integrate Animation" @@ -13,7 +17,7 @@ class IntegrateAnimation(pyblish.api.InstancePlugin): families = ["setdress"] def process(self, instance): - self.log.info("Integrate Animation") + self.log.debug("Integrate Animation") representation = instance.data.get('representations')[0] json_path = representation.get('publishedFiles')[0] diff --git a/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py index 48c267fd18..9b6e513897 100644 --- a/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py +++ b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py @@ -5,10 +5,15 @@ import bpy import pyblish.api import openpype.hosts.blender.api.action -from openpype.pipeline.publish import ValidateContentsOrder +from openpype.pipeline.publish import ( + ValidateContentsOrder, + PublishValidationError, + OptionalPyblishPluginMixin +) -class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin): +class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Camera must have a keyframe at frame 0. Unreal shifts the first keyframe to frame 0. Forcing the camera to have @@ -40,8 +45,12 @@ class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin): return invalid def process(self, instance): + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( - f"Camera must have a keyframe at frame 0: {invalid}" + names = ", ".join(obj.name for obj in invalid) + raise PublishValidationError( + f"Camera must have a keyframe at frame 0: {names}" ) diff --git a/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py new file mode 100644 index 0000000000..bb243f08cc --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_deadline_publish.py @@ -0,0 +1,47 @@ +import os + +import bpy + +import pyblish.api +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.hosts.blender.api.render_lib import prepare_rendering + + +class ValidateDeadlinePublish(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates Render File Directory is + not the same in every submission + """ + + order = ValidateContentsOrder + families = ["render"] + hosts = ["blender"] + label = "Validate Render Output for Deadline" + optional = True + actions = [RepairAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + filepath = bpy.data.filepath + file = os.path.basename(filepath) + filename, ext = os.path.splitext(file) + if filename not in bpy.context.scene.render.filepath: + raise PublishValidationError( + "Render output folder " + "doesn't match the blender scene name! " + "Use Repair action to " + "fix the folder file path." + ) + + @classmethod + def repair(cls, instance): + container = instance.data["transientData"]["instance_node"] + prepare_rendering(container) + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + cls.log.debug("Reset the render output folder...") diff --git a/openpype/hosts/blender/plugins/publish/validate_file_saved.py b/openpype/hosts/blender/plugins/publish/validate_file_saved.py new file mode 100644 index 0000000000..442f856e05 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_file_saved.py @@ -0,0 +1,61 @@ +import bpy + +import pyblish.api + +from openpype.pipeline.publish import ( + OptionalPyblishPluginMixin, + PublishValidationError +) + + +class SaveWorkfileAction(pyblish.api.Action): + """Save Workfile.""" + label = "Save Workfile" + on = "failed" + icon = "save" + + def process(self, context, plugin): + bpy.ops.wm.avalon_workfiles() + + +class ValidateFileSaved(pyblish.api.ContextPlugin, + OptionalPyblishPluginMixin): + """Validate that the workfile has been saved.""" + + order = pyblish.api.ValidatorOrder - 0.01 + hosts = ["blender"] + label = "Validate File Saved" + optional = False + exclude_families = [] + actions = [SaveWorkfileAction] + + def process(self, context): + if not self.is_active(context.data): + return + + if not context.data["currentFile"]: + # File has not been saved at all and has no filename + raise PublishValidationError( + "Current file is empty. Save the file before continuing." + ) + + # Do not validate workfile has unsaved changes if only instances + # present of families that should be excluded + families = { + instance.data["family"] for instance in context + # Consider only enabled instances + if instance.data.get("publish", True) + and instance.data.get("active", True) + } + + def is_excluded(family): + return any(family in exclude_family + for exclude_family in self.exclude_families) + + if all(is_excluded(family) for family in families): + self.log.debug("Only excluded families found, skipping workfile " + "unsaved changes validation..") + return + + if bpy.data.is_dirty: + raise PublishValidationError("Workfile has unsaved changes.") diff --git a/openpype/hosts/blender/plugins/publish/validate_instance_empty.py b/openpype/hosts/blender/plugins/publish/validate_instance_empty.py new file mode 100644 index 0000000000..51a1dcf6ca --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_instance_empty.py @@ -0,0 +1,19 @@ +import pyblish.api +from openpype.pipeline.publish import PublishValidationError + + +class ValidateInstanceEmpty(pyblish.api.InstancePlugin): + """Validator to verify that the instance is not empty""" + + order = pyblish.api.ValidatorOrder - 0.01 + hosts = ["blender"] + families = ["model", "pointcache", "rig", "camera" "layout", "blendScene"] + label = "Validate Instance is not Empty" + optional = False + + def process(self, instance): + # Members are collected by `collect_instance` so we only need to check + # whether any member is included. The instance node will be included + # as a member as well, hence we will check for at least 2 members + if len(instance) < 2: + raise PublishValidationError(f"Instance {instance.name} is empty.") diff --git a/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py b/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py index edf47193be..060bccbd04 100644 --- a/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py +++ b/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py @@ -4,17 +4,24 @@ import bpy import pyblish.api -from openpype.pipeline.publish import ValidateContentsOrder +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, + PublishValidationError +) import openpype.hosts.blender.api.action -class ValidateMeshHasUvs(pyblish.api.InstancePlugin): +class ValidateMeshHasUvs( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin, +): """Validate that the current mesh has UV's.""" order = ValidateContentsOrder hosts = ["blender"] families = ["model"] - label = "Mesh Has UV's" + label = "Mesh Has UVs" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] optional = True @@ -49,8 +56,11 @@ class ValidateMeshHasUvs(pyblish.api.InstancePlugin): return invalid def process(self, instance): + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( + raise PublishValidationError( f"Meshes found in instance without valid UV's: {invalid}" ) diff --git a/openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py b/openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py index 618feb95c1..7f77bbe38c 100644 --- a/openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py +++ b/openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py @@ -4,11 +4,16 @@ import bpy import pyblish.api -from openpype.pipeline.publish import ValidateContentsOrder +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, + PublishValidationError +) import openpype.hosts.blender.api.action -class ValidateMeshNoNegativeScale(pyblish.api.Validator): +class ValidateMeshNoNegativeScale(pyblish.api.Validator, + OptionalPyblishPluginMixin): """Ensure that meshes don't have a negative scale.""" order = ValidateContentsOrder @@ -27,8 +32,12 @@ class ValidateMeshNoNegativeScale(pyblish.api.Validator): return invalid def process(self, instance): + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( - f"Meshes found in instance with negative scale: {invalid}" + names = ", ".join(obj.name for obj in invalid) + raise PublishValidationError( + f"Meshes found in instance with negative scale: {names}" ) diff --git a/openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py b/openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py index 1a98ec4c1d..caf555b535 100644 --- a/openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py +++ b/openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py @@ -5,10 +5,15 @@ import bpy import pyblish.api import openpype.hosts.blender.api.action -from openpype.pipeline.publish import ValidateContentsOrder +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, + PublishValidationError +) -class ValidateNoColonsInName(pyblish.api.InstancePlugin): +class ValidateNoColonsInName(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """There cannot be colons in names Object or bone names cannot include colons. Other software do not @@ -36,8 +41,12 @@ class ValidateNoColonsInName(pyblish.api.InstancePlugin): return invalid def process(self, instance): + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( - f"Objects found with colon in name: {invalid}" + names = ", ".join(obj.name for obj in invalid) + raise PublishValidationError( + f"Objects found with colon in name: {names}" ) diff --git a/openpype/hosts/blender/plugins/publish/validate_object_mode.py b/openpype/hosts/blender/plugins/publish/validate_object_mode.py index ac60e00f89..ab5f4bb467 100644 --- a/openpype/hosts/blender/plugins/publish/validate_object_mode.py +++ b/openpype/hosts/blender/plugins/publish/validate_object_mode.py @@ -3,10 +3,17 @@ from typing import List import bpy import pyblish.api +from openpype.pipeline.publish import ( + OptionalPyblishPluginMixin, + PublishValidationError +) import openpype.hosts.blender.api.action -class ValidateObjectIsInObjectMode(pyblish.api.InstancePlugin): +class ValidateObjectIsInObjectMode( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin, +): """Validate that the objects in the instance are in Object Mode.""" order = pyblish.api.ValidatorOrder - 0.01 @@ -25,8 +32,12 @@ class ValidateObjectIsInObjectMode(pyblish.api.InstancePlugin): return invalid def process(self, instance): + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( - f"Object found in instance is not in Object Mode: {invalid}" + names = ", ".join(obj.name for obj in invalid) + raise PublishValidationError( + f"Object found in instance is not in Object Mode: {names}" ) diff --git a/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py b/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py new file mode 100644 index 0000000000..86d1fcc681 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py @@ -0,0 +1,26 @@ +import bpy + +import pyblish.api + +from openpype.pipeline.publish import ( + OptionalPyblishPluginMixin, + PublishValidationError +) + + +class ValidateRenderCameraIsSet(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate that there is a camera set as active for rendering.""" + + order = pyblish.api.ValidatorOrder + hosts = ["blender"] + families = ["render"] + label = "Validate Render Camera Is Set" + optional = False + + def process(self, instance): + if not self.is_active(instance.data): + return + + if not bpy.context.scene.camera: + raise PublishValidationError("No camera is active for rendering.") diff --git a/openpype/hosts/blender/plugins/publish/validate_transform_zero.py b/openpype/hosts/blender/plugins/publish/validate_transform_zero.py index 66ef731e6e..1fb9535ee4 100644 --- a/openpype/hosts/blender/plugins/publish/validate_transform_zero.py +++ b/openpype/hosts/blender/plugins/publish/validate_transform_zero.py @@ -6,10 +6,15 @@ import bpy import pyblish.api import openpype.hosts.blender.api.action -from openpype.pipeline.publish import ValidateContentsOrder +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, + PublishValidationError +) -class ValidateTransformZero(pyblish.api.InstancePlugin): +class ValidateTransformZero(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Transforms can't have any values To solve this issue, try freezing the transforms. So long @@ -38,9 +43,13 @@ class ValidateTransformZero(pyblish.api.InstancePlugin): return invalid def process(self, instance): + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( - "Object found in instance has not" - f" transform to zero: {invalid}" + names = ", ".join(obj.name for obj in invalid) + raise PublishValidationError( + "Objects found in instance which do not" + f" have transform set to zero: {names}" ) diff --git a/openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py b/openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py index c815c1edd4..875f15fcc5 100644 --- a/openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py +++ b/openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py @@ -1,6 +1,8 @@ import os import pyblish.api +from openpype.client import get_asset_name_identifier + class CollectCelactionInstances(pyblish.api.ContextPlugin): """ Adds the celaction render instances """ @@ -17,8 +19,10 @@ class CollectCelactionInstances(pyblish.api.ContextPlugin): asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] + asset_name = get_asset_name_identifier(asset_entity) + shared_instance_data = { - "asset": asset_entity["name"], + "asset": asset_name, "frameStart": asset_entity["data"]["frameStart"], "frameEnd": asset_entity["data"]["frameEnd"], "handleStart": asset_entity["data"]["handleStart"], diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index 338833b449..ca4eab0f63 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -48,7 +48,6 @@ class LoadClip(opfapi.ClipLoader): self.fpd = fproject.current_workspace.desktop # load clip to timeline and get main variables - namespace = namespace version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index ca43b94ee9..1f3a017d72 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -45,7 +45,6 @@ class LoadClipBatch(opfapi.ClipLoader): self.batch = options.get("batch") or flame.batch # load clip to timeline and get main variables - namespace = namespace version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 23fdf5e785..e14f960a2b 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -325,7 +325,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): def _create_shot_instance(self, context, clip_name, **data): master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") - asset = data.get("asset") if not master_layer: return diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py index f8cfa9e963..20ac048986 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py @@ -1,5 +1,6 @@ import pyblish.api +from openpype.client import get_asset_name_identifier import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export from openpype.pipeline.create import get_subset_name @@ -33,13 +34,15 @@ class CollecTimelineOTIO(pyblish.api.ContextPlugin): project_settings=context.data["project_settings"] ) + asset_name = get_asset_name_identifier(asset_doc) + # adding otio timeline to context with opfapi.maintained_segment_selection(sequence) as selected_seg: otio_timeline = flame_export.create_otio_timeline(sequence) instance_data = { "name": subset_name, - "asset": asset_doc["name"], + "asset": asset_name, "subset": subset_name, "family": "workfile", "families": [] diff --git a/openpype/hosts/fusion/addon.py b/openpype/hosts/fusion/addon.py index 45683cfbde..8343f3c79d 100644 --- a/openpype/hosts/fusion/addon.py +++ b/openpype/hosts/fusion/addon.py @@ -60,8 +60,9 @@ class FusionAddon(OpenPypeModule, IHostAddon): return [] return [os.path.join(FUSION_HOST_DIR, "hooks")] - def add_implementation_envs(self, env, _app): + def add_implementation_envs(self, env, app): # Set default values if are not already set via settings + defaults = {"OPENPYPE_LOG_NO_COLORS": "Yes"} for key, value in defaults.items(): if not env.get(key): diff --git a/openpype/hosts/fusion/api/__init__.py b/openpype/hosts/fusion/api/__init__.py index dba55a98d9..aabc624016 100644 --- a/openpype/hosts/fusion/api/__init__.py +++ b/openpype/hosts/fusion/api/__init__.py @@ -3,9 +3,7 @@ from .pipeline import ( ls, imprint_container, - parse_container, - list_instances, - remove_instance + parse_container ) from .lib import ( @@ -22,6 +20,7 @@ from .menu import launch_openpype_menu __all__ = [ # pipeline + "FusionHost", "ls", "imprint_container", @@ -32,6 +31,7 @@ __all__ = [ "update_frame_range", "set_asset_framerange", "get_current_comp", + "get_bmd_library", "comp_lock_and_undo_chunk", # menu diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index d96557571b..85f9c54a73 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -181,80 +181,6 @@ def validate_comp_prefs(comp=None, force_repair=False): dialog.setStyleSheet(load_stylesheet()) -def switch_item(container, - asset_name=None, - subset_name=None, - representation_name=None): - """Switch container asset, subset or representation of a container by name. - - It'll always switch to the latest version - of course a different - approach could be implemented. - - Args: - container (dict): data of the item to switch with - asset_name (str): name of the asset - subset_name (str): name of the subset - representation_name (str): name of the representation - - Returns: - dict - - """ - - if all(not x for x in [asset_name, subset_name, representation_name]): - raise ValueError("Must have at least one change provided to switch.") - - # Collect any of current asset, subset and representation if not provided - # so we can use the original name from those. - project_name = get_current_project_name() - if any(not x for x in [asset_name, subset_name, representation_name]): - repre_id = container["representation"] - representation = get_representation_by_id(project_name, repre_id) - repre_parent_docs = get_representation_parents( - project_name, representation) - if repre_parent_docs: - version, subset, asset, _ = repre_parent_docs - else: - version = subset = asset = None - - if asset_name is None: - asset_name = asset["name"] - - if subset_name is None: - subset_name = subset["name"] - - if representation_name is None: - representation_name = representation["name"] - - # Find the new one - asset = get_asset_by_name(project_name, asset_name, fields=["_id"]) - assert asset, ("Could not find asset in the database with the name " - "'%s'" % asset_name) - - subset = get_subset_by_name( - project_name, subset_name, asset["_id"], fields=["_id"] - ) - assert subset, ("Could not find subset in the database with the name " - "'%s'" % subset_name) - - version = get_last_version_by_subset_id( - project_name, subset["_id"], fields=["_id"] - ) - assert version, "Could not find a version for {}.{}".format( - asset_name, subset_name - ) - - representation = get_representation_by_name( - project_name, representation_name, version["_id"] - ) - assert representation, ("Could not find representation in the database " - "with the name '%s'" % representation_name) - - switch_container(container, representation) - - return representation - - @contextlib.contextmanager def maintained_selection(comp=None): """Reset comp selection from before the context after the context""" @@ -354,7 +280,11 @@ def get_current_comp(): @contextlib.contextmanager -def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"): +def comp_lock_and_undo_chunk( + comp, + undo_queue_name="Script CMD", + keep_undo=True, +): """Lock comp and open an undo chunk during the context""" try: comp.Lock() @@ -362,4 +292,4 @@ def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"): yield finally: comp.Unlock() - comp.EndUndo() + comp.EndUndo(keep_undo) diff --git a/openpype/hosts/fusion/api/menu.py b/openpype/hosts/fusion/api/menu.py index 50250a6656..0b9ad1a43b 100644 --- a/openpype/hosts/fusion/api/menu.py +++ b/openpype/hosts/fusion/api/menu.py @@ -1,3 +1,4 @@ +import os import sys from qtpy import QtWidgets, QtCore, QtGui @@ -18,6 +19,10 @@ from openpype.resources import get_openpype_icon_filepath from .pipeline import FusionEventHandler from .pulse import FusionPulse + +MENU_LABEL = os.environ["AVALON_LABEL"] + + self = sys.modules[__name__] self.menu = None @@ -26,7 +31,7 @@ class OpenPypeMenu(QtWidgets.QWidget): def __init__(self, *args, **kwargs): super(OpenPypeMenu, self).__init__(*args, **kwargs) - self.setObjectName("OpenPypeMenu") + self.setObjectName(f"{MENU_LABEL}Menu") icon_path = get_openpype_icon_filepath() icon = QtGui.QIcon(icon_path) @@ -41,7 +46,7 @@ class OpenPypeMenu(QtWidgets.QWidget): | QtCore.Qt.WindowStaysOnTopHint ) self.render_mode_widget = None - self.setWindowTitle("OpenPype") + self.setWindowTitle(MENU_LABEL) asset_label = QtWidgets.QLabel("Context", self) asset_label.setStyleSheet( diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index a768a3f0f8..a886086758 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -287,49 +287,6 @@ def parse_container(tool): return container -# TODO: Function below is currently unused prototypes -def list_instances(creator_id=None): - """Return created instances in current workfile which will be published. - Returns: - (list) of dictionaries matching instances format - """ - - comp = get_current_comp() - tools = comp.GetToolList(False).values() - - instance_signature = { - "id": "pyblish.avalon.instance", - "identifier": creator_id - } - instances = [] - for tool in tools: - - data = tool.GetData('openpype') - if not isinstance(data, dict): - continue - - if data.get("id") != instance_signature["id"]: - continue - - if creator_id and data.get("identifier") != creator_id: - continue - - instances.append(tool) - - return instances - - -# TODO: Function below is currently unused prototypes -def remove_instance(instance): - """Remove instance from current workfile. - - Args: - instance (dict): instance representation from subsetmanager model - """ - # Assume instance is a Fusion tool directly - instance["tool"].Delete() - - class FusionEventThread(QtCore.QThread): """QThread which will periodically ping Fusion app for any events. The fusion.UIManager must be set up to be notified of events before they'll diff --git a/openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py b/openpype/hosts/fusion/deploy/MenuScripts/launch_menu.py similarity index 66% rename from openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py rename to openpype/hosts/fusion/deploy/MenuScripts/launch_menu.py index 685e58d58f..1c58ee50e4 100644 --- a/openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py +++ b/openpype/hosts/fusion/deploy/MenuScripts/launch_menu.py @@ -1,6 +1,19 @@ import os import sys +if sys.version_info < (3, 7): + # hack to handle discrepancy between distributed libraries and Python 3.6 + # mostly because wrong version of urllib3 + # TODO remove when not necessary + from openpype import PACKAGE_DIR + FUSION_HOST_DIR = os.path.join(PACKAGE_DIR, "hosts", "fusion") + + vendor_path = os.path.join(FUSION_HOST_DIR, "vendor") + if vendor_path not in sys.path: + sys.path.insert(0, vendor_path) + + print(f"Added vendorized libraries from {vendor_path}") + from openpype.lib import Logger from openpype.pipeline import ( install_host, diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py deleted file mode 100644 index 1a0a9911ea..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all selected backgrounds to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Selected Backgrounds to 32bit'): - tools = comp.GetToolList(True, "Background").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py deleted file mode 100644 index c2eea505e5..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all backgrounds to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Backgrounds to 32bit'): - tools = comp.GetToolList(False, "Background").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py deleted file mode 100644 index 2118767f4d..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all selected loaders to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Selected Loaders to 32bit'): - tools = comp.GetToolList(True, "Loader").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py deleted file mode 100644 index 7dd1f66a5e..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all loaders to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Loaders to 32bit'): - tools = comp.GetToolList(False, "Loader").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py deleted file mode 100644 index 87322235f5..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py +++ /dev/null @@ -1,200 +0,0 @@ -import os -import sys -import glob -import logging - -from qtpy import QtWidgets, QtCore - -import qtawesome as qta - -from openpype.client import get_assets -from openpype import style -from openpype.pipeline import ( - install_host, - get_current_project_name, -) -from openpype.hosts.fusion import api -from openpype.pipeline.context_tools import get_workdir_from_session - -log = logging.getLogger("Fusion Switch Shot") - - -class App(QtWidgets.QWidget): - - def __init__(self, parent=None): - - ################################################ - # |---------------------| |------------------| # - # |Comp | |Asset | # - # |[..][ v]| |[ v]| # - # |---------------------| |------------------| # - # | Update existing comp [ ] | # - # |------------------------------------------| # - # | Switch | # - # |------------------------------------------| # - ################################################ - - QtWidgets.QWidget.__init__(self, parent) - - layout = QtWidgets.QVBoxLayout() - - # Comp related input - comp_hlayout = QtWidgets.QHBoxLayout() - comp_label = QtWidgets.QLabel("Comp file") - comp_label.setFixedWidth(50) - comp_box = QtWidgets.QComboBox() - - button_icon = qta.icon("fa.folder", color="white") - open_from_dir = QtWidgets.QPushButton() - open_from_dir.setIcon(button_icon) - - comp_box.setFixedHeight(25) - open_from_dir.setFixedWidth(25) - open_from_dir.setFixedHeight(25) - - comp_hlayout.addWidget(comp_label) - comp_hlayout.addWidget(comp_box) - comp_hlayout.addWidget(open_from_dir) - - # Asset related input - asset_hlayout = QtWidgets.QHBoxLayout() - asset_label = QtWidgets.QLabel("Shot") - asset_label.setFixedWidth(50) - - asset_box = QtWidgets.QComboBox() - asset_box.setLineEdit(QtWidgets.QLineEdit()) - asset_box.setFixedHeight(25) - - refresh_icon = qta.icon("fa.refresh", color="white") - refresh_btn = QtWidgets.QPushButton() - refresh_btn.setIcon(refresh_icon) - - asset_box.setFixedHeight(25) - refresh_btn.setFixedWidth(25) - refresh_btn.setFixedHeight(25) - - asset_hlayout.addWidget(asset_label) - asset_hlayout.addWidget(asset_box) - asset_hlayout.addWidget(refresh_btn) - - # Options - options = QtWidgets.QHBoxLayout() - options.setAlignment(QtCore.Qt.AlignLeft) - - current_comp_check = QtWidgets.QCheckBox() - current_comp_check.setChecked(True) - current_comp_label = QtWidgets.QLabel("Use current comp") - - options.addWidget(current_comp_label) - options.addWidget(current_comp_check) - - accept_btn = QtWidgets.QPushButton("Switch") - - layout.addLayout(options) - layout.addLayout(comp_hlayout) - layout.addLayout(asset_hlayout) - layout.addWidget(accept_btn) - - self._open_from_dir = open_from_dir - self._comps = comp_box - self._assets = asset_box - self._use_current = current_comp_check - self._accept_btn = accept_btn - self._refresh_btn = refresh_btn - - self.setWindowTitle("Fusion Switch Shot") - self.setLayout(layout) - - self.resize(260, 140) - self.setMinimumWidth(260) - self.setFixedHeight(140) - - self.connections() - - # Update ui to correct state - self._on_use_current_comp() - self._refresh() - - def connections(self): - self._use_current.clicked.connect(self._on_use_current_comp) - self._open_from_dir.clicked.connect(self._on_open_from_dir) - self._refresh_btn.clicked.connect(self._refresh) - self._accept_btn.clicked.connect(self._on_switch) - - def _on_use_current_comp(self): - state = self._use_current.isChecked() - self._open_from_dir.setEnabled(not state) - self._comps.setEnabled(not state) - - def _on_open_from_dir(self): - - start_dir = get_workdir_from_session() - comp_file, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Choose comp", start_dir) - - if not comp_file: - return - - # Create completer - self.populate_comp_box([comp_file]) - self._refresh() - - def _refresh(self): - # Clear any existing items - self._assets.clear() - - asset_names = self.collect_asset_names() - completer = QtWidgets.QCompleter(asset_names) - - self._assets.setCompleter(completer) - self._assets.addItems(asset_names) - - def _on_switch(self): - - if not self._use_current.isChecked(): - file_name = self._comps.itemData(self._comps.currentIndex()) - else: - comp = api.get_current_comp() - file_name = comp.GetAttrs("COMPS_FileName") - - asset = self._assets.currentText() - - import colorbleed.scripts.fusion_switch_shot as switch_shot - switch_shot.switch(asset_name=asset, filepath=file_name, new=True) - - def collect_slap_comps(self, directory): - items = glob.glob("{}/*.comp".format(directory)) - return items - - def collect_asset_names(self): - project_name = get_current_project_name() - asset_docs = get_assets(project_name, fields=["name"]) - asset_names = { - asset_doc["name"] - for asset_doc in asset_docs - } - return list(asset_names) - - def populate_comp_box(self, files): - """Ensure we display the filename only but the path is stored as well - - Args: - files (list): list of full file path [path/to/item/item.ext,] - - Returns: - None - """ - - for f in files: - filename = os.path.basename(f) - self._comps.addItem(filename, userData=f) - - -if __name__ == '__main__': - install_host(api) - - app = QtWidgets.QApplication(sys.argv) - window = App() - window.setStyleSheet(style.load_stylesheet()) - window.show() - sys.exit(app.exec_()) diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py deleted file mode 100644 index 3d2d1ecfa6..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Forces Fusion to 'retrigger' the Loader to update. - -Warning: - This might change settings like 'Reverse', 'Loop', trims and other - settings of the Loader. So use this at your own risk. - -""" -from openpype.hosts.fusion.api.pipeline import ( - get_current_comp, - comp_lock_and_undo_chunk -) - - -def update_loader_ranges(): - comp = get_current_comp() - with comp_lock_and_undo_chunk(comp, "Reload clip time ranges"): - tools = comp.GetToolList(True, "Loader").values() - for tool in tools: - - # Get tool attributes - tool_a = tool.GetAttrs() - clipTable = tool_a['TOOLST_Clip_Name'] - altclipTable = tool_a['TOOLST_AltClip_Name'] - startTime = tool_a['TOOLNT_Clip_Start'] - old_global_in = tool.GlobalIn[comp.CurrentTime] - - # Reapply - for index, _ in clipTable.items(): - time = startTime[index] - tool.Clip[time] = tool.Clip[time] - - for index, _ in altclipTable.items(): - time = startTime[index] - tool.ProxyFilename[time] = tool.ProxyFilename[time] - - tool.GlobalIn[comp.CurrentTime] = old_global_in - - -if __name__ == '__main__': - update_loader_ranges() diff --git a/openpype/hosts/fusion/deploy/ayon/Config/menu.fu b/openpype/hosts/fusion/deploy/ayon/Config/menu.fu new file mode 100644 index 0000000000..c968a1bb3d --- /dev/null +++ b/openpype/hosts/fusion/deploy/ayon/Config/menu.fu @@ -0,0 +1,60 @@ +{ + Action + { + ID = "AYON_Menu", + Category = "AYON", + Name = "AYON Menu", + + Targets = + { + Composition = + { + Execute = _Lua [=[ + local scriptPath = app:MapPath("AYON:../MenuScripts/launch_menu.py") + if bmd.fileexists(scriptPath) == false then + print("[AYON Error] Can't run file: " .. scriptPath) + else + target:RunScript(scriptPath) + end + ]=], + }, + }, + }, + Action + { + ID = "AYON_Install_PySide2", + Category = "AYON", + Name = "Install PySide2", + + Targets = + { + Composition = + { + Execute = _Lua [=[ + local scriptPath = app:MapPath("AYON:../MenuScripts/install_pyside2.py") + if bmd.fileexists(scriptPath) == false then + print("[AYON Error] Can't run file: " .. scriptPath) + else + target:RunScript(scriptPath) + end + ]=], + }, + }, + }, + Menus + { + Target = "ChildFrame", + + Before "Help" + { + Sub "AYON" + { + "AYON_Menu{}", + "_", + Sub "Admin" { + "AYON_Install_PySide2{}" + } + } + }, + }, +} diff --git a/openpype/hosts/fusion/deploy/ayon/fusion_shared.prefs b/openpype/hosts/fusion/deploy/ayon/fusion_shared.prefs new file mode 100644 index 0000000000..9c67af7db9 --- /dev/null +++ b/openpype/hosts/fusion/deploy/ayon/fusion_shared.prefs @@ -0,0 +1,19 @@ +{ +Locked = true, +Global = { + Paths = { + Map = { + ["AYON:"] = "$(OPENPYPE_FUSION)/deploy/ayon", + ["Config:"] = "UserPaths:Config;AYON:Config", + ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts", + }, + }, + Script = { + PythonVersion = 3, + Python3Forced = true + }, + UserInterface = { + Language = "en_US" + }, + }, +} diff --git a/openpype/hosts/fusion/deploy/Config/openpype_menu.fu b/openpype/hosts/fusion/deploy/openpype/Config/menu.fu similarity index 87% rename from openpype/hosts/fusion/deploy/Config/openpype_menu.fu rename to openpype/hosts/fusion/deploy/openpype/Config/menu.fu index 8b8d448259..85134d2c62 100644 --- a/openpype/hosts/fusion/deploy/Config/openpype_menu.fu +++ b/openpype/hosts/fusion/deploy/openpype/Config/menu.fu @@ -10,7 +10,7 @@ Composition = { Execute = _Lua [=[ - local scriptPath = app:MapPath("OpenPype:MenuScripts/openpype_menu.py") + local scriptPath = app:MapPath("OpenPype:../MenuScripts/launch_menu.py") if bmd.fileexists(scriptPath) == false then print("[OpenPype Error] Can't run file: " .. scriptPath) else @@ -31,7 +31,7 @@ Composition = { Execute = _Lua [=[ - local scriptPath = app:MapPath("OpenPype:MenuScripts/install_pyside2.py") + local scriptPath = app:MapPath("OpenPype:../MenuScripts/install_pyside2.py") if bmd.fileexists(scriptPath) == false then print("[OpenPype Error] Can't run file: " .. scriptPath) else diff --git a/openpype/hosts/fusion/deploy/fusion_shared.prefs b/openpype/hosts/fusion/deploy/openpype/fusion_shared.prefs similarity index 64% rename from openpype/hosts/fusion/deploy/fusion_shared.prefs rename to openpype/hosts/fusion/deploy/openpype/fusion_shared.prefs index b379ea7c66..0035a38990 100644 --- a/openpype/hosts/fusion/deploy/fusion_shared.prefs +++ b/openpype/hosts/fusion/deploy/openpype/fusion_shared.prefs @@ -3,9 +3,9 @@ Locked = true, Global = { Paths = { Map = { - ["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy", + ["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy/openpype", ["Config:"] = "UserPaths:Config;OpenPype:Config", - ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts;OpenPype:Scripts", + ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts", }, }, Script = { diff --git a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py index 66b0f803aa..59053ba62a 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py @@ -2,6 +2,7 @@ import os import shutil import platform from pathlib import Path +from openpype import AYON_SERVER_ENABLED from openpype.hosts.fusion import ( FUSION_HOST_DIR, FUSION_VERSIONS_DICT, @@ -161,6 +162,13 @@ class FusionCopyPrefsPrelaunch(PreLaunchHook): # profile directory variables to customize Fusion # to define where it can read custom scripts and tools from master_prefs_variable = f"FUSION{profile_version}_MasterPrefs" - master_prefs = Path(FUSION_HOST_DIR, "deploy", "fusion_shared.prefs") + + if AYON_SERVER_ENABLED: + master_prefs = Path( + FUSION_HOST_DIR, "deploy", "ayon", "fusion_shared.prefs") + else: + master_prefs = Path( + FUSION_HOST_DIR, "deploy", "openpype", "fusion_shared.prefs") + self.log.info(f"Setting {master_prefs_variable}: {master_prefs}") self.launch_context.env[master_prefs_variable] = str(master_prefs) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 04898d0a45..ecf36abdd2 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -30,10 +30,6 @@ class CreateSaver(NewCreator): instance_attributes = [ "reviewable" ] - default_variants = [ - "Main", - "Mask" - ] # TODO: This should be renamed together with Nuke so it is aligned temp_rendering_path_template = ( @@ -73,8 +69,6 @@ class CreateSaver(NewCreator): # TODO Is this needed? saver[file_format]["SaveAlpha"] = 1 - self._imprint(saver, instance_data) - # Register the CreatedInstance instance = CreatedInstance( family=self.family, @@ -82,6 +76,8 @@ class CreateSaver(NewCreator): data=instance_data, creator=self, ) + data = instance.data_to_store() + self._imprint(saver, data) # Insert the transient data instance.transient_data["tool"] = saver @@ -127,6 +123,9 @@ class CreateSaver(NewCreator): 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 @@ -150,9 +149,7 @@ class CreateSaver(NewCreator): # get frame padding from anatomy templates anatomy = Anatomy() - frame_padding = int( - anatomy.templates["render"].get("frame_padding", 4) - ) + frame_padding = anatomy.templates["frame_padding"] # Subset change detected workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) @@ -166,7 +163,8 @@ class CreateSaver(NewCreator): filepath = self.temp_rendering_path_template.format( **formatting_data) - tool["Clip"] = os.path.normpath(filepath) + comp = get_current_comp() + tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath)) # Rename tool if tool.Name != subset: @@ -192,6 +190,10 @@ class CreateSaver(NewCreator): 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_pre_create_attr_defs(self): @@ -250,11 +252,7 @@ class CreateSaver(NewCreator): label="Review", ) - def apply_settings( - self, - project_settings, - system_settings - ): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" # plugin settings diff --git a/openpype/hosts/fusion/plugins/create/create_workfile.py b/openpype/hosts/fusion/plugins/create/create_workfile.py index 8acaaa172f..4092086ea4 100644 --- a/openpype/hosts/fusion/plugins/create/create_workfile.py +++ b/openpype/hosts/fusion/plugins/create/create_workfile.py @@ -1,6 +1,7 @@ from openpype.hosts.fusion.api import ( get_current_comp ) +from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import ( AutoCreator, @@ -68,6 +69,13 @@ class FusionWorkfileCreator(AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name + if existing_instance is None: + existing_instance_asset = None + elif AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance["folderPath"] + else: + existing_instance_asset = existing_instance["asset"] + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -75,10 +83,13 @@ class FusionWorkfileCreator(AutoCreator): project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None @@ -91,7 +102,7 @@ class FusionWorkfileCreator(AutoCreator): self._add_instance_to_context(new_instance) elif ( - existing_instance["asset"] != asset_name + existing_instance_asset != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -99,6 +110,9 @@ class FusionWorkfileCreator(AutoCreator): self.default_variant, task_name, asset_doc, project_name, host_name ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/fusion/plugins/load/actions.py b/openpype/hosts/fusion/plugins/load/actions.py index f83ab433ee..94ba361b50 100644 --- a/openpype/hosts/fusion/plugins/load/actions.py +++ b/openpype/hosts/fusion/plugins/load/actions.py @@ -11,6 +11,7 @@ class FusionSetFrameRangeLoader(load.LoaderPlugin): families = ["animation", "camera", "imagesequence", + "render", "yeticache", "pointcache", "render"] @@ -46,6 +47,7 @@ class FusionSetFrameRangeWithHandlesLoader(load.LoaderPlugin): families = ["animation", "camera", "imagesequence", + "render", "yeticache", "pointcache", "render"] diff --git a/openpype/hosts/fusion/plugins/load/load_sequence.py b/openpype/hosts/fusion/plugins/load/load_sequence.py index 20be5faaba..4401af97eb 100644 --- a/openpype/hosts/fusion/plugins/load/load_sequence.py +++ b/openpype/hosts/fusion/plugins/load/load_sequence.py @@ -161,7 +161,7 @@ class FusionLoadSequence(load.LoaderPlugin): with comp_lock_and_undo_chunk(comp, "Create Loader"): args = (-32768, -32768) tool = comp.AddTool("Loader", *args) - tool["Clip"] = path + tool["Clip"] = comp.ReverseMapPath(path) # Set global in point to start frame (if in version.data) start = self._get_start(context["version"], tool) @@ -244,7 +244,7 @@ class FusionLoadSequence(load.LoaderPlugin): "TimeCodeOffset", ), ): - tool["Clip"] = path + tool["Clip"] = comp.ReverseMapPath(path) # Set the global in to the start frame of the sequence global_in_changed = loader_shift(tool, start, relative=False) diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py new file mode 100644 index 0000000000..4f1813a646 --- /dev/null +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -0,0 +1,87 @@ +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.fusion.api import ( + imprint_container, + get_current_comp, + comp_lock_and_undo_chunk +) +from openpype.hosts.fusion.api.lib import get_fusion_module + + +class FusionLoadUSD(load.LoaderPlugin): + """Load USD into Fusion + + Support for USD was added since Fusion 18.5 + """ + + families = ["*"] + representations = ["*"] + extensions = {"usd", "usda", "usdz"} + + label = "Load USD" + order = -10 + icon = "code-fork" + color = "orange" + + tool_type = "uLoader" + + @classmethod + def apply_settings(cls, project_settings, system_settings): + super(FusionLoadUSD, cls).apply_settings(project_settings, + system_settings) + if cls.enabled: + # Enable only in Fusion 18.5+ + fusion = get_fusion_module() + version = fusion.GetVersion() + major = version[1] + minor = version[2] + is_usd_supported = (major, minor) >= (18, 5) + cls.enabled = is_usd_supported + + def load(self, context, name, namespace, data): + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + # Create the Loader with the filename path set + comp = get_current_comp() + with comp_lock_and_undo_chunk(comp, "Create tool"): + + path = self.fname + + args = (-32768, -32768) + tool = comp.AddTool(self.tool_type, *args) + tool["Filename"] = path + + imprint_container(tool, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + path = get_representation_path(representation) + + with comp_lock_and_undo_chunk(comp, "Update tool"): + tool["Filename"] = path + + # Update the imprinted representation + tool.SetData("avalon.representation", str(representation["_id"])) + + def remove(self, container): + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + with comp_lock_and_undo_chunk(comp, "Remove tool"): + tool.Delete() diff --git a/openpype/hosts/fusion/plugins/publish/collect_instances.py b/openpype/hosts/fusion/plugins/publish/collect_instances.py index 6016baa2a9..4d6da79b77 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_instances.py +++ b/openpype/hosts/fusion/plugins/publish/collect_instances.py @@ -85,5 +85,5 @@ class CollectInstanceData(pyblish.api.InstancePlugin): # Add review family if the instance is marked as 'review' # This could be done through a 'review' Creator attribute. if instance.data.get("review", False): - self.log.info("Adding review family..") + self.log.debug("Adding review family..") instance.data["families"].append("review") diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index a20a142701..a7daa0b64c 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -108,7 +108,6 @@ class CollectFusionRender( fam = "render.farm" if fam not in instance.families: instance.families.append(fam) - instance.toBeRenderedOn = "deadline" instance.farm = True # to skip integrate if "review" in instance.families: # to skip ExtractReview locally @@ -146,9 +145,11 @@ class CollectFusionRender( start = render_instance.frameStart - render_instance.handleStart end = render_instance.frameEnd + render_instance.handleEnd - path = ( - render_instance.tool["Clip"] - [render_instance.workfileComp.TIME_UNDEFINED] + comp = render_instance.workfileComp + path = comp.MapPath( + render_instance.tool["Clip"][ + render_instance.workfileComp.TIME_UNDEFINED + ] ) output_dir = os.path.dirname(path) render_instance.outputDir = output_dir diff --git a/openpype/hosts/fusion/plugins/publish/extract_render_local.py b/openpype/hosts/fusion/plugins/publish/extract_render_local.py index 25c101cf00..08d608139d 100644 --- a/openpype/hosts/fusion/plugins/publish/extract_render_local.py +++ b/openpype/hosts/fusion/plugins/publish/extract_render_local.py @@ -25,20 +25,24 @@ def enabled_savers(comp, savers): """ passthrough_key = "TOOLB_PassThrough" original_states = {} - enabled_save_names = {saver.Name for saver in savers} + enabled_saver_names = {saver.Name for saver in savers} + + all_savers = comp.GetToolList(False, "Saver").values() + savers_by_name = {saver.Name: saver for saver in all_savers} + try: - all_savers = comp.GetToolList(False, "Saver").values() for saver in all_savers: original_state = saver.GetAttrs()[passthrough_key] - original_states[saver] = original_state + original_states[saver.Name] = original_state # The passthrough state we want to set (passthrough != enabled) - state = saver.Name not in enabled_save_names + state = saver.Name not in enabled_saver_names if state != original_state: saver.SetAttrs({passthrough_key: state}) yield finally: - for saver, original_state in original_states.items(): + for saver_name, original_state in original_states.items(): + saver = savers_by_name[saver_name] saver.SetAttrs({"TOOLB_PassThrough": original_state}) diff --git a/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py new file mode 100644 index 0000000000..efa7295d11 --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py @@ -0,0 +1,105 @@ +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin, +) + +from openpype.hosts.fusion.api.action import SelectInvalidAction +from openpype.hosts.fusion.api import comp_lock_and_undo_chunk + + +def get_tool_resolution(tool, frame): + """Return the 2D input resolution to a Fusion tool + + If the current tool hasn't been rendered its input resolution + hasn't been saved. To combat this, add an expression in + the comments field to read the resolution + + Args + tool (Fusion Tool): The tool to query input resolution + frame (int): The frame to query the resolution on. + + Returns: + tuple: width, height as 2-tuple of integers + + """ + comp = tool.Composition + + # False undo removes the undo-stack from the undo list + with comp_lock_and_undo_chunk(comp, "Read resolution", False): + # Save old comment + old_comment = "" + has_expression = False + if tool["Comments"][frame] != "": + if tool["Comments"].GetExpression() is not None: + has_expression = True + old_comment = tool["Comments"].GetExpression() + tool["Comments"].SetExpression(None) + else: + old_comment = tool["Comments"][frame] + tool["Comments"][frame] = "" + + # Get input width + tool["Comments"].SetExpression("self.Input.OriginalWidth") + width = int(tool["Comments"][frame]) + + # Get input height + tool["Comments"].SetExpression("self.Input.OriginalHeight") + height = int(tool["Comments"][frame]) + + # Reset old comment + tool["Comments"].SetExpression(None) + if has_expression: + tool["Comments"].SetExpression(old_comment) + else: + tool["Comments"][frame] = old_comment + + return width, height + + +class ValidateSaverResolution( + pyblish.api.InstancePlugin, OptionalPyblishPluginMixin +): + """Validate that the saver input resolution matches the asset resolution""" + + order = pyblish.api.ValidatorOrder + label = "Validate Asset Resolution" + families = ["render"] + hosts = ["fusion"] + optional = True + actions = [SelectInvalidAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + + resolution = self.get_resolution(instance) + expected_resolution = self.get_expected_resolution(instance) + if resolution != expected_resolution: + raise PublishValidationError( + "The input's resolution does not match " + "the asset's resolution {}x{}.\n\n" + "The input's resolution is {}x{}.".format( + expected_resolution[0], expected_resolution[1], + resolution[0], resolution[1] + ) + ) + + @classmethod + def get_invalid(cls, instance): + resolution = cls.get_resolution(instance) + expected_resolution = cls.get_expected_resolution(instance) + if resolution != expected_resolution: + saver = instance.data["tool"] + return [saver] + + @classmethod + def get_resolution(cls, instance): + saver = instance.data["tool"] + first_frame = instance.data["frameStartHandle"] + return get_tool_resolution(saver, frame=first_frame) + + @classmethod + def get_expected_resolution(cls, instance): + data = instance.data["assetEntity"]["data"] + return data["resolutionWidth"], data["resolutionHeight"] diff --git a/openpype/hosts/fusion/vendor/attr/__init__.py b/openpype/hosts/fusion/vendor/attr/__init__.py new file mode 100644 index 0000000000..b1ce7fe248 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/__init__.py @@ -0,0 +1,78 @@ +from __future__ import absolute_import, division, print_function + +import sys + +from functools import partial + +from . import converters, exceptions, filters, setters, validators +from ._cmp import cmp_using +from ._config import get_run_validators, set_run_validators +from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types +from ._make import ( + NOTHING, + Attribute, + Factory, + attrib, + attrs, + fields, + fields_dict, + make_class, + validate, +) +from ._version_info import VersionInfo + + +__version__ = "21.2.0" +__version_info__ = VersionInfo._from_version_string(__version__) + +__title__ = "attrs" +__description__ = "Classes Without Boilerplate" +__url__ = "https://www.attrs.org/" +__uri__ = __url__ +__doc__ = __description__ + " <" + __uri__ + ">" + +__author__ = "Hynek Schlawack" +__email__ = "hs@ox.cx" + +__license__ = "MIT" +__copyright__ = "Copyright (c) 2015 Hynek Schlawack" + + +s = attributes = attrs +ib = attr = attrib +dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) + +__all__ = [ + "Attribute", + "Factory", + "NOTHING", + "asdict", + "assoc", + "astuple", + "attr", + "attrib", + "attributes", + "attrs", + "cmp_using", + "converters", + "evolve", + "exceptions", + "fields", + "fields_dict", + "filters", + "get_run_validators", + "has", + "ib", + "make_class", + "resolve_types", + "s", + "set_run_validators", + "setters", + "validate", + "validators", +] + +if sys.version_info[:2] >= (3, 6): + from ._next_gen import define, field, frozen, mutable + + __all__.extend((define, field, frozen, mutable)) diff --git a/openpype/hosts/fusion/vendor/attr/__init__.pyi b/openpype/hosts/fusion/vendor/attr/__init__.pyi new file mode 100644 index 0000000000..3503b073b4 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/__init__.pyi @@ -0,0 +1,475 @@ +import sys + +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Mapping, + Optional, + Sequence, + Tuple, + Type, + TypeVar, + Union, + overload, +) + +# `import X as X` is required to make these public +from . import converters as converters +from . import exceptions as exceptions +from . import filters as filters +from . import setters as setters +from . import validators as validators +from ._version_info import VersionInfo + + +__version__: str +__version_info__: VersionInfo +__title__: str +__description__: str +__url__: str +__uri__: str +__author__: str +__email__: str +__license__: str +__copyright__: str + +_T = TypeVar("_T") +_C = TypeVar("_C", bound=type) + +_EqOrderType = Union[bool, Callable[[Any], Any]] +_ValidatorType = Callable[[Any, Attribute[_T], _T], Any] +_ConverterType = Callable[[Any], Any] +_FilterType = Callable[[Attribute[_T], _T], bool] +_ReprType = Callable[[Any], str] +_ReprArgType = Union[bool, _ReprType] +_OnSetAttrType = Callable[[Any, Attribute[Any], Any], Any] +_OnSetAttrArgType = Union[ + _OnSetAttrType, List[_OnSetAttrType], setters._NoOpType +] +_FieldTransformer = Callable[[type, List[Attribute[Any]]], List[Attribute[Any]]] +# FIXME: in reality, if multiple validators are passed they must be in a list +# or tuple, but those are invariant and so would prevent subtypes of +# _ValidatorType from working when passed in a list or tuple. +_ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]] + +# _make -- + +NOTHING: object + +# NOTE: Factory lies about its return type to make this possible: +# `x: List[int] # = Factory(list)` +# Work around mypy issue #4554 in the common case by using an overload. +if sys.version_info >= (3, 8): + from typing import Literal + + @overload + def Factory(factory: Callable[[], _T]) -> _T: ... + @overload + def Factory( + factory: Callable[[Any], _T], + takes_self: Literal[True], + ) -> _T: ... + @overload + def Factory( + factory: Callable[[], _T], + takes_self: Literal[False], + ) -> _T: ... +else: + @overload + def Factory(factory: Callable[[], _T]) -> _T: ... + @overload + def Factory( + factory: Union[Callable[[Any], _T], Callable[[], _T]], + takes_self: bool = ..., + ) -> _T: ... + +# Static type inference support via __dataclass_transform__ implemented as per: +# https://github.com/microsoft/pyright/blob/1.1.135/specs/dataclass_transforms.md +# This annotation must be applied to all overloads of "define" and "attrs" +# +# NOTE: This is a typing construct and does not exist at runtime. Extensions +# wrapping attrs decorators should declare a separate __dataclass_transform__ +# signature in the extension module using the specification linked above to +# provide pyright support. +def __dataclass_transform__( + *, + eq_default: bool = True, + order_default: bool = False, + kw_only_default: bool = False, + field_descriptors: Tuple[Union[type, Callable[..., Any]], ...] = (()), +) -> Callable[[_T], _T]: ... + +class Attribute(Generic[_T]): + name: str + default: Optional[_T] + validator: Optional[_ValidatorType[_T]] + repr: _ReprArgType + cmp: _EqOrderType + eq: _EqOrderType + order: _EqOrderType + hash: Optional[bool] + init: bool + converter: Optional[_ConverterType] + metadata: Dict[Any, Any] + type: Optional[Type[_T]] + kw_only: bool + on_setattr: _OnSetAttrType + + def evolve(self, **changes: Any) -> "Attribute[Any]": ... + +# NOTE: We had several choices for the annotation to use for type arg: +# 1) Type[_T] +# - Pros: Handles simple cases correctly +# - Cons: Might produce less informative errors in the case of conflicting +# TypeVars e.g. `attr.ib(default='bad', type=int)` +# 2) Callable[..., _T] +# - Pros: Better error messages than #1 for conflicting TypeVars +# - Cons: Terrible error messages for validator checks. +# e.g. attr.ib(type=int, validator=validate_str) +# -> error: Cannot infer function type argument +# 3) type (and do all of the work in the mypy plugin) +# - Pros: Simple here, and we could customize the plugin with our own errors. +# - Cons: Would need to write mypy plugin code to handle all the cases. +# We chose option #1. + +# `attr` lies about its return type to make the following possible: +# attr() -> Any +# attr(8) -> int +# attr(validator=) -> Whatever the callable expects. +# This makes this type of assignments possible: +# x: int = attr(8) +# +# This form catches explicit None or no default but with no other arguments +# returns Any. +@overload +def attrib( + default: None = ..., + validator: None = ..., + repr: _ReprArgType = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: None = ..., + converter: None = ..., + factory: None = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... + +# This form catches an explicit None or no default and infers the type from the +# other arguments. +@overload +def attrib( + default: None = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: Optional[Type[_T]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form catches an explicit default argument. +@overload +def attrib( + default: _T, + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: Optional[Type[_T]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form covers type=non-Type: e.g. forward references (str), Any +@overload +def attrib( + default: Optional[_T] = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + type: object = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... +@overload +def field( + *, + default: None = ..., + validator: None = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: None = ..., + factory: None = ..., + kw_only: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... + +# This form catches an explicit None or no default and infers the type from the +# other arguments. +@overload +def field( + *, + default: None = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form catches an explicit default argument. +@overload +def field( + *, + default: _T, + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> _T: ... + +# This form covers type=non-Type: e.g. forward references (str), Any +@overload +def field( + *, + default: Optional[_T] = ..., + validator: Optional[_ValidatorArgType[_T]] = ..., + repr: _ReprArgType = ..., + hash: Optional[bool] = ..., + init: bool = ..., + metadata: Optional[Mapping[Any, Any]] = ..., + converter: Optional[_ConverterType] = ..., + factory: Optional[Callable[[], _T]] = ..., + kw_only: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., +) -> Any: ... +@overload +@__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) +def attrs( + maybe_cls: _C, + these: Optional[Dict[str, Any]] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + auto_detect: bool = ..., + collect_by_mro: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> _C: ... +@overload +@__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) +def attrs( + maybe_cls: None = ..., + these: Optional[Dict[str, Any]] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + auto_detect: bool = ..., + collect_by_mro: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> Callable[[_C], _C]: ... +@overload +@__dataclass_transform__(field_descriptors=(attrib, field)) +def define( + maybe_cls: _C, + *, + these: Optional[Dict[str, Any]] = ..., + repr: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> _C: ... +@overload +@__dataclass_transform__(field_descriptors=(attrib, field)) +def define( + maybe_cls: None = ..., + *, + these: Optional[Dict[str, Any]] = ..., + repr: bool = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[bool] = ..., + order: Optional[bool] = ..., + auto_detect: bool = ..., + getstate_setstate: Optional[bool] = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> Callable[[_C], _C]: ... + +mutable = define +frozen = define # they differ only in their defaults + +# TODO: add support for returning NamedTuple from the mypy plugin +class _Fields(Tuple[Attribute[Any], ...]): + def __getattr__(self, name: str) -> Attribute[Any]: ... + +def fields(cls: type) -> _Fields: ... +def fields_dict(cls: type) -> Dict[str, Attribute[Any]]: ... +def validate(inst: Any) -> None: ... +def resolve_types( + cls: _C, + globalns: Optional[Dict[str, Any]] = ..., + localns: Optional[Dict[str, Any]] = ..., + attribs: Optional[List[Attribute[Any]]] = ..., +) -> _C: ... + +# TODO: add support for returning a proper attrs class from the mypy plugin +# we use Any instead of _CountingAttr so that e.g. `make_class('Foo', +# [attr.ib()])` is valid +def make_class( + name: str, + attrs: Union[List[str], Tuple[str, ...], Dict[str, Any]], + bases: Tuple[type, ...] = ..., + repr_ns: Optional[str] = ..., + repr: bool = ..., + cmp: Optional[_EqOrderType] = ..., + hash: Optional[bool] = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: Optional[_EqOrderType] = ..., + order: Optional[_EqOrderType] = ..., + collect_by_mro: bool = ..., + on_setattr: Optional[_OnSetAttrArgType] = ..., + field_transformer: Optional[_FieldTransformer] = ..., +) -> type: ... + +# _funcs -- + +# TODO: add support for returning TypedDict from the mypy plugin +# FIXME: asdict/astuple do not honor their factory args. Waiting on one of +# these: +# https://github.com/python/mypy/issues/4236 +# https://github.com/python/typing/issues/253 +def asdict( + inst: Any, + recurse: bool = ..., + filter: Optional[_FilterType[Any]] = ..., + dict_factory: Type[Mapping[Any, Any]] = ..., + retain_collection_types: bool = ..., + value_serializer: Optional[Callable[[type, Attribute[Any], Any], Any]] = ..., +) -> Dict[str, Any]: ... + +# TODO: add support for returning NamedTuple from the mypy plugin +def astuple( + inst: Any, + recurse: bool = ..., + filter: Optional[_FilterType[Any]] = ..., + tuple_factory: Type[Sequence[Any]] = ..., + retain_collection_types: bool = ..., +) -> Tuple[Any, ...]: ... +def has(cls: type) -> bool: ... +def assoc(inst: _T, **changes: Any) -> _T: ... +def evolve(inst: _T, **changes: Any) -> _T: ... + +# _config -- + +def set_run_validators(run: bool) -> None: ... +def get_run_validators() -> bool: ... + +# aliases -- + +s = attributes = attrs +ib = attr = attrib +dataclass = attrs # Technically, partial(attrs, auto_attribs=True) ;) diff --git a/openpype/hosts/fusion/vendor/attr/_cmp.py b/openpype/hosts/fusion/vendor/attr/_cmp.py new file mode 100644 index 0000000000..b747b603f1 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_cmp.py @@ -0,0 +1,152 @@ +from __future__ import absolute_import, division, print_function + +import functools + +from ._compat import new_class +from ._make import _make_ne + + +_operation_names = {"eq": "==", "lt": "<", "le": "<=", "gt": ">", "ge": ">="} + + +def cmp_using( + eq=None, + lt=None, + le=None, + gt=None, + ge=None, + require_same_type=True, + class_name="Comparable", +): + """ + Create a class that can be passed into `attr.ib`'s ``eq``, ``order``, and + ``cmp`` arguments to customize field comparison. + + The resulting class will have a full set of ordering methods if + at least one of ``{lt, le, gt, ge}`` and ``eq`` are provided. + + :param Optional[callable] eq: `callable` used to evaluate equality + of two objects. + :param Optional[callable] lt: `callable` used to evaluate whether + one object is less than another object. + :param Optional[callable] le: `callable` used to evaluate whether + one object is less than or equal to another object. + :param Optional[callable] gt: `callable` used to evaluate whether + one object is greater than another object. + :param Optional[callable] ge: `callable` used to evaluate whether + one object is greater than or equal to another object. + + :param bool require_same_type: When `True`, equality and ordering methods + will return `NotImplemented` if objects are not of the same type. + + :param Optional[str] class_name: Name of class. Defaults to 'Comparable'. + + See `comparison` for more details. + + .. versionadded:: 21.1.0 + """ + + body = { + "__slots__": ["value"], + "__init__": _make_init(), + "_requirements": [], + "_is_comparable_to": _is_comparable_to, + } + + # Add operations. + num_order_functions = 0 + has_eq_function = False + + if eq is not None: + has_eq_function = True + body["__eq__"] = _make_operator("eq", eq) + body["__ne__"] = _make_ne() + + if lt is not None: + num_order_functions += 1 + body["__lt__"] = _make_operator("lt", lt) + + if le is not None: + num_order_functions += 1 + body["__le__"] = _make_operator("le", le) + + if gt is not None: + num_order_functions += 1 + body["__gt__"] = _make_operator("gt", gt) + + if ge is not None: + num_order_functions += 1 + body["__ge__"] = _make_operator("ge", ge) + + type_ = new_class(class_name, (object,), {}, lambda ns: ns.update(body)) + + # Add same type requirement. + if require_same_type: + type_._requirements.append(_check_same_type) + + # Add total ordering if at least one operation was defined. + if 0 < num_order_functions < 4: + if not has_eq_function: + # functools.total_ordering requires __eq__ to be defined, + # so raise early error here to keep a nice stack. + raise ValueError( + "eq must be define is order to complete ordering from " + "lt, le, gt, ge." + ) + type_ = functools.total_ordering(type_) + + return type_ + + +def _make_init(): + """ + Create __init__ method. + """ + + def __init__(self, value): + """ + Initialize object with *value*. + """ + self.value = value + + return __init__ + + +def _make_operator(name, func): + """ + Create operator method. + """ + + def method(self, other): + if not self._is_comparable_to(other): + return NotImplemented + + result = func(self.value, other.value) + if result is NotImplemented: + return NotImplemented + + return result + + method.__name__ = "__%s__" % (name,) + method.__doc__ = "Return a %s b. Computed by attrs." % ( + _operation_names[name], + ) + + return method + + +def _is_comparable_to(self, other): + """ + Check whether `other` is comparable to `self`. + """ + for func in self._requirements: + if not func(self, other): + return False + return True + + +def _check_same_type(self, other): + """ + Return True if *self* and *other* are of the same type, False otherwise. + """ + return other.value.__class__ is self.value.__class__ diff --git a/openpype/hosts/fusion/vendor/attr/_cmp.pyi b/openpype/hosts/fusion/vendor/attr/_cmp.pyi new file mode 100644 index 0000000000..7093550f0f --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_cmp.pyi @@ -0,0 +1,14 @@ +from typing import Type + +from . import _CompareWithType + + +def cmp_using( + eq: Optional[_CompareWithType], + lt: Optional[_CompareWithType], + le: Optional[_CompareWithType], + gt: Optional[_CompareWithType], + ge: Optional[_CompareWithType], + require_same_type: bool, + class_name: str, +) -> Type: ... diff --git a/openpype/hosts/fusion/vendor/attr/_compat.py b/openpype/hosts/fusion/vendor/attr/_compat.py new file mode 100644 index 0000000000..6939f338da --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_compat.py @@ -0,0 +1,242 @@ +from __future__ import absolute_import, division, print_function + +import platform +import sys +import types +import warnings + + +PY2 = sys.version_info[0] == 2 +PYPY = platform.python_implementation() == "PyPy" + + +if PYPY or sys.version_info[:2] >= (3, 6): + ordered_dict = dict +else: + from collections import OrderedDict + + ordered_dict = OrderedDict + + +if PY2: + from collections import Mapping, Sequence + + from UserDict import IterableUserDict + + # We 'bundle' isclass instead of using inspect as importing inspect is + # fairly expensive (order of 10-15 ms for a modern machine in 2016) + def isclass(klass): + return isinstance(klass, (type, types.ClassType)) + + def new_class(name, bases, kwds, exec_body): + """ + A minimal stub of types.new_class that we need for make_class. + """ + ns = {} + exec_body(ns) + + return type(name, bases, ns) + + # TYPE is used in exceptions, repr(int) is different on Python 2 and 3. + TYPE = "type" + + def iteritems(d): + return d.iteritems() + + # Python 2 is bereft of a read-only dict proxy, so we make one! + class ReadOnlyDict(IterableUserDict): + """ + Best-effort read-only dict wrapper. + """ + + def __setitem__(self, key, val): + # We gently pretend we're a Python 3 mappingproxy. + raise TypeError( + "'mappingproxy' object does not support item assignment" + ) + + def update(self, _): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'update'" + ) + + def __delitem__(self, _): + # We gently pretend we're a Python 3 mappingproxy. + raise TypeError( + "'mappingproxy' object does not support item deletion" + ) + + def clear(self): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'clear'" + ) + + def pop(self, key, default=None): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'pop'" + ) + + def popitem(self): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'popitem'" + ) + + def setdefault(self, key, default=None): + # We gently pretend we're a Python 3 mappingproxy. + raise AttributeError( + "'mappingproxy' object has no attribute 'setdefault'" + ) + + def __repr__(self): + # Override to be identical to the Python 3 version. + return "mappingproxy(" + repr(self.data) + ")" + + def metadata_proxy(d): + res = ReadOnlyDict() + res.data.update(d) # We blocked update, so we have to do it like this. + return res + + def just_warn(*args, **kw): # pragma: no cover + """ + We only warn on Python 3 because we are not aware of any concrete + consequences of not setting the cell on Python 2. + """ + + +else: # Python 3 and later. + from collections.abc import Mapping, Sequence # noqa + + def just_warn(*args, **kw): + """ + We only warn on Python 3 because we are not aware of any concrete + consequences of not setting the cell on Python 2. + """ + warnings.warn( + "Running interpreter doesn't sufficiently support code object " + "introspection. Some features like bare super() or accessing " + "__class__ will not work with slotted classes.", + RuntimeWarning, + stacklevel=2, + ) + + def isclass(klass): + return isinstance(klass, type) + + TYPE = "class" + + def iteritems(d): + return d.items() + + new_class = types.new_class + + def metadata_proxy(d): + return types.MappingProxyType(dict(d)) + + +def make_set_closure_cell(): + """Return a function of two arguments (cell, value) which sets + the value stored in the closure cell `cell` to `value`. + """ + # pypy makes this easy. (It also supports the logic below, but + # why not do the easy/fast thing?) + if PYPY: + + def set_closure_cell(cell, value): + cell.__setstate__((value,)) + + return set_closure_cell + + # Otherwise gotta do it the hard way. + + # Create a function that will set its first cellvar to `value`. + def set_first_cellvar_to(value): + x = value + return + + # This function will be eliminated as dead code, but + # not before its reference to `x` forces `x` to be + # represented as a closure cell rather than a local. + def force_x_to_be_a_cell(): # pragma: no cover + return x + + try: + # Extract the code object and make sure our assumptions about + # the closure behavior are correct. + if PY2: + co = set_first_cellvar_to.func_code + else: + co = set_first_cellvar_to.__code__ + if co.co_cellvars != ("x",) or co.co_freevars != (): + raise AssertionError # pragma: no cover + + # Convert this code object to a code object that sets the + # function's first _freevar_ (not cellvar) to the argument. + if sys.version_info >= (3, 8): + # CPython 3.8+ has an incompatible CodeType signature + # (added a posonlyargcount argument) but also added + # CodeType.replace() to do this without counting parameters. + set_first_freevar_code = co.replace( + co_cellvars=co.co_freevars, co_freevars=co.co_cellvars + ) + else: + args = [co.co_argcount] + if not PY2: + args.append(co.co_kwonlyargcount) + args.extend( + [ + co.co_nlocals, + co.co_stacksize, + co.co_flags, + co.co_code, + co.co_consts, + co.co_names, + co.co_varnames, + co.co_filename, + co.co_name, + co.co_firstlineno, + co.co_lnotab, + # These two arguments are reversed: + co.co_cellvars, + co.co_freevars, + ] + ) + set_first_freevar_code = types.CodeType(*args) + + def set_closure_cell(cell, value): + # Create a function using the set_first_freevar_code, + # whose first closure cell is `cell`. Calling it will + # change the value of that cell. + setter = types.FunctionType( + set_first_freevar_code, {}, "setter", (), (cell,) + ) + # And call it to set the cell. + setter(value) + + # Make sure it works on this interpreter: + def make_func_with_cell(): + x = None + + def func(): + return x # pragma: no cover + + return func + + if PY2: + cell = make_func_with_cell().func_closure[0] + else: + cell = make_func_with_cell().__closure__[0] + set_closure_cell(cell, 100) + if cell.cell_contents != 100: + raise AssertionError # pragma: no cover + + except Exception: + return just_warn + else: + return set_closure_cell + + +set_closure_cell = make_set_closure_cell() diff --git a/openpype/hosts/fusion/vendor/attr/_config.py b/openpype/hosts/fusion/vendor/attr/_config.py new file mode 100644 index 0000000000..8ec920962d --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_config.py @@ -0,0 +1,23 @@ +from __future__ import absolute_import, division, print_function + + +__all__ = ["set_run_validators", "get_run_validators"] + +_run_validators = True + + +def set_run_validators(run): + """ + Set whether or not validators are run. By default, they are run. + """ + if not isinstance(run, bool): + raise TypeError("'run' must be bool.") + global _run_validators + _run_validators = run + + +def get_run_validators(): + """ + Return whether or not validators are run. + """ + return _run_validators diff --git a/openpype/hosts/fusion/vendor/attr/_funcs.py b/openpype/hosts/fusion/vendor/attr/_funcs.py new file mode 100644 index 0000000000..fda508c5c4 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_funcs.py @@ -0,0 +1,395 @@ +from __future__ import absolute_import, division, print_function + +import copy + +from ._compat import iteritems +from ._make import NOTHING, _obj_setattr, fields +from .exceptions import AttrsAttributeNotFoundError + + +def asdict( + inst, + recurse=True, + filter=None, + dict_factory=dict, + retain_collection_types=False, + value_serializer=None, +): + """ + Return the ``attrs`` attribute values of *inst* as a dict. + + Optionally recurse into other ``attrs``-decorated classes. + + :param inst: Instance of an ``attrs``-decorated class. + :param bool recurse: Recurse into classes that are also + ``attrs``-decorated. + :param callable filter: A callable whose return code determines whether an + attribute or element is included (``True``) or dropped (``False``). Is + called with the `attr.Attribute` as the first argument and the + value as the second argument. + :param callable dict_factory: A callable to produce dictionaries from. For + example, to produce ordered dictionaries instead of normal Python + dictionaries, pass in ``collections.OrderedDict``. + :param bool retain_collection_types: Do not convert to ``list`` when + encountering an attribute whose type is ``tuple`` or ``set``. Only + meaningful if ``recurse`` is ``True``. + :param Optional[callable] value_serializer: A hook that is called for every + attribute or dict key/value. It receives the current instance, field + and value and must return the (updated) value. The hook is run *after* + the optional *filter* has been applied. + + :rtype: return type of *dict_factory* + + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. versionadded:: 16.0.0 *dict_factory* + .. versionadded:: 16.1.0 *retain_collection_types* + .. versionadded:: 20.3.0 *value_serializer* + """ + attrs = fields(inst.__class__) + rv = dict_factory() + for a in attrs: + v = getattr(inst, a.name) + if filter is not None and not filter(a, v): + continue + + if value_serializer is not None: + v = value_serializer(inst, a, v) + + if recurse is True: + if has(v.__class__): + rv[a.name] = asdict( + v, + True, + filter, + dict_factory, + retain_collection_types, + value_serializer, + ) + elif isinstance(v, (tuple, list, set, frozenset)): + cf = v.__class__ if retain_collection_types is True else list + rv[a.name] = cf( + [ + _asdict_anything( + i, + filter, + dict_factory, + retain_collection_types, + value_serializer, + ) + for i in v + ] + ) + elif isinstance(v, dict): + df = dict_factory + rv[a.name] = df( + ( + _asdict_anything( + kk, + filter, + df, + retain_collection_types, + value_serializer, + ), + _asdict_anything( + vv, + filter, + df, + retain_collection_types, + value_serializer, + ), + ) + for kk, vv in iteritems(v) + ) + else: + rv[a.name] = v + else: + rv[a.name] = v + return rv + + +def _asdict_anything( + val, + filter, + dict_factory, + retain_collection_types, + value_serializer, +): + """ + ``asdict`` only works on attrs instances, this works on anything. + """ + if getattr(val.__class__, "__attrs_attrs__", None) is not None: + # Attrs class. + rv = asdict( + val, + True, + filter, + dict_factory, + retain_collection_types, + value_serializer, + ) + elif isinstance(val, (tuple, list, set, frozenset)): + cf = val.__class__ if retain_collection_types is True else list + rv = cf( + [ + _asdict_anything( + i, + filter, + dict_factory, + retain_collection_types, + value_serializer, + ) + for i in val + ] + ) + elif isinstance(val, dict): + df = dict_factory + rv = df( + ( + _asdict_anything( + kk, filter, df, retain_collection_types, value_serializer + ), + _asdict_anything( + vv, filter, df, retain_collection_types, value_serializer + ), + ) + for kk, vv in iteritems(val) + ) + else: + rv = val + if value_serializer is not None: + rv = value_serializer(None, None, rv) + + return rv + + +def astuple( + inst, + recurse=True, + filter=None, + tuple_factory=tuple, + retain_collection_types=False, +): + """ + Return the ``attrs`` attribute values of *inst* as a tuple. + + Optionally recurse into other ``attrs``-decorated classes. + + :param inst: Instance of an ``attrs``-decorated class. + :param bool recurse: Recurse into classes that are also + ``attrs``-decorated. + :param callable filter: A callable whose return code determines whether an + attribute or element is included (``True``) or dropped (``False``). Is + called with the `attr.Attribute` as the first argument and the + value as the second argument. + :param callable tuple_factory: A callable to produce tuples from. For + example, to produce lists instead of tuples. + :param bool retain_collection_types: Do not convert to ``list`` + or ``dict`` when encountering an attribute which type is + ``tuple``, ``dict`` or ``set``. Only meaningful if ``recurse`` is + ``True``. + + :rtype: return type of *tuple_factory* + + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. versionadded:: 16.2.0 + """ + attrs = fields(inst.__class__) + rv = [] + retain = retain_collection_types # Very long. :/ + for a in attrs: + v = getattr(inst, a.name) + if filter is not None and not filter(a, v): + continue + if recurse is True: + if has(v.__class__): + rv.append( + astuple( + v, + recurse=True, + filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + ) + elif isinstance(v, (tuple, list, set, frozenset)): + cf = v.__class__ if retain is True else list + rv.append( + cf( + [ + astuple( + j, + recurse=True, + filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(j.__class__) + else j + for j in v + ] + ) + ) + elif isinstance(v, dict): + df = v.__class__ if retain is True else dict + rv.append( + df( + ( + astuple( + kk, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(kk.__class__) + else kk, + astuple( + vv, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(vv.__class__) + else vv, + ) + for kk, vv in iteritems(v) + ) + ) + else: + rv.append(v) + else: + rv.append(v) + + return rv if tuple_factory is list else tuple_factory(rv) + + +def has(cls): + """ + Check whether *cls* is a class with ``attrs`` attributes. + + :param type cls: Class to introspect. + :raise TypeError: If *cls* is not a class. + + :rtype: bool + """ + return getattr(cls, "__attrs_attrs__", None) is not None + + +def assoc(inst, **changes): + """ + Copy *inst* and apply *changes*. + + :param inst: Instance of a class with ``attrs`` attributes. + :param changes: Keyword changes in the new copy. + + :return: A copy of inst with *changes* incorporated. + + :raise attr.exceptions.AttrsAttributeNotFoundError: If *attr_name* couldn't + be found on *cls*. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. deprecated:: 17.1.0 + Use `evolve` instead. + """ + import warnings + + warnings.warn( + "assoc is deprecated and will be removed after 2018/01.", + DeprecationWarning, + stacklevel=2, + ) + new = copy.copy(inst) + attrs = fields(inst.__class__) + for k, v in iteritems(changes): + a = getattr(attrs, k, NOTHING) + if a is NOTHING: + raise AttrsAttributeNotFoundError( + "{k} is not an attrs attribute on {cl}.".format( + k=k, cl=new.__class__ + ) + ) + _obj_setattr(new, k, v) + return new + + +def evolve(inst, **changes): + """ + Create a new instance, based on *inst* with *changes* applied. + + :param inst: Instance of a class with ``attrs`` attributes. + :param changes: Keyword changes in the new copy. + + :return: A copy of inst with *changes* incorporated. + + :raise TypeError: If *attr_name* couldn't be found in the class + ``__init__``. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + .. versionadded:: 17.1.0 + """ + cls = inst.__class__ + attrs = fields(cls) + for a in attrs: + if not a.init: + continue + attr_name = a.name # To deal with private attributes. + init_name = attr_name if attr_name[0] != "_" else attr_name[1:] + if init_name not in changes: + changes[init_name] = getattr(inst, attr_name) + + return cls(**changes) + + +def resolve_types(cls, globalns=None, localns=None, attribs=None): + """ + Resolve any strings and forward annotations in type annotations. + + This is only required if you need concrete types in `Attribute`'s *type* + field. In other words, you don't need to resolve your types if you only + use them for static type checking. + + With no arguments, names will be looked up in the module in which the class + was created. If this is not what you want, e.g. if the name only exists + inside a method, you may pass *globalns* or *localns* to specify other + dictionaries in which to look up these names. See the docs of + `typing.get_type_hints` for more details. + + :param type cls: Class to resolve. + :param Optional[dict] globalns: Dictionary containing global variables. + :param Optional[dict] localns: Dictionary containing local variables. + :param Optional[list] attribs: List of attribs for the given class. + This is necessary when calling from inside a ``field_transformer`` + since *cls* is not an ``attrs`` class yet. + + :raise TypeError: If *cls* is not a class. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class and you didn't pass any attribs. + :raise NameError: If types cannot be resolved because of missing variables. + + :returns: *cls* so you can use this function also as a class decorator. + Please note that you have to apply it **after** `attr.s`. That means + the decorator has to come in the line **before** `attr.s`. + + .. versionadded:: 20.1.0 + .. versionadded:: 21.1.0 *attribs* + + """ + try: + # Since calling get_type_hints is expensive we cache whether we've + # done it already. + cls.__attrs_types_resolved__ + except AttributeError: + import typing + + hints = typing.get_type_hints(cls, globalns=globalns, localns=localns) + for field in fields(cls) if attribs is None else attribs: + if field.name in hints: + # Since fields have been frozen we must work around it. + _obj_setattr(field, "type", hints[field.name]) + cls.__attrs_types_resolved__ = True + + # Return the class so you can use it as a decorator too. + return cls diff --git a/openpype/hosts/fusion/vendor/attr/_make.py b/openpype/hosts/fusion/vendor/attr/_make.py new file mode 100644 index 0000000000..a1912b1233 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_make.py @@ -0,0 +1,3052 @@ +from __future__ import absolute_import, division, print_function + +import copy +import inspect +import linecache +import sys +import threading +import uuid +import warnings + +from operator import itemgetter + +from . import _config, setters +from ._compat import ( + PY2, + PYPY, + isclass, + iteritems, + metadata_proxy, + new_class, + ordered_dict, + set_closure_cell, +) +from .exceptions import ( + DefaultAlreadySetError, + FrozenInstanceError, + NotAnAttrsClassError, + PythonTooOldError, + UnannotatedAttributeError, +) + + +if not PY2: + import typing + + +# This is used at least twice, so cache it here. +_obj_setattr = object.__setattr__ +_init_converter_pat = "__attr_converter_%s" +_init_factory_pat = "__attr_factory_{}" +_tuple_property_pat = ( + " {attr_name} = _attrs_property(_attrs_itemgetter({index}))" +) +_classvar_prefixes = ( + "typing.ClassVar", + "t.ClassVar", + "ClassVar", + "typing_extensions.ClassVar", +) +# we don't use a double-underscore prefix because that triggers +# name mangling when trying to create a slot for the field +# (when slots=True) +_hash_cache_field = "_attrs_cached_hash" + +_empty_metadata_singleton = metadata_proxy({}) + +# Unique object for unequivocal getattr() defaults. +_sentinel = object() + + +class _Nothing(object): + """ + Sentinel class to indicate the lack of a value when ``None`` is ambiguous. + + ``_Nothing`` is a singleton. There is only ever one of it. + + .. versionchanged:: 21.1.0 ``bool(NOTHING)`` is now False. + """ + + _singleton = None + + def __new__(cls): + if _Nothing._singleton is None: + _Nothing._singleton = super(_Nothing, cls).__new__(cls) + return _Nothing._singleton + + def __repr__(self): + return "NOTHING" + + def __bool__(self): + return False + + def __len__(self): + return 0 # __bool__ for Python 2 + + +NOTHING = _Nothing() +""" +Sentinel to indicate the lack of a value when ``None`` is ambiguous. +""" + + +class _CacheHashWrapper(int): + """ + An integer subclass that pickles / copies as None + + This is used for non-slots classes with ``cache_hash=True``, to avoid + serializing a potentially (even likely) invalid hash value. Since ``None`` + is the default value for uncalculated hashes, whenever this is copied, + the copy's value for the hash should automatically reset. + + See GH #613 for more details. + """ + + if PY2: + # For some reason `type(None)` isn't callable in Python 2, but we don't + # actually need a constructor for None objects, we just need any + # available function that returns None. + def __reduce__(self, _none_constructor=getattr, _args=(0, "", None)): + return _none_constructor, _args + + else: + + def __reduce__(self, _none_constructor=type(None), _args=()): + return _none_constructor, _args + + +def attrib( + default=NOTHING, + validator=None, + repr=True, + cmp=None, + hash=None, + init=True, + metadata=None, + type=None, + converter=None, + factory=None, + kw_only=False, + eq=None, + order=None, + on_setattr=None, +): + """ + Create a new attribute on a class. + + .. warning:: + + Does *not* do anything unless the class is also decorated with + `attr.s`! + + :param default: A value that is used if an ``attrs``-generated ``__init__`` + is used and no value is passed while instantiating or the attribute is + excluded using ``init=False``. + + If the value is an instance of `Factory`, its callable will be + used to construct a new value (useful for mutable data types like lists + or dicts). + + If a default is not set (or set manually to `attr.NOTHING`), a value + *must* be supplied when instantiating; otherwise a `TypeError` + will be raised. + + The default can also be set using decorator notation as shown below. + + :type default: Any value + + :param callable factory: Syntactic sugar for + ``default=attr.Factory(factory)``. + + :param validator: `callable` that is called by ``attrs``-generated + ``__init__`` methods after the instance has been initialized. They + receive the initialized instance, the `Attribute`, and the + passed value. + + The return value is *not* inspected so the validator has to throw an + exception itself. + + If a `list` is passed, its items are treated as validators and must + all pass. + + Validators can be globally disabled and re-enabled using + `get_run_validators`. + + The validator can also be set using decorator notation as shown below. + + :type validator: `callable` or a `list` of `callable`\\ s. + + :param repr: Include this attribute in the generated ``__repr__`` + method. If ``True``, include the attribute; if ``False``, omit it. By + default, the built-in ``repr()`` function is used. To override how the + attribute value is formatted, pass a ``callable`` that takes a single + value and returns a string. Note that the resulting string is used + as-is, i.e. it will be used directly *instead* of calling ``repr()`` + (the default). + :type repr: a `bool` or a `callable` to use a custom function. + + :param eq: If ``True`` (default), include this attribute in the + generated ``__eq__`` and ``__ne__`` methods that check two instances + for equality. To override how the attribute value is compared, + pass a ``callable`` that takes a single value and returns the value + to be compared. + :type eq: a `bool` or a `callable`. + + :param order: If ``True`` (default), include this attributes in the + generated ``__lt__``, ``__le__``, ``__gt__`` and ``__ge__`` methods. + To override how the attribute value is ordered, + pass a ``callable`` that takes a single value and returns the value + to be ordered. + :type order: a `bool` or a `callable`. + + :param cmp: Setting *cmp* is equivalent to setting *eq* and *order* to the + same value. Must not be mixed with *eq* or *order*. + :type cmp: a `bool` or a `callable`. + + :param Optional[bool] hash: Include this attribute in the generated + ``__hash__`` method. If ``None`` (default), mirror *eq*'s value. This + is the correct behavior according the Python spec. Setting this value + to anything else than ``None`` is *discouraged*. + :param bool init: Include this attribute in the generated ``__init__`` + method. It is possible to set this to ``False`` and set a default + value. In that case this attributed is unconditionally initialized + with the specified default value or factory. + :param callable converter: `callable` that is called by + ``attrs``-generated ``__init__`` methods to convert attribute's value + to the desired format. It is given the passed-in value, and the + returned value will be used as the new value of the attribute. The + value is converted before being passed to the validator, if any. + :param metadata: An arbitrary mapping, to be used by third-party + components. See `extending_metadata`. + :param type: The type of the attribute. In Python 3.6 or greater, the + preferred method to specify the type is using a variable annotation + (see `PEP 526 `_). + This argument is provided for backward compatibility. + Regardless of the approach used, the type will be stored on + ``Attribute.type``. + + Please note that ``attrs`` doesn't do anything with this metadata by + itself. You can use it as part of your own code or for + `static type checking `. + :param kw_only: Make this attribute keyword-only (Python 3+) + in the generated ``__init__`` (if ``init`` is ``False``, this + parameter is ignored). + :param on_setattr: Allows to overwrite the *on_setattr* setting from + `attr.s`. If left `None`, the *on_setattr* value from `attr.s` is used. + Set to `attr.setters.NO_OP` to run **no** `setattr` hooks for this + attribute -- regardless of the setting in `attr.s`. + :type on_setattr: `callable`, or a list of callables, or `None`, or + `attr.setters.NO_OP` + + .. versionadded:: 15.2.0 *convert* + .. versionadded:: 16.3.0 *metadata* + .. versionchanged:: 17.1.0 *validator* can be a ``list`` now. + .. versionchanged:: 17.1.0 + *hash* is ``None`` and therefore mirrors *eq* by default. + .. versionadded:: 17.3.0 *type* + .. deprecated:: 17.4.0 *convert* + .. versionadded:: 17.4.0 *converter* as a replacement for the deprecated + *convert* to achieve consistency with other noun-based arguments. + .. versionadded:: 18.1.0 + ``factory=f`` is syntactic sugar for ``default=attr.Factory(f)``. + .. versionadded:: 18.2.0 *kw_only* + .. versionchanged:: 19.2.0 *convert* keyword argument removed. + .. versionchanged:: 19.2.0 *repr* also accepts a custom callable. + .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. + .. versionadded:: 19.2.0 *eq* and *order* + .. versionadded:: 20.1.0 *on_setattr* + .. versionchanged:: 20.3.0 *kw_only* backported to Python 2 + .. versionchanged:: 21.1.0 + *eq*, *order*, and *cmp* also accept a custom callable + .. versionchanged:: 21.1.0 *cmp* undeprecated + """ + eq, eq_key, order, order_key = _determine_attrib_eq_order( + cmp, eq, order, True + ) + + if hash is not None and hash is not True and hash is not False: + raise TypeError( + "Invalid value for hash. Must be True, False, or None." + ) + + if factory is not None: + if default is not NOTHING: + raise ValueError( + "The `default` and `factory` arguments are mutually " + "exclusive." + ) + if not callable(factory): + raise ValueError("The `factory` argument must be a callable.") + default = Factory(factory) + + if metadata is None: + metadata = {} + + # Apply syntactic sugar by auto-wrapping. + if isinstance(on_setattr, (list, tuple)): + on_setattr = setters.pipe(*on_setattr) + + if validator and isinstance(validator, (list, tuple)): + validator = and_(*validator) + + if converter and isinstance(converter, (list, tuple)): + converter = pipe(*converter) + + return _CountingAttr( + default=default, + validator=validator, + repr=repr, + cmp=None, + hash=hash, + init=init, + converter=converter, + metadata=metadata, + type=type, + kw_only=kw_only, + eq=eq, + eq_key=eq_key, + order=order, + order_key=order_key, + on_setattr=on_setattr, + ) + + +def _compile_and_eval(script, globs, locs=None, filename=""): + """ + "Exec" the script with the given global (globs) and local (locs) variables. + """ + bytecode = compile(script, filename, "exec") + eval(bytecode, globs, locs) + + +def _make_method(name, script, filename, globs=None): + """ + Create the method with the script given and return the method object. + """ + locs = {} + if globs is None: + globs = {} + + _compile_and_eval(script, globs, locs, filename) + + # In order of debuggers like PDB being able to step through the code, + # we add a fake linecache entry. + linecache.cache[filename] = ( + len(script), + None, + script.splitlines(True), + filename, + ) + + return locs[name] + + +def _make_attr_tuple_class(cls_name, attr_names): + """ + Create a tuple subclass to hold `Attribute`s for an `attrs` class. + + The subclass is a bare tuple with properties for names. + + class MyClassAttributes(tuple): + __slots__ = () + x = property(itemgetter(0)) + """ + attr_class_name = "{}Attributes".format(cls_name) + attr_class_template = [ + "class {}(tuple):".format(attr_class_name), + " __slots__ = ()", + ] + if attr_names: + for i, attr_name in enumerate(attr_names): + attr_class_template.append( + _tuple_property_pat.format(index=i, attr_name=attr_name) + ) + else: + attr_class_template.append(" pass") + globs = {"_attrs_itemgetter": itemgetter, "_attrs_property": property} + _compile_and_eval("\n".join(attr_class_template), globs) + return globs[attr_class_name] + + +# Tuple class for extracted attributes from a class definition. +# `base_attrs` is a subset of `attrs`. +_Attributes = _make_attr_tuple_class( + "_Attributes", + [ + # all attributes to build dunder methods for + "attrs", + # attributes that have been inherited + "base_attrs", + # map inherited attributes to their originating classes + "base_attrs_map", + ], +) + + +def _is_class_var(annot): + """ + Check whether *annot* is a typing.ClassVar. + + The string comparison hack is used to avoid evaluating all string + annotations which would put attrs-based classes at a performance + disadvantage compared to plain old classes. + """ + annot = str(annot) + + # Annotation can be quoted. + if annot.startswith(("'", '"')) and annot.endswith(("'", '"')): + annot = annot[1:-1] + + return annot.startswith(_classvar_prefixes) + + +def _has_own_attribute(cls, attrib_name): + """ + Check whether *cls* defines *attrib_name* (and doesn't just inherit it). + + Requires Python 3. + """ + attr = getattr(cls, attrib_name, _sentinel) + if attr is _sentinel: + return False + + for base_cls in cls.__mro__[1:]: + a = getattr(base_cls, attrib_name, None) + if attr is a: + return False + + return True + + +def _get_annotations(cls): + """ + Get annotations for *cls*. + """ + if _has_own_attribute(cls, "__annotations__"): + return cls.__annotations__ + + return {} + + +def _counter_getter(e): + """ + Key function for sorting to avoid re-creating a lambda for every class. + """ + return e[1].counter + + +def _collect_base_attrs(cls, taken_attr_names): + """ + Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. + """ + base_attrs = [] + base_attr_map = {} # A dictionary of base attrs to their classes. + + # Traverse the MRO and collect attributes. + for base_cls in reversed(cls.__mro__[1:-1]): + for a in getattr(base_cls, "__attrs_attrs__", []): + if a.inherited or a.name in taken_attr_names: + continue + + a = a.evolve(inherited=True) + base_attrs.append(a) + base_attr_map[a.name] = base_cls + + # For each name, only keep the freshest definition i.e. the furthest at the + # back. base_attr_map is fine because it gets overwritten with every new + # instance. + filtered = [] + seen = set() + for a in reversed(base_attrs): + if a.name in seen: + continue + filtered.insert(0, a) + seen.add(a.name) + + return filtered, base_attr_map + + +def _collect_base_attrs_broken(cls, taken_attr_names): + """ + Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. + + N.B. *taken_attr_names* will be mutated. + + Adhere to the old incorrect behavior. + + Notably it collects from the front and considers inherited attributes which + leads to the buggy behavior reported in #428. + """ + base_attrs = [] + base_attr_map = {} # A dictionary of base attrs to their classes. + + # Traverse the MRO and collect attributes. + for base_cls in cls.__mro__[1:-1]: + for a in getattr(base_cls, "__attrs_attrs__", []): + if a.name in taken_attr_names: + continue + + a = a.evolve(inherited=True) + taken_attr_names.add(a.name) + base_attrs.append(a) + base_attr_map[a.name] = base_cls + + return base_attrs, base_attr_map + + +def _transform_attrs( + cls, these, auto_attribs, kw_only, collect_by_mro, field_transformer +): + """ + Transform all `_CountingAttr`s on a class into `Attribute`s. + + If *these* is passed, use that and don't look for them on the class. + + *collect_by_mro* is True, collect them in the correct MRO order, otherwise + use the old -- incorrect -- order. See #428. + + Return an `_Attributes`. + """ + cd = cls.__dict__ + anns = _get_annotations(cls) + + if these is not None: + ca_list = [(name, ca) for name, ca in iteritems(these)] + + if not isinstance(these, ordered_dict): + ca_list.sort(key=_counter_getter) + elif auto_attribs is True: + ca_names = { + name + for name, attr in cd.items() + if isinstance(attr, _CountingAttr) + } + ca_list = [] + annot_names = set() + for attr_name, type in anns.items(): + if _is_class_var(type): + continue + annot_names.add(attr_name) + a = cd.get(attr_name, NOTHING) + + if not isinstance(a, _CountingAttr): + if a is NOTHING: + a = attrib() + else: + a = attrib(default=a) + ca_list.append((attr_name, a)) + + unannotated = ca_names - annot_names + if len(unannotated) > 0: + raise UnannotatedAttributeError( + "The following `attr.ib`s lack a type annotation: " + + ", ".join( + sorted(unannotated, key=lambda n: cd.get(n).counter) + ) + + "." + ) + else: + ca_list = sorted( + ( + (name, attr) + for name, attr in cd.items() + if isinstance(attr, _CountingAttr) + ), + key=lambda e: e[1].counter, + ) + + own_attrs = [ + Attribute.from_counting_attr( + name=attr_name, ca=ca, type=anns.get(attr_name) + ) + for attr_name, ca in ca_list + ] + + if collect_by_mro: + base_attrs, base_attr_map = _collect_base_attrs( + cls, {a.name for a in own_attrs} + ) + else: + base_attrs, base_attr_map = _collect_base_attrs_broken( + cls, {a.name for a in own_attrs} + ) + + attr_names = [a.name for a in base_attrs + own_attrs] + + AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names) + + if kw_only: + own_attrs = [a.evolve(kw_only=True) for a in own_attrs] + base_attrs = [a.evolve(kw_only=True) for a in base_attrs] + + attrs = AttrsClass(base_attrs + own_attrs) + + # Mandatory vs non-mandatory attr order only matters when they are part of + # the __init__ signature and when they aren't kw_only (which are moved to + # the end and can be mandatory or non-mandatory in any order, as they will + # be specified as keyword args anyway). Check the order of those attrs: + had_default = False + for a in (a for a in attrs if a.init is not False and a.kw_only is False): + if had_default is True and a.default is NOTHING: + raise ValueError( + "No mandatory attributes allowed after an attribute with a " + "default value or factory. Attribute in question: %r" % (a,) + ) + + if had_default is False and a.default is not NOTHING: + had_default = True + + if field_transformer is not None: + attrs = field_transformer(cls, attrs) + return _Attributes((attrs, base_attrs, base_attr_map)) + + +if PYPY: + + def _frozen_setattrs(self, name, value): + """ + Attached to frozen classes as __setattr__. + """ + if isinstance(self, BaseException) and name in ( + "__cause__", + "__context__", + ): + BaseException.__setattr__(self, name, value) + return + + raise FrozenInstanceError() + + +else: + + def _frozen_setattrs(self, name, value): + """ + Attached to frozen classes as __setattr__. + """ + raise FrozenInstanceError() + + +def _frozen_delattrs(self, name): + """ + Attached to frozen classes as __delattr__. + """ + raise FrozenInstanceError() + + +class _ClassBuilder(object): + """ + Iteratively build *one* class. + """ + + __slots__ = ( + "_attr_names", + "_attrs", + "_base_attr_map", + "_base_names", + "_cache_hash", + "_cls", + "_cls_dict", + "_delete_attribs", + "_frozen", + "_has_pre_init", + "_has_post_init", + "_is_exc", + "_on_setattr", + "_slots", + "_weakref_slot", + "_has_own_setattr", + "_has_custom_setattr", + ) + + def __init__( + self, + cls, + these, + slots, + frozen, + weakref_slot, + getstate_setstate, + auto_attribs, + kw_only, + cache_hash, + is_exc, + collect_by_mro, + on_setattr, + has_custom_setattr, + field_transformer, + ): + attrs, base_attrs, base_map = _transform_attrs( + cls, + these, + auto_attribs, + kw_only, + collect_by_mro, + field_transformer, + ) + + self._cls = cls + self._cls_dict = dict(cls.__dict__) if slots else {} + self._attrs = attrs + self._base_names = set(a.name for a in base_attrs) + self._base_attr_map = base_map + self._attr_names = tuple(a.name for a in attrs) + self._slots = slots + self._frozen = frozen + self._weakref_slot = weakref_slot + self._cache_hash = cache_hash + self._has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) + self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False)) + self._delete_attribs = not bool(these) + self._is_exc = is_exc + self._on_setattr = on_setattr + + self._has_custom_setattr = has_custom_setattr + self._has_own_setattr = False + + self._cls_dict["__attrs_attrs__"] = self._attrs + + if frozen: + self._cls_dict["__setattr__"] = _frozen_setattrs + self._cls_dict["__delattr__"] = _frozen_delattrs + + self._has_own_setattr = True + + if getstate_setstate: + ( + self._cls_dict["__getstate__"], + self._cls_dict["__setstate__"], + ) = self._make_getstate_setstate() + + def __repr__(self): + return "<_ClassBuilder(cls={cls})>".format(cls=self._cls.__name__) + + def build_class(self): + """ + Finalize class based on the accumulated configuration. + + Builder cannot be used after calling this method. + """ + if self._slots is True: + return self._create_slots_class() + else: + return self._patch_original_class() + + def _patch_original_class(self): + """ + Apply accumulated methods and return the class. + """ + cls = self._cls + base_names = self._base_names + + # Clean class of attribute definitions (`attr.ib()`s). + if self._delete_attribs: + for name in self._attr_names: + if ( + name not in base_names + and getattr(cls, name, _sentinel) is not _sentinel + ): + try: + delattr(cls, name) + except AttributeError: + # This can happen if a base class defines a class + # variable and we want to set an attribute with the + # same name by using only a type annotation. + pass + + # Attach our dunder methods. + for name, value in self._cls_dict.items(): + setattr(cls, name, value) + + # If we've inherited an attrs __setattr__ and don't write our own, + # reset it to object's. + if not self._has_own_setattr and getattr( + cls, "__attrs_own_setattr__", False + ): + cls.__attrs_own_setattr__ = False + + if not self._has_custom_setattr: + cls.__setattr__ = object.__setattr__ + + return cls + + def _create_slots_class(self): + """ + Build and return a new class with a `__slots__` attribute. + """ + cd = { + k: v + for k, v in iteritems(self._cls_dict) + if k not in tuple(self._attr_names) + ("__dict__", "__weakref__") + } + + # If our class doesn't have its own implementation of __setattr__ + # (either from the user or by us), check the bases, if one of them has + # an attrs-made __setattr__, that needs to be reset. We don't walk the + # MRO because we only care about our immediate base classes. + # XXX: This can be confused by subclassing a slotted attrs class with + # XXX: a non-attrs class and subclass the resulting class with an attrs + # XXX: class. See `test_slotted_confused` for details. For now that's + # XXX: OK with us. + if not self._has_own_setattr: + cd["__attrs_own_setattr__"] = False + + if not self._has_custom_setattr: + for base_cls in self._cls.__bases__: + if base_cls.__dict__.get("__attrs_own_setattr__", False): + cd["__setattr__"] = object.__setattr__ + break + + # Traverse the MRO to collect existing slots + # and check for an existing __weakref__. + existing_slots = dict() + weakref_inherited = False + for base_cls in self._cls.__mro__[1:-1]: + if base_cls.__dict__.get("__weakref__", None) is not None: + weakref_inherited = True + existing_slots.update( + { + name: getattr(base_cls, name) + for name in getattr(base_cls, "__slots__", []) + } + ) + + base_names = set(self._base_names) + + names = self._attr_names + if ( + self._weakref_slot + and "__weakref__" not in getattr(self._cls, "__slots__", ()) + and "__weakref__" not in names + and not weakref_inherited + ): + names += ("__weakref__",) + + # We only add the names of attributes that aren't inherited. + # Setting __slots__ to inherited attributes wastes memory. + slot_names = [name for name in names if name not in base_names] + # There are slots for attributes from current class + # that are defined in parent classes. + # As their descriptors may be overriden by a child class, + # we collect them here and update the class dict + reused_slots = { + slot: slot_descriptor + for slot, slot_descriptor in iteritems(existing_slots) + if slot in slot_names + } + slot_names = [name for name in slot_names if name not in reused_slots] + cd.update(reused_slots) + if self._cache_hash: + slot_names.append(_hash_cache_field) + cd["__slots__"] = tuple(slot_names) + + qualname = getattr(self._cls, "__qualname__", None) + if qualname is not None: + cd["__qualname__"] = qualname + + # Create new class based on old class and our methods. + cls = type(self._cls)(self._cls.__name__, self._cls.__bases__, cd) + + # The following is a fix for + # https://github.com/python-attrs/attrs/issues/102. On Python 3, + # if a method mentions `__class__` or uses the no-arg super(), the + # compiler will bake a reference to the class in the method itself + # as `method.__closure__`. Since we replace the class with a + # clone, we rewrite these references so it keeps working. + for item in cls.__dict__.values(): + if isinstance(item, (classmethod, staticmethod)): + # Class- and staticmethods hide their functions inside. + # These might need to be rewritten as well. + closure_cells = getattr(item.__func__, "__closure__", None) + elif isinstance(item, property): + # Workaround for property `super()` shortcut (PY3-only). + # There is no universal way for other descriptors. + closure_cells = getattr(item.fget, "__closure__", None) + else: + closure_cells = getattr(item, "__closure__", None) + + if not closure_cells: # Catch None or the empty list. + continue + for cell in closure_cells: + try: + match = cell.cell_contents is self._cls + except ValueError: # ValueError: Cell is empty + pass + else: + if match: + set_closure_cell(cell, cls) + + return cls + + def add_repr(self, ns): + self._cls_dict["__repr__"] = self._add_method_dunders( + _make_repr(self._attrs, ns=ns) + ) + return self + + def add_str(self): + repr = self._cls_dict.get("__repr__") + if repr is None: + raise ValueError( + "__str__ can only be generated if a __repr__ exists." + ) + + def __str__(self): + return self.__repr__() + + self._cls_dict["__str__"] = self._add_method_dunders(__str__) + return self + + def _make_getstate_setstate(self): + """ + Create custom __setstate__ and __getstate__ methods. + """ + # __weakref__ is not writable. + state_attr_names = tuple( + an for an in self._attr_names if an != "__weakref__" + ) + + def slots_getstate(self): + """ + Automatically created by attrs. + """ + return tuple(getattr(self, name) for name in state_attr_names) + + hash_caching_enabled = self._cache_hash + + def slots_setstate(self, state): + """ + Automatically created by attrs. + """ + __bound_setattr = _obj_setattr.__get__(self, Attribute) + for name, value in zip(state_attr_names, state): + __bound_setattr(name, value) + + # The hash code cache is not included when the object is + # serialized, but it still needs to be initialized to None to + # indicate that the first call to __hash__ should be a cache + # miss. + if hash_caching_enabled: + __bound_setattr(_hash_cache_field, None) + + return slots_getstate, slots_setstate + + def make_unhashable(self): + self._cls_dict["__hash__"] = None + return self + + def add_hash(self): + self._cls_dict["__hash__"] = self._add_method_dunders( + _make_hash( + self._cls, + self._attrs, + frozen=self._frozen, + cache_hash=self._cache_hash, + ) + ) + + return self + + def add_init(self): + self._cls_dict["__init__"] = self._add_method_dunders( + _make_init( + self._cls, + self._attrs, + self._has_pre_init, + self._has_post_init, + self._frozen, + self._slots, + self._cache_hash, + self._base_attr_map, + self._is_exc, + self._on_setattr is not None + and self._on_setattr is not setters.NO_OP, + attrs_init=False, + ) + ) + + return self + + def add_attrs_init(self): + self._cls_dict["__attrs_init__"] = self._add_method_dunders( + _make_init( + self._cls, + self._attrs, + self._has_pre_init, + self._has_post_init, + self._frozen, + self._slots, + self._cache_hash, + self._base_attr_map, + self._is_exc, + self._on_setattr is not None + and self._on_setattr is not setters.NO_OP, + attrs_init=True, + ) + ) + + return self + + def add_eq(self): + cd = self._cls_dict + + cd["__eq__"] = self._add_method_dunders( + _make_eq(self._cls, self._attrs) + ) + cd["__ne__"] = self._add_method_dunders(_make_ne()) + + return self + + def add_order(self): + cd = self._cls_dict + + cd["__lt__"], cd["__le__"], cd["__gt__"], cd["__ge__"] = ( + self._add_method_dunders(meth) + for meth in _make_order(self._cls, self._attrs) + ) + + return self + + def add_setattr(self): + if self._frozen: + return self + + sa_attrs = {} + for a in self._attrs: + on_setattr = a.on_setattr or self._on_setattr + if on_setattr and on_setattr is not setters.NO_OP: + sa_attrs[a.name] = a, on_setattr + + if not sa_attrs: + return self + + if self._has_custom_setattr: + # We need to write a __setattr__ but there already is one! + raise ValueError( + "Can't combine custom __setattr__ with on_setattr hooks." + ) + + # docstring comes from _add_method_dunders + def __setattr__(self, name, val): + try: + a, hook = sa_attrs[name] + except KeyError: + nval = val + else: + nval = hook(self, a, val) + + _obj_setattr(self, name, nval) + + self._cls_dict["__attrs_own_setattr__"] = True + self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__) + self._has_own_setattr = True + + return self + + def _add_method_dunders(self, method): + """ + Add __module__ and __qualname__ to a *method* if possible. + """ + try: + method.__module__ = self._cls.__module__ + except AttributeError: + pass + + try: + method.__qualname__ = ".".join( + (self._cls.__qualname__, method.__name__) + ) + except AttributeError: + pass + + try: + method.__doc__ = "Method generated by attrs for class %s." % ( + self._cls.__qualname__, + ) + except AttributeError: + pass + + return method + + +_CMP_DEPRECATION = ( + "The usage of `cmp` is deprecated and will be removed on or after " + "2021-06-01. Please use `eq` and `order` instead." +) + + +def _determine_attrs_eq_order(cmp, eq, order, default_eq): + """ + Validate the combination of *cmp*, *eq*, and *order*. Derive the effective + values of eq and order. If *eq* is None, set it to *default_eq*. + """ + if cmp is not None and any((eq is not None, order is not None)): + raise ValueError("Don't mix `cmp` with `eq' and `order`.") + + # cmp takes precedence due to bw-compatibility. + if cmp is not None: + return cmp, cmp + + # If left None, equality is set to the specified default and ordering + # mirrors equality. + if eq is None: + eq = default_eq + + if order is None: + order = eq + + if eq is False and order is True: + raise ValueError("`order` can only be True if `eq` is True too.") + + return eq, order + + +def _determine_attrib_eq_order(cmp, eq, order, default_eq): + """ + Validate the combination of *cmp*, *eq*, and *order*. Derive the effective + values of eq and order. If *eq* is None, set it to *default_eq*. + """ + if cmp is not None and any((eq is not None, order is not None)): + raise ValueError("Don't mix `cmp` with `eq' and `order`.") + + def decide_callable_or_boolean(value): + """ + Decide whether a key function is used. + """ + if callable(value): + value, key = True, value + else: + key = None + return value, key + + # cmp takes precedence due to bw-compatibility. + if cmp is not None: + cmp, cmp_key = decide_callable_or_boolean(cmp) + return cmp, cmp_key, cmp, cmp_key + + # If left None, equality is set to the specified default and ordering + # mirrors equality. + if eq is None: + eq, eq_key = default_eq, None + else: + eq, eq_key = decide_callable_or_boolean(eq) + + if order is None: + order, order_key = eq, eq_key + else: + order, order_key = decide_callable_or_boolean(order) + + if eq is False and order is True: + raise ValueError("`order` can only be True if `eq` is True too.") + + return eq, eq_key, order, order_key + + +def _determine_whether_to_implement( + cls, flag, auto_detect, dunders, default=True +): + """ + Check whether we should implement a set of methods for *cls*. + + *flag* is the argument passed into @attr.s like 'init', *auto_detect* the + same as passed into @attr.s and *dunders* is a tuple of attribute names + whose presence signal that the user has implemented it themselves. + + Return *default* if no reason for either for or against is found. + + auto_detect must be False on Python 2. + """ + if flag is True or flag is False: + return flag + + if flag is None and auto_detect is False: + return default + + # Logically, flag is None and auto_detect is True here. + for dunder in dunders: + if _has_own_attribute(cls, dunder): + return False + + return default + + +def attrs( + maybe_cls=None, + these=None, + repr_ns=None, + repr=None, + cmp=None, + hash=None, + init=None, + slots=False, + frozen=False, + weakref_slot=True, + str=False, + auto_attribs=False, + kw_only=False, + cache_hash=False, + auto_exc=False, + eq=None, + order=None, + auto_detect=False, + collect_by_mro=False, + getstate_setstate=None, + on_setattr=None, + field_transformer=None, +): + r""" + A class decorator that adds `dunder + `_\ -methods according to the + specified attributes using `attr.ib` or the *these* argument. + + :param these: A dictionary of name to `attr.ib` mappings. This is + useful to avoid the definition of your attributes within the class body + because you can't (e.g. if you want to add ``__repr__`` methods to + Django models) or don't want to. + + If *these* is not ``None``, ``attrs`` will *not* search the class body + for attributes and will *not* remove any attributes from it. + + If *these* is an ordered dict (`dict` on Python 3.6+, + `collections.OrderedDict` otherwise), the order is deduced from + the order of the attributes inside *these*. Otherwise the order + of the definition of the attributes is used. + + :type these: `dict` of `str` to `attr.ib` + + :param str repr_ns: When using nested classes, there's no way in Python 2 + to automatically detect that. Therefore it's possible to set the + namespace explicitly for a more meaningful ``repr`` output. + :param bool auto_detect: Instead of setting the *init*, *repr*, *eq*, + *order*, and *hash* arguments explicitly, assume they are set to + ``True`` **unless any** of the involved methods for one of the + arguments is implemented in the *current* class (i.e. it is *not* + inherited from some base class). + + So for example by implementing ``__eq__`` on a class yourself, + ``attrs`` will deduce ``eq=False`` and will create *neither* + ``__eq__`` *nor* ``__ne__`` (but Python classes come with a sensible + ``__ne__`` by default, so it *should* be enough to only implement + ``__eq__`` in most cases). + + .. warning:: + + If you prevent ``attrs`` from creating the ordering methods for you + (``order=False``, e.g. by implementing ``__le__``), it becomes + *your* responsibility to make sure its ordering is sound. The best + way is to use the `functools.total_ordering` decorator. + + + Passing ``True`` or ``False`` to *init*, *repr*, *eq*, *order*, + *cmp*, or *hash* overrides whatever *auto_detect* would determine. + + *auto_detect* requires Python 3. Setting it ``True`` on Python 2 raises + a `PythonTooOldError`. + + :param bool repr: Create a ``__repr__`` method with a human readable + representation of ``attrs`` attributes.. + :param bool str: Create a ``__str__`` method that is identical to + ``__repr__``. This is usually not necessary except for + `Exception`\ s. + :param Optional[bool] eq: If ``True`` or ``None`` (default), add ``__eq__`` + and ``__ne__`` methods that check two instances for equality. + + They compare the instances as if they were tuples of their ``attrs`` + attributes if and only if the types of both classes are *identical*! + :param Optional[bool] order: If ``True``, add ``__lt__``, ``__le__``, + ``__gt__``, and ``__ge__`` methods that behave like *eq* above and + allow instances to be ordered. If ``None`` (default) mirror value of + *eq*. + :param Optional[bool] cmp: Setting *cmp* is equivalent to setting *eq* + and *order* to the same value. Must not be mixed with *eq* or *order*. + :param Optional[bool] hash: If ``None`` (default), the ``__hash__`` method + is generated according how *eq* and *frozen* are set. + + 1. If *both* are True, ``attrs`` will generate a ``__hash__`` for you. + 2. If *eq* is True and *frozen* is False, ``__hash__`` will be set to + None, marking it unhashable (which it is). + 3. If *eq* is False, ``__hash__`` will be left untouched meaning the + ``__hash__`` method of the base class will be used (if base class is + ``object``, this means it will fall back to id-based hashing.). + + Although not recommended, you can decide for yourself and force + ``attrs`` to create one (e.g. if the class is immutable even though you + didn't freeze it programmatically) by passing ``True`` or not. Both of + these cases are rather special and should be used carefully. + + See our documentation on `hashing`, Python's documentation on + `object.__hash__`, and the `GitHub issue that led to the default \ + behavior `_ for more + details. + :param bool init: Create a ``__init__`` method that initializes the + ``attrs`` attributes. Leading underscores are stripped for the argument + name. If a ``__attrs_pre_init__`` method exists on the class, it will + be called before the class is initialized. If a ``__attrs_post_init__`` + method exists on the class, it will be called after the class is fully + initialized. + + If ``init`` is ``False``, an ``__attrs_init__`` method will be + injected instead. This allows you to define a custom ``__init__`` + method that can do pre-init work such as ``super().__init__()``, + and then call ``__attrs_init__()`` and ``__attrs_post_init__()``. + :param bool slots: Create a `slotted class ` that's more + memory-efficient. Slotted classes are generally superior to the default + dict classes, but have some gotchas you should know about, so we + encourage you to read the `glossary entry `. + :param bool frozen: Make instances immutable after initialization. If + someone attempts to modify a frozen instance, + `attr.exceptions.FrozenInstanceError` is raised. + + .. note:: + + 1. This is achieved by installing a custom ``__setattr__`` method + on your class, so you can't implement your own. + + 2. True immutability is impossible in Python. + + 3. This *does* have a minor a runtime performance `impact + ` when initializing new instances. In other words: + ``__init__`` is slightly slower with ``frozen=True``. + + 4. If a class is frozen, you cannot modify ``self`` in + ``__attrs_post_init__`` or a self-written ``__init__``. You can + circumvent that limitation by using + ``object.__setattr__(self, "attribute_name", value)``. + + 5. Subclasses of a frozen class are frozen too. + + :param bool weakref_slot: Make instances weak-referenceable. This has no + effect unless ``slots`` is also enabled. + :param bool auto_attribs: If ``True``, collect `PEP 526`_-annotated + attributes (Python 3.6 and later only) from the class body. + + In this case, you **must** annotate every field. If ``attrs`` + encounters a field that is set to an `attr.ib` but lacks a type + annotation, an `attr.exceptions.UnannotatedAttributeError` is + raised. Use ``field_name: typing.Any = attr.ib(...)`` if you don't + want to set a type. + + If you assign a value to those attributes (e.g. ``x: int = 42``), that + value becomes the default value like if it were passed using + ``attr.ib(default=42)``. Passing an instance of `Factory` also + works as expected in most cases (see warning below). + + Attributes annotated as `typing.ClassVar`, and attributes that are + neither annotated nor set to an `attr.ib` are **ignored**. + + .. warning:: + For features that use the attribute name to create decorators (e.g. + `validators `), you still *must* assign `attr.ib` to + them. Otherwise Python will either not find the name or try to use + the default value to call e.g. ``validator`` on it. + + These errors can be quite confusing and probably the most common bug + report on our bug tracker. + + .. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/ + :param bool kw_only: Make all attributes keyword-only (Python 3+) + in the generated ``__init__`` (if ``init`` is ``False``, this + parameter is ignored). + :param bool cache_hash: Ensure that the object's hash code is computed + only once and stored on the object. If this is set to ``True``, + hashing must be either explicitly or implicitly enabled for this + class. If the hash code is cached, avoid any reassignments of + fields involved in hash code computation or mutations of the objects + those fields point to after object creation. If such changes occur, + the behavior of the object's hash code is undefined. + :param bool auto_exc: If the class subclasses `BaseException` + (which implicitly includes any subclass of any exception), the + following happens to behave like a well-behaved Python exceptions + class: + + - the values for *eq*, *order*, and *hash* are ignored and the + instances compare and hash by the instance's ids (N.B. ``attrs`` will + *not* remove existing implementations of ``__hash__`` or the equality + methods. It just won't add own ones.), + - all attributes that are either passed into ``__init__`` or have a + default value are additionally available as a tuple in the ``args`` + attribute, + - the value of *str* is ignored leaving ``__str__`` to base classes. + :param bool collect_by_mro: Setting this to `True` fixes the way ``attrs`` + collects attributes from base classes. The default behavior is + incorrect in certain cases of multiple inheritance. It should be on by + default but is kept off for backward-compatability. + + See issue `#428 `_ for + more details. + + :param Optional[bool] getstate_setstate: + .. note:: + This is usually only interesting for slotted classes and you should + probably just set *auto_detect* to `True`. + + If `True`, ``__getstate__`` and + ``__setstate__`` are generated and attached to the class. This is + necessary for slotted classes to be pickleable. If left `None`, it's + `True` by default for slotted classes and ``False`` for dict classes. + + If *auto_detect* is `True`, and *getstate_setstate* is left `None`, + and **either** ``__getstate__`` or ``__setstate__`` is detected directly + on the class (i.e. not inherited), it is set to `False` (this is usually + what you want). + + :param on_setattr: A callable that is run whenever the user attempts to set + an attribute (either by assignment like ``i.x = 42`` or by using + `setattr` like ``setattr(i, "x", 42)``). It receives the same arguments + as validators: the instance, the attribute that is being modified, and + the new value. + + If no exception is raised, the attribute is set to the return value of + the callable. + + If a list of callables is passed, they're automatically wrapped in an + `attr.setters.pipe`. + + :param Optional[callable] field_transformer: + A function that is called with the original class object and all + fields right before ``attrs`` finalizes the class. You can use + this, e.g., to automatically add converters or validators to + fields based on their types. See `transform-fields` for more details. + + .. versionadded:: 16.0.0 *slots* + .. versionadded:: 16.1.0 *frozen* + .. versionadded:: 16.3.0 *str* + .. versionadded:: 16.3.0 Support for ``__attrs_post_init__``. + .. versionchanged:: 17.1.0 + *hash* supports ``None`` as value which is also the default now. + .. versionadded:: 17.3.0 *auto_attribs* + .. versionchanged:: 18.1.0 + If *these* is passed, no attributes are deleted from the class body. + .. versionchanged:: 18.1.0 If *these* is ordered, the order is retained. + .. versionadded:: 18.2.0 *weakref_slot* + .. deprecated:: 18.2.0 + ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` now raise a + `DeprecationWarning` if the classes compared are subclasses of + each other. ``__eq`` and ``__ne__`` never tried to compared subclasses + to each other. + .. versionchanged:: 19.2.0 + ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` now do not consider + subclasses comparable anymore. + .. versionadded:: 18.2.0 *kw_only* + .. versionadded:: 18.2.0 *cache_hash* + .. versionadded:: 19.1.0 *auto_exc* + .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. + .. versionadded:: 19.2.0 *eq* and *order* + .. versionadded:: 20.1.0 *auto_detect* + .. versionadded:: 20.1.0 *collect_by_mro* + .. versionadded:: 20.1.0 *getstate_setstate* + .. versionadded:: 20.1.0 *on_setattr* + .. versionadded:: 20.3.0 *field_transformer* + .. versionchanged:: 21.1.0 + ``init=False`` injects ``__attrs_init__`` + .. versionchanged:: 21.1.0 Support for ``__attrs_pre_init__`` + .. versionchanged:: 21.1.0 *cmp* undeprecated + """ + if auto_detect and PY2: + raise PythonTooOldError( + "auto_detect only works on Python 3 and later." + ) + + eq_, order_ = _determine_attrs_eq_order(cmp, eq, order, None) + hash_ = hash # work around the lack of nonlocal + + if isinstance(on_setattr, (list, tuple)): + on_setattr = setters.pipe(*on_setattr) + + def wrap(cls): + + if getattr(cls, "__class__", None) is None: + raise TypeError("attrs only works with new-style classes.") + + is_frozen = frozen or _has_frozen_base_class(cls) + is_exc = auto_exc is True and issubclass(cls, BaseException) + has_own_setattr = auto_detect and _has_own_attribute( + cls, "__setattr__" + ) + + if has_own_setattr and is_frozen: + raise ValueError("Can't freeze a class with a custom __setattr__.") + + builder = _ClassBuilder( + cls, + these, + slots, + is_frozen, + weakref_slot, + _determine_whether_to_implement( + cls, + getstate_setstate, + auto_detect, + ("__getstate__", "__setstate__"), + default=slots, + ), + auto_attribs, + kw_only, + cache_hash, + is_exc, + collect_by_mro, + on_setattr, + has_own_setattr, + field_transformer, + ) + if _determine_whether_to_implement( + cls, repr, auto_detect, ("__repr__",) + ): + builder.add_repr(repr_ns) + if str is True: + builder.add_str() + + eq = _determine_whether_to_implement( + cls, eq_, auto_detect, ("__eq__", "__ne__") + ) + if not is_exc and eq is True: + builder.add_eq() + if not is_exc and _determine_whether_to_implement( + cls, order_, auto_detect, ("__lt__", "__le__", "__gt__", "__ge__") + ): + builder.add_order() + + builder.add_setattr() + + if ( + hash_ is None + and auto_detect is True + and _has_own_attribute(cls, "__hash__") + ): + hash = False + else: + hash = hash_ + if hash is not True and hash is not False and hash is not None: + # Can't use `hash in` because 1 == True for example. + raise TypeError( + "Invalid value for hash. Must be True, False, or None." + ) + elif hash is False or (hash is None and eq is False) or is_exc: + # Don't do anything. Should fall back to __object__'s __hash__ + # which is by id. + if cache_hash: + raise TypeError( + "Invalid value for cache_hash. To use hash caching," + " hashing must be either explicitly or implicitly " + "enabled." + ) + elif hash is True or ( + hash is None and eq is True and is_frozen is True + ): + # Build a __hash__ if told so, or if it's safe. + builder.add_hash() + else: + # Raise TypeError on attempts to hash. + if cache_hash: + raise TypeError( + "Invalid value for cache_hash. To use hash caching," + " hashing must be either explicitly or implicitly " + "enabled." + ) + builder.make_unhashable() + + if _determine_whether_to_implement( + cls, init, auto_detect, ("__init__",) + ): + builder.add_init() + else: + builder.add_attrs_init() + if cache_hash: + raise TypeError( + "Invalid value for cache_hash. To use hash caching," + " init must be True." + ) + + return builder.build_class() + + # maybe_cls's type depends on the usage of the decorator. It's a class + # if it's used as `@attrs` but ``None`` if used as `@attrs()`. + if maybe_cls is None: + return wrap + else: + return wrap(maybe_cls) + + +_attrs = attrs +""" +Internal alias so we can use it in functions that take an argument called +*attrs*. +""" + + +if PY2: + + def _has_frozen_base_class(cls): + """ + Check whether *cls* has a frozen ancestor by looking at its + __setattr__. + """ + return ( + getattr(cls.__setattr__, "__module__", None) + == _frozen_setattrs.__module__ + and cls.__setattr__.__name__ == _frozen_setattrs.__name__ + ) + + +else: + + def _has_frozen_base_class(cls): + """ + Check whether *cls* has a frozen ancestor by looking at its + __setattr__. + """ + return cls.__setattr__ == _frozen_setattrs + + +def _generate_unique_filename(cls, func_name): + """ + Create a "filename" suitable for a function being generated. + """ + unique_id = uuid.uuid4() + extra = "" + count = 1 + + while True: + unique_filename = "".format( + func_name, + cls.__module__, + getattr(cls, "__qualname__", cls.__name__), + extra, + ) + # To handle concurrency we essentially "reserve" our spot in + # the linecache with a dummy line. The caller can then + # set this value correctly. + cache_line = (1, None, (str(unique_id),), unique_filename) + if ( + linecache.cache.setdefault(unique_filename, cache_line) + == cache_line + ): + return unique_filename + + # Looks like this spot is taken. Try again. + count += 1 + extra = "-{0}".format(count) + + +def _make_hash(cls, attrs, frozen, cache_hash): + attrs = tuple( + a for a in attrs if a.hash is True or (a.hash is None and a.eq is True) + ) + + tab = " " + + unique_filename = _generate_unique_filename(cls, "hash") + type_hash = hash(unique_filename) + + hash_def = "def __hash__(self" + hash_func = "hash((" + closing_braces = "))" + if not cache_hash: + hash_def += "):" + else: + if not PY2: + hash_def += ", *" + + hash_def += ( + ", _cache_wrapper=" + + "__import__('attr._make')._make._CacheHashWrapper):" + ) + hash_func = "_cache_wrapper(" + hash_func + closing_braces += ")" + + method_lines = [hash_def] + + def append_hash_computation_lines(prefix, indent): + """ + Generate the code for actually computing the hash code. + Below this will either be returned directly or used to compute + a value which is then cached, depending on the value of cache_hash + """ + + method_lines.extend( + [ + indent + prefix + hash_func, + indent + " %d," % (type_hash,), + ] + ) + + for a in attrs: + method_lines.append(indent + " self.%s," % a.name) + + method_lines.append(indent + " " + closing_braces) + + if cache_hash: + method_lines.append(tab + "if self.%s is None:" % _hash_cache_field) + if frozen: + append_hash_computation_lines( + "object.__setattr__(self, '%s', " % _hash_cache_field, tab * 2 + ) + method_lines.append(tab * 2 + ")") # close __setattr__ + else: + append_hash_computation_lines( + "self.%s = " % _hash_cache_field, tab * 2 + ) + method_lines.append(tab + "return self.%s" % _hash_cache_field) + else: + append_hash_computation_lines("return ", tab) + + script = "\n".join(method_lines) + return _make_method("__hash__", script, unique_filename) + + +def _add_hash(cls, attrs): + """ + Add a hash method to *cls*. + """ + cls.__hash__ = _make_hash(cls, attrs, frozen=False, cache_hash=False) + return cls + + +def _make_ne(): + """ + Create __ne__ method. + """ + + def __ne__(self, other): + """ + Check equality and either forward a NotImplemented or + return the result negated. + """ + result = self.__eq__(other) + if result is NotImplemented: + return NotImplemented + + return not result + + return __ne__ + + +def _make_eq(cls, attrs): + """ + Create __eq__ method for *cls* with *attrs*. + """ + attrs = [a for a in attrs if a.eq] + + unique_filename = _generate_unique_filename(cls, "eq") + lines = [ + "def __eq__(self, other):", + " if other.__class__ is not self.__class__:", + " return NotImplemented", + ] + + # We can't just do a big self.x = other.x and... clause due to + # irregularities like nan == nan is false but (nan,) == (nan,) is true. + globs = {} + if attrs: + lines.append(" return (") + others = [" ) == ("] + for a in attrs: + if a.eq_key: + cmp_name = "_%s_key" % (a.name,) + # Add the key function to the global namespace + # of the evaluated function. + globs[cmp_name] = a.eq_key + lines.append( + " %s(self.%s)," + % ( + cmp_name, + a.name, + ) + ) + others.append( + " %s(other.%s)," + % ( + cmp_name, + a.name, + ) + ) + else: + lines.append(" self.%s," % (a.name,)) + others.append(" other.%s," % (a.name,)) + + lines += others + [" )"] + else: + lines.append(" return True") + + script = "\n".join(lines) + + return _make_method("__eq__", script, unique_filename, globs) + + +def _make_order(cls, attrs): + """ + Create ordering methods for *cls* with *attrs*. + """ + attrs = [a for a in attrs if a.order] + + def attrs_to_tuple(obj): + """ + Save us some typing. + """ + return tuple( + key(value) if key else value + for value, key in ( + (getattr(obj, a.name), a.order_key) for a in attrs + ) + ) + + def __lt__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) < attrs_to_tuple(other) + + return NotImplemented + + def __le__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) <= attrs_to_tuple(other) + + return NotImplemented + + def __gt__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) > attrs_to_tuple(other) + + return NotImplemented + + def __ge__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) >= attrs_to_tuple(other) + + return NotImplemented + + return __lt__, __le__, __gt__, __ge__ + + +def _add_eq(cls, attrs=None): + """ + Add equality methods to *cls* with *attrs*. + """ + if attrs is None: + attrs = cls.__attrs_attrs__ + + cls.__eq__ = _make_eq(cls, attrs) + cls.__ne__ = _make_ne() + + return cls + + +_already_repring = threading.local() + + +def _make_repr(attrs, ns): + """ + Make a repr method that includes relevant *attrs*, adding *ns* to the full + name. + """ + + # Figure out which attributes to include, and which function to use to + # format them. The a.repr value can be either bool or a custom callable. + attr_names_with_reprs = tuple( + (a.name, repr if a.repr is True else a.repr) + for a in attrs + if a.repr is not False + ) + + def __repr__(self): + """ + Automatically created by attrs. + """ + try: + working_set = _already_repring.working_set + except AttributeError: + working_set = set() + _already_repring.working_set = working_set + + if id(self) in working_set: + return "..." + real_cls = self.__class__ + if ns is None: + qualname = getattr(real_cls, "__qualname__", None) + if qualname is not None: + class_name = qualname.rsplit(">.", 1)[-1] + else: + class_name = real_cls.__name__ + else: + class_name = ns + "." + real_cls.__name__ + + # Since 'self' remains on the stack (i.e.: strongly referenced) for the + # duration of this call, it's safe to depend on id(...) stability, and + # not need to track the instance and therefore worry about properties + # like weakref- or hash-ability. + working_set.add(id(self)) + try: + result = [class_name, "("] + first = True + for name, attr_repr in attr_names_with_reprs: + if first: + first = False + else: + result.append(", ") + result.extend( + (name, "=", attr_repr(getattr(self, name, NOTHING))) + ) + return "".join(result) + ")" + finally: + working_set.remove(id(self)) + + return __repr__ + + +def _add_repr(cls, ns=None, attrs=None): + """ + Add a repr method to *cls*. + """ + if attrs is None: + attrs = cls.__attrs_attrs__ + + cls.__repr__ = _make_repr(attrs, ns) + return cls + + +def fields(cls): + """ + Return the tuple of ``attrs`` attributes for a class. + + The tuple also allows accessing the fields by their names (see below for + examples). + + :param type cls: Class to introspect. + + :raise TypeError: If *cls* is not a class. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + :rtype: tuple (with name accessors) of `attr.Attribute` + + .. versionchanged:: 16.2.0 Returned tuple allows accessing the fields + by name. + """ + if not isclass(cls): + raise TypeError("Passed object must be a class.") + attrs = getattr(cls, "__attrs_attrs__", None) + if attrs is None: + raise NotAnAttrsClassError( + "{cls!r} is not an attrs-decorated class.".format(cls=cls) + ) + return attrs + + +def fields_dict(cls): + """ + Return an ordered dictionary of ``attrs`` attributes for a class, whose + keys are the attribute names. + + :param type cls: Class to introspect. + + :raise TypeError: If *cls* is not a class. + :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` + class. + + :rtype: an ordered dict where keys are attribute names and values are + `attr.Attribute`\\ s. This will be a `dict` if it's + naturally ordered like on Python 3.6+ or an + :class:`~collections.OrderedDict` otherwise. + + .. versionadded:: 18.1.0 + """ + if not isclass(cls): + raise TypeError("Passed object must be a class.") + attrs = getattr(cls, "__attrs_attrs__", None) + if attrs is None: + raise NotAnAttrsClassError( + "{cls!r} is not an attrs-decorated class.".format(cls=cls) + ) + return ordered_dict(((a.name, a) for a in attrs)) + + +def validate(inst): + """ + Validate all attributes on *inst* that have a validator. + + Leaves all exceptions through. + + :param inst: Instance of a class with ``attrs`` attributes. + """ + if _config._run_validators is False: + return + + for a in fields(inst.__class__): + v = a.validator + if v is not None: + v(inst, a, getattr(inst, a.name)) + + +def _is_slot_cls(cls): + return "__slots__" in cls.__dict__ + + +def _is_slot_attr(a_name, base_attr_map): + """ + Check if the attribute name comes from a slot class. + """ + return a_name in base_attr_map and _is_slot_cls(base_attr_map[a_name]) + + +def _make_init( + cls, + attrs, + pre_init, + post_init, + frozen, + slots, + cache_hash, + base_attr_map, + is_exc, + has_global_on_setattr, + attrs_init, +): + if frozen and has_global_on_setattr: + raise ValueError("Frozen classes can't use on_setattr.") + + needs_cached_setattr = cache_hash or frozen + filtered_attrs = [] + attr_dict = {} + for a in attrs: + if not a.init and a.default is NOTHING: + continue + + filtered_attrs.append(a) + attr_dict[a.name] = a + + if a.on_setattr is not None: + if frozen is True: + raise ValueError("Frozen classes can't use on_setattr.") + + needs_cached_setattr = True + elif ( + has_global_on_setattr and a.on_setattr is not setters.NO_OP + ) or _is_slot_attr(a.name, base_attr_map): + needs_cached_setattr = True + + unique_filename = _generate_unique_filename(cls, "init") + + script, globs, annotations = _attrs_to_init_script( + filtered_attrs, + frozen, + slots, + pre_init, + post_init, + cache_hash, + base_attr_map, + is_exc, + needs_cached_setattr, + has_global_on_setattr, + attrs_init, + ) + if cls.__module__ in sys.modules: + # This makes typing.get_type_hints(CLS.__init__) resolve string types. + globs.update(sys.modules[cls.__module__].__dict__) + + globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict}) + + if needs_cached_setattr: + # Save the lookup overhead in __init__ if we need to circumvent + # setattr hooks. + globs["_cached_setattr"] = _obj_setattr + + init = _make_method( + "__attrs_init__" if attrs_init else "__init__", + script, + unique_filename, + globs, + ) + init.__annotations__ = annotations + + return init + + +def _setattr(attr_name, value_var, has_on_setattr): + """ + Use the cached object.setattr to set *attr_name* to *value_var*. + """ + return "_setattr('%s', %s)" % (attr_name, value_var) + + +def _setattr_with_converter(attr_name, value_var, has_on_setattr): + """ + Use the cached object.setattr to set *attr_name* to *value_var*, but run + its converter first. + """ + return "_setattr('%s', %s(%s))" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) + + +def _assign(attr_name, value, has_on_setattr): + """ + Unless *attr_name* has an on_setattr hook, use normal assignment. Otherwise + relegate to _setattr. + """ + if has_on_setattr: + return _setattr(attr_name, value, True) + + return "self.%s = %s" % (attr_name, value) + + +def _assign_with_converter(attr_name, value_var, has_on_setattr): + """ + Unless *attr_name* has an on_setattr hook, use normal assignment after + conversion. Otherwise relegate to _setattr_with_converter. + """ + if has_on_setattr: + return _setattr_with_converter(attr_name, value_var, True) + + return "self.%s = %s(%s)" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) + + +if PY2: + + def _unpack_kw_only_py2(attr_name, default=None): + """ + Unpack *attr_name* from _kw_only dict. + """ + if default is not None: + arg_default = ", %s" % default + else: + arg_default = "" + return "%s = _kw_only.pop('%s'%s)" % ( + attr_name, + attr_name, + arg_default, + ) + + def _unpack_kw_only_lines_py2(kw_only_args): + """ + Unpack all *kw_only_args* from _kw_only dict and handle errors. + + Given a list of strings "{attr_name}" and "{attr_name}={default}" + generates list of lines of code that pop attrs from _kw_only dict and + raise TypeError similar to builtin if required attr is missing or + extra key is passed. + + >>> print("\n".join(_unpack_kw_only_lines_py2(["a", "b=42"]))) + try: + a = _kw_only.pop('a') + b = _kw_only.pop('b', 42) + except KeyError as _key_error: + raise TypeError( + ... + if _kw_only: + raise TypeError( + ... + """ + lines = ["try:"] + lines.extend( + " " + _unpack_kw_only_py2(*arg.split("=")) + for arg in kw_only_args + ) + lines += """\ +except KeyError as _key_error: + raise TypeError( + '__init__() missing required keyword-only argument: %s' % _key_error + ) +if _kw_only: + raise TypeError( + '__init__() got an unexpected keyword argument %r' + % next(iter(_kw_only)) + ) +""".split( + "\n" + ) + return lines + + +def _attrs_to_init_script( + attrs, + frozen, + slots, + pre_init, + post_init, + cache_hash, + base_attr_map, + is_exc, + needs_cached_setattr, + has_global_on_setattr, + attrs_init, +): + """ + Return a script of an initializer for *attrs* and a dict of globals. + + The globals are expected by the generated script. + + If *frozen* is True, we cannot set the attributes directly so we use + a cached ``object.__setattr__``. + """ + lines = [] + if pre_init: + lines.append("self.__attrs_pre_init__()") + + if needs_cached_setattr: + lines.append( + # Circumvent the __setattr__ descriptor to save one lookup per + # assignment. + # Note _setattr will be used again below if cache_hash is True + "_setattr = _cached_setattr.__get__(self, self.__class__)" + ) + + if frozen is True: + if slots is True: + fmt_setter = _setattr + fmt_setter_with_converter = _setattr_with_converter + else: + # Dict frozen classes assign directly to __dict__. + # But only if the attribute doesn't come from an ancestor slot + # class. + # Note _inst_dict will be used again below if cache_hash is True + lines.append("_inst_dict = self.__dict__") + + def fmt_setter(attr_name, value_var, has_on_setattr): + if _is_slot_attr(attr_name, base_attr_map): + return _setattr(attr_name, value_var, has_on_setattr) + + return "_inst_dict['%s'] = %s" % (attr_name, value_var) + + def fmt_setter_with_converter( + attr_name, value_var, has_on_setattr + ): + if has_on_setattr or _is_slot_attr(attr_name, base_attr_map): + return _setattr_with_converter( + attr_name, value_var, has_on_setattr + ) + + return "_inst_dict['%s'] = %s(%s)" % ( + attr_name, + _init_converter_pat % (attr_name,), + value_var, + ) + + else: + # Not frozen. + fmt_setter = _assign + fmt_setter_with_converter = _assign_with_converter + + args = [] + kw_only_args = [] + attrs_to_validate = [] + + # This is a dictionary of names to validator and converter callables. + # Injecting this into __init__ globals lets us avoid lookups. + names_for_globals = {} + annotations = {"return": None} + + for a in attrs: + if a.validator: + attrs_to_validate.append(a) + + attr_name = a.name + has_on_setattr = a.on_setattr is not None or ( + a.on_setattr is not setters.NO_OP and has_global_on_setattr + ) + arg_name = a.name.lstrip("_") + + has_factory = isinstance(a.default, Factory) + if has_factory and a.default.takes_self: + maybe_self = "self" + else: + maybe_self = "" + + if a.init is False: + if has_factory: + init_factory_name = _init_factory_pat.format(a.name) + if a.converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, + init_factory_name + "(%s)" % (maybe_self,), + has_on_setattr, + ) + ) + conv_name = _init_converter_pat % (a.name,) + names_for_globals[conv_name] = a.converter + else: + lines.append( + fmt_setter( + attr_name, + init_factory_name + "(%s)" % (maybe_self,), + has_on_setattr, + ) + ) + names_for_globals[init_factory_name] = a.default.factory + else: + if a.converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, + "attr_dict['%s'].default" % (attr_name,), + has_on_setattr, + ) + ) + conv_name = _init_converter_pat % (a.name,) + names_for_globals[conv_name] = a.converter + else: + lines.append( + fmt_setter( + attr_name, + "attr_dict['%s'].default" % (attr_name,), + has_on_setattr, + ) + ) + elif a.default is not NOTHING and not has_factory: + arg = "%s=attr_dict['%s'].default" % (arg_name, attr_name) + if a.kw_only: + kw_only_args.append(arg) + else: + args.append(arg) + + if a.converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) + ) + names_for_globals[ + _init_converter_pat % (a.name,) + ] = a.converter + else: + lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) + + elif has_factory: + arg = "%s=NOTHING" % (arg_name,) + if a.kw_only: + kw_only_args.append(arg) + else: + args.append(arg) + lines.append("if %s is not NOTHING:" % (arg_name,)) + + init_factory_name = _init_factory_pat.format(a.name) + if a.converter is not None: + lines.append( + " " + + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) + ) + lines.append("else:") + lines.append( + " " + + fmt_setter_with_converter( + attr_name, + init_factory_name + "(" + maybe_self + ")", + has_on_setattr, + ) + ) + names_for_globals[ + _init_converter_pat % (a.name,) + ] = a.converter + else: + lines.append( + " " + fmt_setter(attr_name, arg_name, has_on_setattr) + ) + lines.append("else:") + lines.append( + " " + + fmt_setter( + attr_name, + init_factory_name + "(" + maybe_self + ")", + has_on_setattr, + ) + ) + names_for_globals[init_factory_name] = a.default.factory + else: + if a.kw_only: + kw_only_args.append(arg_name) + else: + args.append(arg_name) + + if a.converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr + ) + ) + names_for_globals[ + _init_converter_pat % (a.name,) + ] = a.converter + else: + lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) + + if a.init is True: + if a.type is not None and a.converter is None: + annotations[arg_name] = a.type + elif a.converter is not None and not PY2: + # Try to get the type from the converter. + sig = None + try: + sig = inspect.signature(a.converter) + except (ValueError, TypeError): # inspect failed + pass + if sig: + sig_params = list(sig.parameters.values()) + if ( + sig_params + and sig_params[0].annotation + is not inspect.Parameter.empty + ): + annotations[arg_name] = sig_params[0].annotation + + if attrs_to_validate: # we can skip this if there are no validators. + names_for_globals["_config"] = _config + lines.append("if _config._run_validators is True:") + for a in attrs_to_validate: + val_name = "__attr_validator_" + a.name + attr_name = "__attr_" + a.name + lines.append( + " %s(self, %s, self.%s)" % (val_name, attr_name, a.name) + ) + names_for_globals[val_name] = a.validator + names_for_globals[attr_name] = a + + if post_init: + lines.append("self.__attrs_post_init__()") + + # because this is set only after __attrs_post_init is called, a crash + # will result if post-init tries to access the hash code. This seemed + # preferable to setting this beforehand, in which case alteration to + # field values during post-init combined with post-init accessing the + # hash code would result in silent bugs. + if cache_hash: + if frozen: + if slots: + # if frozen and slots, then _setattr defined above + init_hash_cache = "_setattr('%s', %s)" + else: + # if frozen and not slots, then _inst_dict defined above + init_hash_cache = "_inst_dict['%s'] = %s" + else: + init_hash_cache = "self.%s = %s" + lines.append(init_hash_cache % (_hash_cache_field, "None")) + + # For exceptions we rely on BaseException.__init__ for proper + # initialization. + if is_exc: + vals = ",".join("self." + a.name for a in attrs if a.init) + + lines.append("BaseException.__init__(self, %s)" % (vals,)) + + args = ", ".join(args) + if kw_only_args: + if PY2: + lines = _unpack_kw_only_lines_py2(kw_only_args) + lines + + args += "%s**_kw_only" % (", " if args else "",) # leading comma + else: + args += "%s*, %s" % ( + ", " if args else "", # leading comma + ", ".join(kw_only_args), # kw_only args + ) + return ( + """\ +def {init_name}(self, {args}): + {lines} +""".format( + init_name=("__attrs_init__" if attrs_init else "__init__"), + args=args, + lines="\n ".join(lines) if lines else "pass", + ), + names_for_globals, + annotations, + ) + + +class Attribute(object): + """ + *Read-only* representation of an attribute. + + Instances of this class are frequently used for introspection purposes + like: + + - `fields` returns a tuple of them. + - Validators get them passed as the first argument. + - The *field transformer* hook receives a list of them. + + :attribute name: The name of the attribute. + :attribute inherited: Whether or not that attribute has been inherited from + a base class. + + Plus *all* arguments of `attr.ib` (except for ``factory`` + which is only syntactic sugar for ``default=Factory(...)``. + + .. versionadded:: 20.1.0 *inherited* + .. versionadded:: 20.1.0 *on_setattr* + .. versionchanged:: 20.2.0 *inherited* is not taken into account for + equality checks and hashing anymore. + .. versionadded:: 21.1.0 *eq_key* and *order_key* + + For the full version history of the fields, see `attr.ib`. + """ + + __slots__ = ( + "name", + "default", + "validator", + "repr", + "eq", + "eq_key", + "order", + "order_key", + "hash", + "init", + "metadata", + "type", + "converter", + "kw_only", + "inherited", + "on_setattr", + ) + + def __init__( + self, + name, + default, + validator, + repr, + cmp, # XXX: unused, remove along with other cmp code. + hash, + init, + inherited, + metadata=None, + type=None, + converter=None, + kw_only=False, + eq=None, + eq_key=None, + order=None, + order_key=None, + on_setattr=None, + ): + eq, eq_key, order, order_key = _determine_attrib_eq_order( + cmp, eq_key or eq, order_key or order, True + ) + + # Cache this descriptor here to speed things up later. + bound_setattr = _obj_setattr.__get__(self, Attribute) + + # Despite the big red warning, people *do* instantiate `Attribute` + # themselves. + bound_setattr("name", name) + bound_setattr("default", default) + bound_setattr("validator", validator) + bound_setattr("repr", repr) + bound_setattr("eq", eq) + bound_setattr("eq_key", eq_key) + bound_setattr("order", order) + bound_setattr("order_key", order_key) + bound_setattr("hash", hash) + bound_setattr("init", init) + bound_setattr("converter", converter) + bound_setattr( + "metadata", + ( + metadata_proxy(metadata) + if metadata + else _empty_metadata_singleton + ), + ) + bound_setattr("type", type) + bound_setattr("kw_only", kw_only) + bound_setattr("inherited", inherited) + bound_setattr("on_setattr", on_setattr) + + def __setattr__(self, name, value): + raise FrozenInstanceError() + + @classmethod + def from_counting_attr(cls, name, ca, type=None): + # type holds the annotated value. deal with conflicts: + if type is None: + type = ca.type + elif ca.type is not None: + raise ValueError( + "Type annotation and type argument cannot both be present" + ) + inst_dict = { + k: getattr(ca, k) + for k in Attribute.__slots__ + if k + not in ( + "name", + "validator", + "default", + "type", + "inherited", + ) # exclude methods and deprecated alias + } + return cls( + name=name, + validator=ca._validator, + default=ca._default, + type=type, + cmp=None, + inherited=False, + **inst_dict + ) + + @property + def cmp(self): + """ + Simulate the presence of a cmp attribute and warn. + """ + warnings.warn(_CMP_DEPRECATION, DeprecationWarning, stacklevel=2) + + return self.eq and self.order + + # Don't use attr.evolve since fields(Attribute) doesn't work + def evolve(self, **changes): + """ + Copy *self* and apply *changes*. + + This works similarly to `attr.evolve` but that function does not work + with ``Attribute``. + + It is mainly meant to be used for `transform-fields`. + + .. versionadded:: 20.3.0 + """ + new = copy.copy(self) + + new._setattrs(changes.items()) + + return new + + # Don't use _add_pickle since fields(Attribute) doesn't work + def __getstate__(self): + """ + Play nice with pickle. + """ + return tuple( + getattr(self, name) if name != "metadata" else dict(self.metadata) + for name in self.__slots__ + ) + + def __setstate__(self, state): + """ + Play nice with pickle. + """ + self._setattrs(zip(self.__slots__, state)) + + def _setattrs(self, name_values_pairs): + bound_setattr = _obj_setattr.__get__(self, Attribute) + for name, value in name_values_pairs: + if name != "metadata": + bound_setattr(name, value) + else: + bound_setattr( + name, + metadata_proxy(value) + if value + else _empty_metadata_singleton, + ) + + +_a = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + eq=True, + order=False, + hash=(name != "metadata"), + init=True, + inherited=False, + ) + for name in Attribute.__slots__ +] + +Attribute = _add_hash( + _add_eq( + _add_repr(Attribute, attrs=_a), + attrs=[a for a in _a if a.name != "inherited"], + ), + attrs=[a for a in _a if a.hash and a.name != "inherited"], +) + + +class _CountingAttr(object): + """ + Intermediate representation of attributes that uses a counter to preserve + the order in which the attributes have been defined. + + *Internal* data structure of the attrs library. Running into is most + likely the result of a bug like a forgotten `@attr.s` decorator. + """ + + __slots__ = ( + "counter", + "_default", + "repr", + "eq", + "eq_key", + "order", + "order_key", + "hash", + "init", + "metadata", + "_validator", + "converter", + "type", + "kw_only", + "on_setattr", + ) + __attrs_attrs__ = tuple( + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + hash=True, + init=True, + kw_only=False, + eq=True, + eq_key=None, + order=False, + order_key=None, + inherited=False, + on_setattr=None, + ) + for name in ( + "counter", + "_default", + "repr", + "eq", + "order", + "hash", + "init", + "on_setattr", + ) + ) + ( + Attribute( + name="metadata", + default=None, + validator=None, + repr=True, + cmp=None, + hash=False, + init=True, + kw_only=False, + eq=True, + eq_key=None, + order=False, + order_key=None, + inherited=False, + on_setattr=None, + ), + ) + cls_counter = 0 + + def __init__( + self, + default, + validator, + repr, + cmp, + hash, + init, + converter, + metadata, + type, + kw_only, + eq, + eq_key, + order, + order_key, + on_setattr, + ): + _CountingAttr.cls_counter += 1 + self.counter = _CountingAttr.cls_counter + self._default = default + self._validator = validator + self.converter = converter + self.repr = repr + self.eq = eq + self.eq_key = eq_key + self.order = order + self.order_key = order_key + self.hash = hash + self.init = init + self.metadata = metadata + self.type = type + self.kw_only = kw_only + self.on_setattr = on_setattr + + def validator(self, meth): + """ + Decorator that adds *meth* to the list of validators. + + Returns *meth* unchanged. + + .. versionadded:: 17.1.0 + """ + if self._validator is None: + self._validator = meth + else: + self._validator = and_(self._validator, meth) + return meth + + def default(self, meth): + """ + Decorator that allows to set the default for an attribute. + + Returns *meth* unchanged. + + :raises DefaultAlreadySetError: If default has been set before. + + .. versionadded:: 17.1.0 + """ + if self._default is not NOTHING: + raise DefaultAlreadySetError() + + self._default = Factory(meth, takes_self=True) + + return meth + + +_CountingAttr = _add_eq(_add_repr(_CountingAttr)) + + +class Factory(object): + """ + Stores a factory callable. + + If passed as the default value to `attr.ib`, the factory is used to + generate a new value. + + :param callable factory: A callable that takes either none or exactly one + mandatory positional argument depending on *takes_self*. + :param bool takes_self: Pass the partially initialized instance that is + being initialized as a positional argument. + + .. versionadded:: 17.1.0 *takes_self* + """ + + __slots__ = ("factory", "takes_self") + + def __init__(self, factory, takes_self=False): + """ + `Factory` is part of the default machinery so if we want a default + value here, we have to implement it ourselves. + """ + self.factory = factory + self.takes_self = takes_self + + def __getstate__(self): + """ + Play nice with pickle. + """ + return tuple(getattr(self, name) for name in self.__slots__) + + def __setstate__(self, state): + """ + Play nice with pickle. + """ + for name, value in zip(self.__slots__, state): + setattr(self, name, value) + + +_f = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + eq=True, + order=False, + hash=True, + init=True, + inherited=False, + ) + for name in Factory.__slots__ +] + +Factory = _add_hash(_add_eq(_add_repr(Factory, attrs=_f), attrs=_f), attrs=_f) + + +def make_class(name, attrs, bases=(object,), **attributes_arguments): + """ + A quick way to create a new class called *name* with *attrs*. + + :param str name: The name for the new class. + + :param attrs: A list of names or a dictionary of mappings of names to + attributes. + + If *attrs* is a list or an ordered dict (`dict` on Python 3.6+, + `collections.OrderedDict` otherwise), the order is deduced from + the order of the names or attributes inside *attrs*. Otherwise the + order of the definition of the attributes is used. + :type attrs: `list` or `dict` + + :param tuple bases: Classes that the new class will subclass. + + :param attributes_arguments: Passed unmodified to `attr.s`. + + :return: A new class with *attrs*. + :rtype: type + + .. versionadded:: 17.1.0 *bases* + .. versionchanged:: 18.1.0 If *attrs* is ordered, the order is retained. + """ + if isinstance(attrs, dict): + cls_dict = attrs + elif isinstance(attrs, (list, tuple)): + cls_dict = dict((a, attrib()) for a in attrs) + else: + raise TypeError("attrs argument must be a dict or a list.") + + pre_init = cls_dict.pop("__attrs_pre_init__", None) + post_init = cls_dict.pop("__attrs_post_init__", None) + user_init = cls_dict.pop("__init__", None) + + body = {} + if pre_init is not None: + body["__attrs_pre_init__"] = pre_init + if post_init is not None: + body["__attrs_post_init__"] = post_init + if user_init is not None: + body["__init__"] = user_init + + type_ = new_class(name, bases, {}, lambda ns: ns.update(body)) + + # For pickling to work, the __module__ variable needs to be set to the + # frame where the class is created. Bypass this step in environments where + # sys._getframe is not defined (Jython for example) or sys._getframe is not + # defined for arguments greater than 0 (IronPython). + try: + type_.__module__ = sys._getframe(1).f_globals.get( + "__name__", "__main__" + ) + except (AttributeError, ValueError): + pass + + # We do it here for proper warnings with meaningful stacklevel. + cmp = attributes_arguments.pop("cmp", None) + ( + attributes_arguments["eq"], + attributes_arguments["order"], + ) = _determine_attrs_eq_order( + cmp, + attributes_arguments.get("eq"), + attributes_arguments.get("order"), + True, + ) + + return _attrs(these=cls_dict, **attributes_arguments)(type_) + + +# These are required by within this module so we define them here and merely +# import into .validators / .converters. + + +@attrs(slots=True, hash=True) +class _AndValidator(object): + """ + Compose many validators to a single one. + """ + + _validators = attrib() + + def __call__(self, inst, attr, value): + for v in self._validators: + v(inst, attr, value) + + +def and_(*validators): + """ + A validator that composes multiple validators into one. + + When called on a value, it runs all wrapped validators. + + :param callables validators: Arbitrary number of validators. + + .. versionadded:: 17.1.0 + """ + vals = [] + for validator in validators: + vals.extend( + validator._validators + if isinstance(validator, _AndValidator) + else [validator] + ) + + return _AndValidator(tuple(vals)) + + +def pipe(*converters): + """ + A converter that composes multiple converters into one. + + When called on a value, it runs all wrapped converters, returning the + *last* value. + + Type annotations will be inferred from the wrapped converters', if + they have any. + + :param callables converters: Arbitrary number of converters. + + .. versionadded:: 20.1.0 + """ + + def pipe_converter(val): + for converter in converters: + val = converter(val) + + return val + + if not PY2: + if not converters: + # If the converter list is empty, pipe_converter is the identity. + A = typing.TypeVar("A") + pipe_converter.__annotations__ = {"val": A, "return": A} + else: + # Get parameter type. + sig = None + try: + sig = inspect.signature(converters[0]) + except (ValueError, TypeError): # inspect failed + pass + if sig: + params = list(sig.parameters.values()) + if ( + params + and params[0].annotation is not inspect.Parameter.empty + ): + pipe_converter.__annotations__["val"] = params[ + 0 + ].annotation + # Get return type. + sig = None + try: + sig = inspect.signature(converters[-1]) + except (ValueError, TypeError): # inspect failed + pass + if sig and sig.return_annotation is not inspect.Signature().empty: + pipe_converter.__annotations__[ + "return" + ] = sig.return_annotation + + return pipe_converter diff --git a/openpype/hosts/fusion/vendor/attr/_next_gen.py b/openpype/hosts/fusion/vendor/attr/_next_gen.py new file mode 100644 index 0000000000..fab0af966a --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_next_gen.py @@ -0,0 +1,158 @@ +""" +These are Python 3.6+-only and keyword-only APIs that call `attr.s` and +`attr.ib` with different default values. +""" + +from functools import partial + +from attr.exceptions import UnannotatedAttributeError + +from . import setters +from ._make import NOTHING, _frozen_setattrs, attrib, attrs + + +def define( + maybe_cls=None, + *, + these=None, + repr=None, + hash=None, + init=None, + slots=True, + frozen=False, + weakref_slot=True, + str=False, + auto_attribs=None, + kw_only=False, + cache_hash=False, + auto_exc=True, + eq=None, + order=False, + auto_detect=True, + getstate_setstate=None, + on_setattr=None, + field_transformer=None, +): + r""" + The only behavioral differences are the handling of the *auto_attribs* + option: + + :param Optional[bool] auto_attribs: If set to `True` or `False`, it behaves + exactly like `attr.s`. If left `None`, `attr.s` will try to guess: + + 1. If any attributes are annotated and no unannotated `attr.ib`\ s + are found, it assumes *auto_attribs=True*. + 2. Otherwise it assumes *auto_attribs=False* and tries to collect + `attr.ib`\ s. + + and that mutable classes (``frozen=False``) validate on ``__setattr__``. + + .. versionadded:: 20.1.0 + """ + + def do_it(cls, auto_attribs): + return attrs( + maybe_cls=cls, + these=these, + repr=repr, + hash=hash, + init=init, + slots=slots, + frozen=frozen, + weakref_slot=weakref_slot, + str=str, + auto_attribs=auto_attribs, + kw_only=kw_only, + cache_hash=cache_hash, + auto_exc=auto_exc, + eq=eq, + order=order, + auto_detect=auto_detect, + collect_by_mro=True, + getstate_setstate=getstate_setstate, + on_setattr=on_setattr, + field_transformer=field_transformer, + ) + + def wrap(cls): + """ + Making this a wrapper ensures this code runs during class creation. + + We also ensure that frozen-ness of classes is inherited. + """ + nonlocal frozen, on_setattr + + had_on_setattr = on_setattr not in (None, setters.NO_OP) + + # By default, mutable classes validate on setattr. + if frozen is False and on_setattr is None: + on_setattr = setters.validate + + # However, if we subclass a frozen class, we inherit the immutability + # and disable on_setattr. + for base_cls in cls.__bases__: + if base_cls.__setattr__ is _frozen_setattrs: + if had_on_setattr: + raise ValueError( + "Frozen classes can't use on_setattr " + "(frozen-ness was inherited)." + ) + + on_setattr = setters.NO_OP + break + + if auto_attribs is not None: + return do_it(cls, auto_attribs) + + try: + return do_it(cls, True) + except UnannotatedAttributeError: + return do_it(cls, False) + + # maybe_cls's type depends on the usage of the decorator. It's a class + # if it's used as `@attrs` but ``None`` if used as `@attrs()`. + if maybe_cls is None: + return wrap + else: + return wrap(maybe_cls) + + +mutable = define +frozen = partial(define, frozen=True, on_setattr=None) + + +def field( + *, + default=NOTHING, + validator=None, + repr=True, + hash=None, + init=True, + metadata=None, + converter=None, + factory=None, + kw_only=False, + eq=None, + order=None, + on_setattr=None, +): + """ + Identical to `attr.ib`, except keyword-only and with some arguments + removed. + + .. versionadded:: 20.1.0 + """ + return attrib( + default=default, + validator=validator, + repr=repr, + hash=hash, + init=init, + metadata=metadata, + converter=converter, + factory=factory, + kw_only=kw_only, + eq=eq, + order=order, + on_setattr=on_setattr, + ) diff --git a/openpype/hosts/fusion/vendor/attr/_version_info.py b/openpype/hosts/fusion/vendor/attr/_version_info.py new file mode 100644 index 0000000000..014e78a1b4 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_version_info.py @@ -0,0 +1,85 @@ +from __future__ import absolute_import, division, print_function + +from functools import total_ordering + +from ._funcs import astuple +from ._make import attrib, attrs + + +@total_ordering +@attrs(eq=False, order=False, slots=True, frozen=True) +class VersionInfo(object): + """ + A version object that can be compared to tuple of length 1--4: + + >>> attr.VersionInfo(19, 1, 0, "final") <= (19, 2) + True + >>> attr.VersionInfo(19, 1, 0, "final") < (19, 1, 1) + True + >>> vi = attr.VersionInfo(19, 2, 0, "final") + >>> vi < (19, 1, 1) + False + >>> vi < (19,) + False + >>> vi == (19, 2,) + True + >>> vi == (19, 2, 1) + False + + .. versionadded:: 19.2 + """ + + year = attrib(type=int) + minor = attrib(type=int) + micro = attrib(type=int) + releaselevel = attrib(type=str) + + @classmethod + def _from_version_string(cls, s): + """ + Parse *s* and return a _VersionInfo. + """ + v = s.split(".") + if len(v) == 3: + v.append("final") + + return cls( + year=int(v[0]), minor=int(v[1]), micro=int(v[2]), releaselevel=v[3] + ) + + def _ensure_tuple(self, other): + """ + Ensure *other* is a tuple of a valid length. + + Returns a possibly transformed *other* and ourselves as a tuple of + the same length as *other*. + """ + + if self.__class__ is other.__class__: + other = astuple(other) + + if not isinstance(other, tuple): + raise NotImplementedError + + if not (1 <= len(other) <= 4): + raise NotImplementedError + + return astuple(self)[: len(other)], other + + def __eq__(self, other): + try: + us, them = self._ensure_tuple(other) + except NotImplementedError: + return NotImplemented + + return us == them + + def __lt__(self, other): + try: + us, them = self._ensure_tuple(other) + except NotImplementedError: + return NotImplemented + + # Since alphabetically "dev0" < "final" < "post1" < "post2", we don't + # have to do anything special with releaselevel for now. + return us < them diff --git a/openpype/hosts/fusion/vendor/attr/_version_info.pyi b/openpype/hosts/fusion/vendor/attr/_version_info.pyi new file mode 100644 index 0000000000..45ced08633 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/_version_info.pyi @@ -0,0 +1,9 @@ +class VersionInfo: + @property + def year(self) -> int: ... + @property + def minor(self) -> int: ... + @property + def micro(self) -> int: ... + @property + def releaselevel(self) -> str: ... diff --git a/openpype/hosts/fusion/vendor/attr/converters.py b/openpype/hosts/fusion/vendor/attr/converters.py new file mode 100644 index 0000000000..2777db6d0a --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/converters.py @@ -0,0 +1,111 @@ +""" +Commonly useful converters. +""" + +from __future__ import absolute_import, division, print_function + +from ._compat import PY2 +from ._make import NOTHING, Factory, pipe + + +if not PY2: + import inspect + import typing + + +__all__ = [ + "pipe", + "optional", + "default_if_none", +] + + +def optional(converter): + """ + A converter that allows an attribute to be optional. An optional attribute + is one which can be set to ``None``. + + Type annotations will be inferred from the wrapped converter's, if it + has any. + + :param callable converter: the converter that is used for non-``None`` + values. + + .. versionadded:: 17.1.0 + """ + + def optional_converter(val): + if val is None: + return None + return converter(val) + + if not PY2: + sig = None + try: + sig = inspect.signature(converter) + except (ValueError, TypeError): # inspect failed + pass + if sig: + params = list(sig.parameters.values()) + if params and params[0].annotation is not inspect.Parameter.empty: + optional_converter.__annotations__["val"] = typing.Optional[ + params[0].annotation + ] + if sig.return_annotation is not inspect.Signature.empty: + optional_converter.__annotations__["return"] = typing.Optional[ + sig.return_annotation + ] + + return optional_converter + + +def default_if_none(default=NOTHING, factory=None): + """ + A converter that allows to replace ``None`` values by *default* or the + result of *factory*. + + :param default: Value to be used if ``None`` is passed. Passing an instance + of `attr.Factory` is supported, however the ``takes_self`` option + is *not*. + :param callable factory: A callable that takes no parameters whose result + is used if ``None`` is passed. + + :raises TypeError: If **neither** *default* or *factory* is passed. + :raises TypeError: If **both** *default* and *factory* are passed. + :raises ValueError: If an instance of `attr.Factory` is passed with + ``takes_self=True``. + + .. versionadded:: 18.2.0 + """ + if default is NOTHING and factory is None: + raise TypeError("Must pass either `default` or `factory`.") + + if default is not NOTHING and factory is not None: + raise TypeError( + "Must pass either `default` or `factory` but not both." + ) + + if factory is not None: + default = Factory(factory) + + if isinstance(default, Factory): + if default.takes_self: + raise ValueError( + "`takes_self` is not supported by default_if_none." + ) + + def default_if_none_converter(val): + if val is not None: + return val + + return default.factory() + + else: + + def default_if_none_converter(val): + if val is not None: + return val + + return default + + return default_if_none_converter diff --git a/openpype/hosts/fusion/vendor/attr/converters.pyi b/openpype/hosts/fusion/vendor/attr/converters.pyi new file mode 100644 index 0000000000..84a57590b0 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/converters.pyi @@ -0,0 +1,13 @@ +from typing import Callable, Optional, TypeVar, overload + +from . import _ConverterType + + +_T = TypeVar("_T") + +def pipe(*validators: _ConverterType) -> _ConverterType: ... +def optional(converter: _ConverterType) -> _ConverterType: ... +@overload +def default_if_none(default: _T) -> _ConverterType: ... +@overload +def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ... diff --git a/openpype/hosts/fusion/vendor/attr/exceptions.py b/openpype/hosts/fusion/vendor/attr/exceptions.py new file mode 100644 index 0000000000..f6f9861bea --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/exceptions.py @@ -0,0 +1,92 @@ +from __future__ import absolute_import, division, print_function + + +class FrozenError(AttributeError): + """ + A frozen/immutable instance or attribute have been attempted to be + modified. + + It mirrors the behavior of ``namedtuples`` by using the same error message + and subclassing `AttributeError`. + + .. versionadded:: 20.1.0 + """ + + msg = "can't set attribute" + args = [msg] + + +class FrozenInstanceError(FrozenError): + """ + A frozen instance has been attempted to be modified. + + .. versionadded:: 16.1.0 + """ + + +class FrozenAttributeError(FrozenError): + """ + A frozen attribute has been attempted to be modified. + + .. versionadded:: 20.1.0 + """ + + +class AttrsAttributeNotFoundError(ValueError): + """ + An ``attrs`` function couldn't find an attribute that the user asked for. + + .. versionadded:: 16.2.0 + """ + + +class NotAnAttrsClassError(ValueError): + """ + A non-``attrs`` class has been passed into an ``attrs`` function. + + .. versionadded:: 16.2.0 + """ + + +class DefaultAlreadySetError(RuntimeError): + """ + A default has been set using ``attr.ib()`` and is attempted to be reset + using the decorator. + + .. versionadded:: 17.1.0 + """ + + +class UnannotatedAttributeError(RuntimeError): + """ + A class with ``auto_attribs=True`` has an ``attr.ib()`` without a type + annotation. + + .. versionadded:: 17.3.0 + """ + + +class PythonTooOldError(RuntimeError): + """ + It was attempted to use an ``attrs`` feature that requires a newer Python + version. + + .. versionadded:: 18.2.0 + """ + + +class NotCallableError(TypeError): + """ + A ``attr.ib()`` requiring a callable has been set with a value + that is not callable. + + .. versionadded:: 19.2.0 + """ + + def __init__(self, msg, value): + super(TypeError, self).__init__(msg, value) + self.msg = msg + self.value = value + + def __str__(self): + return str(self.msg) diff --git a/openpype/hosts/fusion/vendor/attr/exceptions.pyi b/openpype/hosts/fusion/vendor/attr/exceptions.pyi new file mode 100644 index 0000000000..a800fb26bb --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/exceptions.pyi @@ -0,0 +1,18 @@ +from typing import Any + + +class FrozenError(AttributeError): + msg: str = ... + +class FrozenInstanceError(FrozenError): ... +class FrozenAttributeError(FrozenError): ... +class AttrsAttributeNotFoundError(ValueError): ... +class NotAnAttrsClassError(ValueError): ... +class DefaultAlreadySetError(RuntimeError): ... +class UnannotatedAttributeError(RuntimeError): ... +class PythonTooOldError(RuntimeError): ... + +class NotCallableError(TypeError): + msg: str = ... + value: Any = ... + def __init__(self, msg: str, value: Any) -> None: ... diff --git a/openpype/hosts/fusion/vendor/attr/filters.py b/openpype/hosts/fusion/vendor/attr/filters.py new file mode 100644 index 0000000000..dc47e8fa38 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/filters.py @@ -0,0 +1,52 @@ +""" +Commonly useful filters for `attr.asdict`. +""" + +from __future__ import absolute_import, division, print_function + +from ._compat import isclass +from ._make import Attribute + + +def _split_what(what): + """ + Returns a tuple of `frozenset`s of classes and attributes. + """ + return ( + frozenset(cls for cls in what if isclass(cls)), + frozenset(cls for cls in what if isinstance(cls, Attribute)), + ) + + +def include(*what): + """ + Whitelist *what*. + + :param what: What to whitelist. + :type what: `list` of `type` or `attr.Attribute`\\ s + + :rtype: `callable` + """ + cls, attrs = _split_what(what) + + def include_(attribute, value): + return value.__class__ in cls or attribute in attrs + + return include_ + + +def exclude(*what): + """ + Blacklist *what*. + + :param what: What to blacklist. + :type what: `list` of classes or `attr.Attribute`\\ s. + + :rtype: `callable` + """ + cls, attrs = _split_what(what) + + def exclude_(attribute, value): + return value.__class__ not in cls and attribute not in attrs + + return exclude_ diff --git a/openpype/hosts/fusion/vendor/attr/filters.pyi b/openpype/hosts/fusion/vendor/attr/filters.pyi new file mode 100644 index 0000000000..f7b63f1bb4 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/filters.pyi @@ -0,0 +1,7 @@ +from typing import Any, Union + +from . import Attribute, _FilterType + + +def include(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ... +def exclude(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ... diff --git a/openpype/hosts/fusion/vendor/attr/py.typed b/openpype/hosts/fusion/vendor/attr/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/fusion/vendor/attr/setters.py b/openpype/hosts/fusion/vendor/attr/setters.py new file mode 100644 index 0000000000..240014b3c1 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/setters.py @@ -0,0 +1,77 @@ +""" +Commonly used hooks for on_setattr. +""" + +from __future__ import absolute_import, division, print_function + +from . import _config +from .exceptions import FrozenAttributeError + + +def pipe(*setters): + """ + Run all *setters* and return the return value of the last one. + + .. versionadded:: 20.1.0 + """ + + def wrapped_pipe(instance, attrib, new_value): + rv = new_value + + for setter in setters: + rv = setter(instance, attrib, rv) + + return rv + + return wrapped_pipe + + +def frozen(_, __, ___): + """ + Prevent an attribute to be modified. + + .. versionadded:: 20.1.0 + """ + raise FrozenAttributeError() + + +def validate(instance, attrib, new_value): + """ + Run *attrib*'s validator on *new_value* if it has one. + + .. versionadded:: 20.1.0 + """ + if _config._run_validators is False: + return new_value + + v = attrib.validator + if not v: + return new_value + + v(instance, attrib, new_value) + + return new_value + + +def convert(instance, attrib, new_value): + """ + Run *attrib*'s converter -- if it has one -- on *new_value* and return the + result. + + .. versionadded:: 20.1.0 + """ + c = attrib.converter + if c: + return c(new_value) + + return new_value + + +NO_OP = object() +""" +Sentinel for disabling class-wide *on_setattr* hooks for certain attributes. + +Does not work in `pipe` or within lists. + +.. versionadded:: 20.1.0 +""" diff --git a/openpype/hosts/fusion/vendor/attr/setters.pyi b/openpype/hosts/fusion/vendor/attr/setters.pyi new file mode 100644 index 0000000000..a921e07deb --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/setters.pyi @@ -0,0 +1,20 @@ +from typing import Any, NewType, NoReturn, TypeVar, cast + +from . import Attribute, _OnSetAttrType + + +_T = TypeVar("_T") + +def frozen( + instance: Any, attribute: Attribute[Any], new_value: Any +) -> NoReturn: ... +def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... +def validate(instance: Any, attribute: Attribute[_T], new_value: _T) -> _T: ... + +# convert is allowed to return Any, because they can be chained using pipe. +def convert( + instance: Any, attribute: Attribute[Any], new_value: Any +) -> Any: ... + +_NoOpType = NewType("_NoOpType", object) +NO_OP: _NoOpType diff --git a/openpype/hosts/fusion/vendor/attr/validators.py b/openpype/hosts/fusion/vendor/attr/validators.py new file mode 100644 index 0000000000..b9a73054e9 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/validators.py @@ -0,0 +1,379 @@ +""" +Commonly useful validators. +""" + +from __future__ import absolute_import, division, print_function + +import re + +from ._make import _AndValidator, and_, attrib, attrs +from .exceptions import NotCallableError + + +__all__ = [ + "and_", + "deep_iterable", + "deep_mapping", + "in_", + "instance_of", + "is_callable", + "matches_re", + "optional", + "provides", +] + + +@attrs(repr=False, slots=True, hash=True) +class _InstanceOfValidator(object): + type = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not isinstance(value, self.type): + raise TypeError( + "'{name}' must be {type!r} (got {value!r} that is a " + "{actual!r}).".format( + name=attr.name, + type=self.type, + actual=value.__class__, + value=value, + ), + attr, + self.type, + value, + ) + + def __repr__(self): + return "".format( + type=self.type + ) + + +def instance_of(type): + """ + A validator that raises a `TypeError` if the initializer is called + with a wrong type for this particular attribute (checks are performed using + `isinstance` therefore it's also valid to pass a tuple of types). + + :param type: The type to check for. + :type type: type or tuple of types + + :raises TypeError: With a human readable error message, the attribute + (of type `attr.Attribute`), the expected type, and the value it + got. + """ + return _InstanceOfValidator(type) + + +@attrs(repr=False, frozen=True, slots=True) +class _MatchesReValidator(object): + regex = attrib() + flags = attrib() + match_func = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.match_func(value): + raise ValueError( + "'{name}' must match regex {regex!r}" + " ({value!r} doesn't)".format( + name=attr.name, regex=self.regex.pattern, value=value + ), + attr, + self.regex, + value, + ) + + def __repr__(self): + return "".format( + regex=self.regex + ) + + +def matches_re(regex, flags=0, func=None): + r""" + A validator that raises `ValueError` if the initializer is called + with a string that doesn't match *regex*. + + :param str regex: a regex string to match against + :param int flags: flags that will be passed to the underlying re function + (default 0) + :param callable func: which underlying `re` function to call (options + are `re.fullmatch`, `re.search`, `re.match`, default + is ``None`` which means either `re.fullmatch` or an emulation of + it on Python 2). For performance reasons, they won't be used directly + but on a pre-`re.compile`\ ed pattern. + + .. versionadded:: 19.2.0 + """ + fullmatch = getattr(re, "fullmatch", None) + valid_funcs = (fullmatch, None, re.search, re.match) + if func not in valid_funcs: + raise ValueError( + "'func' must be one of %s." + % ( + ", ".join( + sorted( + e and e.__name__ or "None" for e in set(valid_funcs) + ) + ), + ) + ) + + pattern = re.compile(regex, flags) + if func is re.match: + match_func = pattern.match + elif func is re.search: + match_func = pattern.search + else: + if fullmatch: + match_func = pattern.fullmatch + else: + pattern = re.compile(r"(?:{})\Z".format(regex), flags) + match_func = pattern.match + + return _MatchesReValidator(pattern, flags, match_func) + + +@attrs(repr=False, slots=True, hash=True) +class _ProvidesValidator(object): + interface = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.interface.providedBy(value): + raise TypeError( + "'{name}' must provide {interface!r} which {value!r} " + "doesn't.".format( + name=attr.name, interface=self.interface, value=value + ), + attr, + self.interface, + value, + ) + + def __repr__(self): + return "".format( + interface=self.interface + ) + + +def provides(interface): + """ + A validator that raises a `TypeError` if the initializer is called + with an object that does not provide the requested *interface* (checks are + performed using ``interface.providedBy(value)`` (see `zope.interface + `_). + + :param interface: The interface to check for. + :type interface: ``zope.interface.Interface`` + + :raises TypeError: With a human readable error message, the attribute + (of type `attr.Attribute`), the expected interface, and the + value it got. + """ + return _ProvidesValidator(interface) + + +@attrs(repr=False, slots=True, hash=True) +class _OptionalValidator(object): + validator = attrib() + + def __call__(self, inst, attr, value): + if value is None: + return + + self.validator(inst, attr, value) + + def __repr__(self): + return "".format( + what=repr(self.validator) + ) + + +def optional(validator): + """ + A validator that makes an attribute optional. An optional attribute is one + which can be set to ``None`` in addition to satisfying the requirements of + the sub-validator. + + :param validator: A validator (or a list of validators) that is used for + non-``None`` values. + :type validator: callable or `list` of callables. + + .. versionadded:: 15.1.0 + .. versionchanged:: 17.1.0 *validator* can be a list of validators. + """ + if isinstance(validator, list): + return _OptionalValidator(_AndValidator(validator)) + return _OptionalValidator(validator) + + +@attrs(repr=False, slots=True, hash=True) +class _InValidator(object): + options = attrib() + + def __call__(self, inst, attr, value): + try: + in_options = value in self.options + except TypeError: # e.g. `1 in "abc"` + in_options = False + + if not in_options: + raise ValueError( + "'{name}' must be in {options!r} (got {value!r})".format( + name=attr.name, options=self.options, value=value + ) + ) + + def __repr__(self): + return "".format( + options=self.options + ) + + +def in_(options): + """ + A validator that raises a `ValueError` if the initializer is called + with a value that does not belong in the options provided. The check is + performed using ``value in options``. + + :param options: Allowed options. + :type options: list, tuple, `enum.Enum`, ... + + :raises ValueError: With a human readable error message, the attribute (of + type `attr.Attribute`), the expected options, and the value it + got. + + .. versionadded:: 17.1.0 + """ + return _InValidator(options) + + +@attrs(repr=False, slots=False, hash=True) +class _IsCallableValidator(object): + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not callable(value): + message = ( + "'{name}' must be callable " + "(got {value!r} that is a {actual!r})." + ) + raise NotCallableError( + msg=message.format( + name=attr.name, value=value, actual=value.__class__ + ), + value=value, + ) + + def __repr__(self): + return "" + + +def is_callable(): + """ + A validator that raises a `attr.exceptions.NotCallableError` if the + initializer is called with a value for this particular attribute + that is not callable. + + .. versionadded:: 19.1.0 + + :raises `attr.exceptions.NotCallableError`: With a human readable error + message containing the attribute (`attr.Attribute`) name, + and the value it got. + """ + return _IsCallableValidator() + + +@attrs(repr=False, slots=True, hash=True) +class _DeepIterable(object): + member_validator = attrib(validator=is_callable()) + iterable_validator = attrib( + default=None, validator=optional(is_callable()) + ) + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if self.iterable_validator is not None: + self.iterable_validator(inst, attr, value) + + for member in value: + self.member_validator(inst, attr, member) + + def __repr__(self): + iterable_identifier = ( + "" + if self.iterable_validator is None + else " {iterable!r}".format(iterable=self.iterable_validator) + ) + return ( + "" + ).format( + iterable_identifier=iterable_identifier, + member=self.member_validator, + ) + + +def deep_iterable(member_validator, iterable_validator=None): + """ + A validator that performs deep validation of an iterable. + + :param member_validator: Validator to apply to iterable members + :param iterable_validator: Validator to apply to iterable itself + (optional) + + .. versionadded:: 19.1.0 + + :raises TypeError: if any sub-validators fail + """ + return _DeepIterable(member_validator, iterable_validator) + + +@attrs(repr=False, slots=True, hash=True) +class _DeepMapping(object): + key_validator = attrib(validator=is_callable()) + value_validator = attrib(validator=is_callable()) + mapping_validator = attrib(default=None, validator=optional(is_callable())) + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if self.mapping_validator is not None: + self.mapping_validator(inst, attr, value) + + for key in value: + self.key_validator(inst, attr, key) + self.value_validator(inst, attr, value[key]) + + def __repr__(self): + return ( + "" + ).format(key=self.key_validator, value=self.value_validator) + + +def deep_mapping(key_validator, value_validator, mapping_validator=None): + """ + A validator that performs deep validation of a dictionary. + + :param key_validator: Validator to apply to dictionary keys + :param value_validator: Validator to apply to dictionary values + :param mapping_validator: Validator to apply to top-level mapping + attribute (optional) + + .. versionadded:: 19.1.0 + + :raises TypeError: if any sub-validators fail + """ + return _DeepMapping(key_validator, value_validator, mapping_validator) diff --git a/openpype/hosts/fusion/vendor/attr/validators.pyi b/openpype/hosts/fusion/vendor/attr/validators.pyi new file mode 100644 index 0000000000..fe92aac421 --- /dev/null +++ b/openpype/hosts/fusion/vendor/attr/validators.pyi @@ -0,0 +1,68 @@ +from typing import ( + Any, + AnyStr, + Callable, + Container, + Iterable, + List, + Mapping, + Match, + Optional, + Tuple, + Type, + TypeVar, + Union, + overload, +) + +from . import _ValidatorType + + +_T = TypeVar("_T") +_T1 = TypeVar("_T1") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") +_I = TypeVar("_I", bound=Iterable) +_K = TypeVar("_K") +_V = TypeVar("_V") +_M = TypeVar("_M", bound=Mapping) + +# To be more precise on instance_of use some overloads. +# If there are more than 3 items in the tuple then we fall back to Any +@overload +def instance_of(type: Type[_T]) -> _ValidatorType[_T]: ... +@overload +def instance_of(type: Tuple[Type[_T]]) -> _ValidatorType[_T]: ... +@overload +def instance_of( + type: Tuple[Type[_T1], Type[_T2]] +) -> _ValidatorType[Union[_T1, _T2]]: ... +@overload +def instance_of( + type: Tuple[Type[_T1], Type[_T2], Type[_T3]] +) -> _ValidatorType[Union[_T1, _T2, _T3]]: ... +@overload +def instance_of(type: Tuple[type, ...]) -> _ValidatorType[Any]: ... +def provides(interface: Any) -> _ValidatorType[Any]: ... +def optional( + validator: Union[_ValidatorType[_T], List[_ValidatorType[_T]]] +) -> _ValidatorType[Optional[_T]]: ... +def in_(options: Container[_T]) -> _ValidatorType[_T]: ... +def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... +def matches_re( + regex: AnyStr, + flags: int = ..., + func: Optional[ + Callable[[AnyStr, AnyStr, int], Optional[Match[AnyStr]]] + ] = ..., +) -> _ValidatorType[AnyStr]: ... +def deep_iterable( + member_validator: _ValidatorType[_T], + iterable_validator: Optional[_ValidatorType[_I]] = ..., +) -> _ValidatorType[_I]: ... +def deep_mapping( + key_validator: _ValidatorType[_K], + value_validator: _ValidatorType[_V], + mapping_validator: Optional[_ValidatorType[_M]] = ..., +) -> _ValidatorType[_M]: ... +def is_callable() -> _ValidatorType[_T]: ... diff --git a/openpype/hosts/fusion/vendor/urllib3/__init__.py b/openpype/hosts/fusion/vendor/urllib3/__init__.py new file mode 100644 index 0000000000..fe86b59d78 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/__init__.py @@ -0,0 +1,85 @@ +""" +Python HTTP library with thread-safe connection pooling, file post support, user friendly, and more +""" +from __future__ import absolute_import + +# Set default logging handler to avoid "No handler found" warnings. +import logging +import warnings +from logging import NullHandler + +from . import exceptions +from ._version import __version__ +from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool, connection_from_url +from .filepost import encode_multipart_formdata +from .poolmanager import PoolManager, ProxyManager, proxy_from_url +from .response import HTTPResponse +from .util.request import make_headers +from .util.retry import Retry +from .util.timeout import Timeout +from .util.url import get_host + +__author__ = "Andrey Petrov (andrey.petrov@shazow.net)" +__license__ = "MIT" +__version__ = __version__ + +__all__ = ( + "HTTPConnectionPool", + "HTTPSConnectionPool", + "PoolManager", + "ProxyManager", + "HTTPResponse", + "Retry", + "Timeout", + "add_stderr_logger", + "connection_from_url", + "disable_warnings", + "encode_multipart_formdata", + "get_host", + "make_headers", + "proxy_from_url", +) + +logging.getLogger(__name__).addHandler(NullHandler()) + + +def add_stderr_logger(level=logging.DEBUG): + """ + Helper for quickly adding a StreamHandler to the logger. Useful for + debugging. + + Returns the handler after adding it. + """ + # This method needs to be in this __init__.py to get the __name__ correct + # even if urllib3 is vendored within another package. + logger = logging.getLogger(__name__) + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + logger.addHandler(handler) + logger.setLevel(level) + logger.debug("Added a stderr logging handler to logger: %s", __name__) + return handler + + +# ... Clean up. +del NullHandler + + +# All warning filters *must* be appended unless you're really certain that they +# shouldn't be: otherwise, it's very hard for users to use most Python +# mechanisms to silence them. +# SecurityWarning's always go off by default. +warnings.simplefilter("always", exceptions.SecurityWarning, append=True) +# SubjectAltNameWarning's should go off once per host +warnings.simplefilter("default", exceptions.SubjectAltNameWarning, append=True) +# InsecurePlatformWarning's don't vary between requests, so we keep it default. +warnings.simplefilter("default", exceptions.InsecurePlatformWarning, append=True) +# SNIMissingWarnings should go off only once. +warnings.simplefilter("default", exceptions.SNIMissingWarning, append=True) + + +def disable_warnings(category=exceptions.HTTPWarning): + """ + Helper for quickly disabling all urllib3 warnings. + """ + warnings.simplefilter("ignore", category) diff --git a/openpype/hosts/fusion/vendor/urllib3/_collections.py b/openpype/hosts/fusion/vendor/urllib3/_collections.py new file mode 100644 index 0000000000..da9857e986 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/_collections.py @@ -0,0 +1,337 @@ +from __future__ import absolute_import + +try: + from collections.abc import Mapping, MutableMapping +except ImportError: + from collections import Mapping, MutableMapping +try: + from threading import RLock +except ImportError: # Platform-specific: No threads available + + class RLock: + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + pass + + +from collections import OrderedDict + +from .exceptions import InvalidHeader +from .packages import six +from .packages.six import iterkeys, itervalues + +__all__ = ["RecentlyUsedContainer", "HTTPHeaderDict"] + + +_Null = object() + + +class RecentlyUsedContainer(MutableMapping): + """ + Provides a thread-safe dict-like container which maintains up to + ``maxsize`` keys while throwing away the least-recently-used keys beyond + ``maxsize``. + + :param maxsize: + Maximum number of recent elements to retain. + + :param dispose_func: + Every time an item is evicted from the container, + ``dispose_func(value)`` is called. Callback which will get called + """ + + ContainerCls = OrderedDict + + def __init__(self, maxsize=10, dispose_func=None): + self._maxsize = maxsize + self.dispose_func = dispose_func + + self._container = self.ContainerCls() + self.lock = RLock() + + def __getitem__(self, key): + # Re-insert the item, moving it to the end of the eviction line. + with self.lock: + item = self._container.pop(key) + self._container[key] = item + return item + + def __setitem__(self, key, value): + evicted_value = _Null + with self.lock: + # Possibly evict the existing value of 'key' + evicted_value = self._container.get(key, _Null) + self._container[key] = value + + # If we didn't evict an existing value, we might have to evict the + # least recently used item from the beginning of the container. + if len(self._container) > self._maxsize: + _key, evicted_value = self._container.popitem(last=False) + + if self.dispose_func and evicted_value is not _Null: + self.dispose_func(evicted_value) + + def __delitem__(self, key): + with self.lock: + value = self._container.pop(key) + + if self.dispose_func: + self.dispose_func(value) + + def __len__(self): + with self.lock: + return len(self._container) + + def __iter__(self): + raise NotImplementedError( + "Iteration over this class is unlikely to be threadsafe." + ) + + def clear(self): + with self.lock: + # Copy pointers to all values, then wipe the mapping + values = list(itervalues(self._container)) + self._container.clear() + + if self.dispose_func: + for value in values: + self.dispose_func(value) + + def keys(self): + with self.lock: + return list(iterkeys(self._container)) + + +class HTTPHeaderDict(MutableMapping): + """ + :param headers: + An iterable of field-value pairs. Must not contain multiple field names + when compared case-insensitively. + + :param kwargs: + Additional field-value pairs to pass in to ``dict.update``. + + A ``dict`` like container for storing HTTP Headers. + + Field names are stored and compared case-insensitively in compliance with + RFC 7230. Iteration provides the first case-sensitive key seen for each + case-insensitive pair. + + Using ``__setitem__`` syntax overwrites fields that compare equal + case-insensitively in order to maintain ``dict``'s api. For fields that + compare equal, instead create a new ``HTTPHeaderDict`` and use ``.add`` + in a loop. + + If multiple fields that are equal case-insensitively are passed to the + constructor or ``.update``, the behavior is undefined and some will be + lost. + + >>> headers = HTTPHeaderDict() + >>> headers.add('Set-Cookie', 'foo=bar') + >>> headers.add('set-cookie', 'baz=quxx') + >>> headers['content-length'] = '7' + >>> headers['SET-cookie'] + 'foo=bar, baz=quxx' + >>> headers['Content-Length'] + '7' + """ + + def __init__(self, headers=None, **kwargs): + super(HTTPHeaderDict, self).__init__() + self._container = OrderedDict() + if headers is not None: + if isinstance(headers, HTTPHeaderDict): + self._copy_from(headers) + else: + self.extend(headers) + if kwargs: + self.extend(kwargs) + + def __setitem__(self, key, val): + self._container[key.lower()] = [key, val] + return self._container[key.lower()] + + def __getitem__(self, key): + val = self._container[key.lower()] + return ", ".join(val[1:]) + + def __delitem__(self, key): + del self._container[key.lower()] + + def __contains__(self, key): + return key.lower() in self._container + + def __eq__(self, other): + if not isinstance(other, Mapping) and not hasattr(other, "keys"): + return False + if not isinstance(other, type(self)): + other = type(self)(other) + return dict((k.lower(), v) for k, v in self.itermerged()) == dict( + (k.lower(), v) for k, v in other.itermerged() + ) + + def __ne__(self, other): + return not self.__eq__(other) + + if six.PY2: # Python 2 + iterkeys = MutableMapping.iterkeys + itervalues = MutableMapping.itervalues + + __marker = object() + + def __len__(self): + return len(self._container) + + def __iter__(self): + # Only provide the originally cased names + for vals in self._container.values(): + yield vals[0] + + def pop(self, key, default=__marker): + """D.pop(k[,d]) -> v, remove specified key and return the corresponding value. + If key is not found, d is returned if given, otherwise KeyError is raised. + """ + # Using the MutableMapping function directly fails due to the private marker. + # Using ordinary dict.pop would expose the internal structures. + # So let's reinvent the wheel. + try: + value = self[key] + except KeyError: + if default is self.__marker: + raise + return default + else: + del self[key] + return value + + def discard(self, key): + try: + del self[key] + except KeyError: + pass + + def add(self, key, val): + """Adds a (name, value) pair, doesn't overwrite the value if it already + exists. + + >>> headers = HTTPHeaderDict(foo='bar') + >>> headers.add('Foo', 'baz') + >>> headers['foo'] + 'bar, baz' + """ + key_lower = key.lower() + new_vals = [key, val] + # Keep the common case aka no item present as fast as possible + vals = self._container.setdefault(key_lower, new_vals) + if new_vals is not vals: + vals.append(val) + + def extend(self, *args, **kwargs): + """Generic import function for any type of header-like object. + Adapted version of MutableMapping.update in order to insert items + with self.add instead of self.__setitem__ + """ + if len(args) > 1: + raise TypeError( + "extend() takes at most 1 positional " + "arguments ({0} given)".format(len(args)) + ) + other = args[0] if len(args) >= 1 else () + + if isinstance(other, HTTPHeaderDict): + for key, val in other.iteritems(): + self.add(key, val) + elif isinstance(other, Mapping): + for key in other: + self.add(key, other[key]) + elif hasattr(other, "keys"): + for key in other.keys(): + self.add(key, other[key]) + else: + for key, value in other: + self.add(key, value) + + for key, value in kwargs.items(): + self.add(key, value) + + def getlist(self, key, default=__marker): + """Returns a list of all the values for the named field. Returns an + empty list if the key doesn't exist.""" + try: + vals = self._container[key.lower()] + except KeyError: + if default is self.__marker: + return [] + return default + else: + return vals[1:] + + # Backwards compatibility for httplib + getheaders = getlist + getallmatchingheaders = getlist + iget = getlist + + # Backwards compatibility for http.cookiejar + get_all = getlist + + def __repr__(self): + return "%s(%s)" % (type(self).__name__, dict(self.itermerged())) + + def _copy_from(self, other): + for key in other: + val = other.getlist(key) + if isinstance(val, list): + # Don't need to convert tuples + val = list(val) + self._container[key.lower()] = [key] + val + + def copy(self): + clone = type(self)() + clone._copy_from(self) + return clone + + def iteritems(self): + """Iterate over all header lines, including duplicate ones.""" + for key in self: + vals = self._container[key.lower()] + for val in vals[1:]: + yield vals[0], val + + def itermerged(self): + """Iterate over all headers, merging duplicate ones together.""" + for key in self: + val = self._container[key.lower()] + yield val[0], ", ".join(val[1:]) + + def items(self): + return list(self.iteritems()) + + @classmethod + def from_httplib(cls, message): # Python 2 + """Read headers from a Python 2 httplib message object.""" + # python2.7 does not expose a proper API for exporting multiheaders + # efficiently. This function re-reads raw lines from the message + # object and extracts the multiheaders properly. + obs_fold_continued_leaders = (" ", "\t") + headers = [] + + for line in message.headers: + if line.startswith(obs_fold_continued_leaders): + if not headers: + # We received a header line that starts with OWS as described + # in RFC-7230 S3.2.4. This indicates a multiline header, but + # there exists no previous header to which we can attach it. + raise InvalidHeader( + "Header continuation with no previous header: %s" % line + ) + else: + key, value = headers[-1] + headers[-1] = (key, value + " " + line.strip()) + continue + + key, value = line.split(":", 1) + headers.append((key, value.strip())) + + return cls(headers) diff --git a/openpype/hosts/fusion/vendor/urllib3/_version.py b/openpype/hosts/fusion/vendor/urllib3/_version.py new file mode 100644 index 0000000000..e8ebee957f --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/_version.py @@ -0,0 +1,2 @@ +# This file is protected via CODEOWNERS +__version__ = "1.26.6" diff --git a/openpype/hosts/fusion/vendor/urllib3/connection.py b/openpype/hosts/fusion/vendor/urllib3/connection.py new file mode 100644 index 0000000000..4c996659c8 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/connection.py @@ -0,0 +1,539 @@ +from __future__ import absolute_import + +import datetime +import logging +import os +import re +import socket +import warnings +from socket import error as SocketError +from socket import timeout as SocketTimeout + +from .packages import six +from .packages.six.moves.http_client import HTTPConnection as _HTTPConnection +from .packages.six.moves.http_client import HTTPException # noqa: F401 +from .util.proxy import create_proxy_ssl_context + +try: # Compiled with SSL? + import ssl + + BaseSSLError = ssl.SSLError +except (ImportError, AttributeError): # Platform-specific: No SSL. + ssl = None + + class BaseSSLError(BaseException): + pass + + +try: + # Python 3: not a no-op, we're adding this to the namespace so it can be imported. + ConnectionError = ConnectionError +except NameError: + # Python 2 + class ConnectionError(Exception): + pass + + +try: # Python 3: + # Not a no-op, we're adding this to the namespace so it can be imported. + BrokenPipeError = BrokenPipeError +except NameError: # Python 2: + + class BrokenPipeError(Exception): + pass + + +from ._collections import HTTPHeaderDict # noqa (historical, removed in v2) +from ._version import __version__ +from .exceptions import ( + ConnectTimeoutError, + NewConnectionError, + SubjectAltNameWarning, + SystemTimeWarning, +) +from .packages.ssl_match_hostname import CertificateError, match_hostname +from .util import SKIP_HEADER, SKIPPABLE_HEADERS, connection +from .util.ssl_ import ( + assert_fingerprint, + create_urllib3_context, + resolve_cert_reqs, + resolve_ssl_version, + ssl_wrap_socket, +) + +log = logging.getLogger(__name__) + +port_by_scheme = {"http": 80, "https": 443} + +# When it comes time to update this value as a part of regular maintenance +# (ie test_recent_date is failing) update it to ~6 months before the current date. +RECENT_DATE = datetime.date(2020, 7, 1) + +_CONTAINS_CONTROL_CHAR_RE = re.compile(r"[^-!#$%&'*+.^_`|~0-9a-zA-Z]") + + +class HTTPConnection(_HTTPConnection, object): + """ + Based on :class:`http.client.HTTPConnection` but provides an extra constructor + backwards-compatibility layer between older and newer Pythons. + + Additional keyword parameters are used to configure attributes of the connection. + Accepted parameters include: + + - ``strict``: See the documentation on :class:`urllib3.connectionpool.HTTPConnectionPool` + - ``source_address``: Set the source address for the current connection. + - ``socket_options``: Set specific options on the underlying socket. If not specified, then + defaults are loaded from ``HTTPConnection.default_socket_options`` which includes disabling + Nagle's algorithm (sets TCP_NODELAY to 1) unless the connection is behind a proxy. + + For example, if you wish to enable TCP Keep Alive in addition to the defaults, + you might pass: + + .. code-block:: python + + HTTPConnection.default_socket_options + [ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + ] + + Or you may want to disable the defaults by passing an empty list (e.g., ``[]``). + """ + + default_port = port_by_scheme["http"] + + #: Disable Nagle's algorithm by default. + #: ``[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)]`` + default_socket_options = [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)] + + #: Whether this connection verifies the host's certificate. + is_verified = False + + def __init__(self, *args, **kw): + if not six.PY2: + kw.pop("strict", None) + + # Pre-set source_address. + self.source_address = kw.get("source_address") + + #: The socket options provided by the user. If no options are + #: provided, we use the default options. + self.socket_options = kw.pop("socket_options", self.default_socket_options) + + # Proxy options provided by the user. + self.proxy = kw.pop("proxy", None) + self.proxy_config = kw.pop("proxy_config", None) + + _HTTPConnection.__init__(self, *args, **kw) + + @property + def host(self): + """ + Getter method to remove any trailing dots that indicate the hostname is an FQDN. + + In general, SSL certificates don't include the trailing dot indicating a + fully-qualified domain name, and thus, they don't validate properly when + checked against a domain name that includes the dot. In addition, some + servers may not expect to receive the trailing dot when provided. + + However, the hostname with trailing dot is critical to DNS resolution; doing a + lookup with the trailing dot will properly only resolve the appropriate FQDN, + whereas a lookup without a trailing dot will search the system's search domain + list. Thus, it's important to keep the original host around for use only in + those cases where it's appropriate (i.e., when doing DNS lookup to establish the + actual TCP connection across which we're going to send HTTP requests). + """ + return self._dns_host.rstrip(".") + + @host.setter + def host(self, value): + """ + Setter for the `host` property. + + We assume that only urllib3 uses the _dns_host attribute; httplib itself + only uses `host`, and it seems reasonable that other libraries follow suit. + """ + self._dns_host = value + + def _new_conn(self): + """Establish a socket connection and set nodelay settings on it. + + :return: New socket connection. + """ + extra_kw = {} + if self.source_address: + extra_kw["source_address"] = self.source_address + + if self.socket_options: + extra_kw["socket_options"] = self.socket_options + + try: + conn = connection.create_connection( + (self._dns_host, self.port), self.timeout, **extra_kw + ) + + except SocketTimeout: + raise ConnectTimeoutError( + self, + "Connection to %s timed out. (connect timeout=%s)" + % (self.host, self.timeout), + ) + + except SocketError as e: + raise NewConnectionError( + self, "Failed to establish a new connection: %s" % e + ) + + return conn + + def _is_using_tunnel(self): + # Google App Engine's httplib does not define _tunnel_host + return getattr(self, "_tunnel_host", None) + + def _prepare_conn(self, conn): + self.sock = conn + if self._is_using_tunnel(): + # TODO: Fix tunnel so it doesn't depend on self.sock state. + self._tunnel() + # Mark this connection as not reusable + self.auto_open = 0 + + def connect(self): + conn = self._new_conn() + self._prepare_conn(conn) + + def putrequest(self, method, url, *args, **kwargs): + """ """ + # Empty docstring because the indentation of CPython's implementation + # is broken but we don't want this method in our documentation. + match = _CONTAINS_CONTROL_CHAR_RE.search(method) + if match: + raise ValueError( + "Method cannot contain non-token characters %r (found at least %r)" + % (method, match.group()) + ) + + return _HTTPConnection.putrequest(self, method, url, *args, **kwargs) + + def putheader(self, header, *values): + """ """ + if not any(isinstance(v, str) and v == SKIP_HEADER for v in values): + _HTTPConnection.putheader(self, header, *values) + elif six.ensure_str(header.lower()) not in SKIPPABLE_HEADERS: + raise ValueError( + "urllib3.util.SKIP_HEADER only supports '%s'" + % ("', '".join(map(str.title, sorted(SKIPPABLE_HEADERS))),) + ) + + def request(self, method, url, body=None, headers=None): + if headers is None: + headers = {} + else: + # Avoid modifying the headers passed into .request() + headers = headers.copy() + if "user-agent" not in (six.ensure_str(k.lower()) for k in headers): + headers["User-Agent"] = _get_default_user_agent() + super(HTTPConnection, self).request(method, url, body=body, headers=headers) + + def request_chunked(self, method, url, body=None, headers=None): + """ + Alternative to the common request method, which sends the + body with chunked encoding and not as one block + """ + headers = headers or {} + header_keys = set([six.ensure_str(k.lower()) for k in headers]) + skip_accept_encoding = "accept-encoding" in header_keys + skip_host = "host" in header_keys + self.putrequest( + method, url, skip_accept_encoding=skip_accept_encoding, skip_host=skip_host + ) + if "user-agent" not in header_keys: + self.putheader("User-Agent", _get_default_user_agent()) + for header, value in headers.items(): + self.putheader(header, value) + if "transfer-encoding" not in header_keys: + self.putheader("Transfer-Encoding", "chunked") + self.endheaders() + + if body is not None: + stringish_types = six.string_types + (bytes,) + if isinstance(body, stringish_types): + body = (body,) + for chunk in body: + if not chunk: + continue + if not isinstance(chunk, bytes): + chunk = chunk.encode("utf8") + len_str = hex(len(chunk))[2:] + to_send = bytearray(len_str.encode()) + to_send += b"\r\n" + to_send += chunk + to_send += b"\r\n" + self.send(to_send) + + # After the if clause, to always have a closed body + self.send(b"0\r\n\r\n") + + +class HTTPSConnection(HTTPConnection): + """ + Many of the parameters to this constructor are passed to the underlying SSL + socket by means of :py:func:`urllib3.util.ssl_wrap_socket`. + """ + + default_port = port_by_scheme["https"] + + cert_reqs = None + ca_certs = None + ca_cert_dir = None + ca_cert_data = None + ssl_version = None + assert_fingerprint = None + tls_in_tls_required = False + + def __init__( + self, + host, + port=None, + key_file=None, + cert_file=None, + key_password=None, + strict=None, + timeout=socket._GLOBAL_DEFAULT_TIMEOUT, + ssl_context=None, + server_hostname=None, + **kw + ): + + HTTPConnection.__init__(self, host, port, strict=strict, timeout=timeout, **kw) + + self.key_file = key_file + self.cert_file = cert_file + self.key_password = key_password + self.ssl_context = ssl_context + self.server_hostname = server_hostname + + # Required property for Google AppEngine 1.9.0 which otherwise causes + # HTTPS requests to go out as HTTP. (See Issue #356) + self._protocol = "https" + + def set_cert( + self, + key_file=None, + cert_file=None, + cert_reqs=None, + key_password=None, + ca_certs=None, + assert_hostname=None, + assert_fingerprint=None, + ca_cert_dir=None, + ca_cert_data=None, + ): + """ + This method should only be called once, before the connection is used. + """ + # If cert_reqs is not provided we'll assume CERT_REQUIRED unless we also + # have an SSLContext object in which case we'll use its verify_mode. + if cert_reqs is None: + if self.ssl_context is not None: + cert_reqs = self.ssl_context.verify_mode + else: + cert_reqs = resolve_cert_reqs(None) + + self.key_file = key_file + self.cert_file = cert_file + self.cert_reqs = cert_reqs + self.key_password = key_password + self.assert_hostname = assert_hostname + self.assert_fingerprint = assert_fingerprint + self.ca_certs = ca_certs and os.path.expanduser(ca_certs) + self.ca_cert_dir = ca_cert_dir and os.path.expanduser(ca_cert_dir) + self.ca_cert_data = ca_cert_data + + def connect(self): + # Add certificate verification + conn = self._new_conn() + hostname = self.host + tls_in_tls = False + + if self._is_using_tunnel(): + if self.tls_in_tls_required: + conn = self._connect_tls_proxy(hostname, conn) + tls_in_tls = True + + self.sock = conn + + # Calls self._set_hostport(), so self.host is + # self._tunnel_host below. + self._tunnel() + # Mark this connection as not reusable + self.auto_open = 0 + + # Override the host with the one we're requesting data from. + hostname = self._tunnel_host + + server_hostname = hostname + if self.server_hostname is not None: + server_hostname = self.server_hostname + + is_time_off = datetime.date.today() < RECENT_DATE + if is_time_off: + warnings.warn( + ( + "System time is way off (before {0}). This will probably " + "lead to SSL verification errors" + ).format(RECENT_DATE), + SystemTimeWarning, + ) + + # Wrap socket using verification with the root certs in + # trusted_root_certs + default_ssl_context = False + if self.ssl_context is None: + default_ssl_context = True + self.ssl_context = create_urllib3_context( + ssl_version=resolve_ssl_version(self.ssl_version), + cert_reqs=resolve_cert_reqs(self.cert_reqs), + ) + + context = self.ssl_context + context.verify_mode = resolve_cert_reqs(self.cert_reqs) + + # Try to load OS default certs if none are given. + # Works well on Windows (requires Python3.4+) + if ( + not self.ca_certs + and not self.ca_cert_dir + and not self.ca_cert_data + and default_ssl_context + and hasattr(context, "load_default_certs") + ): + context.load_default_certs() + + self.sock = ssl_wrap_socket( + sock=conn, + keyfile=self.key_file, + certfile=self.cert_file, + key_password=self.key_password, + ca_certs=self.ca_certs, + ca_cert_dir=self.ca_cert_dir, + ca_cert_data=self.ca_cert_data, + server_hostname=server_hostname, + ssl_context=context, + tls_in_tls=tls_in_tls, + ) + + # If we're using all defaults and the connection + # is TLSv1 or TLSv1.1 we throw a DeprecationWarning + # for the host. + if ( + default_ssl_context + and self.ssl_version is None + and hasattr(self.sock, "version") + and self.sock.version() in {"TLSv1", "TLSv1.1"} + ): + warnings.warn( + "Negotiating TLSv1/TLSv1.1 by default is deprecated " + "and will be disabled in urllib3 v2.0.0. Connecting to " + "'%s' with '%s' can be enabled by explicitly opting-in " + "with 'ssl_version'" % (self.host, self.sock.version()), + DeprecationWarning, + ) + + if self.assert_fingerprint: + assert_fingerprint( + self.sock.getpeercert(binary_form=True), self.assert_fingerprint + ) + elif ( + context.verify_mode != ssl.CERT_NONE + and not getattr(context, "check_hostname", False) + and self.assert_hostname is not False + ): + # While urllib3 attempts to always turn off hostname matching from + # the TLS library, this cannot always be done. So we check whether + # the TLS Library still thinks it's matching hostnames. + cert = self.sock.getpeercert() + if not cert.get("subjectAltName", ()): + warnings.warn( + ( + "Certificate for {0} has no `subjectAltName`, falling back to check for a " + "`commonName` for now. This feature is being removed by major browsers and " + "deprecated by RFC 2818. (See https://github.com/urllib3/urllib3/issues/497 " + "for details.)".format(hostname) + ), + SubjectAltNameWarning, + ) + _match_hostname(cert, self.assert_hostname or server_hostname) + + self.is_verified = ( + context.verify_mode == ssl.CERT_REQUIRED + or self.assert_fingerprint is not None + ) + + def _connect_tls_proxy(self, hostname, conn): + """ + Establish a TLS connection to the proxy using the provided SSL context. + """ + proxy_config = self.proxy_config + ssl_context = proxy_config.ssl_context + if ssl_context: + # If the user provided a proxy context, we assume CA and client + # certificates have already been set + return ssl_wrap_socket( + sock=conn, + server_hostname=hostname, + ssl_context=ssl_context, + ) + + ssl_context = create_proxy_ssl_context( + self.ssl_version, + self.cert_reqs, + self.ca_certs, + self.ca_cert_dir, + self.ca_cert_data, + ) + # By default urllib3's SSLContext disables `check_hostname` and uses + # a custom check. For proxies we're good with relying on the default + # verification. + ssl_context.check_hostname = True + + # If no cert was provided, use only the default options for server + # certificate validation + return ssl_wrap_socket( + sock=conn, + ca_certs=self.ca_certs, + ca_cert_dir=self.ca_cert_dir, + ca_cert_data=self.ca_cert_data, + server_hostname=hostname, + ssl_context=ssl_context, + ) + + +def _match_hostname(cert, asserted_hostname): + try: + match_hostname(cert, asserted_hostname) + except CertificateError as e: + log.warning( + "Certificate did not match expected hostname: %s. Certificate: %s", + asserted_hostname, + cert, + ) + # Add cert to exception and reraise so client code can inspect + # the cert when catching the exception, if they want to + e._peer_cert = cert + raise + + +def _get_default_user_agent(): + return "python-urllib3/%s" % __version__ + + +class DummyConnection(object): + """Used to detect a failed ConnectionCls import.""" + + pass + + +if not ssl: + HTTPSConnection = DummyConnection # noqa: F811 + + +VerifiedHTTPSConnection = HTTPSConnection diff --git a/openpype/hosts/fusion/vendor/urllib3/connectionpool.py b/openpype/hosts/fusion/vendor/urllib3/connectionpool.py new file mode 100644 index 0000000000..459bbe095b --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/connectionpool.py @@ -0,0 +1,1067 @@ +from __future__ import absolute_import + +import errno +import logging +import socket +import sys +import warnings +from socket import error as SocketError +from socket import timeout as SocketTimeout + +from .connection import ( + BaseSSLError, + BrokenPipeError, + DummyConnection, + HTTPConnection, + HTTPException, + HTTPSConnection, + VerifiedHTTPSConnection, + port_by_scheme, +) +from .exceptions import ( + ClosedPoolError, + EmptyPoolError, + HeaderParsingError, + HostChangedError, + InsecureRequestWarning, + LocationValueError, + MaxRetryError, + NewConnectionError, + ProtocolError, + ProxyError, + ReadTimeoutError, + SSLError, + TimeoutError, +) +from .packages import six +from .packages.six.moves import queue +from .packages.ssl_match_hostname import CertificateError +from .request import RequestMethods +from .response import HTTPResponse +from .util.connection import is_connection_dropped +from .util.proxy import connection_requires_http_tunnel +from .util.queue import LifoQueue +from .util.request import set_file_position +from .util.response import assert_header_parsing +from .util.retry import Retry +from .util.timeout import Timeout +from .util.url import Url, _encode_target +from .util.url import _normalize_host as normalize_host +from .util.url import get_host, parse_url + +xrange = six.moves.xrange + +log = logging.getLogger(__name__) + +_Default = object() + + +# Pool objects +class ConnectionPool(object): + """ + Base class for all connection pools, such as + :class:`.HTTPConnectionPool` and :class:`.HTTPSConnectionPool`. + + .. note:: + ConnectionPool.urlopen() does not normalize or percent-encode target URIs + which is useful if your target server doesn't support percent-encoded + target URIs. + """ + + scheme = None + QueueCls = LifoQueue + + def __init__(self, host, port=None): + if not host: + raise LocationValueError("No host specified.") + + self.host = _normalize_host(host, scheme=self.scheme) + self._proxy_host = host.lower() + self.port = port + + def __str__(self): + return "%s(host=%r, port=%r)" % (type(self).__name__, self.host, self.port) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + # Return False to re-raise any potential exceptions + return False + + def close(self): + """ + Close all pooled connections and disable the pool. + """ + pass + + +# This is taken from http://hg.python.org/cpython/file/7aaba721ebc0/Lib/socket.py#l252 +_blocking_errnos = {errno.EAGAIN, errno.EWOULDBLOCK} + + +class HTTPConnectionPool(ConnectionPool, RequestMethods): + """ + Thread-safe connection pool for one host. + + :param host: + Host used for this HTTP Connection (e.g. "localhost"), passed into + :class:`http.client.HTTPConnection`. + + :param port: + Port used for this HTTP Connection (None is equivalent to 80), passed + into :class:`http.client.HTTPConnection`. + + :param strict: + Causes BadStatusLine to be raised if the status line can't be parsed + as a valid HTTP/1.0 or 1.1 status line, passed into + :class:`http.client.HTTPConnection`. + + .. note:: + Only works in Python 2. This parameter is ignored in Python 3. + + :param timeout: + Socket timeout in seconds for each individual connection. This can + be a float or integer, which sets the timeout for the HTTP request, + or an instance of :class:`urllib3.util.Timeout` which gives you more + fine-grained control over request timeouts. After the constructor has + been parsed, this is always a `urllib3.util.Timeout` object. + + :param maxsize: + Number of connections to save that can be reused. More than 1 is useful + in multithreaded situations. If ``block`` is set to False, more + connections will be created but they will not be saved once they've + been used. + + :param block: + If set to True, no more than ``maxsize`` connections will be used at + a time. When no free connections are available, the call will block + until a connection has been released. This is a useful side effect for + particular multithreaded situations where one does not want to use more + than maxsize connections per host to prevent flooding. + + :param headers: + Headers to include with all requests, unless other headers are given + explicitly. + + :param retries: + Retry configuration to use by default with requests in this pool. + + :param _proxy: + Parsed proxy URL, should not be used directly, instead, see + :class:`urllib3.ProxyManager` + + :param _proxy_headers: + A dictionary with proxy headers, should not be used directly, + instead, see :class:`urllib3.ProxyManager` + + :param \\**conn_kw: + Additional parameters are used to create fresh :class:`urllib3.connection.HTTPConnection`, + :class:`urllib3.connection.HTTPSConnection` instances. + """ + + scheme = "http" + ConnectionCls = HTTPConnection + ResponseCls = HTTPResponse + + def __init__( + self, + host, + port=None, + strict=False, + timeout=Timeout.DEFAULT_TIMEOUT, + maxsize=1, + block=False, + headers=None, + retries=None, + _proxy=None, + _proxy_headers=None, + _proxy_config=None, + **conn_kw + ): + ConnectionPool.__init__(self, host, port) + RequestMethods.__init__(self, headers) + + self.strict = strict + + if not isinstance(timeout, Timeout): + timeout = Timeout.from_float(timeout) + + if retries is None: + retries = Retry.DEFAULT + + self.timeout = timeout + self.retries = retries + + self.pool = self.QueueCls(maxsize) + self.block = block + + self.proxy = _proxy + self.proxy_headers = _proxy_headers or {} + self.proxy_config = _proxy_config + + # Fill the queue up so that doing get() on it will block properly + for _ in xrange(maxsize): + self.pool.put(None) + + # These are mostly for testing and debugging purposes. + self.num_connections = 0 + self.num_requests = 0 + self.conn_kw = conn_kw + + if self.proxy: + # Enable Nagle's algorithm for proxies, to avoid packet fragmentation. + # We cannot know if the user has added default socket options, so we cannot replace the + # list. + self.conn_kw.setdefault("socket_options", []) + + self.conn_kw["proxy"] = self.proxy + self.conn_kw["proxy_config"] = self.proxy_config + + def _new_conn(self): + """ + Return a fresh :class:`HTTPConnection`. + """ + self.num_connections += 1 + log.debug( + "Starting new HTTP connection (%d): %s:%s", + self.num_connections, + self.host, + self.port or "80", + ) + + conn = self.ConnectionCls( + host=self.host, + port=self.port, + timeout=self.timeout.connect_timeout, + strict=self.strict, + **self.conn_kw + ) + return conn + + def _get_conn(self, timeout=None): + """ + Get a connection. Will return a pooled connection if one is available. + + If no connections are available and :prop:`.block` is ``False``, then a + fresh connection is returned. + + :param timeout: + Seconds to wait before giving up and raising + :class:`urllib3.exceptions.EmptyPoolError` if the pool is empty and + :prop:`.block` is ``True``. + """ + conn = None + try: + conn = self.pool.get(block=self.block, timeout=timeout) + + except AttributeError: # self.pool is None + raise ClosedPoolError(self, "Pool is closed.") + + except queue.Empty: + if self.block: + raise EmptyPoolError( + self, + "Pool reached maximum size and no more connections are allowed.", + ) + pass # Oh well, we'll create a new connection then + + # If this is a persistent connection, check if it got disconnected + if conn and is_connection_dropped(conn): + log.debug("Resetting dropped connection: %s", self.host) + conn.close() + if getattr(conn, "auto_open", 1) == 0: + # This is a proxied connection that has been mutated by + # http.client._tunnel() and cannot be reused (since it would + # attempt to bypass the proxy) + conn = None + + return conn or self._new_conn() + + def _put_conn(self, conn): + """ + Put a connection back into the pool. + + :param conn: + Connection object for the current host and port as returned by + :meth:`._new_conn` or :meth:`._get_conn`. + + If the pool is already full, the connection is closed and discarded + because we exceeded maxsize. If connections are discarded frequently, + then maxsize should be increased. + + If the pool is closed, then the connection will be closed and discarded. + """ + try: + self.pool.put(conn, block=False) + return # Everything is dandy, done. + except AttributeError: + # self.pool is None. + pass + except queue.Full: + # This should never happen if self.block == True + log.warning("Connection pool is full, discarding connection: %s", self.host) + + # Connection never got put back into the pool, close it. + if conn: + conn.close() + + def _validate_conn(self, conn): + """ + Called right before a request is made, after the socket is created. + """ + pass + + def _prepare_proxy(self, conn): + # Nothing to do for HTTP connections. + pass + + def _get_timeout(self, timeout): + """Helper that always returns a :class:`urllib3.util.Timeout`""" + if timeout is _Default: + return self.timeout.clone() + + if isinstance(timeout, Timeout): + return timeout.clone() + else: + # User passed us an int/float. This is for backwards compatibility, + # can be removed later + return Timeout.from_float(timeout) + + def _raise_timeout(self, err, url, timeout_value): + """Is the error actually a timeout? Will raise a ReadTimeout or pass""" + + if isinstance(err, SocketTimeout): + raise ReadTimeoutError( + self, url, "Read timed out. (read timeout=%s)" % timeout_value + ) + + # See the above comment about EAGAIN in Python 3. In Python 2 we have + # to specifically catch it and throw the timeout error + if hasattr(err, "errno") and err.errno in _blocking_errnos: + raise ReadTimeoutError( + self, url, "Read timed out. (read timeout=%s)" % timeout_value + ) + + # Catch possible read timeouts thrown as SSL errors. If not the + # case, rethrow the original. We need to do this because of: + # http://bugs.python.org/issue10272 + if "timed out" in str(err) or "did not complete (read)" in str( + err + ): # Python < 2.7.4 + raise ReadTimeoutError( + self, url, "Read timed out. (read timeout=%s)" % timeout_value + ) + + def _make_request( + self, conn, method, url, timeout=_Default, chunked=False, **httplib_request_kw + ): + """ + Perform a request on a given urllib connection object taken from our + pool. + + :param conn: + a connection from one of our connection pools + + :param timeout: + Socket timeout in seconds for the request. This can be a + float or integer, which will set the same timeout value for + the socket connect and the socket read, or an instance of + :class:`urllib3.util.Timeout`, which gives you more fine-grained + control over your timeouts. + """ + self.num_requests += 1 + + timeout_obj = self._get_timeout(timeout) + timeout_obj.start_connect() + conn.timeout = timeout_obj.connect_timeout + + # Trigger any extra validation we need to do. + try: + self._validate_conn(conn) + except (SocketTimeout, BaseSSLError) as e: + # Py2 raises this as a BaseSSLError, Py3 raises it as socket timeout. + self._raise_timeout(err=e, url=url, timeout_value=conn.timeout) + raise + + # conn.request() calls http.client.*.request, not the method in + # urllib3.request. It also calls makefile (recv) on the socket. + try: + if chunked: + conn.request_chunked(method, url, **httplib_request_kw) + else: + conn.request(method, url, **httplib_request_kw) + + # We are swallowing BrokenPipeError (errno.EPIPE) since the server is + # legitimately able to close the connection after sending a valid response. + # With this behaviour, the received response is still readable. + except BrokenPipeError: + # Python 3 + pass + except IOError as e: + # Python 2 and macOS/Linux + # EPIPE and ESHUTDOWN are BrokenPipeError on Python 2, and EPROTOTYPE is needed on macOS + # https://erickt.github.io/blog/2014/11/19/adventures-in-debugging-a-potential-osx-kernel-bug/ + if e.errno not in { + errno.EPIPE, + errno.ESHUTDOWN, + errno.EPROTOTYPE, + }: + raise + + # Reset the timeout for the recv() on the socket + read_timeout = timeout_obj.read_timeout + + # App Engine doesn't have a sock attr + if getattr(conn, "sock", None): + # In Python 3 socket.py will catch EAGAIN and return None when you + # try and read into the file pointer created by http.client, which + # instead raises a BadStatusLine exception. Instead of catching + # the exception and assuming all BadStatusLine exceptions are read + # timeouts, check for a zero timeout before making the request. + if read_timeout == 0: + raise ReadTimeoutError( + self, url, "Read timed out. (read timeout=%s)" % read_timeout + ) + if read_timeout is Timeout.DEFAULT_TIMEOUT: + conn.sock.settimeout(socket.getdefaulttimeout()) + else: # None or a value + conn.sock.settimeout(read_timeout) + + # Receive the response from the server + try: + try: + # Python 2.7, use buffering of HTTP responses + httplib_response = conn.getresponse(buffering=True) + except TypeError: + # Python 3 + try: + httplib_response = conn.getresponse() + except BaseException as e: + # Remove the TypeError from the exception chain in + # Python 3 (including for exceptions like SystemExit). + # Otherwise it looks like a bug in the code. + six.raise_from(e, None) + except (SocketTimeout, BaseSSLError, SocketError) as e: + self._raise_timeout(err=e, url=url, timeout_value=read_timeout) + raise + + # AppEngine doesn't have a version attr. + http_version = getattr(conn, "_http_vsn_str", "HTTP/?") + log.debug( + '%s://%s:%s "%s %s %s" %s %s', + self.scheme, + self.host, + self.port, + method, + url, + http_version, + httplib_response.status, + httplib_response.length, + ) + + try: + assert_header_parsing(httplib_response.msg) + except (HeaderParsingError, TypeError) as hpe: # Platform-specific: Python 3 + log.warning( + "Failed to parse headers (url=%s): %s", + self._absolute_url(url), + hpe, + exc_info=True, + ) + + return httplib_response + + def _absolute_url(self, path): + return Url(scheme=self.scheme, host=self.host, port=self.port, path=path).url + + def close(self): + """ + Close all pooled connections and disable the pool. + """ + if self.pool is None: + return + # Disable access to the pool + old_pool, self.pool = self.pool, None + + try: + while True: + conn = old_pool.get(block=False) + if conn: + conn.close() + + except queue.Empty: + pass # Done. + + def is_same_host(self, url): + """ + Check if the given ``url`` is a member of the same host as this + connection pool. + """ + if url.startswith("/"): + return True + + # TODO: Add optional support for socket.gethostbyname checking. + scheme, host, port = get_host(url) + if host is not None: + host = _normalize_host(host, scheme=scheme) + + # Use explicit default port for comparison when none is given + if self.port and not port: + port = port_by_scheme.get(scheme) + elif not self.port and port == port_by_scheme.get(scheme): + port = None + + return (scheme, host, port) == (self.scheme, self.host, self.port) + + def urlopen( + self, + method, + url, + body=None, + headers=None, + retries=None, + redirect=True, + assert_same_host=True, + timeout=_Default, + pool_timeout=None, + release_conn=None, + chunked=False, + body_pos=None, + **response_kw + ): + """ + Get a connection from the pool and perform an HTTP request. This is the + lowest level call for making a request, so you'll need to specify all + the raw details. + + .. note:: + + More commonly, it's appropriate to use a convenience method provided + by :class:`.RequestMethods`, such as :meth:`request`. + + .. note:: + + `release_conn` will only behave as expected if + `preload_content=False` because we want to make + `preload_content=False` the default behaviour someday soon without + breaking backwards compatibility. + + :param method: + HTTP request method (such as GET, POST, PUT, etc.) + + :param url: + The URL to perform the request on. + + :param body: + Data to send in the request body, either :class:`str`, :class:`bytes`, + an iterable of :class:`str`/:class:`bytes`, or a file-like object. + + :param headers: + Dictionary of custom headers to send, such as User-Agent, + If-None-Match, etc. If None, pool headers are used. If provided, + these headers completely replace any pool-specific headers. + + :param retries: + Configure the number of retries to allow before raising a + :class:`~urllib3.exceptions.MaxRetryError` exception. + + Pass ``None`` to retry until you receive a response. Pass a + :class:`~urllib3.util.retry.Retry` object for fine-grained control + over different types of retries. + Pass an integer number to retry connection errors that many times, + but no other types of errors. Pass zero to never retry. + + If ``False``, then retries are disabled and any exception is raised + immediately. Also, instead of raising a MaxRetryError on redirects, + the redirect response will be returned. + + :type retries: :class:`~urllib3.util.retry.Retry`, False, or an int. + + :param redirect: + If True, automatically handle redirects (status codes 301, 302, + 303, 307, 308). Each redirect counts as a retry. Disabling retries + will disable redirect, too. + + :param assert_same_host: + If ``True``, will make sure that the host of the pool requests is + consistent else will raise HostChangedError. When ``False``, you can + use the pool on an HTTP proxy and request foreign hosts. + + :param timeout: + If specified, overrides the default timeout for this one + request. It may be a float (in seconds) or an instance of + :class:`urllib3.util.Timeout`. + + :param pool_timeout: + If set and the pool is set to block=True, then this method will + block for ``pool_timeout`` seconds and raise EmptyPoolError if no + connection is available within the time period. + + :param release_conn: + If False, then the urlopen call will not release the connection + back into the pool once a response is received (but will release if + you read the entire contents of the response such as when + `preload_content=True`). This is useful if you're not preloading + the response's content immediately. You will need to call + ``r.release_conn()`` on the response ``r`` to return the connection + back into the pool. If None, it takes the value of + ``response_kw.get('preload_content', True)``. + + :param chunked: + If True, urllib3 will send the body using chunked transfer + encoding. Otherwise, urllib3 will send the body using the standard + content-length form. Defaults to False. + + :param int body_pos: + Position to seek to in file-like body in the event of a retry or + redirect. Typically this won't need to be set because urllib3 will + auto-populate the value when needed. + + :param \\**response_kw: + Additional parameters are passed to + :meth:`urllib3.response.HTTPResponse.from_httplib` + """ + + parsed_url = parse_url(url) + destination_scheme = parsed_url.scheme + + if headers is None: + headers = self.headers + + if not isinstance(retries, Retry): + retries = Retry.from_int(retries, redirect=redirect, default=self.retries) + + if release_conn is None: + release_conn = response_kw.get("preload_content", True) + + # Check host + if assert_same_host and not self.is_same_host(url): + raise HostChangedError(self, url, retries) + + # Ensure that the URL we're connecting to is properly encoded + if url.startswith("/"): + url = six.ensure_str(_encode_target(url)) + else: + url = six.ensure_str(parsed_url.url) + + conn = None + + # Track whether `conn` needs to be released before + # returning/raising/recursing. Update this variable if necessary, and + # leave `release_conn` constant throughout the function. That way, if + # the function recurses, the original value of `release_conn` will be + # passed down into the recursive call, and its value will be respected. + # + # See issue #651 [1] for details. + # + # [1] + release_this_conn = release_conn + + http_tunnel_required = connection_requires_http_tunnel( + self.proxy, self.proxy_config, destination_scheme + ) + + # Merge the proxy headers. Only done when not using HTTP CONNECT. We + # have to copy the headers dict so we can safely change it without those + # changes being reflected in anyone else's copy. + if not http_tunnel_required: + headers = headers.copy() + headers.update(self.proxy_headers) + + # Must keep the exception bound to a separate variable or else Python 3 + # complains about UnboundLocalError. + err = None + + # Keep track of whether we cleanly exited the except block. This + # ensures we do proper cleanup in finally. + clean_exit = False + + # Rewind body position, if needed. Record current position + # for future rewinds in the event of a redirect/retry. + body_pos = set_file_position(body, body_pos) + + try: + # Request a connection from the queue. + timeout_obj = self._get_timeout(timeout) + conn = self._get_conn(timeout=pool_timeout) + + conn.timeout = timeout_obj.connect_timeout + + is_new_proxy_conn = self.proxy is not None and not getattr( + conn, "sock", None + ) + if is_new_proxy_conn and http_tunnel_required: + self._prepare_proxy(conn) + + # Make the request on the httplib connection object. + httplib_response = self._make_request( + conn, + method, + url, + timeout=timeout_obj, + body=body, + headers=headers, + chunked=chunked, + ) + + # If we're going to release the connection in ``finally:``, then + # the response doesn't need to know about the connection. Otherwise + # it will also try to release it and we'll have a double-release + # mess. + response_conn = conn if not release_conn else None + + # Pass method to Response for length checking + response_kw["request_method"] = method + + # Import httplib's response into our own wrapper object + response = self.ResponseCls.from_httplib( + httplib_response, + pool=self, + connection=response_conn, + retries=retries, + **response_kw + ) + + # Everything went great! + clean_exit = True + + except EmptyPoolError: + # Didn't get a connection from the pool, no need to clean up + clean_exit = True + release_this_conn = False + raise + + except ( + TimeoutError, + HTTPException, + SocketError, + ProtocolError, + BaseSSLError, + SSLError, + CertificateError, + ) as e: + # Discard the connection for these exceptions. It will be + # replaced during the next _get_conn() call. + clean_exit = False + if isinstance(e, (BaseSSLError, CertificateError)): + e = SSLError(e) + elif isinstance(e, (SocketError, NewConnectionError)) and self.proxy: + e = ProxyError("Cannot connect to proxy.", e) + elif isinstance(e, (SocketError, HTTPException)): + e = ProtocolError("Connection aborted.", e) + + retries = retries.increment( + method, url, error=e, _pool=self, _stacktrace=sys.exc_info()[2] + ) + retries.sleep() + + # Keep track of the error for the retry warning. + err = e + + finally: + if not clean_exit: + # We hit some kind of exception, handled or otherwise. We need + # to throw the connection away unless explicitly told not to. + # Close the connection, set the variable to None, and make sure + # we put the None back in the pool to avoid leaking it. + conn = conn and conn.close() + release_this_conn = True + + if release_this_conn: + # Put the connection back to be reused. If the connection is + # expired then it will be None, which will get replaced with a + # fresh connection during _get_conn. + self._put_conn(conn) + + if not conn: + # Try again + log.warning( + "Retrying (%r) after connection broken by '%r': %s", retries, err, url + ) + return self.urlopen( + method, + url, + body, + headers, + retries, + redirect, + assert_same_host, + timeout=timeout, + pool_timeout=pool_timeout, + release_conn=release_conn, + chunked=chunked, + body_pos=body_pos, + **response_kw + ) + + # Handle redirect? + redirect_location = redirect and response.get_redirect_location() + if redirect_location: + if response.status == 303: + method = "GET" + + try: + retries = retries.increment(method, url, response=response, _pool=self) + except MaxRetryError: + if retries.raise_on_redirect: + response.drain_conn() + raise + return response + + response.drain_conn() + retries.sleep_for_retry(response) + log.debug("Redirecting %s -> %s", url, redirect_location) + return self.urlopen( + method, + redirect_location, + body, + headers, + retries=retries, + redirect=redirect, + assert_same_host=assert_same_host, + timeout=timeout, + pool_timeout=pool_timeout, + release_conn=release_conn, + chunked=chunked, + body_pos=body_pos, + **response_kw + ) + + # Check if we should retry the HTTP response. + has_retry_after = bool(response.getheader("Retry-After")) + if retries.is_retry(method, response.status, has_retry_after): + try: + retries = retries.increment(method, url, response=response, _pool=self) + except MaxRetryError: + if retries.raise_on_status: + response.drain_conn() + raise + return response + + response.drain_conn() + retries.sleep(response) + log.debug("Retry: %s", url) + return self.urlopen( + method, + url, + body, + headers, + retries=retries, + redirect=redirect, + assert_same_host=assert_same_host, + timeout=timeout, + pool_timeout=pool_timeout, + release_conn=release_conn, + chunked=chunked, + body_pos=body_pos, + **response_kw + ) + + return response + + +class HTTPSConnectionPool(HTTPConnectionPool): + """ + Same as :class:`.HTTPConnectionPool`, but HTTPS. + + :class:`.HTTPSConnection` uses one of ``assert_fingerprint``, + ``assert_hostname`` and ``host`` in this order to verify connections. + If ``assert_hostname`` is False, no verification is done. + + The ``key_file``, ``cert_file``, ``cert_reqs``, ``ca_certs``, + ``ca_cert_dir``, ``ssl_version``, ``key_password`` are only used if :mod:`ssl` + is available and are fed into :meth:`urllib3.util.ssl_wrap_socket` to upgrade + the connection socket into an SSL socket. + """ + + scheme = "https" + ConnectionCls = HTTPSConnection + + def __init__( + self, + host, + port=None, + strict=False, + timeout=Timeout.DEFAULT_TIMEOUT, + maxsize=1, + block=False, + headers=None, + retries=None, + _proxy=None, + _proxy_headers=None, + key_file=None, + cert_file=None, + cert_reqs=None, + key_password=None, + ca_certs=None, + ssl_version=None, + assert_hostname=None, + assert_fingerprint=None, + ca_cert_dir=None, + **conn_kw + ): + + HTTPConnectionPool.__init__( + self, + host, + port, + strict, + timeout, + maxsize, + block, + headers, + retries, + _proxy, + _proxy_headers, + **conn_kw + ) + + self.key_file = key_file + self.cert_file = cert_file + self.cert_reqs = cert_reqs + self.key_password = key_password + self.ca_certs = ca_certs + self.ca_cert_dir = ca_cert_dir + self.ssl_version = ssl_version + self.assert_hostname = assert_hostname + self.assert_fingerprint = assert_fingerprint + + def _prepare_conn(self, conn): + """ + Prepare the ``connection`` for :meth:`urllib3.util.ssl_wrap_socket` + and establish the tunnel if proxy is used. + """ + + if isinstance(conn, VerifiedHTTPSConnection): + conn.set_cert( + key_file=self.key_file, + key_password=self.key_password, + cert_file=self.cert_file, + cert_reqs=self.cert_reqs, + ca_certs=self.ca_certs, + ca_cert_dir=self.ca_cert_dir, + assert_hostname=self.assert_hostname, + assert_fingerprint=self.assert_fingerprint, + ) + conn.ssl_version = self.ssl_version + return conn + + def _prepare_proxy(self, conn): + """ + Establishes a tunnel connection through HTTP CONNECT. + + Tunnel connection is established early because otherwise httplib would + improperly set Host: header to proxy's IP:port. + """ + + conn.set_tunnel(self._proxy_host, self.port, self.proxy_headers) + + if self.proxy.scheme == "https": + conn.tls_in_tls_required = True + + conn.connect() + + def _new_conn(self): + """ + Return a fresh :class:`http.client.HTTPSConnection`. + """ + self.num_connections += 1 + log.debug( + "Starting new HTTPS connection (%d): %s:%s", + self.num_connections, + self.host, + self.port or "443", + ) + + if not self.ConnectionCls or self.ConnectionCls is DummyConnection: + raise SSLError( + "Can't connect to HTTPS URL because the SSL module is not available." + ) + + actual_host = self.host + actual_port = self.port + if self.proxy is not None: + actual_host = self.proxy.host + actual_port = self.proxy.port + + conn = self.ConnectionCls( + host=actual_host, + port=actual_port, + timeout=self.timeout.connect_timeout, + strict=self.strict, + cert_file=self.cert_file, + key_file=self.key_file, + key_password=self.key_password, + **self.conn_kw + ) + + return self._prepare_conn(conn) + + def _validate_conn(self, conn): + """ + Called right before a request is made, after the socket is created. + """ + super(HTTPSConnectionPool, self)._validate_conn(conn) + + # Force connect early to allow us to validate the connection. + if not getattr(conn, "sock", None): # AppEngine might not have `.sock` + conn.connect() + + if not conn.is_verified: + warnings.warn( + ( + "Unverified HTTPS request is being made to host '%s'. " + "Adding certificate verification is strongly advised. See: " + "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" + "#ssl-warnings" % conn.host + ), + InsecureRequestWarning, + ) + + +def connection_from_url(url, **kw): + """ + Given a url, return an :class:`.ConnectionPool` instance of its host. + + This is a shortcut for not having to parse out the scheme, host, and port + of the url before creating an :class:`.ConnectionPool` instance. + + :param url: + Absolute URL string that must include the scheme. Port is optional. + + :param \\**kw: + Passes additional parameters to the constructor of the appropriate + :class:`.ConnectionPool`. Useful for specifying things like + timeout, maxsize, headers, etc. + + Example:: + + >>> conn = connection_from_url('http://google.com/') + >>> r = conn.request('GET', '/') + """ + scheme, host, port = get_host(url) + port = port or port_by_scheme.get(scheme, 80) + if scheme == "https": + return HTTPSConnectionPool(host, port=port, **kw) + else: + return HTTPConnectionPool(host, port=port, **kw) + + +def _normalize_host(host, scheme): + """ + Normalize hosts for comparisons and use with sockets. + """ + + host = normalize_host(host, scheme) + + # httplib doesn't like it when we include brackets in IPv6 addresses + # Specifically, if we include brackets but also pass the port then + # httplib crazily doubles up the square brackets on the Host header. + # Instead, we need to make sure we never pass ``None`` as the port. + # However, for backward compatibility reasons we can't actually + # *assert* that. See http://bugs.python.org/issue28539 + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + return host diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/__init__.py b/openpype/hosts/fusion/vendor/urllib3/contrib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/_appengine_environ.py b/openpype/hosts/fusion/vendor/urllib3/contrib/_appengine_environ.py new file mode 100644 index 0000000000..8765b907d7 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/_appengine_environ.py @@ -0,0 +1,36 @@ +""" +This module provides means to detect the App Engine environment. +""" + +import os + + +def is_appengine(): + return is_local_appengine() or is_prod_appengine() + + +def is_appengine_sandbox(): + """Reports if the app is running in the first generation sandbox. + + The second generation runtimes are technically still in a sandbox, but it + is much less restrictive, so generally you shouldn't need to check for it. + see https://cloud.google.com/appengine/docs/standard/runtimes + """ + return is_appengine() and os.environ["APPENGINE_RUNTIME"] == "python27" + + +def is_local_appengine(): + return "APPENGINE_RUNTIME" in os.environ and os.environ.get( + "SERVER_SOFTWARE", "" + ).startswith("Development/") + + +def is_prod_appengine(): + return "APPENGINE_RUNTIME" in os.environ and os.environ.get( + "SERVER_SOFTWARE", "" + ).startswith("Google App Engine/") + + +def is_prod_appengine_mvms(): + """Deprecated.""" + return False diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/__init__.py b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/bindings.py b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/bindings.py new file mode 100644 index 0000000000..11524d400b --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/bindings.py @@ -0,0 +1,519 @@ +""" +This module uses ctypes to bind a whole bunch of functions and constants from +SecureTransport. The goal here is to provide the low-level API to +SecureTransport. These are essentially the C-level functions and constants, and +they're pretty gross to work with. + +This code is a bastardised version of the code found in Will Bond's oscrypto +library. An enormous debt is owed to him for blazing this trail for us. For +that reason, this code should be considered to be covered both by urllib3's +license and by oscrypto's: + + Copyright (c) 2015-2016 Will Bond + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +""" +from __future__ import absolute_import + +import platform +from ctypes import ( + CDLL, + CFUNCTYPE, + POINTER, + c_bool, + c_byte, + c_char_p, + c_int32, + c_long, + c_size_t, + c_uint32, + c_ulong, + c_void_p, +) +from ctypes.util import find_library + +from urllib3.packages.six import raise_from + +if platform.system() != "Darwin": + raise ImportError("Only macOS is supported") + +version = platform.mac_ver()[0] +version_info = tuple(map(int, version.split("."))) +if version_info < (10, 8): + raise OSError( + "Only OS X 10.8 and newer are supported, not %s.%s" + % (version_info[0], version_info[1]) + ) + + +def load_cdll(name, macos10_16_path): + """Loads a CDLL by name, falling back to known path on 10.16+""" + try: + # Big Sur is technically 11 but we use 10.16 due to the Big Sur + # beta being labeled as 10.16. + if version_info >= (10, 16): + path = macos10_16_path + else: + path = find_library(name) + if not path: + raise OSError # Caught and reraised as 'ImportError' + return CDLL(path, use_errno=True) + except OSError: + raise_from(ImportError("The library %s failed to load" % name), None) + + +Security = load_cdll( + "Security", "/System/Library/Frameworks/Security.framework/Security" +) +CoreFoundation = load_cdll( + "CoreFoundation", + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", +) + + +Boolean = c_bool +CFIndex = c_long +CFStringEncoding = c_uint32 +CFData = c_void_p +CFString = c_void_p +CFArray = c_void_p +CFMutableArray = c_void_p +CFDictionary = c_void_p +CFError = c_void_p +CFType = c_void_p +CFTypeID = c_ulong + +CFTypeRef = POINTER(CFType) +CFAllocatorRef = c_void_p + +OSStatus = c_int32 + +CFDataRef = POINTER(CFData) +CFStringRef = POINTER(CFString) +CFArrayRef = POINTER(CFArray) +CFMutableArrayRef = POINTER(CFMutableArray) +CFDictionaryRef = POINTER(CFDictionary) +CFArrayCallBacks = c_void_p +CFDictionaryKeyCallBacks = c_void_p +CFDictionaryValueCallBacks = c_void_p + +SecCertificateRef = POINTER(c_void_p) +SecExternalFormat = c_uint32 +SecExternalItemType = c_uint32 +SecIdentityRef = POINTER(c_void_p) +SecItemImportExportFlags = c_uint32 +SecItemImportExportKeyParameters = c_void_p +SecKeychainRef = POINTER(c_void_p) +SSLProtocol = c_uint32 +SSLCipherSuite = c_uint32 +SSLContextRef = POINTER(c_void_p) +SecTrustRef = POINTER(c_void_p) +SSLConnectionRef = c_uint32 +SecTrustResultType = c_uint32 +SecTrustOptionFlags = c_uint32 +SSLProtocolSide = c_uint32 +SSLConnectionType = c_uint32 +SSLSessionOption = c_uint32 + + +try: + Security.SecItemImport.argtypes = [ + CFDataRef, + CFStringRef, + POINTER(SecExternalFormat), + POINTER(SecExternalItemType), + SecItemImportExportFlags, + POINTER(SecItemImportExportKeyParameters), + SecKeychainRef, + POINTER(CFArrayRef), + ] + Security.SecItemImport.restype = OSStatus + + Security.SecCertificateGetTypeID.argtypes = [] + Security.SecCertificateGetTypeID.restype = CFTypeID + + Security.SecIdentityGetTypeID.argtypes = [] + Security.SecIdentityGetTypeID.restype = CFTypeID + + Security.SecKeyGetTypeID.argtypes = [] + Security.SecKeyGetTypeID.restype = CFTypeID + + Security.SecCertificateCreateWithData.argtypes = [CFAllocatorRef, CFDataRef] + Security.SecCertificateCreateWithData.restype = SecCertificateRef + + Security.SecCertificateCopyData.argtypes = [SecCertificateRef] + Security.SecCertificateCopyData.restype = CFDataRef + + Security.SecCopyErrorMessageString.argtypes = [OSStatus, c_void_p] + Security.SecCopyErrorMessageString.restype = CFStringRef + + Security.SecIdentityCreateWithCertificate.argtypes = [ + CFTypeRef, + SecCertificateRef, + POINTER(SecIdentityRef), + ] + Security.SecIdentityCreateWithCertificate.restype = OSStatus + + Security.SecKeychainCreate.argtypes = [ + c_char_p, + c_uint32, + c_void_p, + Boolean, + c_void_p, + POINTER(SecKeychainRef), + ] + Security.SecKeychainCreate.restype = OSStatus + + Security.SecKeychainDelete.argtypes = [SecKeychainRef] + Security.SecKeychainDelete.restype = OSStatus + + Security.SecPKCS12Import.argtypes = [ + CFDataRef, + CFDictionaryRef, + POINTER(CFArrayRef), + ] + Security.SecPKCS12Import.restype = OSStatus + + SSLReadFunc = CFUNCTYPE(OSStatus, SSLConnectionRef, c_void_p, POINTER(c_size_t)) + SSLWriteFunc = CFUNCTYPE( + OSStatus, SSLConnectionRef, POINTER(c_byte), POINTER(c_size_t) + ) + + Security.SSLSetIOFuncs.argtypes = [SSLContextRef, SSLReadFunc, SSLWriteFunc] + Security.SSLSetIOFuncs.restype = OSStatus + + Security.SSLSetPeerID.argtypes = [SSLContextRef, c_char_p, c_size_t] + Security.SSLSetPeerID.restype = OSStatus + + Security.SSLSetCertificate.argtypes = [SSLContextRef, CFArrayRef] + Security.SSLSetCertificate.restype = OSStatus + + Security.SSLSetCertificateAuthorities.argtypes = [SSLContextRef, CFTypeRef, Boolean] + Security.SSLSetCertificateAuthorities.restype = OSStatus + + Security.SSLSetConnection.argtypes = [SSLContextRef, SSLConnectionRef] + Security.SSLSetConnection.restype = OSStatus + + Security.SSLSetPeerDomainName.argtypes = [SSLContextRef, c_char_p, c_size_t] + Security.SSLSetPeerDomainName.restype = OSStatus + + Security.SSLHandshake.argtypes = [SSLContextRef] + Security.SSLHandshake.restype = OSStatus + + Security.SSLRead.argtypes = [SSLContextRef, c_char_p, c_size_t, POINTER(c_size_t)] + Security.SSLRead.restype = OSStatus + + Security.SSLWrite.argtypes = [SSLContextRef, c_char_p, c_size_t, POINTER(c_size_t)] + Security.SSLWrite.restype = OSStatus + + Security.SSLClose.argtypes = [SSLContextRef] + Security.SSLClose.restype = OSStatus + + Security.SSLGetNumberSupportedCiphers.argtypes = [SSLContextRef, POINTER(c_size_t)] + Security.SSLGetNumberSupportedCiphers.restype = OSStatus + + Security.SSLGetSupportedCiphers.argtypes = [ + SSLContextRef, + POINTER(SSLCipherSuite), + POINTER(c_size_t), + ] + Security.SSLGetSupportedCiphers.restype = OSStatus + + Security.SSLSetEnabledCiphers.argtypes = [ + SSLContextRef, + POINTER(SSLCipherSuite), + c_size_t, + ] + Security.SSLSetEnabledCiphers.restype = OSStatus + + Security.SSLGetNumberEnabledCiphers.argtype = [SSLContextRef, POINTER(c_size_t)] + Security.SSLGetNumberEnabledCiphers.restype = OSStatus + + Security.SSLGetEnabledCiphers.argtypes = [ + SSLContextRef, + POINTER(SSLCipherSuite), + POINTER(c_size_t), + ] + Security.SSLGetEnabledCiphers.restype = OSStatus + + Security.SSLGetNegotiatedCipher.argtypes = [SSLContextRef, POINTER(SSLCipherSuite)] + Security.SSLGetNegotiatedCipher.restype = OSStatus + + Security.SSLGetNegotiatedProtocolVersion.argtypes = [ + SSLContextRef, + POINTER(SSLProtocol), + ] + Security.SSLGetNegotiatedProtocolVersion.restype = OSStatus + + Security.SSLCopyPeerTrust.argtypes = [SSLContextRef, POINTER(SecTrustRef)] + Security.SSLCopyPeerTrust.restype = OSStatus + + Security.SecTrustSetAnchorCertificates.argtypes = [SecTrustRef, CFArrayRef] + Security.SecTrustSetAnchorCertificates.restype = OSStatus + + Security.SecTrustSetAnchorCertificatesOnly.argstypes = [SecTrustRef, Boolean] + Security.SecTrustSetAnchorCertificatesOnly.restype = OSStatus + + Security.SecTrustEvaluate.argtypes = [SecTrustRef, POINTER(SecTrustResultType)] + Security.SecTrustEvaluate.restype = OSStatus + + Security.SecTrustGetCertificateCount.argtypes = [SecTrustRef] + Security.SecTrustGetCertificateCount.restype = CFIndex + + Security.SecTrustGetCertificateAtIndex.argtypes = [SecTrustRef, CFIndex] + Security.SecTrustGetCertificateAtIndex.restype = SecCertificateRef + + Security.SSLCreateContext.argtypes = [ + CFAllocatorRef, + SSLProtocolSide, + SSLConnectionType, + ] + Security.SSLCreateContext.restype = SSLContextRef + + Security.SSLSetSessionOption.argtypes = [SSLContextRef, SSLSessionOption, Boolean] + Security.SSLSetSessionOption.restype = OSStatus + + Security.SSLSetProtocolVersionMin.argtypes = [SSLContextRef, SSLProtocol] + Security.SSLSetProtocolVersionMin.restype = OSStatus + + Security.SSLSetProtocolVersionMax.argtypes = [SSLContextRef, SSLProtocol] + Security.SSLSetProtocolVersionMax.restype = OSStatus + + try: + Security.SSLSetALPNProtocols.argtypes = [SSLContextRef, CFArrayRef] + Security.SSLSetALPNProtocols.restype = OSStatus + except AttributeError: + # Supported only in 10.12+ + pass + + Security.SecCopyErrorMessageString.argtypes = [OSStatus, c_void_p] + Security.SecCopyErrorMessageString.restype = CFStringRef + + Security.SSLReadFunc = SSLReadFunc + Security.SSLWriteFunc = SSLWriteFunc + Security.SSLContextRef = SSLContextRef + Security.SSLProtocol = SSLProtocol + Security.SSLCipherSuite = SSLCipherSuite + Security.SecIdentityRef = SecIdentityRef + Security.SecKeychainRef = SecKeychainRef + Security.SecTrustRef = SecTrustRef + Security.SecTrustResultType = SecTrustResultType + Security.SecExternalFormat = SecExternalFormat + Security.OSStatus = OSStatus + + Security.kSecImportExportPassphrase = CFStringRef.in_dll( + Security, "kSecImportExportPassphrase" + ) + Security.kSecImportItemIdentity = CFStringRef.in_dll( + Security, "kSecImportItemIdentity" + ) + + # CoreFoundation time! + CoreFoundation.CFRetain.argtypes = [CFTypeRef] + CoreFoundation.CFRetain.restype = CFTypeRef + + CoreFoundation.CFRelease.argtypes = [CFTypeRef] + CoreFoundation.CFRelease.restype = None + + CoreFoundation.CFGetTypeID.argtypes = [CFTypeRef] + CoreFoundation.CFGetTypeID.restype = CFTypeID + + CoreFoundation.CFStringCreateWithCString.argtypes = [ + CFAllocatorRef, + c_char_p, + CFStringEncoding, + ] + CoreFoundation.CFStringCreateWithCString.restype = CFStringRef + + CoreFoundation.CFStringGetCStringPtr.argtypes = [CFStringRef, CFStringEncoding] + CoreFoundation.CFStringGetCStringPtr.restype = c_char_p + + CoreFoundation.CFStringGetCString.argtypes = [ + CFStringRef, + c_char_p, + CFIndex, + CFStringEncoding, + ] + CoreFoundation.CFStringGetCString.restype = c_bool + + CoreFoundation.CFDataCreate.argtypes = [CFAllocatorRef, c_char_p, CFIndex] + CoreFoundation.CFDataCreate.restype = CFDataRef + + CoreFoundation.CFDataGetLength.argtypes = [CFDataRef] + CoreFoundation.CFDataGetLength.restype = CFIndex + + CoreFoundation.CFDataGetBytePtr.argtypes = [CFDataRef] + CoreFoundation.CFDataGetBytePtr.restype = c_void_p + + CoreFoundation.CFDictionaryCreate.argtypes = [ + CFAllocatorRef, + POINTER(CFTypeRef), + POINTER(CFTypeRef), + CFIndex, + CFDictionaryKeyCallBacks, + CFDictionaryValueCallBacks, + ] + CoreFoundation.CFDictionaryCreate.restype = CFDictionaryRef + + CoreFoundation.CFDictionaryGetValue.argtypes = [CFDictionaryRef, CFTypeRef] + CoreFoundation.CFDictionaryGetValue.restype = CFTypeRef + + CoreFoundation.CFArrayCreate.argtypes = [ + CFAllocatorRef, + POINTER(CFTypeRef), + CFIndex, + CFArrayCallBacks, + ] + CoreFoundation.CFArrayCreate.restype = CFArrayRef + + CoreFoundation.CFArrayCreateMutable.argtypes = [ + CFAllocatorRef, + CFIndex, + CFArrayCallBacks, + ] + CoreFoundation.CFArrayCreateMutable.restype = CFMutableArrayRef + + CoreFoundation.CFArrayAppendValue.argtypes = [CFMutableArrayRef, c_void_p] + CoreFoundation.CFArrayAppendValue.restype = None + + CoreFoundation.CFArrayGetCount.argtypes = [CFArrayRef] + CoreFoundation.CFArrayGetCount.restype = CFIndex + + CoreFoundation.CFArrayGetValueAtIndex.argtypes = [CFArrayRef, CFIndex] + CoreFoundation.CFArrayGetValueAtIndex.restype = c_void_p + + CoreFoundation.kCFAllocatorDefault = CFAllocatorRef.in_dll( + CoreFoundation, "kCFAllocatorDefault" + ) + CoreFoundation.kCFTypeArrayCallBacks = c_void_p.in_dll( + CoreFoundation, "kCFTypeArrayCallBacks" + ) + CoreFoundation.kCFTypeDictionaryKeyCallBacks = c_void_p.in_dll( + CoreFoundation, "kCFTypeDictionaryKeyCallBacks" + ) + CoreFoundation.kCFTypeDictionaryValueCallBacks = c_void_p.in_dll( + CoreFoundation, "kCFTypeDictionaryValueCallBacks" + ) + + CoreFoundation.CFTypeRef = CFTypeRef + CoreFoundation.CFArrayRef = CFArrayRef + CoreFoundation.CFStringRef = CFStringRef + CoreFoundation.CFDictionaryRef = CFDictionaryRef + +except (AttributeError): + raise ImportError("Error initializing ctypes") + + +class CFConst(object): + """ + A class object that acts as essentially a namespace for CoreFoundation + constants. + """ + + kCFStringEncodingUTF8 = CFStringEncoding(0x08000100) + + +class SecurityConst(object): + """ + A class object that acts as essentially a namespace for Security constants. + """ + + kSSLSessionOptionBreakOnServerAuth = 0 + + kSSLProtocol2 = 1 + kSSLProtocol3 = 2 + kTLSProtocol1 = 4 + kTLSProtocol11 = 7 + kTLSProtocol12 = 8 + # SecureTransport does not support TLS 1.3 even if there's a constant for it + kTLSProtocol13 = 10 + kTLSProtocolMaxSupported = 999 + + kSSLClientSide = 1 + kSSLStreamType = 0 + + kSecFormatPEMSequence = 10 + + kSecTrustResultInvalid = 0 + kSecTrustResultProceed = 1 + # This gap is present on purpose: this was kSecTrustResultConfirm, which + # is deprecated. + kSecTrustResultDeny = 3 + kSecTrustResultUnspecified = 4 + kSecTrustResultRecoverableTrustFailure = 5 + kSecTrustResultFatalTrustFailure = 6 + kSecTrustResultOtherError = 7 + + errSSLProtocol = -9800 + errSSLWouldBlock = -9803 + errSSLClosedGraceful = -9805 + errSSLClosedNoNotify = -9816 + errSSLClosedAbort = -9806 + + errSSLXCertChainInvalid = -9807 + errSSLCrypto = -9809 + errSSLInternal = -9810 + errSSLCertExpired = -9814 + errSSLCertNotYetValid = -9815 + errSSLUnknownRootCert = -9812 + errSSLNoRootCert = -9813 + errSSLHostNameMismatch = -9843 + errSSLPeerHandshakeFail = -9824 + errSSLPeerUserCancelled = -9839 + errSSLWeakPeerEphemeralDHKey = -9850 + errSSLServerAuthCompleted = -9841 + errSSLRecordOverflow = -9847 + + errSecVerifyFailed = -67808 + errSecNoTrustSettings = -25263 + errSecItemNotFound = -25300 + errSecInvalidTrustSettings = -25262 + + # Cipher suites. We only pick the ones our default cipher string allows. + # Source: https://developer.apple.com/documentation/security/1550981-ssl_cipher_suite_values + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 = 0xC02C + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 = 0xC030 + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 = 0xC02B + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 = 0xC02F + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 = 0xCCA9 + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 = 0xCCA8 + TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 = 0x009F + TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 = 0x009E + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 = 0xC024 + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 = 0xC028 + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA = 0xC00A + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA = 0xC014 + TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 = 0x006B + TLS_DHE_RSA_WITH_AES_256_CBC_SHA = 0x0039 + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 = 0xC023 + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 = 0xC027 + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA = 0xC009 + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA = 0xC013 + TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 = 0x0067 + TLS_DHE_RSA_WITH_AES_128_CBC_SHA = 0x0033 + TLS_RSA_WITH_AES_256_GCM_SHA384 = 0x009D + TLS_RSA_WITH_AES_128_GCM_SHA256 = 0x009C + TLS_RSA_WITH_AES_256_CBC_SHA256 = 0x003D + TLS_RSA_WITH_AES_128_CBC_SHA256 = 0x003C + TLS_RSA_WITH_AES_256_CBC_SHA = 0x0035 + TLS_RSA_WITH_AES_128_CBC_SHA = 0x002F + TLS_AES_128_GCM_SHA256 = 0x1301 + TLS_AES_256_GCM_SHA384 = 0x1302 + TLS_AES_128_CCM_8_SHA256 = 0x1305 + TLS_AES_128_CCM_SHA256 = 0x1304 diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/low_level.py b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/low_level.py new file mode 100644 index 0000000000..ed8120190c --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/low_level.py @@ -0,0 +1,396 @@ +""" +Low-level helpers for the SecureTransport bindings. + +These are Python functions that are not directly related to the high-level APIs +but are necessary to get them to work. They include a whole bunch of low-level +CoreFoundation messing about and memory management. The concerns in this module +are almost entirely about trying to avoid memory leaks and providing +appropriate and useful assistance to the higher-level code. +""" +import base64 +import ctypes +import itertools +import os +import re +import ssl +import struct +import tempfile + +from .bindings import CFConst, CoreFoundation, Security + +# This regular expression is used to grab PEM data out of a PEM bundle. +_PEM_CERTS_RE = re.compile( + b"-----BEGIN CERTIFICATE-----\n(.*?)\n-----END CERTIFICATE-----", re.DOTALL +) + + +def _cf_data_from_bytes(bytestring): + """ + Given a bytestring, create a CFData object from it. This CFData object must + be CFReleased by the caller. + """ + return CoreFoundation.CFDataCreate( + CoreFoundation.kCFAllocatorDefault, bytestring, len(bytestring) + ) + + +def _cf_dictionary_from_tuples(tuples): + """ + Given a list of Python tuples, create an associated CFDictionary. + """ + dictionary_size = len(tuples) + + # We need to get the dictionary keys and values out in the same order. + keys = (t[0] for t in tuples) + values = (t[1] for t in tuples) + cf_keys = (CoreFoundation.CFTypeRef * dictionary_size)(*keys) + cf_values = (CoreFoundation.CFTypeRef * dictionary_size)(*values) + + return CoreFoundation.CFDictionaryCreate( + CoreFoundation.kCFAllocatorDefault, + cf_keys, + cf_values, + dictionary_size, + CoreFoundation.kCFTypeDictionaryKeyCallBacks, + CoreFoundation.kCFTypeDictionaryValueCallBacks, + ) + + +def _cfstr(py_bstr): + """ + Given a Python binary data, create a CFString. + The string must be CFReleased by the caller. + """ + c_str = ctypes.c_char_p(py_bstr) + cf_str = CoreFoundation.CFStringCreateWithCString( + CoreFoundation.kCFAllocatorDefault, + c_str, + CFConst.kCFStringEncodingUTF8, + ) + return cf_str + + +def _create_cfstring_array(lst): + """ + Given a list of Python binary data, create an associated CFMutableArray. + The array must be CFReleased by the caller. + + Raises an ssl.SSLError on failure. + """ + cf_arr = None + try: + cf_arr = CoreFoundation.CFArrayCreateMutable( + CoreFoundation.kCFAllocatorDefault, + 0, + ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), + ) + if not cf_arr: + raise MemoryError("Unable to allocate memory!") + for item in lst: + cf_str = _cfstr(item) + if not cf_str: + raise MemoryError("Unable to allocate memory!") + try: + CoreFoundation.CFArrayAppendValue(cf_arr, cf_str) + finally: + CoreFoundation.CFRelease(cf_str) + except BaseException as e: + if cf_arr: + CoreFoundation.CFRelease(cf_arr) + raise ssl.SSLError("Unable to allocate array: %s" % (e,)) + return cf_arr + + +def _cf_string_to_unicode(value): + """ + Creates a Unicode string from a CFString object. Used entirely for error + reporting. + + Yes, it annoys me quite a lot that this function is this complex. + """ + value_as_void_p = ctypes.cast(value, ctypes.POINTER(ctypes.c_void_p)) + + string = CoreFoundation.CFStringGetCStringPtr( + value_as_void_p, CFConst.kCFStringEncodingUTF8 + ) + if string is None: + buffer = ctypes.create_string_buffer(1024) + result = CoreFoundation.CFStringGetCString( + value_as_void_p, buffer, 1024, CFConst.kCFStringEncodingUTF8 + ) + if not result: + raise OSError("Error copying C string from CFStringRef") + string = buffer.value + if string is not None: + string = string.decode("utf-8") + return string + + +def _assert_no_error(error, exception_class=None): + """ + Checks the return code and throws an exception if there is an error to + report + """ + if error == 0: + return + + cf_error_string = Security.SecCopyErrorMessageString(error, None) + output = _cf_string_to_unicode(cf_error_string) + CoreFoundation.CFRelease(cf_error_string) + + if output is None or output == u"": + output = u"OSStatus %s" % error + + if exception_class is None: + exception_class = ssl.SSLError + + raise exception_class(output) + + +def _cert_array_from_pem(pem_bundle): + """ + Given a bundle of certs in PEM format, turns them into a CFArray of certs + that can be used to validate a cert chain. + """ + # Normalize the PEM bundle's line endings. + pem_bundle = pem_bundle.replace(b"\r\n", b"\n") + + der_certs = [ + base64.b64decode(match.group(1)) for match in _PEM_CERTS_RE.finditer(pem_bundle) + ] + if not der_certs: + raise ssl.SSLError("No root certificates specified") + + cert_array = CoreFoundation.CFArrayCreateMutable( + CoreFoundation.kCFAllocatorDefault, + 0, + ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), + ) + if not cert_array: + raise ssl.SSLError("Unable to allocate memory!") + + try: + for der_bytes in der_certs: + certdata = _cf_data_from_bytes(der_bytes) + if not certdata: + raise ssl.SSLError("Unable to allocate memory!") + cert = Security.SecCertificateCreateWithData( + CoreFoundation.kCFAllocatorDefault, certdata + ) + CoreFoundation.CFRelease(certdata) + if not cert: + raise ssl.SSLError("Unable to build cert object!") + + CoreFoundation.CFArrayAppendValue(cert_array, cert) + CoreFoundation.CFRelease(cert) + except Exception: + # We need to free the array before the exception bubbles further. + # We only want to do that if an error occurs: otherwise, the caller + # should free. + CoreFoundation.CFRelease(cert_array) + + return cert_array + + +def _is_cert(item): + """ + Returns True if a given CFTypeRef is a certificate. + """ + expected = Security.SecCertificateGetTypeID() + return CoreFoundation.CFGetTypeID(item) == expected + + +def _is_identity(item): + """ + Returns True if a given CFTypeRef is an identity. + """ + expected = Security.SecIdentityGetTypeID() + return CoreFoundation.CFGetTypeID(item) == expected + + +def _temporary_keychain(): + """ + This function creates a temporary Mac keychain that we can use to work with + credentials. This keychain uses a one-time password and a temporary file to + store the data. We expect to have one keychain per socket. The returned + SecKeychainRef must be freed by the caller, including calling + SecKeychainDelete. + + Returns a tuple of the SecKeychainRef and the path to the temporary + directory that contains it. + """ + # Unfortunately, SecKeychainCreate requires a path to a keychain. This + # means we cannot use mkstemp to use a generic temporary file. Instead, + # we're going to create a temporary directory and a filename to use there. + # This filename will be 8 random bytes expanded into base64. We also need + # some random bytes to password-protect the keychain we're creating, so we + # ask for 40 random bytes. + random_bytes = os.urandom(40) + filename = base64.b16encode(random_bytes[:8]).decode("utf-8") + password = base64.b16encode(random_bytes[8:]) # Must be valid UTF-8 + tempdirectory = tempfile.mkdtemp() + + keychain_path = os.path.join(tempdirectory, filename).encode("utf-8") + + # We now want to create the keychain itself. + keychain = Security.SecKeychainRef() + status = Security.SecKeychainCreate( + keychain_path, len(password), password, False, None, ctypes.byref(keychain) + ) + _assert_no_error(status) + + # Having created the keychain, we want to pass it off to the caller. + return keychain, tempdirectory + + +def _load_items_from_file(keychain, path): + """ + Given a single file, loads all the trust objects from it into arrays and + the keychain. + Returns a tuple of lists: the first list is a list of identities, the + second a list of certs. + """ + certificates = [] + identities = [] + result_array = None + + with open(path, "rb") as f: + raw_filedata = f.read() + + try: + filedata = CoreFoundation.CFDataCreate( + CoreFoundation.kCFAllocatorDefault, raw_filedata, len(raw_filedata) + ) + result_array = CoreFoundation.CFArrayRef() + result = Security.SecItemImport( + filedata, # cert data + None, # Filename, leaving it out for now + None, # What the type of the file is, we don't care + None, # what's in the file, we don't care + 0, # import flags + None, # key params, can include passphrase in the future + keychain, # The keychain to insert into + ctypes.byref(result_array), # Results + ) + _assert_no_error(result) + + # A CFArray is not very useful to us as an intermediary + # representation, so we are going to extract the objects we want + # and then free the array. We don't need to keep hold of keys: the + # keychain already has them! + result_count = CoreFoundation.CFArrayGetCount(result_array) + for index in range(result_count): + item = CoreFoundation.CFArrayGetValueAtIndex(result_array, index) + item = ctypes.cast(item, CoreFoundation.CFTypeRef) + + if _is_cert(item): + CoreFoundation.CFRetain(item) + certificates.append(item) + elif _is_identity(item): + CoreFoundation.CFRetain(item) + identities.append(item) + finally: + if result_array: + CoreFoundation.CFRelease(result_array) + + CoreFoundation.CFRelease(filedata) + + return (identities, certificates) + + +def _load_client_cert_chain(keychain, *paths): + """ + Load certificates and maybe keys from a number of files. Has the end goal + of returning a CFArray containing one SecIdentityRef, and then zero or more + SecCertificateRef objects, suitable for use as a client certificate trust + chain. + """ + # Ok, the strategy. + # + # This relies on knowing that macOS will not give you a SecIdentityRef + # unless you have imported a key into a keychain. This is a somewhat + # artificial limitation of macOS (for example, it doesn't necessarily + # affect iOS), but there is nothing inside Security.framework that lets you + # get a SecIdentityRef without having a key in a keychain. + # + # So the policy here is we take all the files and iterate them in order. + # Each one will use SecItemImport to have one or more objects loaded from + # it. We will also point at a keychain that macOS can use to work with the + # private key. + # + # Once we have all the objects, we'll check what we actually have. If we + # already have a SecIdentityRef in hand, fab: we'll use that. Otherwise, + # we'll take the first certificate (which we assume to be our leaf) and + # ask the keychain to give us a SecIdentityRef with that cert's associated + # key. + # + # We'll then return a CFArray containing the trust chain: one + # SecIdentityRef and then zero-or-more SecCertificateRef objects. The + # responsibility for freeing this CFArray will be with the caller. This + # CFArray must remain alive for the entire connection, so in practice it + # will be stored with a single SSLSocket, along with the reference to the + # keychain. + certificates = [] + identities = [] + + # Filter out bad paths. + paths = (path for path in paths if path) + + try: + for file_path in paths: + new_identities, new_certs = _load_items_from_file(keychain, file_path) + identities.extend(new_identities) + certificates.extend(new_certs) + + # Ok, we have everything. The question is: do we have an identity? If + # not, we want to grab one from the first cert we have. + if not identities: + new_identity = Security.SecIdentityRef() + status = Security.SecIdentityCreateWithCertificate( + keychain, certificates[0], ctypes.byref(new_identity) + ) + _assert_no_error(status) + identities.append(new_identity) + + # We now want to release the original certificate, as we no longer + # need it. + CoreFoundation.CFRelease(certificates.pop(0)) + + # We now need to build a new CFArray that holds the trust chain. + trust_chain = CoreFoundation.CFArrayCreateMutable( + CoreFoundation.kCFAllocatorDefault, + 0, + ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), + ) + for item in itertools.chain(identities, certificates): + # ArrayAppendValue does a CFRetain on the item. That's fine, + # because the finally block will release our other refs to them. + CoreFoundation.CFArrayAppendValue(trust_chain, item) + + return trust_chain + finally: + for obj in itertools.chain(identities, certificates): + CoreFoundation.CFRelease(obj) + + +TLS_PROTOCOL_VERSIONS = { + "SSLv2": (0, 2), + "SSLv3": (3, 0), + "TLSv1": (3, 1), + "TLSv1.1": (3, 2), + "TLSv1.2": (3, 3), +} + + +def _build_tls_unknown_ca_alert(version): + """ + Builds a TLS alert record for an unknown CA. + """ + ver_maj, ver_min = TLS_PROTOCOL_VERSIONS[version] + severity_fatal = 0x02 + description_unknown_ca = 0x30 + msg = struct.pack(">BB", severity_fatal, description_unknown_ca) + msg_len = len(msg) + record_type_alert = 0x15 + record = struct.pack(">BBBH", record_type_alert, ver_maj, ver_min, msg_len) + msg + return record diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/appengine.py b/openpype/hosts/fusion/vendor/urllib3/contrib/appengine.py new file mode 100644 index 0000000000..f91bdd6e77 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/appengine.py @@ -0,0 +1,314 @@ +""" +This module provides a pool manager that uses Google App Engine's +`URLFetch Service `_. + +Example usage:: + + from urllib3 import PoolManager + from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox + + if is_appengine_sandbox(): + # AppEngineManager uses AppEngine's URLFetch API behind the scenes + http = AppEngineManager() + else: + # PoolManager uses a socket-level API behind the scenes + http = PoolManager() + + r = http.request('GET', 'https://google.com/') + +There are `limitations `_ to the URLFetch service and it may not be +the best choice for your application. There are three options for using +urllib3 on Google App Engine: + +1. You can use :class:`AppEngineManager` with URLFetch. URLFetch is + cost-effective in many circumstances as long as your usage is within the + limitations. +2. You can use a normal :class:`~urllib3.PoolManager` by enabling sockets. + Sockets also have `limitations and restrictions + `_ and have a lower free quota than URLFetch. + To use sockets, be sure to specify the following in your ``app.yaml``:: + + env_variables: + GAE_USE_SOCKETS_HTTPLIB : 'true' + +3. If you are using `App Engine Flexible +`_, you can use the standard +:class:`PoolManager` without any configuration or special environment variables. +""" + +from __future__ import absolute_import + +import io +import logging +import warnings + +from ..exceptions import ( + HTTPError, + HTTPWarning, + MaxRetryError, + ProtocolError, + SSLError, + TimeoutError, +) +from ..packages.six.moves.urllib.parse import urljoin +from ..request import RequestMethods +from ..response import HTTPResponse +from ..util.retry import Retry +from ..util.timeout import Timeout +from . import _appengine_environ + +try: + from google.appengine.api import urlfetch +except ImportError: + urlfetch = None + + +log = logging.getLogger(__name__) + + +class AppEnginePlatformWarning(HTTPWarning): + pass + + +class AppEnginePlatformError(HTTPError): + pass + + +class AppEngineManager(RequestMethods): + """ + Connection manager for Google App Engine sandbox applications. + + This manager uses the URLFetch service directly instead of using the + emulated httplib, and is subject to URLFetch limitations as described in + the App Engine documentation `here + `_. + + Notably it will raise an :class:`AppEnginePlatformError` if: + * URLFetch is not available. + * If you attempt to use this on App Engine Flexible, as full socket + support is available. + * If a request size is more than 10 megabytes. + * If a response size is more than 32 megabytes. + * If you use an unsupported request method such as OPTIONS. + + Beyond those cases, it will raise normal urllib3 errors. + """ + + def __init__( + self, + headers=None, + retries=None, + validate_certificate=True, + urlfetch_retries=True, + ): + if not urlfetch: + raise AppEnginePlatformError( + "URLFetch is not available in this environment." + ) + + warnings.warn( + "urllib3 is using URLFetch on Google App Engine sandbox instead " + "of sockets. To use sockets directly instead of URLFetch see " + "https://urllib3.readthedocs.io/en/1.26.x/reference/urllib3.contrib.html.", + AppEnginePlatformWarning, + ) + + RequestMethods.__init__(self, headers) + self.validate_certificate = validate_certificate + self.urlfetch_retries = urlfetch_retries + + self.retries = retries or Retry.DEFAULT + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # Return False to re-raise any potential exceptions + return False + + def urlopen( + self, + method, + url, + body=None, + headers=None, + retries=None, + redirect=True, + timeout=Timeout.DEFAULT_TIMEOUT, + **response_kw + ): + + retries = self._get_retries(retries, redirect) + + try: + follow_redirects = redirect and retries.redirect != 0 and retries.total + response = urlfetch.fetch( + url, + payload=body, + method=method, + headers=headers or {}, + allow_truncated=False, + follow_redirects=self.urlfetch_retries and follow_redirects, + deadline=self._get_absolute_timeout(timeout), + validate_certificate=self.validate_certificate, + ) + except urlfetch.DeadlineExceededError as e: + raise TimeoutError(self, e) + + except urlfetch.InvalidURLError as e: + if "too large" in str(e): + raise AppEnginePlatformError( + "URLFetch request too large, URLFetch only " + "supports requests up to 10mb in size.", + e, + ) + raise ProtocolError(e) + + except urlfetch.DownloadError as e: + if "Too many redirects" in str(e): + raise MaxRetryError(self, url, reason=e) + raise ProtocolError(e) + + except urlfetch.ResponseTooLargeError as e: + raise AppEnginePlatformError( + "URLFetch response too large, URLFetch only supports" + "responses up to 32mb in size.", + e, + ) + + except urlfetch.SSLCertificateError as e: + raise SSLError(e) + + except urlfetch.InvalidMethodError as e: + raise AppEnginePlatformError( + "URLFetch does not support method: %s" % method, e + ) + + http_response = self._urlfetch_response_to_http_response( + response, retries=retries, **response_kw + ) + + # Handle redirect? + redirect_location = redirect and http_response.get_redirect_location() + if redirect_location: + # Check for redirect response + if self.urlfetch_retries and retries.raise_on_redirect: + raise MaxRetryError(self, url, "too many redirects") + else: + if http_response.status == 303: + method = "GET" + + try: + retries = retries.increment( + method, url, response=http_response, _pool=self + ) + except MaxRetryError: + if retries.raise_on_redirect: + raise MaxRetryError(self, url, "too many redirects") + return http_response + + retries.sleep_for_retry(http_response) + log.debug("Redirecting %s -> %s", url, redirect_location) + redirect_url = urljoin(url, redirect_location) + return self.urlopen( + method, + redirect_url, + body, + headers, + retries=retries, + redirect=redirect, + timeout=timeout, + **response_kw + ) + + # Check if we should retry the HTTP response. + has_retry_after = bool(http_response.getheader("Retry-After")) + if retries.is_retry(method, http_response.status, has_retry_after): + retries = retries.increment(method, url, response=http_response, _pool=self) + log.debug("Retry: %s", url) + retries.sleep(http_response) + return self.urlopen( + method, + url, + body=body, + headers=headers, + retries=retries, + redirect=redirect, + timeout=timeout, + **response_kw + ) + + return http_response + + def _urlfetch_response_to_http_response(self, urlfetch_resp, **response_kw): + + if is_prod_appengine(): + # Production GAE handles deflate encoding automatically, but does + # not remove the encoding header. + content_encoding = urlfetch_resp.headers.get("content-encoding") + + if content_encoding == "deflate": + del urlfetch_resp.headers["content-encoding"] + + transfer_encoding = urlfetch_resp.headers.get("transfer-encoding") + # We have a full response's content, + # so let's make sure we don't report ourselves as chunked data. + if transfer_encoding == "chunked": + encodings = transfer_encoding.split(",") + encodings.remove("chunked") + urlfetch_resp.headers["transfer-encoding"] = ",".join(encodings) + + original_response = HTTPResponse( + # In order for decoding to work, we must present the content as + # a file-like object. + body=io.BytesIO(urlfetch_resp.content), + msg=urlfetch_resp.header_msg, + headers=urlfetch_resp.headers, + status=urlfetch_resp.status_code, + **response_kw + ) + + return HTTPResponse( + body=io.BytesIO(urlfetch_resp.content), + headers=urlfetch_resp.headers, + status=urlfetch_resp.status_code, + original_response=original_response, + **response_kw + ) + + def _get_absolute_timeout(self, timeout): + if timeout is Timeout.DEFAULT_TIMEOUT: + return None # Defer to URLFetch's default. + if isinstance(timeout, Timeout): + if timeout._read is not None or timeout._connect is not None: + warnings.warn( + "URLFetch does not support granular timeout settings, " + "reverting to total or default URLFetch timeout.", + AppEnginePlatformWarning, + ) + return timeout.total + return timeout + + def _get_retries(self, retries, redirect): + if not isinstance(retries, Retry): + retries = Retry.from_int(retries, redirect=redirect, default=self.retries) + + if retries.connect or retries.read or retries.redirect: + warnings.warn( + "URLFetch only supports total retries and does not " + "recognize connect, read, or redirect retry parameters.", + AppEnginePlatformWarning, + ) + + return retries + + +# Alias methods from _appengine_environ to maintain public API interface. + +is_appengine = _appengine_environ.is_appengine +is_appengine_sandbox = _appengine_environ.is_appengine_sandbox +is_local_appengine = _appengine_environ.is_local_appengine +is_prod_appengine = _appengine_environ.is_prod_appengine +is_prod_appengine_mvms = _appengine_environ.is_prod_appengine_mvms diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/ntlmpool.py b/openpype/hosts/fusion/vendor/urllib3/contrib/ntlmpool.py new file mode 100644 index 0000000000..41a8fd174c --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/ntlmpool.py @@ -0,0 +1,130 @@ +""" +NTLM authenticating pool, contributed by erikcederstran + +Issue #10, see: http://code.google.com/p/urllib3/issues/detail?id=10 +""" +from __future__ import absolute_import + +import warnings +from logging import getLogger + +from ntlm import ntlm + +from .. import HTTPSConnectionPool +from ..packages.six.moves.http_client import HTTPSConnection + +warnings.warn( + "The 'urllib3.contrib.ntlmpool' module is deprecated and will be removed " + "in urllib3 v2.0 release, urllib3 is not able to support it properly due " + "to reasons listed in issue: https://github.com/urllib3/urllib3/issues/2282. " + "If you are a user of this module please comment in the mentioned issue.", + DeprecationWarning, +) + +log = getLogger(__name__) + + +class NTLMConnectionPool(HTTPSConnectionPool): + """ + Implements an NTLM authentication version of an urllib3 connection pool + """ + + scheme = "https" + + def __init__(self, user, pw, authurl, *args, **kwargs): + """ + authurl is a random URL on the server that is protected by NTLM. + user is the Windows user, probably in the DOMAIN\\username format. + pw is the password for the user. + """ + super(NTLMConnectionPool, self).__init__(*args, **kwargs) + self.authurl = authurl + self.rawuser = user + user_parts = user.split("\\", 1) + self.domain = user_parts[0].upper() + self.user = user_parts[1] + self.pw = pw + + def _new_conn(self): + # Performs the NTLM handshake that secures the connection. The socket + # must be kept open while requests are performed. + self.num_connections += 1 + log.debug( + "Starting NTLM HTTPS connection no. %d: https://%s%s", + self.num_connections, + self.host, + self.authurl, + ) + + headers = {"Connection": "Keep-Alive"} + req_header = "Authorization" + resp_header = "www-authenticate" + + conn = HTTPSConnection(host=self.host, port=self.port) + + # Send negotiation message + headers[req_header] = "NTLM %s" % ntlm.create_NTLM_NEGOTIATE_MESSAGE( + self.rawuser + ) + log.debug("Request headers: %s", headers) + conn.request("GET", self.authurl, None, headers) + res = conn.getresponse() + reshdr = dict(res.getheaders()) + log.debug("Response status: %s %s", res.status, res.reason) + log.debug("Response headers: %s", reshdr) + log.debug("Response data: %s [...]", res.read(100)) + + # Remove the reference to the socket, so that it can not be closed by + # the response object (we want to keep the socket open) + res.fp = None + + # Server should respond with a challenge message + auth_header_values = reshdr[resp_header].split(", ") + auth_header_value = None + for s in auth_header_values: + if s[:5] == "NTLM ": + auth_header_value = s[5:] + if auth_header_value is None: + raise Exception( + "Unexpected %s response header: %s" % (resp_header, reshdr[resp_header]) + ) + + # Send authentication message + ServerChallenge, NegotiateFlags = ntlm.parse_NTLM_CHALLENGE_MESSAGE( + auth_header_value + ) + auth_msg = ntlm.create_NTLM_AUTHENTICATE_MESSAGE( + ServerChallenge, self.user, self.domain, self.pw, NegotiateFlags + ) + headers[req_header] = "NTLM %s" % auth_msg + log.debug("Request headers: %s", headers) + conn.request("GET", self.authurl, None, headers) + res = conn.getresponse() + log.debug("Response status: %s %s", res.status, res.reason) + log.debug("Response headers: %s", dict(res.getheaders())) + log.debug("Response data: %s [...]", res.read()[:100]) + if res.status != 200: + if res.status == 401: + raise Exception("Server rejected request: wrong username or password") + raise Exception("Wrong server response: %s %s" % (res.status, res.reason)) + + res.fp = None + log.debug("Connection established") + return conn + + def urlopen( + self, + method, + url, + body=None, + headers=None, + retries=3, + redirect=True, + assert_same_host=True, + ): + if headers is None: + headers = {} + headers["Connection"] = "Keep-Alive" + return super(NTLMConnectionPool, self).urlopen( + method, url, body, headers, retries, redirect, assert_same_host + ) diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/pyopenssl.py b/openpype/hosts/fusion/vendor/urllib3/contrib/pyopenssl.py new file mode 100644 index 0000000000..def83afdb2 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/pyopenssl.py @@ -0,0 +1,511 @@ +""" +TLS with SNI_-support for Python 2. Follow these instructions if you would +like to verify TLS certificates in Python 2. Note, the default libraries do +*not* do certificate checking; you need to do additional work to validate +certificates yourself. + +This needs the following packages installed: + +* `pyOpenSSL`_ (tested with 16.0.0) +* `cryptography`_ (minimum 1.3.4, from pyopenssl) +* `idna`_ (minimum 2.0, from cryptography) + +However, pyopenssl depends on cryptography, which depends on idna, so while we +use all three directly here we end up having relatively few packages required. + +You can install them with the following command: + +.. code-block:: bash + + $ python -m pip install pyopenssl cryptography idna + +To activate certificate checking, call +:func:`~urllib3.contrib.pyopenssl.inject_into_urllib3` from your Python code +before you begin making HTTP requests. This can be done in a ``sitecustomize`` +module, or at any other time before your application begins using ``urllib3``, +like this: + +.. code-block:: python + + try: + import urllib3.contrib.pyopenssl + urllib3.contrib.pyopenssl.inject_into_urllib3() + except ImportError: + pass + +Now you can use :mod:`urllib3` as you normally would, and it will support SNI +when the required modules are installed. + +Activating this module also has the positive side effect of disabling SSL/TLS +compression in Python 2 (see `CRIME attack`_). + +.. _sni: https://en.wikipedia.org/wiki/Server_Name_Indication +.. _crime attack: https://en.wikipedia.org/wiki/CRIME_(security_exploit) +.. _pyopenssl: https://www.pyopenssl.org +.. _cryptography: https://cryptography.io +.. _idna: https://github.com/kjd/idna +""" +from __future__ import absolute_import + +import OpenSSL.SSL +from cryptography import x509 +from cryptography.hazmat.backends.openssl import backend as openssl_backend +from cryptography.hazmat.backends.openssl.x509 import _Certificate + +try: + from cryptography.x509 import UnsupportedExtension +except ImportError: + # UnsupportedExtension is gone in cryptography >= 2.1.0 + class UnsupportedExtension(Exception): + pass + + +from io import BytesIO +from socket import error as SocketError +from socket import timeout + +try: # Platform-specific: Python 2 + from socket import _fileobject +except ImportError: # Platform-specific: Python 3 + _fileobject = None + from ..packages.backports.makefile import backport_makefile + +import logging +import ssl +import sys + +from .. import util +from ..packages import six +from ..util.ssl_ import PROTOCOL_TLS_CLIENT + +__all__ = ["inject_into_urllib3", "extract_from_urllib3"] + +# SNI always works. +HAS_SNI = True + +# Map from urllib3 to PyOpenSSL compatible parameter-values. +_openssl_versions = { + util.PROTOCOL_TLS: OpenSSL.SSL.SSLv23_METHOD, + PROTOCOL_TLS_CLIENT: OpenSSL.SSL.SSLv23_METHOD, + ssl.PROTOCOL_TLSv1: OpenSSL.SSL.TLSv1_METHOD, +} + +if hasattr(ssl, "PROTOCOL_SSLv3") and hasattr(OpenSSL.SSL, "SSLv3_METHOD"): + _openssl_versions[ssl.PROTOCOL_SSLv3] = OpenSSL.SSL.SSLv3_METHOD + +if hasattr(ssl, "PROTOCOL_TLSv1_1") and hasattr(OpenSSL.SSL, "TLSv1_1_METHOD"): + _openssl_versions[ssl.PROTOCOL_TLSv1_1] = OpenSSL.SSL.TLSv1_1_METHOD + +if hasattr(ssl, "PROTOCOL_TLSv1_2") and hasattr(OpenSSL.SSL, "TLSv1_2_METHOD"): + _openssl_versions[ssl.PROTOCOL_TLSv1_2] = OpenSSL.SSL.TLSv1_2_METHOD + + +_stdlib_to_openssl_verify = { + ssl.CERT_NONE: OpenSSL.SSL.VERIFY_NONE, + ssl.CERT_OPTIONAL: OpenSSL.SSL.VERIFY_PEER, + ssl.CERT_REQUIRED: OpenSSL.SSL.VERIFY_PEER + + OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT, +} +_openssl_to_stdlib_verify = dict((v, k) for k, v in _stdlib_to_openssl_verify.items()) + +# OpenSSL will only write 16K at a time +SSL_WRITE_BLOCKSIZE = 16384 + +orig_util_HAS_SNI = util.HAS_SNI +orig_util_SSLContext = util.ssl_.SSLContext + + +log = logging.getLogger(__name__) + + +def inject_into_urllib3(): + "Monkey-patch urllib3 with PyOpenSSL-backed SSL-support." + + _validate_dependencies_met() + + util.SSLContext = PyOpenSSLContext + util.ssl_.SSLContext = PyOpenSSLContext + util.HAS_SNI = HAS_SNI + util.ssl_.HAS_SNI = HAS_SNI + util.IS_PYOPENSSL = True + util.ssl_.IS_PYOPENSSL = True + + +def extract_from_urllib3(): + "Undo monkey-patching by :func:`inject_into_urllib3`." + + util.SSLContext = orig_util_SSLContext + util.ssl_.SSLContext = orig_util_SSLContext + util.HAS_SNI = orig_util_HAS_SNI + util.ssl_.HAS_SNI = orig_util_HAS_SNI + util.IS_PYOPENSSL = False + util.ssl_.IS_PYOPENSSL = False + + +def _validate_dependencies_met(): + """ + Verifies that PyOpenSSL's package-level dependencies have been met. + Throws `ImportError` if they are not met. + """ + # Method added in `cryptography==1.1`; not available in older versions + from cryptography.x509.extensions import Extensions + + if getattr(Extensions, "get_extension_for_class", None) is None: + raise ImportError( + "'cryptography' module missing required functionality. " + "Try upgrading to v1.3.4 or newer." + ) + + # pyOpenSSL 0.14 and above use cryptography for OpenSSL bindings. The _x509 + # attribute is only present on those versions. + from OpenSSL.crypto import X509 + + x509 = X509() + if getattr(x509, "_x509", None) is None: + raise ImportError( + "'pyOpenSSL' module missing required functionality. " + "Try upgrading to v0.14 or newer." + ) + + +def _dnsname_to_stdlib(name): + """ + Converts a dNSName SubjectAlternativeName field to the form used by the + standard library on the given Python version. + + Cryptography produces a dNSName as a unicode string that was idna-decoded + from ASCII bytes. We need to idna-encode that string to get it back, and + then on Python 3 we also need to convert to unicode via UTF-8 (the stdlib + uses PyUnicode_FromStringAndSize on it, which decodes via UTF-8). + + If the name cannot be idna-encoded then we return None signalling that + the name given should be skipped. + """ + + def idna_encode(name): + """ + Borrowed wholesale from the Python Cryptography Project. It turns out + that we can't just safely call `idna.encode`: it can explode for + wildcard names. This avoids that problem. + """ + import idna + + try: + for prefix in [u"*.", u"."]: + if name.startswith(prefix): + name = name[len(prefix) :] + return prefix.encode("ascii") + idna.encode(name) + return idna.encode(name) + except idna.core.IDNAError: + return None + + # Don't send IPv6 addresses through the IDNA encoder. + if ":" in name: + return name + + name = idna_encode(name) + if name is None: + return None + elif sys.version_info >= (3, 0): + name = name.decode("utf-8") + return name + + +def get_subj_alt_name(peer_cert): + """ + Given an PyOpenSSL certificate, provides all the subject alternative names. + """ + # Pass the cert to cryptography, which has much better APIs for this. + if hasattr(peer_cert, "to_cryptography"): + cert = peer_cert.to_cryptography() + else: + # This is technically using private APIs, but should work across all + # relevant versions before PyOpenSSL got a proper API for this. + cert = _Certificate(openssl_backend, peer_cert._x509) + + # We want to find the SAN extension. Ask Cryptography to locate it (it's + # faster than looping in Python) + try: + ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + except x509.ExtensionNotFound: + # No such extension, return the empty list. + return [] + except ( + x509.DuplicateExtension, + UnsupportedExtension, + x509.UnsupportedGeneralNameType, + UnicodeError, + ) as e: + # A problem has been found with the quality of the certificate. Assume + # no SAN field is present. + log.warning( + "A problem was encountered with the certificate that prevented " + "urllib3 from finding the SubjectAlternativeName field. This can " + "affect certificate validation. The error was %s", + e, + ) + return [] + + # We want to return dNSName and iPAddress fields. We need to cast the IPs + # back to strings because the match_hostname function wants them as + # strings. + # Sadly the DNS names need to be idna encoded and then, on Python 3, UTF-8 + # decoded. This is pretty frustrating, but that's what the standard library + # does with certificates, and so we need to attempt to do the same. + # We also want to skip over names which cannot be idna encoded. + names = [ + ("DNS", name) + for name in map(_dnsname_to_stdlib, ext.get_values_for_type(x509.DNSName)) + if name is not None + ] + names.extend( + ("IP Address", str(name)) for name in ext.get_values_for_type(x509.IPAddress) + ) + + return names + + +class WrappedSocket(object): + """API-compatibility wrapper for Python OpenSSL's Connection-class. + + Note: _makefile_refs, _drop() and _reuse() are needed for the garbage + collector of pypy. + """ + + def __init__(self, connection, socket, suppress_ragged_eofs=True): + self.connection = connection + self.socket = socket + self.suppress_ragged_eofs = suppress_ragged_eofs + self._makefile_refs = 0 + self._closed = False + + def fileno(self): + return self.socket.fileno() + + # Copy-pasted from Python 3.5 source code + def _decref_socketios(self): + if self._makefile_refs > 0: + self._makefile_refs -= 1 + if self._closed: + self.close() + + def recv(self, *args, **kwargs): + try: + data = self.connection.recv(*args, **kwargs) + except OpenSSL.SSL.SysCallError as e: + if self.suppress_ragged_eofs and e.args == (-1, "Unexpected EOF"): + return b"" + else: + raise SocketError(str(e)) + except OpenSSL.SSL.ZeroReturnError: + if self.connection.get_shutdown() == OpenSSL.SSL.RECEIVED_SHUTDOWN: + return b"" + else: + raise + except OpenSSL.SSL.WantReadError: + if not util.wait_for_read(self.socket, self.socket.gettimeout()): + raise timeout("The read operation timed out") + else: + return self.recv(*args, **kwargs) + + # TLS 1.3 post-handshake authentication + except OpenSSL.SSL.Error as e: + raise ssl.SSLError("read error: %r" % e) + else: + return data + + def recv_into(self, *args, **kwargs): + try: + return self.connection.recv_into(*args, **kwargs) + except OpenSSL.SSL.SysCallError as e: + if self.suppress_ragged_eofs and e.args == (-1, "Unexpected EOF"): + return 0 + else: + raise SocketError(str(e)) + except OpenSSL.SSL.ZeroReturnError: + if self.connection.get_shutdown() == OpenSSL.SSL.RECEIVED_SHUTDOWN: + return 0 + else: + raise + except OpenSSL.SSL.WantReadError: + if not util.wait_for_read(self.socket, self.socket.gettimeout()): + raise timeout("The read operation timed out") + else: + return self.recv_into(*args, **kwargs) + + # TLS 1.3 post-handshake authentication + except OpenSSL.SSL.Error as e: + raise ssl.SSLError("read error: %r" % e) + + def settimeout(self, timeout): + return self.socket.settimeout(timeout) + + def _send_until_done(self, data): + while True: + try: + return self.connection.send(data) + except OpenSSL.SSL.WantWriteError: + if not util.wait_for_write(self.socket, self.socket.gettimeout()): + raise timeout() + continue + except OpenSSL.SSL.SysCallError as e: + raise SocketError(str(e)) + + def sendall(self, data): + total_sent = 0 + while total_sent < len(data): + sent = self._send_until_done( + data[total_sent : total_sent + SSL_WRITE_BLOCKSIZE] + ) + total_sent += sent + + def shutdown(self): + # FIXME rethrow compatible exceptions should we ever use this + self.connection.shutdown() + + def close(self): + if self._makefile_refs < 1: + try: + self._closed = True + return self.connection.close() + except OpenSSL.SSL.Error: + return + else: + self._makefile_refs -= 1 + + def getpeercert(self, binary_form=False): + x509 = self.connection.get_peer_certificate() + + if not x509: + return x509 + + if binary_form: + return OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, x509) + + return { + "subject": ((("commonName", x509.get_subject().CN),),), + "subjectAltName": get_subj_alt_name(x509), + } + + def version(self): + return self.connection.get_protocol_version_name() + + def _reuse(self): + self._makefile_refs += 1 + + def _drop(self): + if self._makefile_refs < 1: + self.close() + else: + self._makefile_refs -= 1 + + +if _fileobject: # Platform-specific: Python 2 + + def makefile(self, mode, bufsize=-1): + self._makefile_refs += 1 + return _fileobject(self, mode, bufsize, close=True) + + +else: # Platform-specific: Python 3 + makefile = backport_makefile + +WrappedSocket.makefile = makefile + + +class PyOpenSSLContext(object): + """ + I am a wrapper class for the PyOpenSSL ``Context`` object. I am responsible + for translating the interface of the standard library ``SSLContext`` object + to calls into PyOpenSSL. + """ + + def __init__(self, protocol): + self.protocol = _openssl_versions[protocol] + self._ctx = OpenSSL.SSL.Context(self.protocol) + self._options = 0 + self.check_hostname = False + + @property + def options(self): + return self._options + + @options.setter + def options(self, value): + self._options = value + self._ctx.set_options(value) + + @property + def verify_mode(self): + return _openssl_to_stdlib_verify[self._ctx.get_verify_mode()] + + @verify_mode.setter + def verify_mode(self, value): + self._ctx.set_verify(_stdlib_to_openssl_verify[value], _verify_callback) + + def set_default_verify_paths(self): + self._ctx.set_default_verify_paths() + + def set_ciphers(self, ciphers): + if isinstance(ciphers, six.text_type): + ciphers = ciphers.encode("utf-8") + self._ctx.set_cipher_list(ciphers) + + def load_verify_locations(self, cafile=None, capath=None, cadata=None): + if cafile is not None: + cafile = cafile.encode("utf-8") + if capath is not None: + capath = capath.encode("utf-8") + try: + self._ctx.load_verify_locations(cafile, capath) + if cadata is not None: + self._ctx.load_verify_locations(BytesIO(cadata)) + except OpenSSL.SSL.Error as e: + raise ssl.SSLError("unable to load trusted certificates: %r" % e) + + def load_cert_chain(self, certfile, keyfile=None, password=None): + self._ctx.use_certificate_chain_file(certfile) + if password is not None: + if not isinstance(password, six.binary_type): + password = password.encode("utf-8") + self._ctx.set_passwd_cb(lambda *_: password) + self._ctx.use_privatekey_file(keyfile or certfile) + + def set_alpn_protocols(self, protocols): + protocols = [six.ensure_binary(p) for p in protocols] + return self._ctx.set_alpn_protos(protocols) + + def wrap_socket( + self, + sock, + server_side=False, + do_handshake_on_connect=True, + suppress_ragged_eofs=True, + server_hostname=None, + ): + cnx = OpenSSL.SSL.Connection(self._ctx, sock) + + if isinstance(server_hostname, six.text_type): # Platform-specific: Python 3 + server_hostname = server_hostname.encode("utf-8") + + if server_hostname is not None: + cnx.set_tlsext_host_name(server_hostname) + + cnx.set_connect_state() + + while True: + try: + cnx.do_handshake() + except OpenSSL.SSL.WantReadError: + if not util.wait_for_read(sock, sock.gettimeout()): + raise timeout("select timed out") + continue + except OpenSSL.SSL.Error as e: + raise ssl.SSLError("bad handshake: %r" % e) + break + + return WrappedSocket(cnx, sock) + + +def _verify_callback(cnx, x509, err_no, err_depth, return_code): + return err_no == 0 diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/securetransport.py b/openpype/hosts/fusion/vendor/urllib3/contrib/securetransport.py new file mode 100644 index 0000000000..554c015fed --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/securetransport.py @@ -0,0 +1,922 @@ +""" +SecureTranport support for urllib3 via ctypes. + +This makes platform-native TLS available to urllib3 users on macOS without the +use of a compiler. This is an important feature because the Python Package +Index is moving to become a TLSv1.2-or-higher server, and the default OpenSSL +that ships with macOS is not capable of doing TLSv1.2. The only way to resolve +this is to give macOS users an alternative solution to the problem, and that +solution is to use SecureTransport. + +We use ctypes here because this solution must not require a compiler. That's +because pip is not allowed to require a compiler either. + +This is not intended to be a seriously long-term solution to this problem. +The hope is that PEP 543 will eventually solve this issue for us, at which +point we can retire this contrib module. But in the short term, we need to +solve the impending tire fire that is Python on Mac without this kind of +contrib module. So...here we are. + +To use this module, simply import and inject it:: + + import urllib3.contrib.securetransport + urllib3.contrib.securetransport.inject_into_urllib3() + +Happy TLSing! + +This code is a bastardised version of the code found in Will Bond's oscrypto +library. An enormous debt is owed to him for blazing this trail for us. For +that reason, this code should be considered to be covered both by urllib3's +license and by oscrypto's: + +.. code-block:: + + Copyright (c) 2015-2016 Will Bond + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +""" +from __future__ import absolute_import + +import contextlib +import ctypes +import errno +import os.path +import shutil +import socket +import ssl +import struct +import threading +import weakref + +import six + +from .. import util +from ..util.ssl_ import PROTOCOL_TLS_CLIENT +from ._securetransport.bindings import CoreFoundation, Security, SecurityConst +from ._securetransport.low_level import ( + _assert_no_error, + _build_tls_unknown_ca_alert, + _cert_array_from_pem, + _create_cfstring_array, + _load_client_cert_chain, + _temporary_keychain, +) + +try: # Platform-specific: Python 2 + from socket import _fileobject +except ImportError: # Platform-specific: Python 3 + _fileobject = None + from ..packages.backports.makefile import backport_makefile + +__all__ = ["inject_into_urllib3", "extract_from_urllib3"] + +# SNI always works +HAS_SNI = True + +orig_util_HAS_SNI = util.HAS_SNI +orig_util_SSLContext = util.ssl_.SSLContext + +# This dictionary is used by the read callback to obtain a handle to the +# calling wrapped socket. This is a pretty silly approach, but for now it'll +# do. I feel like I should be able to smuggle a handle to the wrapped socket +# directly in the SSLConnectionRef, but for now this approach will work I +# guess. +# +# We need to lock around this structure for inserts, but we don't do it for +# reads/writes in the callbacks. The reasoning here goes as follows: +# +# 1. It is not possible to call into the callbacks before the dictionary is +# populated, so once in the callback the id must be in the dictionary. +# 2. The callbacks don't mutate the dictionary, they only read from it, and +# so cannot conflict with any of the insertions. +# +# This is good: if we had to lock in the callbacks we'd drastically slow down +# the performance of this code. +_connection_refs = weakref.WeakValueDictionary() +_connection_ref_lock = threading.Lock() + +# Limit writes to 16kB. This is OpenSSL's limit, but we'll cargo-cult it over +# for no better reason than we need *a* limit, and this one is right there. +SSL_WRITE_BLOCKSIZE = 16384 + +# This is our equivalent of util.ssl_.DEFAULT_CIPHERS, but expanded out to +# individual cipher suites. We need to do this because this is how +# SecureTransport wants them. +CIPHER_SUITES = [ + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + SecurityConst.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + SecurityConst.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + SecurityConst.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384, + SecurityConst.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, + SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + SecurityConst.TLS_DHE_RSA_WITH_AES_256_CBC_SHA256, + SecurityConst.TLS_DHE_RSA_WITH_AES_256_CBC_SHA, + SecurityConst.TLS_DHE_RSA_WITH_AES_128_CBC_SHA256, + SecurityConst.TLS_DHE_RSA_WITH_AES_128_CBC_SHA, + SecurityConst.TLS_AES_256_GCM_SHA384, + SecurityConst.TLS_AES_128_GCM_SHA256, + SecurityConst.TLS_RSA_WITH_AES_256_GCM_SHA384, + SecurityConst.TLS_RSA_WITH_AES_128_GCM_SHA256, + SecurityConst.TLS_AES_128_CCM_8_SHA256, + SecurityConst.TLS_AES_128_CCM_SHA256, + SecurityConst.TLS_RSA_WITH_AES_256_CBC_SHA256, + SecurityConst.TLS_RSA_WITH_AES_128_CBC_SHA256, + SecurityConst.TLS_RSA_WITH_AES_256_CBC_SHA, + SecurityConst.TLS_RSA_WITH_AES_128_CBC_SHA, +] + +# Basically this is simple: for PROTOCOL_SSLv23 we turn it into a low of +# TLSv1 and a high of TLSv1.2. For everything else, we pin to that version. +# TLSv1 to 1.2 are supported on macOS 10.8+ +_protocol_to_min_max = { + util.PROTOCOL_TLS: (SecurityConst.kTLSProtocol1, SecurityConst.kTLSProtocol12), + PROTOCOL_TLS_CLIENT: (SecurityConst.kTLSProtocol1, SecurityConst.kTLSProtocol12), +} + +if hasattr(ssl, "PROTOCOL_SSLv2"): + _protocol_to_min_max[ssl.PROTOCOL_SSLv2] = ( + SecurityConst.kSSLProtocol2, + SecurityConst.kSSLProtocol2, + ) +if hasattr(ssl, "PROTOCOL_SSLv3"): + _protocol_to_min_max[ssl.PROTOCOL_SSLv3] = ( + SecurityConst.kSSLProtocol3, + SecurityConst.kSSLProtocol3, + ) +if hasattr(ssl, "PROTOCOL_TLSv1"): + _protocol_to_min_max[ssl.PROTOCOL_TLSv1] = ( + SecurityConst.kTLSProtocol1, + SecurityConst.kTLSProtocol1, + ) +if hasattr(ssl, "PROTOCOL_TLSv1_1"): + _protocol_to_min_max[ssl.PROTOCOL_TLSv1_1] = ( + SecurityConst.kTLSProtocol11, + SecurityConst.kTLSProtocol11, + ) +if hasattr(ssl, "PROTOCOL_TLSv1_2"): + _protocol_to_min_max[ssl.PROTOCOL_TLSv1_2] = ( + SecurityConst.kTLSProtocol12, + SecurityConst.kTLSProtocol12, + ) + + +def inject_into_urllib3(): + """ + Monkey-patch urllib3 with SecureTransport-backed SSL-support. + """ + util.SSLContext = SecureTransportContext + util.ssl_.SSLContext = SecureTransportContext + util.HAS_SNI = HAS_SNI + util.ssl_.HAS_SNI = HAS_SNI + util.IS_SECURETRANSPORT = True + util.ssl_.IS_SECURETRANSPORT = True + + +def extract_from_urllib3(): + """ + Undo monkey-patching by :func:`inject_into_urllib3`. + """ + util.SSLContext = orig_util_SSLContext + util.ssl_.SSLContext = orig_util_SSLContext + util.HAS_SNI = orig_util_HAS_SNI + util.ssl_.HAS_SNI = orig_util_HAS_SNI + util.IS_SECURETRANSPORT = False + util.ssl_.IS_SECURETRANSPORT = False + + +def _read_callback(connection_id, data_buffer, data_length_pointer): + """ + SecureTransport read callback. This is called by ST to request that data + be returned from the socket. + """ + wrapped_socket = None + try: + wrapped_socket = _connection_refs.get(connection_id) + if wrapped_socket is None: + return SecurityConst.errSSLInternal + base_socket = wrapped_socket.socket + + requested_length = data_length_pointer[0] + + timeout = wrapped_socket.gettimeout() + error = None + read_count = 0 + + try: + while read_count < requested_length: + if timeout is None or timeout >= 0: + if not util.wait_for_read(base_socket, timeout): + raise socket.error(errno.EAGAIN, "timed out") + + remaining = requested_length - read_count + buffer = (ctypes.c_char * remaining).from_address( + data_buffer + read_count + ) + chunk_size = base_socket.recv_into(buffer, remaining) + read_count += chunk_size + if not chunk_size: + if not read_count: + return SecurityConst.errSSLClosedGraceful + break + except (socket.error) as e: + error = e.errno + + if error is not None and error != errno.EAGAIN: + data_length_pointer[0] = read_count + if error == errno.ECONNRESET or error == errno.EPIPE: + return SecurityConst.errSSLClosedAbort + raise + + data_length_pointer[0] = read_count + + if read_count != requested_length: + return SecurityConst.errSSLWouldBlock + + return 0 + except Exception as e: + if wrapped_socket is not None: + wrapped_socket._exception = e + return SecurityConst.errSSLInternal + + +def _write_callback(connection_id, data_buffer, data_length_pointer): + """ + SecureTransport write callback. This is called by ST to request that data + actually be sent on the network. + """ + wrapped_socket = None + try: + wrapped_socket = _connection_refs.get(connection_id) + if wrapped_socket is None: + return SecurityConst.errSSLInternal + base_socket = wrapped_socket.socket + + bytes_to_write = data_length_pointer[0] + data = ctypes.string_at(data_buffer, bytes_to_write) + + timeout = wrapped_socket.gettimeout() + error = None + sent = 0 + + try: + while sent < bytes_to_write: + if timeout is None or timeout >= 0: + if not util.wait_for_write(base_socket, timeout): + raise socket.error(errno.EAGAIN, "timed out") + chunk_sent = base_socket.send(data) + sent += chunk_sent + + # This has some needless copying here, but I'm not sure there's + # much value in optimising this data path. + data = data[chunk_sent:] + except (socket.error) as e: + error = e.errno + + if error is not None and error != errno.EAGAIN: + data_length_pointer[0] = sent + if error == errno.ECONNRESET or error == errno.EPIPE: + return SecurityConst.errSSLClosedAbort + raise + + data_length_pointer[0] = sent + + if sent != bytes_to_write: + return SecurityConst.errSSLWouldBlock + + return 0 + except Exception as e: + if wrapped_socket is not None: + wrapped_socket._exception = e + return SecurityConst.errSSLInternal + + +# We need to keep these two objects references alive: if they get GC'd while +# in use then SecureTransport could attempt to call a function that is in freed +# memory. That would be...uh...bad. Yeah, that's the word. Bad. +_read_callback_pointer = Security.SSLReadFunc(_read_callback) +_write_callback_pointer = Security.SSLWriteFunc(_write_callback) + + +class WrappedSocket(object): + """ + API-compatibility wrapper for Python's OpenSSL wrapped socket object. + + Note: _makefile_refs, _drop(), and _reuse() are needed for the garbage + collector of PyPy. + """ + + def __init__(self, socket): + self.socket = socket + self.context = None + self._makefile_refs = 0 + self._closed = False + self._exception = None + self._keychain = None + self._keychain_dir = None + self._client_cert_chain = None + + # We save off the previously-configured timeout and then set it to + # zero. This is done because we use select and friends to handle the + # timeouts, but if we leave the timeout set on the lower socket then + # Python will "kindly" call select on that socket again for us. Avoid + # that by forcing the timeout to zero. + self._timeout = self.socket.gettimeout() + self.socket.settimeout(0) + + @contextlib.contextmanager + def _raise_on_error(self): + """ + A context manager that can be used to wrap calls that do I/O from + SecureTransport. If any of the I/O callbacks hit an exception, this + context manager will correctly propagate the exception after the fact. + This avoids silently swallowing those exceptions. + + It also correctly forces the socket closed. + """ + self._exception = None + + # We explicitly don't catch around this yield because in the unlikely + # event that an exception was hit in the block we don't want to swallow + # it. + yield + if self._exception is not None: + exception, self._exception = self._exception, None + self.close() + raise exception + + def _set_ciphers(self): + """ + Sets up the allowed ciphers. By default this matches the set in + util.ssl_.DEFAULT_CIPHERS, at least as supported by macOS. This is done + custom and doesn't allow changing at this time, mostly because parsing + OpenSSL cipher strings is going to be a freaking nightmare. + """ + ciphers = (Security.SSLCipherSuite * len(CIPHER_SUITES))(*CIPHER_SUITES) + result = Security.SSLSetEnabledCiphers( + self.context, ciphers, len(CIPHER_SUITES) + ) + _assert_no_error(result) + + def _set_alpn_protocols(self, protocols): + """ + Sets up the ALPN protocols on the context. + """ + if not protocols: + return + protocols_arr = _create_cfstring_array(protocols) + try: + result = Security.SSLSetALPNProtocols(self.context, protocols_arr) + _assert_no_error(result) + finally: + CoreFoundation.CFRelease(protocols_arr) + + def _custom_validate(self, verify, trust_bundle): + """ + Called when we have set custom validation. We do this in two cases: + first, when cert validation is entirely disabled; and second, when + using a custom trust DB. + Raises an SSLError if the connection is not trusted. + """ + # If we disabled cert validation, just say: cool. + if not verify: + return + + successes = ( + SecurityConst.kSecTrustResultUnspecified, + SecurityConst.kSecTrustResultProceed, + ) + try: + trust_result = self._evaluate_trust(trust_bundle) + if trust_result in successes: + return + reason = "error code: %d" % (trust_result,) + except Exception as e: + # Do not trust on error + reason = "exception: %r" % (e,) + + # SecureTransport does not send an alert nor shuts down the connection. + rec = _build_tls_unknown_ca_alert(self.version()) + self.socket.sendall(rec) + # close the connection immediately + # l_onoff = 1, activate linger + # l_linger = 0, linger for 0 seoncds + opts = struct.pack("ii", 1, 0) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, opts) + self.close() + raise ssl.SSLError("certificate verify failed, %s" % reason) + + def _evaluate_trust(self, trust_bundle): + # We want data in memory, so load it up. + if os.path.isfile(trust_bundle): + with open(trust_bundle, "rb") as f: + trust_bundle = f.read() + + cert_array = None + trust = Security.SecTrustRef() + + try: + # Get a CFArray that contains the certs we want. + cert_array = _cert_array_from_pem(trust_bundle) + + # Ok, now the hard part. We want to get the SecTrustRef that ST has + # created for this connection, shove our CAs into it, tell ST to + # ignore everything else it knows, and then ask if it can build a + # chain. This is a buuuunch of code. + result = Security.SSLCopyPeerTrust(self.context, ctypes.byref(trust)) + _assert_no_error(result) + if not trust: + raise ssl.SSLError("Failed to copy trust reference") + + result = Security.SecTrustSetAnchorCertificates(trust, cert_array) + _assert_no_error(result) + + result = Security.SecTrustSetAnchorCertificatesOnly(trust, True) + _assert_no_error(result) + + trust_result = Security.SecTrustResultType() + result = Security.SecTrustEvaluate(trust, ctypes.byref(trust_result)) + _assert_no_error(result) + finally: + if trust: + CoreFoundation.CFRelease(trust) + + if cert_array is not None: + CoreFoundation.CFRelease(cert_array) + + return trust_result.value + + def handshake( + self, + server_hostname, + verify, + trust_bundle, + min_version, + max_version, + client_cert, + client_key, + client_key_passphrase, + alpn_protocols, + ): + """ + Actually performs the TLS handshake. This is run automatically by + wrapped socket, and shouldn't be needed in user code. + """ + # First, we do the initial bits of connection setup. We need to create + # a context, set its I/O funcs, and set the connection reference. + self.context = Security.SSLCreateContext( + None, SecurityConst.kSSLClientSide, SecurityConst.kSSLStreamType + ) + result = Security.SSLSetIOFuncs( + self.context, _read_callback_pointer, _write_callback_pointer + ) + _assert_no_error(result) + + # Here we need to compute the handle to use. We do this by taking the + # id of self modulo 2**31 - 1. If this is already in the dictionary, we + # just keep incrementing by one until we find a free space. + with _connection_ref_lock: + handle = id(self) % 2147483647 + while handle in _connection_refs: + handle = (handle + 1) % 2147483647 + _connection_refs[handle] = self + + result = Security.SSLSetConnection(self.context, handle) + _assert_no_error(result) + + # If we have a server hostname, we should set that too. + if server_hostname: + if not isinstance(server_hostname, bytes): + server_hostname = server_hostname.encode("utf-8") + + result = Security.SSLSetPeerDomainName( + self.context, server_hostname, len(server_hostname) + ) + _assert_no_error(result) + + # Setup the ciphers. + self._set_ciphers() + + # Setup the ALPN protocols. + self._set_alpn_protocols(alpn_protocols) + + # Set the minimum and maximum TLS versions. + result = Security.SSLSetProtocolVersionMin(self.context, min_version) + _assert_no_error(result) + + result = Security.SSLSetProtocolVersionMax(self.context, max_version) + _assert_no_error(result) + + # If there's a trust DB, we need to use it. We do that by telling + # SecureTransport to break on server auth. We also do that if we don't + # want to validate the certs at all: we just won't actually do any + # authing in that case. + if not verify or trust_bundle is not None: + result = Security.SSLSetSessionOption( + self.context, SecurityConst.kSSLSessionOptionBreakOnServerAuth, True + ) + _assert_no_error(result) + + # If there's a client cert, we need to use it. + if client_cert: + self._keychain, self._keychain_dir = _temporary_keychain() + self._client_cert_chain = _load_client_cert_chain( + self._keychain, client_cert, client_key + ) + result = Security.SSLSetCertificate(self.context, self._client_cert_chain) + _assert_no_error(result) + + while True: + with self._raise_on_error(): + result = Security.SSLHandshake(self.context) + + if result == SecurityConst.errSSLWouldBlock: + raise socket.timeout("handshake timed out") + elif result == SecurityConst.errSSLServerAuthCompleted: + self._custom_validate(verify, trust_bundle) + continue + else: + _assert_no_error(result) + break + + def fileno(self): + return self.socket.fileno() + + # Copy-pasted from Python 3.5 source code + def _decref_socketios(self): + if self._makefile_refs > 0: + self._makefile_refs -= 1 + if self._closed: + self.close() + + def recv(self, bufsiz): + buffer = ctypes.create_string_buffer(bufsiz) + bytes_read = self.recv_into(buffer, bufsiz) + data = buffer[:bytes_read] + return data + + def recv_into(self, buffer, nbytes=None): + # Read short on EOF. + if self._closed: + return 0 + + if nbytes is None: + nbytes = len(buffer) + + buffer = (ctypes.c_char * nbytes).from_buffer(buffer) + processed_bytes = ctypes.c_size_t(0) + + with self._raise_on_error(): + result = Security.SSLRead( + self.context, buffer, nbytes, ctypes.byref(processed_bytes) + ) + + # There are some result codes that we want to treat as "not always + # errors". Specifically, those are errSSLWouldBlock, + # errSSLClosedGraceful, and errSSLClosedNoNotify. + if result == SecurityConst.errSSLWouldBlock: + # If we didn't process any bytes, then this was just a time out. + # However, we can get errSSLWouldBlock in situations when we *did* + # read some data, and in those cases we should just read "short" + # and return. + if processed_bytes.value == 0: + # Timed out, no data read. + raise socket.timeout("recv timed out") + elif result in ( + SecurityConst.errSSLClosedGraceful, + SecurityConst.errSSLClosedNoNotify, + ): + # The remote peer has closed this connection. We should do so as + # well. Note that we don't actually return here because in + # principle this could actually be fired along with return data. + # It's unlikely though. + self.close() + else: + _assert_no_error(result) + + # Ok, we read and probably succeeded. We should return whatever data + # was actually read. + return processed_bytes.value + + def settimeout(self, timeout): + self._timeout = timeout + + def gettimeout(self): + return self._timeout + + def send(self, data): + processed_bytes = ctypes.c_size_t(0) + + with self._raise_on_error(): + result = Security.SSLWrite( + self.context, data, len(data), ctypes.byref(processed_bytes) + ) + + if result == SecurityConst.errSSLWouldBlock and processed_bytes.value == 0: + # Timed out + raise socket.timeout("send timed out") + else: + _assert_no_error(result) + + # We sent, and probably succeeded. Tell them how much we sent. + return processed_bytes.value + + def sendall(self, data): + total_sent = 0 + while total_sent < len(data): + sent = self.send(data[total_sent : total_sent + SSL_WRITE_BLOCKSIZE]) + total_sent += sent + + def shutdown(self): + with self._raise_on_error(): + Security.SSLClose(self.context) + + def close(self): + # TODO: should I do clean shutdown here? Do I have to? + if self._makefile_refs < 1: + self._closed = True + if self.context: + CoreFoundation.CFRelease(self.context) + self.context = None + if self._client_cert_chain: + CoreFoundation.CFRelease(self._client_cert_chain) + self._client_cert_chain = None + if self._keychain: + Security.SecKeychainDelete(self._keychain) + CoreFoundation.CFRelease(self._keychain) + shutil.rmtree(self._keychain_dir) + self._keychain = self._keychain_dir = None + return self.socket.close() + else: + self._makefile_refs -= 1 + + def getpeercert(self, binary_form=False): + # Urgh, annoying. + # + # Here's how we do this: + # + # 1. Call SSLCopyPeerTrust to get hold of the trust object for this + # connection. + # 2. Call SecTrustGetCertificateAtIndex for index 0 to get the leaf. + # 3. To get the CN, call SecCertificateCopyCommonName and process that + # string so that it's of the appropriate type. + # 4. To get the SAN, we need to do something a bit more complex: + # a. Call SecCertificateCopyValues to get the data, requesting + # kSecOIDSubjectAltName. + # b. Mess about with this dictionary to try to get the SANs out. + # + # This is gross. Really gross. It's going to be a few hundred LoC extra + # just to repeat something that SecureTransport can *already do*. So my + # operating assumption at this time is that what we want to do is + # instead to just flag to urllib3 that it shouldn't do its own hostname + # validation when using SecureTransport. + if not binary_form: + raise ValueError("SecureTransport only supports dumping binary certs") + trust = Security.SecTrustRef() + certdata = None + der_bytes = None + + try: + # Grab the trust store. + result = Security.SSLCopyPeerTrust(self.context, ctypes.byref(trust)) + _assert_no_error(result) + if not trust: + # Probably we haven't done the handshake yet. No biggie. + return None + + cert_count = Security.SecTrustGetCertificateCount(trust) + if not cert_count: + # Also a case that might happen if we haven't handshaked. + # Handshook? Handshaken? + return None + + leaf = Security.SecTrustGetCertificateAtIndex(trust, 0) + assert leaf + + # Ok, now we want the DER bytes. + certdata = Security.SecCertificateCopyData(leaf) + assert certdata + + data_length = CoreFoundation.CFDataGetLength(certdata) + data_buffer = CoreFoundation.CFDataGetBytePtr(certdata) + der_bytes = ctypes.string_at(data_buffer, data_length) + finally: + if certdata: + CoreFoundation.CFRelease(certdata) + if trust: + CoreFoundation.CFRelease(trust) + + return der_bytes + + def version(self): + protocol = Security.SSLProtocol() + result = Security.SSLGetNegotiatedProtocolVersion( + self.context, ctypes.byref(protocol) + ) + _assert_no_error(result) + if protocol.value == SecurityConst.kTLSProtocol13: + raise ssl.SSLError("SecureTransport does not support TLS 1.3") + elif protocol.value == SecurityConst.kTLSProtocol12: + return "TLSv1.2" + elif protocol.value == SecurityConst.kTLSProtocol11: + return "TLSv1.1" + elif protocol.value == SecurityConst.kTLSProtocol1: + return "TLSv1" + elif protocol.value == SecurityConst.kSSLProtocol3: + return "SSLv3" + elif protocol.value == SecurityConst.kSSLProtocol2: + return "SSLv2" + else: + raise ssl.SSLError("Unknown TLS version: %r" % protocol) + + def _reuse(self): + self._makefile_refs += 1 + + def _drop(self): + if self._makefile_refs < 1: + self.close() + else: + self._makefile_refs -= 1 + + +if _fileobject: # Platform-specific: Python 2 + + def makefile(self, mode, bufsize=-1): + self._makefile_refs += 1 + return _fileobject(self, mode, bufsize, close=True) + + +else: # Platform-specific: Python 3 + + def makefile(self, mode="r", buffering=None, *args, **kwargs): + # We disable buffering with SecureTransport because it conflicts with + # the buffering that ST does internally (see issue #1153 for more). + buffering = 0 + return backport_makefile(self, mode, buffering, *args, **kwargs) + + +WrappedSocket.makefile = makefile + + +class SecureTransportContext(object): + """ + I am a wrapper class for the SecureTransport library, to translate the + interface of the standard library ``SSLContext`` object to calls into + SecureTransport. + """ + + def __init__(self, protocol): + self._min_version, self._max_version = _protocol_to_min_max[protocol] + self._options = 0 + self._verify = False + self._trust_bundle = None + self._client_cert = None + self._client_key = None + self._client_key_passphrase = None + self._alpn_protocols = None + + @property + def check_hostname(self): + """ + SecureTransport cannot have its hostname checking disabled. For more, + see the comment on getpeercert() in this file. + """ + return True + + @check_hostname.setter + def check_hostname(self, value): + """ + SecureTransport cannot have its hostname checking disabled. For more, + see the comment on getpeercert() in this file. + """ + pass + + @property + def options(self): + # TODO: Well, crap. + # + # So this is the bit of the code that is the most likely to cause us + # trouble. Essentially we need to enumerate all of the SSL options that + # users might want to use and try to see if we can sensibly translate + # them, or whether we should just ignore them. + return self._options + + @options.setter + def options(self, value): + # TODO: Update in line with above. + self._options = value + + @property + def verify_mode(self): + return ssl.CERT_REQUIRED if self._verify else ssl.CERT_NONE + + @verify_mode.setter + def verify_mode(self, value): + self._verify = True if value == ssl.CERT_REQUIRED else False + + def set_default_verify_paths(self): + # So, this has to do something a bit weird. Specifically, what it does + # is nothing. + # + # This means that, if we had previously had load_verify_locations + # called, this does not undo that. We need to do that because it turns + # out that the rest of the urllib3 code will attempt to load the + # default verify paths if it hasn't been told about any paths, even if + # the context itself was sometime earlier. We resolve that by just + # ignoring it. + pass + + def load_default_certs(self): + return self.set_default_verify_paths() + + def set_ciphers(self, ciphers): + # For now, we just require the default cipher string. + if ciphers != util.ssl_.DEFAULT_CIPHERS: + raise ValueError("SecureTransport doesn't support custom cipher strings") + + def load_verify_locations(self, cafile=None, capath=None, cadata=None): + # OK, we only really support cadata and cafile. + if capath is not None: + raise ValueError("SecureTransport does not support cert directories") + + # Raise if cafile does not exist. + if cafile is not None: + with open(cafile): + pass + + self._trust_bundle = cafile or cadata + + def load_cert_chain(self, certfile, keyfile=None, password=None): + self._client_cert = certfile + self._client_key = keyfile + self._client_cert_passphrase = password + + def set_alpn_protocols(self, protocols): + """ + Sets the ALPN protocols that will later be set on the context. + + Raises a NotImplementedError if ALPN is not supported. + """ + if not hasattr(Security, "SSLSetALPNProtocols"): + raise NotImplementedError( + "SecureTransport supports ALPN only in macOS 10.12+" + ) + self._alpn_protocols = [six.ensure_binary(p) for p in protocols] + + def wrap_socket( + self, + sock, + server_side=False, + do_handshake_on_connect=True, + suppress_ragged_eofs=True, + server_hostname=None, + ): + # So, what do we do here? Firstly, we assert some properties. This is a + # stripped down shim, so there is some functionality we don't support. + # See PEP 543 for the real deal. + assert not server_side + assert do_handshake_on_connect + assert suppress_ragged_eofs + + # Ok, we're good to go. Now we want to create the wrapped socket object + # and store it in the appropriate place. + wrapped_socket = WrappedSocket(sock) + + # Now we can handshake + wrapped_socket.handshake( + server_hostname, + self._verify, + self._trust_bundle, + self._min_version, + self._max_version, + self._client_cert, + self._client_key, + self._client_key_passphrase, + self._alpn_protocols, + ) + return wrapped_socket diff --git a/openpype/hosts/fusion/vendor/urllib3/contrib/socks.py b/openpype/hosts/fusion/vendor/urllib3/contrib/socks.py new file mode 100644 index 0000000000..c326e80dd1 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/contrib/socks.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +""" +This module contains provisional support for SOCKS proxies from within +urllib3. This module supports SOCKS4, SOCKS4A (an extension of SOCKS4), and +SOCKS5. To enable its functionality, either install PySocks or install this +module with the ``socks`` extra. + +The SOCKS implementation supports the full range of urllib3 features. It also +supports the following SOCKS features: + +- SOCKS4A (``proxy_url='socks4a://...``) +- SOCKS4 (``proxy_url='socks4://...``) +- SOCKS5 with remote DNS (``proxy_url='socks5h://...``) +- SOCKS5 with local DNS (``proxy_url='socks5://...``) +- Usernames and passwords for the SOCKS proxy + +.. note:: + It is recommended to use ``socks5h://`` or ``socks4a://`` schemes in + your ``proxy_url`` to ensure that DNS resolution is done from the remote + server instead of client-side when connecting to a domain name. + +SOCKS4 supports IPv4 and domain names with the SOCKS4A extension. SOCKS5 +supports IPv4, IPv6, and domain names. + +When connecting to a SOCKS4 proxy the ``username`` portion of the ``proxy_url`` +will be sent as the ``userid`` section of the SOCKS request: + +.. code-block:: python + + proxy_url="socks4a://@proxy-host" + +When connecting to a SOCKS5 proxy the ``username`` and ``password`` portion +of the ``proxy_url`` will be sent as the username/password to authenticate +with the proxy: + +.. code-block:: python + + proxy_url="socks5h://:@proxy-host" + +""" +from __future__ import absolute_import + +try: + import socks +except ImportError: + import warnings + + from ..exceptions import DependencyWarning + + warnings.warn( + ( + "SOCKS support in urllib3 requires the installation of optional " + "dependencies: specifically, PySocks. For more information, see " + "https://urllib3.readthedocs.io/en/1.26.x/contrib.html#socks-proxies" + ), + DependencyWarning, + ) + raise + +from socket import error as SocketError +from socket import timeout as SocketTimeout + +from ..connection import HTTPConnection, HTTPSConnection +from ..connectionpool import HTTPConnectionPool, HTTPSConnectionPool +from ..exceptions import ConnectTimeoutError, NewConnectionError +from ..poolmanager import PoolManager +from ..util.url import parse_url + +try: + import ssl +except ImportError: + ssl = None + + +class SOCKSConnection(HTTPConnection): + """ + A plain-text HTTP connection that connects via a SOCKS proxy. + """ + + def __init__(self, *args, **kwargs): + self._socks_options = kwargs.pop("_socks_options") + super(SOCKSConnection, self).__init__(*args, **kwargs) + + def _new_conn(self): + """ + Establish a new connection via the SOCKS proxy. + """ + extra_kw = {} + if self.source_address: + extra_kw["source_address"] = self.source_address + + if self.socket_options: + extra_kw["socket_options"] = self.socket_options + + try: + conn = socks.create_connection( + (self.host, self.port), + proxy_type=self._socks_options["socks_version"], + proxy_addr=self._socks_options["proxy_host"], + proxy_port=self._socks_options["proxy_port"], + proxy_username=self._socks_options["username"], + proxy_password=self._socks_options["password"], + proxy_rdns=self._socks_options["rdns"], + timeout=self.timeout, + **extra_kw + ) + + except SocketTimeout: + raise ConnectTimeoutError( + self, + "Connection to %s timed out. (connect timeout=%s)" + % (self.host, self.timeout), + ) + + except socks.ProxyError as e: + # This is fragile as hell, but it seems to be the only way to raise + # useful errors here. + if e.socket_err: + error = e.socket_err + if isinstance(error, SocketTimeout): + raise ConnectTimeoutError( + self, + "Connection to %s timed out. (connect timeout=%s)" + % (self.host, self.timeout), + ) + else: + raise NewConnectionError( + self, "Failed to establish a new connection: %s" % error + ) + else: + raise NewConnectionError( + self, "Failed to establish a new connection: %s" % e + ) + + except SocketError as e: # Defensive: PySocks should catch all these. + raise NewConnectionError( + self, "Failed to establish a new connection: %s" % e + ) + + return conn + + +# We don't need to duplicate the Verified/Unverified distinction from +# urllib3/connection.py here because the HTTPSConnection will already have been +# correctly set to either the Verified or Unverified form by that module. This +# means the SOCKSHTTPSConnection will automatically be the correct type. +class SOCKSHTTPSConnection(SOCKSConnection, HTTPSConnection): + pass + + +class SOCKSHTTPConnectionPool(HTTPConnectionPool): + ConnectionCls = SOCKSConnection + + +class SOCKSHTTPSConnectionPool(HTTPSConnectionPool): + ConnectionCls = SOCKSHTTPSConnection + + +class SOCKSProxyManager(PoolManager): + """ + A version of the urllib3 ProxyManager that routes connections via the + defined SOCKS proxy. + """ + + pool_classes_by_scheme = { + "http": SOCKSHTTPConnectionPool, + "https": SOCKSHTTPSConnectionPool, + } + + def __init__( + self, + proxy_url, + username=None, + password=None, + num_pools=10, + headers=None, + **connection_pool_kw + ): + parsed = parse_url(proxy_url) + + if username is None and password is None and parsed.auth is not None: + split = parsed.auth.split(":") + if len(split) == 2: + username, password = split + if parsed.scheme == "socks5": + socks_version = socks.PROXY_TYPE_SOCKS5 + rdns = False + elif parsed.scheme == "socks5h": + socks_version = socks.PROXY_TYPE_SOCKS5 + rdns = True + elif parsed.scheme == "socks4": + socks_version = socks.PROXY_TYPE_SOCKS4 + rdns = False + elif parsed.scheme == "socks4a": + socks_version = socks.PROXY_TYPE_SOCKS4 + rdns = True + else: + raise ValueError("Unable to determine SOCKS version from %s" % proxy_url) + + self.proxy_url = proxy_url + + socks_options = { + "socks_version": socks_version, + "proxy_host": parsed.host, + "proxy_port": parsed.port, + "username": username, + "password": password, + "rdns": rdns, + } + connection_pool_kw["_socks_options"] = socks_options + + super(SOCKSProxyManager, self).__init__( + num_pools, headers, **connection_pool_kw + ) + + self.pool_classes_by_scheme = SOCKSProxyManager.pool_classes_by_scheme diff --git a/openpype/hosts/fusion/vendor/urllib3/exceptions.py b/openpype/hosts/fusion/vendor/urllib3/exceptions.py new file mode 100644 index 0000000000..cba6f3f560 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/exceptions.py @@ -0,0 +1,323 @@ +from __future__ import absolute_import + +from .packages.six.moves.http_client import IncompleteRead as httplib_IncompleteRead + +# Base Exceptions + + +class HTTPError(Exception): + """Base exception used by this module.""" + + pass + + +class HTTPWarning(Warning): + """Base warning used by this module.""" + + pass + + +class PoolError(HTTPError): + """Base exception for errors caused within a pool.""" + + def __init__(self, pool, message): + self.pool = pool + HTTPError.__init__(self, "%s: %s" % (pool, message)) + + def __reduce__(self): + # For pickling purposes. + return self.__class__, (None, None) + + +class RequestError(PoolError): + """Base exception for PoolErrors that have associated URLs.""" + + def __init__(self, pool, url, message): + self.url = url + PoolError.__init__(self, pool, message) + + def __reduce__(self): + # For pickling purposes. + return self.__class__, (None, self.url, None) + + +class SSLError(HTTPError): + """Raised when SSL certificate fails in an HTTPS connection.""" + + pass + + +class ProxyError(HTTPError): + """Raised when the connection to a proxy fails.""" + + def __init__(self, message, error, *args): + super(ProxyError, self).__init__(message, error, *args) + self.original_error = error + + +class DecodeError(HTTPError): + """Raised when automatic decoding based on Content-Type fails.""" + + pass + + +class ProtocolError(HTTPError): + """Raised when something unexpected happens mid-request/response.""" + + pass + + +#: Renamed to ProtocolError but aliased for backwards compatibility. +ConnectionError = ProtocolError + + +# Leaf Exceptions + + +class MaxRetryError(RequestError): + """Raised when the maximum number of retries is exceeded. + + :param pool: The connection pool + :type pool: :class:`~urllib3.connectionpool.HTTPConnectionPool` + :param string url: The requested Url + :param exceptions.Exception reason: The underlying error + + """ + + def __init__(self, pool, url, reason=None): + self.reason = reason + + message = "Max retries exceeded with url: %s (Caused by %r)" % (url, reason) + + RequestError.__init__(self, pool, url, message) + + +class HostChangedError(RequestError): + """Raised when an existing pool gets a request for a foreign host.""" + + def __init__(self, pool, url, retries=3): + message = "Tried to open a foreign host with url: %s" % url + RequestError.__init__(self, pool, url, message) + self.retries = retries + + +class TimeoutStateError(HTTPError): + """Raised when passing an invalid state to a timeout""" + + pass + + +class TimeoutError(HTTPError): + """Raised when a socket timeout error occurs. + + Catching this error will catch both :exc:`ReadTimeoutErrors + ` and :exc:`ConnectTimeoutErrors `. + """ + + pass + + +class ReadTimeoutError(TimeoutError, RequestError): + """Raised when a socket timeout occurs while receiving data from a server""" + + pass + + +# This timeout error does not have a URL attached and needs to inherit from the +# base HTTPError +class ConnectTimeoutError(TimeoutError): + """Raised when a socket timeout occurs while connecting to a server""" + + pass + + +class NewConnectionError(ConnectTimeoutError, PoolError): + """Raised when we fail to establish a new connection. Usually ECONNREFUSED.""" + + pass + + +class EmptyPoolError(PoolError): + """Raised when a pool runs out of connections and no more are allowed.""" + + pass + + +class ClosedPoolError(PoolError): + """Raised when a request enters a pool after the pool has been closed.""" + + pass + + +class LocationValueError(ValueError, HTTPError): + """Raised when there is something wrong with a given URL input.""" + + pass + + +class LocationParseError(LocationValueError): + """Raised when get_host or similar fails to parse the URL input.""" + + def __init__(self, location): + message = "Failed to parse: %s" % location + HTTPError.__init__(self, message) + + self.location = location + + +class URLSchemeUnknown(LocationValueError): + """Raised when a URL input has an unsupported scheme.""" + + def __init__(self, scheme): + message = "Not supported URL scheme %s" % scheme + super(URLSchemeUnknown, self).__init__(message) + + self.scheme = scheme + + +class ResponseError(HTTPError): + """Used as a container for an error reason supplied in a MaxRetryError.""" + + GENERIC_ERROR = "too many error responses" + SPECIFIC_ERROR = "too many {status_code} error responses" + + +class SecurityWarning(HTTPWarning): + """Warned when performing security reducing actions""" + + pass + + +class SubjectAltNameWarning(SecurityWarning): + """Warned when connecting to a host with a certificate missing a SAN.""" + + pass + + +class InsecureRequestWarning(SecurityWarning): + """Warned when making an unverified HTTPS request.""" + + pass + + +class SystemTimeWarning(SecurityWarning): + """Warned when system time is suspected to be wrong""" + + pass + + +class InsecurePlatformWarning(SecurityWarning): + """Warned when certain TLS/SSL configuration is not available on a platform.""" + + pass + + +class SNIMissingWarning(HTTPWarning): + """Warned when making a HTTPS request without SNI available.""" + + pass + + +class DependencyWarning(HTTPWarning): + """ + Warned when an attempt is made to import a module with missing optional + dependencies. + """ + + pass + + +class ResponseNotChunked(ProtocolError, ValueError): + """Response needs to be chunked in order to read it as chunks.""" + + pass + + +class BodyNotHttplibCompatible(HTTPError): + """ + Body should be :class:`http.client.HTTPResponse` like + (have an fp attribute which returns raw chunks) for read_chunked(). + """ + + pass + + +class IncompleteRead(HTTPError, httplib_IncompleteRead): + """ + Response length doesn't match expected Content-Length + + Subclass of :class:`http.client.IncompleteRead` to allow int value + for ``partial`` to avoid creating large objects on streamed reads. + """ + + def __init__(self, partial, expected): + super(IncompleteRead, self).__init__(partial, expected) + + def __repr__(self): + return "IncompleteRead(%i bytes read, %i more expected)" % ( + self.partial, + self.expected, + ) + + +class InvalidChunkLength(HTTPError, httplib_IncompleteRead): + """Invalid chunk length in a chunked response.""" + + def __init__(self, response, length): + super(InvalidChunkLength, self).__init__( + response.tell(), response.length_remaining + ) + self.response = response + self.length = length + + def __repr__(self): + return "InvalidChunkLength(got length %r, %i bytes read)" % ( + self.length, + self.partial, + ) + + +class InvalidHeader(HTTPError): + """The header provided was somehow invalid.""" + + pass + + +class ProxySchemeUnknown(AssertionError, URLSchemeUnknown): + """ProxyManager does not support the supplied scheme""" + + # TODO(t-8ch): Stop inheriting from AssertionError in v2.0. + + def __init__(self, scheme): + # 'localhost' is here because our URL parser parses + # localhost:8080 -> scheme=localhost, remove if we fix this. + if scheme == "localhost": + scheme = None + if scheme is None: + message = "Proxy URL had no scheme, should start with http:// or https://" + else: + message = ( + "Proxy URL had unsupported scheme %s, should use http:// or https://" + % scheme + ) + super(ProxySchemeUnknown, self).__init__(message) + + +class ProxySchemeUnsupported(ValueError): + """Fetching HTTPS resources through HTTPS proxies is unsupported""" + + pass + + +class HeaderParsingError(HTTPError): + """Raised by assert_header_parsing, but we convert it to a log.warning statement.""" + + def __init__(self, defects, unparsed_data): + message = "%s, unparsed data: %r" % (defects or "Unknown", unparsed_data) + super(HeaderParsingError, self).__init__(message) + + +class UnrewindableBodyError(HTTPError): + """urllib3 encountered an error when trying to rewind a body""" + + pass diff --git a/openpype/hosts/fusion/vendor/urllib3/fields.py b/openpype/hosts/fusion/vendor/urllib3/fields.py new file mode 100644 index 0000000000..9d630f491d --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/fields.py @@ -0,0 +1,274 @@ +from __future__ import absolute_import + +import email.utils +import mimetypes +import re + +from .packages import six + + +def guess_content_type(filename, default="application/octet-stream"): + """ + Guess the "Content-Type" of a file. + + :param filename: + The filename to guess the "Content-Type" of using :mod:`mimetypes`. + :param default: + If no "Content-Type" can be guessed, default to `default`. + """ + if filename: + return mimetypes.guess_type(filename)[0] or default + return default + + +def format_header_param_rfc2231(name, value): + """ + Helper function to format and quote a single header parameter using the + strategy defined in RFC 2231. + + Particularly useful for header parameters which might contain + non-ASCII values, like file names. This follows + `RFC 2388 Section 4.4 `_. + + :param name: + The name of the parameter, a string expected to be ASCII only. + :param value: + The value of the parameter, provided as ``bytes`` or `str``. + :ret: + An RFC-2231-formatted unicode string. + """ + if isinstance(value, six.binary_type): + value = value.decode("utf-8") + + if not any(ch in value for ch in '"\\\r\n'): + result = u'%s="%s"' % (name, value) + try: + result.encode("ascii") + except (UnicodeEncodeError, UnicodeDecodeError): + pass + else: + return result + + if six.PY2: # Python 2: + value = value.encode("utf-8") + + # encode_rfc2231 accepts an encoded string and returns an ascii-encoded + # string in Python 2 but accepts and returns unicode strings in Python 3 + value = email.utils.encode_rfc2231(value, "utf-8") + value = "%s*=%s" % (name, value) + + if six.PY2: # Python 2: + value = value.decode("utf-8") + + return value + + +_HTML5_REPLACEMENTS = { + u"\u0022": u"%22", + # Replace "\" with "\\". + u"\u005C": u"\u005C\u005C", +} + +# All control characters from 0x00 to 0x1F *except* 0x1B. +_HTML5_REPLACEMENTS.update( + { + six.unichr(cc): u"%{:02X}".format(cc) + for cc in range(0x00, 0x1F + 1) + if cc not in (0x1B,) + } +) + + +def _replace_multiple(value, needles_and_replacements): + def replacer(match): + return needles_and_replacements[match.group(0)] + + pattern = re.compile( + r"|".join([re.escape(needle) for needle in needles_and_replacements.keys()]) + ) + + result = pattern.sub(replacer, value) + + return result + + +def format_header_param_html5(name, value): + """ + Helper function to format and quote a single header parameter using the + HTML5 strategy. + + Particularly useful for header parameters which might contain + non-ASCII values, like file names. This follows the `HTML5 Working Draft + Section 4.10.22.7`_ and matches the behavior of curl and modern browsers. + + .. _HTML5 Working Draft Section 4.10.22.7: + https://w3c.github.io/html/sec-forms.html#multipart-form-data + + :param name: + The name of the parameter, a string expected to be ASCII only. + :param value: + The value of the parameter, provided as ``bytes`` or `str``. + :ret: + A unicode string, stripped of troublesome characters. + """ + if isinstance(value, six.binary_type): + value = value.decode("utf-8") + + value = _replace_multiple(value, _HTML5_REPLACEMENTS) + + return u'%s="%s"' % (name, value) + + +# For backwards-compatibility. +format_header_param = format_header_param_html5 + + +class RequestField(object): + """ + A data container for request body parameters. + + :param name: + The name of this request field. Must be unicode. + :param data: + The data/value body. + :param filename: + An optional filename of the request field. Must be unicode. + :param headers: + An optional dict-like object of headers to initially use for the field. + :param header_formatter: + An optional callable that is used to encode and format the headers. By + default, this is :func:`format_header_param_html5`. + """ + + def __init__( + self, + name, + data, + filename=None, + headers=None, + header_formatter=format_header_param_html5, + ): + self._name = name + self._filename = filename + self.data = data + self.headers = {} + if headers: + self.headers = dict(headers) + self.header_formatter = header_formatter + + @classmethod + def from_tuples(cls, fieldname, value, header_formatter=format_header_param_html5): + """ + A :class:`~urllib3.fields.RequestField` factory from old-style tuple parameters. + + Supports constructing :class:`~urllib3.fields.RequestField` from + parameter of key/value strings AND key/filetuple. A filetuple is a + (filename, data, MIME type) tuple where the MIME type is optional. + For example:: + + 'foo': 'bar', + 'fakefile': ('foofile.txt', 'contents of foofile'), + 'realfile': ('barfile.txt', open('realfile').read()), + 'typedfile': ('bazfile.bin', open('bazfile').read(), 'image/jpeg'), + 'nonamefile': 'contents of nonamefile field', + + Field names and filenames must be unicode. + """ + if isinstance(value, tuple): + if len(value) == 3: + filename, data, content_type = value + else: + filename, data = value + content_type = guess_content_type(filename) + else: + filename = None + content_type = None + data = value + + request_param = cls( + fieldname, data, filename=filename, header_formatter=header_formatter + ) + request_param.make_multipart(content_type=content_type) + + return request_param + + def _render_part(self, name, value): + """ + Overridable helper function to format a single header parameter. By + default, this calls ``self.header_formatter``. + + :param name: + The name of the parameter, a string expected to be ASCII only. + :param value: + The value of the parameter, provided as a unicode string. + """ + + return self.header_formatter(name, value) + + def _render_parts(self, header_parts): + """ + Helper function to format and quote a single header. + + Useful for single headers that are composed of multiple items. E.g., + 'Content-Disposition' fields. + + :param header_parts: + A sequence of (k, v) tuples or a :class:`dict` of (k, v) to format + as `k1="v1"; k2="v2"; ...`. + """ + parts = [] + iterable = header_parts + if isinstance(header_parts, dict): + iterable = header_parts.items() + + for name, value in iterable: + if value is not None: + parts.append(self._render_part(name, value)) + + return u"; ".join(parts) + + def render_headers(self): + """ + Renders the headers for this request field. + """ + lines = [] + + sort_keys = ["Content-Disposition", "Content-Type", "Content-Location"] + for sort_key in sort_keys: + if self.headers.get(sort_key, False): + lines.append(u"%s: %s" % (sort_key, self.headers[sort_key])) + + for header_name, header_value in self.headers.items(): + if header_name not in sort_keys: + if header_value: + lines.append(u"%s: %s" % (header_name, header_value)) + + lines.append(u"\r\n") + return u"\r\n".join(lines) + + def make_multipart( + self, content_disposition=None, content_type=None, content_location=None + ): + """ + Makes this request field into a multipart request field. + + This method overrides "Content-Disposition", "Content-Type" and + "Content-Location" headers to the request parameter. + + :param content_type: + The 'Content-Type' of the request body. + :param content_location: + The 'Content-Location' of the request body. + + """ + self.headers["Content-Disposition"] = content_disposition or u"form-data" + self.headers["Content-Disposition"] += u"; ".join( + [ + u"", + self._render_parts( + ((u"name", self._name), (u"filename", self._filename)) + ), + ] + ) + self.headers["Content-Type"] = content_type + self.headers["Content-Location"] = content_location diff --git a/openpype/hosts/fusion/vendor/urllib3/filepost.py b/openpype/hosts/fusion/vendor/urllib3/filepost.py new file mode 100644 index 0000000000..36c9252c64 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/filepost.py @@ -0,0 +1,98 @@ +from __future__ import absolute_import + +import binascii +import codecs +import os +from io import BytesIO + +from .fields import RequestField +from .packages import six +from .packages.six import b + +writer = codecs.lookup("utf-8")[3] + + +def choose_boundary(): + """ + Our embarrassingly-simple replacement for mimetools.choose_boundary. + """ + boundary = binascii.hexlify(os.urandom(16)) + if not six.PY2: + boundary = boundary.decode("ascii") + return boundary + + +def iter_field_objects(fields): + """ + Iterate over fields. + + Supports list of (k, v) tuples and dicts, and lists of + :class:`~urllib3.fields.RequestField`. + + """ + if isinstance(fields, dict): + i = six.iteritems(fields) + else: + i = iter(fields) + + for field in i: + if isinstance(field, RequestField): + yield field + else: + yield RequestField.from_tuples(*field) + + +def iter_fields(fields): + """ + .. deprecated:: 1.6 + + Iterate over fields. + + The addition of :class:`~urllib3.fields.RequestField` makes this function + obsolete. Instead, use :func:`iter_field_objects`, which returns + :class:`~urllib3.fields.RequestField` objects. + + Supports list of (k, v) tuples and dicts. + """ + if isinstance(fields, dict): + return ((k, v) for k, v in six.iteritems(fields)) + + return ((k, v) for k, v in fields) + + +def encode_multipart_formdata(fields, boundary=None): + """ + Encode a dictionary of ``fields`` using the multipart/form-data MIME format. + + :param fields: + Dictionary of fields or list of (key, :class:`~urllib3.fields.RequestField`). + + :param boundary: + If not specified, then a random boundary will be generated using + :func:`urllib3.filepost.choose_boundary`. + """ + body = BytesIO() + if boundary is None: + boundary = choose_boundary() + + for field in iter_field_objects(fields): + body.write(b("--%s\r\n" % (boundary))) + + writer(body).write(field.render_headers()) + data = field.data + + if isinstance(data, int): + data = str(data) # Backwards compatibility + + if isinstance(data, six.text_type): + writer(body).write(data) + else: + body.write(data) + + body.write(b"\r\n") + + body.write(b("--%s--\r\n" % (boundary))) + + content_type = str("multipart/form-data; boundary=%s" % boundary) + + return body.getvalue(), content_type diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/__init__.py b/openpype/hosts/fusion/vendor/urllib3/packages/__init__.py new file mode 100644 index 0000000000..fce4caa65d --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import + +from . import ssl_match_hostname + +__all__ = ("ssl_match_hostname",) diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/backports/__init__.py b/openpype/hosts/fusion/vendor/urllib3/packages/backports/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/backports/makefile.py b/openpype/hosts/fusion/vendor/urllib3/packages/backports/makefile.py new file mode 100644 index 0000000000..b8fb2154b6 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/backports/makefile.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +backports.makefile +~~~~~~~~~~~~~~~~~~ + +Backports the Python 3 ``socket.makefile`` method for use with anything that +wants to create a "fake" socket object. +""" +import io +from socket import SocketIO + + +def backport_makefile( + self, mode="r", buffering=None, encoding=None, errors=None, newline=None +): + """ + Backport of ``socket.makefile`` from Python 3.5. + """ + if not set(mode) <= {"r", "w", "b"}: + raise ValueError("invalid mode %r (only r, w, b allowed)" % (mode,)) + writing = "w" in mode + reading = "r" in mode or not writing + assert reading or writing + binary = "b" in mode + rawmode = "" + if reading: + rawmode += "r" + if writing: + rawmode += "w" + raw = SocketIO(self, rawmode) + self._makefile_refs += 1 + if buffering is None: + buffering = -1 + if buffering < 0: + buffering = io.DEFAULT_BUFFER_SIZE + if buffering == 0: + if not binary: + raise ValueError("unbuffered streams must be binary") + return raw + if reading and writing: + buffer = io.BufferedRWPair(raw, raw, buffering) + elif reading: + buffer = io.BufferedReader(raw, buffering) + else: + assert writing + buffer = io.BufferedWriter(raw, buffering) + if binary: + return buffer + text = io.TextIOWrapper(buffer, encoding, errors, newline) + text.mode = mode + return text diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/six.py b/openpype/hosts/fusion/vendor/urllib3/packages/six.py new file mode 100644 index 0000000000..ba50acb062 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/six.py @@ -0,0 +1,1077 @@ +# Copyright (c) 2010-2020 Benjamin Peterson +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Utilities for writing code that runs on Python 2 and 3""" + +from __future__ import absolute_import + +import functools +import itertools +import operator +import sys +import types + +__author__ = "Benjamin Peterson " +__version__ = "1.16.0" + + +# Useful for very coarse version differentiation. +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 +PY34 = sys.version_info[0:2] >= (3, 4) + +if PY3: + string_types = (str,) + integer_types = (int,) + class_types = (type,) + text_type = str + binary_type = bytes + + MAXSIZE = sys.maxsize +else: + string_types = (basestring,) + integer_types = (int, long) + class_types = (type, types.ClassType) + text_type = unicode + binary_type = str + + if sys.platform.startswith("java"): + # Jython always uses 32 bits. + MAXSIZE = int((1 << 31) - 1) + else: + # It's possible to have sizeof(long) != sizeof(Py_ssize_t). + class X(object): + def __len__(self): + return 1 << 31 + + try: + len(X()) + except OverflowError: + # 32-bit + MAXSIZE = int((1 << 31) - 1) + else: + # 64-bit + MAXSIZE = int((1 << 63) - 1) + del X + +if PY34: + from importlib.util import spec_from_loader +else: + spec_from_loader = None + + +def _add_doc(func, doc): + """Add documentation to a function.""" + func.__doc__ = doc + + +def _import_module(name): + """Import module, returning the module after the last dot.""" + __import__(name) + return sys.modules[name] + + +class _LazyDescr(object): + def __init__(self, name): + self.name = name + + def __get__(self, obj, tp): + result = self._resolve() + setattr(obj, self.name, result) # Invokes __set__. + try: + # This is a bit ugly, but it avoids running this again by + # removing this descriptor. + delattr(obj.__class__, self.name) + except AttributeError: + pass + return result + + +class MovedModule(_LazyDescr): + def __init__(self, name, old, new=None): + super(MovedModule, self).__init__(name) + if PY3: + if new is None: + new = name + self.mod = new + else: + self.mod = old + + def _resolve(self): + return _import_module(self.mod) + + def __getattr__(self, attr): + _module = self._resolve() + value = getattr(_module, attr) + setattr(self, attr, value) + return value + + +class _LazyModule(types.ModuleType): + def __init__(self, name): + super(_LazyModule, self).__init__(name) + self.__doc__ = self.__class__.__doc__ + + def __dir__(self): + attrs = ["__doc__", "__name__"] + attrs += [attr.name for attr in self._moved_attributes] + return attrs + + # Subclasses should override this + _moved_attributes = [] + + +class MovedAttribute(_LazyDescr): + def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None): + super(MovedAttribute, self).__init__(name) + if PY3: + if new_mod is None: + new_mod = name + self.mod = new_mod + if new_attr is None: + if old_attr is None: + new_attr = name + else: + new_attr = old_attr + self.attr = new_attr + else: + self.mod = old_mod + if old_attr is None: + old_attr = name + self.attr = old_attr + + def _resolve(self): + module = _import_module(self.mod) + return getattr(module, self.attr) + + +class _SixMetaPathImporter(object): + + """ + A meta path importer to import six.moves and its submodules. + + This class implements a PEP302 finder and loader. It should be compatible + with Python 2.5 and all existing versions of Python3 + """ + + def __init__(self, six_module_name): + self.name = six_module_name + self.known_modules = {} + + def _add_module(self, mod, *fullnames): + for fullname in fullnames: + self.known_modules[self.name + "." + fullname] = mod + + def _get_module(self, fullname): + return self.known_modules[self.name + "." + fullname] + + def find_module(self, fullname, path=None): + if fullname in self.known_modules: + return self + return None + + def find_spec(self, fullname, path, target=None): + if fullname in self.known_modules: + return spec_from_loader(fullname, self) + return None + + def __get_module(self, fullname): + try: + return self.known_modules[fullname] + except KeyError: + raise ImportError("This loader does not know module " + fullname) + + def load_module(self, fullname): + try: + # in case of a reload + return sys.modules[fullname] + except KeyError: + pass + mod = self.__get_module(fullname) + if isinstance(mod, MovedModule): + mod = mod._resolve() + else: + mod.__loader__ = self + sys.modules[fullname] = mod + return mod + + def is_package(self, fullname): + """ + Return true, if the named module is a package. + + We need this method to get correct spec objects with + Python 3.4 (see PEP451) + """ + return hasattr(self.__get_module(fullname), "__path__") + + def get_code(self, fullname): + """Return None + + Required, if is_package is implemented""" + self.__get_module(fullname) # eventually raises ImportError + return None + + get_source = get_code # same as get_code + + def create_module(self, spec): + return self.load_module(spec.name) + + def exec_module(self, module): + pass + + +_importer = _SixMetaPathImporter(__name__) + + +class _MovedItems(_LazyModule): + + """Lazy loading of moved objects""" + + __path__ = [] # mark as package + + +_moved_attributes = [ + MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), + MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), + MovedAttribute( + "filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse" + ), + MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), + MovedAttribute("intern", "__builtin__", "sys"), + MovedAttribute("map", "itertools", "builtins", "imap", "map"), + MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"), + MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"), + MovedAttribute("getoutput", "commands", "subprocess"), + MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"), + MovedAttribute( + "reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload" + ), + MovedAttribute("reduce", "__builtin__", "functools"), + MovedAttribute("shlex_quote", "pipes", "shlex", "quote"), + MovedAttribute("StringIO", "StringIO", "io"), + MovedAttribute("UserDict", "UserDict", "collections"), + MovedAttribute("UserList", "UserList", "collections"), + MovedAttribute("UserString", "UserString", "collections"), + MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), + MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), + MovedAttribute( + "zip_longest", "itertools", "itertools", "izip_longest", "zip_longest" + ), + MovedModule("builtins", "__builtin__"), + MovedModule("configparser", "ConfigParser"), + MovedModule( + "collections_abc", + "collections", + "collections.abc" if sys.version_info >= (3, 3) else "collections", + ), + MovedModule("copyreg", "copy_reg"), + MovedModule("dbm_gnu", "gdbm", "dbm.gnu"), + MovedModule("dbm_ndbm", "dbm", "dbm.ndbm"), + MovedModule( + "_dummy_thread", + "dummy_thread", + "_dummy_thread" if sys.version_info < (3, 9) else "_thread", + ), + MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), + MovedModule("http_cookies", "Cookie", "http.cookies"), + MovedModule("html_entities", "htmlentitydefs", "html.entities"), + MovedModule("html_parser", "HTMLParser", "html.parser"), + MovedModule("http_client", "httplib", "http.client"), + MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"), + MovedModule("email_mime_image", "email.MIMEImage", "email.mime.image"), + MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"), + MovedModule( + "email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart" + ), + MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"), + MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), + MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), + MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), + MovedModule("cPickle", "cPickle", "pickle"), + MovedModule("queue", "Queue"), + MovedModule("reprlib", "repr"), + MovedModule("socketserver", "SocketServer"), + MovedModule("_thread", "thread", "_thread"), + MovedModule("tkinter", "Tkinter"), + MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), + MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), + MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), + MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), + MovedModule("tkinter_tix", "Tix", "tkinter.tix"), + MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"), + MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), + MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), + MovedModule("tkinter_colorchooser", "tkColorChooser", "tkinter.colorchooser"), + MovedModule("tkinter_commondialog", "tkCommonDialog", "tkinter.commondialog"), + MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"), + MovedModule("tkinter_font", "tkFont", "tkinter.font"), + MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), + MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", "tkinter.simpledialog"), + MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"), + MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"), + MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), + MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), + MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), + MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"), +] +# Add windows specific modules. +if sys.platform == "win32": + _moved_attributes += [ + MovedModule("winreg", "_winreg"), + ] + +for attr in _moved_attributes: + setattr(_MovedItems, attr.name, attr) + if isinstance(attr, MovedModule): + _importer._add_module(attr, "moves." + attr.name) +del attr + +_MovedItems._moved_attributes = _moved_attributes + +moves = _MovedItems(__name__ + ".moves") +_importer._add_module(moves, "moves") + + +class Module_six_moves_urllib_parse(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_parse""" + + +_urllib_parse_moved_attributes = [ + MovedAttribute("ParseResult", "urlparse", "urllib.parse"), + MovedAttribute("SplitResult", "urlparse", "urllib.parse"), + MovedAttribute("parse_qs", "urlparse", "urllib.parse"), + MovedAttribute("parse_qsl", "urlparse", "urllib.parse"), + MovedAttribute("urldefrag", "urlparse", "urllib.parse"), + MovedAttribute("urljoin", "urlparse", "urllib.parse"), + MovedAttribute("urlparse", "urlparse", "urllib.parse"), + MovedAttribute("urlsplit", "urlparse", "urllib.parse"), + MovedAttribute("urlunparse", "urlparse", "urllib.parse"), + MovedAttribute("urlunsplit", "urlparse", "urllib.parse"), + MovedAttribute("quote", "urllib", "urllib.parse"), + MovedAttribute("quote_plus", "urllib", "urllib.parse"), + MovedAttribute("unquote", "urllib", "urllib.parse"), + MovedAttribute("unquote_plus", "urllib", "urllib.parse"), + MovedAttribute( + "unquote_to_bytes", "urllib", "urllib.parse", "unquote", "unquote_to_bytes" + ), + MovedAttribute("urlencode", "urllib", "urllib.parse"), + MovedAttribute("splitquery", "urllib", "urllib.parse"), + MovedAttribute("splittag", "urllib", "urllib.parse"), + MovedAttribute("splituser", "urllib", "urllib.parse"), + MovedAttribute("splitvalue", "urllib", "urllib.parse"), + MovedAttribute("uses_fragment", "urlparse", "urllib.parse"), + MovedAttribute("uses_netloc", "urlparse", "urllib.parse"), + MovedAttribute("uses_params", "urlparse", "urllib.parse"), + MovedAttribute("uses_query", "urlparse", "urllib.parse"), + MovedAttribute("uses_relative", "urlparse", "urllib.parse"), +] +for attr in _urllib_parse_moved_attributes: + setattr(Module_six_moves_urllib_parse, attr.name, attr) +del attr + +Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes + +_importer._add_module( + Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"), + "moves.urllib_parse", + "moves.urllib.parse", +) + + +class Module_six_moves_urllib_error(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_error""" + + +_urllib_error_moved_attributes = [ + MovedAttribute("URLError", "urllib2", "urllib.error"), + MovedAttribute("HTTPError", "urllib2", "urllib.error"), + MovedAttribute("ContentTooShortError", "urllib", "urllib.error"), +] +for attr in _urllib_error_moved_attributes: + setattr(Module_six_moves_urllib_error, attr.name, attr) +del attr + +Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes + +_importer._add_module( + Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"), + "moves.urllib_error", + "moves.urllib.error", +) + + +class Module_six_moves_urllib_request(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_request""" + + +_urllib_request_moved_attributes = [ + MovedAttribute("urlopen", "urllib2", "urllib.request"), + MovedAttribute("install_opener", "urllib2", "urllib.request"), + MovedAttribute("build_opener", "urllib2", "urllib.request"), + MovedAttribute("pathname2url", "urllib", "urllib.request"), + MovedAttribute("url2pathname", "urllib", "urllib.request"), + MovedAttribute("getproxies", "urllib", "urllib.request"), + MovedAttribute("Request", "urllib2", "urllib.request"), + MovedAttribute("OpenerDirector", "urllib2", "urllib.request"), + MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"), + MovedAttribute("ProxyHandler", "urllib2", "urllib.request"), + MovedAttribute("BaseHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"), + MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"), + MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"), + MovedAttribute("FileHandler", "urllib2", "urllib.request"), + MovedAttribute("FTPHandler", "urllib2", "urllib.request"), + MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"), + MovedAttribute("UnknownHandler", "urllib2", "urllib.request"), + MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"), + MovedAttribute("urlretrieve", "urllib", "urllib.request"), + MovedAttribute("urlcleanup", "urllib", "urllib.request"), + MovedAttribute("URLopener", "urllib", "urllib.request"), + MovedAttribute("FancyURLopener", "urllib", "urllib.request"), + MovedAttribute("proxy_bypass", "urllib", "urllib.request"), + MovedAttribute("parse_http_list", "urllib2", "urllib.request"), + MovedAttribute("parse_keqv_list", "urllib2", "urllib.request"), +] +for attr in _urllib_request_moved_attributes: + setattr(Module_six_moves_urllib_request, attr.name, attr) +del attr + +Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes + +_importer._add_module( + Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"), + "moves.urllib_request", + "moves.urllib.request", +) + + +class Module_six_moves_urllib_response(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_response""" + + +_urllib_response_moved_attributes = [ + MovedAttribute("addbase", "urllib", "urllib.response"), + MovedAttribute("addclosehook", "urllib", "urllib.response"), + MovedAttribute("addinfo", "urllib", "urllib.response"), + MovedAttribute("addinfourl", "urllib", "urllib.response"), +] +for attr in _urllib_response_moved_attributes: + setattr(Module_six_moves_urllib_response, attr.name, attr) +del attr + +Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes + +_importer._add_module( + Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"), + "moves.urllib_response", + "moves.urllib.response", +) + + +class Module_six_moves_urllib_robotparser(_LazyModule): + + """Lazy loading of moved objects in six.moves.urllib_robotparser""" + + +_urllib_robotparser_moved_attributes = [ + MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"), +] +for attr in _urllib_robotparser_moved_attributes: + setattr(Module_six_moves_urllib_robotparser, attr.name, attr) +del attr + +Module_six_moves_urllib_robotparser._moved_attributes = ( + _urllib_robotparser_moved_attributes +) + +_importer._add_module( + Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"), + "moves.urllib_robotparser", + "moves.urllib.robotparser", +) + + +class Module_six_moves_urllib(types.ModuleType): + + """Create a six.moves.urllib namespace that resembles the Python 3 namespace""" + + __path__ = [] # mark as package + parse = _importer._get_module("moves.urllib_parse") + error = _importer._get_module("moves.urllib_error") + request = _importer._get_module("moves.urllib_request") + response = _importer._get_module("moves.urllib_response") + robotparser = _importer._get_module("moves.urllib_robotparser") + + def __dir__(self): + return ["parse", "error", "request", "response", "robotparser"] + + +_importer._add_module( + Module_six_moves_urllib(__name__ + ".moves.urllib"), "moves.urllib" +) + + +def add_move(move): + """Add an item to six.moves.""" + setattr(_MovedItems, move.name, move) + + +def remove_move(name): + """Remove item from six.moves.""" + try: + delattr(_MovedItems, name) + except AttributeError: + try: + del moves.__dict__[name] + except KeyError: + raise AttributeError("no such move, %r" % (name,)) + + +if PY3: + _meth_func = "__func__" + _meth_self = "__self__" + + _func_closure = "__closure__" + _func_code = "__code__" + _func_defaults = "__defaults__" + _func_globals = "__globals__" +else: + _meth_func = "im_func" + _meth_self = "im_self" + + _func_closure = "func_closure" + _func_code = "func_code" + _func_defaults = "func_defaults" + _func_globals = "func_globals" + + +try: + advance_iterator = next +except NameError: + + def advance_iterator(it): + return it.next() + + +next = advance_iterator + + +try: + callable = callable +except NameError: + + def callable(obj): + return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) + + +if PY3: + + def get_unbound_function(unbound): + return unbound + + create_bound_method = types.MethodType + + def create_unbound_method(func, cls): + return func + + Iterator = object +else: + + def get_unbound_function(unbound): + return unbound.im_func + + def create_bound_method(func, obj): + return types.MethodType(func, obj, obj.__class__) + + def create_unbound_method(func, cls): + return types.MethodType(func, None, cls) + + class Iterator(object): + def next(self): + return type(self).__next__(self) + + callable = callable +_add_doc( + get_unbound_function, """Get the function out of a possibly unbound function""" +) + + +get_method_function = operator.attrgetter(_meth_func) +get_method_self = operator.attrgetter(_meth_self) +get_function_closure = operator.attrgetter(_func_closure) +get_function_code = operator.attrgetter(_func_code) +get_function_defaults = operator.attrgetter(_func_defaults) +get_function_globals = operator.attrgetter(_func_globals) + + +if PY3: + + def iterkeys(d, **kw): + return iter(d.keys(**kw)) + + def itervalues(d, **kw): + return iter(d.values(**kw)) + + def iteritems(d, **kw): + return iter(d.items(**kw)) + + def iterlists(d, **kw): + return iter(d.lists(**kw)) + + viewkeys = operator.methodcaller("keys") + + viewvalues = operator.methodcaller("values") + + viewitems = operator.methodcaller("items") +else: + + def iterkeys(d, **kw): + return d.iterkeys(**kw) + + def itervalues(d, **kw): + return d.itervalues(**kw) + + def iteritems(d, **kw): + return d.iteritems(**kw) + + def iterlists(d, **kw): + return d.iterlists(**kw) + + viewkeys = operator.methodcaller("viewkeys") + + viewvalues = operator.methodcaller("viewvalues") + + viewitems = operator.methodcaller("viewitems") + +_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") +_add_doc(itervalues, "Return an iterator over the values of a dictionary.") +_add_doc(iteritems, "Return an iterator over the (key, value) pairs of a dictionary.") +_add_doc( + iterlists, "Return an iterator over the (key, [values]) pairs of a dictionary." +) + + +if PY3: + + def b(s): + return s.encode("latin-1") + + def u(s): + return s + + unichr = chr + import struct + + int2byte = struct.Struct(">B").pack + del struct + byte2int = operator.itemgetter(0) + indexbytes = operator.getitem + iterbytes = iter + import io + + StringIO = io.StringIO + BytesIO = io.BytesIO + del io + _assertCountEqual = "assertCountEqual" + if sys.version_info[1] <= 1: + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + _assertNotRegex = "assertNotRegexpMatches" + else: + _assertRaisesRegex = "assertRaisesRegex" + _assertRegex = "assertRegex" + _assertNotRegex = "assertNotRegex" +else: + + def b(s): + return s + + # Workaround for standalone backslash + + def u(s): + return unicode(s.replace(r"\\", r"\\\\"), "unicode_escape") + + unichr = unichr + int2byte = chr + + def byte2int(bs): + return ord(bs[0]) + + def indexbytes(buf, i): + return ord(buf[i]) + + iterbytes = functools.partial(itertools.imap, ord) + import StringIO + + StringIO = BytesIO = StringIO.StringIO + _assertCountEqual = "assertItemsEqual" + _assertRaisesRegex = "assertRaisesRegexp" + _assertRegex = "assertRegexpMatches" + _assertNotRegex = "assertNotRegexpMatches" +_add_doc(b, """Byte literal""") +_add_doc(u, """Text literal""") + + +def assertCountEqual(self, *args, **kwargs): + return getattr(self, _assertCountEqual)(*args, **kwargs) + + +def assertRaisesRegex(self, *args, **kwargs): + return getattr(self, _assertRaisesRegex)(*args, **kwargs) + + +def assertRegex(self, *args, **kwargs): + return getattr(self, _assertRegex)(*args, **kwargs) + + +def assertNotRegex(self, *args, **kwargs): + return getattr(self, _assertNotRegex)(*args, **kwargs) + + +if PY3: + exec_ = getattr(moves.builtins, "exec") + + def reraise(tp, value, tb=None): + try: + if value is None: + value = tp() + if value.__traceback__ is not tb: + raise value.with_traceback(tb) + raise value + finally: + value = None + tb = None + + +else: + + def exec_(_code_, _globs_=None, _locs_=None): + """Execute code in a namespace.""" + if _globs_ is None: + frame = sys._getframe(1) + _globs_ = frame.f_globals + if _locs_ is None: + _locs_ = frame.f_locals + del frame + elif _locs_ is None: + _locs_ = _globs_ + exec ("""exec _code_ in _globs_, _locs_""") + + exec_( + """def reraise(tp, value, tb=None): + try: + raise tp, value, tb + finally: + tb = None +""" + ) + + +if sys.version_info[:2] > (3,): + exec_( + """def raise_from(value, from_value): + try: + raise value from from_value + finally: + value = None +""" + ) +else: + + def raise_from(value, from_value): + raise value + + +print_ = getattr(moves.builtins, "print", None) +if print_ is None: + + def print_(*args, **kwargs): + """The new-style print function for Python 2.4 and 2.5.""" + fp = kwargs.pop("file", sys.stdout) + if fp is None: + return + + def write(data): + if not isinstance(data, basestring): + data = str(data) + # If the file has an encoding, encode unicode with it. + if ( + isinstance(fp, file) + and isinstance(data, unicode) + and fp.encoding is not None + ): + errors = getattr(fp, "errors", None) + if errors is None: + errors = "strict" + data = data.encode(fp.encoding, errors) + fp.write(data) + + want_unicode = False + sep = kwargs.pop("sep", None) + if sep is not None: + if isinstance(sep, unicode): + want_unicode = True + elif not isinstance(sep, str): + raise TypeError("sep must be None or a string") + end = kwargs.pop("end", None) + if end is not None: + if isinstance(end, unicode): + want_unicode = True + elif not isinstance(end, str): + raise TypeError("end must be None or a string") + if kwargs: + raise TypeError("invalid keyword arguments to print()") + if not want_unicode: + for arg in args: + if isinstance(arg, unicode): + want_unicode = True + break + if want_unicode: + newline = unicode("\n") + space = unicode(" ") + else: + newline = "\n" + space = " " + if sep is None: + sep = space + if end is None: + end = newline + for i, arg in enumerate(args): + if i: + write(sep) + write(arg) + write(end) + + +if sys.version_info[:2] < (3, 3): + _print = print_ + + def print_(*args, **kwargs): + fp = kwargs.get("file", sys.stdout) + flush = kwargs.pop("flush", False) + _print(*args, **kwargs) + if flush and fp is not None: + fp.flush() + + +_add_doc(reraise, """Reraise an exception.""") + +if sys.version_info[0:2] < (3, 4): + # This does exactly the same what the :func:`py3:functools.update_wrapper` + # function does on Python versions after 3.2. It sets the ``__wrapped__`` + # attribute on ``wrapper`` object and it doesn't raise an error if any of + # the attributes mentioned in ``assigned`` and ``updated`` are missing on + # ``wrapped`` object. + def _update_wrapper( + wrapper, + wrapped, + assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES, + ): + for attr in assigned: + try: + value = getattr(wrapped, attr) + except AttributeError: + continue + else: + setattr(wrapper, attr, value) + for attr in updated: + getattr(wrapper, attr).update(getattr(wrapped, attr, {})) + wrapper.__wrapped__ = wrapped + return wrapper + + _update_wrapper.__doc__ = functools.update_wrapper.__doc__ + + def wraps( + wrapped, + assigned=functools.WRAPPER_ASSIGNMENTS, + updated=functools.WRAPPER_UPDATES, + ): + return functools.partial( + _update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated + ) + + wraps.__doc__ = functools.wraps.__doc__ + +else: + wraps = functools.wraps + + +def with_metaclass(meta, *bases): + """Create a base class with a metaclass.""" + # This requires a bit of explanation: the basic idea is to make a dummy + # metaclass for one level of class instantiation that replaces itself with + # the actual metaclass. + class metaclass(type): + def __new__(cls, name, this_bases, d): + if sys.version_info[:2] >= (3, 7): + # This version introduced PEP 560 that requires a bit + # of extra care (we mimic what is done by __build_class__). + resolved_bases = types.resolve_bases(bases) + if resolved_bases is not bases: + d["__orig_bases__"] = bases + else: + resolved_bases = bases + return meta(name, resolved_bases, d) + + @classmethod + def __prepare__(cls, name, this_bases): + return meta.__prepare__(name, bases) + + return type.__new__(metaclass, "temporary_class", (), {}) + + +def add_metaclass(metaclass): + """Class decorator for creating a class with a metaclass.""" + + def wrapper(cls): + orig_vars = cls.__dict__.copy() + slots = orig_vars.get("__slots__") + if slots is not None: + if isinstance(slots, str): + slots = [slots] + for slots_var in slots: + orig_vars.pop(slots_var) + orig_vars.pop("__dict__", None) + orig_vars.pop("__weakref__", None) + if hasattr(cls, "__qualname__"): + orig_vars["__qualname__"] = cls.__qualname__ + return metaclass(cls.__name__, cls.__bases__, orig_vars) + + return wrapper + + +def ensure_binary(s, encoding="utf-8", errors="strict"): + """Coerce **s** to six.binary_type. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> encoded to `bytes` + - `bytes` -> `bytes` + """ + if isinstance(s, binary_type): + return s + if isinstance(s, text_type): + return s.encode(encoding, errors) + raise TypeError("not expecting type '%s'" % type(s)) + + +def ensure_str(s, encoding="utf-8", errors="strict"): + """Coerce *s* to `str`. + + For Python 2: + - `unicode` -> encoded to `str` + - `str` -> `str` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + # Optimization: Fast return for the common case. + if type(s) is str: + return s + if PY2 and isinstance(s, text_type): + return s.encode(encoding, errors) + elif PY3 and isinstance(s, binary_type): + return s.decode(encoding, errors) + elif not isinstance(s, (text_type, binary_type)): + raise TypeError("not expecting type '%s'" % type(s)) + return s + + +def ensure_text(s, encoding="utf-8", errors="strict"): + """Coerce *s* to six.text_type. + + For Python 2: + - `unicode` -> `unicode` + - `str` -> `unicode` + + For Python 3: + - `str` -> `str` + - `bytes` -> decoded to `str` + """ + if isinstance(s, binary_type): + return s.decode(encoding, errors) + elif isinstance(s, text_type): + return s + else: + raise TypeError("not expecting type '%s'" % type(s)) + + +def python_2_unicode_compatible(klass): + """ + A class decorator that defines __unicode__ and __str__ methods under Python 2. + Under Python 3 it does nothing. + + To support Python 2 and 3 with a single code base, define a __str__ method + returning text and apply this decorator to the class. + """ + if PY2: + if "__str__" not in klass.__dict__: + raise ValueError( + "@python_2_unicode_compatible cannot be applied " + "to %s because it doesn't define __str__()." % klass.__name__ + ) + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda self: self.__unicode__().encode("utf-8") + return klass + + +# Complete the moves implementation. +# This code is at the end of this module to speed up module loading. +# Turn this module into a package. +__path__ = [] # required for PEP 302 and PEP 451 +__package__ = __name__ # see PEP 366 @ReservedAssignment +if globals().get("__spec__") is not None: + __spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable +# Remove other six meta path importers, since they cause problems. This can +# happen if six is removed from sys.modules and then reloaded. (Setuptools does +# this for some reason.) +if sys.meta_path: + for i, importer in enumerate(sys.meta_path): + # Here's some real nastiness: Another "instance" of the six module might + # be floating around. Therefore, we can't use isinstance() to check for + # the six meta path importer, since the other six instance will have + # inserted an importer with different class. + if ( + type(importer).__name__ == "_SixMetaPathImporter" + and importer.name == __name__ + ): + del sys.meta_path[i] + break + del i, importer +# Finally, add the importer to the meta path import hook. +sys.meta_path.append(_importer) diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/__init__.py b/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/__init__.py new file mode 100644 index 0000000000..ef3fde5206 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/__init__.py @@ -0,0 +1,24 @@ +import sys + +try: + # Our match_hostname function is the same as 3.10's, so we only want to + # import the match_hostname function if it's at least that good. + # We also fallback on Python 3.10+ because our code doesn't emit + # deprecation warnings and is the same as Python 3.10 otherwise. + if sys.version_info < (3, 5) or sys.version_info >= (3, 10): + raise ImportError("Fallback to vendored code") + + from ssl import CertificateError, match_hostname +except ImportError: + try: + # Backport of the function from a pypi module + from backports.ssl_match_hostname import ( # type: ignore + CertificateError, + match_hostname, + ) + except ImportError: + # Our vendored copy + from ._implementation import CertificateError, match_hostname # type: ignore + +# Not needed, but documenting what we provide. +__all__ = ("CertificateError", "match_hostname") diff --git a/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/_implementation.py b/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/_implementation.py new file mode 100644 index 0000000000..689208d3c6 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/_implementation.py @@ -0,0 +1,160 @@ +"""The match_hostname() function from Python 3.3.3, essential when using SSL.""" + +# Note: This file is under the PSF license as the code comes from the python +# stdlib. http://docs.python.org/3/license.html + +import re +import sys + +# ipaddress has been backported to 2.6+ in pypi. If it is installed on the +# system, use it to handle IPAddress ServerAltnames (this was added in +# python-3.5) otherwise only do DNS matching. This allows +# backports.ssl_match_hostname to continue to be used in Python 2.7. +try: + import ipaddress +except ImportError: + ipaddress = None + +__version__ = "3.5.0.1" + + +class CertificateError(ValueError): + pass + + +def _dnsname_match(dn, hostname, max_wildcards=1): + """Matching according to RFC 6125, section 6.4.3 + + http://tools.ietf.org/html/rfc6125#section-6.4.3 + """ + pats = [] + if not dn: + return False + + # Ported from python3-syntax: + # leftmost, *remainder = dn.split(r'.') + parts = dn.split(r".") + leftmost = parts[0] + remainder = parts[1:] + + wildcards = leftmost.count("*") + if wildcards > max_wildcards: + # Issue #17980: avoid denials of service by refusing more + # than one wildcard per fragment. A survey of established + # policy among SSL implementations showed it to be a + # reasonable choice. + raise CertificateError( + "too many wildcards in certificate DNS name: " + repr(dn) + ) + + # speed up common case w/o wildcards + if not wildcards: + return dn.lower() == hostname.lower() + + # RFC 6125, section 6.4.3, subitem 1. + # The client SHOULD NOT attempt to match a presented identifier in which + # the wildcard character comprises a label other than the left-most label. + if leftmost == "*": + # When '*' is a fragment by itself, it matches a non-empty dotless + # fragment. + pats.append("[^.]+") + elif leftmost.startswith("xn--") or hostname.startswith("xn--"): + # RFC 6125, section 6.4.3, subitem 3. + # The client SHOULD NOT attempt to match a presented identifier + # where the wildcard character is embedded within an A-label or + # U-label of an internationalized domain name. + pats.append(re.escape(leftmost)) + else: + # Otherwise, '*' matches any dotless string, e.g. www* + pats.append(re.escape(leftmost).replace(r"\*", "[^.]*")) + + # add the remaining fragments, ignore any wildcards + for frag in remainder: + pats.append(re.escape(frag)) + + pat = re.compile(r"\A" + r"\.".join(pats) + r"\Z", re.IGNORECASE) + return pat.match(hostname) + + +def _to_unicode(obj): + if isinstance(obj, str) and sys.version_info < (3,): + obj = unicode(obj, encoding="ascii", errors="strict") + return obj + + +def _ipaddress_match(ipname, host_ip): + """Exact matching of IP addresses. + + RFC 6125 explicitly doesn't define an algorithm for this + (section 1.7.2 - "Out of Scope"). + """ + # OpenSSL may add a trailing newline to a subjectAltName's IP address + # Divergence from upstream: ipaddress can't handle byte str + ip = ipaddress.ip_address(_to_unicode(ipname).rstrip()) + return ip == host_ip + + +def match_hostname(cert, hostname): + """Verify that *cert* (in decoded format as returned by + SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 and RFC 6125 + rules are followed, but IP addresses are not accepted for *hostname*. + + CertificateError is raised on failure. On success, the function + returns nothing. + """ + if not cert: + raise ValueError( + "empty or no certificate, match_hostname needs a " + "SSL socket or SSL context with either " + "CERT_OPTIONAL or CERT_REQUIRED" + ) + try: + # Divergence from upstream: ipaddress can't handle byte str + host_ip = ipaddress.ip_address(_to_unicode(hostname)) + except ValueError: + # Not an IP address (common case) + host_ip = None + except UnicodeError: + # Divergence from upstream: Have to deal with ipaddress not taking + # byte strings. addresses should be all ascii, so we consider it not + # an ipaddress in this case + host_ip = None + except AttributeError: + # Divergence from upstream: Make ipaddress library optional + if ipaddress is None: + host_ip = None + else: + raise + dnsnames = [] + san = cert.get("subjectAltName", ()) + for key, value in san: + if key == "DNS": + if host_ip is None and _dnsname_match(value, hostname): + return + dnsnames.append(value) + elif key == "IP Address": + if host_ip is not None and _ipaddress_match(value, host_ip): + return + dnsnames.append(value) + if not dnsnames: + # The subject is only checked when there is no dNSName entry + # in subjectAltName + for sub in cert.get("subject", ()): + for key, value in sub: + # XXX according to RFC 2818, the most specific Common Name + # must be used. + if key == "commonName": + if _dnsname_match(value, hostname): + return + dnsnames.append(value) + if len(dnsnames) > 1: + raise CertificateError( + "hostname %r " + "doesn't match either of %s" % (hostname, ", ".join(map(repr, dnsnames))) + ) + elif len(dnsnames) == 1: + raise CertificateError("hostname %r doesn't match %r" % (hostname, dnsnames[0])) + else: + raise CertificateError( + "no appropriate commonName or subjectAltName fields were found" + ) diff --git a/openpype/hosts/fusion/vendor/urllib3/poolmanager.py b/openpype/hosts/fusion/vendor/urllib3/poolmanager.py new file mode 100644 index 0000000000..3a31a285bf --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/poolmanager.py @@ -0,0 +1,536 @@ +from __future__ import absolute_import + +import collections +import functools +import logging + +from ._collections import RecentlyUsedContainer +from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool, port_by_scheme +from .exceptions import ( + LocationValueError, + MaxRetryError, + ProxySchemeUnknown, + ProxySchemeUnsupported, + URLSchemeUnknown, +) +from .packages import six +from .packages.six.moves.urllib.parse import urljoin +from .request import RequestMethods +from .util.proxy import connection_requires_http_tunnel +from .util.retry import Retry +from .util.url import parse_url + +__all__ = ["PoolManager", "ProxyManager", "proxy_from_url"] + + +log = logging.getLogger(__name__) + +SSL_KEYWORDS = ( + "key_file", + "cert_file", + "cert_reqs", + "ca_certs", + "ssl_version", + "ca_cert_dir", + "ssl_context", + "key_password", +) + +# All known keyword arguments that could be provided to the pool manager, its +# pools, or the underlying connections. This is used to construct a pool key. +_key_fields = ( + "key_scheme", # str + "key_host", # str + "key_port", # int + "key_timeout", # int or float or Timeout + "key_retries", # int or Retry + "key_strict", # bool + "key_block", # bool + "key_source_address", # str + "key_key_file", # str + "key_key_password", # str + "key_cert_file", # str + "key_cert_reqs", # str + "key_ca_certs", # str + "key_ssl_version", # str + "key_ca_cert_dir", # str + "key_ssl_context", # instance of ssl.SSLContext or urllib3.util.ssl_.SSLContext + "key_maxsize", # int + "key_headers", # dict + "key__proxy", # parsed proxy url + "key__proxy_headers", # dict + "key__proxy_config", # class + "key_socket_options", # list of (level (int), optname (int), value (int or str)) tuples + "key__socks_options", # dict + "key_assert_hostname", # bool or string + "key_assert_fingerprint", # str + "key_server_hostname", # str +) + +#: The namedtuple class used to construct keys for the connection pool. +#: All custom key schemes should include the fields in this key at a minimum. +PoolKey = collections.namedtuple("PoolKey", _key_fields) + +_proxy_config_fields = ("ssl_context", "use_forwarding_for_https") +ProxyConfig = collections.namedtuple("ProxyConfig", _proxy_config_fields) + + +def _default_key_normalizer(key_class, request_context): + """ + Create a pool key out of a request context dictionary. + + According to RFC 3986, both the scheme and host are case-insensitive. + Therefore, this function normalizes both before constructing the pool + key for an HTTPS request. If you wish to change this behaviour, provide + alternate callables to ``key_fn_by_scheme``. + + :param key_class: + The class to use when constructing the key. This should be a namedtuple + with the ``scheme`` and ``host`` keys at a minimum. + :type key_class: namedtuple + :param request_context: + A dictionary-like object that contain the context for a request. + :type request_context: dict + + :return: A namedtuple that can be used as a connection pool key. + :rtype: PoolKey + """ + # Since we mutate the dictionary, make a copy first + context = request_context.copy() + context["scheme"] = context["scheme"].lower() + context["host"] = context["host"].lower() + + # These are both dictionaries and need to be transformed into frozensets + for key in ("headers", "_proxy_headers", "_socks_options"): + if key in context and context[key] is not None: + context[key] = frozenset(context[key].items()) + + # The socket_options key may be a list and needs to be transformed into a + # tuple. + socket_opts = context.get("socket_options") + if socket_opts is not None: + context["socket_options"] = tuple(socket_opts) + + # Map the kwargs to the names in the namedtuple - this is necessary since + # namedtuples can't have fields starting with '_'. + for key in list(context.keys()): + context["key_" + key] = context.pop(key) + + # Default to ``None`` for keys missing from the context + for field in key_class._fields: + if field not in context: + context[field] = None + + return key_class(**context) + + +#: A dictionary that maps a scheme to a callable that creates a pool key. +#: This can be used to alter the way pool keys are constructed, if desired. +#: Each PoolManager makes a copy of this dictionary so they can be configured +#: globally here, or individually on the instance. +key_fn_by_scheme = { + "http": functools.partial(_default_key_normalizer, PoolKey), + "https": functools.partial(_default_key_normalizer, PoolKey), +} + +pool_classes_by_scheme = {"http": HTTPConnectionPool, "https": HTTPSConnectionPool} + + +class PoolManager(RequestMethods): + """ + Allows for arbitrary requests while transparently keeping track of + necessary connection pools for you. + + :param num_pools: + Number of connection pools to cache before discarding the least + recently used pool. + + :param headers: + Headers to include with all requests, unless other headers are given + explicitly. + + :param \\**connection_pool_kw: + Additional parameters are used to create fresh + :class:`urllib3.connectionpool.ConnectionPool` instances. + + Example:: + + >>> manager = PoolManager(num_pools=2) + >>> r = manager.request('GET', 'http://google.com/') + >>> r = manager.request('GET', 'http://google.com/mail') + >>> r = manager.request('GET', 'http://yahoo.com/') + >>> len(manager.pools) + 2 + + """ + + proxy = None + proxy_config = None + + def __init__(self, num_pools=10, headers=None, **connection_pool_kw): + RequestMethods.__init__(self, headers) + self.connection_pool_kw = connection_pool_kw + self.pools = RecentlyUsedContainer(num_pools, dispose_func=lambda p: p.close()) + + # Locally set the pool classes and keys so other PoolManagers can + # override them. + self.pool_classes_by_scheme = pool_classes_by_scheme + self.key_fn_by_scheme = key_fn_by_scheme.copy() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.clear() + # Return False to re-raise any potential exceptions + return False + + def _new_pool(self, scheme, host, port, request_context=None): + """ + Create a new :class:`urllib3.connectionpool.ConnectionPool` based on host, port, scheme, and + any additional pool keyword arguments. + + If ``request_context`` is provided, it is provided as keyword arguments + to the pool class used. This method is used to actually create the + connection pools handed out by :meth:`connection_from_url` and + companion methods. It is intended to be overridden for customization. + """ + pool_cls = self.pool_classes_by_scheme[scheme] + if request_context is None: + request_context = self.connection_pool_kw.copy() + + # Although the context has everything necessary to create the pool, + # this function has historically only used the scheme, host, and port + # in the positional args. When an API change is acceptable these can + # be removed. + for key in ("scheme", "host", "port"): + request_context.pop(key, None) + + if scheme == "http": + for kw in SSL_KEYWORDS: + request_context.pop(kw, None) + + return pool_cls(host, port, **request_context) + + def clear(self): + """ + Empty our store of pools and direct them all to close. + + This will not affect in-flight connections, but they will not be + re-used after completion. + """ + self.pools.clear() + + def connection_from_host(self, host, port=None, scheme="http", pool_kwargs=None): + """ + Get a :class:`urllib3.connectionpool.ConnectionPool` based on the host, port, and scheme. + + If ``port`` isn't given, it will be derived from the ``scheme`` using + ``urllib3.connectionpool.port_by_scheme``. If ``pool_kwargs`` is + provided, it is merged with the instance's ``connection_pool_kw`` + variable and used to create the new connection pool, if one is + needed. + """ + + if not host: + raise LocationValueError("No host specified.") + + request_context = self._merge_pool_kwargs(pool_kwargs) + request_context["scheme"] = scheme or "http" + if not port: + port = port_by_scheme.get(request_context["scheme"].lower(), 80) + request_context["port"] = port + request_context["host"] = host + + return self.connection_from_context(request_context) + + def connection_from_context(self, request_context): + """ + Get a :class:`urllib3.connectionpool.ConnectionPool` based on the request context. + + ``request_context`` must at least contain the ``scheme`` key and its + value must be a key in ``key_fn_by_scheme`` instance variable. + """ + scheme = request_context["scheme"].lower() + pool_key_constructor = self.key_fn_by_scheme.get(scheme) + if not pool_key_constructor: + raise URLSchemeUnknown(scheme) + pool_key = pool_key_constructor(request_context) + + return self.connection_from_pool_key(pool_key, request_context=request_context) + + def connection_from_pool_key(self, pool_key, request_context=None): + """ + Get a :class:`urllib3.connectionpool.ConnectionPool` based on the provided pool key. + + ``pool_key`` should be a namedtuple that only contains immutable + objects. At a minimum it must have the ``scheme``, ``host``, and + ``port`` fields. + """ + with self.pools.lock: + # If the scheme, host, or port doesn't match existing open + # connections, open a new ConnectionPool. + pool = self.pools.get(pool_key) + if pool: + return pool + + # Make a fresh ConnectionPool of the desired type + scheme = request_context["scheme"] + host = request_context["host"] + port = request_context["port"] + pool = self._new_pool(scheme, host, port, request_context=request_context) + self.pools[pool_key] = pool + + return pool + + def connection_from_url(self, url, pool_kwargs=None): + """ + Similar to :func:`urllib3.connectionpool.connection_from_url`. + + If ``pool_kwargs`` is not provided and a new pool needs to be + constructed, ``self.connection_pool_kw`` is used to initialize + the :class:`urllib3.connectionpool.ConnectionPool`. If ``pool_kwargs`` + is provided, it is used instead. Note that if a new pool does not + need to be created for the request, the provided ``pool_kwargs`` are + not used. + """ + u = parse_url(url) + return self.connection_from_host( + u.host, port=u.port, scheme=u.scheme, pool_kwargs=pool_kwargs + ) + + def _merge_pool_kwargs(self, override): + """ + Merge a dictionary of override values for self.connection_pool_kw. + + This does not modify self.connection_pool_kw and returns a new dict. + Any keys in the override dictionary with a value of ``None`` are + removed from the merged dictionary. + """ + base_pool_kwargs = self.connection_pool_kw.copy() + if override: + for key, value in override.items(): + if value is None: + try: + del base_pool_kwargs[key] + except KeyError: + pass + else: + base_pool_kwargs[key] = value + return base_pool_kwargs + + def _proxy_requires_url_absolute_form(self, parsed_url): + """ + Indicates if the proxy requires the complete destination URL in the + request. Normally this is only needed when not using an HTTP CONNECT + tunnel. + """ + if self.proxy is None: + return False + + return not connection_requires_http_tunnel( + self.proxy, self.proxy_config, parsed_url.scheme + ) + + def _validate_proxy_scheme_url_selection(self, url_scheme): + """ + Validates that were not attempting to do TLS in TLS connections on + Python2 or with unsupported SSL implementations. + """ + if self.proxy is None or url_scheme != "https": + return + + if self.proxy.scheme != "https": + return + + if six.PY2 and not self.proxy_config.use_forwarding_for_https: + raise ProxySchemeUnsupported( + "Contacting HTTPS destinations through HTTPS proxies " + "'via CONNECT tunnels' is not supported in Python 2" + ) + + def urlopen(self, method, url, redirect=True, **kw): + """ + Same as :meth:`urllib3.HTTPConnectionPool.urlopen` + with custom cross-host redirect logic and only sends the request-uri + portion of the ``url``. + + The given ``url`` parameter must be absolute, such that an appropriate + :class:`urllib3.connectionpool.ConnectionPool` can be chosen for it. + """ + u = parse_url(url) + self._validate_proxy_scheme_url_selection(u.scheme) + + conn = self.connection_from_host(u.host, port=u.port, scheme=u.scheme) + + kw["assert_same_host"] = False + kw["redirect"] = False + + if "headers" not in kw: + kw["headers"] = self.headers.copy() + + if self._proxy_requires_url_absolute_form(u): + response = conn.urlopen(method, url, **kw) + else: + response = conn.urlopen(method, u.request_uri, **kw) + + redirect_location = redirect and response.get_redirect_location() + if not redirect_location: + return response + + # Support relative URLs for redirecting. + redirect_location = urljoin(url, redirect_location) + + # RFC 7231, Section 6.4.4 + if response.status == 303: + method = "GET" + + retries = kw.get("retries") + if not isinstance(retries, Retry): + retries = Retry.from_int(retries, redirect=redirect) + + # Strip headers marked as unsafe to forward to the redirected location. + # Check remove_headers_on_redirect to avoid a potential network call within + # conn.is_same_host() which may use socket.gethostbyname() in the future. + if retries.remove_headers_on_redirect and not conn.is_same_host( + redirect_location + ): + headers = list(six.iterkeys(kw["headers"])) + for header in headers: + if header.lower() in retries.remove_headers_on_redirect: + kw["headers"].pop(header, None) + + try: + retries = retries.increment(method, url, response=response, _pool=conn) + except MaxRetryError: + if retries.raise_on_redirect: + response.drain_conn() + raise + return response + + kw["retries"] = retries + kw["redirect"] = redirect + + log.info("Redirecting %s -> %s", url, redirect_location) + + response.drain_conn() + return self.urlopen(method, redirect_location, **kw) + + +class ProxyManager(PoolManager): + """ + Behaves just like :class:`PoolManager`, but sends all requests through + the defined proxy, using the CONNECT method for HTTPS URLs. + + :param proxy_url: + The URL of the proxy to be used. + + :param proxy_headers: + A dictionary containing headers that will be sent to the proxy. In case + of HTTP they are being sent with each request, while in the + HTTPS/CONNECT case they are sent only once. Could be used for proxy + authentication. + + :param proxy_ssl_context: + The proxy SSL context is used to establish the TLS connection to the + proxy when using HTTPS proxies. + + :param use_forwarding_for_https: + (Defaults to False) If set to True will forward requests to the HTTPS + proxy to be made on behalf of the client instead of creating a TLS + tunnel via the CONNECT method. **Enabling this flag means that request + and response headers and content will be visible from the HTTPS proxy** + whereas tunneling keeps request and response headers and content + private. IP address, target hostname, SNI, and port are always visible + to an HTTPS proxy even when this flag is disabled. + + Example: + >>> proxy = urllib3.ProxyManager('http://localhost:3128/') + >>> r1 = proxy.request('GET', 'http://google.com/') + >>> r2 = proxy.request('GET', 'http://httpbin.org/') + >>> len(proxy.pools) + 1 + >>> r3 = proxy.request('GET', 'https://httpbin.org/') + >>> r4 = proxy.request('GET', 'https://twitter.com/') + >>> len(proxy.pools) + 3 + + """ + + def __init__( + self, + proxy_url, + num_pools=10, + headers=None, + proxy_headers=None, + proxy_ssl_context=None, + use_forwarding_for_https=False, + **connection_pool_kw + ): + + if isinstance(proxy_url, HTTPConnectionPool): + proxy_url = "%s://%s:%i" % ( + proxy_url.scheme, + proxy_url.host, + proxy_url.port, + ) + proxy = parse_url(proxy_url) + + if proxy.scheme not in ("http", "https"): + raise ProxySchemeUnknown(proxy.scheme) + + if not proxy.port: + port = port_by_scheme.get(proxy.scheme, 80) + proxy = proxy._replace(port=port) + + self.proxy = proxy + self.proxy_headers = proxy_headers or {} + self.proxy_ssl_context = proxy_ssl_context + self.proxy_config = ProxyConfig(proxy_ssl_context, use_forwarding_for_https) + + connection_pool_kw["_proxy"] = self.proxy + connection_pool_kw["_proxy_headers"] = self.proxy_headers + connection_pool_kw["_proxy_config"] = self.proxy_config + + super(ProxyManager, self).__init__(num_pools, headers, **connection_pool_kw) + + def connection_from_host(self, host, port=None, scheme="http", pool_kwargs=None): + if scheme == "https": + return super(ProxyManager, self).connection_from_host( + host, port, scheme, pool_kwargs=pool_kwargs + ) + + return super(ProxyManager, self).connection_from_host( + self.proxy.host, self.proxy.port, self.proxy.scheme, pool_kwargs=pool_kwargs + ) + + def _set_proxy_headers(self, url, headers=None): + """ + Sets headers needed by proxies: specifically, the Accept and Host + headers. Only sets headers not provided by the user. + """ + headers_ = {"Accept": "*/*"} + + netloc = parse_url(url).netloc + if netloc: + headers_["Host"] = netloc + + if headers: + headers_.update(headers) + return headers_ + + def urlopen(self, method, url, redirect=True, **kw): + "Same as HTTP(S)ConnectionPool.urlopen, ``url`` must be absolute." + u = parse_url(url) + if not connection_requires_http_tunnel(self.proxy, self.proxy_config, u.scheme): + # For connections using HTTP CONNECT, httplib sets the necessary + # headers on the CONNECT to the proxy. If we're not using CONNECT, + # we'll definitely need to set 'Host' at the very least. + headers = kw.get("headers", self.headers) + kw["headers"] = self._set_proxy_headers(url, headers) + + return super(ProxyManager, self).urlopen(method, url, redirect=redirect, **kw) + + +def proxy_from_url(url, **kw): + return ProxyManager(proxy_url=url, **kw) diff --git a/openpype/hosts/fusion/vendor/urllib3/request.py b/openpype/hosts/fusion/vendor/urllib3/request.py new file mode 100644 index 0000000000..398386a5b9 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/request.py @@ -0,0 +1,170 @@ +from __future__ import absolute_import + +from .filepost import encode_multipart_formdata +from .packages.six.moves.urllib.parse import urlencode + +__all__ = ["RequestMethods"] + + +class RequestMethods(object): + """ + Convenience mixin for classes who implement a :meth:`urlopen` method, such + as :class:`urllib3.HTTPConnectionPool` and + :class:`urllib3.PoolManager`. + + Provides behavior for making common types of HTTP request methods and + decides which type of request field encoding to use. + + Specifically, + + :meth:`.request_encode_url` is for sending requests whose fields are + encoded in the URL (such as GET, HEAD, DELETE). + + :meth:`.request_encode_body` is for sending requests whose fields are + encoded in the *body* of the request using multipart or www-form-urlencoded + (such as for POST, PUT, PATCH). + + :meth:`.request` is for making any kind of request, it will look up the + appropriate encoding format and use one of the above two methods to make + the request. + + Initializer parameters: + + :param headers: + Headers to include with all requests, unless other headers are given + explicitly. + """ + + _encode_url_methods = {"DELETE", "GET", "HEAD", "OPTIONS"} + + def __init__(self, headers=None): + self.headers = headers or {} + + def urlopen( + self, + method, + url, + body=None, + headers=None, + encode_multipart=True, + multipart_boundary=None, + **kw + ): # Abstract + raise NotImplementedError( + "Classes extending RequestMethods must implement " + "their own ``urlopen`` method." + ) + + def request(self, method, url, fields=None, headers=None, **urlopen_kw): + """ + Make a request using :meth:`urlopen` with the appropriate encoding of + ``fields`` based on the ``method`` used. + + This is a convenience method that requires the least amount of manual + effort. It can be used in most situations, while still having the + option to drop down to more specific methods when necessary, such as + :meth:`request_encode_url`, :meth:`request_encode_body`, + or even the lowest level :meth:`urlopen`. + """ + method = method.upper() + + urlopen_kw["request_url"] = url + + if method in self._encode_url_methods: + return self.request_encode_url( + method, url, fields=fields, headers=headers, **urlopen_kw + ) + else: + return self.request_encode_body( + method, url, fields=fields, headers=headers, **urlopen_kw + ) + + def request_encode_url(self, method, url, fields=None, headers=None, **urlopen_kw): + """ + Make a request using :meth:`urlopen` with the ``fields`` encoded in + the url. This is useful for request methods like GET, HEAD, DELETE, etc. + """ + if headers is None: + headers = self.headers + + extra_kw = {"headers": headers} + extra_kw.update(urlopen_kw) + + if fields: + url += "?" + urlencode(fields) + + return self.urlopen(method, url, **extra_kw) + + def request_encode_body( + self, + method, + url, + fields=None, + headers=None, + encode_multipart=True, + multipart_boundary=None, + **urlopen_kw + ): + """ + Make a request using :meth:`urlopen` with the ``fields`` encoded in + the body. This is useful for request methods like POST, PUT, PATCH, etc. + + When ``encode_multipart=True`` (default), then + :func:`urllib3.encode_multipart_formdata` is used to encode + the payload with the appropriate content type. Otherwise + :func:`urllib.parse.urlencode` is used with the + 'application/x-www-form-urlencoded' content type. + + Multipart encoding must be used when posting files, and it's reasonably + safe to use it in other times too. However, it may break request + signing, such as with OAuth. + + Supports an optional ``fields`` parameter of key/value strings AND + key/filetuple. A filetuple is a (filename, data, MIME type) tuple where + the MIME type is optional. For example:: + + fields = { + 'foo': 'bar', + 'fakefile': ('foofile.txt', 'contents of foofile'), + 'realfile': ('barfile.txt', open('realfile').read()), + 'typedfile': ('bazfile.bin', open('bazfile').read(), + 'image/jpeg'), + 'nonamefile': 'contents of nonamefile field', + } + + When uploading a file, providing a filename (the first parameter of the + tuple) is optional but recommended to best mimic behavior of browsers. + + Note that if ``headers`` are supplied, the 'Content-Type' header will + be overwritten because it depends on the dynamic random boundary string + which is used to compose the body of the request. The random boundary + string can be explicitly set with the ``multipart_boundary`` parameter. + """ + if headers is None: + headers = self.headers + + extra_kw = {"headers": {}} + + if fields: + if "body" in urlopen_kw: + raise TypeError( + "request got values for both 'fields' and 'body', can only specify one." + ) + + if encode_multipart: + body, content_type = encode_multipart_formdata( + fields, boundary=multipart_boundary + ) + else: + body, content_type = ( + urlencode(fields), + "application/x-www-form-urlencoded", + ) + + extra_kw["body"] = body + extra_kw["headers"] = {"Content-Type": content_type} + + extra_kw["headers"].update(headers) + extra_kw.update(urlopen_kw) + + return self.urlopen(method, url, **extra_kw) diff --git a/openpype/hosts/fusion/vendor/urllib3/response.py b/openpype/hosts/fusion/vendor/urllib3/response.py new file mode 100644 index 0000000000..38693f4fc6 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/response.py @@ -0,0 +1,821 @@ +from __future__ import absolute_import + +import io +import logging +import zlib +from contextlib import contextmanager +from socket import error as SocketError +from socket import timeout as SocketTimeout + +try: + import brotli +except ImportError: + brotli = None + +from ._collections import HTTPHeaderDict +from .connection import BaseSSLError, HTTPException +from .exceptions import ( + BodyNotHttplibCompatible, + DecodeError, + HTTPError, + IncompleteRead, + InvalidChunkLength, + InvalidHeader, + ProtocolError, + ReadTimeoutError, + ResponseNotChunked, + SSLError, +) +from .packages import six +from .util.response import is_fp_closed, is_response_to_head + +log = logging.getLogger(__name__) + + +class DeflateDecoder(object): + def __init__(self): + self._first_try = True + self._data = b"" + self._obj = zlib.decompressobj() + + def __getattr__(self, name): + return getattr(self._obj, name) + + def decompress(self, data): + if not data: + return data + + if not self._first_try: + return self._obj.decompress(data) + + self._data += data + try: + decompressed = self._obj.decompress(data) + if decompressed: + self._first_try = False + self._data = None + return decompressed + except zlib.error: + self._first_try = False + self._obj = zlib.decompressobj(-zlib.MAX_WBITS) + try: + return self.decompress(self._data) + finally: + self._data = None + + +class GzipDecoderState(object): + + FIRST_MEMBER = 0 + OTHER_MEMBERS = 1 + SWALLOW_DATA = 2 + + +class GzipDecoder(object): + def __init__(self): + self._obj = zlib.decompressobj(16 + zlib.MAX_WBITS) + self._state = GzipDecoderState.FIRST_MEMBER + + def __getattr__(self, name): + return getattr(self._obj, name) + + def decompress(self, data): + ret = bytearray() + if self._state == GzipDecoderState.SWALLOW_DATA or not data: + return bytes(ret) + while True: + try: + ret += self._obj.decompress(data) + except zlib.error: + previous_state = self._state + # Ignore data after the first error + self._state = GzipDecoderState.SWALLOW_DATA + if previous_state == GzipDecoderState.OTHER_MEMBERS: + # Allow trailing garbage acceptable in other gzip clients + return bytes(ret) + raise + data = self._obj.unused_data + if not data: + return bytes(ret) + self._state = GzipDecoderState.OTHER_MEMBERS + self._obj = zlib.decompressobj(16 + zlib.MAX_WBITS) + + +if brotli is not None: + + class BrotliDecoder(object): + # Supports both 'brotlipy' and 'Brotli' packages + # since they share an import name. The top branches + # are for 'brotlipy' and bottom branches for 'Brotli' + def __init__(self): + self._obj = brotli.Decompressor() + if hasattr(self._obj, "decompress"): + self.decompress = self._obj.decompress + else: + self.decompress = self._obj.process + + def flush(self): + if hasattr(self._obj, "flush"): + return self._obj.flush() + return b"" + + +class MultiDecoder(object): + """ + From RFC7231: + If one or more encodings have been applied to a representation, the + sender that applied the encodings MUST generate a Content-Encoding + header field that lists the content codings in the order in which + they were applied. + """ + + def __init__(self, modes): + self._decoders = [_get_decoder(m.strip()) for m in modes.split(",")] + + def flush(self): + return self._decoders[0].flush() + + def decompress(self, data): + for d in reversed(self._decoders): + data = d.decompress(data) + return data + + +def _get_decoder(mode): + if "," in mode: + return MultiDecoder(mode) + + if mode == "gzip": + return GzipDecoder() + + if brotli is not None and mode == "br": + return BrotliDecoder() + + return DeflateDecoder() + + +class HTTPResponse(io.IOBase): + """ + HTTP Response container. + + Backwards-compatible with :class:`http.client.HTTPResponse` but the response ``body`` is + loaded and decoded on-demand when the ``data`` property is accessed. This + class is also compatible with the Python standard library's :mod:`io` + module, and can hence be treated as a readable object in the context of that + framework. + + Extra parameters for behaviour not present in :class:`http.client.HTTPResponse`: + + :param preload_content: + If True, the response's body will be preloaded during construction. + + :param decode_content: + If True, will attempt to decode the body based on the + 'content-encoding' header. + + :param original_response: + When this HTTPResponse wrapper is generated from an :class:`http.client.HTTPResponse` + object, it's convenient to include the original for debug purposes. It's + otherwise unused. + + :param retries: + The retries contains the last :class:`~urllib3.util.retry.Retry` that + was used during the request. + + :param enforce_content_length: + Enforce content length checking. Body returned by server must match + value of Content-Length header, if present. Otherwise, raise error. + """ + + CONTENT_DECODERS = ["gzip", "deflate"] + if brotli is not None: + CONTENT_DECODERS += ["br"] + REDIRECT_STATUSES = [301, 302, 303, 307, 308] + + def __init__( + self, + body="", + headers=None, + status=0, + version=0, + reason=None, + strict=0, + preload_content=True, + decode_content=True, + original_response=None, + pool=None, + connection=None, + msg=None, + retries=None, + enforce_content_length=False, + request_method=None, + request_url=None, + auto_close=True, + ): + + if isinstance(headers, HTTPHeaderDict): + self.headers = headers + else: + self.headers = HTTPHeaderDict(headers) + self.status = status + self.version = version + self.reason = reason + self.strict = strict + self.decode_content = decode_content + self.retries = retries + self.enforce_content_length = enforce_content_length + self.auto_close = auto_close + + self._decoder = None + self._body = None + self._fp = None + self._original_response = original_response + self._fp_bytes_read = 0 + self.msg = msg + self._request_url = request_url + + if body and isinstance(body, (six.string_types, bytes)): + self._body = body + + self._pool = pool + self._connection = connection + + if hasattr(body, "read"): + self._fp = body + + # Are we using the chunked-style of transfer encoding? + self.chunked = False + self.chunk_left = None + tr_enc = self.headers.get("transfer-encoding", "").lower() + # Don't incur the penalty of creating a list and then discarding it + encodings = (enc.strip() for enc in tr_enc.split(",")) + if "chunked" in encodings: + self.chunked = True + + # Determine length of response + self.length_remaining = self._init_length(request_method) + + # If requested, preload the body. + if preload_content and not self._body: + self._body = self.read(decode_content=decode_content) + + def get_redirect_location(self): + """ + Should we redirect and where to? + + :returns: Truthy redirect location string if we got a redirect status + code and valid location. ``None`` if redirect status and no + location. ``False`` if not a redirect status code. + """ + if self.status in self.REDIRECT_STATUSES: + return self.headers.get("location") + + return False + + def release_conn(self): + if not self._pool or not self._connection: + return + + self._pool._put_conn(self._connection) + self._connection = None + + def drain_conn(self): + """ + Read and discard any remaining HTTP response data in the response connection. + + Unread data in the HTTPResponse connection blocks the connection from being released back to the pool. + """ + try: + self.read() + except (HTTPError, SocketError, BaseSSLError, HTTPException): + pass + + @property + def data(self): + # For backwards-compat with earlier urllib3 0.4 and earlier. + if self._body: + return self._body + + if self._fp: + return self.read(cache_content=True) + + @property + def connection(self): + return self._connection + + def isclosed(self): + return is_fp_closed(self._fp) + + def tell(self): + """ + Obtain the number of bytes pulled over the wire so far. May differ from + the amount of content returned by :meth:``urllib3.response.HTTPResponse.read`` + if bytes are encoded on the wire (e.g, compressed). + """ + return self._fp_bytes_read + + def _init_length(self, request_method): + """ + Set initial length value for Response content if available. + """ + length = self.headers.get("content-length") + + if length is not None: + if self.chunked: + # This Response will fail with an IncompleteRead if it can't be + # received as chunked. This method falls back to attempt reading + # the response before raising an exception. + log.warning( + "Received response with both Content-Length and " + "Transfer-Encoding set. This is expressly forbidden " + "by RFC 7230 sec 3.3.2. Ignoring Content-Length and " + "attempting to process response as Transfer-Encoding: " + "chunked." + ) + return None + + try: + # RFC 7230 section 3.3.2 specifies multiple content lengths can + # be sent in a single Content-Length header + # (e.g. Content-Length: 42, 42). This line ensures the values + # are all valid ints and that as long as the `set` length is 1, + # all values are the same. Otherwise, the header is invalid. + lengths = set([int(val) for val in length.split(",")]) + if len(lengths) > 1: + raise InvalidHeader( + "Content-Length contained multiple " + "unmatching values (%s)" % length + ) + length = lengths.pop() + except ValueError: + length = None + else: + if length < 0: + length = None + + # Convert status to int for comparison + # In some cases, httplib returns a status of "_UNKNOWN" + try: + status = int(self.status) + except ValueError: + status = 0 + + # Check for responses that shouldn't include a body + if status in (204, 304) or 100 <= status < 200 or request_method == "HEAD": + length = 0 + + return length + + def _init_decoder(self): + """ + Set-up the _decoder attribute if necessary. + """ + # Note: content-encoding value should be case-insensitive, per RFC 7230 + # Section 3.2 + content_encoding = self.headers.get("content-encoding", "").lower() + if self._decoder is None: + if content_encoding in self.CONTENT_DECODERS: + self._decoder = _get_decoder(content_encoding) + elif "," in content_encoding: + encodings = [ + e.strip() + for e in content_encoding.split(",") + if e.strip() in self.CONTENT_DECODERS + ] + if len(encodings): + self._decoder = _get_decoder(content_encoding) + + DECODER_ERROR_CLASSES = (IOError, zlib.error) + if brotli is not None: + DECODER_ERROR_CLASSES += (brotli.error,) + + def _decode(self, data, decode_content, flush_decoder): + """ + Decode the data passed in and potentially flush the decoder. + """ + if not decode_content: + return data + + try: + if self._decoder: + data = self._decoder.decompress(data) + except self.DECODER_ERROR_CLASSES as e: + content_encoding = self.headers.get("content-encoding", "").lower() + raise DecodeError( + "Received response with content-encoding: %s, but " + "failed to decode it." % content_encoding, + e, + ) + if flush_decoder: + data += self._flush_decoder() + + return data + + def _flush_decoder(self): + """ + Flushes the decoder. Should only be called if the decoder is actually + being used. + """ + if self._decoder: + buf = self._decoder.decompress(b"") + return buf + self._decoder.flush() + + return b"" + + @contextmanager + def _error_catcher(self): + """ + Catch low-level python exceptions, instead re-raising urllib3 + variants, so that low-level exceptions are not leaked in the + high-level api. + + On exit, release the connection back to the pool. + """ + clean_exit = False + + try: + try: + yield + + except SocketTimeout: + # FIXME: Ideally we'd like to include the url in the ReadTimeoutError but + # there is yet no clean way to get at it from this context. + raise ReadTimeoutError(self._pool, None, "Read timed out.") + + except BaseSSLError as e: + # FIXME: Is there a better way to differentiate between SSLErrors? + if "read operation timed out" not in str(e): + # SSL errors related to framing/MAC get wrapped and reraised here + raise SSLError(e) + + raise ReadTimeoutError(self._pool, None, "Read timed out.") + + except (HTTPException, SocketError) as e: + # This includes IncompleteRead. + raise ProtocolError("Connection broken: %r" % e, e) + + # If no exception is thrown, we should avoid cleaning up + # unnecessarily. + clean_exit = True + finally: + # If we didn't terminate cleanly, we need to throw away our + # connection. + if not clean_exit: + # The response may not be closed but we're not going to use it + # anymore so close it now to ensure that the connection is + # released back to the pool. + if self._original_response: + self._original_response.close() + + # Closing the response may not actually be sufficient to close + # everything, so if we have a hold of the connection close that + # too. + if self._connection: + self._connection.close() + + # If we hold the original response but it's closed now, we should + # return the connection back to the pool. + if self._original_response and self._original_response.isclosed(): + self.release_conn() + + def read(self, amt=None, decode_content=None, cache_content=False): + """ + Similar to :meth:`http.client.HTTPResponse.read`, but with two additional + parameters: ``decode_content`` and ``cache_content``. + + :param amt: + How much of the content to read. If specified, caching is skipped + because it doesn't make sense to cache partial content as the full + response. + + :param decode_content: + If True, will attempt to decode the body based on the + 'content-encoding' header. + + :param cache_content: + If True, will save the returned data such that the same result is + returned despite of the state of the underlying file object. This + is useful if you want the ``.data`` property to continue working + after having ``.read()`` the file object. (Overridden if ``amt`` is + set.) + """ + self._init_decoder() + if decode_content is None: + decode_content = self.decode_content + + if self._fp is None: + return + + flush_decoder = False + fp_closed = getattr(self._fp, "closed", False) + + with self._error_catcher(): + if amt is None: + # cStringIO doesn't like amt=None + data = self._fp.read() if not fp_closed else b"" + flush_decoder = True + else: + cache_content = False + data = self._fp.read(amt) if not fp_closed else b"" + if ( + amt != 0 and not data + ): # Platform-specific: Buggy versions of Python. + # Close the connection when no data is returned + # + # This is redundant to what httplib/http.client _should_ + # already do. However, versions of python released before + # December 15, 2012 (http://bugs.python.org/issue16298) do + # not properly close the connection in all cases. There is + # no harm in redundantly calling close. + self._fp.close() + flush_decoder = True + if self.enforce_content_length and self.length_remaining not in ( + 0, + None, + ): + # This is an edge case that httplib failed to cover due + # to concerns of backward compatibility. We're + # addressing it here to make sure IncompleteRead is + # raised during streaming, so all calls with incorrect + # Content-Length are caught. + raise IncompleteRead(self._fp_bytes_read, self.length_remaining) + + if data: + self._fp_bytes_read += len(data) + if self.length_remaining is not None: + self.length_remaining -= len(data) + + data = self._decode(data, decode_content, flush_decoder) + + if cache_content: + self._body = data + + return data + + def stream(self, amt=2 ** 16, decode_content=None): + """ + A generator wrapper for the read() method. A call will block until + ``amt`` bytes have been read from the connection or until the + connection is closed. + + :param amt: + How much of the content to read. The generator will return up to + much data per iteration, but may return less. This is particularly + likely when using compressed data. However, the empty string will + never be returned. + + :param decode_content: + If True, will attempt to decode the body based on the + 'content-encoding' header. + """ + if self.chunked and self.supports_chunked_reads(): + for line in self.read_chunked(amt, decode_content=decode_content): + yield line + else: + while not is_fp_closed(self._fp): + data = self.read(amt=amt, decode_content=decode_content) + + if data: + yield data + + @classmethod + def from_httplib(ResponseCls, r, **response_kw): + """ + Given an :class:`http.client.HTTPResponse` instance ``r``, return a + corresponding :class:`urllib3.response.HTTPResponse` object. + + Remaining parameters are passed to the HTTPResponse constructor, along + with ``original_response=r``. + """ + headers = r.msg + + if not isinstance(headers, HTTPHeaderDict): + if six.PY2: + # Python 2.7 + headers = HTTPHeaderDict.from_httplib(headers) + else: + headers = HTTPHeaderDict(headers.items()) + + # HTTPResponse objects in Python 3 don't have a .strict attribute + strict = getattr(r, "strict", 0) + resp = ResponseCls( + body=r, + headers=headers, + status=r.status, + version=r.version, + reason=r.reason, + strict=strict, + original_response=r, + **response_kw + ) + return resp + + # Backwards-compatibility methods for http.client.HTTPResponse + def getheaders(self): + return self.headers + + def getheader(self, name, default=None): + return self.headers.get(name, default) + + # Backwards compatibility for http.cookiejar + def info(self): + return self.headers + + # Overrides from io.IOBase + def close(self): + if not self.closed: + self._fp.close() + + if self._connection: + self._connection.close() + + if not self.auto_close: + io.IOBase.close(self) + + @property + def closed(self): + if not self.auto_close: + return io.IOBase.closed.__get__(self) + elif self._fp is None: + return True + elif hasattr(self._fp, "isclosed"): + return self._fp.isclosed() + elif hasattr(self._fp, "closed"): + return self._fp.closed + else: + return True + + def fileno(self): + if self._fp is None: + raise IOError("HTTPResponse has no file to get a fileno from") + elif hasattr(self._fp, "fileno"): + return self._fp.fileno() + else: + raise IOError( + "The file-like object this HTTPResponse is wrapped " + "around has no file descriptor" + ) + + def flush(self): + if ( + self._fp is not None + and hasattr(self._fp, "flush") + and not getattr(self._fp, "closed", False) + ): + return self._fp.flush() + + def readable(self): + # This method is required for `io` module compatibility. + return True + + def readinto(self, b): + # This method is required for `io` module compatibility. + temp = self.read(len(b)) + if len(temp) == 0: + return 0 + else: + b[: len(temp)] = temp + return len(temp) + + def supports_chunked_reads(self): + """ + Checks if the underlying file-like object looks like a + :class:`http.client.HTTPResponse` object. We do this by testing for + the fp attribute. If it is present we assume it returns raw chunks as + processed by read_chunked(). + """ + return hasattr(self._fp, "fp") + + def _update_chunk_length(self): + # First, we'll figure out length of a chunk and then + # we'll try to read it from socket. + if self.chunk_left is not None: + return + line = self._fp.fp.readline() + line = line.split(b";", 1)[0] + try: + self.chunk_left = int(line, 16) + except ValueError: + # Invalid chunked protocol response, abort. + self.close() + raise InvalidChunkLength(self, line) + + def _handle_chunk(self, amt): + returned_chunk = None + if amt is None: + chunk = self._fp._safe_read(self.chunk_left) + returned_chunk = chunk + self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. + self.chunk_left = None + elif amt < self.chunk_left: + value = self._fp._safe_read(amt) + self.chunk_left = self.chunk_left - amt + returned_chunk = value + elif amt == self.chunk_left: + value = self._fp._safe_read(amt) + self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. + self.chunk_left = None + returned_chunk = value + else: # amt > self.chunk_left + returned_chunk = self._fp._safe_read(self.chunk_left) + self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. + self.chunk_left = None + return returned_chunk + + def read_chunked(self, amt=None, decode_content=None): + """ + Similar to :meth:`HTTPResponse.read`, but with an additional + parameter: ``decode_content``. + + :param amt: + How much of the content to read. If specified, caching is skipped + because it doesn't make sense to cache partial content as the full + response. + + :param decode_content: + If True, will attempt to decode the body based on the + 'content-encoding' header. + """ + self._init_decoder() + # FIXME: Rewrite this method and make it a class with a better structured logic. + if not self.chunked: + raise ResponseNotChunked( + "Response is not chunked. " + "Header 'transfer-encoding: chunked' is missing." + ) + if not self.supports_chunked_reads(): + raise BodyNotHttplibCompatible( + "Body should be http.client.HTTPResponse like. " + "It should have have an fp attribute which returns raw chunks." + ) + + with self._error_catcher(): + # Don't bother reading the body of a HEAD request. + if self._original_response and is_response_to_head(self._original_response): + self._original_response.close() + return + + # If a response is already read and closed + # then return immediately. + if self._fp.fp is None: + return + + while True: + self._update_chunk_length() + if self.chunk_left == 0: + break + chunk = self._handle_chunk(amt) + decoded = self._decode( + chunk, decode_content=decode_content, flush_decoder=False + ) + if decoded: + yield decoded + + if decode_content: + # On CPython and PyPy, we should never need to flush the + # decoder. However, on Jython we *might* need to, so + # lets defensively do it anyway. + decoded = self._flush_decoder() + if decoded: # Platform-specific: Jython. + yield decoded + + # Chunk content ends with \r\n: discard it. + while True: + line = self._fp.fp.readline() + if not line: + # Some sites may not end with '\r\n'. + break + if line == b"\r\n": + break + + # We read everything; close the "file". + if self._original_response: + self._original_response.close() + + def geturl(self): + """ + Returns the URL that was the source of this response. + If the request that generated this response redirected, this method + will return the final redirect location. + """ + if self.retries is not None and len(self.retries.history): + return self.retries.history[-1].redirect_location + else: + return self._request_url + + def __iter__(self): + buffer = [] + for chunk in self.stream(decode_content=True): + if b"\n" in chunk: + chunk = chunk.split(b"\n") + yield b"".join(buffer) + chunk[0] + b"\n" + for x in chunk[1:-1]: + yield x + b"\n" + if chunk[-1]: + buffer = [chunk[-1]] + else: + buffer = [] + else: + buffer.append(chunk) + if buffer: + yield b"".join(buffer) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/__init__.py b/openpype/hosts/fusion/vendor/urllib3/util/__init__.py new file mode 100644 index 0000000000..4547fc522b --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/__init__.py @@ -0,0 +1,49 @@ +from __future__ import absolute_import + +# For backwards compatibility, provide imports that used to be here. +from .connection import is_connection_dropped +from .request import SKIP_HEADER, SKIPPABLE_HEADERS, make_headers +from .response import is_fp_closed +from .retry import Retry +from .ssl_ import ( + ALPN_PROTOCOLS, + HAS_SNI, + IS_PYOPENSSL, + IS_SECURETRANSPORT, + PROTOCOL_TLS, + SSLContext, + assert_fingerprint, + resolve_cert_reqs, + resolve_ssl_version, + ssl_wrap_socket, +) +from .timeout import Timeout, current_time +from .url import Url, get_host, parse_url, split_first +from .wait import wait_for_read, wait_for_write + +__all__ = ( + "HAS_SNI", + "IS_PYOPENSSL", + "IS_SECURETRANSPORT", + "SSLContext", + "PROTOCOL_TLS", + "ALPN_PROTOCOLS", + "Retry", + "Timeout", + "Url", + "assert_fingerprint", + "current_time", + "is_connection_dropped", + "is_fp_closed", + "get_host", + "parse_url", + "make_headers", + "resolve_cert_reqs", + "resolve_ssl_version", + "split_first", + "ssl_wrap_socket", + "wait_for_read", + "wait_for_write", + "SKIP_HEADER", + "SKIPPABLE_HEADERS", +) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/connection.py b/openpype/hosts/fusion/vendor/urllib3/util/connection.py new file mode 100644 index 0000000000..bdc240c50c --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/connection.py @@ -0,0 +1,150 @@ +from __future__ import absolute_import + +import socket + +from urllib3.exceptions import LocationParseError + +from ..contrib import _appengine_environ +from ..packages import six +from .wait import NoWayToWaitForSocketError, wait_for_read + + +def is_connection_dropped(conn): # Platform-specific + """ + Returns True if the connection is dropped and should be closed. + + :param conn: + :class:`http.client.HTTPConnection` object. + + Note: For platforms like AppEngine, this will always return ``False`` to + let the platform handle connection recycling transparently for us. + """ + sock = getattr(conn, "sock", False) + if sock is False: # Platform-specific: AppEngine + return False + if sock is None: # Connection already closed (such as by httplib). + return True + try: + # Returns True if readable, which here means it's been dropped + return wait_for_read(sock, timeout=0.0) + except NoWayToWaitForSocketError: # Platform-specific: AppEngine + return False + + +# This function is copied from socket.py in the Python 2.7 standard +# library test suite. Added to its signature is only `socket_options`. +# One additional modification is that we avoid binding to IPv6 servers +# discovered in DNS if the system doesn't have IPv6 functionality. +def create_connection( + address, + timeout=socket._GLOBAL_DEFAULT_TIMEOUT, + source_address=None, + socket_options=None, +): + """Connect to *address* and return the socket object. + + Convenience function. Connect to *address* (a 2-tuple ``(host, + port)``) and return the socket object. Passing the optional + *timeout* parameter will set the timeout on the socket instance + before attempting to connect. If no *timeout* is supplied, the + global default timeout setting returned by :func:`socket.getdefaulttimeout` + is used. If *source_address* is set it must be a tuple of (host, port) + for the socket to bind as a source address before making the connection. + An host of '' or port 0 tells the OS to use the default. + """ + + host, port = address + if host.startswith("["): + host = host.strip("[]") + err = None + + # Using the value from allowed_gai_family() in the context of getaddrinfo lets + # us select whether to work with IPv4 DNS records, IPv6 records, or both. + # The original create_connection function always returns all records. + family = allowed_gai_family() + + try: + host.encode("idna") + except UnicodeError: + return six.raise_from( + LocationParseError(u"'%s', label empty or too long" % host), None + ) + + for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + sock = None + try: + sock = socket.socket(af, socktype, proto) + + # If provided, set socket level options before connecting. + _set_socket_options(sock, socket_options) + + if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: + sock.settimeout(timeout) + if source_address: + sock.bind(source_address) + sock.connect(sa) + return sock + + except socket.error as e: + err = e + if sock is not None: + sock.close() + sock = None + + if err is not None: + raise err + + raise socket.error("getaddrinfo returns an empty list") + + +def _set_socket_options(sock, options): + if options is None: + return + + for opt in options: + sock.setsockopt(*opt) + + +def allowed_gai_family(): + """This function is designed to work in the context of + getaddrinfo, where family=socket.AF_UNSPEC is the default and + will perform a DNS search for both IPv6 and IPv4 records.""" + + family = socket.AF_INET + if HAS_IPV6: + family = socket.AF_UNSPEC + return family + + +def _has_ipv6(host): + """Returns True if the system can bind an IPv6 address.""" + sock = None + has_ipv6 = False + + # App Engine doesn't support IPV6 sockets and actually has a quota on the + # number of sockets that can be used, so just early out here instead of + # creating a socket needlessly. + # See https://github.com/urllib3/urllib3/issues/1446 + if _appengine_environ.is_appengine_sandbox(): + return False + + if socket.has_ipv6: + # has_ipv6 returns true if cPython was compiled with IPv6 support. + # It does not tell us if the system has IPv6 support enabled. To + # determine that we must bind to an IPv6 address. + # https://github.com/urllib3/urllib3/pull/611 + # https://bugs.python.org/issue658327 + try: + sock = socket.socket(socket.AF_INET6) + sock.bind((host, 0)) + has_ipv6 = True + except Exception: + pass + + if sock: + sock.close() + return has_ipv6 + + +HAS_IPV6 = _has_ipv6("::1") diff --git a/openpype/hosts/fusion/vendor/urllib3/util/proxy.py b/openpype/hosts/fusion/vendor/urllib3/util/proxy.py new file mode 100644 index 0000000000..34f884d5b3 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/proxy.py @@ -0,0 +1,56 @@ +from .ssl_ import create_urllib3_context, resolve_cert_reqs, resolve_ssl_version + + +def connection_requires_http_tunnel( + proxy_url=None, proxy_config=None, destination_scheme=None +): + """ + Returns True if the connection requires an HTTP CONNECT through the proxy. + + :param URL proxy_url: + URL of the proxy. + :param ProxyConfig proxy_config: + Proxy configuration from poolmanager.py + :param str destination_scheme: + The scheme of the destination. (i.e https, http, etc) + """ + # If we're not using a proxy, no way to use a tunnel. + if proxy_url is None: + return False + + # HTTP destinations never require tunneling, we always forward. + if destination_scheme == "http": + return False + + # Support for forwarding with HTTPS proxies and HTTPS destinations. + if ( + proxy_url.scheme == "https" + and proxy_config + and proxy_config.use_forwarding_for_https + ): + return False + + # Otherwise always use a tunnel. + return True + + +def create_proxy_ssl_context( + ssl_version, cert_reqs, ca_certs=None, ca_cert_dir=None, ca_cert_data=None +): + """ + Generates a default proxy ssl context if one hasn't been provided by the + user. + """ + ssl_context = create_urllib3_context( + ssl_version=resolve_ssl_version(ssl_version), + cert_reqs=resolve_cert_reqs(cert_reqs), + ) + if ( + not ca_certs + and not ca_cert_dir + and not ca_cert_data + and hasattr(ssl_context, "load_default_certs") + ): + ssl_context.load_default_certs() + + return ssl_context diff --git a/openpype/hosts/fusion/vendor/urllib3/util/queue.py b/openpype/hosts/fusion/vendor/urllib3/util/queue.py new file mode 100644 index 0000000000..41784104ee --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/queue.py @@ -0,0 +1,22 @@ +import collections + +from ..packages import six +from ..packages.six.moves import queue + +if six.PY2: + # Queue is imported for side effects on MS Windows. See issue #229. + import Queue as _unused_module_Queue # noqa: F401 + + +class LifoQueue(queue.Queue): + def _init(self, _): + self.queue = collections.deque() + + def _qsize(self, len=len): + return len(self.queue) + + def _put(self, item): + self.queue.append(item) + + def _get(self): + return self.queue.pop() diff --git a/openpype/hosts/fusion/vendor/urllib3/util/request.py b/openpype/hosts/fusion/vendor/urllib3/util/request.py new file mode 100644 index 0000000000..25103383ec --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/request.py @@ -0,0 +1,143 @@ +from __future__ import absolute_import + +from base64 import b64encode + +from ..exceptions import UnrewindableBodyError +from ..packages.six import b, integer_types + +# Pass as a value within ``headers`` to skip +# emitting some HTTP headers that are added automatically. +# The only headers that are supported are ``Accept-Encoding``, +# ``Host``, and ``User-Agent``. +SKIP_HEADER = "@@@SKIP_HEADER@@@" +SKIPPABLE_HEADERS = frozenset(["accept-encoding", "host", "user-agent"]) + +ACCEPT_ENCODING = "gzip,deflate" +try: + import brotli as _unused_module_brotli # noqa: F401 +except ImportError: + pass +else: + ACCEPT_ENCODING += ",br" + +_FAILEDTELL = object() + + +def make_headers( + keep_alive=None, + accept_encoding=None, + user_agent=None, + basic_auth=None, + proxy_basic_auth=None, + disable_cache=None, +): + """ + Shortcuts for generating request headers. + + :param keep_alive: + If ``True``, adds 'connection: keep-alive' header. + + :param accept_encoding: + Can be a boolean, list, or string. + ``True`` translates to 'gzip,deflate'. + List will get joined by comma. + String will be used as provided. + + :param user_agent: + String representing the user-agent you want, such as + "python-urllib3/0.6" + + :param basic_auth: + Colon-separated username:password string for 'authorization: basic ...' + auth header. + + :param proxy_basic_auth: + Colon-separated username:password string for 'proxy-authorization: basic ...' + auth header. + + :param disable_cache: + If ``True``, adds 'cache-control: no-cache' header. + + Example:: + + >>> make_headers(keep_alive=True, user_agent="Batman/1.0") + {'connection': 'keep-alive', 'user-agent': 'Batman/1.0'} + >>> make_headers(accept_encoding=True) + {'accept-encoding': 'gzip,deflate'} + """ + headers = {} + if accept_encoding: + if isinstance(accept_encoding, str): + pass + elif isinstance(accept_encoding, list): + accept_encoding = ",".join(accept_encoding) + else: + accept_encoding = ACCEPT_ENCODING + headers["accept-encoding"] = accept_encoding + + if user_agent: + headers["user-agent"] = user_agent + + if keep_alive: + headers["connection"] = "keep-alive" + + if basic_auth: + headers["authorization"] = "Basic " + b64encode(b(basic_auth)).decode("utf-8") + + if proxy_basic_auth: + headers["proxy-authorization"] = "Basic " + b64encode( + b(proxy_basic_auth) + ).decode("utf-8") + + if disable_cache: + headers["cache-control"] = "no-cache" + + return headers + + +def set_file_position(body, pos): + """ + If a position is provided, move file to that point. + Otherwise, we'll attempt to record a position for future use. + """ + if pos is not None: + rewind_body(body, pos) + elif getattr(body, "tell", None) is not None: + try: + pos = body.tell() + except (IOError, OSError): + # This differentiates from None, allowing us to catch + # a failed `tell()` later when trying to rewind the body. + pos = _FAILEDTELL + + return pos + + +def rewind_body(body, body_pos): + """ + Attempt to rewind body to a certain position. + Primarily used for request redirects and retries. + + :param body: + File-like object that supports seek. + + :param int pos: + Position to seek to in file. + """ + body_seek = getattr(body, "seek", None) + if body_seek is not None and isinstance(body_pos, integer_types): + try: + body_seek(body_pos) + except (IOError, OSError): + raise UnrewindableBodyError( + "An error occurred when rewinding request body for redirect/retry." + ) + elif body_pos is _FAILEDTELL: + raise UnrewindableBodyError( + "Unable to record file position for rewinding " + "request body during a redirect/retry." + ) + else: + raise ValueError( + "body_pos must be of type integer, instead it was %s." % type(body_pos) + ) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/response.py b/openpype/hosts/fusion/vendor/urllib3/util/response.py new file mode 100644 index 0000000000..5ea609cced --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/response.py @@ -0,0 +1,107 @@ +from __future__ import absolute_import + +from email.errors import MultipartInvariantViolationDefect, StartBoundaryNotFoundDefect + +from ..exceptions import HeaderParsingError +from ..packages.six.moves import http_client as httplib + + +def is_fp_closed(obj): + """ + Checks whether a given file-like object is closed. + + :param obj: + The file-like object to check. + """ + + try: + # Check `isclosed()` first, in case Python3 doesn't set `closed`. + # GH Issue #928 + return obj.isclosed() + except AttributeError: + pass + + try: + # Check via the official file-like-object way. + return obj.closed + except AttributeError: + pass + + try: + # Check if the object is a container for another file-like object that + # gets released on exhaustion (e.g. HTTPResponse). + return obj.fp is None + except AttributeError: + pass + + raise ValueError("Unable to determine whether fp is closed.") + + +def assert_header_parsing(headers): + """ + Asserts whether all headers have been successfully parsed. + Extracts encountered errors from the result of parsing headers. + + Only works on Python 3. + + :param http.client.HTTPMessage headers: Headers to verify. + + :raises urllib3.exceptions.HeaderParsingError: + If parsing errors are found. + """ + + # This will fail silently if we pass in the wrong kind of parameter. + # To make debugging easier add an explicit check. + if not isinstance(headers, httplib.HTTPMessage): + raise TypeError("expected httplib.Message, got {0}.".format(type(headers))) + + defects = getattr(headers, "defects", None) + get_payload = getattr(headers, "get_payload", None) + + unparsed_data = None + if get_payload: + # get_payload is actually email.message.Message.get_payload; + # we're only interested in the result if it's not a multipart message + if not headers.is_multipart(): + payload = get_payload() + + if isinstance(payload, (bytes, str)): + unparsed_data = payload + if defects: + # httplib is assuming a response body is available + # when parsing headers even when httplib only sends + # header data to parse_headers() This results in + # defects on multipart responses in particular. + # See: https://github.com/urllib3/urllib3/issues/800 + + # So we ignore the following defects: + # - StartBoundaryNotFoundDefect: + # The claimed start boundary was never found. + # - MultipartInvariantViolationDefect: + # A message claimed to be a multipart but no subparts were found. + defects = [ + defect + for defect in defects + if not isinstance( + defect, (StartBoundaryNotFoundDefect, MultipartInvariantViolationDefect) + ) + ] + + if defects or unparsed_data: + raise HeaderParsingError(defects=defects, unparsed_data=unparsed_data) + + +def is_response_to_head(response): + """ + Checks whether the request of a response has been a HEAD-request. + Handles the quirks of AppEngine. + + :param http.client.HTTPResponse response: + Response to check if the originating request + used 'HEAD' as a method. + """ + # FIXME: Can we do this somehow without accessing private httplib _method? + method = response._method + if isinstance(method, int): # Platform-specific: Appengine + return method == 3 + return method.upper() == "HEAD" diff --git a/openpype/hosts/fusion/vendor/urllib3/util/retry.py b/openpype/hosts/fusion/vendor/urllib3/util/retry.py new file mode 100644 index 0000000000..c7dc42f1d6 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/retry.py @@ -0,0 +1,602 @@ +from __future__ import absolute_import + +import email +import logging +import re +import time +import warnings +from collections import namedtuple +from itertools import takewhile + +from ..exceptions import ( + ConnectTimeoutError, + InvalidHeader, + MaxRetryError, + ProtocolError, + ProxyError, + ReadTimeoutError, + ResponseError, +) +from ..packages import six + +log = logging.getLogger(__name__) + + +# Data structure for representing the metadata of requests that result in a retry. +RequestHistory = namedtuple( + "RequestHistory", ["method", "url", "error", "status", "redirect_location"] +) + + +# TODO: In v2 we can remove this sentinel and metaclass with deprecated options. +_Default = object() + + +class _RetryMeta(type): + @property + def DEFAULT_METHOD_WHITELIST(cls): + warnings.warn( + "Using 'Retry.DEFAULT_METHOD_WHITELIST' is deprecated and " + "will be removed in v2.0. Use 'Retry.DEFAULT_ALLOWED_METHODS' instead", + DeprecationWarning, + ) + return cls.DEFAULT_ALLOWED_METHODS + + @DEFAULT_METHOD_WHITELIST.setter + def DEFAULT_METHOD_WHITELIST(cls, value): + warnings.warn( + "Using 'Retry.DEFAULT_METHOD_WHITELIST' is deprecated and " + "will be removed in v2.0. Use 'Retry.DEFAULT_ALLOWED_METHODS' instead", + DeprecationWarning, + ) + cls.DEFAULT_ALLOWED_METHODS = value + + @property + def DEFAULT_REDIRECT_HEADERS_BLACKLIST(cls): + warnings.warn( + "Using 'Retry.DEFAULT_REDIRECT_HEADERS_BLACKLIST' is deprecated and " + "will be removed in v2.0. Use 'Retry.DEFAULT_REMOVE_HEADERS_ON_REDIRECT' instead", + DeprecationWarning, + ) + return cls.DEFAULT_REMOVE_HEADERS_ON_REDIRECT + + @DEFAULT_REDIRECT_HEADERS_BLACKLIST.setter + def DEFAULT_REDIRECT_HEADERS_BLACKLIST(cls, value): + warnings.warn( + "Using 'Retry.DEFAULT_REDIRECT_HEADERS_BLACKLIST' is deprecated and " + "will be removed in v2.0. Use 'Retry.DEFAULT_REMOVE_HEADERS_ON_REDIRECT' instead", + DeprecationWarning, + ) + cls.DEFAULT_REMOVE_HEADERS_ON_REDIRECT = value + + +@six.add_metaclass(_RetryMeta) +class Retry(object): + """Retry configuration. + + Each retry attempt will create a new Retry object with updated values, so + they can be safely reused. + + Retries can be defined as a default for a pool:: + + retries = Retry(connect=5, read=2, redirect=5) + http = PoolManager(retries=retries) + response = http.request('GET', 'http://example.com/') + + Or per-request (which overrides the default for the pool):: + + response = http.request('GET', 'http://example.com/', retries=Retry(10)) + + Retries can be disabled by passing ``False``:: + + response = http.request('GET', 'http://example.com/', retries=False) + + Errors will be wrapped in :class:`~urllib3.exceptions.MaxRetryError` unless + retries are disabled, in which case the causing exception will be raised. + + :param int total: + Total number of retries to allow. Takes precedence over other counts. + + Set to ``None`` to remove this constraint and fall back on other + counts. + + Set to ``0`` to fail on the first retry. + + Set to ``False`` to disable and imply ``raise_on_redirect=False``. + + :param int connect: + How many connection-related errors to retry on. + + These are errors raised before the request is sent to the remote server, + which we assume has not triggered the server to process the request. + + Set to ``0`` to fail on the first retry of this type. + + :param int read: + How many times to retry on read errors. + + These errors are raised after the request was sent to the server, so the + request may have side-effects. + + Set to ``0`` to fail on the first retry of this type. + + :param int redirect: + How many redirects to perform. Limit this to avoid infinite redirect + loops. + + A redirect is a HTTP response with a status code 301, 302, 303, 307 or + 308. + + Set to ``0`` to fail on the first retry of this type. + + Set to ``False`` to disable and imply ``raise_on_redirect=False``. + + :param int status: + How many times to retry on bad status codes. + + These are retries made on responses, where status code matches + ``status_forcelist``. + + Set to ``0`` to fail on the first retry of this type. + + :param int other: + How many times to retry on other errors. + + Other errors are errors that are not connect, read, redirect or status errors. + These errors might be raised after the request was sent to the server, so the + request might have side-effects. + + Set to ``0`` to fail on the first retry of this type. + + If ``total`` is not set, it's a good idea to set this to 0 to account + for unexpected edge cases and avoid infinite retry loops. + + :param iterable allowed_methods: + Set of uppercased HTTP method verbs that we should retry on. + + By default, we only retry on methods which are considered to be + idempotent (multiple requests with the same parameters end with the + same state). See :attr:`Retry.DEFAULT_ALLOWED_METHODS`. + + Set to a ``False`` value to retry on any verb. + + .. warning:: + + Previously this parameter was named ``method_whitelist``, that + usage is deprecated in v1.26.0 and will be removed in v2.0. + + :param iterable status_forcelist: + A set of integer HTTP status codes that we should force a retry on. + A retry is initiated if the request method is in ``allowed_methods`` + and the response status code is in ``status_forcelist``. + + By default, this is disabled with ``None``. + + :param float backoff_factor: + A backoff factor to apply between attempts after the second try + (most errors are resolved immediately by a second try without a + delay). urllib3 will sleep for:: + + {backoff factor} * (2 ** ({number of total retries} - 1)) + + seconds. If the backoff_factor is 0.1, then :func:`.sleep` will sleep + for [0.0s, 0.2s, 0.4s, ...] between retries. It will never be longer + than :attr:`Retry.BACKOFF_MAX`. + + By default, backoff is disabled (set to 0). + + :param bool raise_on_redirect: Whether, if the number of redirects is + exhausted, to raise a MaxRetryError, or to return a response with a + response code in the 3xx range. + + :param bool raise_on_status: Similar meaning to ``raise_on_redirect``: + whether we should raise an exception, or return a response, + if status falls in ``status_forcelist`` range and retries have + been exhausted. + + :param tuple history: The history of the request encountered during + each call to :meth:`~Retry.increment`. The list is in the order + the requests occurred. Each list item is of class :class:`RequestHistory`. + + :param bool respect_retry_after_header: + Whether to respect Retry-After header on status codes defined as + :attr:`Retry.RETRY_AFTER_STATUS_CODES` or not. + + :param iterable remove_headers_on_redirect: + Sequence of headers to remove from the request when a response + indicating a redirect is returned before firing off the redirected + request. + """ + + #: Default methods to be used for ``allowed_methods`` + DEFAULT_ALLOWED_METHODS = frozenset( + ["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"] + ) + + #: Default status codes to be used for ``status_forcelist`` + RETRY_AFTER_STATUS_CODES = frozenset([413, 429, 503]) + + #: Default headers to be used for ``remove_headers_on_redirect`` + DEFAULT_REMOVE_HEADERS_ON_REDIRECT = frozenset(["Authorization"]) + + #: Maximum backoff time. + BACKOFF_MAX = 120 + + def __init__( + self, + total=10, + connect=None, + read=None, + redirect=None, + status=None, + other=None, + allowed_methods=_Default, + status_forcelist=None, + backoff_factor=0, + raise_on_redirect=True, + raise_on_status=True, + history=None, + respect_retry_after_header=True, + remove_headers_on_redirect=_Default, + # TODO: Deprecated, remove in v2.0 + method_whitelist=_Default, + ): + + if method_whitelist is not _Default: + if allowed_methods is not _Default: + raise ValueError( + "Using both 'allowed_methods' and " + "'method_whitelist' together is not allowed. " + "Instead only use 'allowed_methods'" + ) + warnings.warn( + "Using 'method_whitelist' with Retry is deprecated and " + "will be removed in v2.0. Use 'allowed_methods' instead", + DeprecationWarning, + stacklevel=2, + ) + allowed_methods = method_whitelist + if allowed_methods is _Default: + allowed_methods = self.DEFAULT_ALLOWED_METHODS + if remove_headers_on_redirect is _Default: + remove_headers_on_redirect = self.DEFAULT_REMOVE_HEADERS_ON_REDIRECT + + self.total = total + self.connect = connect + self.read = read + self.status = status + self.other = other + + if redirect is False or total is False: + redirect = 0 + raise_on_redirect = False + + self.redirect = redirect + self.status_forcelist = status_forcelist or set() + self.allowed_methods = allowed_methods + self.backoff_factor = backoff_factor + self.raise_on_redirect = raise_on_redirect + self.raise_on_status = raise_on_status + self.history = history or tuple() + self.respect_retry_after_header = respect_retry_after_header + self.remove_headers_on_redirect = frozenset( + [h.lower() for h in remove_headers_on_redirect] + ) + + def new(self, **kw): + params = dict( + total=self.total, + connect=self.connect, + read=self.read, + redirect=self.redirect, + status=self.status, + other=self.other, + status_forcelist=self.status_forcelist, + backoff_factor=self.backoff_factor, + raise_on_redirect=self.raise_on_redirect, + raise_on_status=self.raise_on_status, + history=self.history, + remove_headers_on_redirect=self.remove_headers_on_redirect, + respect_retry_after_header=self.respect_retry_after_header, + ) + + # TODO: If already given in **kw we use what's given to us + # If not given we need to figure out what to pass. We decide + # based on whether our class has the 'method_whitelist' property + # and if so we pass the deprecated 'method_whitelist' otherwise + # we use 'allowed_methods'. Remove in v2.0 + if "method_whitelist" not in kw and "allowed_methods" not in kw: + if "method_whitelist" in self.__dict__: + warnings.warn( + "Using 'method_whitelist' with Retry is deprecated and " + "will be removed in v2.0. Use 'allowed_methods' instead", + DeprecationWarning, + ) + params["method_whitelist"] = self.allowed_methods + else: + params["allowed_methods"] = self.allowed_methods + + params.update(kw) + return type(self)(**params) + + @classmethod + def from_int(cls, retries, redirect=True, default=None): + """Backwards-compatibility for the old retries format.""" + if retries is None: + retries = default if default is not None else cls.DEFAULT + + if isinstance(retries, Retry): + return retries + + redirect = bool(redirect) and None + new_retries = cls(retries, redirect=redirect) + log.debug("Converted retries value: %r -> %r", retries, new_retries) + return new_retries + + def get_backoff_time(self): + """Formula for computing the current backoff + + :rtype: float + """ + # We want to consider only the last consecutive errors sequence (Ignore redirects). + consecutive_errors_len = len( + list( + takewhile(lambda x: x.redirect_location is None, reversed(self.history)) + ) + ) + if consecutive_errors_len <= 1: + return 0 + + backoff_value = self.backoff_factor * (2 ** (consecutive_errors_len - 1)) + return min(self.BACKOFF_MAX, backoff_value) + + def parse_retry_after(self, retry_after): + # Whitespace: https://tools.ietf.org/html/rfc7230#section-3.2.4 + if re.match(r"^\s*[0-9]+\s*$", retry_after): + seconds = int(retry_after) + else: + retry_date_tuple = email.utils.parsedate_tz(retry_after) + if retry_date_tuple is None: + raise InvalidHeader("Invalid Retry-After header: %s" % retry_after) + if retry_date_tuple[9] is None: # Python 2 + # Assume UTC if no timezone was specified + # On Python2.7, parsedate_tz returns None for a timezone offset + # instead of 0 if no timezone is given, where mktime_tz treats + # a None timezone offset as local time. + retry_date_tuple = retry_date_tuple[:9] + (0,) + retry_date_tuple[10:] + + retry_date = email.utils.mktime_tz(retry_date_tuple) + seconds = retry_date - time.time() + + if seconds < 0: + seconds = 0 + + return seconds + + def get_retry_after(self, response): + """Get the value of Retry-After in seconds.""" + + retry_after = response.getheader("Retry-After") + + if retry_after is None: + return None + + return self.parse_retry_after(retry_after) + + def sleep_for_retry(self, response=None): + retry_after = self.get_retry_after(response) + if retry_after: + time.sleep(retry_after) + return True + + return False + + def _sleep_backoff(self): + backoff = self.get_backoff_time() + if backoff <= 0: + return + time.sleep(backoff) + + def sleep(self, response=None): + """Sleep between retry attempts. + + This method will respect a server's ``Retry-After`` response header + and sleep the duration of the time requested. If that is not present, it + will use an exponential backoff. By default, the backoff factor is 0 and + this method will return immediately. + """ + + if self.respect_retry_after_header and response: + slept = self.sleep_for_retry(response) + if slept: + return + + self._sleep_backoff() + + def _is_connection_error(self, err): + """Errors when we're fairly sure that the server did not receive the + request, so it should be safe to retry. + """ + if isinstance(err, ProxyError): + err = err.original_error + return isinstance(err, ConnectTimeoutError) + + def _is_read_error(self, err): + """Errors that occur after the request has been started, so we should + assume that the server began processing it. + """ + return isinstance(err, (ReadTimeoutError, ProtocolError)) + + def _is_method_retryable(self, method): + """Checks if a given HTTP method should be retried upon, depending if + it is included in the allowed_methods + """ + # TODO: For now favor if the Retry implementation sets its own method_whitelist + # property outside of our constructor to avoid breaking custom implementations. + if "method_whitelist" in self.__dict__: + warnings.warn( + "Using 'method_whitelist' with Retry is deprecated and " + "will be removed in v2.0. Use 'allowed_methods' instead", + DeprecationWarning, + ) + allowed_methods = self.method_whitelist + else: + allowed_methods = self.allowed_methods + + if allowed_methods and method.upper() not in allowed_methods: + return False + return True + + def is_retry(self, method, status_code, has_retry_after=False): + """Is this method/status code retryable? (Based on allowlists and control + variables such as the number of total retries to allow, whether to + respect the Retry-After header, whether this header is present, and + whether the returned status code is on the list of status codes to + be retried upon on the presence of the aforementioned header) + """ + if not self._is_method_retryable(method): + return False + + if self.status_forcelist and status_code in self.status_forcelist: + return True + + return ( + self.total + and self.respect_retry_after_header + and has_retry_after + and (status_code in self.RETRY_AFTER_STATUS_CODES) + ) + + def is_exhausted(self): + """Are we out of retries?""" + retry_counts = ( + self.total, + self.connect, + self.read, + self.redirect, + self.status, + self.other, + ) + retry_counts = list(filter(None, retry_counts)) + if not retry_counts: + return False + + return min(retry_counts) < 0 + + def increment( + self, + method=None, + url=None, + response=None, + error=None, + _pool=None, + _stacktrace=None, + ): + """Return a new Retry object with incremented retry counters. + + :param response: A response object, or None, if the server did not + return a response. + :type response: :class:`~urllib3.response.HTTPResponse` + :param Exception error: An error encountered during the request, or + None if the response was received successfully. + + :return: A new ``Retry`` object. + """ + if self.total is False and error: + # Disabled, indicate to re-raise the error. + raise six.reraise(type(error), error, _stacktrace) + + total = self.total + if total is not None: + total -= 1 + + connect = self.connect + read = self.read + redirect = self.redirect + status_count = self.status + other = self.other + cause = "unknown" + status = None + redirect_location = None + + if error and self._is_connection_error(error): + # Connect retry? + if connect is False: + raise six.reraise(type(error), error, _stacktrace) + elif connect is not None: + connect -= 1 + + elif error and self._is_read_error(error): + # Read retry? + if read is False or not self._is_method_retryable(method): + raise six.reraise(type(error), error, _stacktrace) + elif read is not None: + read -= 1 + + elif error: + # Other retry? + if other is not None: + other -= 1 + + elif response and response.get_redirect_location(): + # Redirect retry? + if redirect is not None: + redirect -= 1 + cause = "too many redirects" + redirect_location = response.get_redirect_location() + status = response.status + + else: + # Incrementing because of a server error like a 500 in + # status_forcelist and the given method is in the allowed_methods + cause = ResponseError.GENERIC_ERROR + if response and response.status: + if status_count is not None: + status_count -= 1 + cause = ResponseError.SPECIFIC_ERROR.format(status_code=response.status) + status = response.status + + history = self.history + ( + RequestHistory(method, url, error, status, redirect_location), + ) + + new_retry = self.new( + total=total, + connect=connect, + read=read, + redirect=redirect, + status=status_count, + other=other, + history=history, + ) + + if new_retry.is_exhausted(): + raise MaxRetryError(_pool, url, error or ResponseError(cause)) + + log.debug("Incremented Retry for (url='%s'): %r", url, new_retry) + + return new_retry + + def __repr__(self): + return ( + "{cls.__name__}(total={self.total}, connect={self.connect}, " + "read={self.read}, redirect={self.redirect}, status={self.status})" + ).format(cls=type(self), self=self) + + def __getattr__(self, item): + if item == "method_whitelist": + # TODO: Remove this deprecated alias in v2.0 + warnings.warn( + "Using 'method_whitelist' with Retry is deprecated and " + "will be removed in v2.0. Use 'allowed_methods' instead", + DeprecationWarning, + ) + return self.allowed_methods + try: + return getattr(super(Retry, self), item) + except AttributeError: + return getattr(Retry, item) + + +# For backwards compatibility (equivalent to pre-v1.9): +Retry.DEFAULT = Retry(3) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/ssl_.py b/openpype/hosts/fusion/vendor/urllib3/util/ssl_.py new file mode 100644 index 0000000000..8f867812a5 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/ssl_.py @@ -0,0 +1,495 @@ +from __future__ import absolute_import + +import hmac +import os +import sys +import warnings +from binascii import hexlify, unhexlify +from hashlib import md5, sha1, sha256 + +from ..exceptions import ( + InsecurePlatformWarning, + ProxySchemeUnsupported, + SNIMissingWarning, + SSLError, +) +from ..packages import six +from .url import BRACELESS_IPV6_ADDRZ_RE, IPV4_RE + +SSLContext = None +SSLTransport = None +HAS_SNI = False +IS_PYOPENSSL = False +IS_SECURETRANSPORT = False +ALPN_PROTOCOLS = ["http/1.1"] + +# Maps the length of a digest to a possible hash function producing this digest +HASHFUNC_MAP = {32: md5, 40: sha1, 64: sha256} + + +def _const_compare_digest_backport(a, b): + """ + Compare two digests of equal length in constant time. + + The digests must be of type str/bytes. + Returns True if the digests match, and False otherwise. + """ + result = abs(len(a) - len(b)) + for left, right in zip(bytearray(a), bytearray(b)): + result |= left ^ right + return result == 0 + + +_const_compare_digest = getattr(hmac, "compare_digest", _const_compare_digest_backport) + +try: # Test for SSL features + import ssl + from ssl import CERT_REQUIRED, wrap_socket +except ImportError: + pass + +try: + from ssl import HAS_SNI # Has SNI? +except ImportError: + pass + +try: + from .ssltransport import SSLTransport +except ImportError: + pass + + +try: # Platform-specific: Python 3.6 + from ssl import PROTOCOL_TLS + + PROTOCOL_SSLv23 = PROTOCOL_TLS +except ImportError: + try: + from ssl import PROTOCOL_SSLv23 as PROTOCOL_TLS + + PROTOCOL_SSLv23 = PROTOCOL_TLS + except ImportError: + PROTOCOL_SSLv23 = PROTOCOL_TLS = 2 + +try: + from ssl import PROTOCOL_TLS_CLIENT +except ImportError: + PROTOCOL_TLS_CLIENT = PROTOCOL_TLS + + +try: + from ssl import OP_NO_COMPRESSION, OP_NO_SSLv2, OP_NO_SSLv3 +except ImportError: + OP_NO_SSLv2, OP_NO_SSLv3 = 0x1000000, 0x2000000 + OP_NO_COMPRESSION = 0x20000 + + +try: # OP_NO_TICKET was added in Python 3.6 + from ssl import OP_NO_TICKET +except ImportError: + OP_NO_TICKET = 0x4000 + + +# A secure default. +# Sources for more information on TLS ciphers: +# +# - https://wiki.mozilla.org/Security/Server_Side_TLS +# - https://www.ssllabs.com/projects/best-practices/index.html +# - https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/ +# +# The general intent is: +# - prefer cipher suites that offer perfect forward secrecy (DHE/ECDHE), +# - prefer ECDHE over DHE for better performance, +# - prefer any AES-GCM and ChaCha20 over any AES-CBC for better performance and +# security, +# - prefer AES-GCM over ChaCha20 because hardware-accelerated AES is common, +# - disable NULL authentication, MD5 MACs, DSS, and other +# insecure ciphers for security reasons. +# - NOTE: TLS 1.3 cipher suites are managed through a different interface +# not exposed by CPython (yet!) and are enabled by default if they're available. +DEFAULT_CIPHERS = ":".join( + [ + "ECDHE+AESGCM", + "ECDHE+CHACHA20", + "DHE+AESGCM", + "DHE+CHACHA20", + "ECDH+AESGCM", + "DH+AESGCM", + "ECDH+AES", + "DH+AES", + "RSA+AESGCM", + "RSA+AES", + "!aNULL", + "!eNULL", + "!MD5", + "!DSS", + ] +) + +try: + from ssl import SSLContext # Modern SSL? +except ImportError: + + class SSLContext(object): # Platform-specific: Python 2 + def __init__(self, protocol_version): + self.protocol = protocol_version + # Use default values from a real SSLContext + self.check_hostname = False + self.verify_mode = ssl.CERT_NONE + self.ca_certs = None + self.options = 0 + self.certfile = None + self.keyfile = None + self.ciphers = None + + def load_cert_chain(self, certfile, keyfile): + self.certfile = certfile + self.keyfile = keyfile + + def load_verify_locations(self, cafile=None, capath=None, cadata=None): + self.ca_certs = cafile + + if capath is not None: + raise SSLError("CA directories not supported in older Pythons") + + if cadata is not None: + raise SSLError("CA data not supported in older Pythons") + + def set_ciphers(self, cipher_suite): + self.ciphers = cipher_suite + + def wrap_socket(self, socket, server_hostname=None, server_side=False): + warnings.warn( + "A true SSLContext object is not available. This prevents " + "urllib3 from configuring SSL appropriately and may cause " + "certain SSL connections to fail. You can upgrade to a newer " + "version of Python to solve this. For more information, see " + "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" + "#ssl-warnings", + InsecurePlatformWarning, + ) + kwargs = { + "keyfile": self.keyfile, + "certfile": self.certfile, + "ca_certs": self.ca_certs, + "cert_reqs": self.verify_mode, + "ssl_version": self.protocol, + "server_side": server_side, + } + return wrap_socket(socket, ciphers=self.ciphers, **kwargs) + + +def assert_fingerprint(cert, fingerprint): + """ + Checks if given fingerprint matches the supplied certificate. + + :param cert: + Certificate as bytes object. + :param fingerprint: + Fingerprint as string of hexdigits, can be interspersed by colons. + """ + + fingerprint = fingerprint.replace(":", "").lower() + digest_length = len(fingerprint) + hashfunc = HASHFUNC_MAP.get(digest_length) + if not hashfunc: + raise SSLError("Fingerprint of invalid length: {0}".format(fingerprint)) + + # We need encode() here for py32; works on py2 and p33. + fingerprint_bytes = unhexlify(fingerprint.encode()) + + cert_digest = hashfunc(cert).digest() + + if not _const_compare_digest(cert_digest, fingerprint_bytes): + raise SSLError( + 'Fingerprints did not match. Expected "{0}", got "{1}".'.format( + fingerprint, hexlify(cert_digest) + ) + ) + + +def resolve_cert_reqs(candidate): + """ + Resolves the argument to a numeric constant, which can be passed to + the wrap_socket function/method from the ssl module. + Defaults to :data:`ssl.CERT_REQUIRED`. + If given a string it is assumed to be the name of the constant in the + :mod:`ssl` module or its abbreviation. + (So you can specify `REQUIRED` instead of `CERT_REQUIRED`. + If it's neither `None` nor a string we assume it is already the numeric + constant which can directly be passed to wrap_socket. + """ + if candidate is None: + return CERT_REQUIRED + + if isinstance(candidate, str): + res = getattr(ssl, candidate, None) + if res is None: + res = getattr(ssl, "CERT_" + candidate) + return res + + return candidate + + +def resolve_ssl_version(candidate): + """ + like resolve_cert_reqs + """ + if candidate is None: + return PROTOCOL_TLS + + if isinstance(candidate, str): + res = getattr(ssl, candidate, None) + if res is None: + res = getattr(ssl, "PROTOCOL_" + candidate) + return res + + return candidate + + +def create_urllib3_context( + ssl_version=None, cert_reqs=None, options=None, ciphers=None +): + """All arguments have the same meaning as ``ssl_wrap_socket``. + + By default, this function does a lot of the same work that + ``ssl.create_default_context`` does on Python 3.4+. It: + + - Disables SSLv2, SSLv3, and compression + - Sets a restricted set of server ciphers + + If you wish to enable SSLv3, you can do:: + + from urllib3.util import ssl_ + context = ssl_.create_urllib3_context() + context.options &= ~ssl_.OP_NO_SSLv3 + + You can do the same to enable compression (substituting ``COMPRESSION`` + for ``SSLv3`` in the last line above). + + :param ssl_version: + The desired protocol version to use. This will default to + PROTOCOL_SSLv23 which will negotiate the highest protocol that both + the server and your installation of OpenSSL support. + :param cert_reqs: + Whether to require the certificate verification. This defaults to + ``ssl.CERT_REQUIRED``. + :param options: + Specific OpenSSL options. These default to ``ssl.OP_NO_SSLv2``, + ``ssl.OP_NO_SSLv3``, ``ssl.OP_NO_COMPRESSION``, and ``ssl.OP_NO_TICKET``. + :param ciphers: + Which cipher suites to allow the server to select. + :returns: + Constructed SSLContext object with specified options + :rtype: SSLContext + """ + # PROTOCOL_TLS is deprecated in Python 3.10 + if not ssl_version or ssl_version == PROTOCOL_TLS: + ssl_version = PROTOCOL_TLS_CLIENT + + context = SSLContext(ssl_version) + + context.set_ciphers(ciphers or DEFAULT_CIPHERS) + + # Setting the default here, as we may have no ssl module on import + cert_reqs = ssl.CERT_REQUIRED if cert_reqs is None else cert_reqs + + if options is None: + options = 0 + # SSLv2 is easily broken and is considered harmful and dangerous + options |= OP_NO_SSLv2 + # SSLv3 has several problems and is now dangerous + options |= OP_NO_SSLv3 + # Disable compression to prevent CRIME attacks for OpenSSL 1.0+ + # (issue #309) + options |= OP_NO_COMPRESSION + # TLSv1.2 only. Unless set explicitly, do not request tickets. + # This may save some bandwidth on wire, and although the ticket is encrypted, + # there is a risk associated with it being on wire, + # if the server is not rotating its ticketing keys properly. + options |= OP_NO_TICKET + + context.options |= options + + # Enable post-handshake authentication for TLS 1.3, see GH #1634. PHA is + # necessary for conditional client cert authentication with TLS 1.3. + # The attribute is None for OpenSSL <= 1.1.0 or does not exist in older + # versions of Python. We only enable on Python 3.7.4+ or if certificate + # verification is enabled to work around Python issue #37428 + # See: https://bugs.python.org/issue37428 + if (cert_reqs == ssl.CERT_REQUIRED or sys.version_info >= (3, 7, 4)) and getattr( + context, "post_handshake_auth", None + ) is not None: + context.post_handshake_auth = True + + def disable_check_hostname(): + if ( + getattr(context, "check_hostname", None) is not None + ): # Platform-specific: Python 3.2 + # We do our own verification, including fingerprints and alternative + # hostnames. So disable it here + context.check_hostname = False + + # The order of the below lines setting verify_mode and check_hostname + # matter due to safe-guards SSLContext has to prevent an SSLContext with + # check_hostname=True, verify_mode=NONE/OPTIONAL. This is made even more + # complex because we don't know whether PROTOCOL_TLS_CLIENT will be used + # or not so we don't know the initial state of the freshly created SSLContext. + if cert_reqs == ssl.CERT_REQUIRED: + context.verify_mode = cert_reqs + disable_check_hostname() + else: + disable_check_hostname() + context.verify_mode = cert_reqs + + # Enable logging of TLS session keys via defacto standard environment variable + # 'SSLKEYLOGFILE', if the feature is available (Python 3.8+). Skip empty values. + if hasattr(context, "keylog_filename"): + sslkeylogfile = os.environ.get("SSLKEYLOGFILE") + if sslkeylogfile: + context.keylog_filename = sslkeylogfile + + return context + + +def ssl_wrap_socket( + sock, + keyfile=None, + certfile=None, + cert_reqs=None, + ca_certs=None, + server_hostname=None, + ssl_version=None, + ciphers=None, + ssl_context=None, + ca_cert_dir=None, + key_password=None, + ca_cert_data=None, + tls_in_tls=False, +): + """ + All arguments except for server_hostname, ssl_context, and ca_cert_dir have + the same meaning as they do when using :func:`ssl.wrap_socket`. + + :param server_hostname: + When SNI is supported, the expected hostname of the certificate + :param ssl_context: + A pre-made :class:`SSLContext` object. If none is provided, one will + be created using :func:`create_urllib3_context`. + :param ciphers: + A string of ciphers we wish the client to support. + :param ca_cert_dir: + A directory containing CA certificates in multiple separate files, as + supported by OpenSSL's -CApath flag or the capath argument to + SSLContext.load_verify_locations(). + :param key_password: + Optional password if the keyfile is encrypted. + :param ca_cert_data: + Optional string containing CA certificates in PEM format suitable for + passing as the cadata parameter to SSLContext.load_verify_locations() + :param tls_in_tls: + Use SSLTransport to wrap the existing socket. + """ + context = ssl_context + if context is None: + # Note: This branch of code and all the variables in it are no longer + # used by urllib3 itself. We should consider deprecating and removing + # this code. + context = create_urllib3_context(ssl_version, cert_reqs, ciphers=ciphers) + + if ca_certs or ca_cert_dir or ca_cert_data: + try: + context.load_verify_locations(ca_certs, ca_cert_dir, ca_cert_data) + except (IOError, OSError) as e: + raise SSLError(e) + + elif ssl_context is None and hasattr(context, "load_default_certs"): + # try to load OS default certs; works well on Windows (require Python3.4+) + context.load_default_certs() + + # Attempt to detect if we get the goofy behavior of the + # keyfile being encrypted and OpenSSL asking for the + # passphrase via the terminal and instead error out. + if keyfile and key_password is None and _is_key_file_encrypted(keyfile): + raise SSLError("Client private key is encrypted, password is required") + + if certfile: + if key_password is None: + context.load_cert_chain(certfile, keyfile) + else: + context.load_cert_chain(certfile, keyfile, key_password) + + try: + if hasattr(context, "set_alpn_protocols"): + context.set_alpn_protocols(ALPN_PROTOCOLS) + except NotImplementedError: # Defensive: in CI, we always have set_alpn_protocols + pass + + # If we detect server_hostname is an IP address then the SNI + # extension should not be used according to RFC3546 Section 3.1 + use_sni_hostname = server_hostname and not is_ipaddress(server_hostname) + # SecureTransport uses server_hostname in certificate verification. + send_sni = (use_sni_hostname and HAS_SNI) or ( + IS_SECURETRANSPORT and server_hostname + ) + # Do not warn the user if server_hostname is an invalid SNI hostname. + if not HAS_SNI and use_sni_hostname: + warnings.warn( + "An HTTPS request has been made, but the SNI (Server Name " + "Indication) extension to TLS is not available on this platform. " + "This may cause the server to present an incorrect TLS " + "certificate, which can cause validation failures. You can upgrade to " + "a newer version of Python to solve this. For more information, see " + "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" + "#ssl-warnings", + SNIMissingWarning, + ) + + if send_sni: + ssl_sock = _ssl_wrap_socket_impl( + sock, context, tls_in_tls, server_hostname=server_hostname + ) + else: + ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls) + return ssl_sock + + +def is_ipaddress(hostname): + """Detects whether the hostname given is an IPv4 or IPv6 address. + Also detects IPv6 addresses with Zone IDs. + + :param str hostname: Hostname to examine. + :return: True if the hostname is an IP address, False otherwise. + """ + if not six.PY2 and isinstance(hostname, bytes): + # IDN A-label bytes are ASCII compatible. + hostname = hostname.decode("ascii") + return bool(IPV4_RE.match(hostname) or BRACELESS_IPV6_ADDRZ_RE.match(hostname)) + + +def _is_key_file_encrypted(key_file): + """Detects if a key file is encrypted or not.""" + with open(key_file, "r") as f: + for line in f: + # Look for Proc-Type: 4,ENCRYPTED + if "ENCRYPTED" in line: + return True + + return False + + +def _ssl_wrap_socket_impl(sock, ssl_context, tls_in_tls, server_hostname=None): + if tls_in_tls: + if not SSLTransport: + # Import error, ssl is not available. + raise ProxySchemeUnsupported( + "TLS in TLS requires support for the 'ssl' module" + ) + + SSLTransport._validate_ssl_context_for_tls_in_tls(ssl_context) + return SSLTransport(sock, ssl_context, server_hostname) + + if server_hostname: + return ssl_context.wrap_socket(sock, server_hostname=server_hostname) + else: + return ssl_context.wrap_socket(sock) diff --git a/openpype/hosts/fusion/vendor/urllib3/util/ssltransport.py b/openpype/hosts/fusion/vendor/urllib3/util/ssltransport.py new file mode 100644 index 0000000000..c2186bced9 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/ssltransport.py @@ -0,0 +1,221 @@ +import io +import socket +import ssl + +from urllib3.exceptions import ProxySchemeUnsupported +from urllib3.packages import six + +SSL_BLOCKSIZE = 16384 + + +class SSLTransport: + """ + The SSLTransport wraps an existing socket and establishes an SSL connection. + + Contrary to Python's implementation of SSLSocket, it allows you to chain + multiple TLS connections together. It's particularly useful if you need to + implement TLS within TLS. + + The class supports most of the socket API operations. + """ + + @staticmethod + def _validate_ssl_context_for_tls_in_tls(ssl_context): + """ + Raises a ProxySchemeUnsupported if the provided ssl_context can't be used + for TLS in TLS. + + The only requirement is that the ssl_context provides the 'wrap_bio' + methods. + """ + + if not hasattr(ssl_context, "wrap_bio"): + if six.PY2: + raise ProxySchemeUnsupported( + "TLS in TLS requires SSLContext.wrap_bio() which isn't " + "supported on Python 2" + ) + else: + raise ProxySchemeUnsupported( + "TLS in TLS requires SSLContext.wrap_bio() which isn't " + "available on non-native SSLContext" + ) + + def __init__( + self, socket, ssl_context, server_hostname=None, suppress_ragged_eofs=True + ): + """ + Create an SSLTransport around socket using the provided ssl_context. + """ + self.incoming = ssl.MemoryBIO() + self.outgoing = ssl.MemoryBIO() + + self.suppress_ragged_eofs = suppress_ragged_eofs + self.socket = socket + + self.sslobj = ssl_context.wrap_bio( + self.incoming, self.outgoing, server_hostname=server_hostname + ) + + # Perform initial handshake. + self._ssl_io_loop(self.sslobj.do_handshake) + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + def fileno(self): + return self.socket.fileno() + + def read(self, len=1024, buffer=None): + return self._wrap_ssl_read(len, buffer) + + def recv(self, len=1024, flags=0): + if flags != 0: + raise ValueError("non-zero flags not allowed in calls to recv") + return self._wrap_ssl_read(len) + + def recv_into(self, buffer, nbytes=None, flags=0): + if flags != 0: + raise ValueError("non-zero flags not allowed in calls to recv_into") + if buffer and (nbytes is None): + nbytes = len(buffer) + elif nbytes is None: + nbytes = 1024 + return self.read(nbytes, buffer) + + def sendall(self, data, flags=0): + if flags != 0: + raise ValueError("non-zero flags not allowed in calls to sendall") + count = 0 + with memoryview(data) as view, view.cast("B") as byte_view: + amount = len(byte_view) + while count < amount: + v = self.send(byte_view[count:]) + count += v + + def send(self, data, flags=0): + if flags != 0: + raise ValueError("non-zero flags not allowed in calls to send") + response = self._ssl_io_loop(self.sslobj.write, data) + return response + + def makefile( + self, mode="r", buffering=None, encoding=None, errors=None, newline=None + ): + """ + Python's httpclient uses makefile and buffered io when reading HTTP + messages and we need to support it. + + This is unfortunately a copy and paste of socket.py makefile with small + changes to point to the socket directly. + """ + if not set(mode) <= {"r", "w", "b"}: + raise ValueError("invalid mode %r (only r, w, b allowed)" % (mode,)) + + writing = "w" in mode + reading = "r" in mode or not writing + assert reading or writing + binary = "b" in mode + rawmode = "" + if reading: + rawmode += "r" + if writing: + rawmode += "w" + raw = socket.SocketIO(self, rawmode) + self.socket._io_refs += 1 + if buffering is None: + buffering = -1 + if buffering < 0: + buffering = io.DEFAULT_BUFFER_SIZE + if buffering == 0: + if not binary: + raise ValueError("unbuffered streams must be binary") + return raw + if reading and writing: + buffer = io.BufferedRWPair(raw, raw, buffering) + elif reading: + buffer = io.BufferedReader(raw, buffering) + else: + assert writing + buffer = io.BufferedWriter(raw, buffering) + if binary: + return buffer + text = io.TextIOWrapper(buffer, encoding, errors, newline) + text.mode = mode + return text + + def unwrap(self): + self._ssl_io_loop(self.sslobj.unwrap) + + def close(self): + self.socket.close() + + def getpeercert(self, binary_form=False): + return self.sslobj.getpeercert(binary_form) + + def version(self): + return self.sslobj.version() + + def cipher(self): + return self.sslobj.cipher() + + def selected_alpn_protocol(self): + return self.sslobj.selected_alpn_protocol() + + def selected_npn_protocol(self): + return self.sslobj.selected_npn_protocol() + + def shared_ciphers(self): + return self.sslobj.shared_ciphers() + + def compression(self): + return self.sslobj.compression() + + def settimeout(self, value): + self.socket.settimeout(value) + + def gettimeout(self): + return self.socket.gettimeout() + + def _decref_socketios(self): + self.socket._decref_socketios() + + def _wrap_ssl_read(self, len, buffer=None): + try: + return self._ssl_io_loop(self.sslobj.read, len, buffer) + except ssl.SSLError as e: + if e.errno == ssl.SSL_ERROR_EOF and self.suppress_ragged_eofs: + return 0 # eof, return 0. + else: + raise + + def _ssl_io_loop(self, func, *args): + """Performs an I/O loop between incoming/outgoing and the socket.""" + should_loop = True + ret = None + + while should_loop: + errno = None + try: + ret = func(*args) + except ssl.SSLError as e: + if e.errno not in (ssl.SSL_ERROR_WANT_READ, ssl.SSL_ERROR_WANT_WRITE): + # WANT_READ, and WANT_WRITE are expected, others are not. + raise e + errno = e.errno + + buf = self.outgoing.read() + self.socket.sendall(buf) + + if errno is None: + should_loop = False + elif errno == ssl.SSL_ERROR_WANT_READ: + buf = self.socket.recv(SSL_BLOCKSIZE) + if buf: + self.incoming.write(buf) + else: + self.incoming.write_eof() + return ret diff --git a/openpype/hosts/fusion/vendor/urllib3/util/timeout.py b/openpype/hosts/fusion/vendor/urllib3/util/timeout.py new file mode 100644 index 0000000000..ff69593b05 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/timeout.py @@ -0,0 +1,268 @@ +from __future__ import absolute_import + +import time + +# The default socket timeout, used by httplib to indicate that no timeout was +# specified by the user +from socket import _GLOBAL_DEFAULT_TIMEOUT + +from ..exceptions import TimeoutStateError + +# A sentinel value to indicate that no timeout was specified by the user in +# urllib3 +_Default = object() + + +# Use time.monotonic if available. +current_time = getattr(time, "monotonic", time.time) + + +class Timeout(object): + """Timeout configuration. + + Timeouts can be defined as a default for a pool: + + .. code-block:: python + + timeout = Timeout(connect=2.0, read=7.0) + http = PoolManager(timeout=timeout) + response = http.request('GET', 'http://example.com/') + + Or per-request (which overrides the default for the pool): + + .. code-block:: python + + response = http.request('GET', 'http://example.com/', timeout=Timeout(10)) + + Timeouts can be disabled by setting all the parameters to ``None``: + + .. code-block:: python + + no_timeout = Timeout(connect=None, read=None) + response = http.request('GET', 'http://example.com/, timeout=no_timeout) + + + :param total: + This combines the connect and read timeouts into one; the read timeout + will be set to the time leftover from the connect attempt. In the + event that both a connect timeout and a total are specified, or a read + timeout and a total are specified, the shorter timeout will be applied. + + Defaults to None. + + :type total: int, float, or None + + :param connect: + The maximum amount of time (in seconds) to wait for a connection + attempt to a server to succeed. Omitting the parameter will default the + connect timeout to the system default, probably `the global default + timeout in socket.py + `_. + None will set an infinite timeout for connection attempts. + + :type connect: int, float, or None + + :param read: + The maximum amount of time (in seconds) to wait between consecutive + read operations for a response from the server. Omitting the parameter + will default the read timeout to the system default, probably `the + global default timeout in socket.py + `_. + None will set an infinite timeout. + + :type read: int, float, or None + + .. note:: + + Many factors can affect the total amount of time for urllib3 to return + an HTTP response. + + For example, Python's DNS resolver does not obey the timeout specified + on the socket. Other factors that can affect total request time include + high CPU load, high swap, the program running at a low priority level, + or other behaviors. + + In addition, the read and total timeouts only measure the time between + read operations on the socket connecting the client and the server, + not the total amount of time for the request to return a complete + response. For most requests, the timeout is raised because the server + has not sent the first byte in the specified time. This is not always + the case; if a server streams one byte every fifteen seconds, a timeout + of 20 seconds will not trigger, even though the request will take + several minutes to complete. + + If your goal is to cut off any request after a set amount of wall clock + time, consider having a second "watcher" thread to cut off a slow + request. + """ + + #: A sentinel object representing the default timeout value + DEFAULT_TIMEOUT = _GLOBAL_DEFAULT_TIMEOUT + + def __init__(self, total=None, connect=_Default, read=_Default): + self._connect = self._validate_timeout(connect, "connect") + self._read = self._validate_timeout(read, "read") + self.total = self._validate_timeout(total, "total") + self._start_connect = None + + def __repr__(self): + return "%s(connect=%r, read=%r, total=%r)" % ( + type(self).__name__, + self._connect, + self._read, + self.total, + ) + + # __str__ provided for backwards compatibility + __str__ = __repr__ + + @classmethod + def _validate_timeout(cls, value, name): + """Check that a timeout attribute is valid. + + :param value: The timeout value to validate + :param name: The name of the timeout attribute to validate. This is + used to specify in error messages. + :return: The validated and casted version of the given value. + :raises ValueError: If it is a numeric value less than or equal to + zero, or the type is not an integer, float, or None. + """ + if value is _Default: + return cls.DEFAULT_TIMEOUT + + if value is None or value is cls.DEFAULT_TIMEOUT: + return value + + if isinstance(value, bool): + raise ValueError( + "Timeout cannot be a boolean value. It must " + "be an int, float or None." + ) + try: + float(value) + except (TypeError, ValueError): + raise ValueError( + "Timeout value %s was %s, but it must be an " + "int, float or None." % (name, value) + ) + + try: + if value <= 0: + raise ValueError( + "Attempted to set %s timeout to %s, but the " + "timeout cannot be set to a value less " + "than or equal to 0." % (name, value) + ) + except TypeError: + # Python 3 + raise ValueError( + "Timeout value %s was %s, but it must be an " + "int, float or None." % (name, value) + ) + + return value + + @classmethod + def from_float(cls, timeout): + """Create a new Timeout from a legacy timeout value. + + The timeout value used by httplib.py sets the same timeout on the + connect(), and recv() socket requests. This creates a :class:`Timeout` + object that sets the individual timeouts to the ``timeout`` value + passed to this function. + + :param timeout: The legacy timeout value. + :type timeout: integer, float, sentinel default object, or None + :return: Timeout object + :rtype: :class:`Timeout` + """ + return Timeout(read=timeout, connect=timeout) + + def clone(self): + """Create a copy of the timeout object + + Timeout properties are stored per-pool but each request needs a fresh + Timeout object to ensure each one has its own start/stop configured. + + :return: a copy of the timeout object + :rtype: :class:`Timeout` + """ + # We can't use copy.deepcopy because that will also create a new object + # for _GLOBAL_DEFAULT_TIMEOUT, which socket.py uses as a sentinel to + # detect the user default. + return Timeout(connect=self._connect, read=self._read, total=self.total) + + def start_connect(self): + """Start the timeout clock, used during a connect() attempt + + :raises urllib3.exceptions.TimeoutStateError: if you attempt + to start a timer that has been started already. + """ + if self._start_connect is not None: + raise TimeoutStateError("Timeout timer has already been started.") + self._start_connect = current_time() + return self._start_connect + + def get_connect_duration(self): + """Gets the time elapsed since the call to :meth:`start_connect`. + + :return: Elapsed time in seconds. + :rtype: float + :raises urllib3.exceptions.TimeoutStateError: if you attempt + to get duration for a timer that hasn't been started. + """ + if self._start_connect is None: + raise TimeoutStateError( + "Can't get connect duration for timer that has not started." + ) + return current_time() - self._start_connect + + @property + def connect_timeout(self): + """Get the value to use when setting a connection timeout. + + This will be a positive float or integer, the value None + (never timeout), or the default system timeout. + + :return: Connect timeout. + :rtype: int, float, :attr:`Timeout.DEFAULT_TIMEOUT` or None + """ + if self.total is None: + return self._connect + + if self._connect is None or self._connect is self.DEFAULT_TIMEOUT: + return self.total + + return min(self._connect, self.total) + + @property + def read_timeout(self): + """Get the value for the read timeout. + + This assumes some time has elapsed in the connection timeout and + computes the read timeout appropriately. + + If self.total is set, the read timeout is dependent on the amount of + time taken by the connect timeout. If the connection time has not been + established, a :exc:`~urllib3.exceptions.TimeoutStateError` will be + raised. + + :return: Value to use for the read timeout. + :rtype: int, float, :attr:`Timeout.DEFAULT_TIMEOUT` or None + :raises urllib3.exceptions.TimeoutStateError: If :meth:`start_connect` + has not yet been called on this object. + """ + if ( + self.total is not None + and self.total is not self.DEFAULT_TIMEOUT + and self._read is not None + and self._read is not self.DEFAULT_TIMEOUT + ): + # In case the connect timeout has not yet been established. + if self._start_connect is None: + return self._read + return max(0, min(self.total - self.get_connect_duration(), self._read)) + elif self.total is not None and self.total is not self.DEFAULT_TIMEOUT: + return max(0, self.total - self.get_connect_duration()) + else: + return self._read diff --git a/openpype/hosts/fusion/vendor/urllib3/util/url.py b/openpype/hosts/fusion/vendor/urllib3/util/url.py new file mode 100644 index 0000000000..81a03da9e3 --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/url.py @@ -0,0 +1,432 @@ +from __future__ import absolute_import + +import re +from collections import namedtuple + +from ..exceptions import LocationParseError +from ..packages import six + +url_attrs = ["scheme", "auth", "host", "port", "path", "query", "fragment"] + +# We only want to normalize urls with an HTTP(S) scheme. +# urllib3 infers URLs without a scheme (None) to be http. +NORMALIZABLE_SCHEMES = ("http", "https", None) + +# Almost all of these patterns were derived from the +# 'rfc3986' module: https://github.com/python-hyper/rfc3986 +PERCENT_RE = re.compile(r"%[a-fA-F0-9]{2}") +SCHEME_RE = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9+-]*:|/)") +URI_RE = re.compile( + r"^(?:([a-zA-Z][a-zA-Z0-9+.-]*):)?" + r"(?://([^\\/?#]*))?" + r"([^?#]*)" + r"(?:\?([^#]*))?" + r"(?:#(.*))?$", + re.UNICODE | re.DOTALL, +) + +IPV4_PAT = r"(?:[0-9]{1,3}\.){3}[0-9]{1,3}" +HEX_PAT = "[0-9A-Fa-f]{1,4}" +LS32_PAT = "(?:{hex}:{hex}|{ipv4})".format(hex=HEX_PAT, ipv4=IPV4_PAT) +_subs = {"hex": HEX_PAT, "ls32": LS32_PAT} +_variations = [ + # 6( h16 ":" ) ls32 + "(?:%(hex)s:){6}%(ls32)s", + # "::" 5( h16 ":" ) ls32 + "::(?:%(hex)s:){5}%(ls32)s", + # [ h16 ] "::" 4( h16 ":" ) ls32 + "(?:%(hex)s)?::(?:%(hex)s:){4}%(ls32)s", + # [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 + "(?:(?:%(hex)s:)?%(hex)s)?::(?:%(hex)s:){3}%(ls32)s", + # [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 + "(?:(?:%(hex)s:){0,2}%(hex)s)?::(?:%(hex)s:){2}%(ls32)s", + # [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 + "(?:(?:%(hex)s:){0,3}%(hex)s)?::%(hex)s:%(ls32)s", + # [ *4( h16 ":" ) h16 ] "::" ls32 + "(?:(?:%(hex)s:){0,4}%(hex)s)?::%(ls32)s", + # [ *5( h16 ":" ) h16 ] "::" h16 + "(?:(?:%(hex)s:){0,5}%(hex)s)?::%(hex)s", + # [ *6( h16 ":" ) h16 ] "::" + "(?:(?:%(hex)s:){0,6}%(hex)s)?::", +] + +UNRESERVED_PAT = r"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._!\-~" +IPV6_PAT = "(?:" + "|".join([x % _subs for x in _variations]) + ")" +ZONE_ID_PAT = "(?:%25|%)(?:[" + UNRESERVED_PAT + "]|%[a-fA-F0-9]{2})+" +IPV6_ADDRZ_PAT = r"\[" + IPV6_PAT + r"(?:" + ZONE_ID_PAT + r")?\]" +REG_NAME_PAT = r"(?:[^\[\]%:/?#]|%[a-fA-F0-9]{2})*" +TARGET_RE = re.compile(r"^(/[^?#]*)(?:\?([^#]*))?(?:#.*)?$") + +IPV4_RE = re.compile("^" + IPV4_PAT + "$") +IPV6_RE = re.compile("^" + IPV6_PAT + "$") +IPV6_ADDRZ_RE = re.compile("^" + IPV6_ADDRZ_PAT + "$") +BRACELESS_IPV6_ADDRZ_RE = re.compile("^" + IPV6_ADDRZ_PAT[2:-2] + "$") +ZONE_ID_RE = re.compile("(" + ZONE_ID_PAT + r")\]$") + +_HOST_PORT_PAT = ("^(%s|%s|%s)(?::([0-9]{0,5}))?$") % ( + REG_NAME_PAT, + IPV4_PAT, + IPV6_ADDRZ_PAT, +) +_HOST_PORT_RE = re.compile(_HOST_PORT_PAT, re.UNICODE | re.DOTALL) + +UNRESERVED_CHARS = set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-~" +) +SUB_DELIM_CHARS = set("!$&'()*+,;=") +USERINFO_CHARS = UNRESERVED_CHARS | SUB_DELIM_CHARS | {":"} +PATH_CHARS = USERINFO_CHARS | {"@", "/"} +QUERY_CHARS = FRAGMENT_CHARS = PATH_CHARS | {"?"} + + +class Url(namedtuple("Url", url_attrs)): + """ + Data structure for representing an HTTP URL. Used as a return value for + :func:`parse_url`. Both the scheme and host are normalized as they are + both case-insensitive according to RFC 3986. + """ + + __slots__ = () + + def __new__( + cls, + scheme=None, + auth=None, + host=None, + port=None, + path=None, + query=None, + fragment=None, + ): + if path and not path.startswith("/"): + path = "/" + path + if scheme is not None: + scheme = scheme.lower() + return super(Url, cls).__new__( + cls, scheme, auth, host, port, path, query, fragment + ) + + @property + def hostname(self): + """For backwards-compatibility with urlparse. We're nice like that.""" + return self.host + + @property + def request_uri(self): + """Absolute path including the query string.""" + uri = self.path or "/" + + if self.query is not None: + uri += "?" + self.query + + return uri + + @property + def netloc(self): + """Network location including host and port""" + if self.port: + return "%s:%d" % (self.host, self.port) + return self.host + + @property + def url(self): + """ + Convert self into a url + + This function should more or less round-trip with :func:`.parse_url`. The + returned url may not be exactly the same as the url inputted to + :func:`.parse_url`, but it should be equivalent by the RFC (e.g., urls + with a blank port will have : removed). + + Example: :: + + >>> U = parse_url('http://google.com/mail/') + >>> U.url + 'http://google.com/mail/' + >>> Url('http', 'username:password', 'host.com', 80, + ... '/path', 'query', 'fragment').url + 'http://username:password@host.com:80/path?query#fragment' + """ + scheme, auth, host, port, path, query, fragment = self + url = u"" + + # We use "is not None" we want things to happen with empty strings (or 0 port) + if scheme is not None: + url += scheme + u"://" + if auth is not None: + url += auth + u"@" + if host is not None: + url += host + if port is not None: + url += u":" + str(port) + if path is not None: + url += path + if query is not None: + url += u"?" + query + if fragment is not None: + url += u"#" + fragment + + return url + + def __str__(self): + return self.url + + +def split_first(s, delims): + """ + .. deprecated:: 1.25 + + Given a string and an iterable of delimiters, split on the first found + delimiter. Return two split parts and the matched delimiter. + + If not found, then the first part is the full input string. + + Example:: + + >>> split_first('foo/bar?baz', '?/=') + ('foo', 'bar?baz', '/') + >>> split_first('foo/bar?baz', '123') + ('foo/bar?baz', '', None) + + Scales linearly with number of delims. Not ideal for large number of delims. + """ + min_idx = None + min_delim = None + for d in delims: + idx = s.find(d) + if idx < 0: + continue + + if min_idx is None or idx < min_idx: + min_idx = idx + min_delim = d + + if min_idx is None or min_idx < 0: + return s, "", None + + return s[:min_idx], s[min_idx + 1 :], min_delim + + +def _encode_invalid_chars(component, allowed_chars, encoding="utf-8"): + """Percent-encodes a URI component without reapplying + onto an already percent-encoded component. + """ + if component is None: + return component + + component = six.ensure_text(component) + + # Normalize existing percent-encoded bytes. + # Try to see if the component we're encoding is already percent-encoded + # so we can skip all '%' characters but still encode all others. + component, percent_encodings = PERCENT_RE.subn( + lambda match: match.group(0).upper(), component + ) + + uri_bytes = component.encode("utf-8", "surrogatepass") + is_percent_encoded = percent_encodings == uri_bytes.count(b"%") + encoded_component = bytearray() + + for i in range(0, len(uri_bytes)): + # Will return a single character bytestring on both Python 2 & 3 + byte = uri_bytes[i : i + 1] + byte_ord = ord(byte) + if (is_percent_encoded and byte == b"%") or ( + byte_ord < 128 and byte.decode() in allowed_chars + ): + encoded_component += byte + continue + encoded_component.extend(b"%" + (hex(byte_ord)[2:].encode().zfill(2).upper())) + + return encoded_component.decode(encoding) + + +def _remove_path_dot_segments(path): + # See http://tools.ietf.org/html/rfc3986#section-5.2.4 for pseudo-code + segments = path.split("/") # Turn the path into a list of segments + output = [] # Initialize the variable to use to store output + + for segment in segments: + # '.' is the current directory, so ignore it, it is superfluous + if segment == ".": + continue + # Anything other than '..', should be appended to the output + elif segment != "..": + output.append(segment) + # In this case segment == '..', if we can, we should pop the last + # element + elif output: + output.pop() + + # If the path starts with '/' and the output is empty or the first string + # is non-empty + if path.startswith("/") and (not output or output[0]): + output.insert(0, "") + + # If the path starts with '/.' or '/..' ensure we add one more empty + # string to add a trailing '/' + if path.endswith(("/.", "/..")): + output.append("") + + return "/".join(output) + + +def _normalize_host(host, scheme): + if host: + if isinstance(host, six.binary_type): + host = six.ensure_str(host) + + if scheme in NORMALIZABLE_SCHEMES: + is_ipv6 = IPV6_ADDRZ_RE.match(host) + if is_ipv6: + match = ZONE_ID_RE.search(host) + if match: + start, end = match.span(1) + zone_id = host[start:end] + + if zone_id.startswith("%25") and zone_id != "%25": + zone_id = zone_id[3:] + else: + zone_id = zone_id[1:] + zone_id = "%" + _encode_invalid_chars(zone_id, UNRESERVED_CHARS) + return host[:start].lower() + zone_id + host[end:] + else: + return host.lower() + elif not IPV4_RE.match(host): + return six.ensure_str( + b".".join([_idna_encode(label) for label in host.split(".")]) + ) + return host + + +def _idna_encode(name): + if name and any([ord(x) > 128 for x in name]): + try: + import idna + except ImportError: + six.raise_from( + LocationParseError("Unable to parse URL without the 'idna' module"), + None, + ) + try: + return idna.encode(name.lower(), strict=True, std3_rules=True) + except idna.IDNAError: + six.raise_from( + LocationParseError(u"Name '%s' is not a valid IDNA label" % name), None + ) + return name.lower().encode("ascii") + + +def _encode_target(target): + """Percent-encodes a request target so that there are no invalid characters""" + path, query = TARGET_RE.match(target).groups() + target = _encode_invalid_chars(path, PATH_CHARS) + query = _encode_invalid_chars(query, QUERY_CHARS) + if query is not None: + target += "?" + query + return target + + +def parse_url(url): + """ + Given a url, return a parsed :class:`.Url` namedtuple. Best-effort is + performed to parse incomplete urls. Fields not provided will be None. + This parser is RFC 3986 compliant. + + The parser logic and helper functions are based heavily on + work done in the ``rfc3986`` module. + + :param str url: URL to parse into a :class:`.Url` namedtuple. + + Partly backwards-compatible with :mod:`urlparse`. + + Example:: + + >>> parse_url('http://google.com/mail/') + Url(scheme='http', host='google.com', port=None, path='/mail/', ...) + >>> parse_url('google.com:80') + Url(scheme=None, host='google.com', port=80, path=None, ...) + >>> parse_url('/foo?bar') + Url(scheme=None, host=None, port=None, path='/foo', query='bar', ...) + """ + if not url: + # Empty + return Url() + + source_url = url + if not SCHEME_RE.search(url): + url = "//" + url + + try: + scheme, authority, path, query, fragment = URI_RE.match(url).groups() + normalize_uri = scheme is None or scheme.lower() in NORMALIZABLE_SCHEMES + + if scheme: + scheme = scheme.lower() + + if authority: + auth, _, host_port = authority.rpartition("@") + auth = auth or None + host, port = _HOST_PORT_RE.match(host_port).groups() + if auth and normalize_uri: + auth = _encode_invalid_chars(auth, USERINFO_CHARS) + if port == "": + port = None + else: + auth, host, port = None, None, None + + if port is not None: + port = int(port) + if not (0 <= port <= 65535): + raise LocationParseError(url) + + host = _normalize_host(host, scheme) + + if normalize_uri and path: + path = _remove_path_dot_segments(path) + path = _encode_invalid_chars(path, PATH_CHARS) + if normalize_uri and query: + query = _encode_invalid_chars(query, QUERY_CHARS) + if normalize_uri and fragment: + fragment = _encode_invalid_chars(fragment, FRAGMENT_CHARS) + + except (ValueError, AttributeError): + return six.raise_from(LocationParseError(source_url), None) + + # For the sake of backwards compatibility we put empty + # string values for path if there are any defined values + # beyond the path in the URL. + # TODO: Remove this when we break backwards compatibility. + if not path: + if query is not None or fragment is not None: + path = "" + else: + path = None + + # Ensure that each part of the URL is a `str` for + # backwards compatibility. + if isinstance(url, six.text_type): + ensure_func = six.ensure_text + else: + ensure_func = six.ensure_str + + def ensure_type(x): + return x if x is None else ensure_func(x) + + return Url( + scheme=ensure_type(scheme), + auth=ensure_type(auth), + host=ensure_type(host), + port=port, + path=ensure_type(path), + query=ensure_type(query), + fragment=ensure_type(fragment), + ) + + +def get_host(url): + """ + Deprecated. Use :func:`parse_url` instead. + """ + p = parse_url(url) + return p.scheme or "http", p.hostname, p.port diff --git a/openpype/hosts/fusion/vendor/urllib3/util/wait.py b/openpype/hosts/fusion/vendor/urllib3/util/wait.py new file mode 100644 index 0000000000..c280646c7b --- /dev/null +++ b/openpype/hosts/fusion/vendor/urllib3/util/wait.py @@ -0,0 +1,153 @@ +import errno +import select +import sys +from functools import partial + +try: + from time import monotonic +except ImportError: + from time import time as monotonic + +__all__ = ["NoWayToWaitForSocketError", "wait_for_read", "wait_for_write"] + + +class NoWayToWaitForSocketError(Exception): + pass + + +# How should we wait on sockets? +# +# There are two types of APIs you can use for waiting on sockets: the fancy +# modern stateful APIs like epoll/kqueue, and the older stateless APIs like +# select/poll. The stateful APIs are more efficient when you have a lots of +# sockets to keep track of, because you can set them up once and then use them +# lots of times. But we only ever want to wait on a single socket at a time +# and don't want to keep track of state, so the stateless APIs are actually +# more efficient. So we want to use select() or poll(). +# +# Now, how do we choose between select() and poll()? On traditional Unixes, +# select() has a strange calling convention that makes it slow, or fail +# altogether, for high-numbered file descriptors. The point of poll() is to fix +# that, so on Unixes, we prefer poll(). +# +# On Windows, there is no poll() (or at least Python doesn't provide a wrapper +# for it), but that's OK, because on Windows, select() doesn't have this +# strange calling convention; plain select() works fine. +# +# So: on Windows we use select(), and everywhere else we use poll(). We also +# fall back to select() in case poll() is somehow broken or missing. + +if sys.version_info >= (3, 5): + # Modern Python, that retries syscalls by default + def _retry_on_intr(fn, timeout): + return fn(timeout) + + +else: + # Old and broken Pythons. + def _retry_on_intr(fn, timeout): + if timeout is None: + deadline = float("inf") + else: + deadline = monotonic() + timeout + + while True: + try: + return fn(timeout) + # OSError for 3 <= pyver < 3.5, select.error for pyver <= 2.7 + except (OSError, select.error) as e: + # 'e.args[0]' incantation works for both OSError and select.error + if e.args[0] != errno.EINTR: + raise + else: + timeout = deadline - monotonic() + if timeout < 0: + timeout = 0 + if timeout == float("inf"): + timeout = None + continue + + +def select_wait_for_socket(sock, read=False, write=False, timeout=None): + if not read and not write: + raise RuntimeError("must specify at least one of read=True, write=True") + rcheck = [] + wcheck = [] + if read: + rcheck.append(sock) + if write: + wcheck.append(sock) + # When doing a non-blocking connect, most systems signal success by + # marking the socket writable. Windows, though, signals success by marked + # it as "exceptional". We paper over the difference by checking the write + # sockets for both conditions. (The stdlib selectors module does the same + # thing.) + fn = partial(select.select, rcheck, wcheck, wcheck) + rready, wready, xready = _retry_on_intr(fn, timeout) + return bool(rready or wready or xready) + + +def poll_wait_for_socket(sock, read=False, write=False, timeout=None): + if not read and not write: + raise RuntimeError("must specify at least one of read=True, write=True") + mask = 0 + if read: + mask |= select.POLLIN + if write: + mask |= select.POLLOUT + poll_obj = select.poll() + poll_obj.register(sock, mask) + + # For some reason, poll() takes timeout in milliseconds + def do_poll(t): + if t is not None: + t *= 1000 + return poll_obj.poll(t) + + return bool(_retry_on_intr(do_poll, timeout)) + + +def null_wait_for_socket(*args, **kwargs): + raise NoWayToWaitForSocketError("no select-equivalent available") + + +def _have_working_poll(): + # Apparently some systems have a select.poll that fails as soon as you try + # to use it, either due to strange configuration or broken monkeypatching + # from libraries like eventlet/greenlet. + try: + poll_obj = select.poll() + _retry_on_intr(poll_obj.poll, 0) + except (AttributeError, OSError): + return False + else: + return True + + +def wait_for_socket(*args, **kwargs): + # We delay choosing which implementation to use until the first time we're + # called. We could do it at import time, but then we might make the wrong + # decision if someone goes wild with monkeypatching select.poll after + # we're imported. + global wait_for_socket + if _have_working_poll(): + wait_for_socket = poll_wait_for_socket + elif hasattr(select, "select"): + wait_for_socket = select_wait_for_socket + else: # Platform-specific: Appengine. + wait_for_socket = null_wait_for_socket + return wait_for_socket(*args, **kwargs) + + +def wait_for_read(sock, timeout=None): + """Waits for reading to be available on a given socket. + Returns True if the socket is readable, or False if the timeout expired. + """ + return wait_for_socket(sock, read=True, timeout=timeout) + + +def wait_for_write(sock, timeout=None): + """Waits for writing to be available on a given socket. + Returns True if the socket is readable, or False if the timeout expired. + """ + return wait_for_socket(sock, write=True, timeout=timeout) diff --git a/openpype/hosts/harmony/api/TB_sceneOpened.js b/openpype/hosts/harmony/api/TB_sceneOpened.js index a284a6ec5c..48daf094dd 100644 --- a/openpype/hosts/harmony/api/TB_sceneOpened.js +++ b/openpype/hosts/harmony/api/TB_sceneOpened.js @@ -13,7 +13,7 @@ var LD_OPENHARMONY_PATH = System.getenv('LIB_OPENHARMONY_PATH'); LD_OPENHARMONY_PATH = LD_OPENHARMONY_PATH + '/openHarmony.js'; LD_OPENHARMONY_PATH = LD_OPENHARMONY_PATH.replace(/\\/g, "/"); include(LD_OPENHARMONY_PATH); -this.__proto__['$'] = $; +//this.__proto__['$'] = $; function Client() { var self = this; diff --git a/openpype/hosts/harmony/plugins/load/load_template.py b/openpype/hosts/harmony/plugins/load/load_template.py index f3c69a9104..a78a1bf1ec 100644 --- a/openpype/hosts/harmony/plugins/load/load_template.py +++ b/openpype/hosts/harmony/plugins/load/load_template.py @@ -82,7 +82,6 @@ class TemplateLoader(load.LoaderPlugin): node = harmony.find_node_by_name(node_name, "GROUP") self_name = self.__class__.__name__ - update_and_replace = False if is_representation_from_latest(representation): self._set_green(node) else: diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 5e9b9094a7..af825c052a 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -147,13 +147,13 @@ class CollectFarmRender(publish.AbstractCollectRender): attachTo=False, setMembers=[node], publish=info[4], - review=False, renderer=None, priority=50, name=node.split("/")[1], family="render.farm", families=["render.farm"], + farm=True, resolutionWidth=context.data["resolutionWidth"], resolutionHeight=context.data["resolutionHeight"], @@ -174,7 +174,6 @@ class CollectFarmRender(publish.AbstractCollectRender): outputFormat=info[1], outputStartFrame=info[3], leadingZeros=info[2], - toBeRenderedOn='deadline', ignoreFrameHandleCheck=True ) diff --git a/openpype/hosts/harmony/plugins/publish/extract_render.py b/openpype/hosts/harmony/plugins/publish/extract_render.py index 5825d95a4a..96a375716b 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_render.py +++ b/openpype/hosts/harmony/plugins/publish/extract_render.py @@ -59,8 +59,8 @@ class ExtractRender(pyblish.api.InstancePlugin): args = [application_path, "-batch", "-frames", str(frame_start), str(frame_end), - "-scene", scene_path] - self.log.info(f"running [ {application_path} {' '.join(args)}") + scene_path] + self.log.info(f"running: {' '.join(args)}") proc = subprocess.Popen( args, stdout=subprocess.PIPE, diff --git a/openpype/hosts/hiero/api/menu.py b/openpype/hosts/hiero/api/menu.py index 9967e9c875..ca611570cc 100644 --- a/openpype/hosts/hiero/api/menu.py +++ b/openpype/hosts/hiero/api/menu.py @@ -95,18 +95,18 @@ def menu_install(): menu.addSeparator() - publish_action = menu.addAction("Publish...") - publish_action.setIcon(QtGui.QIcon("icons:Output.png")) - publish_action.triggered.connect( - lambda *args: publish(hiero.ui.mainWindow()) - ) - creator_action = menu.addAction("Create...") creator_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) creator_action.triggered.connect( lambda: host_tools.show_creator(parent=main_window) ) + publish_action = menu.addAction("Publish...") + publish_action.setIcon(QtGui.QIcon("icons:Output.png")) + publish_action.triggered.connect( + lambda *args: publish(hiero.ui.mainWindow()) + ) + loader_action = menu.addAction("Load...") loader_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) loader_action.triggered.connect( diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 65a4009756..b0c73e41fb 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -11,7 +11,6 @@ import qargparse from openpype.settings import get_current_project_settings from openpype.lib import Logger from openpype.pipeline import LoaderPlugin, LegacyCreator -from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline.load import get_representation_path_from_context from . import lib @@ -32,7 +31,7 @@ def load_stylesheet(): class CreatorWidget(QtWidgets.QDialog): # output items - items = dict() + items = {} def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) @@ -317,20 +316,6 @@ class Spacer(QtWidgets.QWidget): self.setLayout(layout) -def get_reference_node_parents(ref): - """Return all parent reference nodes of reference node - - Args: - ref (str): reference node. - - Returns: - list: The upstream parent reference nodes. - - """ - parents = [] - return parents - - class SequenceLoader(LoaderPlugin): """A basic SequenceLoader for Resolve @@ -508,9 +493,8 @@ class ClipLoader: joint `data` key with asset.data dict into the representation """ - asset_name = self.context["representation"]["context"]["asset"] - asset_doc = get_current_project_asset(asset_name) - log.debug("__ asset_doc: {}".format(pformat(asset_doc))) + + asset_doc = self.context["asset"] self.data["assetData"] = asset_doc["data"] def _make_track_item(self, source_bin_item, audio=False): @@ -658,8 +642,8 @@ class PublishClip: Returns: hiero.core.TrackItem: hiero track item object with pype tag """ - vertical_clip_match = dict() - tag_data = dict() + vertical_clip_match = {} + tag_data = {} types = { "shot": "shot", "folder": "folder", @@ -721,9 +705,10 @@ class PublishClip: self._create_parents() def convert(self): - # solve track item data and add them to tag data - self._convert_to_tag_data() + tag_hierarchy_data = self._convert_to_tag_data() + + self.tag_data.update(tag_hierarchy_data) # if track name is in review track name and also if driving track name # is not in review track name: skip tag creation @@ -737,16 +722,23 @@ class PublishClip: if self.rename: # rename track item self.track_item.setName(new_name) - self.tag_data["asset"] = new_name + self.tag_data["asset_name"] = new_name else: - self.tag_data["asset"] = self.ti_name + self.tag_data["asset_name"] = self.ti_name self.tag_data["hierarchyData"]["shot"] = self.ti_name + # AYON unique identifier + folder_path = "/{}/{}".format( + tag_hierarchy_data["hierarchy"], + self.tag_data["asset_name"] + ) + self.tag_data["folderPath"] = folder_path if self.tag_data["heroTrack"] and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) else: self.tag_data.update({"reviewTrack": None}) + # TODO: remove debug print log.debug("___ self.tag_data: {}".format( pformat(self.tag_data) )) @@ -905,7 +897,7 @@ class PublishClip: tag_hierarchy_data = hero_data # add data to return data dict - self.tag_data.update(tag_hierarchy_data) + return tag_hierarchy_data def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve tag data from hierarchy data and templates. """ diff --git a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py index d455ad4a4e..fcb1ab27a0 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py +++ b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py @@ -43,7 +43,6 @@ class CollectClipEffects(pyblish.api.InstancePlugin): if review and review_track_index == _track_index: continue for sitem in sub_track_items: - effect = None # make sure this subtrack item is relative of track item if ((track_item not in sitem.linkedItems()) and (len(sitem.linkedItems()) > 0)): @@ -53,7 +52,6 @@ class CollectClipEffects(pyblish.api.InstancePlugin): continue effect = self.add_effect(_track_index, sitem) - if effect: effects.update(effect) diff --git a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py index 982a34efd6..79bf67b336 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py +++ b/openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py @@ -5,6 +5,8 @@ import json import pyblish.api +from openpype.client import get_asset_name_identifier + class CollectFrameTagInstances(pyblish.api.ContextPlugin): """Collect frames from tags. @@ -99,6 +101,9 @@ class CollectFrameTagInstances(pyblish.api.ContextPlugin): # first collect all available subset tag frames subset_data = {} + context_asset_doc = context.data["assetEntity"] + context_asset_name = get_asset_name_identifier(context_asset_doc) + for tag_data in sequence_tags: frame = int(tag_data["start"]) @@ -115,7 +120,7 @@ class CollectFrameTagInstances(pyblish.api.ContextPlugin): subset_data[subset] = { "frames": [frame], "format": tag_data["format"], - "asset": context.data["assetEntity"]["name"] + "asset": context_asset_name } return subset_data diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 3f9da2cf60..590d7b7050 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -1,9 +1,12 @@ import pyblish + +from openpype import AYON_SERVER_ENABLED from openpype.pipeline.editorial import is_overlapping_otio_ranges + from openpype.hosts.hiero import api as phiero from openpype.hosts.hiero.api.otio import hiero_export -import hiero +import hiero # # developer reload modules from pprint import pformat @@ -80,25 +83,24 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if k not in ("id", "applieswhole", "label") }) - asset = tag_data["asset"] + asset, asset_name = self._get_asset_data(tag_data) + subset = tag_data["subset"] # insert family into families - family = tag_data["family"] families = [str(f) for f in tag_data["families"]] - families.insert(0, str(family)) # form label - label = asset - if asset != clip_name: + label = "{} -".format(asset) + if asset_name != clip_name: label += " ({})".format(clip_name) label += " {}".format(subset) - label += " {}".format("[" + ", ".join(families) + "]") data.update({ "name": "{}_{}".format(asset, subset), "label": label, "asset": asset, + "asset_name": asset_name, "item": track_item, "families": families, "publish": tag_data["publish"], @@ -176,9 +178,9 @@ class PrecollectInstances(pyblish.api.ContextPlugin): }) def create_shot_instance(self, context, **data): + subset = "shotMain" master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") - asset = data.get("asset") item = data.get("item") clip_name = item.name() @@ -189,23 +191,21 @@ class PrecollectInstances(pyblish.api.ContextPlugin): return asset = data["asset"] - subset = "shotMain" + asset_name = data["asset_name"] # insert family into families family = "shot" # form label - label = asset - if asset != clip_name: + label = "{} -".format(asset) + if asset_name != clip_name: label += " ({}) ".format(clip_name) label += " {}".format(subset) - label += " [{}]".format(family) data.update({ "name": "{}_{}".format(asset, subset), "label": label, "subset": subset, - "asset": asset, "family": family, "families": [] }) @@ -215,7 +215,33 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) + def _get_asset_data(self, data): + folder_path = data.pop("folderPath", None) + + if data.get("asset_name"): + asset_name = data["asset_name"] + else: + asset_name = data["asset"] + + # backward compatibility for clip tags + # which are missing folderPath key + # TODO remove this in future versions + if not folder_path: + hierarchy_path = data["hierarchy"] + folder_path = "/{}/{}".format( + hierarchy_path, + asset_name + ) + + if AYON_SERVER_ENABLED: + asset = folder_path + else: + asset = asset_name + + return asset, asset_name + def create_audio_instance(self, context, **data): + subset = "audioMain" master_layer = data.get("heroTrack") if not master_layer: @@ -230,23 +256,21 @@ class PrecollectInstances(pyblish.api.ContextPlugin): return asset = data["asset"] - subset = "audioMain" + asset_name = data["asset_name"] # insert family into families family = "audio" # form label - label = asset - if asset != clip_name: + label = "{} -".format(asset) + if asset_name != clip_name: label += " ({}) ".format(clip_name) label += " {}".format(subset) - label += " [{}]".format(family) data.update({ "name": "{}_{}".format(asset, subset), "label": label, "subset": subset, - "asset": asset, "family": family, "families": ["clip"] }) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index 5a66581531..8abb0885c6 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -7,6 +7,7 @@ from qtpy.QtGui import QPixmap import hiero.ui +from openpype import AYON_SERVER_ENABLED from openpype.hosts.hiero.api.otio import hiero_export @@ -17,9 +18,11 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.491 def process(self, context): - asset = context.data["asset"] - subset = "workfile" + asset_name = asset + if AYON_SERVER_ENABLED: + asset_name = asset_name.split("/")[-1] + active_timeline = hiero.ui.activeSequence() project = active_timeline.project() fps = active_timeline.framerate().toFloat() @@ -27,7 +30,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): # adding otio timeline to context otio_timeline = hiero_export.create_otio_timeline() - # get workfile thumnail paths + # get workfile thumbnail paths tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_") thumbnail_name = "workfile_thumbnail.png" thumbnail_path = os.path.join(tmp_staging, thumbnail_name) @@ -49,8 +52,8 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): } # get workfile paths - curent_file = project.path() - staging_dir, base_name = os.path.split(curent_file) + current_file = project.path() + staging_dir, base_name = os.path.split(current_file) # creating workfile representation workfile_representation = { @@ -59,13 +62,16 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): 'files': base_name, "stagingDir": staging_dir, } - + family = "workfile" instance_data = { - "name": "{}_{}".format(asset, subset), - "asset": asset, - "subset": "{}{}".format(asset, subset.capitalize()), + "label": "{} - {}Main".format( + asset, family), + "name": "{}_{}".format(asset_name, family), + "asset": context.data["asset"], + # TODO use 'get_subset_name' + "subset": "{}{}Main".format(asset_name, family.capitalize()), "item": project, - "family": "workfile", + "family": family, "families": [], "representations": [workfile_representation, thumb_representation] } @@ -78,7 +84,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): "activeProject": project, "activeTimeline": active_timeline, "otioTimeline": otio_timeline, - "currentFile": curent_file, + "currentFile": current_file, "colorspace": self.get_colorspace(project), "fps": fps } diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py index 767f7c30f7..37370497a5 100644 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py +++ b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py @@ -1,5 +1,6 @@ from pyblish import api -from openpype.client import get_assets + +from openpype.client import get_assets, get_asset_name_identifier class CollectAssetBuilds(api.ContextPlugin): @@ -19,10 +20,13 @@ class CollectAssetBuilds(api.ContextPlugin): def process(self, context): project_name = context.data["projectName"] asset_builds = {} - for asset in get_assets(project_name): - if asset["data"]["entityType"] == "AssetBuild": - self.log.debug("Found \"{}\" in database.".format(asset)) - asset_builds[asset["name"]] = asset + for asset_doc in get_assets(project_name): + if asset_doc["data"].get("entityType") != "AssetBuild": + continue + + asset_name = get_asset_name_identifier(asset_doc) + self.log.debug("Found \"{}\" in database.".format(asset_doc)) + asset_builds[asset_name] = asset_doc for instance in context: if instance.data["family"] != "clip": @@ -50,9 +54,7 @@ class CollectAssetBuilds(api.ContextPlugin): # Collect asset builds. data = {"assetbuilds": []} for name in asset_names: - data["assetbuilds"].append( - asset_builds[name] - ) + data["assetbuilds"].append(asset_builds[name]) self.log.debug( "Found asset builds: {}".format(data["assetbuilds"]) ) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index 7047644225..cc40b9df1c 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -1,7 +1,7 @@ import attr import hou from openpype.hosts.houdini.api.lib import get_color_management_preferences - +from openpype.pipeline.colorspace import get_display_view_colorspace_name @attr.s class LayerMetadata(object): @@ -54,3 +54,16 @@ class ARenderProduct(object): ) ] return colorspace_data + + +def get_default_display_view_colorspace(): + """Returns the colorspace attribute of the default (display, view) pair. + + It's used for 'ociocolorspace' parm in OpenGL Node.""" + + prefs = get_color_management_preferences() + return get_display_view_colorspace_name( + config_path=prefs["config"], + display=prefs["display"], + view=prefs["view"] + ) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 7c6122cffe..14662dc419 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -57,28 +57,31 @@ def create_interactive(creator_identifier, **kwargs): list: The created instances. """ - - # TODO Use Qt instead - result, variant = hou.ui.readInput('Define variant name', - buttons=("Ok", "Cancel"), - initial_contents='Main', - title="Define variant", - help="Set the variant for the " - "publish instance", - close_choice=1) - if result == 1: - # User interrupted - return - variant = variant.strip() - if not variant: - raise RuntimeError("Empty variant value entered.") - host = registered_host() context = CreateContext(host) creator = context.manual_creators.get(creator_identifier) if not creator: - raise RuntimeError("Invalid creator identifier: " - "{}".format(creator_identifier)) + raise RuntimeError("Invalid creator identifier: {}".format( + creator_identifier) + ) + + # TODO Use Qt instead + result, variant = hou.ui.readInput( + "Define variant name", + buttons=("Ok", "Cancel"), + initial_contents=creator.get_default_variant(), + title="Define variant", + help="Set the variant for the publish instance", + close_choice=1 + ) + + if result == 1: + # User interrupted + return + + variant = variant.strip() + if not variant: + raise RuntimeError("Empty variant value entered.") # TODO: Once more elaborate unique create behavior should exist per Creator # instead of per network editor area then we should move this from here @@ -170,6 +173,7 @@ def install(): os.remove(filepath) icon = get_openpype_icon_filepath() + tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" # Create context only to get creator plugins, so we don't reset and only # populate what we need to retrieve the list of creator plugins @@ -194,14 +198,14 @@ def install(): if not network_categories: continue - key = "openpype_create.{}".format(identifier) + key = "ayon_create.{}".format(identifier) log.debug(f"Registering {key}") script = CREATE_SCRIPT.format(identifier=identifier) data = { "script": script, "language": hou.scriptLanguage.Python, "icon": icon, - "help": "Create OpenPype publish instance for {}".format( + "help": "Create Ayon publish instance for {}".format( creator.label ), "help_url": None, @@ -210,7 +214,7 @@ def install(): "cop_viewer_categories": [], "network_op_type": None, "viewer_op_type": None, - "locations": ["OpenPype"] + "locations": [tab_menu_label] } label = "Create {}".format(creator.label) tool = hou.shelves.tool(key) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 94c4529ae2..614052431f 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import sys import os +import errno import re import uuid import logging @@ -9,9 +10,22 @@ import json import six -from openpype.client import get_asset_by_name -from openpype.pipeline import get_current_project_name, get_current_asset_name +from openpype.lib import StringTemplate +from openpype.client import get_project, get_asset_by_name +from openpype.settings import get_current_project_settings +from openpype.pipeline import ( + Anatomy, + get_current_project_name, + get_current_asset_name, + registered_host, + get_current_context, + get_current_host_name, +) +from openpype.pipeline.create import CreateContext +from openpype.pipeline.template_data import get_template_data from openpype.pipeline.context_tools import get_current_project_asset +from openpype.widgets import popup +from openpype.tools.utils.host_tools import get_tool_by_name import hou @@ -22,9 +36,12 @@ log = logging.getLogger(__name__) JSON_PREFIX = "JSON:::" -def get_asset_fps(): +def get_asset_fps(asset_doc=None): """Return current asset fps.""" - return get_current_project_asset()["data"].get("fps") + + if asset_doc is None: + asset_doc = get_current_project_asset(fields=["data.fps"]) + return asset_doc["data"]["fps"] def set_id(node, unique_id, overwrite=False): @@ -185,43 +202,16 @@ def get_output_parameter(node): return node.parm("sopoutput") elif node_type == "Composite": return node.parm("copoutput") - elif node_type == "Channel": - return node.parm("chopoutput") - elif node_type == "Dynamics" or \ - (node_type == "ROP Output Driver" and category == "Dop"): - return node.parm("dopoutput") - elif node_type == "Alfred": - return node.parm("alf_diskfile") - elif node_type == "RenderMan" or node_type == "RenderMan RIS": - return node.parm("ri_display") - elif node_type == "Redshift": - return node.parm("RS_returnmePrefix") - elif node_type == "Mantra": - return node.parm("vm_picture") - elif node_type == "Wedge": - driver_node = node.node(node.parm("driver").eval()) - if driver_node: - return get_output_parameter(driver_node) - elif node_type == "Arnold": - return node.parm("ar_picture") - elif node_type == "HQueue Simulation": - inner_node = node.node(node.parm("hq_driver").eval()) - if inner_node: - return get_output_parameter(inner_node) - elif node_type == "ROP Alembic Output": - return node.parm("filename") - elif node_type == "Redshift": - return node.parm("RS_returnmePrefix") - elif node_type == "Alembic": - return node.parm("filename") - elif node_type == "Shotgun Mantra": - return node.parm("sgtk_vm_picture") - elif node_type == "Shotgun Alembic": - return node.parm("filename") - elif node_type == "Bake Texture": - return node.parm("vm_uvoutputpicture1") - elif node_type == "OpenGL": + elif node_type == "opengl": return node.parm("picture") + elif node_type == "arnold": + if node.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": + if node.evalParm("soho_outputmode"): + return node.parm("soho_diskfile") elif node_type == "Octane": return node.parm("HO_img_fileName") elif node_type == "Fetch": @@ -252,8 +242,6 @@ def validate_fps(): if current_fps != fps: - from openpype.widgets import popup - # Find main window parent = hou.ui.mainQtWindow() if parent is None: @@ -413,52 +401,61 @@ def imprint(node, data, update=False): return current_parms = {p.name(): p for p in node.spareParms()} - update_parms = [] - templates = [] + update_parm_templates = [] + new_parm_templates = [] for key, value in data.items(): if value is None: continue - parm = get_template_from_value(key, value) + parm_template = get_template_from_value(key, value) if key in current_parms: - if node.evalParm(key) == data[key]: + if node.evalParm(key) == value: continue if not update: log.debug(f"{key} already exists on {node}") else: log.debug(f"replacing {key}") - update_parms.append(parm) + update_parm_templates.append(parm_template) continue - templates.append(parm) + new_parm_templates.append(parm_template) - parm_group = node.parmTemplateGroup() - parm_folder = parm_group.findFolder("Extra") - - # if folder doesn't exist yet, create one and append to it, - # else append to existing one - if not parm_folder: - parm_folder = hou.FolderParmTemplate("folder", "Extra") - parm_folder.setParmTemplates(templates) - parm_group.append(parm_folder) - else: - for template in templates: - parm_group.appendToFolder(parm_folder, template) - # this is needed because the pointer to folder - # is for some reason lost every call to `appendToFolder()` - parm_folder = parm_group.findFolder("Extra") - - node.setParmTemplateGroup(parm_group) - - # TODO: Updating is done here, by calling probably deprecated functions. - # This needs to be addressed in the future. - if not update_parms: + if not new_parm_templates and not update_parm_templates: return - for parm in update_parms: - node.replaceSpareParmTuple(parm.name(), parm) + parm_group = node.parmTemplateGroup() + + # Add new parm templates + if new_parm_templates: + parm_folder = parm_group.findFolder("Extra") + + # if folder doesn't exist yet, create one and append to it, + # else append to existing one + if not parm_folder: + parm_folder = hou.FolderParmTemplate("folder", "Extra") + parm_folder.setParmTemplates(new_parm_templates) + parm_group.append(parm_folder) + else: + # Add to parm template folder instance then replace with updated + # one in parm template group + for template in new_parm_templates: + parm_folder.addParmTemplate(template) + parm_group.replace(parm_folder.name(), parm_folder) + + # Update existing parm templates + for parm_template in update_parm_templates: + parm_group.replace(parm_template.name(), parm_template) + + # When replacing a parm with a parm of the same name it preserves its + # value if before the replacement the parm was not at the default, + # because it has a value override set. Since we're trying to update the + # parm by using the new value as `default` we enforce the parm is at + # default state + node.parm(parm_template.name()).revertToDefaults() + + node.setParmTemplateGroup(parm_group) def lsattr(attr, value=None, root="/"): @@ -567,14 +564,19 @@ def maintained_selection(): def reset_framerange(): - """Set frame range to current asset""" + """Set frame range and FPS to current asset""" + # Get asset data project_name = get_current_project_name() asset_name = get_current_asset_name() # Get the asset ID from the database for the asset of current context asset_doc = get_asset_by_name(project_name, asset_name) asset_data = asset_doc["data"] + # Get FPS + fps = get_asset_fps(asset_doc) + + # Get Start and End Frames frame_start = asset_data.get("frameStart") frame_end = asset_data.get("frameEnd") @@ -588,6 +590,9 @@ def reset_framerange(): frame_start -= int(handle_start) frame_end += int(handle_end) + # Set frame range and FPS + print("Setting scene FPS to {}".format(int(fps))) + set_scene_fps(fps) hou.playbar.setFrameRange(frame_start, frame_end) hou.playbar.setPlaybackRange(frame_start, frame_end) hou.setFrame(frame_start) @@ -632,29 +637,56 @@ def get_template_from_value(key, value): return parm -def get_frame_data(node): - """Get the frame data: start frame, end frame and steps. +def get_frame_data(node, log=None): + """Get the frame data: `frameStartHandle`, `frameEndHandle` + and `byFrameStep`. + + This function uses Houdini node's `trange`, `t1, `t2` and `t3` + parameters as the source of truth for the full inclusive frame + range to render, as such these are considered as the frame + range including the handles. + + The non-inclusive frame start and frame end without handles + can be computed by subtracting the handles from the inclusive + frame range. Args: - node(hou.Node) + node (hou.Node): ROP node to retrieve frame range from, + the frame range is assumed to be the frame range + *including* the start and end handles. Returns: - dict: frame data for star, end and steps. + dict: frame data for `frameStartHandle`, `frameEndHandle` + and `byFrameStep`. """ + + if log is None: + log = self.log + data = {} if node.parm("trange") is None: - + log.debug( + "Node has no 'trange' parameter: {}".format(node.path()) + ) return data if node.evalParm("trange") == 0: - self.log.debug("trange is 0") - return data + data["frameStartHandle"] = hou.intFrame() + data["frameEndHandle"] = hou.intFrame() + data["byFrameStep"] = 1.0 - data["frameStart"] = node.evalParm("f1") - data["frameEnd"] = node.evalParm("f2") - data["steps"] = node.evalParm("f3") + log.info( + "Node '{}' has 'Render current frame' set.\n" + "Asset Handles are ignored.\n" + "frameStart and frameEnd are set to the " + "current frame.".format(node.path()) + ) + else: + data["frameStartHandle"] = int(node.evalParm("f1")) + data["frameEndHandle"] = int(node.evalParm("f2")) + data["byFrameStep"] = node.evalParm("f3") return data @@ -733,3 +765,330 @@ def get_color_management_preferences(): "display": hou.Color.ocio_defaultDisplay(), "view": hou.Color.ocio_defaultView() } + + +def get_obj_node_output(obj_node): + """Find output node. + + If the node has any output node return the + output node with the minimum `outputidx`. + When no output is present return the node + with the display flag set. If no output node is + detected then None is returned. + + Arguments: + node (hou.Node): The node to retrieve a single + the output node for. + + Returns: + Optional[hou.Node]: The child output node. + + """ + + outputs = obj_node.subnetOutputs() + if not outputs: + return + + elif len(outputs) == 1: + return outputs[0] + + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) + + +def get_output_children(output_node, include_sops=True): + """Recursively return a list of all output nodes + contained in this node including this node. + + It works in a similar manner to output_node.allNodes(). + """ + out_list = [output_node] + + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + for child in output_node.children(): + out_list += get_output_children(child, include_sops=include_sops) + + elif include_sops and \ + output_node.childTypeCategory() == hou.sopNodeTypeCategory(): + out = get_obj_node_output(output_node) + if out: + out_list += [out] + + return out_list + + +def get_resolution_from_doc(doc): + """Get resolution from the given asset document. """ + + if not doc or "data" not in doc: + print("Entered document is not valid. \"{}\"".format(str(doc))) + return None + + resolution_width = doc["data"].get("resolutionWidth") + resolution_height = doc["data"].get("resolutionHeight") + + # Make sure both width and height are set + if resolution_width is None or resolution_height is None: + print("No resolution information found for \"{}\"".format(doc["name"])) + return None + + return int(resolution_width), int(resolution_height) + + +def set_camera_resolution(camera, asset_doc=None): + """Apply resolution to camera from asset document of the publish""" + + if not asset_doc: + asset_doc = get_current_project_asset() + + resolution = get_resolution_from_doc(asset_doc) + + if resolution: + print("Setting camera resolution: {} -> {}x{}".format( + camera.name(), resolution[0], resolution[1] + )) + camera.parm("resx").set(resolution[0]) + camera.parm("resy").set(resolution[1]) + + +def get_camera_from_container(container): + """Get camera from container node. """ + + cameras = container.recursiveGlob( + "*", + filter=hou.nodeTypeFilter.ObjCamera, + include_subnets=False + ) + + assert len(cameras) == 1, "Camera instance must have only one camera" + return cameras[0] + + +def get_current_context_template_data_with_asset_data(): + """ + TODOs: + Support both 'assetData' and 'folderData' in future. + """ + + context = get_current_context() + project_name = context["project_name"] + asset_name = context["asset_name"] + task_name = context["task_name"] + host_name = get_current_host_name() + + anatomy = Anatomy(project_name) + project_doc = get_project(project_name) + asset_doc = get_asset_by_name(project_name, asset_name) + + # get context specific vars + asset_data = asset_doc["data"] + + # compute `frameStartHandle` and `frameEndHandle` + frame_start = asset_data.get("frameStart") + frame_end = asset_data.get("frameEnd") + handle_start = asset_data.get("handleStart") + handle_end = asset_data.get("handleEnd") + if frame_start is not None and handle_start is not None: + asset_data["frameStartHandle"] = frame_start - handle_start + + if frame_end is not None and handle_end is not None: + asset_data["frameEndHandle"] = frame_end + handle_end + + template_data = get_template_data( + project_doc, asset_doc, task_name, host_name + ) + template_data["root"] = anatomy.roots + template_data["assetData"] = asset_data + + return template_data + + +def get_context_var_changes(): + """get context var changes.""" + + houdini_vars_to_update = {} + + project_settings = get_current_project_settings() + houdini_vars_settings = \ + project_settings["houdini"]["general"]["update_houdini_var_context"] + + if not houdini_vars_settings["enabled"]: + return houdini_vars_to_update + + houdini_vars = houdini_vars_settings["houdini_vars"] + + # No vars specified - nothing to do + if not houdini_vars: + return houdini_vars_to_update + + # Get Template data + template_data = get_current_context_template_data_with_asset_data() + + # Set Houdini Vars + for item in houdini_vars: + # For consistency reasons we always force all vars to be uppercase + # Also remove any leading, and trailing whitespaces. + var = item["var"].strip().upper() + + # get and resolve template in value + item_value = StringTemplate.format_template( + item["value"], + template_data + ) + + if var == "JOB" and item_value == "": + # sync $JOB to $HIP if $JOB is empty + item_value = os.environ["HIP"] + + if item["is_directory"]: + item_value = item_value.replace("\\", "/") + + current_value = hou.hscript("echo -n `${}`".format(var))[0] + + if current_value != item_value: + houdini_vars_to_update[var] = ( + current_value, item_value, item["is_directory"] + ) + + return houdini_vars_to_update + + +def update_houdini_vars_context(): + """Update asset context variables""" + + for var, (_old, new, is_directory) in get_context_var_changes().items(): + if is_directory: + try: + os.makedirs(new) + except OSError as e: + if e.errno != errno.EEXIST: + print( + "Failed to create ${} dir. Maybe due to " + "insufficient permissions.".format(var) + ) + + hou.hscript("set {}={}".format(var, new)) + os.environ[var] = new + print("Updated ${} to {}".format(var, new)) + + +def update_houdini_vars_context_dialog(): + """Show pop-up to update asset context variables""" + update_vars = get_context_var_changes() + if not update_vars: + # Nothing to change + print("Nothing to change, Houdini vars are already up to date.") + return + + message = "\n".join( + "${}: {} -> {}".format(var, old or "None", new or "None") + for var, (old, new, _is_directory) in update_vars.items() + ) + + # TODO: Use better UI! + parent = hou.ui.mainQtWindow() + dialog = popup.Popup(parent=parent) + dialog.setModal(True) + dialog.setWindowTitle("Houdini scene has outdated asset variables") + dialog.setMessage(message) + dialog.setButtonText("Fix") + + # on_show is the Fix button clicked callback + dialog.on_clicked.connect(update_houdini_vars_context) + + dialog.show() + + +def publisher_show_and_publish(comment=None): + """Open publisher window and trigger publishing action. + + Args: + comment (Optional[str]): Comment to set in publisher window. + """ + + main_window = get_main_window() + publisher_window = get_tool_by_name( + tool_name="publisher", + parent=main_window, + ) + publisher_window.show_and_publish(comment) + + +def find_rop_input_dependencies(input_tuple): + """Self publish from ROP nodes. + + Arguments: + tuple (hou.RopNode.inputDependencies) which can be a nested tuples + represents the input dependencies of the ROP node, consisting of ROPs, + and the frames that need to be be rendered prior to rendering the ROP. + + Returns: + list of the RopNode.path() that can be found inside + the input tuple. + """ + + out_list = [] + if isinstance(input_tuple[0], hou.RopNode): + return input_tuple[0].path() + + if isinstance(input_tuple[0], tuple): + for item in input_tuple: + out_list.append(find_rop_input_dependencies(item)) + + return out_list + + +def self_publish(): + """Self publish from ROP nodes. + + Firstly, it gets the node and its dependencies. + Then, it deactivates all other ROPs + And finaly, it triggers the publishing action. + """ + + result, comment = hou.ui.readInput( + "Add Publish Comment", + buttons=("Publish", "Cancel"), + title="Publish comment", + close_choice=1 + ) + + if result: + return + + current_node = hou.node(".") + inputs_paths = find_rop_input_dependencies( + current_node.inputDependencies() + ) + inputs_paths.append(current_node.path()) + + host = registered_host() + context = CreateContext(host, reset=True) + + for instance in context.instances: + node_path = instance.data.get("instance_node") + instance["active"] = node_path and node_path in inputs_paths + + context.save_changes() + + publisher_show_and_publish(comment) + + +def add_self_publish_button(node): + """Adds a self publish button to the rop node.""" + + label = os.environ.get("AVALON_LABEL") or "AYON" + + button_parm = hou.ButtonParmTemplate( + "ayon_self_publish", + "{} Publish".format(label), + script_callback="from openpype.hosts.houdini.api.lib import " + "self_publish; self_publish()", + script_callback_language=hou.scriptLanguage.Python, + join_with_next=True + ) + + template = node.parmTemplateGroup() + template.insertBefore((0,), button_parm) + node.setParmTemplateGroup(template) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 8a26bbb504..d0f45c36b5 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -3,7 +3,6 @@ import os import sys import logging -import contextlib import hou # noqa @@ -14,6 +13,7 @@ import pyblish.api from openpype.pipeline import ( register_creator_plugin_path, register_loader_plugin_path, + register_inventory_action_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.load import any_outdated_containers @@ -25,7 +25,6 @@ from openpype.lib import ( emit_event, ) -from .lib import get_asset_fps log = logging.getLogger("openpype.hosts.houdini") @@ -56,6 +55,7 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) + register_inventory_action_path(INVENTORY_PATH) log.info("Installing callbacks ... ") # register_event_callback("init", on_init) @@ -65,15 +65,7 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_event_callback("open", on_open) register_event_callback("new", on_new) - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - self._has_been_setup = True - # add houdini vendor packages - hou_pythonpath = os.path.join(HOUDINI_HOST_DIR, "vendor") - - sys.path.append(hou_pythonpath) # Set asset settings for the empty scene directly after launch of # Houdini so it initializes into the correct scene FPS, @@ -299,11 +291,36 @@ def on_save(): log.info("Running callback on save..") + # update houdini vars + lib.update_houdini_vars_context_dialog() + nodes = lib.get_id_required_nodes() for node, new_id in lib.generate_ids(nodes): lib.set_id(node, new_id, overwrite=False) +def _show_outdated_content_popup(): + # Get main window + parent = lib.get_main_window() + if parent is None: + log.info("Skipping outdated content pop-up " + "because Houdini window can't be found.") + else: + from openpype.widgets import popup + + # Show outdated pop-up + def _on_show_inventory(): + from openpype.tools.utils import host_tools + host_tools.show_scene_inventory(parent=parent) + + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Houdini scene has outdated content") + dialog.setMessage("There are outdated containers in " + "your Houdini scene.") + dialog.on_clicked.connect(_on_show_inventory) + dialog.show() + + def on_open(): if not hou.isUIAvailable(): @@ -312,33 +329,26 @@ def on_open(): log.info("Running callback on open..") + # update houdini vars + lib.update_houdini_vars_context_dialog() + # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset lib.validate_fps() if any_outdated_containers(): - from openpype.widgets import popup - - log.warning("Scene has outdated content.") - - # Get main window parent = lib.get_main_window() if parent is None: - log.info("Skipping outdated content pop-up " - "because Houdini window can't be found.") + # When opening Houdini with last workfile on launch the UI hasn't + # initialized yet completely when the `on_open` callback triggers. + # We defer the dialog popup to wait for the UI to become available. + # We assume it will open because `hou.isUIAvailable()` returns True + import hdefereval + hdefereval.executeDeferred(_show_outdated_content_popup) else: + _show_outdated_content_popup() - # Show outdated pop-up - def _on_show_inventory(): - from openpype.tools.utils import host_tools - host_tools.show_scene_inventory(parent=parent) - - dialog = popup.Popup(parent=parent) - dialog.setWindowTitle("Houdini scene has outdated content") - dialog.setMessage("There are outdated containers in " - "your Houdini scene.") - dialog.on_clicked.connect(_on_show_inventory) - dialog.show() + log.warning("Scene has outdated content.") def on_new(): @@ -385,60 +395,5 @@ def _set_context_settings(): None """ - # Set new scene fps - fps = get_asset_fps() - print("Setting scene FPS to %i" % fps) - lib.set_scene_fps(fps) - lib.reset_framerange() - - -def on_pyblish_instance_toggled(instance, new_value, old_value): - """Toggle saver tool passthrough states on instance toggles.""" - @contextlib.contextmanager - def main_take(no_update=True): - """Enter root take during context""" - original_take = hou.takes.currentTake() - original_update_mode = hou.updateModeSetting() - root = hou.takes.rootTake() - has_changed = False - try: - if original_take != root: - has_changed = True - if no_update: - hou.setUpdateMode(hou.updateMode.Manual) - hou.takes.setCurrentTake(root) - yield - finally: - if has_changed: - if no_update: - hou.setUpdateMode(original_update_mode) - hou.takes.setCurrentTake(original_take) - - if not instance.data.get("_allowToggleBypass", True): - return - - nodes = instance[:] - if not nodes: - return - - # Assume instance node is first node - instance_node = nodes[0] - - if not hasattr(instance_node, "isBypassed"): - # Likely not a node that can actually be bypassed - log.debug("Can't bypass node: %s", instance_node.path()) - return - - if instance_node.isBypassed() != (not old_value): - print("%s old bypass state didn't match old instance state, " - "updating anyway.." % instance_node.path()) - - try: - # Go into the main take, because when in another take changing - # the bypass state of a note cannot be done due to it being locked - # by default. - with main_take(no_update=True): - instance_node.bypass(not new_value) - except hou.PermissionError as exc: - log.warning("%s - %s", instance_node.path(), exc) + lib.update_houdini_vars_context() diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 70c837205e..e162d0e461 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -6,6 +6,8 @@ from abc import ( ) import six import hou + +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import ( CreatorError, LegacyCreator, @@ -13,7 +15,7 @@ from openpype.pipeline import ( CreatedInstance ) from openpype.lib import BoolDef -from .lib import imprint, read, lsattr +from .lib import imprint, read, lsattr, add_self_publish_button class OpenPypeCreatorError(CreatorError): @@ -142,12 +144,13 @@ class HoudiniCreatorBase(object): @staticmethod def create_instance_node( - node_name, parent, - node_type="geometry"): + asset_name, node_name, parent, node_type="geometry" + ): # type: (str, str, str) -> hou.Node """Create node representing instance. Arguments: + asset_name (str): Asset name. node_name (str): Name of the new node. parent (str): Name of the parent node. node_type (str, optional): Type of the node. @@ -168,6 +171,7 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """Base class for most of the Houdini creator plugins.""" selected_nodes = [] settings_name = None + add_publish_button = False def create(self, subset_name, instance_data, pre_create_data): try: @@ -181,19 +185,29 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): if node_type is None: node_type = "geometry" + if AYON_SERVER_ENABLED: + asset_name = instance_data["folderPath"] + else: + asset_name = instance_data["asset"] + instance_node = self.create_instance_node( - subset_name, "/out", node_type) + asset_name, subset_name, "/out", node_type) self.customize_node_look(instance_node) instance_data["instance_node"] = instance_node.path() + instance_data["instance_id"] = instance_node.path() instance = CreatedInstance( self.family, subset_name, instance_data, self) self._add_instance_to_context(instance) - imprint(instance_node, instance.data_to_store()) + self.imprint(instance_node, instance.data_to_store()) + + if self.add_publish_button: + add_self_publish_button(instance_node) + return instance except hou.Error as er: @@ -222,25 +236,42 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self.cache_subsets(self.collection_shared_data) for instance in self.collection_shared_data[ "houdini_cached_subsets"].get(self.identifier, []): + + node_data = read(instance) + + # Node paths are always the full node path since that is unique + # Because it's the node's path it's not written into attributes + # but explicitly collected + node_path = instance.path() + node_data["instance_id"] = node_path + node_data["instance_node"] = node_path + created_instance = CreatedInstance.from_existing( - read(instance), self + node_data, self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = hou.node(created_inst.get("instance_node")) - new_values = { key: changes[key].new_value for key in changes.changed_keys } - imprint( + # Update parm templates and values + self.imprint( instance_node, new_values, update=True ) + def imprint(self, node, values, update=False): + # Never store instance node and instance id since that data comes + # from the node's path + values.pop("instance_node", None) + values.pop("instance_id", None) + imprint(node, values, update=update) + def remove_instances(self, instances): """Remove specified instance from the scene. @@ -296,9 +327,15 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """ return [hou.ropNodeTypeCategory()] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" + # Apply General Settings + houdini_general_settings = project_settings["houdini"]["general"] + self.add_publish_button = houdini_general_settings.get( + "add_self_publish_button", False) + + # Apply Creator Settings settings_name = self.settings_name if settings_name is None: settings_name = self.__class__.__name__ diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 21e44e494a..5093a90988 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -6,8 +6,12 @@ import platform from openpype.settings import get_project_settings from openpype.pipeline import get_current_project_name +from openpype.lib import StringTemplate + import hou +from .lib import get_current_context_template_data_with_asset_data + log = logging.getLogger("openpype.hosts.houdini.shelves") @@ -20,23 +24,33 @@ def generate_shelves(): # load configuration of houdini shelves project_name = get_current_project_name() project_settings = get_project_settings(project_name) - shelves_set_config = project_settings["houdini"]["shelves"] + shelves_configs = project_settings["houdini"]["shelves"] - if not shelves_set_config: + if not shelves_configs: log.debug("No custom shelves found in project settings.") return - for shelf_set_config in shelves_set_config: - shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') - shelf_set_os_filepath = shelf_set_filepath[current_os] - if shelf_set_os_filepath: - if not os.path.isfile(shelf_set_os_filepath): - log.error("Shelf path doesn't exist - " - "{}".format(shelf_set_os_filepath)) - continue + # Get Template data + template_data = get_current_context_template_data_with_asset_data() - hou.shelves.newShelfSet(file_path=shelf_set_os_filepath) - continue + for config in shelves_configs: + selected_option = config["options"] + shelf_set_config = config[selected_option] + + shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') + if shelf_set_filepath: + shelf_set_os_filepath = shelf_set_filepath[current_os] + if shelf_set_os_filepath: + shelf_set_os_filepath = get_path_using_template_data( + shelf_set_os_filepath, template_data + ) + if not os.path.isfile(shelf_set_os_filepath): + log.error("Shelf path doesn't exist - " + "{}".format(shelf_set_os_filepath)) + continue + + hou.shelves.loadFile(shelf_set_os_filepath) + continue shelf_set_name = shelf_set_config.get('shelf_set_name') if not shelf_set_name: @@ -81,7 +95,9 @@ def generate_shelves(): "script path of the tool.") continue - tool = get_or_create_tool(tool_definition, shelf) + tool = get_or_create_tool( + tool_definition, shelf, template_data + ) if not tool: continue @@ -144,7 +160,7 @@ def get_or_create_shelf(shelf_label): return new_shelf -def get_or_create_tool(tool_definition, shelf): +def get_or_create_tool(tool_definition, shelf, template_data): """This function verifies if the tool exists and updates it. If not, creates a new one. @@ -162,10 +178,16 @@ def get_or_create_tool(tool_definition, shelf): return script_path = tool_definition["script"] + script_path = get_path_using_template_data(script_path, template_data) if not script_path or not os.path.exists(script_path): log.warning("This path doesn't exist - {}".format(script_path)) return + icon_path = tool_definition["icon"] + if icon_path: + icon_path = get_path_using_template_data(icon_path, template_data) + tool_definition["icon"] = icon_path + existing_tools = shelf.tools() existing_tool = next( (tool for tool in existing_tools if tool.label() == tool_label), @@ -184,3 +206,10 @@ def get_or_create_tool(tool_definition, shelf): tool_name = re.sub(r"[^\w\d]+", "_", tool_label).lower() return hou.shelves.newTool(name=tool_name, **tool_definition) + + +def get_path_using_template_data(path, template_data): + path = StringTemplate.format_template(path, template_data) + path = path.replace("\\", "/") + + return path diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py index 12d08f7d83..437a14c723 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Creator plugin for creating Arnold ASS files.""" from openpype.hosts.houdini.api import plugin +from openpype.lib import BoolDef class CreateArnoldAss(plugin.HoudiniCreator): @@ -21,6 +22,9 @@ class CreateArnoldAss(plugin.HoudiniCreator): instance_data.pop("active", None) instance_data.update({"node_type": "arnold"}) + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateArnoldAss, self).create( subset_name, @@ -52,3 +56,15 @@ class CreateArnoldAss(plugin.HoudiniCreator): # Lock any parameters in this list to_lock = ["ar_ass_export_enable", "family", "id"] self.lock_parameters(instance_node, to_lock) + + def get_instance_attr_defs(self): + return [ + BoolDef("farm", + label="Submitting to Farm", + default=False) + ] + + def get_pre_create_attr_defs(self): + attrs = super().get_pre_create_attr_defs() + # Use same attributes as for instance attributes + return attrs + self.get_instance_attr_defs() diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index a3f31e7e94..4140919202 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -2,7 +2,8 @@ """Creator plugin for creating pointcache bgeo files.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance, CreatorError -from openpype.lib import EnumDef +import hou +from openpype.lib import EnumDef, BoolDef class CreateBGEO(plugin.HoudiniCreator): @@ -13,11 +14,13 @@ class CreateBGEO(plugin.HoudiniCreator): icon = "gears" def create(self, subset_name, instance_data, pre_create_data): - import hou instance_data.pop("active", None) instance_data.update({"node_type": "geometry"}) + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateBGEO, self).create( subset_name, @@ -58,6 +61,13 @@ class CreateBGEO(plugin.HoudiniCreator): instance_node.setParms(parms) + def get_instance_attr_defs(self): + return [ + BoolDef("farm", + label="Submitting to Farm", + default=False) + ] + def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() bgeo_enum = [ @@ -89,4 +99,10 @@ class CreateBGEO(plugin.HoudiniCreator): return attrs + [ EnumDef("bgeo_type", bgeo_enum, label="BGEO Options"), + ] + self.get_instance_attr_defs() + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() ] diff --git a/openpype/hosts/houdini/plugins/create/create_composite.py b/openpype/hosts/houdini/plugins/create/create_composite.py index 9d4f7969bb..52ea6fa054 100644 --- a/openpype/hosts/houdini/plugins/create/create_composite.py +++ b/openpype/hosts/houdini/plugins/create/create_composite.py @@ -45,6 +45,11 @@ class CreateCompositeSequence(plugin.HoudiniCreator): instance_node.setParms(parms) + # Manually set f1 & f2 to $FSTART and $FEND respectively + # to match other Houdini nodes default. + instance_node.parm("f1").setExpression("$FSTART") + instance_node.parm("f2").setExpression("$FEND") + # Lock any parameters in this list to_lock = ["prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py index c4093bfbc6..f670b55eb6 100644 --- a/openpype/hosts/houdini/plugins/create/create_hda.py +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -5,6 +5,7 @@ from openpype.client import ( get_subsets, ) from openpype.hosts.houdini.api import plugin +import hou class CreateHDA(plugin.HoudiniCreator): @@ -16,13 +17,13 @@ class CreateHDA(plugin.HoudiniCreator): icon = "gears" maintain_selection = False - def _check_existing(self, subset_name): + def _check_existing(self, asset_name, subset_name): # type: (str) -> bool """Check if existing subset name versions already exists.""" # Get all subsets of the current asset project_name = self.project_name asset_doc = get_asset_by_name( - project_name, self.data["asset"], fields=["_id"] + project_name, asset_name, fields=["_id"] ) subset_docs = get_subsets( project_name, asset_ids=[asset_doc["_id"]], fields=["name"] @@ -34,8 +35,8 @@ class CreateHDA(plugin.HoudiniCreator): return subset_name.lower() in existing_subset_names_low def create_instance_node( - self, node_name, parent, node_type="geometry"): - import hou + self, asset_name, node_name, parent, node_type="geometry" + ): parent_node = hou.node("/obj") if self.selected_nodes: @@ -61,7 +62,7 @@ class CreateHDA(plugin.HoudiniCreator): hda_file_name="$HIP/{}.hda".format(node_name) ) hda_node.layoutChildren() - elif self._check_existing(node_name): + elif self._check_existing(asset_name, node_name): raise plugin.OpenPypeCreatorError( ("subset {} is already published with different HDA" "definition.").format(node_name)) @@ -81,3 +82,8 @@ class CreateHDA(plugin.HoudiniCreator): pre_create_data) # type: plugin.CreatedInstance return instance + + def get_network_categories(self): + return [ + hou.objNodeTypeCategory() + ] diff --git a/openpype/hosts/houdini/plugins/create/create_mantra_ifd.py b/openpype/hosts/houdini/plugins/create/create_mantra_ifd.py new file mode 100644 index 0000000000..7ea7d1042f --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_mantra_ifd.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating pointcache alembics.""" +from openpype.hosts.houdini.api import plugin +from openpype.pipeline import CreatedInstance +from openpype.lib import BoolDef + + +class CreateMantraIFD(plugin.HoudiniCreator): + """Mantra .ifd Archive""" + identifier = "io.openpype.creators.houdini.mantraifd" + label = "Mantra IFD" + family = "mantraifd" + icon = "gears" + + def create(self, subset_name, instance_data, pre_create_data): + import hou + instance_data.pop("active", None) + instance_data.update({"node_type": "ifd"}) + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + creator_attributes["farm"] = pre_create_data["farm"] + instance = super(CreateMantraIFD, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + + instance_node = hou.node(instance.get("instance_node")) + + filepath = "{}{}".format( + hou.text.expandString("$HIP/pyblish/"), + "{}.$F4.ifd".format(subset_name)) + parms = { + # Render frame range + "trange": 1, + # Arnold ROP settings + "soho_diskfile": filepath, + "soho_outputmode": 1 + } + + instance_node.setParms(parms) + + # Lock any parameters in this list + to_lock = ["soho_outputmode", "family", "id"] + self.lock_parameters(instance_node, to_lock) + + def get_instance_attr_defs(self): + return [ + BoolDef("farm", + label="Submitting to Farm", + default=False) + ] + + def get_pre_create_attr_defs(self): + attrs = super().get_pre_create_attr_defs() + # Use same attributes as for instance attributes + return attrs + self.get_instance_attr_defs() diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index 7eaf2aff2b..2d2f89cc48 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- """Creator plugin for creating pointcache alembics.""" from openpype.hosts.houdini.api import plugin +from openpype.lib import BoolDef import hou + class CreatePointCache(plugin.HoudiniCreator): """Alembic ROP to pointcache""" identifier = "io.openpype.creators.houdini.pointcache" @@ -15,6 +17,9 @@ class CreatePointCache(plugin.HoudiniCreator): def create(self, subset_name, instance_data, pre_create_data): instance_data.pop("active", None) instance_data.update({"node_type": "alembic"}) + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreatePointCache, self).create( subset_name, @@ -105,3 +110,15 @@ class CreatePointCache(plugin.HoudiniCreator): else: return min(outputs, key=lambda node: node.evalParm('outputidx')) + + def get_instance_attr_defs(self): + return [ + BoolDef("farm", + label="Submitting to Farm", + default=False) + ] + + def get_pre_create_attr_defs(self): + attrs = super().get_pre_create_attr_defs() + # Use same attributes as for instance attributes + return attrs + self.get_instance_attr_defs() diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py b/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py index b814dd9d57..e1577c92e9 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- """Creator plugin for creating Redshift proxies.""" from openpype.hosts.houdini.api import plugin -from openpype.pipeline import CreatedInstance +import hou +from openpype.lib import BoolDef class CreateRedshiftProxy(plugin.HoudiniCreator): @@ -12,7 +13,7 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): icon = "magic" def create(self, subset_name, instance_data, pre_create_data): - import hou # noqa + # Remove the active, we are checking the bypass flag of the nodes instance_data.pop("active", None) @@ -24,11 +25,14 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): # TODO: Somehow enforce so that it only shows the original limited # attributes of the Redshift_Proxy_Output node type instance_data.update({"node_type": "Redshift_Proxy_Output"}) + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateRedshiftProxy, self).create( subset_name, instance_data, - pre_create_data) # type: CreatedInstance + pre_create_data) instance_node = hou.node(instance.get("instance_node")) @@ -44,3 +48,21 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): # Lock some Avalon attributes to_lock = ["family", "id", "prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] + + def get_instance_attr_defs(self): + return [ + BoolDef("farm", + label="Submitting to Farm", + default=False) + ] + + def get_pre_create_attr_defs(self): + attrs = super().get_pre_create_attr_defs() + # Use same attributes as for instance attributes + return attrs + self.get_instance_attr_defs() diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index ab06b30c35..60c34a358b 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -3,6 +3,9 @@ from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef +import os +import hou + class CreateReview(plugin.HoudiniCreator): """Review with OpenGL ROP""" @@ -13,7 +16,6 @@ class CreateReview(plugin.HoudiniCreator): icon = "video-camera" def create(self, subset_name, instance_data, pre_create_data): - import hou instance_data.pop("active", None) instance_data.update({"node_type": "opengl"}) @@ -82,6 +84,11 @@ class CreateReview(plugin.HoudiniCreator): instance_node.setParms(parms) + # Set OCIO Colorspace to the default output colorspace + # if there's OCIO + if os.getenv("OCIO"): + self.set_colorcorrect_to_default_view_space(instance_node) + to_lock = ["id", "family"] self.lock_parameters(instance_node, to_lock) @@ -123,3 +130,23 @@ class CreateReview(plugin.HoudiniCreator): minimum=0.0001, decimals=3) ] + + def set_colorcorrect_to_default_view_space(self, + instance_node): + """Set ociocolorspace to the default output space.""" + from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa + + # set Color Correction parameter to OpenColorIO + instance_node.setParms({"colorcorrect": 2}) + + # Get default view space for ociocolorspace parm. + default_view_space = get_default_display_view_colorspace() + instance_node.setParms( + {"ociocolorspace": default_view_space} + ) + + self.log.debug( + "'OCIO Colorspace' parm on '{}' has been set to " + "the default view color space '{}'" + .format(instance_node, default_view_space) + ) diff --git a/openpype/hosts/houdini/plugins/create/create_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_staticmesh.py new file mode 100644 index 0000000000..d0985198bd --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_staticmesh.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +"""Creator for Unreal Static Meshes.""" +from openpype.hosts.houdini.api import plugin +from openpype.lib import BoolDef, EnumDef + +import hou + + +class CreateStaticMesh(plugin.HoudiniCreator): + """Static Meshes as FBX. """ + + identifier = "io.openpype.creators.houdini.staticmesh.fbx" + label = "Static Mesh (FBX)" + family = "staticMesh" + icon = "fa5s.cubes" + + default_variants = ["Main"] + + def create(self, subset_name, instance_data, pre_create_data): + + instance_data.update({"node_type": "filmboxfbx"}) + + instance = super(CreateStaticMesh, self).create( + subset_name, + instance_data, + pre_create_data) + + # get the created rop node + instance_node = hou.node(instance.get("instance_node")) + + # prepare parms + output_path = hou.text.expandString( + "$HIP/pyblish/{}.fbx".format(subset_name) + ) + + parms = { + "startnode": self.get_selection(), + "sopoutput": output_path, + # vertex cache format + "vcformat": pre_create_data.get("vcformat"), + "convertunits": pre_create_data.get("convertunits"), + # set render range to use frame range start-end frame + "trange": 1, + "createsubnetroot": pre_create_data.get("createsubnetroot") + } + + # set parms + instance_node.setParms(parms) + + # Lock any parameters in this list + to_lock = ["family", "id"] + self.lock_parameters(instance_node, to_lock) + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] + + def get_pre_create_attr_defs(self): + """Add settings for users. """ + + attrs = super(CreateStaticMesh, self).get_pre_create_attr_defs() + createsubnetroot = BoolDef("createsubnetroot", + tooltip="Create an extra root for the " + "Export node when it's a " + "subnetwork. This causes the " + "exporting subnetwork node to be " + "represented in the FBX file.", + default=False, + label="Create Root for Subnet") + vcformat = EnumDef("vcformat", + items={ + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" + }, + default=0, + label="Vertex Cache Format") + convert_units = BoolDef("convertunits", + tooltip="When on, the FBX is converted" + "from the current Houdini " + "system units to the native " + "FBX unit of centimeters.", + default=False, + label="Convert Units") + + return attrs + [createsubnetroot, vcformat, convert_units] + + def get_dynamic_data( + self, variant, task_name, asset_doc, project_name, host_name, instance + ): + """ + The default subset name templates for Unreal include {asset} and thus + we should pass that along as dynamic data. + """ + dynamic_data = super(CreateStaticMesh, self).get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name, instance + ) + dynamic_data["asset"] = asset_doc["name"] + return dynamic_data + + def get_selection(self): + """Selection Logic. + + how self.selected_nodes should be processed to get + the desirable node from selection. + + Returns: + str : node path + """ + + selection = "" + + if self.selected_nodes: + selected_node = self.selected_nodes[0] + + # Accept sop level nodes (e.g. /obj/geo1/box1) + if isinstance(selected_node, hou.SopNode): + selection = selected_node.path() + self.log.debug( + "Valid SopNode selection, 'Export' in filmboxfbx" + " will be set to '%s'.", selected_node + ) + + # Accept object level nodes (e.g. /obj/geo1) + elif isinstance(selected_node, hou.ObjNode): + selection = selected_node.path() + self.log.debug( + "Valid ObjNode selection, 'Export' in filmboxfbx " + "will be set to the child path '%s'.", selection + ) + + else: + self.log.debug( + "Selection isn't valid. 'Export' in " + "filmboxfbx will be empty." + ) + else: + self.log.debug( + "No Selection. 'Export' in filmboxfbx will be empty." + ) + + return selection diff --git a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py index c015cebd49..53df9dda68 100644 --- a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py +++ b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py @@ -2,6 +2,7 @@ """Creator plugin for creating VDB Caches.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance +from openpype.lib import BoolDef import hou @@ -19,26 +20,104 @@ class CreateVDBCache(plugin.HoudiniCreator): instance_data.pop("active", None) instance_data.update({"node_type": "geometry"}) - + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateVDBCache, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) + file_path = "{}{}".format( + hou.text.expandString("$HIP/pyblish/"), + "{}.$F4.vdb".format(subset_name)) parms = { - "sopoutput": "$HIP/pyblish/{}.$F4.vdb".format(subset_name), + "sopoutput": file_path, "initsim": True, "trange": 1 } if self.selected_nodes: - parms["soppath"] = self.selected_nodes[0].path() + parms["soppath"] = self.get_sop_node_path(self.selected_nodes[0]) instance_node.setParms(parms) def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] + + def get_sop_node_path(self, selected_node): + """Get Sop Path of the selected node. + + Although Houdini allows ObjNode path on `sop_path` for the + the ROP node, we prefer it set to the SopNode path explicitly. + """ + + # Allow sop level paths (e.g. /obj/geo1/box1) + if isinstance(selected_node, hou.SopNode): + self.log.debug( + "Valid SopNode selection, 'SOP Path' in ROP will" + " be set to '%s'.", selected_node.path() + ) + return selected_node.path() + + # Allow object level paths to Geometry nodes (e.g. /obj/geo1) + # but do not allow other object level nodes types like cameras, etc. + elif isinstance(selected_node, hou.ObjNode) and \ + selected_node.type().name() == "geo": + + # Try to find output node. + sop_node = self.get_obj_output(selected_node) + if sop_node: + self.log.debug( + "Valid ObjNode selection, 'SOP Path' in ROP will " + "be set to the child path '%s'.", sop_node.path() + ) + return sop_node.path() + + self.log.debug( + "Selection isn't valid. 'SOP Path' in ROP will be empty." + ) + return "" + + def get_obj_output(self, obj_node): + """Try to find output node. + + If any output nodes are present, return the output node with + the minimum 'outputidx' + If no output nodes are present, return the node with display flag + If no nodes are present at all, return None + """ + + outputs = obj_node.subnetOutputs() + + # if obj_node is empty + if not outputs: + return + + # if obj_node has one output child whether its + # sop output node or a node with the render flag + elif len(outputs) == 1: + return outputs[0] + + # if there are more than one, then it has multiple output nodes + # return the one with the minimum 'outputidx' + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) + + def get_instance_attr_defs(self): + return [ + BoolDef("farm", + label="Submitting to Farm", + default=False) + ] + + def get_pre_create_attr_defs(self): + attrs = super().get_pre_create_attr_defs() + # Use same attributes as for instance attributes + return attrs + self.get_instance_attr_defs() diff --git a/openpype/hosts/houdini/plugins/create/create_workfile.py b/openpype/hosts/houdini/plugins/create/create_workfile.py index cc45a6c2a8..850f5c994e 100644 --- a/openpype/hosts/houdini/plugins/create/create_workfile.py +++ b/openpype/hosts/houdini/plugins/create/create_workfile.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" +from openpype import AYON_SERVER_ENABLED from openpype.hosts.houdini.api import plugin from openpype.hosts.houdini.api.lib import read, imprint from openpype.hosts.houdini.api.pipeline import CONTEXT_CONTAINER @@ -30,16 +31,27 @@ class CreateWorkfile(plugin.HoudiniCreatorBase, AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.host_name + if current_instance is None: + current_instance_asset = None + elif AYON_SERVER_ENABLED: + current_instance_asset = current_instance["folderPath"] + else: + current_instance_asset = current_instance["asset"] + if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name + data.update( self.get_dynamic_data( variant, task_name, asset_doc, @@ -51,15 +63,18 @@ class CreateWorkfile(plugin.HoudiniCreatorBase, AutoCreator): ) self._add_instance_to_context(current_instance) elif ( - current_instance["asset"] != asset_name - or current_instance["task"] != task_name + current_instance_asset != asset_name + or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) - current_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + current_instance["folderPath"] = asset_name + else: + current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name diff --git a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py new file mode 100644 index 0000000000..18ececb019 --- /dev/null +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -0,0 +1,26 @@ +from openpype.pipeline import InventoryAction +from openpype.hosts.houdini.api.lib import ( + get_camera_from_container, + set_camera_resolution +) +from openpype.pipeline.context_tools import get_current_project_asset + + +class SetCameraResolution(InventoryAction): + + label = "Set Camera Resolution" + icon = "desktop" + color = "orange" + + @staticmethod + def is_compatible(container): + return ( + container.get("loader") == "CameraLoader" + ) + + def process(self, containers): + asset_doc = get_current_project_asset() + for container in containers: + node = container["node"] + camera = get_camera_from_container(node) + set_camera_resolution(camera, asset_doc) diff --git a/openpype/hosts/houdini/plugins/load/load_bgeo.py b/openpype/hosts/houdini/plugins/load/load_bgeo.py index 22680178c0..489bf944ed 100644 --- a/openpype/hosts/houdini/plugins/load/load_bgeo.py +++ b/openpype/hosts/houdini/plugins/load/load_bgeo.py @@ -34,7 +34,6 @@ class BgeoLoader(load.LoaderPlugin): # Create a new geo node container = obj.createNode("geo", node_name=node_name) - is_sequence = bool(context["representation"]["context"].get("frame")) # Remove the file node, it only loads static meshes # Houdini 17 has removed the file node from the geo node diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 7b4a04809e..e16146a267 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -4,6 +4,13 @@ from openpype.pipeline import ( ) from openpype.hosts.houdini.api import pipeline +from openpype.hosts.houdini.api.lib import ( + set_camera_resolution, + get_camera_from_container +) + +import hou + ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' '.alembicGetCameraDict') @@ -25,7 +32,15 @@ def transfer_non_default_values(src, dest, ignore=None): channel expression and ignore certain Parm types. """ - import hou + + ignore_types = { + hou.parmTemplateType.Toggle, + hou.parmTemplateType.Menu, + hou.parmTemplateType.Button, + hou.parmTemplateType.FolderSet, + hou.parmTemplateType.Separator, + hou.parmTemplateType.Label, + } src.updateParmStates() @@ -62,14 +77,6 @@ def transfer_non_default_values(src, dest, ignore=None): continue # Ignore folders, separators, etc. - ignore_types = { - hou.parmTemplateType.Toggle, - hou.parmTemplateType.Menu, - hou.parmTemplateType.Button, - hou.parmTemplateType.FolderSet, - hou.parmTemplateType.Separator, - hou.parmTemplateType.Label, - } if parm.parmTemplate().type() in ignore_types: continue @@ -90,13 +97,8 @@ class CameraLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): - import os - import hou - # Format file name, Houdini only wants forward slashes - file_path = self.filepath_from_context(context) - file_path = os.path.normpath(file_path) - file_path = file_path.replace("\\", "/") + file_path = self.filepath_from_context(context).replace("\\", "/") # Get the root node obj = hou.node("/obj") @@ -106,19 +108,21 @@ class CameraLoader(load.LoaderPlugin): node_name = "{}_{}".format(namespace, name) if namespace else name # Create a archive node - container = self.create_and_connect(obj, "alembicarchive", node_name) + node = self.create_and_connect(obj, "alembicarchive", node_name) # TODO: add FPS of project / asset - container.setParms({"fileName": file_path, - "channelRef": True}) + node.setParms({"fileName": file_path, "channelRef": True}) # Apply some magic - container.parm("buildHierarchy").pressButton() - container.moveToGoodPosition() + node.parm("buildHierarchy").pressButton() + node.moveToGoodPosition() # Create an alembic xform node - nodes = [container] + nodes = [node] + camera = get_camera_from_container(node) + self._match_maya_render_mask(camera) + set_camera_resolution(camera, asset_doc=context["asset"]) self[:] = nodes return pipeline.containerise(node_name, @@ -143,14 +147,14 @@ class CameraLoader(load.LoaderPlugin): # Store the cam temporarily next to the Alembic Archive # so that we can preserve parm values the user set on it # after build hierarchy was triggered. - old_camera = self._get_camera(node) + old_camera = get_camera_from_container(node) temp_camera = old_camera.copyTo(node.parent()) # Rebuild node.parm("buildHierarchy").pressButton() # Apply values to the new camera - new_camera = self._get_camera(node) + new_camera = get_camera_from_container(node) transfer_non_default_values(temp_camera, new_camera, # The hidden uniform scale attribute @@ -158,6 +162,9 @@ class CameraLoader(load.LoaderPlugin): # "icon_scale" just skip that completely ignore={"scale"}) + self._match_maya_render_mask(new_camera) + set_camera_resolution(new_camera) + temp_camera.destroy() def remove(self, container): @@ -165,15 +172,6 @@ class CameraLoader(load.LoaderPlugin): node = container["node"] node.destroy() - def _get_camera(self, node): - import hou - cameras = node.recursiveGlob("*", - filter=hou.nodeTypeFilter.ObjCamera, - include_subnets=False) - - assert len(cameras) == 1, "Camera instance must have only one camera" - return cameras[0] - def create_and_connect(self, node, node_type, name=None): """Create a node within a node which and connect it to the input @@ -194,5 +192,20 @@ class CameraLoader(load.LoaderPlugin): new_node.moveToGoodPosition() return new_node - def switch(self, container, representation): - self.update(container, representation) + def _match_maya_render_mask(self, camera): + """Workaround to match Maya render mask in Houdini""" + + # print("Setting match maya render mask ") + parm = camera.parm("aperture") + expression = parm.expression() + expression = expression.replace("return ", "aperture = ") + expression += """ +# Match maya render mask (logic from Houdini's own FBX importer) +node = hou.pwd() +resx = node.evalParm('resx') +resy = node.evalParm('resy') +aspect = node.evalParm('aspect') +aperture *= min(1, (resx / resy * aspect) / 1.5) +return aperture +""" + parm.setExpression(expression, language=hou.exprLanguage.Python) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py new file mode 100644 index 0000000000..cac22d62d4 --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""Fbx Loader for houdini. """ +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.houdini.api import pipeline + + +class FbxLoader(load.LoaderPlugin): + """Load fbx files. """ + + label = "Load FBX" + icon = "code-fork" + color = "orange" + + order = -10 + + families = ["staticMesh", "fbx"] + representations = ["fbx"] + + def load(self, context, name=None, namespace=None, data=None): + + # get file path from context + file_path = self.filepath_from_context(context) + file_path = file_path.replace("\\", "/") + + # get necessary data + namespace, node_name = self.get_node_name(context, name, namespace) + + # create load tree + nodes = self.create_load_node_tree(file_path, node_name, name) + + self[:] = nodes + + # Call containerise function which does some automations for you + # like moving created nodes to the AVALON_CONTAINERS subnetwork + containerised_nodes = pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", + ) + + return containerised_nodes + + def update(self, container, representation): + + node = container["node"] + try: + file_node = next( + n for n in node.children() if n.type().name() == "file" + ) + except StopIteration: + self.log.error("Could not find node of type `file`") + return + + # Update the file path from representation + file_path = get_representation_path(representation) + file_path = file_path.replace("\\", "/") + + file_node.setParms({"file": file_path}) + + # Update attribute + node.setParms({"representation": str(representation["_id"])}) + + def remove(self, container): + + node = container["node"] + node.destroy() + + def switch(self, container, representation): + self.update(container, representation) + + def get_node_name(self, context, name=None, namespace=None): + """Define node name.""" + + if not namespace: + namespace = context["asset"]["name"] + + if namespace: + node_name = "{}_{}".format(namespace, name) + else: + node_name = name + + return namespace, node_name + + def create_load_node_tree(self, file_path, node_name, subset_name): + """Create Load network. + + you can start building your tree at any obj level. + it'll be much easier to build it in the root obj level. + + Afterwards, your tree will be automatically moved to + '/obj/AVALON_CONTAINERS' subnetwork. + """ + import hou + + # Get the root obj level + obj = hou.node("/obj") + + # Create a new obj geo node + parent_node = obj.createNode("geo", node_name=node_name) + + # In older houdini, + # when reating a new obj geo node, a default file node will be + # automatically created. + # so, we will delete it if exists. + file_node = parent_node.node("file1") + if file_node: + file_node.destroy() + + # Create a new file node + file_node = parent_node.createNode("file", node_name=node_name) + file_node.setParms({"file": file_path}) + + # Create attribute delete + attribdelete_name = "attribdelete_{}".format(subset_name) + attribdelete = parent_node.createNode("attribdelete", + node_name=attribdelete_name) + attribdelete.setParms({"ptdel": "fbx_*"}) + attribdelete.setInput(0, file_node) + + # Create a Null node + null_name = "OUT_{}".format(subset_name) + null = parent_node.createNode("null", node_name=null_name) + null.setInput(0, attribdelete) + + # Ensure display flag is on the file_node input node and not on the OUT + # node to optimize "debug" displaying in the viewport. + file_node.setDisplayFlag(True) + + # Set new position for children nodes + parent_node.layoutChildren() + + # Return all the nodes + return [parent_node, file_node, attribdelete, null] diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py index 57edc341a3..9630716253 100644 --- a/openpype/hosts/houdini/plugins/load/load_hda.py +++ b/openpype/hosts/houdini/plugins/load/load_hda.py @@ -59,6 +59,9 @@ class HdaLoader(load.LoaderPlugin): def_paths = [d.libraryFilePath() for d in defs] new = def_paths.index(file_path) defs[new].setIsPreferred(True) + hda_node.setParms({ + "representation": str(representation["_id"]) + }) def remove(self, container): node = container["node"] diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 663a93e48b..cff2b74e52 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -119,7 +119,8 @@ class ImageLoader(load.LoaderPlugin): if not parent.children(): parent.destroy() - def _get_file_sequence(self, root): + def _get_file_sequence(self, file_path): + root = os.path.dirname(file_path) files = sorted(os.listdir(root)) first_fname = files[0] diff --git a/openpype/hosts/houdini/plugins/load/show_usdview.py b/openpype/hosts/houdini/plugins/load/show_usdview.py index 7b03a0738a..d56c4acc4f 100644 --- a/openpype/hosts/houdini/plugins/load/show_usdview.py +++ b/openpype/hosts/houdini/plugins/load/show_usdview.py @@ -1,4 +1,5 @@ import os +import platform import subprocess from openpype.lib.vendor_bin_utils import find_executable @@ -8,17 +9,31 @@ from openpype.pipeline import load class ShowInUsdview(load.LoaderPlugin): """Open USD file in usdview""" - families = ["colorbleed.usd"] label = "Show in usdview" - representations = ["usd", "usda", "usdlc", "usdnc"] - order = 10 + representations = ["*"] + families = ["*"] + extensions = {"usd", "usda", "usdlc", "usdnc", "abc"} + order = 15 icon = "code-fork" color = "white" def load(self, context, name=None, namespace=None, data=None): + from pathlib import Path - usdview = find_executable("usdview") + if platform.system() == "Windows": + executable = "usdview.bat" + else: + executable = "usdview" + + usdview = find_executable(executable) + if not usdview: + raise RuntimeError("Unable to find usdview") + + # For some reason Windows can return the path like: + # C:/PROGRA~1/SIDEEF~1/HOUDIN~1.435/bin/usdview + # convert to resolved path so `subprocess` can take it + usdview = str(Path(usdview).resolve().as_posix()) filepath = self.filepath_from_context(context) filepath = os.path.normpath(filepath) @@ -30,14 +45,4 @@ class ShowInUsdview(load.LoaderPlugin): self.log.info("Start houdini variant of usdview...") - # For now avoid some pipeline environment variables that initialize - # Avalon in Houdini as it is redundant for usdview and slows boot time - env = os.environ.copy() - env.pop("PYTHONPATH", None) - env.pop("HOUDINI_SCRIPT_PATH", None) - env.pop("HOUDINI_MENU_PATH", None) - - # Force string to avoid unicode issues - env = {str(key): str(value) for key, value in env.items()} - - subprocess.Popen([usdview, filepath, "--renderer", "GL"], env=env) + subprocess.Popen([usdview, filepath, "--renderer", "GL"]) diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index 4af1d80a2d..c7da8397dc 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -20,7 +20,9 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): """ label = "Arnold ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["arnold_rop"] @@ -145,8 +147,9 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py new file mode 100644 index 0000000000..67a281639d --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +"""Collector plugin for frames data on ROP instances.""" +import hou # noqa +import pyblish.api +from openpype.lib import BoolDef +from openpype.pipeline import OpenPypePyblishPluginMixin + + +class CollectAssetHandles(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Apply asset handles. + + If instance does not have: + - frameStart + - frameEnd + - handleStart + - handleEnd + But it does have: + - frameStartHandle + - frameEndHandle + + Then we will retrieve the asset's handles to compute + the exclusive frame range and actual handle ranges. + """ + + hosts = ["houdini"] + + # This specific order value is used so that + # this plugin runs after CollectAnatomyInstanceData + order = pyblish.api.CollectorOrder + 0.499 + + label = "Collect Asset Handles" + use_asset_handles = True + + def process(self, instance): + # Only process instances without already existing handles data + # but that do have frameStartHandle and frameEndHandle defined + # like the data collected from CollectRopFrameRange + if "frameStartHandle" not in instance.data: + return + if "frameEndHandle" not in instance.data: + return + + has_existing_data = { + "handleStart", + "handleEnd", + "frameStart", + "frameEnd" + }.issubset(instance.data) + if has_existing_data: + return + + attr_values = self.get_attr_values_from_data(instance.data) + if attr_values.get("use_handles", self.use_asset_handles): + asset_data = instance.data["assetEntity"]["data"] + handle_start = asset_data.get("handleStart", 0) + handle_end = asset_data.get("handleEnd", 0) + else: + handle_start = 0 + handle_end = 0 + + frame_start = instance.data["frameStartHandle"] + handle_start + frame_end = instance.data["frameEndHandle"] - handle_end + + instance.data.update({ + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStart": frame_start, + "frameEnd": frame_end + }) + + # Log debug message about the collected frame range + if attr_values.get("use_handles", self.use_asset_handles): + self.log.debug( + "Full Frame range with Handles " + "[{frame_start_handle} - {frame_end_handle}]" + .format( + frame_start_handle=instance.data["frameStartHandle"], + frame_end_handle=instance.data["frameEndHandle"] + ) + ) + else: + self.log.debug( + "Use handles is deactivated for this instance, " + "start and end handles are set to 0." + ) + + # Log collected frame range to the user + message = "Frame range [{frame_start} - {frame_end}]".format( + frame_start=frame_start, + frame_end=frame_end + ) + if handle_start or handle_end: + message += " with handles [{handle_start}]-[{handle_end}]".format( + handle_start=handle_start, + handle_end=handle_end + ) + self.log.info(message) + + if instance.data.get("byFrameStep", 1.0) != 1.0: + self.log.info( + "Frame steps {}".format(instance.data["byFrameStep"])) + + # Add frame range to label if the instance has a frame range. + label = instance.data.get("label", instance.data["name"]) + instance.data["label"] = ( + "{label} [{frame_start_handle} - {frame_end_handle}]" + .format( + label=label, + frame_start_handle=instance.data["frameStartHandle"], + frame_end_handle=instance.data["frameEndHandle"] + ) + ) + + @classmethod + def get_attribute_defs(cls): + return [ + BoolDef("use_handles", + tooltip="Disable this if you want the publisher to" + " ignore start and end handles specified in the" + " asset data for this publish instance", + default=cls.use_asset_handles, + label="Use asset handles") + ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_cache_farm.py b/openpype/hosts/houdini/plugins/publish/collect_cache_farm.py new file mode 100644 index 0000000000..36ade32a35 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_cache_farm.py @@ -0,0 +1,75 @@ +import os +import pyblish.api +import hou +from openpype.hosts.houdini.api import lib + + +class CollectDataforCache(pyblish.api.InstancePlugin): + """Collect data for caching to Deadline.""" + + order = pyblish.api.CollectorOrder + 0.04 + families = ["ass", "pointcache", + "mantraifd", "redshiftproxy", + "vdbcache"] + hosts = ["houdini"] + targets = ["local", "remote"] + label = "Collect Data for Cache" + + def process(self, instance): + creator_attribute = instance.data["creator_attributes"] + farm_enabled = creator_attribute["farm"] + instance.data["farm"] = farm_enabled + if not farm_enabled: + self.log.debug("Caching on farm is disabled. " + "Skipping farm collecting.") + return + # Why do we need this particular collector to collect the expected + # output files from a ROP node. Don't we have a dedicated collector + # for that yet? + # Collect expected files + ropnode = hou.node(instance.data["instance_node"]) + output_parm = lib.get_output_parameter(ropnode) + expected_filepath = output_parm.eval() + instance.data.setdefault("files", list()) + instance.data.setdefault("expectedFiles", list()) + if instance.data.get("frames"): + files = self.get_files(instance, expected_filepath) + # list of files + instance.data["files"].extend(files) + else: + # single file + instance.data["files"].append(output_parm.eval()) + cache_files = {"_": instance.data["files"]} + # Convert instance family to pointcache if it is bgeo or abc + # because ??? + for family in instance.data["families"]: + if family == "bgeo" or "abc": + instance.data["family"] = "pointcache" + break + instance.data.update({ + "plugin": "Houdini", + "publish": True + }) + instance.data["families"].append("publish.hou") + instance.data["expectedFiles"].append(cache_files) + + self.log.debug("{}".format(instance.data)) + + def get_files(self, instance, output_parm): + """Get the files with the frame range data + + Args: + instance (_type_): instance + output_parm (_type_): path of output parameter + + Returns: + files: a list of files + """ + directory = os.path.dirname(output_parm) + + files = [ + os.path.join(directory, frame).replace("\\", "/") + for frame in instance.data["frames"] + ] + + return files diff --git a/openpype/hosts/houdini/plugins/publish/collect_chunk_size.py b/openpype/hosts/houdini/plugins/publish/collect_chunk_size.py new file mode 100644 index 0000000000..1c867e930a --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_chunk_size.py @@ -0,0 +1,39 @@ +import pyblish.api +from openpype.lib import NumberDef +from openpype.pipeline import OpenPypePyblishPluginMixin + + +class CollectChunkSize(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Collect chunk size for cache submission to Deadline.""" + + order = pyblish.api.CollectorOrder + 0.05 + families = ["ass", "pointcache", + "vdbcache", "mantraifd", + "redshiftproxy"] + hosts = ["houdini"] + targets = ["local", "remote"] + label = "Collect Chunk Size" + chunkSize = 999999 + + def process(self, instance): + # need to get the chunk size info from the setting + attr_values = self.get_attr_values_from_data(instance.data) + instance.data["chunkSize"] = attr_values.get("chunkSize") + + @classmethod + def apply_settings(cls, project_settings): + project_setting = project_settings["houdini"]["publish"]["CollectChunkSize"] # noqa + cls.chunkSize = project_setting["chunk_size"] + + @classmethod + def get_attribute_defs(cls): + return [ + NumberDef("chunkSize", + minimum=1, + maximum=999999, + decimals=0, + default=cls.chunkSize, + label="Frame Per Task") + + ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 01df809d4c..089fae6b1b 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -11,17 +11,20 @@ from openpype.hosts.houdini.api import lib class CollectFrames(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" - order = pyblish.api.CollectorOrder + 0.01 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.1 label = "Collect Frames" families = ["vdbcache", "imagesequence", "ass", - "redshiftproxy", "review", "bgeo"] + "mantraifd", "redshiftproxy", "review", + "bgeo"] def process(self, instance): ropnode = hou.node(instance.data["instance_node"]) - start_frame = instance.data.get("frameStart", None) - end_frame = instance.data.get("frameEnd", None) + start_frame = instance.data.get("frameStartHandle", None) + end_frame = instance.data.get("frameEndHandle", None) output_parm = lib.get_output_parameter(ropnode) if start_frame is not None: diff --git a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py deleted file mode 100644 index 584343cd64..0000000000 --- a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py +++ /dev/null @@ -1,56 +0,0 @@ -import hou - -import pyblish.api - - -class CollectInstanceNodeFrameRange(pyblish.api.InstancePlugin): - """Collect time range frame data for the instance node.""" - - order = pyblish.api.CollectorOrder + 0.001 - label = "Instance Node Frame Range" - hosts = ["houdini"] - - def process(self, instance): - - node_path = instance.data.get("instance_node") - node = hou.node(node_path) if node_path else None - if not node_path or not node: - self.log.debug("No instance node found for instance: " - "{}".format(instance)) - return - - frame_data = self.get_frame_data(node) - if not frame_data: - return - - self.log.info("Collected time data: {}".format(frame_data)) - instance.data.update(frame_data) - - def get_frame_data(self, node): - """Get the frame data: start frame, end frame and steps - Args: - node(hou.Node) - - Returns: - dict - - """ - - data = {} - - if node.parm("trange") is None: - self.log.debug("Node has no 'trange' parameter: " - "{}".format(node.path())) - return data - - if node.evalParm("trange") == 0: - # Ignore 'render current frame' - self.log.debug("Node '{}' has 'Render current frame' set. " - "Time range data ignored.".format(node.path())) - return data - - data["frameStart"] = node.evalParm("f1") - data["frameEnd"] = node.evalParm("f2") - data["byFrameStep"] = node.evalParm("f3") - - return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index 3772c9e705..52966fb3c2 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -91,27 +91,3 @@ class CollectInstances(pyblish.api.ContextPlugin): context[:] = sorted(context, key=sort_by_family) return context - - def get_frame_data(self, node): - """Get the frame data: start frame, end frame and steps - Args: - node(hou.Node) - - Returns: - dict - - """ - - data = {} - - if node.parm("trange") is None: - return data - - if node.evalParm("trange") == 0: - return data - - data["frameStart"] = node.evalParm("f1") - data["frameEnd"] = node.evalParm("f2") - data["byFrameStep"] = node.evalParm("f3") - - return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py index 0600730d00..d154cdc7c0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py @@ -122,10 +122,6 @@ class CollectInstancesUsdLayered(pyblish.api.ContextPlugin): instance.data.update(save_data) instance.data["usdLayer"] = layer - # Don't allow the Pyblish `instanceToggled` we have installed - # to set this node to bypass. - instance.data["_allowToggleBypass"] = False - instances.append(instance) # Store the collected ROP node dependencies diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index eabb1128d8..dac350a6ef 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -24,7 +24,9 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): """ label = "Karma ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["karma_rop"] @@ -95,8 +97,9 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index 7385a87679..bc71576174 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -24,7 +24,9 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): """ label = "Mantra ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["mantra_rop"] @@ -137,8 +139,9 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_output_node.py b/openpype/hosts/houdini/plugins/publish/collect_output_node.py index 601ed17b39..bca3d9fdc1 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/collect_output_node.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import KnownPublishError + class CollectOutputSOPPath(pyblish.api.InstancePlugin): """Collect the out node's SOP/COP Path value.""" @@ -12,7 +14,8 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin): "imagesequence", "usd", "usdrender", - "redshiftproxy" + "redshiftproxy", + "staticMesh" ] hosts = ["houdini"] @@ -57,9 +60,13 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin): elif node_type == "Redshift_Proxy_Output": out_node = node.parm("RS_archive_sopPath").evalAsNode() + + elif node_type == "filmboxfbx": + out_node = node.parm("startnode").evalAsNode() + else: - raise ValueError( - "ROP node type '%s' is" " not supported." % node_type + raise KnownPublishError( + "ROP node type '{}' is not supported.".format(node_type) ) if not out_node: diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index dbb15ab88f..0acddab011 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -24,7 +24,9 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): """ label = "Redshift ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["redshift_rop"] @@ -132,8 +134,9 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/collect_review_data.py b/openpype/hosts/houdini/plugins/publish/collect_review_data.py index 3efb75e66c..9671945b9a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_review_data.py +++ b/openpype/hosts/houdini/plugins/publish/collect_review_data.py @@ -6,6 +6,8 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin): """Collect Review Data.""" label = "Collect Review Data" + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange order = pyblish.api.CollectorOrder + 0.1 hosts = ["houdini"] families = ["review"] @@ -41,8 +43,8 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin): return if focal_length_parm.isTimeDependent(): - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + 1 + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + 1 focal_length = [ focal_length_parm.evalAsFloatAtFrame(t) for t in range(int(start), int(end)) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 2a6be6b9f1..1e6bc3b16e 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -8,6 +8,7 @@ from openpype.hosts.houdini.api import lib class CollectRopFrameRange(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" + hosts = ["houdini"] order = pyblish.api.CollectorOrder label = "Collect RopNode Frame Range" @@ -16,26 +17,22 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin): node_path = instance.data.get("instance_node") if node_path is None: # Instance without instance node like a workfile instance + self.log.debug( + "No instance node found for instance: {}".format(instance) + ) return ropnode = hou.node(node_path) - frame_data = lib.get_frame_data(ropnode) + frame_data = lib.get_frame_data( + ropnode, self.log + ) - if "frameStart" in frame_data and "frameEnd" in frame_data: + if not frame_data: + return - # Log artist friendly message about the collected frame range - message = ( - "Frame range {0[frameStart]} - {0[frameEnd]}" - ).format(frame_data) - if frame_data.get("step", 1.0) != 1.0: - message += " with step {0[step]}".format(frame_data) - self.log.info(message) + # Log debug message about the collected frame range + self.log.debug( + "Collected frame_data: {}".format(frame_data) + ) - instance.data.update(frame_data) - - # Add frame range to label if the instance has a frame range. - label = instance.data.get("label", instance.data["name"]) - instance.data["label"] = ( - "{0} [{1[frameStart]} - {1[frameEnd]}]".format(label, - frame_data) - ) + instance.data.update(frame_data) diff --git a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py new file mode 100644 index 0000000000..db9efec7a1 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +"""Collector for staticMesh types. """ + +import pyblish.api + + +class CollectStaticMeshType(pyblish.api.InstancePlugin): + """Collect data type for fbx instance.""" + + hosts = ["houdini"] + families = ["staticMesh"] + label = "Collect type of staticMesh" + + order = pyblish.api.CollectorOrder + + def process(self, instance): + + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.staticmesh.fbx": # noqa: E501 + # Marking this instance as FBX triggers the FBX extractor. + instance.data["families"] += ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py b/openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py index 14a8e3c056..462cf99b9c 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py +++ b/openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py @@ -1,6 +1,10 @@ import pyblish.api -from openpype.client import get_subset_by_name, get_asset_by_name +from openpype.client import ( + get_subset_by_name, + get_asset_by_name, + get_asset_name_identifier, +) import openpype.lib.usdlib as usdlib @@ -51,8 +55,9 @@ class CollectUsdBootstrap(pyblish.api.InstancePlugin): self.log.debug("Add bootstrap for: %s" % bootstrap) project_name = instance.context.data["projectName"] - asset = get_asset_by_name(project_name, instance.data["asset"]) - assert asset, "Asset must exist: %s" % asset + asset_name = instance.data["asset"] + asset_doc = get_asset_by_name(project_name, asset_name) + assert asset_doc, "Asset must exist: %s" % asset_name # Check which are not about to be created and don't exist yet required = {"shot": ["usdShot"], "asset": ["usdAsset"]}.get(bootstrap) @@ -67,19 +72,21 @@ class CollectUsdBootstrap(pyblish.api.InstancePlugin): required += list(layers) self.log.debug("Checking required bootstrap: %s" % required) - for subset in required: - if self._subset_exists(project_name, instance, subset, asset): + for subset_name in required: + if self._subset_exists( + project_name, instance, subset_name, asset_doc + ): continue self.log.debug( "Creating {0} USD bootstrap: {1} {2}".format( - bootstrap, asset["name"], subset + bootstrap, asset_name, subset_name ) ) - new = instance.context.create_instance(subset) - new.data["subset"] = subset - new.data["label"] = "{0} ({1})".format(subset, asset["name"]) + new = instance.context.create_instance(subset_name) + new.data["subset"] = subset_name + new.data["label"] = "{0} ({1})".format(subset_name, asset_name) new.data["family"] = "usd.bootstrap" new.data["comment"] = "Automated bootstrap USD file." new.data["publishFamilies"] = ["usd"] @@ -91,21 +98,23 @@ class CollectUsdBootstrap(pyblish.api.InstancePlugin): for key in ["asset"]: new.data[key] = instance.data[key] - def _subset_exists(self, project_name, instance, subset, asset): + def _subset_exists(self, project_name, instance, subset_name, asset_doc): """Return whether subset exists in current context or in database.""" # Allow it to be created during this publish session context = instance.context + + asset_doc_name = get_asset_name_identifier(asset_doc) for inst in context: if ( - inst.data["subset"] == subset - and inst.data["asset"] == asset["name"] + inst.data["subset"] == subset_name + and inst.data["asset"] == asset_doc_name ): return True # Or, if they already exist in the database we can # skip them too. if get_subset_by_name( - project_name, subset, asset["_id"], fields=["_id"] + project_name, subset_name, asset_doc["_id"], fields=["_id"] ): return True return False diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index 184c644fd6..a1f4554726 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -24,7 +24,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): """ label = "VRay ROP Render Products" - order = pyblish.api.CollectorOrder + 0.4 + # This specific order value is used so that + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["vray_rop"] @@ -99,14 +101,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): def get_render_product_name(self, prefix, suffix=""): """Return the beauty output filename if render element enabled """ + # Remove aov suffix from the product: `prefix.aov_suffix` -> `prefix` aov_parm = ".{}".format(suffix) - beauty_product = None - if aov_parm in prefix: - beauty_product = prefix.replace(aov_parm, "") - else: - beauty_product = prefix - - return beauty_product + return prefix.replace(aov_parm, "") def get_render_element_name(self, node, prefix, suffix=""): """Return the output filename using the AOV prefix and suffix @@ -139,8 +136,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): return path expected_files = [] - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) diff --git a/openpype/hosts/houdini/plugins/publish/extract_alembic.py b/openpype/hosts/houdini/plugins/publish/extract_alembic.py index bdd19b23d4..df2fdda241 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_alembic.py +++ b/openpype/hosts/houdini/plugins/publish/extract_alembic.py @@ -14,8 +14,12 @@ class ExtractAlembic(publish.Extractor): label = "Extract Alembic" hosts = ["houdini"] families = ["abc", "camera"] + targets = ["local", "remote"] def process(self, instance): + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return ropnode = hou.node(instance.data["instance_node"]) diff --git a/openpype/hosts/houdini/plugins/publish/extract_ass.py b/openpype/hosts/houdini/plugins/publish/extract_ass.py index 0d246625ba..28dd08f999 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_ass.py +++ b/openpype/hosts/houdini/plugins/publish/extract_ass.py @@ -14,9 +14,12 @@ class ExtractAss(publish.Extractor): label = "Extract Ass" families = ["ass"] hosts = ["houdini"] + targets = ["local", "remote"] def process(self, instance): - + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return ropnode = hou.node(instance.data["instance_node"]) # Get the filename from the filename parameter @@ -56,7 +59,7 @@ class ExtractAss(publish.Extractor): 'ext': ext, "files": files, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index c9625ec880..a3840f8f73 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -17,7 +17,9 @@ class ExtractBGEO(publish.Extractor): families = ["bgeo"] def process(self, instance): - + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return ropnode = hou.node(instance.data["instance_node"]) # Get the filename from the filename parameter @@ -47,7 +49,7 @@ class ExtractBGEO(publish.Extractor): "ext": ext.lstrip("."), "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"] + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"] } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_composite.py b/openpype/hosts/houdini/plugins/publish/extract_composite.py index 7a1ab36b93..11cf83a46d 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_composite.py +++ b/openpype/hosts/houdini/plugins/publish/extract_composite.py @@ -41,8 +41,8 @@ class ExtractComposite(publish.Extractor): "ext": ext, "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } from pprint import pformat diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py new file mode 100644 index 0000000000..7dc193c6a9 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""Fbx Extractor for houdini. """ + +import os +import pyblish.api +from openpype.pipeline import publish +from openpype.hosts.houdini.api.lib import render_rop + +import hou + + +class ExtractFBX(publish.Extractor): + + label = "Extract FBX" + families = ["fbx"] + hosts = ["houdini"] + + order = pyblish.api.ExtractorOrder + 0.1 + + def process(self, instance): + + # get rop node + ropnode = hou.node(instance.data.get("instance_node")) + output_file = ropnode.evalParm("sopoutput") + + # get staging_dir and file_name + staging_dir = os.path.normpath(os.path.dirname(output_file)) + file_name = os.path.basename(output_file) + + # render rop + self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir) + render_rop(ropnode) + + # prepare representation + representation = { + "name": "fbx", + "ext": "fbx", + "files": file_name, + "stagingDir": staging_dir + } + + # A single frame may also be rendered without start/end frame. + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa + representation["frameStart"] = instance.data["frameStartHandle"] + representation["frameEnd"] = instance.data["frameEndHandle"] + + # set value type for 'representations' key to list + if "representations" not in instance.data: + instance.data["representations"] = [] + + # update instance data + instance.data["stagingDir"] = staging_dir + instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_mantra_ifd.py b/openpype/hosts/houdini/plugins/publish/extract_mantra_ifd.py new file mode 100644 index 0000000000..894260d1bf --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_mantra_ifd.py @@ -0,0 +1,51 @@ +import os + +import pyblish.api + +from openpype.pipeline import publish + +import hou + + +class ExtractMantraIFD(publish.Extractor): + + order = pyblish.api.ExtractorOrder + label = "Extract Mantra ifd" + hosts = ["houdini"] + families = ["mantraifd"] + targets = ["local", "remote"] + + def process(self, instance): + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return + + ropnode = hou.node(instance.data.get("instance_node")) + output = ropnode.evalParm("soho_diskfile") + staging_dir = os.path.dirname(output) + instance.data["stagingDir"] = staging_dir + + files = instance.data["frames"] + missing_frames = [ + frame + for frame in instance.data["frames"] + if not os.path.exists( + os.path.normpath(os.path.join(staging_dir, frame))) + ] + if missing_frames: + raise RuntimeError("Failed to complete Mantra ifd extraction. " + "Missing output files: {}".format( + missing_frames)) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'ifd', + 'ext': 'ifd', + 'files': files, + "stagingDir": staging_dir, + "frameStart": instance.data["frameStart"], + "frameEnd": instance.data["frameEnd"], + } + instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_opengl.py b/openpype/hosts/houdini/plugins/publish/extract_opengl.py index 6c36dec5f5..38808089ac 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_opengl.py +++ b/openpype/hosts/houdini/plugins/publish/extract_opengl.py @@ -39,8 +39,8 @@ class ExtractOpenGL(publish.Extractor): "ext": instance.data["imageFormat"], "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": instance.data.get("review_camera") diff --git a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py index 1d99ac665c..218f3b9256 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py @@ -14,9 +14,12 @@ class ExtractRedshiftProxy(publish.Extractor): label = "Extract Redshift Proxy" families = ["redshiftproxy"] hosts = ["houdini"] + targets = ["local", "remote"] def process(self, instance): - + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return ropnode = hou.node(instance.data.get("instance_node")) # Get the filename from the filename parameter @@ -44,8 +47,8 @@ class ExtractRedshiftProxy(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStart" in instance.data and "frameEnd" in instance.data: - representation["frameStart"] = instance.data["frameStart"] - representation["frameEnd"] = instance.data["frameEnd"] + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa + representation["frameStart"] = instance.data["frameStartHandle"] + representation["frameEnd"] = instance.data["frameEndHandle"] instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py index 4bca758f08..8ac16704f0 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py +++ b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py @@ -16,7 +16,9 @@ class ExtractVDBCache(publish.Extractor): hosts = ["houdini"] def process(self, instance): - + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return ropnode = hou.node(instance.data["instance_node"]) # Get the filename from the filename parameter @@ -40,7 +42,7 @@ class ExtractVDBCache(publish.Extractor): "ext": "vdb", "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/increment_current_file.py b/openpype/hosts/houdini/plugins/publish/increment_current_file.py index 2493b28bc1..4788cca3cf 100644 --- a/openpype/hosts/houdini/plugins/publish/increment_current_file.py +++ b/openpype/hosts/houdini/plugins/publish/increment_current_file.py @@ -2,7 +2,7 @@ import pyblish.api from openpype.lib import version_up from openpype.pipeline import registered_host -from openpype.action import get_errored_plugins_from_data +from openpype.pipeline.publish import get_errored_plugins_from_context from openpype.hosts.houdini.api import HoudiniHost from openpype.pipeline.publish import KnownPublishError @@ -22,12 +22,13 @@ class IncrementCurrentFile(pyblish.api.ContextPlugin): "arnold_rop", "mantra_rop", "karma_rop", - "usdrender"] + "usdrender", + "publish.hou"] optional = True def process(self, context): - errored_plugins = get_errored_plugins_from_data(context) + errored_plugins = get_errored_plugins_from_context(context) if any( plugin.__name__ == "HoudiniSubmitPublishDeadline" for plugin in errored_plugins @@ -40,9 +41,10 @@ class IncrementCurrentFile(pyblish.api.ContextPlugin): # Filename must not have changed since collecting host = registered_host() # type: HoudiniHost current_file = host.current_file() - assert ( - context.data["currentFile"] == current_file - ), "Collected filename mismatches from current scene name." + if context.data["currentFile"] != current_file: + raise KnownPublishError( + "Collected filename mismatches from current scene name." + ) new_filepath = version_up(current_file) host.save_workfile(new_filepath) diff --git a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py index 4878738ed3..79387fbef5 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py +++ b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py @@ -1,5 +1,6 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError from openpype.hosts.houdini.api import lib import hou @@ -30,7 +31,7 @@ class ValidateAnimationSettings(pyblish.api.InstancePlugin): invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( + raise PublishValidationError( "Output settings do no match for '%s'" % instance ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py new file mode 100644 index 0000000000..894dad7d72 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.hosts.houdini.api.action import ( + SelectInvalidAction, + SelectROPAction, +) +from openpype.hosts.houdini.api.lib import get_obj_node_output +import hou + + +class ValidateFBXOutputNode(pyblish.api.InstancePlugin): + """Validate the instance Output Node. + + This will ensure: + - The Output Node Path is set. + - The Output Node Path refers to an existing object. + - The Output Node is a Sop or Obj node. + - The Output Node has geometry data. + - The Output Node doesn't include invalid primitive types. + """ + + order = pyblish.api.ValidatorOrder + families = ["fbx"] + hosts = ["houdini"] + label = "Validate FBX Output Node" + actions = [SelectROPAction, SelectInvalidAction] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes), + title="Invalid output node(s)" + ) + + @classmethod + def get_invalid(cls, instance): + output_node = instance.data.get("output_node") + + # Check if The Output Node Path is set and + # refers to an existing object. + if output_node is None: + rop_node = hou.node(instance.data["instance_node"]) + cls.log.error( + "Output node in '%s' does not exist. " + "Ensure a valid output path is set.", rop_node.path() + ) + + return [rop_node] + + # Check if the Output Node is a Sop or an Obj node + # also, list all sop output nodes inside as well as + # invalid empty nodes. + all_out_sops = [] + invalid = [] + + # if output_node is an ObjSubnet or an ObjNetwork + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + for node in output_node.allSubChildren(): + if node.type().name() == "geo": + out = get_obj_node_output(node) + if out: + all_out_sops.append(out) + else: + invalid.append(node) # empty_objs + cls.log.error( + "Geo Obj Node '%s' is empty!", + node.path() + ) + if not all_out_sops: + invalid.append(output_node) # empty_objs + cls.log.error( + "Output Node '%s' is empty!", + node.path() + ) + + # elif output_node is an ObjNode + elif output_node.type().name() == "geo": + out = get_obj_node_output(output_node) + if out: + all_out_sops.append(out) + else: + invalid.append(node) # empty_objs + cls.log.error( + "Output Node '%s' is empty!", + node.path() + ) + + # elif output_node is a SopNode + elif output_node.type().category().name() == "Sop": + all_out_sops.append(output_node) + + # Then it's a wrong node type + else: + cls.log.error( + "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. " + "Instead found category type: %s %s", + output_node.path(), output_node.type().category().name(), + output_node.type().name() + ) + return [output_node] + + # Check if all output sop nodes have geometry + # and don't contain invalid prims + invalid_prim_types = ["VDB", "Volume"] + for sop_node in all_out_sops: + # Empty Geometry test + if not hasattr(sop_node, "geometry"): + invalid.append(sop_node) # empty_geometry + cls.log.error( + "Sop node '%s' doesn't include any prims.", + sop_node.path() + ) + continue + + frame = instance.data.get("frameStart", 0) + geo = sop_node.geometryAtFrame(frame) + if len(geo.iterPrims()) == 0: + invalid.append(sop_node) # empty_geometry + cls.log.error( + "Sop node '%s' doesn't include any prims.", + sop_node.path() + ) + continue + + # Invalid Prims test + for prim_type in invalid_prim_types: + if geo.countPrimType(prim_type) > 0: + invalid.append(sop_node) # invalid_prims + cls.log.error( + "Sop node '%s' includes invalid prims of type '%s'.", + sop_node.path(), prim_type + ) + + if invalid: + return invalid diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py new file mode 100644 index 0000000000..1b12fa7096 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import RepairAction +from openpype.hosts.houdini.api.action import SelectInvalidAction + +import hou + + +class DisableUseAssetHandlesAction(RepairAction): + label = "Disable use asset handles" + icon = "mdi.toggle-switch-off" + + +class ValidateFrameRange(pyblish.api.InstancePlugin): + """Validate Frame Range. + + Due to the usage of start and end handles, + then Frame Range must be >= (start handle + end handle) + which results that frameEnd be smaller than frameStart + """ + + order = pyblish.api.ValidatorOrder - 0.1 + hosts = ["houdini"] + label = "Validate Frame Range" + actions = [DisableUseAssetHandlesAction, SelectInvalidAction] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + title="Invalid Frame Range", + message=( + "Invalid frame range because the instance " + "start frame ({0[frameStart]}) is higher than " + "the end frame ({0[frameEnd]})" + .format(instance.data) + ), + description=( + "## Invalid Frame Range\n" + "The frame range for the instance is invalid because " + "the start frame is higher than the end frame.\n\nThis " + "is likely due to asset handles being applied to your " + "instance or the ROP node's start frame " + "is set higher than the end frame.\n\nIf your ROP frame " + "range is correct and you do not want to apply asset " + "handles make sure to disable Use asset handles on the " + "publish instance." + ) + ) + + @classmethod + def get_invalid(cls, instance): + + if not instance.data.get("instance_node"): + return + + rop_node = hou.node(instance.data["instance_node"]) + frame_start = instance.data.get("frameStart") + frame_end = instance.data.get("frameEnd") + + if frame_start is None or frame_end is None: + cls.log.debug( + "Skipping frame range validation for " + "instance without frame data: {}".format(rop_node.path()) + ) + return + + if frame_start > frame_end: + cls.log.info( + "The ROP node render range is set to " + "{0[frameStartHandle]} - {0[frameEndHandle]} " + "The asset handles applied to the instance are start handle " + "{0[handleStart]} and end handle {0[handleEnd]}" + .format(instance.data) + ) + return [rop_node] + + @classmethod + def repair(cls, instance): + + if not cls.get_invalid(instance): + # Already fixed + return + + # Disable use asset handles + context = instance.context + create_context = context.data["create_context"] + instance_id = instance.data.get("instance_id") + if not instance_id: + cls.log.debug("'{}' must have instance id" + .format(instance)) + return + + created_instance = create_context.get_instance_by_id(instance_id) + if not instance_id: + cls.log.debug("Unable to find instance '{}' by id" + .format(instance)) + return + + created_instance.publish_attributes["CollectAssetHandles"]["use_handles"] = False # noqa + + create_context.save_changes() + cls.log.debug("use asset handles is turned off for '{}'" + .format(instance)) diff --git a/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py b/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py index f1c52f22c1..108a700bbe 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py +++ b/openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py @@ -1,32 +1,39 @@ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError +import hou -class ValidateHoudiniCommercialLicense(pyblish.api.InstancePlugin): - """Validate the Houdini instance runs a Commercial license. +class ValidateHoudiniNotApprenticeLicense(pyblish.api.InstancePlugin): + """Validate the Houdini instance runs a non Apprentice license. - When extracting USD files from a non-commercial Houdini license, even with - Houdini Indie license, the resulting files will get "scrambled" with - a license protection and get a special .usdnc or .usdlc suffix. + USD ROPs: + When extracting USD files from an apprentice Houdini license, + the resulting files will get "scrambled" with a license protection + and get a special .usdnc suffix. - This currently breaks the Subset/representation pipeline so we disallow - any publish with those licenses. Only the commercial license is valid. + This currently breaks the Subset/representation pipeline so we disallow + any publish with apprentice license. + Alembic ROPs: + Houdini Apprentice does not export Alembic. """ order = pyblish.api.ValidatorOrder - families = ["usd"] + families = ["usd", "abc", "fbx", "camera"] hosts = ["houdini"] - label = "Houdini Commercial License" + label = "Houdini Apprentice License" def process(self, instance): - import hou + if hou.isApprentice(): + # Find which family was matched with the plug-in + families = {instance.data["family"]} + families.update(instance.data.get("families", [])) + disallowed_families = families.intersection(self.families) + families = " ".join(sorted(disallowed_families)).title() - license = hou.licenseCategory() - if license != hou.licenseCategoryType.Commercial: raise PublishValidationError( - ("USD Publishing requires a full Commercial " - "license. You are on: {}").format(license), + "{} publishing requires a non apprentice license." + .format(families), title=self.label) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py new file mode 100644 index 0000000000..b499682e0b --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ValidateContentsOrder + +from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.hosts.houdini.api.lib import get_output_children + + +class ValidateMeshIsStatic(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate mesh is static. + + It checks if output node is time dependent. + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Validate Mesh is Static" + order = ValidateContentsOrder + 0.1 + actions = [SelectInvalidAction] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes) + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + output_node = instance.data.get("output_node") + if output_node is None: + cls.log.debug( + "No Output Node, skipping check.." + ) + return + + all_outputs = get_output_children(output_node) + + for output in all_outputs: + if output.isTimeDependent(): + invalid.append(output) + cls.log.error( + "Output node '%s' is time dependent.", + output.path() + ) + + return invalid diff --git a/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py b/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py index 4e8e5fc0e8..4f71d79382 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py +++ b/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py @@ -36,11 +36,11 @@ class ValidateRemotePublishOutNode(pyblish.api.ContextPlugin): if node.parm("shellexec").eval(): self.raise_error("Must not execute in shell") if node.parm("prerender").eval() != cmd: - self.raise_error(("REMOTE_PUBLISH node does not have " - "correct prerender script.")) + self.raise_error("REMOTE_PUBLISH node does not have " + "correct prerender script.") if node.parm("lprerender").eval() != "python": - self.raise_error(("REMOTE_PUBLISH node prerender script " - "type not set to 'python'")) + self.raise_error("REMOTE_PUBLISH node prerender script " + "type not set to 'python'") @classmethod def repair(cls, context): @@ -48,5 +48,4 @@ class ValidateRemotePublishOutNode(pyblish.api.ContextPlugin): lib.create_remote_publish_node(force=True) def raise_error(self, message): - self.log.error(message) - raise PublishValidationError(message, title=self.label) + raise PublishValidationError(message) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py new file mode 100644 index 0000000000..03ecd1b052 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import RepairAction +from openpype.hosts.houdini.api.action import SelectROPAction + +import os +import hou + + +class SetDefaultViewSpaceAction(RepairAction): + label = "Set default view colorspace" + icon = "mdi.monitor" + + +class ValidateReviewColorspace(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate Review Colorspace parameters. + + It checks if 'OCIO Colorspace' parameter was set to valid value. + """ + + order = pyblish.api.ValidatorOrder + 0.1 + families = ["review"] + hosts = ["houdini"] + label = "Validate Review Colorspace" + actions = [SetDefaultViewSpaceAction, SelectROPAction] + + optional = True + + def process(self, instance): + + if not self.is_active(instance.data): + return + + if os.getenv("OCIO") is None: + self.log.debug( + "Using Houdini's Default Color Management, " + " skipping check.." + ) + return + + rop_node = hou.node(instance.data["instance_node"]) + if rop_node.evalParm("colorcorrect") != 2: + # any colorspace settings other than default requires + # 'Color Correct' parm to be set to 'OpenColorIO' + raise PublishValidationError( + "'Color Correction' parm on '{}' ROP must be set to" + " 'OpenColorIO'".format(rop_node.path()) + ) + + if rop_node.evalParm("ociocolorspace") not in \ + hou.Color.ocio_spaces(): + + raise PublishValidationError( + "Invalid value: Colorspace name doesn't exist.\n" + "Check 'OCIO Colorspace' parameter on '{}' ROP" + .format(rop_node.path()) + ) + + @classmethod + def repair(cls, instance): + """Set Default View Space Action. + + It is a helper action more than a repair action, + used to set colorspace on opengl node to the default view. + """ + from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa + + rop_node = hou.node(instance.data["instance_node"]) + + if rop_node.evalParm("colorcorrect") != 2: + rop_node.setParms({"colorcorrect": 2}) + cls.log.debug( + "'Color Correction' parm on '{}' has been set to" + " 'OpenColorIO'".format(rop_node.path()) + ) + + # Get default view colorspace name + default_view_space = get_default_display_view_colorspace() + + rop_node.setParms({"ociocolorspace": default_view_space}) + cls.log.info( + "'OCIO Colorspace' parm on '{}' has been set to " + "the default view color space '{}'" + .format(rop_node, default_view_space) + ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index d9dee38680..9590e37d26 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -24,7 +24,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder families = ["pointcache", "vdbcache"] hosts = ["houdini"] - label = "Validate Output Node" + label = "Validate Output Node (SOP)" actions = [SelectROPAction, SelectInvalidAction] def process(self, instance): diff --git a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py new file mode 100644 index 0000000000..7bed74ebb1 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ( + ValidateContentsOrder, + RepairAction, +) +from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.pipeline.create import get_subset_name + +import hou + + +class FixSubsetNameAction(RepairAction): + label = "Fix Subset Name" + + +class ValidateSubsetName(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate Subset name. + + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Validate Subset Name" + order = ValidateContentsOrder + 0.1 + actions = [FixSubsetNameAction, SelectInvalidAction] + + optional = True + + def process(self, instance): + + if not self.is_active(instance.data): + return + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes) + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + rop_node = hou.node(instance.data["instance_node"]) + + # Check subset name + asset_doc = instance.data["assetEntity"] + subset_name = get_subset_name( + family=instance.data["family"], + variant=instance.data["variant"], + task_name=instance.data["task"], + asset_doc=asset_doc, + dynamic_data={"asset": asset_doc["name"]} + ) + + if instance.data.get("subset") != subset_name: + invalid.append(rop_node) + cls.log.error( + "Invalid subset name on rop node '%s' should be '%s'.", + rop_node.path(), subset_name + ) + + return invalid + + @classmethod + def repair(cls, instance): + rop_node = hou.node(instance.data["instance_node"]) + + # Check subset name + asset_doc = instance.data["assetEntity"] + subset_name = get_subset_name( + family=instance.data["family"], + variant=instance.data["variant"], + task_name=instance.data["task"], + asset_doc=asset_doc, + dynamic_data={"asset": asset_doc["name"]} + ) + + instance.data["subset"] = subset_name + rop_node.parm("subset").set(subset_name) + + cls.log.debug( + "Subset name on rop node '%s' has been set to '%s'.", + rop_node.path(), subset_name + ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py new file mode 100644 index 0000000000..ae3c7e5602 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ValidateContentsOrder + +from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.hosts.houdini.api.lib import get_output_children + +import hou + + +class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate name of Unreal Static Mesh. + + This validator checks if output node name has a collision prefix: + - UBX + - UCP + - USP + - UCX + + This validator also checks if subset name is correct + - {static mesh prefix}_{Asset-Name}{Variant}. + + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Unreal Static Mesh Name (FBX)" + order = ValidateContentsOrder + 0.1 + actions = [SelectInvalidAction] + + optional = True + collision_prefixes = [] + static_mesh_prefix = "" + + @classmethod + def apply_settings(cls, project_settings, system_settings): + + settings = ( + project_settings["houdini"]["create"]["CreateStaticMesh"] + ) + cls.collision_prefixes = settings["collision_prefixes"] + cls.static_mesh_prefix = settings["static_mesh_prefix"] + + def process(self, instance): + + if not self.is_active(instance.data): + return + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes) + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + rop_node = hou.node(instance.data["instance_node"]) + output_node = instance.data.get("output_node") + if output_node is None: + cls.log.debug( + "No Output Node, skipping check.." + ) + return + + if rop_node.evalParm("buildfrompath"): + # This validator doesn't support naming check if + # building hierarchy from path' is used + cls.log.info( + "Using 'Build Hierarchy from Path Attribute', skipping check.." + ) + return + + # Check nodes names + all_outputs = get_output_children(output_node, include_sops=False) + for output in all_outputs: + for prefix in cls.collision_prefixes: + if output.name().startswith(prefix): + invalid.append(output) + cls.log.error( + "Invalid node name: Node '%s' " + "includes a collision prefix '%s'", + output.path(), prefix + ) + break + + return invalid diff --git a/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py b/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py index 02c44ab94e..1daa96f2b9 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py +++ b/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py @@ -24,7 +24,7 @@ class ValidateUSDRenderProductNames(pyblish.api.InstancePlugin): if not os.path.isabs(filepath): invalid.append( - "Output file path is not " "absolute path: %s" % filepath + "Output file path is not absolute path: %s" % filepath ) if invalid: diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 47a4653d5d..0903aef7bc 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -2,9 +2,21 @@ - + + + + - + + + - + - + + + + + + diff --git a/openpype/hosts/houdini/startup/python3.10libs/pythonrc.py b/openpype/hosts/houdini/startup/python3.10libs/pythonrc.py new file mode 100644 index 0000000000..683ea6721c --- /dev/null +++ b/openpype/hosts/houdini/startup/python3.10libs/pythonrc.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +"""OpenPype startup script.""" +from openpype.pipeline import install_host +from openpype.hosts.houdini.api import HoudiniHost + + +def main(): + print("Installing OpenPype ...") + install_host(HoudiniHost()) + + +main() diff --git a/openpype/hosts/houdini/vendor/husdoutputprocessors/__init__.py b/openpype/hosts/houdini/vendor/husdoutputprocessors/__init__.py deleted file mode 100644 index 69e3be50da..0000000000 --- a/openpype/hosts/houdini/vendor/husdoutputprocessors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py b/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py deleted file mode 100644 index 310d057a11..0000000000 --- a/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py +++ /dev/null @@ -1,152 +0,0 @@ -import os -import hou -import husdoutputprocessors.base as base - -import colorbleed.usdlib as usdlib - -from openpype.client import get_asset_by_name -from openpype.pipeline import Anatomy, get_current_project_name - - -class AvalonURIOutputProcessor(base.OutputProcessorBase): - """Process Avalon URIs into their full path equivalents. - - """ - - _parameters = None - _param_prefix = 'avalonurioutputprocessor_' - _parms = { - "use_publish_paths": _param_prefix + "use_publish_paths" - } - - def __init__(self): - """ There is only one object of each output processor class that is - ever created in a Houdini session. Therefore be very careful - about what data gets put in this object. - """ - self._use_publish_paths = False - self._cache = dict() - - def displayName(self): - return 'Avalon URI Output Processor' - - def parameters(self): - - if not self._parameters: - parameters = hou.ParmTemplateGroup() - use_publish_path = hou.ToggleParmTemplate( - name=self._parms["use_publish_paths"], - label='Resolve Reference paths to publish paths', - default_value=False, - help=("When enabled any paths for Layers, References or " - "Payloads are resolved to published master versions.\n" - "This is usually only used by the publishing pipeline, " - "but can be used for testing too.")) - parameters.append(use_publish_path) - self._parameters = parameters.asDialogScript() - - return self._parameters - - def beginSave(self, config_node, t): - parm = self._parms["use_publish_paths"] - self._use_publish_paths = config_node.parm(parm).evalAtTime(t) - self._cache.clear() - - def endSave(self): - self._use_publish_paths = None - self._cache.clear() - - def processAsset(self, - asset_path, - asset_path_for_save, - referencing_layer_path, - asset_is_layer, - for_save): - """ - Args: - asset_path (str): The incoming file path you want to alter or not. - asset_path_for_save (bool): Whether the current path is a - referenced path in the USD file. When True, return the path - you want inside USD file. - referencing_layer_path (str): ??? - asset_is_layer (bool): Whether this asset is a USD layer file. - If this is False, the asset is something else (for example, - a texture or volume file). - for_save (bool): Whether the asset path is for a file to be saved - out. If so, then return actual written filepath. - - Returns: - The refactored asset path. - - """ - - # Retrieve from cache if this query occurred before (optimization) - cache_key = (asset_path, asset_path_for_save, asset_is_layer, for_save) - if cache_key in self._cache: - return self._cache[cache_key] - - relative_template = "{asset}_{subset}.{ext}" - uri_data = usdlib.parse_avalon_uri(asset_path) - if uri_data: - - if for_save: - # Set save output path to a relative path so other - # processors can potentially manage it easily? - path = relative_template.format(**uri_data) - - print("Avalon URI Resolver: %s -> %s" % (asset_path, path)) - self._cache[cache_key] = path - return path - - if self._use_publish_paths: - # Resolve to an Avalon published asset for embedded paths - path = self._get_usd_master_path(**uri_data) - else: - path = relative_template.format(**uri_data) - - print("Avalon URI Resolver: %s -> %s" % (asset_path, path)) - self._cache[cache_key] = path - return path - - self._cache[cache_key] = asset_path - return asset_path - - def _get_usd_master_path(self, - asset, - subset, - ext): - """Get the filepath for a .usd file of a subset. - - This will return the path to an unversioned master file generated by - `usd_master_file.py`. - - """ - - PROJECT = get_current_project_name() - anatomy = Anatomy(PROJECT) - asset_doc = get_asset_by_name(PROJECT, asset) - if not asset_doc: - raise RuntimeError("Invalid asset name: '%s'" % asset) - - template_obj = anatomy.templates_obj["publish"]["path"] - path = template_obj.format_strict({ - "project": PROJECT, - "asset": asset_doc["name"], - "subset": subset, - "representation": ext, - "version": 0 # stub version zero - }) - - # Remove the version folder - subset_folder = os.path.dirname(os.path.dirname(path)) - master_folder = os.path.join(subset_folder, "master") - fname = "{0}.{1}".format(subset, ext) - - return os.path.join(master_folder, fname).replace("\\", "/") - - -output_processor = AvalonURIOutputProcessor() - - -def usdOutputProcessor(): - return output_processor diff --git a/openpype/hosts/houdini/vendor/husdoutputprocessors/stagingdir_processor.py b/openpype/hosts/houdini/vendor/husdoutputprocessors/stagingdir_processor.py deleted file mode 100644 index d8e36d5aa8..0000000000 --- a/openpype/hosts/houdini/vendor/husdoutputprocessors/stagingdir_processor.py +++ /dev/null @@ -1,90 +0,0 @@ -import hou -import husdoutputprocessors.base as base -import os - - -class StagingDirOutputProcessor(base.OutputProcessorBase): - """Output all USD Rop file nodes into the Staging Directory - - Ignore any folders and paths set in the Configured Layers - and USD Rop node, just take the filename and save into a - single directory. - - """ - theParameters = None - parameter_prefix = "stagingdiroutputprocessor_" - stagingdir_parm_name = parameter_prefix + "stagingDir" - - def __init__(self): - self.staging_dir = None - - def displayName(self): - return 'StagingDir Output Processor' - - def parameters(self): - if not self.theParameters: - parameters = hou.ParmTemplateGroup() - rootdirparm = hou.StringParmTemplate( - self.stagingdir_parm_name, - 'Staging Directory', 1, - string_type=hou.stringParmType.FileReference, - file_type=hou.fileType.Directory - ) - parameters.append(rootdirparm) - self.theParameters = parameters.asDialogScript() - return self.theParameters - - def beginSave(self, config_node, t): - - # Use the Root Directory parameter if it is set. - root_dir_parm = config_node.parm(self.stagingdir_parm_name) - if root_dir_parm: - self.staging_dir = root_dir_parm.evalAtTime(t) - - if not self.staging_dir: - out_file_parm = config_node.parm('lopoutput') - if out_file_parm: - self.staging_dir = out_file_parm.evalAtTime(t) - if self.staging_dir: - (self.staging_dir, filename) = os.path.split(self.staging_dir) - - def endSave(self): - self.staging_dir = None - - def processAsset(self, asset_path, - asset_path_for_save, - referencing_layer_path, - asset_is_layer, - for_save): - """ - Args: - asset_path (str): The incoming file path you want to alter or not. - asset_path_for_save (bool): Whether the current path is a - referenced path in the USD file. When True, return the path - you want inside USD file. - referencing_layer_path (str): ??? - asset_is_layer (bool): Whether this asset is a USD layer file. - If this is False, the asset is something else (for example, - a texture or volume file). - for_save (bool): Whether the asset path is for a file to be saved - out. If so, then return actual written filepath. - - Returns: - The refactored asset path. - - """ - - # Treat save paths as being relative to the output path. - if for_save and self.staging_dir: - # Whenever we're processing a Save Path make sure to - # resolve it to the Staging Directory - filename = os.path.basename(asset_path) - return os.path.join(self.staging_dir, filename) - - return asset_path - - -output_processor = StagingDirOutputProcessor() -def usdOutputProcessor(): - return output_processor - diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index ccd4cd67e1..298084a4e8 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -1,15 +1,35 @@ # -*- coding: utf-8 -*- """Library of functions useful for 3dsmax pipeline.""" import contextlib +import logging import json from typing import Any, Dict, Union import six +from openpype.pipeline import get_current_project_name, colorspace +from openpype.settings import get_project_settings from openpype.pipeline.context_tools import ( - get_current_project, get_current_project_asset,) + get_current_project, get_current_project_asset) +from openpype.style import load_stylesheet from pymxs import runtime as rt + JSON_PREFIX = "JSON::" +log = logging.getLogger("openpype.hosts.max") + + +def get_main_window(): + """Acquire Max's main window""" + from qtpy import QtWidgets + top_widgets = QtWidgets.QApplication.topLevelWidgets() + name = "QmaxApplicationWindow" + for widget in top_widgets: + if ( + widget.inherits("QMainWindow") + and widget.metaObject().className() == name + ): + return widget + raise RuntimeError('Count not find 3dsMax main window.') def imprint(node_name: str, data: dict) -> bool: @@ -214,27 +234,40 @@ def reset_scene_resolution(): set_scene_resolution(width, height) -def get_frame_range() -> Union[Dict[str, Any], None]: +def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: """Get the current assets frame range and handles. + Args: + asset_doc (dict): Asset Entity Data + Returns: dict: with frame start, frame end, handle start, handle end. """ # Set frame start/end - asset = get_current_project_asset() - frame_start = asset["data"].get("frameStart") - frame_end = asset["data"].get("frameEnd") + if asset_doc is None: + asset_doc = get_current_project_asset() + + data = asset_doc["data"] + frame_start = data.get("frameStart") + frame_end = data.get("frameEnd") if frame_start is None or frame_end is None: - return + return {} + + frame_start = int(frame_start) + frame_end = int(frame_end) + handle_start = int(data.get("handleStart", 0)) + handle_end = int(data.get("handleEnd", 0)) + frame_start_handle = frame_start - handle_start + frame_end_handle = frame_end + handle_end - handle_start = asset["data"].get("handleStart", 0) - handle_end = asset["data"].get("handleEnd", 0) return { "frameStart": frame_start, "frameEnd": frame_end, "handleStart": handle_start, - "handleEnd": handle_end + "handleEnd": handle_end, + "frameStartHandle": frame_start_handle, + "frameEndHandle": frame_end_handle, } @@ -254,12 +287,11 @@ def reset_frame_range(fps: bool = True): fps_number = float(data_fps["data"]["fps"]) rt.frameRate = fps_number frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int(frame_range["handleEnd"]) - set_timeline(frame_start_handle, frame_end_handle) - set_render_frame_range(frame_start_handle, frame_end_handle) + + set_timeline( + frame_range["frameStartHandle"], frame_range["frameEndHandle"]) + set_render_frame_range( + frame_range["frameStartHandle"], frame_range["frameEndHandle"]) def set_context_setting(): @@ -277,6 +309,7 @@ def set_context_setting(): """ reset_scene_resolution() reset_frame_range() + reset_colorspace() def get_max_version(): @@ -292,19 +325,12 @@ def get_max_version(): return max_info[7] -@contextlib.contextmanager -def viewport_camera(camera): - original = rt.viewport.getCamera() - if not original: - # if there is no original camera - # use the current camera as original - original = rt.getNodeByName(camera) - review_camera = rt.getNodeByName(camera) - try: - rt.viewport.setCamera(review_camera) - yield - finally: - rt.viewport.setCamera(original) +def is_headless(): + """Check if 3dsMax runs in batch mode. + If it returns True, it runs in 3dsbatch.exe + If it returns False, it runs in 3dsmax.exe + """ + return rt.maxops.isInNonInteractiveMode() def set_timeline(frameStart, frameEnd): @@ -312,3 +338,176 @@ def set_timeline(frameStart, frameEnd): """ rt.animationRange = rt.interval(frameStart, frameEnd) return rt.animationRange + + +def reset_colorspace(): + """OCIO Configuration + Supports in 3dsMax 2024+ + + """ + if int(get_max_version()) < 2024: + return + project_name = get_current_project_name() + colorspace_mgr = rt.ColorPipelineMgr + project_settings = get_project_settings(project_name) + + max_config_data = colorspace.get_imageio_config( + project_name, "max", project_settings) + if max_config_data: + ocio_config_path = max_config_data["path"] + colorspace_mgr = rt.ColorPipelineMgr + colorspace_mgr.Mode = rt.Name("OCIO_Custom") + colorspace_mgr.OCIOConfigPath = ocio_config_path + + +def check_colorspace(): + parent = get_main_window() + if parent is None: + log.info("Skipping outdated pop-up " + "because Max main window can't be found.") + if int(get_max_version()) >= 2024: + color_mgr = rt.ColorPipelineMgr + project_name = get_current_project_name() + project_settings = get_project_settings(project_name) + max_config_data = colorspace.get_imageio_config( + project_name, "max", project_settings) + if max_config_data and color_mgr.Mode != rt.Name("OCIO_Custom"): + if not is_headless(): + from openpype.widgets import popup + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Warning: Wrong OCIO Mode") + dialog.setMessage("This scene has wrong OCIO " + "Mode setting.") + dialog.setButtonText("Fix") + dialog.setStyleSheet(load_stylesheet()) + dialog.on_clicked.connect(reset_colorspace) + dialog.show() + +def unique_namespace(namespace, format="%02d", + prefix="", suffix="", con_suffix="CON"): + """Return unique namespace + + Arguments: + namespace (str): Name of namespace to consider + format (str, optional): Formatting of the given iteration number + suffix (str, optional): Only consider namespaces with this suffix. + con_suffix: max only, for finding the name of the master container + + >>> unique_namespace("bar") + # bar01 + >>> unique_namespace(":hello") + # :hello01 + >>> unique_namespace("bar:", suffix="_NS") + # bar01_NS: + + """ + + def current_namespace(): + current = namespace + # When inside a namespace Max adds no trailing : + if not current.endswith(":"): + current += ":" + return current + + # Always check against the absolute namespace root + # There's no clash with :x if we're defining namespace :a:x + ROOT = ":" if namespace.startswith(":") else current_namespace() + + # Strip trailing `:` tokens since we might want to add a suffix + start = ":" if namespace.startswith(":") else "" + end = ":" if namespace.endswith(":") else "" + namespace = namespace.strip(":") + if ":" in namespace: + # Split off any nesting that we don't uniqify anyway. + parents, namespace = namespace.rsplit(":", 1) + start += parents + ":" + ROOT += start + + iteration = 1 + increment_version = True + while increment_version: + nr_namespace = namespace + format % iteration + unique = prefix + nr_namespace + suffix + container_name = f"{unique}:{namespace}{con_suffix}" + if not rt.getNodeByName(container_name): + name_space = start + unique + end + increment_version = False + return name_space + else: + increment_version = True + iteration += 1 + + +def get_namespace(container_name): + """Get the namespace and name of the sub-container + + Args: + container_name (str): the name of master container + + Raises: + RuntimeError: when there is no master container found + + Returns: + namespace (str): namespace of the sub-container + name (str): name of the sub-container + """ + node = rt.getNodeByName(container_name) + if not node: + raise RuntimeError("Master Container Not Found..") + name = rt.getUserProp(node, "name") + namespace = rt.getUserProp(node, "namespace") + return namespace, name + + +def object_transform_set(container_children): + """A function which allows to store the transform of + previous loaded object(s) + Args: + container_children(list): A list of nodes + + Returns: + transform_set (dict): A dict with all transform data of + the previous loaded object(s) + """ + transform_set = {} + for node in container_children: + name = f"{node.name}.transform" + transform_set[name] = node.pos + name = f"{node.name}.scale" + transform_set[name] = node.scale + return transform_set + + +def get_plugins() -> list: + """Get all loaded plugins in 3dsMax + + Returns: + plugin_info_list: a list of loaded plugins + """ + manager = rt.PluginManager + count = manager.pluginDllCount + plugin_info_list = [] + for p in range(1, count + 1): + plugin_info = manager.pluginDllName(p) + plugin_info_list.append(plugin_info) + + return plugin_info_list + + +@contextlib.contextmanager +def render_resolution(width, height): + """Set render resolution option during context + + Args: + width (int): render width + height (int): render height + """ + current_renderWidth = rt.renderWidth + current_renderHeight = rt.renderHeight + try: + rt.renderWidth = width + rt.renderHeight = height + yield + finally: + rt.renderWidth = current_renderWidth + rt.renderHeight = current_renderHeight diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py index 1b62edabee..26e176aa8d 100644 --- a/openpype/hosts/max/api/lib_rendersettings.py +++ b/openpype/hosts/max/api/lib_rendersettings.py @@ -37,13 +37,10 @@ class RenderSettings(object): def set_render_camera(self, selection): for sel in selection: # to avoid Attribute Error from pymxs wrapper - found = False if rt.classOf(sel) in rt.Camera.classes: - found = True rt.viewport.setCamera(sel) - break - if not found: - raise RuntimeError("Camera not found") + return + raise RuntimeError("Active Camera not found") def render_output(self, container): folder = rt.maxFilePath @@ -113,7 +110,8 @@ class RenderSettings(object): # for setting up renderable camera arv = rt.MAXToAOps.ArnoldRenderView() render_camera = rt.viewport.GetCamera() - arv.setOption("Camera", str(render_camera)) + if render_camera: + arv.setOption("Camera", str(render_camera)) # TODO: add AOVs and extension img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index 066cc90039..caaa3e3730 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -"""3dsmax menu definition of OpenPype.""" +"""3dsmax menu definition of AYON.""" +import os from qtpy import QtWidgets, QtCore from pymxs import runtime as rt @@ -8,7 +9,7 @@ from openpype.hosts.max.api import lib class OpenPypeMenu(object): - """Object representing OpenPype menu. + """Object representing OpenPype/AYON menu. This is using "hack" to inject itself before "Help" menu of 3dsmax. For some reason `postLoadingMenus` event doesn't fire, and main menu @@ -50,17 +51,17 @@ class OpenPypeMenu(object): return list(self.main_widget.findChildren(QtWidgets.QMenuBar))[0] def get_or_create_openpype_menu( - self, name: str = "&OpenPype", + self, name: str = "&Openpype", before: str = "&Help") -> QtWidgets.QAction: - """Create OpenPype menu. + """Create AYON menu. Args: - name (str, Optional): OpenPypep menu name. + name (str, Optional): AYON menu name. before (str, Optional): Name of the 3dsmax main menu item to - add OpenPype menu before. + add AYON menu before. Returns: - QtWidgets.QAction: OpenPype menu action. + QtWidgets.QAction: AYON menu action. """ if self.menu is not None: @@ -77,15 +78,15 @@ class OpenPypeMenu(object): if before in item.title(): help_action = item.menuAction() - - op_menu = QtWidgets.QMenu("&OpenPype") + tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" + op_menu = QtWidgets.QMenu("&{}".format(tab_menu_label)) menu_bar.insertMenu(help_action, op_menu) self.menu = op_menu return op_menu def build_openpype_menu(self) -> QtWidgets.QAction: - """Build items in OpenPype menu.""" + """Build items in AYON menu.""" openpype_menu = self.get_or_create_openpype_menu() load_action = QtWidgets.QAction("Load...", openpype_menu) load_action.triggered.connect(self.load_callback) @@ -119,6 +120,10 @@ class OpenPypeMenu(object): frame_action.triggered.connect(self.frame_range_callback) openpype_menu.addAction(frame_action) + colorspace_action = QtWidgets.QAction("Set Colorspace", openpype_menu) + colorspace_action.triggered.connect(self.colorspace_callback) + openpype_menu.addAction(colorspace_action) + return openpype_menu def load_callback(self): @@ -148,3 +153,7 @@ class OpenPypeMenu(object): def frame_range_callback(self): """Callback to reset frame range""" return lib.reset_frame_range() + + def colorspace_callback(self): + """Callback to reset colorspace""" + return lib.reset_colorspace() diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 03b85a4066..d0ae854dc8 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -15,6 +15,7 @@ from openpype.pipeline import ( ) from openpype.hosts.max.api.menu import OpenPypeMenu from openpype.hosts.max.api import lib +from openpype.hosts.max.api.plugin import MS_CUSTOM_ATTRIB from openpype.hosts.max import MAX_HOST_DIR from pymxs import runtime as rt # noqa @@ -56,6 +57,9 @@ class MaxHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): rt.callbacks.addScript(rt.Name('systemPostNew'), context_setting) + rt.callbacks.addScript(rt.Name('filePostOpen'), + lib.check_colorspace) + def has_unsaved_changes(self): # TODO: how to get it from 3dsmax? return True @@ -152,21 +156,87 @@ def ls() -> list: yield lib.read(container) -def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): +def containerise(name: str, nodes: list, context, + namespace=None, loader=None, suffix="_CON"): data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, - "namespace": "", + "namespace": namespace or "", "loader": loader, "representation": context["representation"]["_id"], } - - container_name = f"{name}{suffix}" + container_name = f"{namespace}:{name}{suffix}" container = rt.container(name=container_name) - for node in nodes: - node.Parent = container - + import_custom_attribute_data(container, nodes) if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") return container + + +def load_custom_attribute_data(): + """Re-loading the AYON custom parameter built by the creator + + Returns: + attribute: re-loading the custom OP attributes set in Maxscript + """ + return rt.Execute(MS_CUSTOM_ATTRIB) + + +def import_custom_attribute_data(container: str, selections: list): + """Importing the Openpype/AYON custom parameter built by the creator + + Args: + container (str): target container which adds custom attributes + selections (list): nodes to be added into + group in custom attributes + """ + attrs = load_custom_attribute_data() + modifier = rt.EmptyModifier() + rt.addModifier(container, modifier) + container.modifiers[0].name = "OP Data" + rt.custAttributes.add(container.modifiers[0], attrs) + node_list = [] + sel_list = [] + for i in selections: + node_ref = rt.NodeTransformMonitor(node=i) + node_list.append(node_ref) + sel_list.append(str(i)) + + # Setting the property + rt.setProperty( + container.modifiers[0].openPypeData, + "all_handles", node_list) + rt.setProperty( + container.modifiers[0].openPypeData, + "sel_list", sel_list) + + +def update_custom_attribute_data(container: str, selections: list): + """Updating the AYON custom parameter built by the creator + + Args: + container (str): target container which adds custom attributes + selections (list): nodes to be added into + group in custom attributes + """ + if container.modifiers[0].name == "OP Data": + rt.deleteModifier(container, container.modifiers[0]) + import_custom_attribute_data(container, selections) + + +def get_previous_loaded_object(container: str): + """Get previous loaded_object through the OP data + + Args: + container (str): the container which stores the OP data + + Returns: + node_list(list): list of nodes which are previously loaded + """ + node_list = [] + sel_list = rt.getProperty(container.modifiers[0].openPypeData, "sel_list") + for obj in rt.Objects: + if str(obj) in sel_list: + node_list.append(obj) + return node_list diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 3389447cb0..2cf0d69146 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -65,12 +65,12 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" on button_add pressed do ( - current_selection = selectByName title:"Select Objects to add to + current_sel = selectByName title:"Select Objects to add to the Container" buttontext:"Add" filter:nodes_to_add - if current_selection == undefined then return False + if current_sel == undefined then return False temp_arr = #() i_node_arr = #() - for c in current_selection do + for c in current_sel do ( handle_name = node_to_name c node_ref = NodeTransformMonitor node:c @@ -89,15 +89,18 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" on button_del pressed do ( - current_selection = selectByName title:"Select Objects to remove + current_sel = selectByName title:"Select Objects to remove from the Container" buttontext:"Remove" filter: nodes_to_rmv - if current_selection == undefined then return False + if current_sel == undefined or current_sel.count == 0 then + ( + return False + ) temp_arr = #() i_node_arr = #() new_i_node_arr = #() new_temp_arr = #() - for c in current_selection do + for c in current_sel do ( node_ref = NodeTransformMonitor node:c as string handle_name = node_to_name c @@ -201,6 +204,8 @@ class MaxCreator(Creator, MaxCreatorBase): def create(self, subset_name, instance_data, pre_create_data): if pre_create_data.get("use_selection"): self.selected_nodes = rt.GetCurrentSelection() + if rt.getNodeByName(subset_name): + raise CreatorError(f"'{subset_name}' is already created..") instance_node = self.create_instance_node(subset_name) instance_data["instance_node"] = instance_node.name @@ -243,14 +248,25 @@ class MaxCreator(Creator, MaxCreatorBase): def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = created_inst.get("instance_node") - new_values = { key: changes[key].new_value for key in changes.changed_keys } + subset = new_values.get("subset", "") + if subset and instance_node != subset: + node = rt.getNodeByName(instance_node) + new_subset_name = new_values["subset"] + if rt.getNodeByName(new_subset_name): + raise CreatorError( + "The subset '{}' already exists.".format( + new_subset_name)) + instance_node = new_subset_name + created_inst["instance_node"] = instance_node + node.name = instance_node + imprint( instance_node, - new_values, + created_inst.data_to_store(), ) def remove_instances(self, instances): diff --git a/openpype/hosts/max/api/preview_animation.py b/openpype/hosts/max/api/preview_animation.py new file mode 100644 index 0000000000..6c7b8eaa80 --- /dev/null +++ b/openpype/hosts/max/api/preview_animation.py @@ -0,0 +1,341 @@ +import logging +import contextlib +from pymxs import runtime as rt +from .lib import get_max_version, render_resolution + +log = logging.getLogger("openpype.hosts.max") + + +@contextlib.contextmanager +def play_preview_when_done(has_autoplay): + """Set preview playback option during context + + Args: + has_autoplay (bool): autoplay during creating + preview animation + """ + current_playback = rt.preferences.playPreviewWhenDone + try: + rt.preferences.playPreviewWhenDone = has_autoplay + yield + finally: + rt.preferences.playPreviewWhenDone = current_playback + + +@contextlib.contextmanager +def viewport_layout_and_camera(camera, layout="layout_1"): + """Set viewport layout and camera during context + ***For 3dsMax 2024+ + Args: + camera (str): viewport camera + layout (str): layout to use in viewport, defaults to `layout_1` + Use None to not change viewport layout during context. + """ + original_camera = rt.viewport.getCamera() + original_layout = rt.viewport.getLayout() + if not original_camera: + # if there is no original camera + # use the current camera as original + original_camera = rt.getNodeByName(camera) + review_camera = rt.getNodeByName(camera) + try: + if layout is not None: + layout = rt.Name(layout) + if rt.viewport.getLayout() != layout: + rt.viewport.setLayout(layout) + rt.viewport.setCamera(review_camera) + yield + finally: + rt.viewport.setLayout(original_layout) + rt.viewport.setCamera(original_camera) + + +@contextlib.contextmanager +def viewport_preference_setting(general_viewport, + nitrous_manager, + nitrous_viewport, + vp_button_mgr): + """Function to set viewport setting during context + ***For Max Version < 2024 + Args: + camera (str): Viewport camera for review render + general_viewport (dict): General viewport setting + nitrous_manager (dict): Nitrous graphic manager + nitrous_viewport (dict): Nitrous setting for + preview animation + vp_button_mgr (dict): Viewport button manager Setting + preview_preferences (dict): Preview Preferences Setting + """ + orig_vp_grid = rt.viewport.getGridVisibility(1) + orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode() + + nitrousGraphicMgr = rt.NitrousGraphicsManager + viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() + vp_button_mgr_original = { + key: getattr(rt.ViewportButtonMgr, key) for key in vp_button_mgr + } + nitrous_manager_original = { + key: getattr(nitrousGraphicMgr, key) for key in nitrous_manager + } + nitrous_viewport_original = { + key: getattr(viewport_setting, key) for key in nitrous_viewport + } + + try: + rt.viewport.setGridVisibility(1, general_viewport["dspGrid"]) + rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"]) + for key, value in vp_button_mgr.items(): + setattr(rt.ViewportButtonMgr, key, value) + for key, value in nitrous_manager.items(): + setattr(nitrousGraphicMgr, key, value) + for key, value in nitrous_viewport.items(): + if nitrous_viewport[key] != nitrous_viewport_original[key]: + setattr(viewport_setting, key, value) + yield + + finally: + rt.viewport.setGridVisibility(1, orig_vp_grid) + rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) + for key, value in vp_button_mgr_original.items(): + setattr(rt.ViewportButtonMgr, key, value) + for key, value in nitrous_manager_original.items(): + setattr(nitrousGraphicMgr, key, value) + for key, value in nitrous_viewport_original.items(): + setattr(viewport_setting, key, value) + + +def _render_preview_animation_max_2024( + filepath, start, end, percentSize, ext, viewport_options): + """Render viewport preview with MaxScript using `CreateAnimation`. + ****For 3dsMax 2024+ + Args: + filepath (str): filepath for render output without frame number and + extension, for example: /path/to/file + start (int): startFrame + end (int): endFrame + percentSize (float): render resolution multiplier by 100 + e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x + viewport_options (dict): viewport setting options, e.g. + {"vpStyle": "defaultshading", "vpPreset": "highquality"} + Returns: + list: Created files + """ + # the percentSize argument must be integer + percent = int(percentSize) + filepath = filepath.replace("\\", "/") + preview_output = f"{filepath}..{ext}" + frame_template = f"{filepath}.{{:04d}}.{ext}" + job_args = [] + for key, value in viewport_options.items(): + if isinstance(value, bool): + if value: + job_args.append(f"{key}:{value}") + elif isinstance(value, str): + if key == "vpStyle": + if value == "Realistic": + value = "defaultshading" + elif value == "Shaded": + log.warning( + "'Shaded' Mode not supported in " + "preview animation in Max 2024.\n" + "Using 'defaultshading' instead.") + value = "defaultshading" + elif value == "ConsistentColors": + value = "flatcolor" + else: + value = value.lower() + elif key == "vpPreset": + if value == "Quality": + value = "highquality" + elif value == "Customize": + value = "userdefined" + else: + value = value.lower() + job_args.append(f"{key}: #{value}") + + job_str = ( + f'CreatePreview filename:"{preview_output}" outputAVI:false ' + f"percentSize:{percent} start:{start} end:{end} " + f"{' '.join(job_args)} " + "autoPlay:false" + ) + rt.completeRedraw() + rt.execute(job_str) + # Return the created files + return [frame_template.format(frame) for frame in range(start, end + 1)] + + +def _render_preview_animation_max_pre_2024( + filepath, startFrame, endFrame, + width, height, percentSize, ext): + """Render viewport animation by creating bitmaps + ***For 3dsMax Version <2024 + Args: + filepath (str): filepath without frame numbers and extension + startFrame (int): start frame + endFrame (int): end frame + width (int): render resolution width + height (int): render resolution height + percentSize (float): render resolution multiplier by 100 + e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x + ext (str): image extension + Returns: + list: Created filepaths + """ + + # get the screenshot + percent = percentSize / 100.0 + res_width = width * percent + res_height = height * percent + frame_template = "{}.{{:04}}.{}".format(filepath, ext) + frame_template.replace("\\", "/") + files = [] + user_cancelled = False + for frame in range(startFrame, endFrame + 1): + rt.sliderTime = frame + filepath = frame_template.format(frame) + preview_res = rt.bitmap( + res_width, res_height, filename=filepath + ) + dib = rt.gw.getViewportDib() + dib_width = rt.renderWidth + dib_height = rt.renderHeight + # aspect ratio + viewportRatio = dib_width / dib_height + renderRatio = float(res_width / res_height) + if viewportRatio < renderRatio: + heightCrop = (dib_width / renderRatio) + topEdge = int((dib_height - heightCrop) / 2.0) + tempImage_bmp = rt.bitmap(dib_width, heightCrop) + src_box_value = rt.Box2(0, topEdge, dib_width, heightCrop) + rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) + rt.copy(tempImage_bmp, preview_res) + rt.close(tempImage_bmp) + elif viewportRatio > renderRatio: + widthCrop = dib_height * renderRatio + leftEdge = int((dib_width - widthCrop) / 2.0) + tempImage_bmp = rt.bitmap(widthCrop, dib_height) + src_box_value = rt.Box2(leftEdge, 0, widthCrop, dib_height) + rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) + rt.copy(tempImage_bmp, preview_res) + rt.close(tempImage_bmp) + else: + rt.copy(dib, preview_res) + rt.save(preview_res) + rt.close(preview_res) + rt.close(dib) + files.append(filepath) + if rt.keyboard.escPressed: + user_cancelled = True + break + # clean up the cache + rt.gc(delayed=True) + if user_cancelled: + raise RuntimeError("User cancelled rendering of viewport animation.") + return files + + +def render_preview_animation( + filepath, + ext, + camera, + start_frame=None, + end_frame=None, + percentSize=100.0, + width=1920, + height=1080, + viewport_options=None): + """Render camera review animation + Args: + filepath (str): filepath to render to, without frame number and + extension + ext (str): output file extension + camera (str): viewport camera for preview render + start_frame (int): start frame + end_frame (int): end frame + percentSize (float): render resolution multiplier by 100 + e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x + width (int): render resolution width + height (int): render resolution height + viewport_options (dict): viewport setting options + Returns: + list: Rendered output files + """ + if start_frame is None: + start_frame = int(rt.animationRange.start) + if end_frame is None: + end_frame = int(rt.animationRange.end) + + if viewport_options is None: + viewport_options = viewport_options_for_preview_animation() + with play_preview_when_done(False): + with viewport_layout_and_camera(camera): + if int(get_max_version()) < 2024: + with viewport_preference_setting( + viewport_options["general_viewport"], + viewport_options["nitrous_manager"], + viewport_options["nitrous_viewport"], + viewport_options["vp_btn_mgr"] + ): + return _render_preview_animation_max_pre_2024( + filepath, + start_frame, + end_frame, + width, + height, + percentSize, + ext + ) + else: + with render_resolution(width, height): + return _render_preview_animation_max_2024( + filepath, + start_frame, + end_frame, + percentSize, + ext, + viewport_options + ) + + +def viewport_options_for_preview_animation(): + """Get default viewport options for `render_preview_animation`. + + Returns: + dict: viewport setting options + """ + # viewport_options should be the dictionary + if int(get_max_version()) < 2024: + return { + "visualStyleMode": "defaultshading", + "viewportPreset": "highquality", + "vpTexture": False, + "dspGeometry": True, + "dspShapes": False, + "dspLights": False, + "dspCameras": False, + "dspHelpers": False, + "dspParticles": True, + "dspBones": False, + "dspBkg": True, + "dspGrid": False, + "dspSafeFrame": False, + "dspFrameNums": False + } + else: + viewport_options = {} + viewport_options["general_viewport"] = { + "dspBkg": True, + "dspGrid": False + } + viewport_options["nitrous_manager"] = { + "AntialiasingQuality": "None" + } + viewport_options["nitrous_viewport"] = { + "VisualStyleMode": "defaultshading", + "ViewportPreset": "highquality", + "UseTextureEnabled": False + } + viewport_options["vp_btn_mgr"] = { + "EnableButtons": False} + return viewport_options diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 235046684e..9cc3c8da8a 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -14,7 +14,6 @@ class CreateRender(plugin.MaxCreator): def create(self, subset_name, instance_data, pre_create_data): from pymxs import runtime as rt - sel_obj = list(rt.selection) file = rt.maxFileName filename, _ = os.path.splitext(file) instance_data["AssetName"] = filename diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py index 5737114fcc..78d27a722b 100644 --- a/openpype/hosts/max/plugins/create/create_review.py +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -12,46 +12,109 @@ class CreateReview(plugin.MaxCreator): family = "review" icon = "video-camera" - def create(self, subset_name, instance_data, pre_create_data): + review_width = 1920 + review_height = 1080 + percentSize = 100 + keep_images = False + image_format = "png" + visual_style = "Realistic" + viewport_preset = "Quality" + vp_texture = True + anti_aliasing = "None" - instance_data["imageFormat"] = pre_create_data.get("imageFormat") - instance_data["keepImages"] = pre_create_data.get("keepImages") - instance_data["percentSize"] = pre_create_data.get("percentSize") - instance_data["rndLevel"] = pre_create_data.get("rndLevel") + def apply_settings(self, project_settings): + settings = project_settings["max"]["CreateReview"] # noqa + + # Take some defaults from settings + self.review_width = settings.get("review_width", self.review_width) + self.review_height = settings.get("review_height", self.review_height) + self.percentSize = settings.get("percentSize", self.percentSize) + self.keep_images = settings.get("keep_images", self.keep_images) + self.image_format = settings.get("image_format", self.image_format) + self.visual_style = settings.get("visual_style", self.visual_style) + self.viewport_preset = settings.get( + "viewport_preset", self.viewport_preset) + self.anti_aliasing = settings.get( + "anti_aliasing", self.anti_aliasing) + self.vp_texture = settings.get("vp_texture", self.vp_texture) + + def create(self, subset_name, instance_data, pre_create_data): + # Transfer settings from pre create to instance + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + for key in ["imageFormat", + "keepImages", + "review_width", + "review_height", + "percentSize", + "visualStyleMode", + "viewportPreset", + "antialiasingQuality", + "vpTexture"]: + if key in pre_create_data: + creator_attributes[key] = pre_create_data[key] super(CreateReview, self).create( subset_name, instance_data, pre_create_data) - def get_pre_create_attr_defs(self): - attrs = super(CreateReview, self).get_pre_create_attr_defs() + def get_instance_attr_defs(self): + image_format_enum = ["exr", "jpg", "png", "tga"] - image_format_enum = [ - "bmp", "cin", "exr", "jpg", "hdr", "rgb", "png", - "rla", "rpf", "dds", "sgi", "tga", "tif", "vrimg" + visual_style_preset_enum = [ + "Realistic", "Shaded", "Facets", + "ConsistentColors", "HiddenLine", + "Wireframe", "BoundingBox", "Ink", + "ColorInk", "Acrylic", "Tech", "Graphite", + "ColorPencil", "Pastel", "Clay", "ModelAssist" ] + preview_preset_enum = [ + "Quality", "Standard", "Performance", + "DXMode", "Customize"] + anti_aliasing_enum = ["None", "2X", "4X", "8X"] - rndLevel_enum = [ - "smoothhighlights", "smooth", "facethighlights", - "facet", "flat", "litwireframe", "wireframe", "box" - ] - - return attrs + [ - BoolDef("keepImages", - label="Keep Image Sequences", - default=False), - EnumDef("imageFormat", - image_format_enum, - default="png", - label="Image Format Options"), + return [ + NumberDef("review_width", + label="Review width", + decimals=0, + minimum=0, + default=self.review_width), + NumberDef("review_height", + label="Review height", + decimals=0, + minimum=0, + default=self.review_height), NumberDef("percentSize", label="Percent of Output", - default=100, + default=self.percentSize, minimum=1, decimals=0), - EnumDef("rndLevel", - rndLevel_enum, - default="smoothhighlights", - label="Preference") + BoolDef("keepImages", + label="Keep Image Sequences", + default=self.keep_images), + EnumDef("imageFormat", + image_format_enum, + default=self.image_format, + label="Image Format Options"), + EnumDef("visualStyleMode", + visual_style_preset_enum, + default=self.visual_style, + label="Preference"), + EnumDef("viewportPreset", + preview_preset_enum, + default=self.viewport_preset, + label="Preview Preset"), + EnumDef("antialiasingQuality", + anti_aliasing_enum, + default=self.anti_aliasing, + label="Anti-aliasing Quality"), + BoolDef("vpTexture", + label="Viewport Texture", + default=self.vp_texture) ] + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attributes + attrs = super().get_pre_create_attr_defs() + return attrs + self.get_instance_attr_defs() diff --git a/openpype/hosts/max/plugins/create/create_tycache.py b/openpype/hosts/max/plugins/create/create_tycache.py new file mode 100644 index 0000000000..92d12e012f --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_tycache.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating TyCache.""" +from openpype.hosts.max.api import plugin + + +class CreateTyCache(plugin.MaxCreator): + """Creator plugin for TyCache.""" + identifier = "io.openpype.creators.max.tycache" + label = "TyCache" + family = "tycache" + icon = "gear" diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 62284b23d9..ce1427a980 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,7 +1,16 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.lib import ( + unique_namespace, + get_namespace, + object_transform_set +) +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -16,51 +25,66 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - filepath = self.filepath_from_context(context) filepath = os.path.normpath(filepath) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.GetNodeByName(f"{name}") - if not container: - container = rt.Container() - container.name = f"{name}" + namespace = unique_namespace( + name + "_", + suffix="_", + ) + selections = rt.GetCurrentSelection() - for selection in rt.GetCurrentSelection(): - selection.Parent = container + for selection in selections: + selection.name = f"{namespace}:{selection.name}" return containerise( - name, [container], context, loader=self.__class__.__name__) + name, selections, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.GetNodeByName(container["instance_node"]) - rt.Select(node.Children) - fbx_reimport_cmd = ( - f""" + node_name = container["instance_node"] + node = rt.getNodeByName(node_name) + namespace, _ = get_namespace(node_name) -FBXImporterSetParam "Animation" true -FBXImporterSetParam "Cameras" true -FBXImporterSetParam "AxisConversionMethod" true -FbxExporterSetParam "UpAxis" "Y" -FbxExporterSetParam "Preserveinstances" true + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_fbx_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_fbx_objects) + for prev_fbx_obj in prev_fbx_objects: + if rt.isValidNode(prev_fbx_obj): + rt.Delete(prev_fbx_obj) -importFile @"{path}" #noPrompt using:FBXIMP - """) - rt.Execute(fbx_reimport_cmd) - - with maintained_selection(): - rt.Select(node) + rt.FBXImporterSetParam("Animation", True) + rt.FBXImporterSetParam("Camera", True) + rt.FBXImporterSetParam("Mode", rt.Name("merge")) + rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("Preserveinstances", True) + rt.ImportFile( + path, rt.name("noPrompt"), using=rt.FBXIMP) + current_fbx_objects = rt.GetCurrentSelection() + fbx_objects = [] + for fbx_object in current_fbx_objects: + fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_objects.append(fbx_object) + fbx_transform = f"{fbx_object.name}.transform" + if fbx_transform in transform_data.keys(): + fbx_object.pos = transform_data[fbx_transform] or 0 + fbx_object.scale = transform_data[ + f"{fbx_object.name}.scale"] or 0 + update_custom_attribute_data(node, fbx_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 76cd3bf367..0b5f0a2858 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,15 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.lib import ( + unique_namespace, + get_namespace, + object_transform_set +) +from openpype.hosts.max.api.pipeline import ( + containerise, get_previous_loaded_object, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -19,36 +27,63 @@ class MaxSceneLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - path = self.filepath_from_context(context) path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile(path) + rt.MergeMaxFile(path, quiet=True, includeFullGroup=True) max_objects = rt.getLastMergedNodes() - max_container = rt.Container(name=f"{name}") - for max_object in max_objects: - max_object.Parent = max_container - + max_object_names = [obj.name for obj in max_objects] + # implement the OP/AYON custom attributes before load + max_container = [] + namespace = unique_namespace( + name + "_", + suffix="_", + ) + for max_obj, obj_name in zip(max_objects, max_object_names): + max_obj.name = f"{namespace}:{obj_name}" + max_container.append(rt.getNodeByName(max_obj.name)) return containerise( - name, [max_container], context, loader=self.__class__.__name__) + name, max_container, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node_name = container["instance_node"] + node = rt.getNodeByName(node_name) + namespace, _ = get_namespace(node_name) + # delete the old container with attribute + # delete old duplicate + # use the modifier OP data to delete the data + node_list = get_previous_loaded_object(node) + rt.select(node_list) + prev_max_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_max_objects) - rt.MergeMaxFile(path, - rt.Name("noRedraw"), - rt.Name("deleteOldDups"), - rt.Name("useSceneMtlDups")) + for prev_max_obj in prev_max_objects: + if rt.isValidNode(prev_max_obj): # noqa + rt.Delete(prev_max_obj) + rt.MergeMaxFile(path, quiet=True) - max_objects = rt.getLastMergedNodes() - container_node = rt.GetNodeByName(node_name) - for max_object in max_objects: - max_object.Parent = container_node + current_max_objects = rt.getLastMergedNodes() + current_max_object_names = [obj.name for obj + in current_max_objects] + + max_objects = [] + for max_obj, obj_name in zip(current_max_objects, + current_max_object_names): + max_obj.name = f"{namespace}:{obj_name}" + max_objects.append(max_obj) + max_transform = f"{max_obj.name}.transform" + if max_transform in transform_data.keys(): + max_obj.pos = transform_data[max_transform] or 0 + max_obj.scale = transform_data[ + f"{max_obj.name}.scale"] or 0 + + update_custom_attribute_data(node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index cff82a593c..c41608c860 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -1,15 +1,20 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object +) from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import maintained_selection +from openpype.hosts.max.api.lib import ( + maintained_selection, unique_namespace +) class ModelAbcLoader(load.LoaderPlugin): """Loading model with the Alembic loader.""" families = ["model"] - label = "Load Model(Alembic)" + label = "Load Model with Alembic" representations = ["abc"] order = -10 icon = "code-fork" @@ -30,7 +35,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.AlembicImport.CustomAttributes = True rt.AlembicImport.UVs = True rt.AlembicImport.VertexColors = True - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c @@ -46,8 +51,22 @@ class ModelAbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() + namespace = unique_namespace( + name + "_", + suffix="_", + ) + abc_objects = [] + for abc_object in abc_container.Children: + abc_object.name = f"{namespace}:{abc_object.name}" + abc_objects.append(abc_object) + # rename the abc container with namespace + abc_container_name = f"{namespace}:{name}" + abc_container.name = abc_container_name + abc_objects.append(abc_container) + return containerise( - name, [abc_container], context, loader=self.__class__.__name__ + name, abc_objects, context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): @@ -55,22 +74,19 @@ class ModelAbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - rt.Select(node.Children) - - for alembic in rt.Selection: - abc = rt.GetNodeByName(alembic.name) - rt.Select(abc.Children) - for abc_con in rt.Selection: - container = rt.GetNodeByName(abc_con.name) - container.source = path - rt.Select(container.Children) - for abc_obj in rt.Selection: - alembic_obj = rt.GetNodeByName(abc_obj.name) - alembic_obj.source = path - + node_list = [n for n in get_previous_loaded_object(node) + if rt.ClassOf(n) == rt.AlembicContainer] with maintained_selection(): - rt.Select(node) + rt.Select(node_list) + for alembic in rt.Selection: + abc = rt.GetNodeByName(alembic.name) + rt.Select(abc.Children) + for abc_con in abc.Children: + abc_con.source = path + rt.Select(abc_con.Children) + for abc_obj in abc_con.Children: + abc_obj.source = path lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 12f526ab95..71fc382eed 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,7 +1,15 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import ( + containerise, get_previous_loaded_object, + update_custom_attribute_data +) from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import ( + unique_namespace, + get_namespace, + object_transform_set +) from openpype.hosts.max.api.lib import maintained_selection @@ -16,45 +24,68 @@ class FbxModelLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - - filepath = os.path.normpath(self.filepath_from_context(context)) + filepath = self.filepath_from_context(context) + filepath = os.path.normpath(filepath) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) - rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) + rt.importFile( + filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.GetNodeByName(name) - if not container: - container = rt.Container() - container.name = name + namespace = unique_namespace( + name + "_", + suffix="_", + ) + selections = rt.GetCurrentSelection() - for selection in rt.GetCurrentSelection(): - selection.Parent = container + for selection in selections: + selection.name = f"{namespace}:{selection.name}" return containerise( - name, [container], context, loader=self.__class__.__name__ - ) + name, selections, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt + path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) - rt.select(node.Children) + node_name = container["instance_node"] + node = rt.getNodeByName(node_name) + if not node: + rt.Container(name=node_name) + namespace, _ = get_namespace(node_name) + + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_fbx_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_fbx_objects) + for prev_fbx_obj in prev_fbx_objects: + if rt.isValidNode(prev_fbx_obj): + rt.Delete(prev_fbx_obj) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) - rt.FBXImporterSetParam("AxisConversionMethod", True) - rt.FBXImporterSetParam("UpAxis", "Y") + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) + current_fbx_objects = rt.GetCurrentSelection() + fbx_objects = [] + for fbx_object in current_fbx_objects: + fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_objects.append(fbx_object) + fbx_transform = f"{fbx_object.name}.transform" + if fbx_transform in transform_data.keys(): + fbx_object.pos = transform_data[fbx_transform] or 0 + fbx_object.scale = transform_data[ + f"{fbx_object.name}.scale"] or 0 with maintained_selection(): rt.Select(node) - - lib.imprint( - container["instance_node"], - {"representation": str(representation["_id"])}, - ) + update_custom_attribute_data(node, fbx_objects) + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 18a19414fa..aedb288a2d 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -1,8 +1,18 @@ import os from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import ( + unique_namespace, + get_namespace, + maintained_selection, + object_transform_set +) from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -22,36 +32,46 @@ class ObjLoader(load.LoaderPlugin): self.log.debug("Executing command to import..") rt.Execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') + + namespace = unique_namespace( + name + "_", + suffix="_", + ) # create "missing" container for obj import - container = rt.Container() - container.name = name - + selections = rt.GetCurrentSelection() # get current selection - for selection in rt.GetCurrentSelection(): - selection.Parent = container - - asset = rt.GetNodeByName(name) - + for selection in selections: + selection.name = f"{namespace}:{selection.name}" return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, selections, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node_name = container["instance_node"] - node = rt.GetNodeByName(node_name) - - instance_name, _ = node_name.split("_") - container = rt.GetNodeByName(instance_name) - for child in container.Children: - rt.Delete(child) + node = rt.getNodeByName(node_name) + namespace, _ = get_namespace(node_name) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + previous_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(previous_objects) + for prev_obj in previous_objects: + if rt.isValidNode(prev_obj): + rt.Delete(prev_obj) rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection - for selection in rt.GetCurrentSelection(): - selection.Parent = container - + selections = rt.GetCurrentSelection() + for selection in selections: + selection.name = f"{namespace}:{selection.name}" + selection_transform = f"{selection.name}.transform" + if selection_transform in transform_data.keys(): + selection.pos = transform_data[selection_transform] or 0 + selection.scale = transform_data[ + f"{selection.name}.scale"] or 0 + update_custom_attribute_data(node, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 48b50b9b18..bce4bd4a9a 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,8 +1,20 @@ import os +from pymxs import runtime as rt +from openpype.pipeline.load import LoadError from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import ( + unique_namespace, + get_namespace, + object_transform_set, + get_plugins +) from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -17,36 +29,54 @@ class ModelUSDLoader(load.LoaderPlugin): color = "orange" def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - # asset_filepath + plugin_info = get_plugins() + if "usdimport.dli" not in plugin_info: + raise LoadError("No USDImporter loaded/installed in Max..") filepath = os.path.normpath(self.filepath_from_context(context)) import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(filepath) - filename, ext = os.path.splitext(base_filename) + _, ext = os.path.splitext(base_filename) log_filepath = filepath.replace(ext, "txt") rt.LogPath = log_filepath rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) - + namespace = unique_namespace( + name + "_", + suffix="_", + ) asset = rt.GetNodeByName(name) + usd_objects = [] + + for usd_asset in asset.Children: + usd_asset.name = f"{namespace}:{usd_asset.name}" + usd_objects.append(usd_asset) + + asset_name = f"{namespace}:{name}" + asset.name = asset_name + # need to get the correct container after renamed + asset = rt.GetNodeByName(asset_name) + usd_objects.append(asset) return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, usd_objects, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): - from pymxs import runtime as rt - path = get_representation_path(representation) node_name = container["instance_node"] node = rt.GetNodeByName(node_name) - for n in node.Children: - for r in n.Children: - rt.Delete(r) + namespace, name = get_namespace(node_name) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name != node_name] + transform_data = object_transform_set(prev_objects) + for n in prev_objects: rt.Delete(n) - instance_name, _ = node_name.split("_") import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(path) @@ -55,12 +85,23 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogPath = log_filepath rt.LogLevel = rt.Name("info") - rt.USDImporter.importFile(path, - importOptions=import_options) + rt.USDImporter.importFile( + path, importOptions=import_options) - asset = rt.GetNodeByName(instance_name) - asset.Parent = node + asset = rt.GetNodeByName(name) + usd_objects = [] + for children in asset.Children: + children.name = f"{namespace}:{children.name}" + usd_objects.append(children) + children_transform = f"{children.name}.transform" + if children_transform in transform_data.keys(): + children.pos = transform_data[children_transform] or 0 + children.scale = transform_data[ + f"{children.name}.scale"] or 0 + asset.name = f"{namespace}:{asset.name}" + usd_objects.append(asset) + update_custom_attribute_data(node, usd_objects) with maintained_selection(): rt.Select(node) @@ -72,7 +113,5 @@ class ModelUSDLoader(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 290503e053..3c2dfe8c25 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -7,7 +7,11 @@ Because of limited api, alembics can be only loaded, but not easily updated. import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object +) class AbcLoader(load.LoaderPlugin): @@ -33,7 +37,7 @@ class AbcLoader(load.LoaderPlugin): } rt.AlembicImport.ImportToRoot = False - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c @@ -48,13 +52,27 @@ class AbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - - for abc in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + for abc in selections: for cam_shape in abc.Children: - cam_shape.playbackType = 2 + cam_shape.playbackType = 0 + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + abc_objects = [] + for abc_object in abc_container.Children: + abc_object.name = f"{namespace}:{abc_object.name}" + abc_objects.append(abc_object) + # rename the abc container with namespace + abc_container_name = f"{namespace}:{name}" + abc_container.name = abc_container_name + abc_objects.append(abc_container) return containerise( - name, [abc_container], context, loader=self.__class__.__name__ + name, abc_objects, context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): @@ -62,29 +80,23 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - - alembic_objects = self.get_container_children(node, "AlembicObject") - for alembic_object in alembic_objects: - alembic_object.source = path - - lib.imprint( - container["instance_node"], - {"representation": str(representation["_id"])}, - ) - + abc_container = [n for n in get_previous_loaded_object(node) + if rt.ClassOf(n) == rt.AlembicContainer] with maintained_selection(): - rt.Select(node.Children) + rt.Select(abc_container) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) rt.Select(abc.Children) - for abc_con in rt.Selection: - container = rt.GetNodeByName(abc_con.name) - container.source = path - rt.Select(container.Children) - for abc_obj in rt.Selection: - alembic_obj = rt.GetNodeByName(abc_obj.name) - alembic_obj.source = path + for abc_con in abc.Children: + abc_con.source = path + rt.Select(abc_con.Children) + for abc_obj in abc_con.Children: + abc_obj.source = path + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py new file mode 100644 index 0000000000..96060a6a6f --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py @@ -0,0 +1,108 @@ +import os +from openpype.pipeline import load, get_representation_path +from openpype.pipeline.load import LoadError +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object, + update_custom_attribute_data +) + +from openpype.hosts.max.api.lib import ( + unique_namespace, + get_namespace, + object_transform_set, + get_plugins +) +from openpype.hosts.max.api import lib +from pymxs import runtime as rt + + +class OxAbcLoader(load.LoaderPlugin): + """Ornatrix Alembic loader.""" + + families = ["camera", "animation", "pointcache"] + label = "Load Alembic with Ornatrix" + representations = ["abc"] + order = -10 + icon = "code-fork" + color = "orange" + postfix = "param" + + def load(self, context, name=None, namespace=None, data=None): + plugin_list = get_plugins() + if "ephere.plugins.autodesk.max.ornatrix.dlo" not in plugin_list: + raise LoadError("Ornatrix plugin not " + "found/installed in Max yet..") + + file_path = os.path.normpath(self.filepath_from_context(context)) + rt.AlembicImport.ImportToRoot = True + rt.AlembicImport.CustomAttributes = True + rt.importFile( + file_path, rt.name("noPrompt"), + using=rt.Ornatrix_Alembic_Importer) + + scene_object = [] + for obj in rt.rootNode.Children: + obj_type = rt.ClassOf(obj) + if str(obj_type).startswith("Ox_"): + scene_object.append(obj) + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + abc_container = [] + for abc in scene_object: + abc.name = f"{namespace}:{abc.name}" + abc_container.append(abc) + + return containerise( + name, abc_container, context, + namespace, loader=self.__class__.__name__ + ) + + def update(self, container, representation): + path = get_representation_path(representation) + node_name = container["instance_node"] + namespace, name = get_namespace(node_name) + node = rt.getNodeByName(node_name) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + selections = rt.getCurrentSelection() + transform_data = object_transform_set(selections) + for prev_obj in selections: + if rt.isValidNode(prev_obj): + rt.Delete(prev_obj) + + rt.AlembicImport.ImportToRoot = False + rt.AlembicImport.CustomAttributes = True + rt.importFile( + path, rt.name("noPrompt"), + using=rt.Ornatrix_Alembic_Importer) + + scene_object = [] + for obj in rt.rootNode.Children: + obj_type = rt.ClassOf(obj) + if str(obj_type).startswith("Ox_"): + scene_object.append(obj) + ox_abc_objects = [] + for abc in scene_object: + abc.Parent = container + abc.name = f"{namespace}:{abc.name}" + ox_abc_objects.append(abc) + ox_transform = f"{abc.name}.transform" + if ox_transform in transform_data.keys(): + abc.pos = transform_data[ox_transform] or 0 + abc.scale = transform_data[f"{abc.name}.scale"] or 0 + update_custom_attribute_data(node, ox_abc_objects) + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 2a1175167a..e0317a2e22 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,7 +1,15 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.lib import ( + unique_namespace, + +) +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -13,19 +21,24 @@ class PointCloudLoader(load.LoaderPlugin): order = -8 icon = "code-fork" color = "green" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): """load point cloud by tyCache""" from pymxs import runtime as rt - filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath - prt_container = rt.GetNodeByName(obj.name) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + obj.name = f"{namespace}:{obj.name}" return containerise( - name, [prt_container], context, loader=self.__class__.__name__) + name, [obj], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): """update the container""" @@ -33,15 +46,16 @@ class PointCloudLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) + node_list = get_previous_loaded_object(node) + update_custom_attribute_data( + node, node_list) with maintained_selection(): - rt.Select(node.Children) + rt.Select(node_list) for prt in rt.Selection: - prt_object = rt.GetNodeByName(prt.name) - prt_object.filename = path - - lib.imprint(container["instance_node"], { - "representation": str(representation["_id"]) - }) + prt.filename = path + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 31692f6367..daf6d3e169 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -5,8 +5,17 @@ from openpype.pipeline import ( load, get_representation_path ) -from openpype.hosts.max.api.pipeline import containerise +from openpype.pipeline.load import LoadError +from openpype.hosts.max.api.pipeline import ( + containerise, + update_custom_attribute_data, + get_previous_loaded_object +) from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import ( + unique_namespace, + get_plugins +) class RedshiftProxyLoader(load.LoaderPlugin): @@ -21,7 +30,9 @@ class RedshiftProxyLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - + plugin_info = get_plugins() + if "redshift4max.dlr" not in plugin_info: + raise LoadError("Redshift not loaded/installed in Max..") filepath = self.filepath_from_context(context) rs_proxy = rt.RedshiftProxy() rs_proxy.file = filepath @@ -30,24 +41,27 @@ class RedshiftProxyLoader(load.LoaderPlugin): if collections: rs_proxy.is_sequence = True - container = rt.container() - container.name = name - rs_proxy.Parent = container - - asset = rt.getNodeByName(name) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + rs_proxy.name = f"{namespace}:{rs_proxy.name}" return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, [rs_proxy], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) - for children in node.Children: - children_node = rt.getNodeByName(children.name) - for proxy in children_node.Children: - proxy.file = path + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + update_custom_attribute_data( + node, rt.Selection) + for proxy in rt.Selection: + proxy.file = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) diff --git a/openpype/hosts/max/plugins/load/load_tycache.py b/openpype/hosts/max/plugins/load/load_tycache.py new file mode 100644 index 0000000000..41ea267c3d --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_tycache.py @@ -0,0 +1,64 @@ +import os +from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import ( + unique_namespace, + +) +from openpype.hosts.max.api.pipeline import ( + containerise, + get_previous_loaded_object, + update_custom_attribute_data +) +from openpype.pipeline import get_representation_path, load + + +class TyCacheLoader(load.LoaderPlugin): + """TyCache Loader.""" + + families = ["tycache"] + representations = ["tyc"] + order = -8 + icon = "code-fork" + color = "green" + + def load(self, context, name=None, namespace=None, data=None): + """Load tyCache""" + from pymxs import runtime as rt + filepath = os.path.normpath(self.filepath_from_context(context)) + obj = rt.tyCache() + obj.filename = filepath + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + obj.name = f"{namespace}:{obj.name}" + + return containerise( + name, [obj], context, + namespace, loader=self.__class__.__name__) + + def update(self, container, representation): + """update the container""" + from pymxs import runtime as rt + + path = get_representation_path(representation) + node = rt.GetNodeByName(container["instance_node"]) + node_list = get_previous_loaded_object(node) + update_custom_attribute_data(node, node_list) + with maintained_selection(): + for tyc in node_list: + tyc.filename = path + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + """remove the container""" + from pymxs import runtime as rt + + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py new file mode 100644 index 0000000000..86fb6e856c --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from pymxs import runtime as rt + + +class CollectFrameRange(pyblish.api.InstancePlugin): + """Collect Frame Range.""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Collect Frame Range" + hosts = ['max'] + families = ["camera", "maxrender", + "pointcache", "pointcloud", + "review", "redshiftproxy"] + + def process(self, instance): + if instance.data["family"] == "maxrender": + instance.data["frameStartHandle"] = int(rt.rendStart) + instance.data["frameEndHandle"] = int(rt.rendEnd) + else: + instance.data["frameStartHandle"] = int(rt.animationRange.start) + instance.data["frameEndHandle"] = int(rt.animationRange.end) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index db5c84fad9..38194a0735 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -4,17 +4,15 @@ import os import pyblish.api from pymxs import runtime as rt -from openpype.pipeline import get_current_asset_name 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_renderproducts import RenderProducts -from openpype.client import get_last_version_by_subset_name class CollectRender(pyblish.api.InstancePlugin): """Collect Render for Deadline""" - order = pyblish.api.CollectorOrder + 0.01 + order = pyblish.api.CollectorOrder + 0.02 label = "Collect 3dsmax Render Layers" hosts = ['max'] families = ["maxrender"] @@ -27,13 +25,20 @@ class CollectRender(pyblish.api.InstancePlugin): filepath = current_file.replace("\\", "/") context.data['currentFile'] = current_file - asset = get_current_asset_name() files_by_aov = RenderProducts().get_beauty(instance.name) - folder = folder.replace("\\", "/") aovs = RenderProducts().get_aovs(instance.name) files_by_aov.update(aovs) + camera = rt.viewport.GetCamera() + if instance.data.get("members"): + camera_list = [member for member in instance.data["members"] + if rt.ClassOf(member) == rt.Camera.Classes] + if camera_list: + camera = camera_list[-1] + + instance.data["cameras"] = [camera.name] if camera else None # noqa + if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["files"] = list() @@ -41,19 +46,6 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["files"].append(files_by_aov) img_format = RenderProducts().image_format() - project_name = context.data["projectName"] - asset_doc = context.data["assetEntity"] - asset_id = asset_doc["_id"] - version_doc = get_last_version_by_subset_name(project_name, - instance.name, - asset_id) - self.log.debug("version_doc: {0}".format(version_doc)) - version_int = 1 - if version_doc: - version_int += int(version_doc["name"]) - - self.log.debug(f"Setting {version_int} to context.") - context.data["version"] = version_int # OCIO config not support in # most of the 3dsmax renderers # so this is currently hard coded @@ -61,6 +53,17 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["colorspaceConfig"] = "" instance.data["colorspaceDisplay"] = "sRGB" instance.data["colorspaceView"] = "ACES 1.0 SDR-video" + + if int(get_max_version()) >= 2024: + colorspace_mgr = rt.ColorPipelineMgr # noqa + display = next( + (display for display in colorspace_mgr.GetDisplayList())) + view_transform = next( + (view for view in colorspace_mgr.GetViewList(display))) + instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath + instance.data["colorspaceDisplay"] = display + instance.data["colorspaceView"] = view_transform + instance.data["renderProducts"] = colorspace.ARenderProduct() instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] @@ -68,7 +71,7 @@ class CollectRender(pyblish.api.InstancePlugin): renderer = str(renderer_class).split(":")[0] # also need to get the render dir for conversion data = { - "asset": asset, + "asset": instance.data["asset"], "subset": str(instance.name), "publish": True, "maxversion": str(get_max_version()), @@ -78,9 +81,8 @@ class CollectRender(pyblish.api.InstancePlugin): "renderer": renderer, "source": filepath, "plugin": "3dsmax", - "frameStart": int(rt.rendStart), - "frameEnd": int(rt.rendEnd), - "version": version_int, + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "farm": True } instance.data.update(data) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 7aeb45f46b..e7e957e6f1 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -4,7 +4,11 @@ import pyblish.api from pymxs import runtime as rt from openpype.lib import BoolDef -from openpype.pipeline.publish import OpenPypePyblishPluginMixin +from openpype.hosts.max.api.lib import get_max_version +from openpype.pipeline.publish import ( + OpenPypePyblishPluginMixin, + KnownPublishError +) class CollectReview(pyblish.api.InstancePlugin, @@ -18,43 +22,100 @@ class CollectReview(pyblish.api.InstancePlugin, def process(self, instance): nodes = instance.data["members"] - focal_length = None - camera_name = None - for node in nodes: - if rt.classOf(node) in rt.Camera.classes: - camera_name = node.name - focal_length = node.fov + def is_camera(node): + is_camera_class = rt.classOf(node) in rt.Camera.classes + return is_camera_class and rt.isProperty(node, "fov") + + # Use first camera in instance + cameras = [node for node in nodes if is_camera(node)] + if cameras: + if len(cameras) > 1: + self.log.warning( + "Found more than one camera in instance, using first " + f"one found: {cameras[0]}" + ) + camera = cameras[0] + camera_name = camera.name + focal_length = camera.fov + else: + raise KnownPublishError( + "Unable to find a valid camera in 'Review' container." + " Only native max Camera supported. " + f"Found objects: {nodes}" + ) + creator_attrs = instance.data["creator_attributes"] attr_values = self.get_attr_values_from_data(instance.data) - data = { + + general_preview_data = { "review_camera": camera_name, - "frameStart": instance.context.data["frameStart"], - "frameEnd": instance.context.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], + "percentSize": creator_attrs["percentSize"], + "imageFormat": creator_attrs["imageFormat"], + "keepImages": creator_attrs["keepImages"], "fps": instance.context.data["fps"], - "dspGeometry": attr_values.get("dspGeometry"), - "dspShapes": attr_values.get("dspShapes"), - "dspLights": attr_values.get("dspLights"), - "dspCameras": attr_values.get("dspCameras"), - "dspHelpers": attr_values.get("dspHelpers"), - "dspParticles": attr_values.get("dspParticles"), - "dspBones": attr_values.get("dspBones"), - "dspBkg": attr_values.get("dspBkg"), - "dspGrid": attr_values.get("dspGrid"), - "dspSafeFrame": attr_values.get("dspSafeFrame"), - "dspFrameNums": attr_values.get("dspFrameNums") + "review_width": creator_attrs["review_width"], + "review_height": creator_attrs["review_height"], } + + if int(get_max_version()) >= 2024: + colorspace_mgr = rt.ColorPipelineMgr # noqa + display = next( + (display for display in colorspace_mgr.GetDisplayList())) + view_transform = next( + (view for view in colorspace_mgr.GetViewList(display))) + instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath + instance.data["colorspaceDisplay"] = display + instance.data["colorspaceView"] = view_transform + + preview_data = { + "vpStyle": creator_attrs["visualStyleMode"], + "vpPreset": creator_attrs["viewportPreset"], + "vpTextures": creator_attrs["vpTexture"], + "dspGeometry": attr_values.get("dspGeometry"), + "dspShapes": attr_values.get("dspShapes"), + "dspLights": attr_values.get("dspLights"), + "dspCameras": attr_values.get("dspCameras"), + "dspHelpers": attr_values.get("dspHelpers"), + "dspParticles": attr_values.get("dspParticles"), + "dspBones": attr_values.get("dspBones"), + "dspBkg": attr_values.get("dspBkg"), + "dspGrid": attr_values.get("dspGrid"), + "dspSafeFrame": attr_values.get("dspSafeFrame"), + "dspFrameNums": attr_values.get("dspFrameNums") + } + else: + general_viewport = { + "dspBkg": attr_values.get("dspBkg"), + "dspGrid": attr_values.get("dspGrid") + } + nitrous_manager = { + "AntialiasingQuality": creator_attrs["antialiasingQuality"], + } + nitrous_viewport = { + "VisualStyleMode": creator_attrs["visualStyleMode"], + "ViewportPreset": creator_attrs["viewportPreset"], + "UseTextureEnabled": creator_attrs["vpTexture"] + } + preview_data = { + "general_viewport": general_viewport, + "nitrous_manager": nitrous_manager, + "nitrous_viewport": nitrous_viewport, + "vp_btn_mgr": {"EnableButtons": False} + } + # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') burnin_members = instance.data.setdefault("burninDataMembers", {}) burnin_members["focalLength"] = focal_length - self.log.debug(f"data:{data}") - instance.data.update(data) + instance.data.update(general_preview_data) + instance.data["viewport_options"] = preview_data @classmethod def get_attribute_defs(cls): - return [ BoolDef("dspGeometry", label="Geometry", diff --git a/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py new file mode 100644 index 0000000000..0351ca45c5 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_tycache_attributes.py @@ -0,0 +1,76 @@ +import pyblish.api + +from openpype.lib import EnumDef, TextDef +from openpype.pipeline.publish import OpenPypePyblishPluginMixin + + +class CollectTyCacheData(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Collect Channel Attributes for TyCache Export""" + + order = pyblish.api.CollectorOrder + 0.02 + label = "Collect tyCache attribute Data" + hosts = ['max'] + families = ["tycache"] + + def process(self, instance): + attr_values = self.get_attr_values_from_data(instance.data) + attributes = {} + for attr_key in attr_values.get("tycacheAttributes", []): + attributes[attr_key] = True + + for key in ["tycacheLayer", "tycacheObjectName"]: + attributes[key] = attr_values.get(key, "") + + # Collect the selected channel data before exporting + instance.data["tyc_attrs"] = attributes + self.log.debug( + f"Found tycache attributes: {attributes}" + ) + + @classmethod + def get_attribute_defs(cls): + # TODO: Support the attributes with maxObject array + tyc_attr_enum = ["tycacheChanAge", "tycacheChanGroups", + "tycacheChanPos", "tycacheChanRot", + "tycacheChanScale", "tycacheChanVel", + "tycacheChanSpin", "tycacheChanShape", + "tycacheChanMatID", "tycacheChanMapping", + "tycacheChanMaterials", "tycacheChanCustomFloat" + "tycacheChanCustomVector", "tycacheChanCustomTM", + "tycacheChanPhysX", "tycacheMeshBackup", + "tycacheCreateObject", + "tycacheCreateObjectIfNotCreated", + "tycacheAdditionalCloth", + "tycacheAdditionalSkin", + "tycacheAdditionalSkinID", + "tycacheAdditionalSkinIDValue", + "tycacheAdditionalTerrain", + "tycacheAdditionalVDB", + "tycacheAdditionalSplinePaths", + "tycacheAdditionalGeo", + "tycacheAdditionalGeoActivateModifiers", + "tycacheSplines", + "tycacheSplinesAdditionalSplines" + ] + tyc_default_attrs = ["tycacheChanGroups", "tycacheChanPos", + "tycacheChanRot", "tycacheChanScale", + "tycacheChanVel", "tycacheChanShape", + "tycacheChanMatID", "tycacheChanMapping", + "tycacheChanMaterials", + "tycacheCreateObjectIfNotCreated"] + return [ + EnumDef("tycacheAttributes", + tyc_attr_enum, + default=tyc_default_attrs, + multiselection=True, + label="TyCache Attributes"), + TextDef("tycacheLayer", + label="TyCache Layer", + tooltip="Name of tycache layer", + default="$(tyFlowLayer)"), + TextDef("tycacheObjectName", + label="TyCache Object Name", + tooltip="TyCache Object Name", + default="$(tyFlowName)_tyCache") + ] diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py index b42732e70d..a42f27be6e 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -19,10 +19,8 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - start = float(instance.data.get("frameStartHandle", 1)) - end = float(instance.data.get("frameEndHandle", 1)) - - container = instance.data["instance_node"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.info("Extracting Camera ...") diff --git a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py index 06ac3da093..537c88eb4d 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py @@ -19,9 +19,8 @@ class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - self.log.info("Extracting Camera ...") + self.log.debug("Extracting Camera ...") stagingdir = self.staging_dir(instance) filename = "{name}.fbx".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py index de5db9ab56..a7a889c587 100644 --- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py +++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py @@ -18,10 +18,9 @@ class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - container = instance.data["instance_node"] # publish the raw scene for camera - self.log.info("Extracting Raw Max Scene ...") + self.log.debug("Extracting Raw Max Scene ...") stagingdir = self.staging_dir(instance) filename = "{name}.max".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model.py b/openpype/hosts/max/plugins/publish/extract_model.py index c7ecf7efc9..38f4848c5e 100644 --- a/openpype/hosts/max/plugins/publish/extract_model.py +++ b/openpype/hosts/max/plugins/publish/extract_model.py @@ -20,9 +20,7 @@ class ExtractModel(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.abc".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model_fbx.py b/openpype/hosts/max/plugins/publish/extract_model_fbx.py index 56c2cadd94..fd48ed5007 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_model_fbx.py @@ -20,10 +20,7 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.fbx".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model_obj.py b/openpype/hosts/max/plugins/publish/extract_model_obj.py index 4fde65cf22..a5d9ad6597 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_obj.py +++ b/openpype/hosts/max/plugins/publish/extract_model_obj.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.pipeline.publish import KnownPublishError class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): @@ -20,15 +21,14 @@ class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.obj".format(**instance.data) filepath = os.path.join(stagingdir, filename) self.log.info("Writing OBJ '%s' to '%s'" % (filepath, stagingdir)) + self.log.info("Performing Extraction ...") with maintained_selection(): # select and export node_list = instance.data["members"] @@ -40,7 +40,10 @@ class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): using=rt.ObjExp, ) - self.log.info("Performing Extraction ...") + if not os.path.exists(filepath): + raise KnownPublishError( + "File {} wasn't produced by 3ds max, please check the logs.") + if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index 5a99a8b845..f6a8500c08 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -51,10 +51,8 @@ class ExtractAlembic(publish.Extractor): families = ["pointcache"] def process(self, instance): - start = float(instance.data.get("frameStartHandle", 1)) - end = float(instance.data.get("frameEndHandle", 1)) - - container = instance.data["instance_node"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.debug("Extracting pointcache ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 583bbb6dbd..d9fbe5e9dd 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -36,11 +36,12 @@ class ExtractPointCloud(publish.Extractor): label = "Extract Point Cloud" hosts = ["max"] families = ["pointcloud"] + settings = [] def process(self, instance): self.settings = self.get_setting(instance) - start = int(instance.context.data.get("frameStart")) - end = int(instance.context.data.get("frameEnd")) + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.info("Extracting PRT...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py index ab569ecbcb..47ed85977b 100644 --- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py @@ -16,11 +16,10 @@ class ExtractRedshiftProxy(publish.Extractor): families = ["redshiftproxy"] def process(self, instance): - container = instance.data["instance_node"] - start = int(instance.context.data.get("frameStart")) - end = int(instance.context.data.get("frameEnd")) + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] - self.log.info("Extracting Redshift Proxy...") + self.log.debug("Extracting Redshift Proxy...") stagingdir = self.staging_dir(instance) rs_filename = "{name}.rs".format(**instance.data) rs_filepath = os.path.join(stagingdir, rs_filename) diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 8e06e52b5c..99dc5c5cdc 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -1,8 +1,9 @@ import os import pyblish.api -from pymxs import runtime as rt from openpype.pipeline import publish -from openpype.hosts.max.api.lib import viewport_camera, get_max_version +from openpype.hosts.max.api.preview_animation import ( + render_preview_animation +) class ExtractReviewAnimation(publish.Extractor): @@ -18,24 +19,26 @@ class ExtractReviewAnimation(publish.Extractor): def process(self, instance): staging_dir = self.staging_dir(instance) ext = instance.data.get("imageFormat") - filename = "{0}..{1}".format(instance.name, ext) start = int(instance.data["frameStart"]) end = int(instance.data["frameEnd"]) - fps = int(instance.data["fps"]) - filepath = os.path.join(staging_dir, filename) - filepath = filepath.replace("\\", "/") - filenames = self.get_files( - instance.name, start, end, ext) - + filepath = os.path.join(staging_dir, instance.name) self.log.debug( - "Writing Review Animation to" - " '%s' to '%s'" % (filename, staging_dir)) + "Writing Review Animation to '{}'".format(filepath)) review_camera = instance.data["review_camera"] - with viewport_camera(review_camera): - preview_arg = self.set_preview_arg( - instance, filepath, start, end, fps) - rt.execute(preview_arg) + viewport_options = instance.data.get("viewport_options", {}) + files = render_preview_animation( + filepath, + ext, + review_camera, + start, + end, + percentSize=instance.data["percentSize"], + width=instance.data["review_width"], + height=instance.data["review_height"], + viewport_options=viewport_options) + + filenames = [os.path.basename(path) for path in files] tags = ["review"] if not instance.data.get("keepImages"): @@ -48,8 +51,8 @@ class ExtractReviewAnimation(publish.Extractor): "ext": instance.data["imageFormat"], "files": filenames, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": review_camera @@ -59,44 +62,3 @@ class ExtractReviewAnimation(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(representation) - - def get_files(self, filename, start, end, ext): - file_list = [] - for frame in range(int(start), int(end) + 1): - actual_name = "{}.{:04}.{}".format( - filename, frame, ext) - file_list.append(actual_name) - - return file_list - - def set_preview_arg(self, instance, filepath, - start, end, fps): - job_args = list() - default_option = f'CreatePreview filename:"{filepath}"' - job_args.append(default_option) - frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa - job_args.append(frame_option) - rndLevel = instance.data.get("rndLevel") - if rndLevel: - option = f"rndLevel:#{rndLevel}" - job_args.append(option) - options = [ - "percentSize", "dspGeometry", "dspShapes", - "dspLights", "dspCameras", "dspHelpers", "dspParticles", - "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" - ] - - for key in options: - enabled = instance.data.get(key) - if enabled: - job_args.append(f"{key}:{enabled}") - - if get_max_version() == 2024: - # hardcoded for current stage - auto_play_option = "autoPlay:false" - job_args.append(auto_play_option) - - job_str = " ".join(job_args) - self.log.debug(job_str) - - return job_str diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index 82f4fc7a8b..02fa75e032 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -1,14 +1,11 @@ import os -import tempfile import pyblish.api -from pymxs import runtime as rt from openpype.pipeline import publish -from openpype.hosts.max.api.lib import viewport_camera, get_max_version +from openpype.hosts.max.api.preview_animation import render_preview_animation class ExtractThumbnail(publish.Extractor): - """ - Extract Thumbnail for Review + """Extract Thumbnail for Review """ order = pyblish.api.ExtractorOrder @@ -17,34 +14,33 @@ class ExtractThumbnail(publish.Extractor): families = ["review"] def process(self, instance): - # TODO: Create temp directory for thumbnail - # - this is to avoid "override" of source file - tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_") - self.log.debug( - f"Create temp directory {tmp_staging} for thumbnail" - ) - fps = int(instance.data["fps"]) + ext = instance.data.get("imageFormat") frame = int(instance.data["frameStart"]) - instance.context.data["cleanupFullPaths"].append(tmp_staging) - filename = "{name}_thumbnail..png".format(**instance.data) - filepath = os.path.join(tmp_staging, filename) - filepath = filepath.replace("\\", "/") - thumbnail = self.get_filename(instance.name, frame) + staging_dir = self.staging_dir(instance) + filepath = os.path.join( + staging_dir, f"{instance.name}_thumbnail") + self.log.debug("Writing Thumbnail to '{}'".format(filepath)) - self.log.debug( - "Writing Thumbnail to" - " '%s' to '%s'" % (filename, tmp_staging)) review_camera = instance.data["review_camera"] - with viewport_camera(review_camera): - preview_arg = self.set_preview_arg( - instance, filepath, fps, frame) - rt.execute(preview_arg) + viewport_options = instance.data.get("viewport_options", {}) + files = render_preview_animation( + filepath, + ext, + review_camera, + start_frame=frame, + end_frame=frame, + percentSize=instance.data["percentSize"], + width=instance.data["review_width"], + height=instance.data["review_height"], + viewport_options=viewport_options) + + thumbnail = next(os.path.basename(path) for path in files) representation = { "name": "thumbnail", - "ext": "png", + "ext": ext, "files": thumbnail, - "stagingDir": tmp_staging, + "stagingDir": staging_dir, "thumbnail": True } @@ -53,39 +49,3 @@ class ExtractThumbnail(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(representation) - - def get_filename(self, filename, target_frame): - thumbnail_name = "{}_thumbnail.{:04}.png".format( - filename, target_frame - ) - return thumbnail_name - - def set_preview_arg(self, instance, filepath, fps, frame): - job_args = list() - default_option = f'CreatePreview filename:"{filepath}"' - job_args.append(default_option) - frame_option = f"outputAVI:false start:{frame} end:{frame} fps:{fps}" # noqa - job_args.append(frame_option) - rndLevel = instance.data.get("rndLevel") - if rndLevel: - option = f"rndLevel:#{rndLevel}" - job_args.append(option) - options = [ - "percentSize", "dspGeometry", "dspShapes", - "dspLights", "dspCameras", "dspHelpers", "dspParticles", - "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" - ] - - for key in options: - enabled = instance.data.get(key) - if enabled: - job_args.append(f"{key}:{enabled}") - if get_max_version() == 2024: - # hardcoded for current stage - auto_play_option = "autoPlay:false" - job_args.append(auto_play_option) - - job_str = " ".join(job_args) - self.log.debug(job_str) - - return job_str diff --git a/openpype/hosts/max/plugins/publish/extract_tycache.py b/openpype/hosts/max/plugins/publish/extract_tycache.py new file mode 100644 index 0000000000..9bfe74f679 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_tycache.py @@ -0,0 +1,157 @@ +import os + +import pyblish.api +from pymxs import runtime as rt + +from openpype.hosts.max.api import maintained_selection +from openpype.pipeline import publish + + +class ExtractTyCache(publish.Extractor): + """Extract tycache format with tyFlow operators. + Notes: + - TyCache only works for TyFlow Pro Plugin. + + Methods: + self.get_export_particles_job_args(): sets up all job arguments + for attributes to be exported in MAXscript + + self.get_operators(): get the export_particle operator + + self.get_files(): get the files with tyFlow naming convention + before publishing + """ + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Extract TyCache" + hosts = ["max"] + families = ["tycache"] + + def process(self, instance): + # TODO: let user decide the param + start = int(instance.context.data["frameStart"]) + end = int(instance.context.data.get("frameEnd")) + self.log.debug("Extracting Tycache...") + + stagingdir = self.staging_dir(instance) + filename = "{name}.tyc".format(**instance.data) + path = os.path.join(stagingdir, filename) + filenames = self.get_files(instance, start, end) + additional_attributes = instance.data.get("tyc_attrs", {}) + + with maintained_selection(): + job_args = self.get_export_particles_job_args( + instance.data["members"], + start, end, path, + additional_attributes) + for job in job_args: + rt.Execute(job) + representations = instance.data.setdefault("representations", []) + representation = { + 'name': 'tyc', + 'ext': 'tyc', + 'files': filenames if len(filenames) > 1 else filenames[0], + "stagingDir": stagingdir, + } + representations.append(representation) + + # Get the tyMesh filename for extraction + mesh_filename = f"{instance.name}__tyMesh.tyc" + mesh_repres = { + 'name': 'tyMesh', + 'ext': 'tyc', + 'files': mesh_filename, + "stagingDir": stagingdir, + "outputName": '__tyMesh' + } + representations.append(mesh_repres) + self.log.debug(f"Extracted instance '{instance.name}' to: {filenames}") + + def get_files(self, instance, start_frame, end_frame): + """Get file names for tyFlow in tyCache format. + + Set the filenames accordingly to the tyCache file + naming extension(.tyc) for the publishing purpose + + Actual File Output from tyFlow in tyCache format: + __tyPart_.tyc + + e.g. tycacheMain__tyPart_00000.tyc + + Args: + instance (pyblish.api.Instance): instance. + start_frame (int): Start frame. + end_frame (int): End frame. + + Returns: + filenames(list): list of filenames + + """ + filenames = [] + for frame in range(int(start_frame), int(end_frame) + 1): + filename = f"{instance.name}__tyPart_{frame:05}.tyc" + filenames.append(filename) + return filenames + + def get_export_particles_job_args(self, members, start, end, + filepath, additional_attributes): + """Sets up all job arguments for attributes. + + Those attributes are to be exported in MAX Script. + + Args: + members (list): Member nodes of the instance. + start (int): Start frame. + end (int): End frame. + filepath (str): Output path of the TyCache file. + additional_attributes (dict): channel attributes data + which needed to be exported + + Returns: + list of arguments for MAX Script. + + """ + settings = { + "exportMode": 2, + "frameStart": start, + "frameEnd": end, + "tyCacheFilename": filepath.replace("\\", "/") + } + settings.update(additional_attributes) + + job_args = [] + for operator in self.get_operators(members): + for key, value in settings.items(): + if isinstance(value, str): + # embed in quotes + value = f'"{value}"' + + job_args.append(f"{operator}.{key}={value}") + job_args.append(f"{operator}.exportTyCache()") + return job_args + + @staticmethod + def get_operators(members): + """Get Export Particles Operator. + + Args: + members (list): Instance members. + + Returns: + list of particle operators + + """ + opt_list = [] + for member in members: + obj = member.baseobject + # TODO: see if it can use maxscript instead + anim_names = rt.GetSubAnimNames(obj) + for anim_name in anim_names: + sub_anim = rt.GetSubAnim(obj, anim_name) + boolean = rt.IsProperty(sub_anim, "Export_Particles") + if boolean: + event_name = sub_anim.Name + opt = f"${member.Name}.{event_name}.export_particles" + opt_list.append(opt) + + return opt_list diff --git a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py b/openpype/hosts/max/plugins/publish/validate_animation_timeline.py deleted file mode 100644 index 2a9483c763..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py +++ /dev/null @@ -1,48 +0,0 @@ -import pyblish.api - -from pymxs import runtime as rt -from openpype.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, - PublishValidationError -) -from openpype.hosts.max.api.lib import get_frame_range, set_timeline - - -class ValidateAnimationTimeline(pyblish.api.InstancePlugin): - """ - Validates Animation Timeline for Preview Animation in Max - """ - - label = "Animation Timeline for Review" - order = ValidateContentsOrder - families = ["review"] - hosts = ["max"] - actions = [RepairAction] - - def process(self, instance): - frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) - if rt.animationRange.start != frame_start_handle or ( - rt.animationRange.end != frame_end_handle - ): - raise PublishValidationError("Incorrect animation timeline " - "set for preview animation.. " - "\nYou can use repair action to " - "the correct animation timeline") - - @classmethod - def repair(cls, instance): - frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) - set_timeline(frame_start_handle, frame_end_handle) diff --git a/openpype/hosts/max/plugins/publish/validate_attributes.py b/openpype/hosts/max/plugins/publish/validate_attributes.py new file mode 100644 index 0000000000..0632ee38f0 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_attributes.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +"""Validator for Attributes.""" +from pyblish.api import ContextPlugin, ValidatorOrder +from pymxs import runtime as rt + +from openpype.pipeline.publish import ( + OptionalPyblishPluginMixin, + PublishValidationError, + RepairContextAction +) + + +def has_property(object_name, property_name): + """Return whether an object has a property with given name""" + return rt.Execute(f'isProperty {object_name} "{property_name}"') + + +def is_matching_value(object_name, property_name, value): + """Return whether an existing property matches value `value""" + property_value = rt.Execute(f"{object_name}.{property_name}") + + # Wrap property value if value is a string valued attributes + # starting with a `#` + if ( + isinstance(value, str) and + value.startswith("#") and + not value.endswith(")") + ): + # prefix value with `#` + # not applicable for #() array value type + # and only applicable for enum i.e. #bob, #sally + property_value = f"#{property_value}" + + return property_value == value + + +class ValidateAttributes(OptionalPyblishPluginMixin, + ContextPlugin): + """Validates attributes in the project setting are consistent + with the nodes from MaxWrapper Class in 3ds max. + E.g. "renderers.current.separateAovFiles", + "renderers.production.PrimaryGIEngine" + Admin(s) need to put the dict below and enable this validator for a check: + { + "renderers.current":{ + "separateAovFiles" : True + }, + "renderers.production":{ + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" + } + .... + } + + """ + + order = ValidatorOrder + hosts = ["max"] + label = "Attributes" + actions = [RepairContextAction] + optional = True + + @classmethod + def get_invalid(cls, context): + attributes = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateAttributes"]["attributes"] + ) + if not attributes: + return + invalid = [] + for object_name, required_properties in attributes.items(): + if not rt.Execute(f"isValidValue {object_name}"): + # Skip checking if the node does not + # exist in MaxWrapper Class + cls.log.debug(f"Unable to find '{object_name}'." + " Skipping validation of attributes.") + continue + + for property_name, value in required_properties.items(): + if not has_property(object_name, property_name): + cls.log.error( + "Non-existing property: " + f"{object_name}.{property_name}") + invalid.append((object_name, property_name)) + + if not is_matching_value(object_name, property_name, value): + cls.log.error( + f"Invalid value for: {object_name}.{property_name}" + f" should be: {value}") + invalid.append((object_name, property_name)) + + return invalid + + def process(self, context): + if not self.is_active(context.data): + self.log.debug("Skipping Validate Attributes...") + return + invalid_attributes = self.get_invalid(context) + if invalid_attributes: + bullet_point_invalid_statement = "\n".join( + "- {}".format(invalid) for invalid + in invalid_attributes + ) + report = ( + "Required Attribute(s) have invalid value(s).\n\n" + f"{bullet_point_invalid_statement}\n\n" + "You can use repair action to fix them if they are not\n" + "unknown property value(s)." + ) + raise PublishValidationError( + report, title="Invalid Value(s) for Required Attribute(s)") + + @classmethod + def repair(cls, context): + attributes = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateAttributes"]["attributes"] + ) + invalid_attributes = cls.get_invalid(context) + for attrs in invalid_attributes: + prop, attr = attrs + value = attributes[prop][attr] + if isinstance(value, str) and not value.startswith("#"): + attribute_fix = '{}.{}="{}"'.format( + prop, attr, value + ) + else: + attribute_fix = "{}.{}={}".format( + prop, attr, value + ) + rt.Execute(attribute_fix) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index 21e847405e..0e8316e844 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -7,8 +7,10 @@ from openpype.pipeline import ( from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, - PublishValidationError + PublishValidationError, + KnownPublishError ) +from openpype.hosts.max.api.lib import get_frame_range, set_timeline class ValidateFrameRange(pyblish.api.InstancePlugin, @@ -27,38 +29,60 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, label = "Validate Frame Range" order = ValidateContentsOrder - families = ["maxrender"] + families = ["camera", "maxrender", + "pointcache", "pointcloud", + "review", "redshiftproxy"] hosts = ["max"] optional = True actions = [RepairAction] def process(self, instance): if not self.is_active(instance.data): - self.log.info("Skipping validation...") + self.log.debug("Skipping Validate Frame Range...") return - context = instance.context - frame_start = int(context.data.get("frameStart")) - frame_end = int(context.data.get("frameEnd")) - - inst_frame_start = int(instance.data.get("frameStart")) - inst_frame_end = int(instance.data.get("frameEnd")) + frame_range = get_frame_range( + asset_doc=instance.data["assetEntity"]) + inst_frame_start = instance.data.get("frameStartHandle") + inst_frame_end = instance.data.get("frameEndHandle") + if inst_frame_start is None or inst_frame_end is None: + raise KnownPublishError( + "Missing frame start and frame end on " + "instance to to validate." + ) + frame_start_handle = frame_range["frameStartHandle"] + frame_end_handle = frame_range["frameEndHandle"] errors = [] - if frame_start != inst_frame_start: + if frame_start_handle != inst_frame_start: errors.append( f"Start frame ({inst_frame_start}) on instance does not match " # noqa - f"with the start frame ({frame_start}) set on the asset data. ") # noqa - if frame_end != inst_frame_end: + f"with the start frame ({frame_start_handle}) set on the asset data. ") # noqa + if frame_end_handle != inst_frame_end: errors.append( f"End frame ({inst_frame_end}) on instance does not match " - f"with the end frame ({frame_start}) from the asset data. ") + f"with the end frame ({frame_end_handle}) " + "from the asset data. ") if errors: - errors.append("You can use repair action to fix it.") - raise PublishValidationError("\n".join(errors)) + bullet_point_errors = "\n".join( + "- {}".format(error) for error in errors + ) + report = ( + "Frame range settings are incorrect.\n\n" + f"{bullet_point_errors}\n\n" + "You can use repair action to fix it." + ) + raise PublishValidationError(report, title="Frame Range incorrect") @classmethod def repair(cls, instance): - rt.rendStart = instance.context.data.get("frameStart") - rt.rendEnd = instance.context.data.get("frameEnd") + frame_range = get_frame_range() + frame_start_handle = frame_range["frameStartHandle"] + frame_end_handle = frame_range["frameEndHandle"] + + if instance.data["family"] == "maxrender": + rt.rendStart = frame_start_handle + rt.rendEnd = frame_end_handle + else: + set_timeline(frame_start_handle, frame_end_handle) diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_instance_has_members.py similarity index 52% rename from openpype/hosts/max/plugins/publish/validate_no_max_content.py rename to openpype/hosts/max/plugins/publish/validate_instance_has_members.py index c6a27dace3..3c0039d5e0 100644 --- a/openpype/hosts/max/plugins/publish/validate_no_max_content.py +++ b/openpype/hosts/max/plugins/publish/validate_instance_has_members.py @@ -1,22 +1,24 @@ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError -from pymxs import runtime as rt -class ValidateMaxContents(pyblish.api.InstancePlugin): - """Validates Max contents. +class ValidateInstanceHasMembers(pyblish.api.InstancePlugin): + """Validates Instance has members. - Check if MaxScene container includes any contents underneath. + Check if MaxScene containers includes any contents underneath. """ order = pyblish.api.ValidatorOrder families = ["camera", + "model", "maxScene", - "maxrender", - "review"] + "review", + "pointcache", + "pointcloud", + "redshiftproxy"] hosts = ["max"] - label = "Max Scene Contents" + label = "Container Contents" def process(self, instance): if not instance.data["members"]: diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py new file mode 100644 index 0000000000..efa06795b0 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""Validator for Loaded Plugin.""" +import os +import pyblish.api +from pymxs import runtime as rt + +from openpype.pipeline.publish import ( + RepairAction, + OptionalPyblishPluginMixin, + PublishValidationError +) +from openpype.hosts.max.api.lib import get_plugins + + +class ValidateLoadedPlugin(OptionalPyblishPluginMixin, + pyblish.api.InstancePlugin): + """Validates if the specific plugin is loaded in 3ds max. + Studio Admin(s) can add the plugins they want to check in validation + via studio defined project settings + """ + + order = pyblish.api.ValidatorOrder + hosts = ["max"] + label = "Validate Loaded Plugins" + optional = True + actions = [RepairAction] + + family_plugins_mapping = {} + + @classmethod + def get_invalid(cls, instance): + """Plugin entry point.""" + family_plugins_mapping = cls.family_plugins_mapping + if not family_plugins_mapping: + return + + invalid = [] + # Find all plug-in requirements for current instance + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + cls.log.debug("Checking plug-in validation " + f"for instance families: {instance_families}") + all_required_plugins = set() + + for mapping in family_plugins_mapping: + # Check for matching families + if not mapping: + return + + match_families = {fam.strip() for fam in mapping["families"]} + has_match = "*" in match_families or match_families.intersection( + instance_families) + + if not has_match: + continue + + cls.log.debug( + f"Found plug-in family requirements: {match_families}") + required_plugins = [ + # match lowercase and format with os.environ to allow + # plugin names defined by max version, e.g. {3DSMAX_VERSION} + plugin.format(**os.environ).lower() + for plugin in mapping["plugins"] + # ignore empty fields in settings + if plugin.strip() + ] + + all_required_plugins.update(required_plugins) + + if not all_required_plugins: + # Instance has no plug-in requirements + return + + # get all DLL loaded plugins in Max and their plugin index + available_plugins = { + plugin_name.lower(): index for index, plugin_name in enumerate( + get_plugins()) + } + # validate the required plug-ins + for plugin in sorted(all_required_plugins): + plugin_index = available_plugins.get(plugin) + if plugin_index is None: + debug_msg = ( + f"Plugin {plugin} does not exist" + " in 3dsMax Plugin List." + ) + invalid.append((plugin, debug_msg)) + continue + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + debug_msg = f"Plugin {plugin} not loaded." + invalid.append((plugin, debug_msg)) + return invalid + + def process(self, instance): + if not self.is_active(instance.data): + self.log.debug("Skipping Validate Loaded Plugin...") + return + invalid = self.get_invalid(instance) + if invalid: + bullet_point_invalid_statement = "\n".join( + "- {}".format(message) for _, message in invalid + ) + report = ( + "Required plugins are not loaded.\n\n" + f"{bullet_point_invalid_statement}\n\n" + "You can use repair action to load the plugin." + ) + raise PublishValidationError( + report, title="Missing Required Plugins") + + @classmethod + def repair(cls, instance): + # get all DLL loaded plugins in Max and their plugin index + invalid = cls.get_invalid(instance) + if not invalid: + return + + # get all DLL loaded plugins in Max and their plugin index + available_plugins = { + plugin_name.lower(): index for index, plugin_name in enumerate( + get_plugins()) + } + + for invalid_plugin, _ in invalid: + plugin_index = available_plugins.get(invalid_plugin) + + if plugin_index is None: + cls.log.warning( + f"Can't enable missing plugin: {invalid_plugin}") + continue + + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + rt.pluginManager.loadPluginDll(plugin_index) diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py index 295a23f1f6..54d6d0f11a 100644 --- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -14,29 +14,16 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): def process(self, instance): """ Notes: - - 1. Validate the container only include tyFlow objects - 2. Validate if tyFlow operator Export Particle exists - 3. Validate if the export mode of Export Particle is at PRT format - 4. Validate the partition count and range set as default value + 1. Validate if the export mode of Export Particle is at PRT format + 2. Validate the partition count and range set as default value Partition Count : 100 Partition Range : 1 to 1 - 5. Validate if the custom attribute(s) exist as parameter(s) + 3. Validate if the custom attribute(s) exist as parameter(s) of export_particle operator """ report = [] - invalid_object = self.get_tyflow_object(instance) - if invalid_object: - report.append(f"Non tyFlow object found: {invalid_object}") - - invalid_operator = self.get_tyflow_operator(instance) - if invalid_operator: - report.append(( - "tyFlow ExportParticle operator not " - f"found: {invalid_operator}")) - if self.validate_export_mode(instance): report.append("The export mode is not at PRT") @@ -52,46 +39,6 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): if report: raise PublishValidationError(f"{report}") - def get_tyflow_object(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info(f"Validating tyFlow container for {container}") - - selection_list = instance.data["members"] - for sel in selection_list: - sel_tmp = str(sel) - if rt.ClassOf(sel) in [rt.tyFlow, - rt.Editable_Mesh]: - if "tyFlow" not in sel_tmp: - invalid.append(sel) - else: - invalid.append(sel) - - return invalid - - def get_tyflow_operator(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info(f"Validating tyFlow object for {container}") - selection_list = instance.data["members"] - bool_list = [] - for sel in selection_list: - obj = sel.baseobject - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - # get all the names of the related tyFlow nodes - sub_anim = rt.GetSubAnim(obj, anim_name) - # check if there is export particle operator - boolean = rt.IsProperty(sub_anim, "Export_Particles") - bool_list.append(str(boolean)) - # if the export_particles property is not there - # it means there is not a "Export Particle" operator - if "True" not in bool_list: - self.log.error("Operator 'Export Particles' not found!") - invalid.append(sel) - - return invalid - def validate_custom_attribute(self, instance): invalid = [] container = instance.data["instance_node"] @@ -100,8 +47,8 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): selection_list = instance.data["members"] - project_setting = instance.data["project_setting"] - attr_settings = project_setting["max"]["PointCloud"]["attribute"] + project_settings = instance.context.data["project_settings"] + attr_settings = project_settings["max"]["PointCloud"]["attribute"] for sel in selection_list: obj = sel.baseobject anim_names = rt.GetSubAnimNames(obj) diff --git a/openpype/hosts/max/plugins/publish/validate_renderable_camera.py b/openpype/hosts/max/plugins/publish/validate_renderable_camera.py new file mode 100644 index 0000000000..61321661b5 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_renderable_camera.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin) +from openpype.pipeline.publish import RepairAction +from openpype.hosts.max.api.lib import get_current_renderer + +from pymxs import runtime as rt + + +class ValidateRenderableCamera(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates Renderable Camera + + Check if the renderable camera used for rendering + """ + + order = pyblish.api.ValidatorOrder + families = ["maxrender"] + hosts = ["max"] + label = "Renderable Camera" + optional = True + actions = [RepairAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + if not instance.data["cameras"]: + raise PublishValidationError( + "No renderable Camera found in scene." + ) + + @classmethod + def repair(cls, instance): + + rt.viewport.setType(rt.Name("view_camera")) + camera = rt.viewport.GetCamera() + cls.log.info(f"Camera {camera} set as renderable camera") + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + if renderer == "Arnold": + arv = rt.MAXToAOps.ArnoldRenderView() + arv.setOption("Camera", str(camera)) + arv.close() + instance.data["cameras"] = [camera.name] diff --git a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py index 5fcb843b20..7d91a7b991 100644 --- a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py +++ b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py @@ -6,11 +6,6 @@ from openpype.pipeline import ( from pymxs import runtime as rt from openpype.hosts.max.api.lib import reset_scene_resolution -from openpype.pipeline.context_tools import ( - get_current_project_asset, - get_current_project -) - class ValidateResolutionSetting(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): @@ -26,7 +21,7 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return width, height = self.get_db_resolution(instance) - current_width = rt.renderwidth + current_width = rt.renderWidth current_height = rt.renderHeight if current_width != width and current_height != height: raise PublishValidationError("Resolution Setting " @@ -43,22 +38,16 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin, "on asset or shot.") def get_db_resolution(self, instance): - data = ["data.resolutionWidth", "data.resolutionHeight"] - project_resolution = get_current_project(fields=data) - project_resolution_data = project_resolution["data"] - asset_resolution = get_current_project_asset(fields=data) - asset_resolution_data = asset_resolution["data"] - # Set project resolution - project_width = int( - project_resolution_data.get("resolutionWidth", 1920)) - project_height = int( - project_resolution_data.get("resolutionHeight", 1080)) - width = int( - asset_resolution_data.get("resolutionWidth", project_width)) - height = int( - asset_resolution_data.get("resolutionHeight", project_height)) + asset_doc = instance.data["assetEntity"] + project_doc = instance.context.data["projectEntity"] + for data in [asset_doc["data"], project_doc["data"]]: + if "resolutionWidth" in data and "resolutionHeight" in data: + width = data["resolutionWidth"] + height = data["resolutionHeight"] + return int(width), int(height) - return width, height + # Defaults if not found in asset document or project document + return 1920, 1080 @classmethod def repair(cls, instance): diff --git a/openpype/hosts/max/plugins/publish/validate_tyflow_data.py b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py new file mode 100644 index 0000000000..c0f29422ec --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_tyflow_data.py @@ -0,0 +1,88 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError +from pymxs import runtime as rt + + +class ValidateTyFlowData(pyblish.api.InstancePlugin): + """Validate TyFlow plugins or relevant operators are set correctly.""" + + order = pyblish.api.ValidatorOrder + families = ["pointcloud", "tycache"] + hosts = ["max"] + label = "TyFlow Data" + + def process(self, instance): + """ + Notes: + 1. Validate the container only include tyFlow objects + 2. Validate if tyFlow operator Export Particle exists + + """ + + invalid_object = self.get_tyflow_object(instance) + if invalid_object: + self.log.error(f"Non tyFlow object found: {invalid_object}") + + invalid_operator = self.get_tyflow_operator(instance) + if invalid_operator: + self.log.error( + "Operator 'Export Particles' not found in tyFlow editor.") + if invalid_object or invalid_operator: + raise PublishValidationError( + "issues occurred", + description="Container should only include tyFlow object " + "and tyflow operator 'Export Particle' should be in " + "the tyFlow editor.") + + def get_tyflow_object(self, instance): + """Get the nodes which are not tyFlow object(s) + and editable mesh(es) + + Args: + instance (pyblish.api.Instance): instance + + Returns: + list: invalid nodes which are not tyFlow + object(s) and editable mesh(es). + """ + container = instance.data["instance_node"] + self.log.debug(f"Validating tyFlow container for {container}") + + allowed_classes = [rt.tyFlow, rt.Editable_Mesh] + return [ + member for member in instance.data["members"] + if rt.ClassOf(member) not in allowed_classes + ] + + def get_tyflow_operator(self, instance): + """Check if the Export Particle Operators in the node + connections. + + Args: + instance (str): instance node + + Returns: + invalid(list): list of invalid nodes which do + not consist of Export Particle Operators as parts + of the node connections + """ + invalid = [] + members = instance.data["members"] + for member in members: + obj = member.baseobject + + # There must be at least one animation with export + # particles enabled + has_export_particles = False + anim_names = rt.GetSubAnimNames(obj) + for anim_name in anim_names: + # get name of the related tyFlow node + sub_anim = rt.GetSubAnim(obj, anim_name) + # check if there is export particle operator + if rt.IsProperty(sub_anim, "Export_Particles"): + has_export_particles = True + break + + if not has_export_particles: + invalid.append(member) + return invalid diff --git a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py deleted file mode 100644 index 9957e62736..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator for USD plugin.""" -from openpype.pipeline import PublishValidationError -from pyblish.api import InstancePlugin, ValidatorOrder -from pymxs import runtime as rt - - -def get_plugins() -> list: - """Get plugin list from 3ds max.""" - manager = rt.PluginManager - count = manager.pluginDllCount - plugin_info_list = [] - for p in range(1, count + 1): - plugin_info = manager.pluginDllName(p) - plugin_info_list.append(plugin_info) - - return plugin_info_list - - -class ValidateUSDPlugin(InstancePlugin): - """Validates if USD plugin is installed or loaded in 3ds max.""" - - order = ValidatorOrder - 0.01 - families = ["model"] - hosts = ["max"] - label = "USD Plugin" - - def process(self, instance): - """Plugin entry point.""" - - plugin_info = get_plugins() - usd_import = "usdimport.dli" - if usd_import not in plugin_info: - raise PublishValidationError(f"USD Plugin {usd_import} not found") - usd_export = "usdexport.dle" - if usd_export not in plugin_info: - raise PublishValidationError(f"USD Plugin {usd_export} not found") diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 260241f5fc..c8f4050bc1 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -6,6 +6,7 @@ from pyblish.api import Instance from maya import cmds # noqa import maya.mel as mel # noqa +from openpype.hosts.maya.api.lib import maintained_selection class FBXExtractor: @@ -53,7 +54,6 @@ class FBXExtractor: "bakeComplexEnd": int, "bakeComplexStep": int, "bakeResampleAnimation": bool, - "animationOnly": bool, "useSceneName": bool, "quaternion": str, # "euler" "shapes": bool, @@ -63,7 +63,10 @@ class FBXExtractor: "embeddedTextures": bool, "inputConnections": bool, "upAxis": str, # x, y or z, - "triangulate": bool + "triangulate": bool, + "fileVersion": str, + "skeletonDefinitions": bool, + "referencedAssetsContent": bool } @property @@ -94,7 +97,6 @@ class FBXExtractor: "bakeComplexEnd": end_frame, "bakeComplexStep": 1, "bakeResampleAnimation": True, - "animationOnly": False, "useSceneName": False, "quaternion": "euler", "shapes": True, @@ -104,7 +106,10 @@ class FBXExtractor: "embeddedTextures": False, "inputConnections": True, "upAxis": "y", - "triangulate": False + "triangulate": False, + "fileVersion": "FBX202000", + "skeletonDefinitions": False, + "referencedAssetsContent": False } def __init__(self, log=None): @@ -151,7 +156,7 @@ class FBXExtractor: # Parse export options options = self.default_options options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Collect the start and end including handles start = instance.data.get("frameStartHandle") or \ @@ -181,7 +186,7 @@ class FBXExtractor: template = "FBXExport{0} {1}" if key == "UpAxis" else \ "FBXExport{0} -v {1}" # noqa cmd = template.format(key, value) - self.log.info(cmd) + self.log.debug(cmd) mel.eval(cmd) # Never show the UI or generate a log @@ -198,5 +203,9 @@ class FBXExtractor: path (str): Path to use for export. """ - cmds.select(members, r=True, noExpand=True) - mel.eval('FBXExport -f "{}" -s'.format(path)) + # The export requires forward slashes because we need + # to format it into a string in a mel expression + path = path.replace("\\", "/") + with maintained_selection(): + cmds.select(members, r=True, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 40b3419e73..af726409d4 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -62,19 +62,6 @@ SHAPE_ATTRS = {"castsShadows", "doubleSided", "opposite"} -RENDER_ATTRS = {"vray": { - "node": "vraySettings", - "prefix": "fileNamePrefix", - "padding": "fileNamePadding", - "ext": "imageFormatStr" -}, - "default": { - "node": "defaultRenderGlobals", - "prefix": "imageFilePrefix", - "padding": "extensionPadding" -} -} - DEFAULT_MATRIX = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, @@ -115,8 +102,6 @@ _alembic_options = { INT_FPS = {15, 24, 25, 30, 48, 50, 60, 44100, 48000} FLOAT_FPS = {23.98, 23.976, 29.97, 47.952, 59.94} -RENDERLIKE_INSTANCE_FAMILIES = ["rendering", "vrayscene"] - DISPLAY_LIGHTS_ENUM = [ {"label": "Use Project Settings", "value": "project_settings"}, @@ -146,6 +131,10 @@ def suspended_refresh(suspend=True): cmds.ogs(pause=True) is a toggle so we cant pass False. """ + if IS_HEADLESS: + yield + return + original_state = cmds.ogs(query=True, pause=True) try: if suspend and not original_state: @@ -183,6 +172,51 @@ def maintained_selection(): cmds.select(clear=True) +def get_namespace(node): + """Return namespace of given node""" + node_name = node.rsplit("|", 1)[-1] + if ":" in node_name: + return node_name.rsplit(":", 1)[0] + else: + return "" + + +def strip_namespace(node, namespace): + """Strip given namespace from node path. + + The namespace will only be stripped from names + if it starts with that namespace. If the namespace + occurs within another namespace it's not removed. + + Examples: + >>> strip_namespace("namespace:node", namespace="namespace:") + "node" + >>> strip_namespace("hello:world:node", namespace="hello:world") + "node" + >>> strip_namespace("hello:world:node", namespace="hello") + "world:node" + >>> strip_namespace("hello:world:node", namespace="world") + "hello:world:node" + >>> strip_namespace("ns:group|ns:node", namespace="ns") + "group|node" + + Returns: + str: Node name without given starting namespace. + + """ + + # Ensure namespace ends with `:` + if not namespace.endswith(":"): + namespace = "{}:".format(namespace) + + # The long path for a node can also have the namespace + # in its parents so we need to remove it from each + return "|".join( + name[len(namespace):] if name.startswith(namespace) else name + for name in node.split("|") + ) + + def get_custom_namespace(custom_namespace): """Return unique namespace. @@ -922,7 +956,7 @@ def no_display_layers(nodes): @contextlib.contextmanager -def namespaced(namespace, new=True): +def namespaced(namespace, new=True, relative_names=None): """Work inside namespace during context Args: @@ -934,15 +968,19 @@ def namespaced(namespace, new=True): """ original = cmds.namespaceInfo(cur=True, absoluteName=True) + original_relative_names = cmds.namespace(query=True, relativeNames=True) if new: namespace = unique_namespace(namespace) cmds.namespace(add=namespace) - + if relative_names is not None: + cmds.namespace(relativeNames=relative_names) try: cmds.namespace(set=namespace) yield namespace finally: cmds.namespace(set=original) + if relative_names is not None: + cmds.namespace(relativeNames=original_relative_names) @contextlib.contextmanager @@ -2571,7 +2609,7 @@ def bake_to_world_space(nodes, new_name = "{0}_baked".format(short_name) new_node = cmds.duplicate(node, name=new_name, - renameChildren=True)[0] + renameChildren=True)[0] # noqa # Connect all attributes on the node except for transform # attributes @@ -2981,194 +3019,6 @@ class shelf(): cmds.shelfLayout(self.name, p="ShelfLayout") -def _get_render_instances(): - """Return all 'render-like' instances. - - This returns list of instance sets that needs to receive information - about render layer changes. - - Returns: - list: list of instances - - """ - objectset = cmds.ls("*.id", long=True, exactType="objectSet", - recursive=True, objectsOnly=True) - - instances = [] - for objset in objectset: - if not cmds.attributeQuery("id", node=objset, exists=True): - continue - - id_attr = "{}.id".format(objset) - if cmds.getAttr(id_attr) != "pyblish.avalon.instance": - continue - - has_family = cmds.attributeQuery("family", - node=objset, - exists=True) - if not has_family: - continue - - if cmds.getAttr( - "{}.family".format(objset)) in RENDERLIKE_INSTANCE_FAMILIES: - instances.append(objset) - - return instances - - -renderItemObserverList = [] - - -class RenderSetupListObserver: - """Observer to catch changes in render setup layers.""" - - def listItemAdded(self, item): - print("--- adding ...") - self._add_render_layer(item) - - def listItemRemoved(self, item): - print("--- removing ...") - self._remove_render_layer(item.name()) - - def _add_render_layer(self, item): - render_sets = _get_render_instances() - layer_name = item.name() - - for render_set in render_sets: - members = cmds.sets(render_set, query=True) or [] - - namespace_name = "_{}".format(render_set) - if not cmds.namespace(exists=namespace_name): - index = 1 - namespace_name = "_{}".format(render_set) - try: - cmds.namespace(rm=namespace_name) - except RuntimeError: - # namespace is not empty, so we leave it untouched - pass - orignal_namespace_name = namespace_name - while(cmds.namespace(exists=namespace_name)): - namespace_name = "{}{}".format( - orignal_namespace_name, index) - index += 1 - - namespace = cmds.namespace(add=namespace_name) - - if members: - # if set already have namespaced members, use the same - # namespace as others. - namespace = members[0].rpartition(":")[0] - else: - namespace = namespace_name - - render_layer_set_name = "{}:{}".format(namespace, layer_name) - if render_layer_set_name in members: - continue - print(" - creating set for {}".format(layer_name)) - maya_set = cmds.sets(n=render_layer_set_name, empty=True) - cmds.sets(maya_set, forceElement=render_set) - rio = RenderSetupItemObserver(item) - print("- adding observer for {}".format(item.name())) - item.addItemObserver(rio.itemChanged) - renderItemObserverList.append(rio) - - def _remove_render_layer(self, layer_name): - render_sets = _get_render_instances() - - for render_set in render_sets: - members = cmds.sets(render_set, query=True) - if not members: - continue - - # all sets under set should have the same namespace - namespace = members[0].rpartition(":")[0] - render_layer_set_name = "{}:{}".format(namespace, layer_name) - - if render_layer_set_name in members: - print(" - removing set for {}".format(layer_name)) - cmds.delete(render_layer_set_name) - - -class RenderSetupItemObserver: - """Handle changes in render setup items.""" - - def __init__(self, item): - self.item = item - self.original_name = item.name() - - def itemChanged(self, *args, **kwargs): - """Item changed callback.""" - if self.item.name() == self.original_name: - return - - render_sets = _get_render_instances() - - for render_set in render_sets: - members = cmds.sets(render_set, query=True) - if not members: - continue - - # all sets under set should have the same namespace - namespace = members[0].rpartition(":")[0] - render_layer_set_name = "{}:{}".format( - namespace, self.original_name) - - if render_layer_set_name in members: - print(" <> renaming {} to {}".format(self.original_name, - self.item.name())) - cmds.rename(render_layer_set_name, - "{}:{}".format( - namespace, self.item.name())) - self.original_name = self.item.name() - - -renderListObserver = RenderSetupListObserver() - - -def add_render_layer_change_observer(): - import maya.app.renderSetup.model.renderSetup as renderSetup - - rs = renderSetup.instance() - render_sets = _get_render_instances() - - layers = rs.getRenderLayers() - for render_set in render_sets: - members = cmds.sets(render_set, query=True) - if not members: - continue - # all sets under set should have the same namespace - namespace = members[0].rpartition(":")[0] - for layer in layers: - render_layer_set_name = "{}:{}".format(namespace, layer.name()) - if render_layer_set_name not in members: - continue - rio = RenderSetupItemObserver(layer) - print("- adding observer for {}".format(layer.name())) - layer.addItemObserver(rio.itemChanged) - renderItemObserverList.append(rio) - - -def add_render_layer_observer(): - import maya.app.renderSetup.model.renderSetup as renderSetup - - print("> adding renderSetup observer ...") - rs = renderSetup.instance() - rs.addListObserver(renderListObserver) - pass - - -def remove_render_layer_observer(): - import maya.app.renderSetup.model.renderSetup as renderSetup - - print("< removing renderSetup observer ...") - rs = renderSetup.instance() - try: - rs.removeListObserver(renderListObserver) - except ValueError: - # no observer set yet - pass - - def update_content_on_context_change(): """ This will update scene content to match new asset on context change @@ -4100,14 +3950,19 @@ def create_rig_animation_instance( """ if options is None: options = {} - + name = context["representation"]["name"] output = next((node for node in nodes if node.endswith("out_SET")), None) controls = next((node for node in nodes if node.endswith("controls_SET")), None) + if name != "fbx": + assert output, "No out_SET in rig, this is a bug." + assert controls, "No controls_SET in rig, this is a bug." - assert output, "No out_SET in rig, this is a bug." - assert controls, "No controls_SET in rig, this is a bug." + anim_skeleton = next((node for node in nodes if + node.endswith("skeletonAnim_SET")), None) + skeleton_mesh = next((node for node in nodes if + node.endswith("skeletonMesh_SET")), None) # Find the roots amongst the loaded nodes roots = ( @@ -4119,9 +3974,7 @@ def create_rig_animation_instance( custom_subset = options.get("animationSubsetName") if custom_subset: formatting_data = { - # TODO remove 'asset_type' and replace 'asset_name' with 'asset' - "asset_name": context['asset']['name'], - "asset_type": context['asset']['type'], + "asset": context["asset"], "subset": context['subset']['name'], "family": ( context['subset']['data'].get('family') or @@ -4142,10 +3995,12 @@ def create_rig_animation_instance( host = registered_host() create_context = CreateContext(host) - # Create the animation instance + rig_sets = [output, controls, anim_skeleton, skeleton_mesh] + # Remove sets that this particular rig does not have + rig_sets = [s for s in rig_sets if s is not None] with maintained_selection(): - cmds.select([output, controls] + roots, noExpand=True) + cmds.select(rig_sets + roots, noExpand=True) create_context.create( creator_identifier=creator_identifier, variant=namespace, diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py index f54633c04d..1f964589a9 100644 --- a/openpype/hosts/maya/api/lib_rendersettings.py +++ b/openpype/hosts/maya/api/lib_rendersettings.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- """Class for handling Render Settings.""" -from maya import cmds # noqa -import maya.mel as mel import six import sys @@ -35,6 +33,14 @@ class RenderSettings(object): def get_image_prefix_attr(cls, renderer): return cls._image_prefix_nodes[renderer] + @staticmethod + def get_padding_attr(renderer): + """Return attribute for renderer that defines frame padding amount""" + if renderer == "vray": + return "vraySettings.fileNamePadding" + else: + return "defaultRenderGlobals.extensionPadding" + def __init__(self, project_settings=None): if not project_settings: project_settings = get_project_settings( @@ -63,6 +69,10 @@ class RenderSettings(object): def set_default_renderer_settings(self, renderer=None): """Set basic settings based on renderer.""" + # Not all hosts can import this module. + from maya import cmds # noqa: F401 + import maya.mel as mel # noqa: F401 + if not renderer: renderer = cmds.getAttr( 'defaultRenderGlobals.currentRenderer').lower() @@ -116,6 +126,10 @@ class RenderSettings(object): """Sets settings for Arnold.""" from mtoa.core import createOptions # noqa from mtoa.aovs import AOVInterface # noqa + # Not all hosts can import this module. + from maya import cmds # noqa: F401 + import maya.mel as mel # noqa: F401 + createOptions() render_settings = self._project_settings["maya"]["RenderSettings"] arnold_render_presets = render_settings["arnold_renderer"] # noqa @@ -162,6 +176,10 @@ class RenderSettings(object): def _set_redshift_settings(self, width, height): """Sets settings for Redshift.""" + # Not all hosts can import this module. + from maya import cmds # noqa: F401 + import maya.mel as mel # noqa: F401 + render_settings = self._project_settings["maya"]["RenderSettings"] redshift_render_presets = render_settings["redshift_renderer"] @@ -177,12 +195,7 @@ class RenderSettings(object): # list all the aovs all_rs_aovs = cmds.ls(type='RedshiftAOV') for rs_aov in redshift_aovs: - rs_layername = rs_aov - if " " in rs_aov: - rs_renderlayer = rs_aov.replace(" ", "") - rs_layername = "rsAov_{}".format(rs_renderlayer) - else: - rs_layername = "rsAov_{}".format(rs_aov) + rs_layername = "rsAov_{}".format(rs_aov.replace(" ", "")) if rs_layername in all_rs_aovs: continue cmds.rsCreateAov(type=rs_aov) @@ -219,6 +232,10 @@ class RenderSettings(object): def _set_renderman_settings(self, width, height, aov_separator): """Sets settings for Renderman""" + # Not all hosts can import this module. + from maya import cmds # noqa: F401 + import maya.mel as mel # noqa: F401 + rman_render_presets = ( self._project_settings ["maya"] @@ -280,6 +297,11 @@ class RenderSettings(object): def _set_vray_settings(self, aov_separator, width, height): # type: (str, int, int) -> None """Sets important settings for Vray.""" + # Not all hosts can import this module. + from maya import cmds # noqa: F401 + import maya.mel as mel # noqa: F401 + + settings = cmds.ls(type="VRaySettingsNode") node = settings[0] if settings else cmds.createNode("VRaySettingsNode") render_settings = self._project_settings["maya"]["RenderSettings"] @@ -317,7 +339,7 @@ class RenderSettings(object): separators = [cmds.menuItem(i, query=True, label=True) for i in items] # noqa: E501 try: sep_idx = separators.index(aov_separator) - except ValueError as e: + except ValueError: six.reraise( CreatorError, CreatorError( @@ -352,6 +374,10 @@ class RenderSettings(object): @staticmethod def _set_global_output_settings(): + # Not all hosts can import this module. + from maya import cmds # noqa: F401 + import maya.mel as mel # noqa: F401 + # enable animation cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) cmds.setAttr("defaultRenderGlobals.animation", 1) @@ -359,6 +385,10 @@ class RenderSettings(object): cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) def _additional_attribs_setter(self, additional_attribs): + # Not all hosts can import this module. + from maya import cmds # noqa: F401 + import maya.mel as mel # noqa: F401 + for item in additional_attribs: attribute, value = item attribute = str(attribute) # ensure str conversion from settings diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 715f54686c..18a4ea0e9a 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -1,14 +1,13 @@ import os import logging +from functools import partial from qtpy import QtWidgets, QtGui import maya.utils import maya.cmds as cmds -from openpype.settings import get_project_settings from openpype.pipeline import ( - get_current_project_name, get_current_asset_name, get_current_task_name ) @@ -46,12 +45,12 @@ def get_context_label(): ) -def install(): +def install(project_settings): if cmds.about(batch=True): log.info("Skipping openpype.menu initialization in batch mode..") return - def deferred(): + def add_menu(): pyblish_icon = host_tools.get_pyblish_icon() parent_widget = get_main_window() cmds.menu( @@ -191,7 +190,7 @@ def install(): cmds.setParent(MENU_NAME, menu=True) - def add_scripts_menu(): + def add_scripts_menu(project_settings): try: import scriptsmenu.launchformaya as launchformaya except ImportError: @@ -201,9 +200,6 @@ def install(): ) return - # load configuration of custom menu - project_name = get_current_project_name() - project_settings = get_project_settings(project_name) config = project_settings["maya"]["scriptsmenu"]["definition"] _menu = project_settings["maya"]["scriptsmenu"]["name"] @@ -225,8 +221,9 @@ def install(): # so that it only gets called after Maya UI has initialized too. # This is crucial with Maya 2020+ which initializes without UI # first as a QCoreApplication - maya.utils.executeDeferred(deferred) - cmds.evalDeferred(add_scripts_menu, lowestPriority=True) + maya.utils.executeDeferred(add_menu) + cmds.evalDeferred(partial(add_scripts_menu, project_settings), + lowestPriority=True) def uninstall(): diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 60495ac652..1ecfdfaa40 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -28,8 +28,6 @@ from openpype.lib import ( from openpype.pipeline import ( legacy_io, get_current_project_name, - get_current_asset_name, - get_current_task_name, register_loader_plugin_path, register_inventory_action_path, register_creator_plugin_path, @@ -97,6 +95,8 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): self.log.info("Installing callbacks ... ") register_event_callback("init", on_init) + _set_project() + if lib.IS_HEADLESS: self.log.info(( "Running in headless mode, skipping Maya save/open/new" @@ -105,10 +105,9 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): return - _set_project() self._register_callbacks() - menu.install() + menu.install(project_settings) register_event_callback("save", on_save) register_event_callback("open", on_open) @@ -581,20 +580,11 @@ def on_save(): lib.set_id(node, new_id, overwrite=False) -def _update_render_layer_observers(): - # Helper to trigger update for all renderlayer observer logic - lib.remove_render_layer_observer() - lib.add_render_layer_observer() - lib.add_render_layer_change_observer() - - def on_open(): """On scene open let's assume the containers have changed.""" from openpype.widgets import popup - utils.executeDeferred(_update_render_layer_observers) - # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset lib.validate_fps() @@ -631,7 +621,6 @@ def on_new(): with lib.suspended_refresh(): lib.set_context_settings() - utils.executeDeferred(_update_render_layer_observers) _remove_workfile_lock() @@ -659,17 +648,6 @@ def on_task_changed(): lib.set_context_settings() lib.update_content_on_context_change() - msg = " project: {}\n asset: {}\n task:{}".format( - get_current_project_name(), - get_current_asset_name(), - get_current_task_name() - ) - - lib.show_message( - "Context was changed", - ("Context was changed to:\n{}".format(msg)), - ) - def before_workfile_open(): if handle_workfile_locks(): diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index f705133e4f..e684a91fe2 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -7,6 +7,7 @@ import six from maya import cmds from maya.app.renderSetup.model import renderSetup +from openpype import AYON_SERVER_ENABLED from openpype.lib import BoolDef, Logger from openpype.settings import get_project_settings from openpype.pipeline import ( @@ -22,10 +23,10 @@ from openpype.pipeline import ( LegacyCreator, LoaderPlugin, get_representation_path, - - legacy_io, ) from openpype.pipeline.load import LoadError +from openpype.client import get_asset_by_name +from openpype.pipeline.create import get_subset_name from . import lib from .lib import imprint, read @@ -129,18 +130,50 @@ class MayaCreatorBase(object): shared_data["maya_cached_legacy_subsets"] = cache_legacy return shared_data + def get_publish_families(self): + """Return families for the instances of this creator. + + Allow a Creator to define multiple families so that a creator can + e.g. specify `usd` and `usdMaya` and another USD creator can also + specify `usd` but apply different extractors like `usdMultiverse`. + + There is no need to override this method if you only have the + primary family defined by the `family` property as that will always + be set. + + Returns: + list: families for instances of this creator + + """ + return [] + def imprint_instance_node(self, node, data): # We never store the instance_node as value on the node since # it's the node name itself data.pop("instance_node", None) + data.pop("instance_id", None) + + # Don't store `families` since it's up to the creator itself + # to define the initial publish families - not a stored attribute of + # `families` + data.pop("families", None) # We store creator attributes at the root level and assume they # will not clash in names with `subset`, `task`, etc. and other # default names. This is just so these attributes in many cases # are still editable in the maya UI by artists. - # pop to move to end of dict to sort attributes last on the node + # note: pop to move to end of dict to sort attributes last on the node creator_attributes = data.pop("creator_attributes", {}) + + # We only flatten value types which `imprint` function supports + json_creator_attributes = {} + for key, value in dict(creator_attributes).items(): + if isinstance(value, (list, tuple, dict)): + creator_attributes.pop(key) + json_creator_attributes[key] = value + + # Flatten remaining creator attributes to the node itself data.update(creator_attributes) # We know the "publish_attributes" will be complex data of @@ -150,6 +183,10 @@ class MayaCreatorBase(object): data.pop("publish_attributes", {}) ) + # Persist the non-flattened creator attributes (special value types, + # like multiselection EnumDef) + data["creator_attributes"] = json.dumps(json_creator_attributes) + # Since we flattened the data structure for creator attributes we want # to correctly detect which flattened attributes should end back in the # creator attributes when reading the data from the node, so we store @@ -170,21 +207,34 @@ class MayaCreatorBase(object): # being read as 'data' node_data.pop("cbId", None) + # Make sure we convert any creator attributes from the json string + creator_attributes = node_data.get("creator_attributes") + if creator_attributes: + node_data["creator_attributes"] = json.loads(creator_attributes) + else: + node_data["creator_attributes"] = {} + # Move the relevant attributes into "creator_attributes" that # we flattened originally - node_data["creator_attributes"] = {} creator_attribute_keys = node_data.pop("__creator_attributes_keys", "").split(",") for key in creator_attribute_keys: if key in node_data: node_data["creator_attributes"][key] = node_data.pop(key) + # Make sure we convert any publish attributes from the json string publish_attributes = node_data.get("publish_attributes") if publish_attributes: node_data["publish_attributes"] = json.loads(publish_attributes) # Explicitly re-parse the node name node_data["instance_node"] = node + node_data["instance_id"] = node + + # If the creator plug-in specifies + families = self.get_publish_families() + if families: + node_data["families"] = families return node_data @@ -222,7 +272,7 @@ class MayaCreatorBase(object): @six.add_metaclass(ABCMeta) class MayaCreator(NewCreator, MayaCreatorBase): - settings_name = None + settings_category = "maya" def create(self, subset_name, instance_data, pre_create_data): @@ -230,6 +280,14 @@ class MayaCreator(NewCreator, MayaCreatorBase): if pre_create_data.get("use_selection"): members = cmds.ls(selection=True) + # Allow a Creator to define multiple families + publish_families = self.get_publish_families() + if publish_families: + families = instance_data.setdefault("families", []) + for family in self.get_publish_families(): + if family not in families: + families.append(family) + with lib.undo_chunk(): instance_node = cmds.sets(members, name=subset_name) instance_data["instance_node"] = instance_node @@ -260,24 +318,6 @@ class MayaCreator(NewCreator, MayaCreatorBase): default=True) ] - def apply_settings(self, project_settings, system_settings): - """Method called on initialization of plugin to apply settings.""" - - settings_name = self.settings_name - if settings_name is None: - settings_name = self.__class__.__name__ - - settings = project_settings["maya"]["create"] - settings = settings.get(settings_name) - if settings is None: - self.log.debug( - "No settings found for {}".format(self.__class__.__name__) - ) - return - - for key, value in settings.items(): - setattr(self, key, value) - class MayaAutoCreator(AutoCreator, MayaCreatorBase): """Automatically triggered creator for Maya. @@ -286,6 +326,8 @@ class MayaAutoCreator(AutoCreator, MayaCreatorBase): any arguments. """ + settings_category = "maya" + def collect_instances(self): return self._default_collect_instances() @@ -303,6 +345,8 @@ class MayaHiddenCreator(HiddenCreator, MayaCreatorBase): arguments for 'create' method. """ + settings_category = "maya" + def create(self, *args, **kwargs): return MayaCreator.create(self, *args, **kwargs) @@ -405,14 +449,23 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): # No existing scene instance node for this layer. Note that # this instance will not have the `instance_node` data yet # until it's been saved/persisted at least once. - # TODO: Correctly define the subset name using templates - prefix = self.layer_instance_prefix or self.family - subset_name = "{}{}".format(prefix, layer.name()) + project_name = self.create_context.get_current_project_name() + asset_name = self.create_context.get_current_asset_name() instance_data = { - "asset": legacy_io.Session["AVALON_ASSET"], - "task": legacy_io.Session["AVALON_TASK"], + "task": self.create_context.get_current_task_name(), "variant": layer.name(), } + if AYON_SERVER_ENABLED: + instance_data["folderPath"] = asset_name + else: + instance_data["asset"] = asset_name + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + layer.name(), + instance_data["task"], + asset_doc, + project_name) + instance = CreatedInstance( family=self.family, subset_name=subset_name, @@ -519,10 +572,33 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): if node and cmds.objExists(node): cmds.delete(node) + def get_subset_name( + self, + variant, + task_name, + asset_doc, + project_name, + host_name=None, + instance=None + ): + # creator.family != 'render' as expected + return get_subset_name(self.layer_instance_prefix, + variant, + task_name, + asset_doc, + project_name) + class Loader(LoaderPlugin): hosts = ["maya"] + load_settings = {} # defined in settings + + @classmethod + def apply_settings(cls, project_settings, system_settings): + super(Loader, cls).apply_settings(project_settings, system_settings) + cls.load_settings = project_settings['maya']['load'] + def get_custom_namespace_and_group(self, context, options, loader_key): """Queries Settings to get custom template for namespace and group. @@ -535,12 +611,9 @@ class Loader(LoaderPlugin): loader_key (str): key to get separate configuration from Settings ('reference_loader'|'import_loader') """ - options["attach_to_root"] = True - asset = context['asset'] - subset = context['subset'] - settings = get_project_settings(context['project']['name']) - custom_naming = settings['maya']['load'][loader_key] + options["attach_to_root"] = True + custom_naming = self.load_settings[loader_key] if not custom_naming['namespace']: raise LoadError("No namespace specified in " @@ -549,6 +622,8 @@ class Loader(LoaderPlugin): self.log.debug("No custom group_name, no group will be created.") options["attach_to_root"] = False + asset = context['asset'] + subset = context['subset'] formatting_data = { "asset_name": asset['name'], "asset_type": asset['type'], @@ -660,7 +735,6 @@ class ReferenceLoader(Loader): loaded_containers.append(container) self._organize_containers(nodes, container) c += 1 - namespace = None return loaded_containers @@ -686,7 +760,8 @@ class ReferenceLoader(Loader): "ma": "mayaAscii", "mb": "mayaBinary", "abc": "Alembic", - "fbx": "FBX" + "fbx": "FBX", + "usd": "USD Import" }.get(representation["name"]) assert file_type, "Unsupported representation: %s" % representation diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py index 865f497710..d1ba3cc306 100644 --- a/openpype/hosts/maya/api/workfile_template_builder.py +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -244,8 +244,14 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): return self.get_load_plugin_options(options) def post_placeholder_process(self, placeholder, failed): - """Hide placeholder, add them to placeholder set + """Cleanup placeholder after load of its corresponding representations. + + Args: + placeholder (PlaceholderItem): Item which was just used to load + representation. + failed (bool): Loading of representation failed. """ + # Hide placeholder and add them to placeholder set node = placeholder.scene_identifier cmds.sets(node, addElement=PLACEHOLDER_SET) diff --git a/openpype/hosts/maya/hooks/pre_copy_mel.py b/openpype/hosts/maya/hooks/pre_copy_mel.py index 0fb5af149a..6cd2c69e20 100644 --- a/openpype/hosts/maya/hooks/pre_copy_mel.py +++ b/openpype/hosts/maya/hooks/pre_copy_mel.py @@ -7,7 +7,7 @@ class PreCopyMel(PreLaunchHook): Hook `GlobalHostDataHook` must be executed before this hook. """ - app_groups = {"maya"} + app_groups = {"maya", "mayapy"} launch_types = {LaunchTypes.local} def execute(self): diff --git a/openpype/hosts/maya/plugins/create/convert_legacy.py b/openpype/hosts/maya/plugins/create/convert_legacy.py index 33a1e020dd..cd8faf291b 100644 --- a/openpype/hosts/maya/plugins/create/convert_legacy.py +++ b/openpype/hosts/maya/plugins/create/convert_legacy.py @@ -2,6 +2,8 @@ from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin from openpype.hosts.maya.api import plugin from openpype.hosts.maya.api.lib import read +from openpype.client import get_asset_by_name + from maya import cmds from maya.app.renderSetup.model import renderSetup @@ -135,6 +137,18 @@ class MayaLegacyConvertor(SubsetConvertorPlugin, # "rendering" family being converted to "renderlayer" family) original_data["family"] = creator.family + # recreate subset name as without it would be + # `renderingMain` vs correct `renderMain` + project_name = self.create_context.get_current_project_name() + asset_doc = get_asset_by_name(project_name, + original_data["asset"]) + subset_name = creator.get_subset_name( + original_data["variant"], + data["task"], + asset_doc, + project_name) + original_data["subset"] = subset_name + # Convert to creator attributes when relevant creator_attributes = {} for key in list(original_data.keys()): diff --git a/openpype/hosts/maya/plugins/create/create_animation.py b/openpype/hosts/maya/plugins/create/create_animation.py index 214ac18aef..115c73c0d3 100644 --- a/openpype/hosts/maya/plugins/create/create_animation.py +++ b/openpype/hosts/maya/plugins/create/create_animation.py @@ -81,10 +81,8 @@ class CreateAnimation(plugin.MayaHiddenCreator): return defs - def apply_settings(self, project_settings, system_settings): - super(CreateAnimation, self).apply_settings( - project_settings, system_settings - ) + def apply_settings(self, project_settings): + super(CreateAnimation, self).apply_settings(project_settings) # Hardcoding creator to be enabled due to existing settings would # disable the creator causing the creator plugin to not be # discoverable. diff --git a/openpype/hosts/maya/plugins/create/create_look.py b/openpype/hosts/maya/plugins/create/create_look.py index 385ae81e01..11a69151fd 100644 --- a/openpype/hosts/maya/plugins/create/create_look.py +++ b/openpype/hosts/maya/plugins/create/create_look.py @@ -37,13 +37,7 @@ class CreateLook(plugin.MayaCreator): label="Convert textures to .rstex", tooltip="Whether to generate Redshift .rstex files for " "your textures", - default=self.rs_tex), - BoolDef("forceCopy", - label="Force Copy", - tooltip="Enable users to force a copy instead of hardlink." - "\nNote: On Windows copy is always forced due to " - "bugs in windows' implementation of hardlinks.", - default=False) + default=self.rs_tex) ] def get_pre_create_attr_defs(self): diff --git a/openpype/hosts/maya/plugins/create/create_matchmove.py b/openpype/hosts/maya/plugins/create/create_matchmove.py new file mode 100644 index 0000000000..e64eb6a471 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_matchmove.py @@ -0,0 +1,32 @@ +from openpype.hosts.maya.api import ( + lib, + plugin +) +from openpype.lib import BoolDef + + +class CreateMatchmove(plugin.MayaCreator): + """Instance for more complex setup of cameras. + + Might contain multiple cameras, geometries etc. + + It is expected to be extracted into .abc or .ma + """ + + identifier = "io.openpype.creators.maya.matchmove" + label = "Matchmove" + family = "matchmove" + icon = "video-camera" + + def get_instance_attr_defs(self): + + defs = lib.collect_animation_defs() + + defs.extend([ + BoolDef("bakeToWorldSpace", + label="Bake Cameras to World-Space", + tooltip="Bake Cameras to World-Space", + default=True), + ]) + + return defs diff --git a/openpype/hosts/maya/plugins/create/create_maya_usd.py b/openpype/hosts/maya/plugins/create/create_maya_usd.py new file mode 100644 index 0000000000..cc9a14bd3a --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_maya_usd.py @@ -0,0 +1,102 @@ +from openpype.hosts.maya.api import plugin, lib +from openpype.lib import ( + BoolDef, + EnumDef, + TextDef +) + +from maya import cmds + + +class CreateMayaUsd(plugin.MayaCreator): + """Create Maya USD Export""" + + identifier = "io.openpype.creators.maya.mayausd" + label = "Maya USD" + family = "usd" + icon = "cubes" + description = "Create Maya USD Export" + + cache = {} + + def get_publish_families(self): + return ["usd", "mayaUsd"] + + def get_instance_attr_defs(self): + + if "jobContextItems" not in self.cache: + # Query once instead of per instance + job_context_items = {} + try: + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + job_context_items = { + cmds.mayaUSDListJobContexts(jobContext=name): name + for name in cmds.mayaUSDListJobContexts(export=True) or [] + } + except RuntimeError: + # Likely `mayaUsdPlugin` plug-in not available + self.log.warning("Unable to retrieve available job " + "contexts for `mayaUsdPlugin` exports") + + if not job_context_items: + # enumdef multiselection may not be empty + job_context_items = [""] + + self.cache["jobContextItems"] = job_context_items + + defs = lib.collect_animation_defs() + defs.extend([ + EnumDef("defaultUSDFormat", + label="File format", + items={ + "usdc": "Binary", + "usda": "ASCII" + }, + default="usdc"), + BoolDef("stripNamespaces", + label="Strip Namespaces", + tooltip=( + "Remove namespaces during export. By default, " + "namespaces are exported to the USD file in the " + "following format: nameSpaceExample_pPlatonic1" + ), + default=True), + BoolDef("mergeTransformAndShape", + label="Merge Transform and Shape", + tooltip=( + "Combine Maya transform and shape into a single USD" + "prim that has transform and geometry, for all" + " \"geometric primitives\" (gprims).\n" + "This results in smaller and faster scenes. Gprims " + "will be \"unpacked\" back into transform and shape " + "nodes when imported into Maya from USD." + ), + default=True), + BoolDef("includeUserDefinedAttributes", + label="Include User Defined Attributes", + tooltip=( + "Whether to include all custom maya attributes found " + "on nodes as metadata (userProperties) in USD." + ), + default=False), + TextDef("attr", + label="Custom Attributes", + default="", + placeholder="attr1, attr2"), + TextDef("attrPrefix", + label="Custom Attributes Prefix", + default="", + placeholder="prefix1, prefix2"), + EnumDef("jobContext", + label="Job Context", + items=self.cache["jobContextItems"], + tooltip=( + "Specifies an additional export context to handle.\n" + "These usually contain extra schemas, primitives,\n" + "and materials that are to be exported for a " + "specific\ntask, a target renderer for example." + ), + multiselection=True), + ]) + + return defs diff --git a/openpype/hosts/maya/plugins/create/create_multishot_layout.py b/openpype/hosts/maya/plugins/create/create_multishot_layout.py new file mode 100644 index 0000000000..8f5c423202 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_multishot_layout.py @@ -0,0 +1,215 @@ +from ayon_api import ( + get_folder_by_name, + get_folder_by_path, + get_folders, +) +from maya import cmds # noqa: F401 + +from openpype import AYON_SERVER_ENABLED +from openpype.client import get_assets +from openpype.hosts.maya.api import plugin +from openpype.lib import BoolDef, EnumDef, TextDef +from openpype.pipeline import ( + Creator, + get_current_asset_name, + get_current_project_name, +) +from openpype.pipeline.create import CreatorError + + +class CreateMultishotLayout(plugin.MayaCreator): + """Create a multi-shot layout in the Maya scene. + + This creator will create a Camera Sequencer in the Maya scene based on + the shots found under the specified folder. The shots will be added to + the sequencer in the order of their clipIn and clipOut values. For each + shot a Layout will be created. + + """ + identifier = "io.openpype.creators.maya.multishotlayout" + label = "Multi-shot Layout" + family = "layout" + icon = "project-diagram" + + def get_pre_create_attr_defs(self): + # Present artist with a list of parents of the current context + # to choose from. This will be used to get the shots under the + # selected folder to create the Camera Sequencer. + + """ + Todo: `get_folder_by_name` should be switched to `get_folder_by_path` + once the fork to pure AYON is done. + + Warning: this will not work for projects where the asset name + is not unique across the project until the switch mentioned + above is done. + """ + + project_name = get_current_project_name() + folder_path = get_current_asset_name() + if "/" in folder_path: + current_folder = get_folder_by_path(project_name, folder_path) + else: + current_folder = get_folder_by_name( + project_name, folder_name=folder_path + ) + + current_path_parts = current_folder["path"].split("/") + + # populate the list with parents of the current folder + # this will create menu items like: + # [ + # { + # "value": "", + # "label": "project (shots directly under the project)" + # }, { + # "value": "shots/shot_01", "label": "shot_01 (current)" + # }, { + # "value": "shots", "label": "shots" + # } + # ] + + # add the project as the first item + items_with_label = [ + { + "label": f"{self.project_name} " + "(shots directly under the project)", + "value": "" + } + ] + + # go through the current folder path and add each part to the list, + # but mark the current folder. + for part_idx, part in enumerate(current_path_parts): + label = part + if label == current_folder["name"]: + label = f"{label} (current)" + + value = "/".join(current_path_parts[:part_idx + 1]) + + items_with_label.append({"label": label, "value": value}) + + return [ + EnumDef("shotParent", + default=current_folder["name"], + label="Shot Parent Folder", + items=items_with_label, + ), + BoolDef("groupLoadedAssets", + label="Group Loaded Assets", + tooltip="Enable this when you want to publish group of " + "loaded asset", + default=False), + TextDef("taskName", + label="Associated Task Name", + tooltip=("Task name to be associated " + "with the created Layout"), + default="layout"), + ] + + def create(self, subset_name, instance_data, pre_create_data): + shots = list( + self.get_related_shots(folder_path=pre_create_data["shotParent"]) + ) + if not shots: + # There are no shot folders under the specified folder. + # We are raising an error here but in the future we might + # want to create a new shot folders by publishing the layouts + # and shot defined in the sequencer. Sort of editorial publish + # in side of Maya. + raise CreatorError(( + "No shots found under the specified " + f"folder: {pre_create_data['shotParent']}.")) + + # Get layout creator + layout_creator_id = "io.openpype.creators.maya.layout" + layout_creator: Creator = self.create_context.creators.get( + layout_creator_id) + if not layout_creator: + raise CreatorError( + f"Creator {layout_creator_id} not found.") + + # Get OpenPype style asset documents for the shots + op_asset_docs = get_assets( + self.project_name, [s["id"] for s in shots]) + asset_docs_by_id = {doc["_id"]: doc for doc in op_asset_docs} + for shot in shots: + # we are setting shot name to be displayed in the sequencer to + # `shot name (shot label)` if the label is set, otherwise just + # `shot name`. So far, labels are used only when the name is set + # with characters that are not allowed in the shot name. + if not shot["active"]: + continue + + # get task for shot + asset_doc = asset_docs_by_id[shot["id"]] + + tasks = asset_doc.get("data").get("tasks").keys() + layout_task = None + if pre_create_data["taskName"] in tasks: + layout_task = pre_create_data["taskName"] + + shot_name = f"{shot['name']}%s" % ( + f" ({shot['label']})" if shot["label"] else "") + cmds.shot(sequenceStartTime=shot["attrib"]["clipIn"], + sequenceEndTime=shot["attrib"]["clipOut"], + shotName=shot_name) + + # Create layout instance by the layout creator + + instance_data = { + "folderPath": shot["path"], + "variant": layout_creator.get_default_variant() + } + if layout_task: + instance_data["task"] = layout_task + + layout_creator.create( + subset_name=layout_creator.get_subset_name( + layout_creator.get_default_variant(), + self.create_context.get_current_task_name(), + asset_doc, + self.project_name), + instance_data=instance_data, + pre_create_data={ + "groupLoadedAssets": pre_create_data["groupLoadedAssets"] + } + ) + + def get_related_shots(self, folder_path: str): + """Get all shots related to the current asset. + + Get all folders of type Shot under specified folder. + + Args: + folder_path (str): Path of the folder. + + Returns: + list: List of dicts with folder data. + + """ + # if folder_path is None, project is selected as a root + # and its name is used as a parent id + parent_id = self.project_name + if folder_path: + current_folder = get_folder_by_path( + project_name=self.project_name, + folder_path=folder_path, + ) + parent_id = current_folder["id"] + + # get all child folders of the current one + return get_folders( + project_name=self.project_name, + parent_ids=[parent_id], + fields=[ + "attrib.clipIn", "attrib.clipOut", + "attrib.frameStart", "attrib.frameEnd", + "name", "label", "path", "folderType", "id" + ] + ) + + +# blast this creator if Ayon server is not enabled +if not AYON_SERVER_ENABLED: + del CreateMultishotLayout diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py index 0b0ad3bccb..2963d4d5b6 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py @@ -14,6 +14,10 @@ class CreateMultiverseUsd(plugin.MayaCreator): label = "Multiverse USD Asset" family = "usd" icon = "cubes" + description = "Create Multiverse USD Asset" + + def get_publish_families(self): + return ["usd", "mvUsd"] def get_instance_attr_defs(self): diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py index e1534dd68c..166dbf6515 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py @@ -6,7 +6,7 @@ from openpype.lib import ( ) -class CreateMultiverseUsdOver(plugin.Creator): +class CreateMultiverseUsdOver(plugin.MayaCreator): """Create Multiverse USD Override""" identifier = "io.openpype.creators.maya.mvusdoverride" diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index cc5c1eb205..6266689af4 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -34,7 +34,7 @@ class CreateRenderlayer(plugin.RenderlayerCreator): render_settings = {} @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): cls.render_settings = project_settings["maya"]["RenderSettings"] def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/maya/plugins/create/create_review.py b/openpype/hosts/maya/plugins/create/create_review.py index f60e2406bc..18d661b186 100644 --- a/openpype/hosts/maya/plugins/create/create_review.py +++ b/openpype/hosts/maya/plugins/create/create_review.py @@ -2,6 +2,7 @@ import json from maya import cmds +from openpype import AYON_SERVER_ENABLED from openpype.hosts.maya.api import ( lib, plugin @@ -43,7 +44,11 @@ class CreateReview(plugin.MayaCreator): members = cmds.ls(selection=True) project_name = self.project_name - asset_doc = get_asset_by_name(project_name, instance_data["asset"]) + if AYON_SERVER_ENABLED: + asset_name = instance_data["folderPath"] + else: + asset_name = instance_data["asset"] + asset_doc = get_asset_by_name(project_name, asset_name) task_name = instance_data["task"] preset = lib.get_capture_preset( task_name, diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 345ab6c00d..acd5c98f89 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -20,6 +20,13 @@ class CreateRig(plugin.MayaCreator): instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") + # TODO:change name (_controls_SET -> _rigs_SET) controls = cmds.sets(name=subset_name + "_controls_SET", empty=True) + # TODO:change name (_out_SET -> _geo_SET) pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) - cmds.sets([controls, pointcache], forceElement=instance_node) + skeleton = cmds.sets( + name=subset_name + "_skeletonAnim_SET", empty=True) + skeleton_mesh = cmds.sets( + name=subset_name + "_skeletonMesh_SET", empty=True) + cmds.sets([controls, pointcache, + skeleton, skeleton_mesh], forceElement=instance_node) diff --git a/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py index 4e2a99eced..b4151bac99 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py @@ -21,7 +21,7 @@ class CreateUnrealSkeletalMesh(plugin.MayaCreator): # Defined in settings joint_hints = set() - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Apply project settings to creator""" settings = ( project_settings["maya"]["create"]["CreateUnrealSkeletalMesh"] @@ -51,7 +51,7 @@ class CreateUnrealSkeletalMesh(plugin.MayaCreator): # We reorganize the geometry that was originally added into the # set into either 'joints_SET' or 'geometry_SET' based on the # joint_hints from project settings - members = cmds.sets(instance_node, query=True) + members = cmds.sets(instance_node, query=True) or [] cmds.sets(clear=instance_node) geometry_set = cmds.sets(name="geometry_SET", empty=True) diff --git a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py index 3f96d91a54..025b39fa55 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py @@ -16,7 +16,7 @@ class CreateUnrealStaticMesh(plugin.MayaCreator): # Defined in settings collision_prefixes = [] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Apply project settings to creator""" settings = project_settings["maya"]["create"]["CreateUnrealStaticMesh"] self.collision_prefixes = settings["collision_prefixes"] diff --git a/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py new file mode 100644 index 0000000000..c9f9cd9ba8 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_unreal_yeticache.py @@ -0,0 +1,39 @@ +from openpype.hosts.maya.api import ( + lib, + plugin +) +from openpype.lib import NumberDef + + +class CreateYetiCache(plugin.MayaCreator): + """Output for procedural plugin nodes of Yeti """ + + identifier = "io.openpype.creators.maya.unrealyeticache" + label = "Unreal - Yeti Cache" + family = "yeticacheUE" + icon = "pagelines" + + def get_instance_attr_defs(self): + + defs = [ + NumberDef("preroll", + label="Preroll", + minimum=0, + default=0, + decimals=0) + ] + + # Add animation data without step and handles + defs.extend(lib.collect_animation_defs()) + remove = {"step", "handleStart", "handleEnd"} + defs = [attr_def for attr_def in defs if attr_def.key not in remove] + + # Add samples after frame range + defs.append( + NumberDef("samples", + label="Samples", + default=3, + decimals=0) + ) + + return defs diff --git a/openpype/hosts/maya/plugins/create/create_vrayscene.py b/openpype/hosts/maya/plugins/create/create_vrayscene.py index d601dceb54..2726979d30 100644 --- a/openpype/hosts/maya/plugins/create/create_vrayscene.py +++ b/openpype/hosts/maya/plugins/create/create_vrayscene.py @@ -22,7 +22,7 @@ class CreateVRayScene(plugin.RenderlayerCreator): singleton_node_name = "vraysceneMain" @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): cls.render_settings = project_settings["maya"]["RenderSettings"] def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/maya/plugins/create/create_workfile.py b/openpype/hosts/maya/plugins/create/create_workfile.py index d84753cd7f..198f9c4a36 100644 --- a/openpype/hosts/maya/plugins/create/create_workfile.py +++ b/openpype/hosts/maya/plugins/create/create_workfile.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import CreatedInstance, AutoCreator -from openpype.client import get_asset_by_name +from openpype.client import get_asset_by_name, get_asset_name_identifier from openpype.hosts.maya.api import plugin from maya import cmds @@ -29,16 +30,27 @@ class CreateWorkfile(plugin.MayaCreatorBase, AutoCreator): task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name + if current_instance is None: + current_instance_asset = None + elif AYON_SERVER_ENABLED: + current_instance_asset = current_instance["folderPath"] + else: + current_instance_asset = current_instance["asset"] + if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name + data.update( self.get_dynamic_data( variant, task_name, asset_doc, @@ -50,15 +62,20 @@ class CreateWorkfile(plugin.MayaCreatorBase, AutoCreator): ) self._add_instance_to_context(current_instance) elif ( - current_instance["asset"] != asset_name - or current_instance["task"] != task_name + current_instance_asset != asset_name + or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) - current_instance["asset"] = asset_name + asset_name = get_asset_name_identifier(asset_doc) + + if AYON_SERVER_ENABLED: + current_instance["folderPath"] = asset_name + else: + current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name diff --git a/openpype/hosts/maya/plugins/create/create_yeti_cache.py b/openpype/hosts/maya/plugins/create/create_yeti_cache.py index 395aa62325..ca002392d4 100644 --- a/openpype/hosts/maya/plugins/create/create_yeti_cache.py +++ b/openpype/hosts/maya/plugins/create/create_yeti_cache.py @@ -13,8 +13,7 @@ class CreateYetiCache(plugin.MayaCreator): family = "yeticache" icon = "pagelines" - def __init__(self, *args, **kwargs): - super(CreateYetiCache, self).__init__(*args, **kwargs) + def get_instance_attr_defs(self): defs = [ NumberDef("preroll", @@ -36,3 +35,5 @@ class CreateYetiCache(plugin.MayaCreator): default=3, decimals=0) ) + + return defs diff --git a/openpype/hosts/maya/plugins/inventory/import_modelrender.py b/openpype/hosts/maya/plugins/inventory/import_modelrender.py index 4db8c4f2f6..3b30695146 100644 --- a/openpype/hosts/maya/plugins/inventory/import_modelrender.py +++ b/openpype/hosts/maya/plugins/inventory/import_modelrender.py @@ -33,7 +33,7 @@ class ImportModelRender(InventoryAction): ) def process(self, containers): - from maya import cmds + from maya import cmds # noqa: F401 project_name = get_current_project_name() for container in containers: @@ -66,7 +66,7 @@ class ImportModelRender(InventoryAction): None """ - from maya import cmds + from maya import cmds # noqa: F401 project_name = get_current_project_name() repre_docs = get_representations( @@ -85,12 +85,7 @@ class ImportModelRender(InventoryAction): if scene_type_regex.fullmatch(repre_name): look_repres.append(repre_doc) - # QUESTION should we care if there is more then one look - # representation? (since it's based on regex match) - look_repre = None - if look_repres: - look_repre = look_repres[0] - + look_repre = look_repres[0] if look_repres else None # QUESTION shouldn't be json representation validated too? if not look_repre: print("No model render sets for this model version..") diff --git a/openpype/hosts/maya/plugins/inventory/import_reference.py b/openpype/hosts/maya/plugins/inventory/import_reference.py index ecc424209d..3f3b85ba6c 100644 --- a/openpype/hosts/maya/plugins/inventory/import_reference.py +++ b/openpype/hosts/maya/plugins/inventory/import_reference.py @@ -12,7 +12,6 @@ class ImportReference(InventoryAction): color = "#d8d8d8" def process(self, containers): - references = cmds.ls(type="reference") for container in containers: if container["loader"] != "ReferenceLoader": print("Not a reference, skipping") diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index 981b9ef434..0781735bc4 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -1,4 +1,46 @@ import openpype.hosts.maya.api.plugin +import maya.cmds as cmds + + +def _process_reference(file_url, name, namespace, options): + """Load files by referencing scene in Maya. + + Args: + file_url (str): fileapth of the objects to be loaded + name (str): subset name + namespace (str): namespace + options (dict): dict of storing the param + + Returns: + list: list of object nodes + """ + from openpype.hosts.maya.api.lib import unique_namespace + # Get name from asset being loaded + # Assuming name is subset name from the animation, we split the number + # suffix from the name to ensure the namespace is unique + name = name.split("_")[0] + ext = file_url.split(".")[-1] + namespace = unique_namespace( + "{}_".format(name), + format="%03d", + suffix="_{}".format(ext) + ) + + attach_to_root = options.get("attach_to_root", True) + group_name = options["group_name"] + + # no group shall be created + if not attach_to_root: + group_name = namespace + + nodes = cmds.file(file_url, + namespace=namespace, + sharedReferenceFile=False, + groupReference=attach_to_root, + groupName=group_name, + reference=True, + returnNewNodes=True) + return nodes class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): @@ -16,44 +58,42 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference(self, context, name, namespace, options): - import maya.cmds as cmds - from openpype.hosts.maya.api.lib import unique_namespace - cmds.loadPlugin("AbcImport.mll", quiet=True) - # Prevent identical alembic nodes from being shared - # Create unique namespace for the cameras - - # Get name from asset being loaded - # Assuming name is subset name from the animation, we split the number - # suffix from the name to ensure the namespace is unique - name = name.split("_")[0] - namespace = unique_namespace( - "{}_".format(name), - format="%03d", - suffix="_abc" - ) - - attach_to_root = options.get("attach_to_root", True) - group_name = options["group_name"] - - # no group shall be created - if not attach_to_root: - group_name = namespace - # hero_001 (abc) # asset_counter{optional} path = self.filepath_from_context(context) file_url = self.prepare_root_value(path, context["project"]["name"]) - nodes = cmds.file(file_url, - namespace=namespace, - sharedReferenceFile=False, - groupReference=attach_to_root, - groupName=group_name, - reference=True, - returnNewNodes=True) + nodes = _process_reference(file_url, name, namespace, options) # load colorbleed ID attribute self[:] = nodes return nodes + + +class FbxLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): + """Loader to reference an Fbx files""" + + families = ["animation", + "camera"] + representations = ["fbx"] + + label = "Reference animation" + order = -10 + icon = "code-fork" + color = "orange" + + def process_reference(self, context, name, namespace, options): + + cmds.loadPlugin("fbx4maya.mll", quiet=True) + + path = self.filepath_from_context(context) + file_url = self.prepare_root_value(path, + context["project"]["name"]) + + nodes = _process_reference(file_url, name, namespace, options) + + self[:] = nodes + + return nodes diff --git a/openpype/hosts/maya/plugins/load/load_arnold_standin.py b/openpype/hosts/maya/plugins/load/load_arnold_standin.py index b5cc4d629b..2e1329f201 100644 --- a/openpype/hosts/maya/plugins/load/load_arnold_standin.py +++ b/openpype/hosts/maya/plugins/load/load_arnold_standin.py @@ -17,6 +17,7 @@ from openpype.hosts.maya.api.lib import ( ) from openpype.hosts.maya.api.pipeline import containerise + def is_sequence(files): sequence = False collections, remainder = clique.assemble(files, minimum_items=1) @@ -29,11 +30,12 @@ def get_current_session_fps(): session_fps = float(legacy_io.Session.get('AVALON_FPS', 25)) return convert_to_maya_fps(session_fps) + class ArnoldStandinLoader(load.LoaderPlugin): """Load as Arnold standin""" - families = ["ass", "animation", "model", "proxyAbc", "pointcache"] - representations = ["ass", "abc"] + families = ["ass", "animation", "model", "proxyAbc", "pointcache", "usd"] + representations = ["ass", "abc", "usda", "usdc", "usd"] label = "Load as Arnold standin" order = -5 diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 265b15f4ae..90cadb31b1 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -1,12 +1,6 @@ from maya import cmds, mel -from openpype.client import ( - get_asset_by_id, - get_subset_by_id, - get_version_by_id, -) from openpype.pipeline import ( - get_current_project_name, load, get_representation_path, ) @@ -18,7 +12,7 @@ class AudioLoader(load.LoaderPlugin): """Specific loader of audio.""" families = ["audio"] - label = "Import audio" + label = "Load audio" representations = ["wav"] icon = "volume-up" color = "orange" @@ -27,10 +21,10 @@ class AudioLoader(load.LoaderPlugin): start_frame = cmds.playbackOptions(query=True, min=True) sound_node = cmds.sound( - file=context["representation"]["data"]["path"], offset=start_frame + file=self.filepath_from_context(context), offset=start_frame ) cmds.timeControl( - mel.eval("$tmpVar=$gPlayBackSlider"), + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), edit=True, sound=sound_node, displaySound=True @@ -59,32 +53,50 @@ class AudioLoader(load.LoaderPlugin): assert audio_nodes is not None, "Audio node not found." audio_node = audio_nodes[0] + current_sound = cmds.timeControl( + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), + query=True, + sound=True + ) + activate_sound = current_sound == audio_node + path = get_representation_path(representation) - cmds.setAttr("{}.filename".format(audio_node), path, type="string") + + cmds.sound( + audio_node, + edit=True, + file=path + ) + + # The source start + end does not automatically update itself to the + # length of thew new audio file, even though maya does do that when + # creating a new audio node. So to update we compute it manually. + # This would however override any source start and source end a user + # might have done on the original audio node after load. + audio_frame_count = cmds.getAttr("{}.frameCount".format(audio_node)) + audio_sample_rate = cmds.getAttr("{}.sampleRate".format(audio_node)) + duration_in_seconds = audio_frame_count / audio_sample_rate + fps = mel.eval('currentTimeUnitToFPS()') # workfile FPS + source_start = 0 + source_end = (duration_in_seconds * fps) + cmds.setAttr("{}.sourceStart".format(audio_node), source_start) + cmds.setAttr("{}.sourceEnd".format(audio_node), source_end) + + if activate_sound: + # maya by default deactivates it from timeline on file change + cmds.timeControl( + mel.eval("$gPlayBackSlider=$gPlayBackSlider"), + edit=True, + sound=audio_node, + displaySound=True + ) + cmds.setAttr( container["objectName"] + ".representation", str(representation["_id"]), type="string" ) - # Set frame range. - project_name = get_current_project_name() - version = get_version_by_id( - project_name, representation["parent"], fields=["parent"] - ) - subset = get_subset_by_id( - project_name, version["parent"], fields=["parent"] - ) - asset = get_asset_by_id( - project_name, subset["parent"], fields=["parent"] - ) - - source_start = 1 - asset["data"]["frameStart"] - source_end = asset["data"]["frameEnd"] - - cmds.setAttr("{}.sourceStart".format(audio_node), source_start) - cmds.setAttr("{}.sourceEnd".format(audio_node), source_end) - def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/maya/plugins/load/load_maya_usd.py b/openpype/hosts/maya/plugins/load/load_maya_usd.py new file mode 100644 index 0000000000..2fb1a625a5 --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_maya_usd.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +import maya.cmds as cmds + +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.pipeline.load import get_representation_path_from_context +from openpype.hosts.maya.api.lib import ( + namespaced, + unique_namespace +) +from openpype.hosts.maya.api.pipeline import containerise + + +class MayaUsdLoader(load.LoaderPlugin): + """Read USD data in a Maya USD Proxy""" + + families = ["model", "usd", "pointcache", "animation"] + representations = ["usd", "usda", "usdc", "usdz", "abc"] + + label = "Load USD to Maya Proxy" + order = -1 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, options=None): + asset = context['asset']['name'] + namespace = namespace or unique_namespace( + asset + "_", + prefix="_" if asset[0].isdigit() else "", + suffix="_", + ) + + # Make sure we can load the plugin + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + path = get_representation_path_from_context(context) + + # Create the shape + cmds.namespace(addNamespace=namespace) + with namespaced(namespace, new=False): + transform = cmds.createNode("transform", + name=name, + skipSelect=True) + proxy = cmds.createNode('mayaUsdProxyShape', + name="{}Shape".format(name), + parent=transform, + skipSelect=True) + + cmds.connectAttr("time1.outTime", "{}.time".format(proxy)) + cmds.setAttr("{}.filePath".format(proxy), path, type="string") + + # By default, we force the proxy to not use a shared stage because + # when doing so Maya will quite easily allow to save into the + # loaded usd file. Since we are loading published files we want to + # avoid altering them. Unshared stages also save their edits into + # the workfile as an artist might expect it to do. + cmds.setAttr("{}.shareStage".format(proxy), False) + # cmds.setAttr("{}.shareStage".format(proxy), lock=True) + + nodes = [transform, proxy] + self[:] = nodes + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__) + + def update(self, container, representation): + # type: (dict, dict) -> None + """Update container with specified representation.""" + node = container['objectName'] + assert cmds.objExists(node), "Missing container" + + members = cmds.sets(node, query=True) or [] + shapes = cmds.ls(members, type="mayaUsdProxyShape") + + path = get_representation_path(representation) + for shape in shapes: + cmds.setAttr("{}.filePath".format(shape), path, type="string") + + cmds.setAttr("{}.representation".format(node), + str(representation["_id"]), + type="string") + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + # type: (dict) -> None + """Remove loaded container.""" + # Delete container and its contents + if cmds.objExists(container['objectName']): + members = cmds.sets(container['objectName'], query=True) or [] + cmds.delete([container['objectName']] + members) + + # Remove the namespace, if empty + namespace = container['namespace'] + if cmds.namespace(exists=namespace): + members = cmds.namespaceInfo(namespace, listNamespace=True) + if not members: + cmds.namespace(removeNamespace=namespace) + else: + self.log.warning("Namespace not deleted because it " + "still has members: %s", namespace) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index d08fcd904e..cad42b55f9 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -43,8 +43,6 @@ class MultiverseUsdLoader(load.LoaderPlugin): import multiverse # Create the shape - shape = None - transform = None with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 91767249e0..a4ab6c79c1 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -1,7 +1,9 @@ import os import difflib import contextlib + from maya import cmds +import qargparse from openpype.settings import get_project_settings import openpype.hosts.maya.api.plugin @@ -101,7 +103,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): "camerarig", "staticMesh", "skeletalMesh", - "mvLook"] + "mvLook", + "matchmove"] representations = ["ma", "abc", "fbx", "mb"] @@ -127,6 +130,12 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): if not attach_to_root: group_name = namespace + kwargs = {} + if "file_options" in options: + kwargs["options"] = options["file_options"] + if "file_type" in options: + kwargs["type"] = options["file_type"] + path = self.filepath_from_context(context) with maintained_selection(): cmds.loadPlugin("AbcImport.mll", quiet=True) @@ -138,7 +147,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): reference=True, returnNewNodes=True, groupReference=attach_to_root, - groupName=group_name) + groupName=group_name, + **kwargs) shapes = cmds.ls(nodes, shapes=True, long=True) @@ -205,7 +215,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): cmds.setAttr("{}.selectHandleZ".format(group_name), cz) if family == "rig": - self._post_process_rig(name, namespace, context, options) + self._post_process_rig(namespace, context, options) else: if "translate" in options: if not attach_to_root and new_nodes: @@ -229,7 +239,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): members = get_container_members(container) self._lock_camera_transforms(members) - def _post_process_rig(self, name, namespace, context, options): + def _post_process_rig(self, namespace, context, options): nodes = self[:] create_rig_animation_instance( @@ -250,3 +260,93 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): else: self.log.warning("This version of Maya does not support locking of" " transforms of cameras.") + + +class MayaUSDReferenceLoader(ReferenceLoader): + """Reference USD file to native Maya nodes using MayaUSDImport reference""" + + label = "Reference Maya USD" + families = ["usd"] + representations = ["usd"] + extensions = {"usd", "usda", "usdc"} + + options = ReferenceLoader.options + [ + qargparse.Boolean( + "readAnimData", + label="Load anim data", + default=True, + help="Load animation data from USD file" + ), + qargparse.Boolean( + "useAsAnimationCache", + label="Use as animation cache", + default=True, + help=( + "Imports geometry prims with time-sampled point data using a " + "point-based deformer that references the imported " + "USD file.\n" + "This provides better import and playback performance when " + "importing time-sampled geometry from USD, and should " + "reduce the weight of the resulting Maya scene." + ) + ), + qargparse.Boolean( + "importInstances", + label="Import instances", + default=True, + help=( + "Import USD instanced geometries as Maya instanced shapes. " + "Will flatten the scene otherwise." + ) + ), + qargparse.String( + "primPath", + label="Prim Path", + default="/", + help=( + "Name of the USD scope where traversing will begin.\n" + "The prim at the specified primPath (including the prim) will " + "be imported.\n" + "Specifying the pseudo-root (/) means you want " + "to import everything in the file.\n" + "If the passed prim path is empty, it will first try to " + "import the defaultPrim for the rootLayer if it exists.\n" + "Otherwise, it will behave as if the pseudo-root was passed " + "in." + ) + ) + ] + + file_type = "USD Import" + + def process_reference(self, context, name, namespace, options): + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + def bool_option(key, default): + # Shorthand for getting optional boolean file option from options + value = int(bool(options.get(key, default))) + return "{}={}".format(key, value) + + def string_option(key, default): + # Shorthand for getting optional string file option from options + value = str(options.get(key, default)) + return "{}={}".format(key, value) + + options["file_options"] = ";".join([ + string_option("primPath", default="/"), + bool_option("importInstances", default=True), + bool_option("useAsAnimationCache", default=True), + bool_option("readAnimData", default=True), + # TODO: Expose more parameters + # "preferredMaterial=none", + # "importRelativeTextures=Automatic", + # "useCustomFrameRange=0", + # "startTime=0", + # "endTime=0", + # "importUSDZTextures=0" + ]) + options["file_type"] = self.file_type + + return super(MayaUSDReferenceLoader, self).process_reference( + context, name, namespace, options + ) diff --git a/openpype/hosts/maya/plugins/load/load_xgen.py b/openpype/hosts/maya/plugins/load/load_xgen.py index 323f8d7eda..2ad6ad55bc 100644 --- a/openpype/hosts/maya/plugins/load/load_xgen.py +++ b/openpype/hosts/maya/plugins/load/load_xgen.py @@ -53,8 +53,6 @@ class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): ) # Reference xgen. Xgen does not like being referenced in under a group. - new_nodes = [] - with maintained_selection(): nodes = cmds.file( maya_filepath, diff --git a/openpype/hosts/maya/plugins/load/load_yeti_cache.py b/openpype/hosts/maya/plugins/load/load_yeti_cache.py index 5cded13d4e..4a11ea9a2c 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_cache.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_cache.py @@ -15,6 +15,16 @@ from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.pipeline import containerise +# Do not reset these values on update but only apply on first load +# to preserve any potential local overrides +SKIP_UPDATE_ATTRS = { + "displayOutput", + "viewportDensity", + "viewportWidth", + "viewportLength", +} + + def set_attribute(node, attr, value): """Wrapper of set attribute which ignores None values""" if value is None: @@ -205,6 +215,8 @@ class YetiCacheLoader(load.LoaderPlugin): yeti_node = yeti_nodes[0] for attr, value in node_settings["attrs"].items(): + if attr in SKIP_UPDATE_ATTRS: + continue set_attribute(attr, value, yeti_node) cmds.setAttr("{}.representation".format(container_node), @@ -311,7 +323,6 @@ class YetiCacheLoader(load.LoaderPlugin): # Update attributes with defaults attributes = node_settings["attrs"] attributes.update({ - "viewportDensity": 0.1, "verbosity": 2, "fileMode": 1, @@ -321,6 +332,9 @@ class YetiCacheLoader(load.LoaderPlugin): "visibleInRefractions": True }) + if "viewportDensity" not in attributes: + attributes["viewportDensity"] = 0.1 + # Apply attributes to pgYetiMaya node for attr, value in attributes.items(): set_attribute(attr, value, yeti_node) diff --git a/openpype/hosts/maya/plugins/publish/collect_animation.py b/openpype/hosts/maya/plugins/publish/collect_animation.py index 8f523f770b..26a0a01c8b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_animation.py @@ -58,17 +58,3 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): if instance.data.get("farm"): instance.data["families"].append("publish.farm") - # Collect user defined attributes. - if not instance.data.get("includeUserDefinedAttributes", False): - return - - user_defined_attributes = set() - for node in hierarchy: - attrs = cmds.listAttr(node, userDefined=True) or list() - shapes = cmds.listRelatives(node, shapes=True) or list() - for shape in shapes: - attrs.extend(cmds.listAttr(shape, userDefined=True) or list()) - - user_defined_attributes.update(attrs) - - instance.data["userDefinedAttributes"] = list(user_defined_attributes) diff --git a/openpype/hosts/maya/plugins/publish/collect_assembly.py b/openpype/hosts/maya/plugins/publish/collect_assembly.py index 2aef9ab908..f64d6bee44 100644 --- a/openpype/hosts/maya/plugins/publish/collect_assembly.py +++ b/openpype/hosts/maya/plugins/publish/collect_assembly.py @@ -35,14 +35,11 @@ class CollectAssembly(pyblish.api.InstancePlugin): # Get all content from the instance instance_lookup = set(cmds.ls(instance, type="transform", long=True)) data = defaultdict(list) - self.log.info(instance_lookup) hierarchy_nodes = [] for container in containers: - self.log.info(container) root = lib.get_container_transforms(container, root=True) - self.log.info(root) if not root or root not in instance_lookup: continue diff --git a/openpype/hosts/maya/plugins/publish/collect_current_file.py b/openpype/hosts/maya/plugins/publish/collect_current_file.py index e777a209d4..c7105a7f3c 100644 --- a/openpype/hosts/maya/plugins/publish/collect_current_file.py +++ b/openpype/hosts/maya/plugins/publish/collect_current_file.py @@ -10,7 +10,6 @@ class CollectCurrentFile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.4 label = "Maya Current File" hosts = ['maya'] - families = ["workfile"] def process(self, context): """Inject the current working file""" diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py new file mode 100644 index 0000000000..aef8765e9c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api +from openpype.pipeline import OptionalPyblishPluginMixin + + +class CollectFbxAnimation(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Collect Animated Rig Data for FBX Extractor.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Fbx Animation" + hosts = ["maya"] + families = ["animation"] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + skeleton_sets = [ + i for i in instance + if i.endswith("skeletonAnim_SET") + ] + if not skeleton_sets: + return + + instance.data["families"].append("animation.fbx") + instance.data["animated_skeleton"] = [] + for skeleton_set in skeleton_sets: + skeleton_content = cmds.sets(skeleton_set, query=True) + self.log.debug( + "Collected animated skeleton data: {}".format( + skeleton_content + )) + if skeleton_content: + instance.data["animated_skeleton"] = skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/collect_history.py b/openpype/hosts/maya/plugins/publish/collect_history.py index 71f0169971..d4e8c6298b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_history.py +++ b/openpype/hosts/maya/plugins/publish/collect_history.py @@ -18,7 +18,6 @@ class CollectMayaHistory(pyblish.api.InstancePlugin): hosts = ["maya"] label = "Maya History" families = ["rig"] - verbose = False def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/collect_instances.py b/openpype/hosts/maya/plugins/publish/collect_instances.py index 5f914b40d7..5058da3d01 100644 --- a/openpype/hosts/maya/plugins/publish/collect_instances.py +++ b/openpype/hosts/maya/plugins/publish/collect_instances.py @@ -28,6 +28,8 @@ class CollectNewInstances(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder hosts = ["maya"] + valid_empty_families = {"workfile", "renderlayer"} + def process(self, instance): objset = instance.data.get("instance_node") @@ -58,7 +60,7 @@ class CollectNewInstances(pyblish.api.InstancePlugin): instance[:] = members_hierarchy - elif instance.data["family"] != "workfile": + elif instance.data["family"] not in self.valid_empty_families: self.log.warning("Empty instance: \"%s\" " % objset) # Store the exact members of the object set instance.data["setMembers"] = members diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index b3da920566..72682f7800 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -17,11 +17,6 @@ SHAPE_ATTRS = ["castsShadows", "visibleInRefractions", "doubleSided", "opposite"] - -RENDERER_NODE_TYPES = [ - # redshift - "RedshiftMeshParameters" -] SHAPE_ATTRS = set(SHAPE_ATTRS) @@ -36,12 +31,13 @@ def get_pxr_multitexture_file_attrs(node): FILE_NODES = { + # maya "file": "fileTextureName", - + # arnold (mtoa) "aiImage": "filename", - + # redshift "RedshiftNormalMap": "tex0", - + # renderman "PxrBump": "filename", "PxrNormalMap": "filename", "PxrMultiTexture": get_pxr_multitexture_file_attrs, @@ -49,6 +45,34 @@ FILE_NODES = { "PxrTexture": "filename" } +RENDER_SET_TYPES = [ + "VRayDisplacement", + "VRayLightMesh", + "VRayObjectProperties", + "RedshiftObjectId", + "RedshiftMeshParameters", +] + +# Keep only node types that actually exist +all_node_types = set(cmds.allNodeTypes()) +for node_type in list(FILE_NODES.keys()): + if node_type not in all_node_types: + FILE_NODES.pop(node_type) + +for node_type in RENDER_SET_TYPES: + if node_type not in all_node_types: + RENDER_SET_TYPES.remove(node_type) +del all_node_types + +# Cache pixar dependency node types so we can perform a type lookup against it +PXR_NODES = set() +if cmds.pluginInfo("RenderMan_for_Maya", query=True, loaded=True): + PXR_NODES = set( + cmds.pluginInfo("RenderMan_for_Maya", + query=True, + dependNode=True) + ) + def get_attributes(dictionary, attr, node=None): # type: (dict, str, str) -> list @@ -57,9 +81,7 @@ def get_attributes(dictionary, attr, node=None): else: val = dictionary.get(attr, []) - if not isinstance(val, list): - return [val] - return val + return val if isinstance(val, list) else [val] def get_look_attrs(node): @@ -94,7 +116,7 @@ def get_look_attrs(node): def node_uses_image_sequence(node, node_path): - # type: (str) -> bool + # type: (str, str) -> bool """Return whether file node uses an image sequence or single image. Determine if a node uses an image sequence or just a single image, @@ -102,6 +124,7 @@ def node_uses_image_sequence(node, node_path): Args: node (str): Name of the Maya node + node_path (str): The file path of the node Returns: bool: True if node uses an image sequence @@ -232,20 +255,17 @@ def get_file_node_files(node): """ paths = get_file_node_paths(node) - sequences = [] - replaces = [] - for index, path in enumerate(paths): + + # For sequences get all files and filter to only existing files + result = [] + for path in paths: if node_uses_image_sequence(node, path): glob_pattern = seq_to_glob(path) - sequences.extend(glob.glob(glob_pattern)) - replaces.append(index) + result.extend(glob.glob(glob_pattern)) + elif os.path.exists(path): + result.append(path) - for index in replaces: - paths.pop(index) - - paths.extend(sequences) - - return [p for p in paths if os.path.exists(p)] + return result class CollectLook(pyblish.api.InstancePlugin): @@ -260,7 +280,7 @@ class CollectLook(pyblish.api.InstancePlugin): membership relations. Collects: - lookAttribtutes (list): Nodes in instance with their altered attributes + lookAttributes (list): Nodes in instance with their altered attributes lookSetRelations (list): Sets and their memberships lookSets (list): List of set names included in the look @@ -286,7 +306,10 @@ class CollectLook(pyblish.api.InstancePlugin): """ self.log.debug("Looking for look associations " - "for %s" % instance.data['name']) + "for %s" % instance.data['name']) + + # Lookup set (optimization) + instance_lookup = set(cmds.ls(instance, long=True)) # Discover related object sets self.log.debug("Gathering sets ...") @@ -296,68 +319,24 @@ class CollectLook(pyblish.api.InstancePlugin): instance_lookup = set(cmds.ls(instance, long=True)) self.log.debug("Gathering set relations ...") - # Ensure iteration happen in a list so we can remove keys from the + # Ensure iteration happen in a list to allow removing keys from the # dict within the loop - - # skipped types of attribute on render specific nodes - disabled_types = ["message", "TdataCompound"] - for obj_set in list(sets): self.log.debug("From {}".format(obj_set)) - - # if node is specified as renderer node type, it will be - # serialized with its attributes. - if cmds.nodeType(obj_set) in RENDERER_NODE_TYPES: - self.log.debug("- {} is {}".format( - obj_set, cmds.nodeType(obj_set))) - - node_attrs = [] - - # serialize its attributes so they can be recreated on look - # load. - for attr in cmds.listAttr(obj_set): - # skip publishedNodeInfo attributes as they break - # getAttr() and we don't need them anyway - if attr.startswith("publishedNodeInfo"): - continue - - # skip attributes types defined in 'disabled_type' list - if cmds.getAttr("{}.{}".format(obj_set, attr), type=True) in disabled_types: # noqa - continue - - node_attrs.append(( - attr, - cmds.getAttr("{}.{}".format(obj_set, attr)), - cmds.getAttr( - "{}.{}".format(obj_set, attr), type=True) - )) - - for member in cmds.ls( - cmds.sets(obj_set, query=True), long=True): - member_data = self.collect_member_data(member, - instance_lookup) - if not member_data: - continue - - # Add information of the node to the members list - sets[obj_set]["members"].append(member_data) - # Get all nodes of the current objectSet (shadingEngine) for member in cmds.ls(cmds.sets(obj_set, query=True), long=True): member_data = self.collect_member_data(member, instance_lookup) - if not member_data: - continue - - # Add information of the node to the members list - sets[obj_set]["members"].append(member_data) + if member_data: + # Add information of the node to the members list + sets[obj_set]["members"].append(member_data) # Remove sets that didn't have any members assigned in the end # Thus the data will be limited to only what we need. - self.log.debug("obj_set {}".format(sets[obj_set])) if not sets[obj_set]["members"]: - self.log.info( - "Removing redundant set information: {}".format(obj_set)) + self.log.debug( + "Removing redundant set information: {}".format(obj_set) + ) sets.pop(obj_set, None) self.log.debug("Gathering attribute changes to instance members..") @@ -382,66 +361,68 @@ class CollectLook(pyblish.api.InstancePlugin): "rman__displacement" ] if look_sets: - materials = [] + self.log.debug("Found look sets: {}".format(look_sets)) + # Get all material attrs for all look sets to retrieve their inputs + existing_attrs = [] for look in look_sets: - for at in shader_attrs: - try: - con = cmds.listConnections("{}.{}".format(look, at)) - except ValueError: - # skip attributes that are invalid in current - # context. For example in the case where - # Arnold is not enabled. - continue - if con: - materials.extend(con) + for attr in shader_attrs: + if cmds.attributeQuery(attr, node=look, exists=True): + existing_attrs.append("{}.{}".format(look, attr)) - self.log.info("Found materials:\n{}".format(materials)) + materials = cmds.listConnections(existing_attrs, + source=True, + destination=False) or [] - self.log.info("Found the following sets:\n{}".format(look_sets)) + self.log.debug("Found materials:\n{}".format(materials)) + + self.log.debug("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets - # history = cmds.listHistory(look_sets) - history = [] - for material in materials: - history.extend(cmds.listHistory(material, ac=True)) - - # handle VrayPluginNodeMtl node - see #1397 - vray_plugin_nodes = cmds.ls( - history, type="VRayPluginNodeMtl", long=True) - for vray_node in vray_plugin_nodes: - history.extend(cmds.listHistory(vray_node, ac=True)) + # history = cmds.listHistory(look_sets, allConnections=True) + # if materials list is empty, listHistory() will crash with + # RuntimeError + history = set() + if materials: + history = set( + cmds.listHistory(materials, allConnections=True)) + # Since we retrieved history only of the connected materials + # connected to the look sets above we now add direct history + # for some of the look sets directly # handling render attribute sets - render_set_types = [ - "VRayDisplacement", - "VRayLightMesh", - "VRayObjectProperties", - "RedshiftObjectId", - "RedshiftMeshParameters", - ] - render_sets = cmds.ls(look_sets, type=render_set_types) - if render_sets: - history.extend( - cmds.listHistory(render_sets, - future=False, - pruneDagObjects=True) - or [] - ) - all_supported_nodes = FILE_NODES.keys() - files = [] - for node_type in all_supported_nodes: - files.extend(cmds.ls(history, type=node_type, long=True)) + # Maya (at least 2024) crashes with Warning when render set type + # isn't available. cmds.ls() will return empty list + if RENDER_SET_TYPES: + render_sets = cmds.ls(look_sets, type=RENDER_SET_TYPES) + if render_sets: + history.update( + cmds.listHistory(render_sets, + future=False, + pruneDagObjects=True) + or [] + ) + + # Ensure unique entries only + history = list(history) + + files = cmds.ls(history, + # It's important only node types are passed that + # exist (e.g. for loaded plugins) because otherwise + # the result will turn back empty + type=list(FILE_NODES.keys()), + long=True) + + # Sort for log readability + files.sort() self.log.debug("Collected file nodes:\n{}".format(files)) # Collect textures if any file nodes are found - instance.data["resources"] = [] - for n in files: - for res in self.collect_resources(n): - instance.data["resources"].append(res) - - self.log.debug("Collected resources: {}".format( - instance.data["resources"])) + resources = [] + for node in files: # sort for log readability + resources.extend(self.collect_resources(node)) + instance.data["resources"] = resources + self.log.debug("Collected resources: {}".format(resources)) # Log warning when no relevant sets were retrieved for the look. if ( @@ -456,7 +437,7 @@ class CollectLook(pyblish.api.InstancePlugin): instance.extend(shader for shader in look_sets if shader not in instance_lookup) - self.log.info("Collected look for %s" % instance) + self.log.debug("Collected look for %s" % instance) def collect_sets(self, instance): """Collect all objectSets which are of importance for publishing @@ -536,14 +517,14 @@ class CollectLook(pyblish.api.InstancePlugin): # Collect changes to "custom" attributes node_attrs = get_look_attrs(node) - self.log.debug( - "Node \"{0}\" attributes: {1}".format(node, node_attrs) - ) - # Only include if there are any properties we care about if not node_attrs: continue + self.log.debug( + "Node \"{0}\" attributes: {1}".format(node, node_attrs) + ) + node_attributes = {} for attr in node_attrs: if not cmds.attributeQuery(attr, node=node, exists=True): @@ -574,14 +555,14 @@ class CollectLook(pyblish.api.InstancePlugin): Returns: dict """ - self.log.debug("processing: {}".format(node)) - all_supported_nodes = FILE_NODES.keys() - if cmds.nodeType(node) not in all_supported_nodes: + if cmds.nodeType(node) not in FILE_NODES: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") - self.log.debug(" - got {}".format(cmds.nodeType(node))) + self.log.debug( + "Collecting resource: {} ({})".format(node, cmds.nodeType(node)) + ) attributes = get_attributes(FILE_NODES, cmds.nodeType(node), node) for attribute in attributes: @@ -589,39 +570,34 @@ class CollectLook(pyblish.api.InstancePlugin): node, attribute )) - computed_attribute = "{}.{}".format(node, attribute) - if attribute == "fileTextureName": - computed_attribute = node + ".computedFileTextureNamePattern" - self.log.info(" - file source: {}".format(source)) + self.log.debug(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) try: color_space = cmds.getAttr(color_space_attr) except ValueError: # node doesn't have colorspace attribute color_space = "Raw" + # Compare with the computed file path, e.g. the one with # the pattern in it, to generate some logging information # about this difference - computed_source = cmds.getAttr(computed_attribute) - if source != computed_source: - self.log.debug("Detected computed file pattern difference " - "from original pattern: {0} " - "({1} -> {2})".format(node, - source, - computed_source)) + # Only for file nodes with `fileTextureName` attribute + if attribute == "fileTextureName": + computed_source = cmds.getAttr( + "{}.computedFileTextureNamePattern".format(node) + ) + if source != computed_source: + self.log.debug("Detected computed file pattern difference " + "from original pattern: {0} " + "({1} -> {2})".format(node, + source, + computed_source)) # renderman allows nodes to have filename attribute empty while # you can have another incoming connection from different node. - pxr_nodes = set() - if cmds.pluginInfo("RenderMan_for_Maya", query=True, loaded=True): - pxr_nodes = set( - cmds.pluginInfo("RenderMan_for_Maya", - query=True, - dependNode=True) - ) - if not source and cmds.nodeType(node) in pxr_nodes: - self.log.info("Renderman: source is empty, skipping...") + if not source and cmds.nodeType(node) in PXR_NODES: + self.log.debug("Renderman: source is empty, skipping...") continue # We replace backslashes with forward slashes because V-Ray # can't handle the UDIM files with the backslashes in the @@ -630,14 +606,14 @@ class CollectLook(pyblish.api.InstancePlugin): files = get_file_node_files(node) if len(files) == 0: - self.log.error("No valid files found from node `%s`" % node) + self.log.debug("No valid files found from node `%s`" % node) - self.log.info("collection of resource done:") - self.log.info(" - node: {}".format(node)) - self.log.info(" - attribute: {}".format(attribute)) - self.log.info(" - source: {}".format(source)) - self.log.info(" - file: {}".format(files)) - self.log.info(" - color space: {}".format(color_space)) + self.log.debug("collection of resource done:") + self.log.debug(" - node: {}".format(node)) + self.log.debug(" - attribute: {}".format(attribute)) + self.log.debug(" - source: {}".format(source)) + self.log.debug(" - file: {}".format(files)) + self.log.debug(" - color space: {}".format(color_space)) # Define the resource yield { diff --git a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py index 33fc7a025f..bcb979edfc 100644 --- a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py @@ -268,7 +268,7 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): cmds.loadPlugin("MultiverseForMaya", quiet=True) import multiverse - self.log.info("Processing mvLook for '{}'".format(instance)) + self.log.debug("Processing mvLook for '{}'".format(instance)) nodes = set() for node in instance: @@ -281,13 +281,12 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): long=True) nodes.update(nodes_of_interest) - files = [] sets = {} instance.data["resources"] = [] publishMipMap = instance.data["publishMipMap"] for node in nodes: - self.log.info("Getting resources for '{}'".format(node)) + self.log.debug("Getting resources for '{}'".format(node)) # We know what nodes need to be collected, now we need to # extract the materials overrides. @@ -380,12 +379,12 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): if len(files) == 0: self.log.error("No valid files found from node `%s`" % node) - self.log.info("collection of resource done:") - self.log.info(" - node: {}".format(node)) - self.log.info(" - attribute: {}".format(fname_attrib)) - self.log.info(" - source: {}".format(source)) - self.log.info(" - file: {}".format(files)) - self.log.info(" - color space: {}".format(color_space)) + self.log.debug("collection of resource done:") + self.log.debug(" - node: {}".format(node)) + self.log.debug(" - attribute: {}".format(fname_attrib)) + self.log.debug(" - source: {}".format(source)) + self.log.debug(" - file: {}".format(files)) + self.log.debug(" - color space: {}".format(color_space)) # Define the resource resource = {"node": node, @@ -406,14 +405,14 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): extra_files = [] self.log.debug("Expecting MipMaps, going to look for them.") for fname in files: - self.log.info("Checking '{}' for mipmaps".format(fname)) + self.log.debug("Checking '{}' for mipmaps".format(fname)) if is_mipmap(fname): self.log.debug(" - file is already MipMap, skipping.") continue mipmap = get_mipmap(fname) if mipmap: - self.log.info(" mipmap found for '{}'".format(fname)) + self.log.debug(" mipmap found for '{}'".format(fname)) extra_files.append(mipmap) else: self.log.warning(" no mipmap found for '{}'".format(fname)) diff --git a/openpype/hosts/maya/plugins/publish/collect_pointcache.py b/openpype/hosts/maya/plugins/publish/collect_pointcache.py index bb9065792f..5578a57f31 100644 --- a/openpype/hosts/maya/plugins/publish/collect_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/collect_pointcache.py @@ -45,18 +45,3 @@ class CollectPointcache(pyblish.api.InstancePlugin): if proxy_set: instance.remove(proxy_set) instance.data["setMembers"].remove(proxy_set) - - # Collect user defined attributes. - if not instance.data.get("includeUserDefinedAttributes", False): - return - - user_defined_attributes = set() - for node in instance: - attrs = cmds.listAttr(node, userDefined=True) or list() - shapes = cmds.listRelatives(node, shapes=True) or list() - for shape in shapes: - attrs.extend(cmds.listAttr(shape, userDefined=True) or list()) - - user_defined_attributes.update(attrs) - - instance.data["userDefinedAttributes"] = list(user_defined_attributes) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index c37b54ea9a..886c2b4caa 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -105,7 +105,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): "family": cmds.getAttr("{}.family".format(s)), } ) - self.log.info(" -> attach render to: {}".format(s)) + self.log.debug(" -> attach render to: {}".format(s)) layer_name = layer.name() @@ -137,10 +137,10 @@ class CollectMayaRender(pyblish.api.InstancePlugin): has_cameras = any(product.camera for product in render_products) assert has_cameras, "No render cameras found." - self.log.info("multipart: {}".format( + self.log.debug("multipart: {}".format( multipart)) assert expected_files, "no file names were generated, this is a bug" - self.log.info( + self.log.debug( "expected files: {}".format( json.dumps(expected_files, indent=4, sort_keys=True) ) @@ -157,10 +157,10 @@ class CollectMayaRender(pyblish.api.InstancePlugin): # append full path aov_dict = {} - default_render_folder = context.data.get("project_settings")\ - .get("maya")\ - .get("RenderSettings")\ - .get("default_render_image_folder") or "" + image_directory = os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="images") + ) # replace relative paths with absolute. Render products are # returned as list of dictionaries. publish_meta_path = None @@ -168,14 +168,13 @@ class CollectMayaRender(pyblish.api.InstancePlugin): full_paths = [] aov_first_key = list(aov.keys())[0] for file in aov[aov_first_key]: - full_path = os.path.join(workspace, default_render_folder, - file) + full_path = os.path.join(image_directory, file) full_path = full_path.replace("\\", "/") full_paths.append(full_path) publish_meta_path = os.path.dirname(full_path) aov_dict[aov_first_key] = full_paths full_exp_files = [aov_dict] - self.log.info(full_exp_files) + self.log.debug(full_exp_files) if publish_meta_path is None: raise KnownPublishError("Unable to detect any expected output " @@ -227,7 +226,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): if platform.system().lower() in ["linux", "darwin"]: common_publish_meta_path = "/" + common_publish_meta_path - self.log.info( + self.log.debug( "Publish meta path: {}".format(common_publish_meta_path)) # Get layer specific settings, might be overrides @@ -300,13 +299,13 @@ class CollectMayaRender(pyblish.api.InstancePlugin): ) if rr_settings["enabled"]: data["rrPathName"] = instance.data.get("rrPathName") - self.log.info(data["rrPathName"]) + self.log.debug(data["rrPathName"]) if self.sync_workfile_version: data["version"] = context.data["version"] - for instance in context: - if instance.data['family'] == "workfile": - instance.data["version"] = context.data["version"] + for _instance in context: + if _instance.data['family'] == "workfile": + _instance.data["version"] = context.data["version"] # Define nice label label = "{0} ({1})".format(layer_name, instance.data["asset"]) diff --git a/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py b/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py index c3dc31ead9..035c531a9b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py +++ b/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py @@ -37,7 +37,7 @@ class CollectRenderLayerAOVS(pyblish.api.InstancePlugin): # Get renderer renderer = instance.data["renderer"] - self.log.info("Renderer found: {}".format(renderer)) + self.log.debug("Renderer found: {}".format(renderer)) rp_node_types = {"vray": ["VRayRenderElement", "VRayRenderElementSet"], "arnold": ["aiAOV"], @@ -66,8 +66,8 @@ class CollectRenderLayerAOVS(pyblish.api.InstancePlugin): result.append(render_pass) - self.log.info("Found {} render elements / AOVs for " - "'{}'".format(len(result), instance.data["subset"])) + self.log.debug("Found {} render elements / AOVs for " + "'{}'".format(len(result), instance.data["subset"])) instance.data["renderPasses"] = result diff --git a/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py b/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py index d1c3cf3b2c..4443e2e0db 100644 --- a/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py +++ b/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py @@ -21,11 +21,12 @@ class CollectRenderableCamera(pyblish.api.InstancePlugin): else: layer = instance.data["renderlayer"] - self.log.info("layer: {}".format(layer)) cameras = cmds.ls(type="camera", long=True) - renderable = [c for c in cameras if - get_attr_in_layer("%s.renderable" % c, layer)] + renderable = [cam for cam in cameras if + get_attr_in_layer("{}.renderable".format(cam), layer)] - self.log.info("Found cameras %s: %s" % (len(renderable), renderable)) + self.log.debug( + "Found renderable cameras %s: %s", len(renderable), renderable + ) instance.data["cameras"] = renderable diff --git a/openpype/hosts/maya/plugins/publish/collect_review.py b/openpype/hosts/maya/plugins/publish/collect_review.py index 586939a3b8..0930da8f27 100644 --- a/openpype/hosts/maya/plugins/publish/collect_review.py +++ b/openpype/hosts/maya/plugins/publish/collect_review.py @@ -3,7 +3,7 @@ from maya import cmds, mel import pyblish.api from openpype.client import get_subset_by_name -from openpype.pipeline import legacy_io, KnownPublishError +from openpype.pipeline import KnownPublishError from openpype.hosts.maya.api import lib @@ -116,10 +116,10 @@ class CollectReview(pyblish.api.InstancePlugin): instance.data['remove'] = True else: - task = legacy_io.Session["AVALON_TASK"] - legacy_subset_name = task + 'Review' + project_name = instance.context.data["projectName"] asset_doc = instance.context.data['assetEntity'] - project_name = legacy_io.active_project() + task = instance.context.data["task"] + legacy_subset_name = task + 'Review' subset_doc = get_subset_by_name( project_name, legacy_subset_name, diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py new file mode 100644 index 0000000000..34ff26a8b8 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py @@ -0,0 +1,40 @@ +import pyblish.api +from maya import cmds + + +class CollectRigSets(pyblish.api.InstancePlugin): + """Ensure rig contains pipeline-critical content + + Every rig must contain at least two object sets: + "controls_SET" - Set of all animatable controls + "out_SET" - Set of all cacheable meshes + + """ + + order = pyblish.api.CollectorOrder + 0.05 + label = "Collect Rig Sets" + hosts = ["maya"] + families = ["rig"] + + accepted_output = ["mesh", "transform"] + accepted_controllers = ["transform"] + + def process(self, instance): + + # Find required sets by suffix + searching = {"controls_SET", "out_SET", + "skeletonAnim_SET", "skeletonMesh_SET"} + found = {} + for node in cmds.ls(instance, exactType="objectSet"): + for suffix in searching: + if node.endswith(suffix): + found[suffix] = node + searching.remove(suffix) + break + if not searching: + break + + self.log.debug("Found sets: {}".format(found)) + rig_sets = instance.data.setdefault("rig_sets", {}) + for name, objset in found.items(): + rig_sets[name] = objset diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py new file mode 100644 index 0000000000..31f0eca88c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api + + +class CollectSkeletonMesh(pyblish.api.InstancePlugin): + """Collect Static Rig Data for FBX Extractor.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Skeleton Mesh" + hosts = ["maya"] + families = ["rig"] + + def process(self, instance): + skeleton_mesh_set = instance.data["rig_sets"].get( + "skeletonMesh_SET") + if not skeleton_mesh_set: + self.log.debug( + "No skeletonMesh_SET found. " + "Skipping collecting of skeleton mesh..." + ) + return + + # Store current frame to ensure single frame export + frame = cmds.currentTime(query=True) + instance.data["frameStart"] = frame + instance.data["frameEnd"] = frame + + instance.data["skeleton_mesh"] = [] + + skeleton_mesh_content = cmds.sets( + skeleton_mesh_set, query=True) or [] + if not skeleton_mesh_content: + self.log.debug( + "No object nodes in skeletonMesh_SET. " + "Skipping collecting of skeleton mesh..." + ) + return + instance.data["families"] += ["rig.fbx"] + instance.data["skeleton_mesh"] = skeleton_mesh_content + self.log.debug( + "Collected skeletonMesh_SET members: {}".format( + skeleton_mesh_content + )) diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 79d0856fa0..03b6c4a188 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -19,7 +19,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): instance.data["geometryMembers"] = cmds.sets( geometry_set, query=True) - self.log.info("geometry: {}".format( + self.log.debug("geometry: {}".format( pformat(instance.data.get("geometryMembers")))) collision_set = [ @@ -29,7 +29,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): instance.data["collisionMembers"] = cmds.sets( collision_set, query=True) - self.log.info("collisions: {}".format( + self.log.debug("collisions: {}".format( pformat(instance.data.get("collisionMembers")))) frame = cmds.currentTime(query=True) diff --git a/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py b/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py new file mode 100644 index 0000000000..16fef2e168 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py @@ -0,0 +1,39 @@ +from maya import cmds + +import pyblish.api + + +class CollectUserDefinedAttributes(pyblish.api.InstancePlugin): + """Collect user defined attributes for nodes in instance.""" + + order = pyblish.api.CollectorOrder + 0.45 + families = ["pointcache", "animation", "usd"] + label = "Collect User Defined Attributes" + hosts = ["maya"] + + def process(self, instance): + + # Collect user defined attributes. + if not instance.data.get("includeUserDefinedAttributes", False): + return + + if "out_hierarchy" in instance.data: + # animation family + nodes = instance.data["out_hierarchy"] + else: + nodes = instance[:] + if not nodes: + return + + shapes = cmds.listRelatives(nodes, shapes=True, fullPath=True) or [] + nodes = set(nodes).union(shapes) + + attrs = cmds.listAttr(list(nodes), userDefined=True) or [] + user_defined_attributes = list(sorted(set(attrs))) + instance.data["userDefinedAttributes"] = user_defined_attributes + + self.log.debug( + "Collected user defined attributes: {}".format( + ", ".join(user_defined_attributes) + ) + ) diff --git a/openpype/hosts/maya/plugins/publish/collect_xgen.py b/openpype/hosts/maya/plugins/publish/collect_xgen.py index 46968f7d1a..45648e1776 100644 --- a/openpype/hosts/maya/plugins/publish/collect_xgen.py +++ b/openpype/hosts/maya/plugins/publish/collect_xgen.py @@ -67,5 +67,5 @@ class CollectXgen(pyblish.api.InstancePlugin): data["transfers"] = transfers - self.log.info(data) + self.log.debug(data) instance.data.update(data) diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py index e6b5ca4260..7a1516997a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py @@ -4,12 +4,23 @@ import pyblish.api from openpype.hosts.maya.api import lib -SETTINGS = {"renderDensity", - "renderWidth", - "renderLength", - "increaseRenderBounds", - "imageSearchPath", - "cbId"} + +SETTINGS = { + # Preview + "displayOutput", + "colorR", "colorG", "colorB", + "viewportDensity", + "viewportWidth", + "viewportLength", + # Render attributes + "renderDensity", + "renderWidth", + "renderLength", + "increaseRenderBounds", + "imageSearchPath", + # Pipeline specific + "cbId" +} class CollectYetiCache(pyblish.api.InstancePlugin): @@ -28,7 +39,7 @@ class CollectYetiCache(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.45 label = "Collect Yeti Cache" - families = ["yetiRig", "yeticache"] + families = ["yetiRig", "yeticache", "yeticacheUE"] hosts = ["maya"] def process(self, instance): @@ -39,10 +50,6 @@ class CollectYetiCache(pyblish.api.InstancePlugin): # Get yeti nodes and their transforms yeti_shapes = cmds.ls(instance, type="pgYetiMaya") for shape in yeti_shapes: - shape_data = {"transform": None, - "name": shape, - "cbId": lib.get_id(shape), - "attrs": None} # Get specific node attributes attr_data = {} @@ -58,9 +65,12 @@ class CollectYetiCache(pyblish.api.InstancePlugin): parent = cmds.listRelatives(shape, parent=True)[0] transform_data = {"name": parent, "cbId": lib.get_id(parent)} - # Store collected data - shape_data["attrs"] = attr_data - shape_data["transform"] = transform_data + shape_data = { + "transform": transform_data, + "name": shape, + "cbId": lib.get_id(shape), + "attrs": attr_data, + } settings["nodes"].append(shape_data) diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py b/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py index bc15edd9e0..df761cde13 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py @@ -119,7 +119,6 @@ class CollectYetiRig(pyblish.api.InstancePlugin): texture_filenames = [] if image_search_paths: - # TODO: Somehow this uses OS environment path separator, `:` vs `;` # Later on check whether this is pipeline OS cross-compatible. image_search_paths = [p for p in @@ -130,13 +129,13 @@ class CollectYetiRig(pyblish.api.InstancePlugin): # List all related textures texture_filenames = cmds.pgYetiCommand(node, listTextures=True) - self.log.info("Found %i texture(s)" % len(texture_filenames)) + self.log.debug("Found %i texture(s)" % len(texture_filenames)) # Get all reference nodes reference_nodes = cmds.pgYetiGraph(node, listNodes=True, type="reference") - self.log.info("Found %i reference node(s)" % len(reference_nodes)) + 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 " diff --git a/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py new file mode 100644 index 0000000000..483ae6d9d3 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py @@ -0,0 +1,60 @@ +import maya.api.OpenMaya as om +import maya.api.OpenMayaUI as omui + +import pyblish.api +import tempfile + +from openpype.hosts.maya.api.lib import IS_HEADLESS + + +class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): + """Set instance thumbnail to a screengrab of current active viewport. + + This makes it so that if an instance does not have a thumbnail set yet that + it will get a thumbnail of the currently active view at the time of + publishing as a fallback. + + """ + order = pyblish.api.ExtractorOrder + 0.49 + label = "Active View Thumbnail" + families = ["workfile"] + hosts = ["maya"] + + def process(self, instance): + if IS_HEADLESS: + self.log.debug( + "Skip extraction of active view thumbnail, due to being in" + "headless mode." + ) + return + + thumbnail = instance.data.get("thumbnailPath") + if not thumbnail: + view_thumbnail = self.get_view_thumbnail(instance) + if not view_thumbnail: + return + + self.log.debug("Setting instance thumbnail path to: {}".format( + view_thumbnail + )) + instance.data["thumbnailPath"] = view_thumbnail + + def get_view_thumbnail(self, instance): + cache_key = "__maya_view_thumbnail" + context = instance.context + + if cache_key not in context.data: + # Generate only a single thumbnail, even for multiple instances + with tempfile.NamedTemporaryFile(suffix="_thumbnail.jpg", + delete=False) as f: + path = f.name + + view = omui.M3dView.active3dView() + image = om.MImage() + view.readColorBuffer(image, True) + image.writeToFile(path, "jpg") + self.log.debug("Generated thumbnail: {}".format(path)) + + context.data["cleanupFullPaths"].append(path) + context.data[cache_key] = path + return context.data[cache_key] diff --git a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py index 102f0e46a2..46cc9090bb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py +++ b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py @@ -100,7 +100,7 @@ class ExtractArnoldSceneSource(publish.Extractor): instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance {} to: {}".format(instance.name, staging_dir) ) @@ -126,7 +126,7 @@ class ExtractArnoldSceneSource(publish.Extractor): instance.data["representations"].append(representation) def _extract(self, nodes, attribute_data, kwargs): - self.log.info( + self.log.debug( "Writing {} with:\n{}".format(kwargs["filename"], kwargs) ) filenames = [] @@ -180,12 +180,12 @@ class ExtractArnoldSceneSource(publish.Extractor): with lib.attribute_values(attribute_data): with lib.maintained_selection(): - self.log.info( + self.log.debug( "Writing: {}".format(duplicate_nodes) ) cmds.select(duplicate_nodes, noExpand=True) - self.log.info( + self.log.debug( "Extracting ass sequence with: {}".format(kwargs) ) @@ -194,6 +194,6 @@ class ExtractArnoldSceneSource(publish.Extractor): for file in exported_files: filenames.append(os.path.split(file)[1]) - self.log.info("Exported: {}".format(filenames)) + self.log.debug("Exported: {}".format(filenames)) return filenames, nodes_by_id diff --git a/openpype/hosts/maya/plugins/publish/extract_assembly.py b/openpype/hosts/maya/plugins/publish/extract_assembly.py index 9b2978d192..86ffdcef24 100644 --- a/openpype/hosts/maya/plugins/publish/extract_assembly.py +++ b/openpype/hosts/maya/plugins/publish/extract_assembly.py @@ -27,7 +27,7 @@ class ExtractAssembly(publish.Extractor): json_filename = "{}.json".format(instance.name) json_path = os.path.join(staging_dir, json_filename) - self.log.info("Dumping scene data for debugging ..") + self.log.debug("Dumping scene data for debugging ..") with open(json_path, "w") as filepath: json.dump(instance.data["scenedata"], filepath, ensure_ascii=False) diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index aa445a0387..43803743bc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -6,17 +6,21 @@ from openpype.pipeline import publish from openpype.hosts.maya.api import lib -class ExtractCameraAlembic(publish.Extractor): +class ExtractCameraAlembic(publish.Extractor, + publish.OptionalPyblishPluginMixin): """Extract a Camera as Alembic. - The cameras gets baked to world space by default. Only when the instance's + The camera gets baked to world space by default. Only when the instance's `bakeToWorldSpace` is set to False it will include its full hierarchy. + 'camera' family expects only single camera, if multiple cameras are needed, + 'matchmove' is better choice. + """ - label = "Camera (Alembic)" + label = "Extract Camera (Alembic)" hosts = ["maya"] - families = ["camera"] + families = ["camera", "matchmove"] bake_attributes = [] def process(self, instance): @@ -35,10 +39,11 @@ class ExtractCameraAlembic(publish.Extractor): # validate required settings assert isinstance(step, float), "Step must be a float value" - camera = cameras[0] # Define extract output file path dir_path = self.staging_dir(instance) + if not os.path.exists(dir_path): + os.makedirs(dir_path) filename = "{0}.abc".format(instance.name) path = os.path.join(dir_path, filename) @@ -64,9 +69,10 @@ class ExtractCameraAlembic(publish.Extractor): # if baked, drop the camera hierarchy to maintain # clean output and backwards compatibility - camera_root = cmds.listRelatives( - camera, parent=True, fullPath=True)[0] - job_str += ' -root {0}'.format(camera_root) + camera_roots = cmds.listRelatives( + cameras, parent=True, fullPath=True) + for camera_root in camera_roots: + job_str += ' -root {0}'.format(camera_root) for member in members: descendants = cmds.listRelatives(member, @@ -94,7 +100,7 @@ class ExtractCameraAlembic(publish.Extractor): "Attributes to bake must be specified as a list" ) for attr in self.bake_attributes: - self.log.info("Adding {} attribute".format(attr)) + self.log.debug("Adding {} attribute".format(attr)) job_str += " -attr {0}".format(attr) with lib.evaluation("off"): @@ -112,5 +118,5 @@ class ExtractCameraAlembic(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 30e6b89f2f..38cf00bbdd 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -2,11 +2,15 @@ """Extract camera as Maya Scene.""" import os import itertools +import contextlib from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api import lib +from openpype.lib import ( + BoolDef +) def massage_ma_file(path): @@ -78,7 +82,8 @@ def unlock(plug): cmds.disconnectAttr(source, destination) -class ExtractCameraMayaScene(publish.Extractor): +class ExtractCameraMayaScene(publish.Extractor, + publish.OptionalPyblishPluginMixin): """Extract a Camera as Maya Scene. This will create a duplicate of the camera that will be baked *with* @@ -88,17 +93,22 @@ class ExtractCameraMayaScene(publish.Extractor): The cameras gets baked to world space by default. Only when the instance's `bakeToWorldSpace` is set to False it will include its full hierarchy. + 'camera' family expects only single camera, if multiple cameras are needed, + 'matchmove' is better choice. + Note: The extracted Maya ascii file gets "massaged" removing the uuid values so they are valid for older versions of Fusion (e.g. 6.4) """ - label = "Camera (Maya Scene)" + label = "Extract Camera (Maya Scene)" hosts = ["maya"] - families = ["camera"] + families = ["camera", "matchmove"] scene_type = "ma" + keep_image_planes = True + def process(self, instance): """Plugin entry point.""" # get settings @@ -111,7 +121,7 @@ class ExtractCameraMayaScene(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -131,15 +141,15 @@ class ExtractCameraMayaScene(publish.Extractor): "bake to world space is ignored...") # get cameras - members = cmds.ls(instance.data['setMembers'], leaf=True, shapes=True, - long=True, dag=True) - cameras = cmds.ls(members, leaf=True, shapes=True, long=True, - dag=True, type="camera") + members = set(cmds.ls(instance.data['setMembers'], leaf=True, + shapes=True, long=True, dag=True)) + cameras = set(cmds.ls(members, leaf=True, shapes=True, long=True, + dag=True, type="camera")) # validate required settings assert isinstance(step, float), "Step must be a float value" - camera = cameras[0] - transform = cmds.listRelatives(camera, parent=True, fullPath=True) + transforms = cmds.listRelatives(list(cameras), + parent=True, fullPath=True) # Define extract output file path dir_path = self.staging_dir(instance) @@ -151,23 +161,21 @@ class ExtractCameraMayaScene(publish.Extractor): with lib.evaluation("off"): with lib.suspended_refresh(): if bake_to_worldspace: - self.log.info( - "Performing camera bakes: {}".format(transform)) baked = lib.bake_to_world_space( - transform, + transforms, frame_range=[start, end], step=step ) - baked_camera_shapes = cmds.ls(baked, - type="camera", - dag=True, - shapes=True, - long=True) + baked_camera_shapes = set(cmds.ls(baked, + type="camera", + dag=True, + shapes=True, + long=True)) - members = members + baked_camera_shapes - members.remove(camera) + members.update(baked_camera_shapes) + members.difference_update(cameras) else: - baked_camera_shapes = cmds.ls(cameras, + baked_camera_shapes = cmds.ls(list(cameras), type="camera", dag=True, shapes=True, @@ -186,19 +194,28 @@ class ExtractCameraMayaScene(publish.Extractor): unlock(plug) cmds.setAttr(plug, value) - self.log.info("Performing extraction..") - cmds.select(cmds.ls(members, dag=True, - shapes=True, long=True), noExpand=True) - cmds.file(path, - force=True, - typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 - exportSelected=True, - preserveReferences=False, - constructionHistory=False, - channels=True, # allow animation - constraints=False, - shader=False, - expressions=False) + attr_values = self.get_attr_values_from_data( + instance.data) + keep_image_planes = attr_values.get("keep_image_planes") + + with transfer_image_planes(sorted(cameras), + sorted(baked_camera_shapes), + keep_image_planes): + + self.log.info("Performing extraction..") + cmds.select(cmds.ls(list(members), dag=True, + shapes=True, long=True), + noExpand=True) + cmds.file(path, + force=True, + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 + exportSelected=True, + preserveReferences=False, + constructionHistory=False, + channels=True, # allow animation + constraints=False, + shader=False, + expressions=False) # Delete the baked hierarchy if bake_to_worldspace: @@ -217,5 +234,64 @@ class ExtractCameraMayaScene(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) + + @classmethod + def get_attribute_defs(cls): + defs = super(ExtractCameraMayaScene, cls).get_attribute_defs() + + defs.extend([ + BoolDef("keep_image_planes", + label="Keep Image Planes", + tooltip="Preserving connected image planes on camera", + default=cls.keep_image_planes), + + ]) + + return defs + + +@contextlib.contextmanager +def transfer_image_planes(source_cameras, target_cameras, + keep_input_connections): + """Reattaches image planes to baked or original cameras. + + Baked cameras are duplicates of original ones. + This attaches it to duplicated camera properly and after + export it reattaches it back to original to keep image plane in workfile. + """ + originals = {} + try: + for source_camera, target_camera in zip(source_cameras, + target_cameras): + image_planes = cmds.listConnections(source_camera, + type="imagePlane") or [] + + # Split of the parent path they are attached - we want + # the image plane node name. + # TODO: Does this still mean the image plane name is unique? + image_planes = [x.split("->", 1)[1] for x in image_planes] + + if not image_planes: + continue + + originals[source_camera] = [] + for image_plane in image_planes: + if keep_input_connections: + if source_camera == target_camera: + continue + _attach_image_plane(target_camera, image_plane) + else: # explicitly dettaching image planes + cmds.imagePlane(image_plane, edit=True, detach=True) + originals[source_camera].append(image_plane) + yield + finally: + for camera, image_planes in originals.items(): + for image_plane in image_planes: + _attach_image_plane(camera, image_plane) + + +def _attach_image_plane(camera, image_plane): + cmds.imagePlane(image_plane, edit=True, detach=True) + cmds.imagePlane(image_plane, edit=True, camera=camera) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index 9af3acef65..4f7eaf57bf 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -33,11 +33,11 @@ class ExtractFBX(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) + self.log.debug("Extracting FBX to: {0}".format(path)) members = instance.data["setMembers"] - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) @@ -58,4 +58,4 @@ class ExtractFBX(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py new file mode 100644 index 0000000000..756158d4f0 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +import os + +from maya import cmds # noqa +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.maya.api import fbx +from openpype.hosts.maya.api.lib import ( + namespaced, get_namespace, strip_namespace +) + + +class ExtractFBXAnimation(publish.Extractor): + """Extract Rig in FBX format from Maya. + + This extracts the rig in fbx with the constraints + and referenced asset content included. + This also optionally extract animated rig in fbx with + geometries included. + + """ + order = pyblish.api.ExtractorOrder + label = "Extract Animation (FBX)" + hosts = ["maya"] + families = ["animation.fbx"] + + def process(self, instance): + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + path = path.replace("\\", "/") + + fbx_exporter = fbx.FBXExtractor(log=self.log) + out_members = instance.data.get("animated_skeleton", []) + # Export + instance.data["constraints"] = True + instance.data["skeletonDefinitions"] = True + instance.data["referencedAssetsContent"] = True + fbx_exporter.set_options_from_instance(instance) + # Export from the rig's namespace so that the exported + # FBX does not include the namespace but preserves the node + # names as existing in the rig workfile + if not out_members: + skeleton_set = [ + i for i in instance + if i.endswith("skeletonAnim_SET") + ] + self.log.debug( + "Top group of animated skeleton not found in " + "{}.\nSkipping fbx animation extraction.".format(skeleton_set)) + return + + namespace = get_namespace(out_members[0]) + relative_out_members = [ + strip_namespace(node, namespace) for node in out_members + ] + with namespaced( + ":" + namespace, + new=False, + relative_names=True + ) as namespace: + fbx_exporter.export(relative_out_members, path) + + representations = instance.data.setdefault("representations", []) + representations.append({ + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir + }) + + self.log.debug( + "Extracted FBX animation to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_gltf.py b/openpype/hosts/maya/plugins/publish/extract_gltf.py index ac258ffb3d..6d72d28525 100644 --- a/openpype/hosts/maya/plugins/publish/extract_gltf.py +++ b/openpype/hosts/maya/plugins/publish/extract_gltf.py @@ -20,14 +20,10 @@ class ExtractGLB(publish.Extractor): filename = "{0}.glb".format(instance.name) path = os.path.join(staging_dir, filename) - self.log.info("Extracting GLB to: {}".format(path)) - cmds.loadPlugin("maya2glTF", quiet=True) nodes = instance[:] - self.log.info("Instance: {0}".format(nodes)) - start_frame = instance.data('frameStart') or \ int(cmds.playbackOptions(query=True, animationStartTime=True))# noqa @@ -48,6 +44,7 @@ class ExtractGLB(publish.Extractor): "vno": True # visibleNodeOnly } + self.log.debug("Extracting GLB to: {}".format(path)) with lib.maintained_selection(): cmds.select(nodes, hi=True, noExpand=True) extract_gltf(staging_dir, @@ -65,4 +62,4 @@ class ExtractGLB(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract GLB successful to: {0}".format(path)) + self.log.debug("Extract GLB successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py b/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py index 422f5ad019..16436c6fe4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py @@ -60,6 +60,6 @@ class ExtractGPUCache(publish.Extractor): instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance {} to: {}".format(instance.name, staging_dir) ) diff --git a/openpype/hosts/maya/plugins/publish/extract_import_reference.py b/openpype/hosts/maya/plugins/publish/extract_import_reference.py index 8bb82be9b6..1fdee28d0c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_import_reference.py +++ b/openpype/hosts/maya/plugins/publish/extract_import_reference.py @@ -30,8 +30,8 @@ class ExtractImportReference(publish.Extractor, tmp_format = "_tmp" @classmethod - def apply_settings(cls, project_setting, system_settings): - cls.active = project_setting["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa + def apply_settings(cls, project_settings): + cls.active = project_settings["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa def process(self, instance): if not self.is_active(instance.data): @@ -46,7 +46,7 @@ class ExtractImportReference(publish.Extractor, for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break @@ -69,7 +69,7 @@ class ExtractImportReference(publish.Extractor, reference_path = os.path.join(dir_path, ref_scene_name) tmp_path = os.path.dirname(current_name) + "/" + ref_scene_name - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") # This generates script for mayapy to take care of reference # importing outside current session. It is passing current scene @@ -111,7 +111,7 @@ print("*** Done") # process until handles are closed by context manager. with tempfile.TemporaryDirectory() as tmp_dir_name: tmp_script_path = os.path.join(tmp_dir_name, "import_ref.py") - self.log.info("Using script file: {}".format(tmp_script_path)) + self.log.debug("Using script file: {}".format(tmp_script_path)) with open(tmp_script_path, "wt") as tmp: tmp.write(script) @@ -149,9 +149,9 @@ print("*** Done") "stagingDir": os.path.dirname(current_name), "outputName": "imported" } - self.log.info("%s" % ref_representation) + self.log.debug(ref_representation) instance.data["representations"].append(ref_representation) - self.log.info("Extracted instance '%s' to : '%s'" % (ref_scene_name, - reference_path)) + self.log.debug("Extracted instance '%s' to : '%s'" % (ref_scene_name, + reference_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_layout.py b/openpype/hosts/maya/plugins/publish/extract_layout.py index bf5b4fc0e7..75920b44a2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_layout.py +++ b/openpype/hosts/maya/plugins/publish/extract_layout.py @@ -23,7 +23,7 @@ class ExtractLayout(publish.Extractor): stagingdir = self.staging_dir(instance) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") if "representations" not in instance.data: instance.data["representations"] = [] @@ -64,7 +64,7 @@ class ExtractLayout(publish.Extractor): fields=["parent", "context.family"] ) - self.log.info(representation) + self.log.debug(representation) version_id = representation.get("parent") family = representation.get("context").get("family") @@ -159,5 +159,5 @@ class ExtractLayout(publish.Extractor): } instance.data["representations"].append(json_representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, json_representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, json_representation) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index b13568c781..635c2c425c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- """Maya look extractor.""" +import sys from abc import ABCMeta, abstractmethod from collections import OrderedDict import contextlib import json import logging import os -import platform import tempfile import six import attr @@ -25,6 +25,7 @@ from openpype.lib import ( from openpype.pipeline import legacy_io, publish, KnownPublishError from openpype.hosts.maya.api import lib +from openpype import AYON_SERVER_ENABLED # Modes for transfer COPY = 1 @@ -57,6 +58,12 @@ def find_paths_by_hash(texture_hash): str: path to texture if found. """ + if AYON_SERVER_ENABLED: + raise KnownPublishError( + "This is a bug. \"find_paths_by_hash\" is not compatible with " + "AYON." + ) + key = "data.sourceHashes.{0}".format(texture_hash) return legacy_io.distinct(key, {"type": "version"}) @@ -170,6 +177,24 @@ class MakeRSTexBin(TextureProcessor): source ] + # if color management is enabled we pass color space information + if color_management["enabled"]: + config_path = color_management["config"] + if not os.path.exists(config_path): + raise RuntimeError("OCIO config not found at: " + "{}".format(config_path)) + + if not os.getenv("OCIO"): + self.log.debug( + "OCIO environment variable not set." + "Setting it with OCIO config from Maya." + ) + os.environ["OCIO"] = config_path + + self.log.debug("converting colorspace {0} to redshift render " + "colorspace".format(colorspace)) + subprocess_args.extend(["-cs", colorspace]) + hash_args = ["rstex"] texture_hash = source_hash(source, *hash_args) @@ -180,11 +205,11 @@ class MakeRSTexBin(TextureProcessor): self.log.debug(" ".join(subprocess_args)) try: - run_subprocess(subprocess_args) + run_subprocess(subprocess_args, logger=self.log) except Exception: self.log.error("Texture .rstexbin conversion failed", exc_info=True) - raise + six.reraise(*sys.exc_info()) return TextureResult( path=destination, @@ -307,7 +332,7 @@ class MakeTX(TextureProcessor): render_colorspace = color_management["rendering_space"] - self.log.info("tx: converting colorspace {0} " + self.log.debug("tx: converting colorspace {0} " "-> {1}".format(colorspace, render_colorspace)) args.extend(["--colorconvert", colorspace, render_colorspace]) @@ -331,7 +356,7 @@ class MakeTX(TextureProcessor): if not os.path.exists(resources_dir): os.makedirs(resources_dir) - self.log.info("Generating .tx file for %s .." % source) + self.log.debug("Generating .tx file for %s .." % source) subprocess_args = maketx_args + [ "-v", # verbose @@ -421,7 +446,7 @@ class ExtractLook(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -453,7 +478,7 @@ class ExtractLook(publish.Extractor): relationships = lookdata["relationships"] sets = list(relationships.keys()) if not sets: - self.log.info("No sets found for the look") + self.log.debug("No sets found for the look") return # Specify texture processing executables to activate @@ -466,7 +491,7 @@ class ExtractLook(publish.Extractor): "rstex": MakeRSTexBin }.items(): if instance.data.get(key, False): - processor = Processor() + processor = Processor(log=self.log) processor.apply_settings(context.data["system_settings"], context.data["project_settings"]) processors.append(processor) @@ -485,7 +510,7 @@ class ExtractLook(publish.Extractor): remap = results["attrRemap"] # Extract in correct render layer - self.log.info("Extracting look maya scene file: {}".format(maya_path)) + self.log.debug("Extracting look maya scene file: {}".format(maya_path)) layer = instance.data.get("renderlayer", "defaultRenderLayer") with lib.renderlayer(layer): # TODO: Ensure membership edits don't become renderlayer overrides @@ -511,12 +536,12 @@ class ExtractLook(publish.Extractor): ) # Write the JSON data - self.log.info("Extract json..") data = { "attributes": lookdata["attributes"], "relationships": relationships } + self.log.debug("Extracting json file: {}".format(json_path)) with open(json_path, "w") as f: json.dump(data, f) @@ -557,8 +582,8 @@ class ExtractLook(publish.Extractor): # Source hash for the textures instance.data["sourceHashes"] = hashes - self.log.info("Extracted instance '%s' to: %s" % (instance.name, - maya_path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + maya_path)) def _set_resource_result_colorspace(self, resource, colorspace): """Update resource resulting colorspace after texture processing""" @@ -589,14 +614,19 @@ class ExtractLook(publish.Extractor): resources = instance.data["resources"] color_management = lib.get_color_management_preferences() - # Temporary fix to NOT create hardlinks on windows machines - if platform.system().lower() == "windows": - self.log.info( + # TODO: Temporary disable all hardlinking, due to the feature not being + # used or properly working. + self.log.info( + "Forcing copy instead of hardlink." + ) + force_copy = True + + if not force_copy and platform.system().lower() == "windows": + # Temporary fix to NOT create hardlinks on windows machines + self.log.warning( "Forcing copy instead of hardlink due to issues on Windows..." ) force_copy = True - else: - force_copy = instance.data.get("forceCopy", False) destinations_cache = {} @@ -671,11 +701,11 @@ class ExtractLook(publish.Extractor): destination = get_resource_destination_cached(source) if force_copy or texture_result.transfer_mode == COPY: transfers.append((source, destination)) - self.log.info('file will be copied {} -> {}'.format( + self.log.debug('file will be copied {} -> {}'.format( source, destination)) elif texture_result.transfer_mode == HARDLINK: hardlinks.append((source, destination)) - self.log.info('file will be hardlinked {} -> {}'.format( + self.log.debug('file will be hardlinked {} -> {}'.format( source, destination)) # Store the hashes from hash to destination to include in the @@ -707,7 +737,7 @@ class ExtractLook(publish.Extractor): color_space_attr = "{}.colorSpace".format(node) remap[color_space_attr] = resource["result_color_space"] - self.log.info("Finished remapping destinations ...") + self.log.debug("Finished remapping destinations ...") return { "fileTransfers": transfers, @@ -815,8 +845,8 @@ class ExtractLook(publish.Extractor): if not processed_result: raise RuntimeError("Texture Processor {} returned " "no result.".format(processor)) - self.log.info("Generated processed " - "texture: {}".format(processed_result.path)) + self.log.debug("Generated processed " + "texture: {}".format(processed_result.path)) # TODO: Currently all processors force copy instead of allowing # hardlinks using source hashes. This should be refactored @@ -827,7 +857,7 @@ class ExtractLook(publish.Extractor): if not force_copy: existing = self._get_existing_hashed_texture(filepath) if existing: - self.log.info("Found hash in database, preparing hardlink..") + self.log.debug("Found hash in database, preparing hardlink..") return TextureResult( path=filepath, file_hash=texture_hash, 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 d87d6c208a..ab170fe48c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -34,7 +34,7 @@ class ExtractMayaSceneRaw(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -63,7 +63,7 @@ class ExtractMayaSceneRaw(publish.Extractor): selection += self._get_loaded_containers(members) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): cmds.select(selection, noExpand=True) cmds.file(path, @@ -87,7 +87,8 @@ class ExtractMayaSceneRaw(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + path)) @staticmethod def _get_loaded_containers(members): diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py new file mode 100644 index 0000000000..8c32ac1e39 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py @@ -0,0 +1,293 @@ +import os +import six +import json +import contextlib + +from maya import cmds + +import pyblish.api +from openpype.pipeline import publish +from openpype.hosts.maya.api.lib import maintained_selection + + +@contextlib.contextmanager +def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None): + """Define attributes for the given nodes that should be exported. + + MayaUSDExport will export custom attributes if the Maya node has a + string attribute `USD_UserExportedAttributesJson` that provides an + export mapping for the maya attributes. This context manager will try + to autogenerate such an attribute during the export to include attributes + for the export. + + Arguments: + nodes (List[str]): Nodes to process. + attrs (Optional[List[str]]): Full name of attributes to include. + attr_prefixes (Optional[List[str]]): Prefixes of attributes to include. + mapping (Optional[Dict[Dict]]): A mapping per attribute name for the + conversion to a USD attribute, including renaming, defining type, + converting attribute precision, etc. This match the usual + `USD_UserExportedAttributesJson` json mapping of `mayaUSDExport`. + When no mapping provided for an attribute it will use `{}` as + value. + + Examples: + >>> with usd_export_attributes( + >>> ["pCube1"], attrs="myDoubleAttributeAsFloat", mapping={ + >>> "myDoubleAttributeAsFloat": { + >>> "usdAttrName": "my:namespace:attrib", + >>> "translateMayaDoubleToUsdSinglePrecision": True, + >>> } + >>> }) + + """ + # todo: this might be better done with a custom export chaser + # see `chaser` argument for `mayaUSDExport` + + import maya.api.OpenMaya as om + + if not attrs and not attr_prefixes: + # context manager does nothing + yield + return + + if attrs is None: + attrs = [] + if attr_prefixes is None: + attr_prefixes = [] + if mapping is None: + mapping = {} + + usd_json_attr = "USD_UserExportedAttributesJson" + strings = attrs + ["{}*".format(prefix) for prefix in attr_prefixes] + context_state = {} + for node in set(nodes): + node_attrs = cmds.listAttr(node, st=strings) + if not node_attrs: + # Nothing to do for this node + continue + + node_attr_data = {} + for node_attr in set(node_attrs): + node_attr_data[node_attr] = mapping.get(node_attr, {}) + + if cmds.attributeQuery(usd_json_attr, node=node, exists=True): + existing_node_attr_value = cmds.getAttr( + "{}.{}".format(node, usd_json_attr) + ) + if existing_node_attr_value and existing_node_attr_value != "{}": + # Any existing attribute mappings in an existing + # `USD_UserExportedAttributesJson` attribute always take + # precedence over what this function tries to imprint + existing_node_attr_data = json.loads(existing_node_attr_value) + node_attr_data.update(existing_node_attr_data) + + context_state[node] = json.dumps(node_attr_data) + + sel = om.MSelectionList() + dg_mod = om.MDGModifier() + fn_string = om.MFnStringData() + fn_typed = om.MFnTypedAttribute() + try: + for node, value in context_state.items(): + data = fn_string.create(value) + sel.clear() + if cmds.attributeQuery(usd_json_attr, node=node, exists=True): + # Set the attribute value + sel.add("{}.{}".format(node, usd_json_attr)) + plug = sel.getPlug(0) + dg_mod.newPlugValue(plug, data) + else: + # Create attribute with the value as default value + sel.add(node) + node_obj = sel.getDependNode(0) + attr_obj = fn_typed.create(usd_json_attr, + usd_json_attr, + om.MFnData.kString, + data) + dg_mod.addAttribute(node_obj, attr_obj) + dg_mod.doIt() + yield + finally: + dg_mod.undoIt() + + +class ExtractMayaUsd(publish.Extractor): + """Extractor for Maya USD Asset data. + + Upon publish a .usd (or .usdz) asset file will typically be written. + """ + + label = "Extract Maya USD Asset" + hosts = ["maya"] + families = ["mayaUsd"] + + @property + def options(self): + """Overridable options for Maya USD Export + + Given in the following format + - {NAME: EXPECTED TYPE} + + If the overridden option's type does not match, + the option is not included and a warning is logged. + + """ + + # TODO: Support more `mayaUSDExport` parameters + return { + "defaultUSDFormat": str, + "stripNamespaces": bool, + "mergeTransformAndShape": bool, + "exportDisplayColor": bool, + "exportColorSets": bool, + "exportInstances": bool, + "exportUVs": bool, + "exportVisibility": bool, + "exportComponentTags": bool, + "exportRefsAsInstanceable": bool, + "eulerFilter": bool, + "renderableOnly": bool, + "jobContext": (list, None) # optional list + # "worldspace": bool, + } + + @property + def default_options(self): + """The default options for Maya USD Export.""" + + # TODO: Support more `mayaUSDExport` parameters + return { + "defaultUSDFormat": "usdc", + "stripNamespaces": False, + "mergeTransformAndShape": False, + "exportDisplayColor": False, + "exportColorSets": True, + "exportInstances": True, + "exportUVs": True, + "exportVisibility": True, + "exportComponentTags": True, + "exportRefsAsInstanceable": False, + "eulerFilter": True, + "renderableOnly": False, + "jobContext": None + # "worldspace": False + } + + def parse_overrides(self, instance, options): + """Inspect data of instance to determine overridden options""" + + for key in instance.data: + if key not in self.options: + continue + + # Ensure the data is of correct type + value = instance.data[key] + if isinstance(value, six.text_type): + value = str(value) + if not isinstance(value, self.options[key]): + self.log.warning( + "Overridden attribute {key} was of " + "the wrong type: {invalid_type} " + "- should have been {valid_type}".format( + key=key, + invalid_type=type(value).__name__, + valid_type=self.options[key].__name__)) + continue + + options[key] = value + + return options + + def filter_members(self, members): + # Can be overridden by inherited classes + return members + + def process(self, instance): + + # Load plugin first + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + # Define output file path + staging_dir = self.staging_dir(instance) + file_name = "{0}.usd".format(instance.name) + file_path = os.path.join(staging_dir, file_name) + file_path = file_path.replace('\\', '/') + + # Parse export options + options = self.default_options + options = self.parse_overrides(instance, options) + self.log.debug("Export options: {0}".format(options)) + + # Perform extraction + self.log.debug("Performing extraction ...") + + members = instance.data("setMembers") + self.log.debug('Collected objects: {}'.format(members)) + members = self.filter_members(members) + if not members: + self.log.error('No members!') + return + + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + + def parse_attr_str(attr_str): + result = list() + for attr in attr_str.split(","): + attr = attr.strip() + if not attr: + continue + result.append(attr) + return result + + attrs = parse_attr_str(instance.data.get("attr", "")) + attrs += instance.data.get("userDefinedAttributes", []) + attrs += ["cbId"] + attr_prefixes = parse_attr_str(instance.data.get("attrPrefix", "")) + + self.log.debug('Exporting USD: {} / {}'.format(file_path, members)) + with maintained_selection(): + with usd_export_attributes(instance[:], + attrs=attrs, + attr_prefixes=attr_prefixes): + cmds.mayaUSDExport(file=file_path, + frameRange=(start, end), + frameStride=instance.data.get("step", 1.0), + exportRoots=members, + **options) + + representation = { + 'name': "usd", + 'ext': "usd", + 'files': file_name, + 'stagingDir': staging_dir + } + instance.data.setdefault("representations", []).append(representation) + + self.log.debug( + "Extracted instance {} to {}".format(instance.name, file_path) + ) + + +class ExtractMayaUsdAnim(ExtractMayaUsd): + """Extractor for Maya USD Animation Sparse Cache data. + + This will extract the sparse cache data from the scene and generate a + USD file with all the animation data. + + Upon publish a .usd sparse cache will be written. + """ + label = "Extract Maya USD Animation Sparse Cache" + families = ["animation", "mayaUsd"] + match = pyblish.api.Subset + + def filter_members(self, members): + out_set = next((i for i in members if i.endswith("out_SET")), None) + + if out_set is None: + self.log.warning("Expecting out_SET") + return None + + members = cmds.ls(cmds.sets(out_set, query=True), long=True) + return members diff --git a/openpype/hosts/maya/plugins/publish/extract_model.py b/openpype/hosts/maya/plugins/publish/extract_model.py index 5137dffd94..29c952ebbc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_model.py +++ b/openpype/hosts/maya/plugins/publish/extract_model.py @@ -44,7 +44,7 @@ class ExtractModel(publish.Extractor, for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -56,7 +56,7 @@ class ExtractModel(publish.Extractor, path = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") # Get only the shape contents we need in such a way that we avoid # taking along intermediateObjects @@ -102,4 +102,5 @@ class ExtractModel(publish.Extractor, } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py index 6fe7cf0d55..c2bebeaee6 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py @@ -101,10 +101,10 @@ class ExtractMultiverseLook(publish.Extractor): # Parse export options options = self.default_options - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") @@ -114,7 +114,7 @@ class ExtractMultiverseLook(publish.Extractor): type="mvUsdCompoundShape", noIntermediate=True, long=True) - self.log.info('Collected object {}'.format(members)) + self.log.debug('Collected object {}'.format(members)) if len(members) > 1: self.log.error('More than one member: {}'.format(members)) @@ -153,5 +153,5 @@ class ExtractMultiverseLook(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 4399eacda1..60185bb152 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -28,7 +28,7 @@ class ExtractMultiverseUsd(publish.Extractor): label = "Extract Multiverse USD Asset" hosts = ["maya"] - families = ["usd"] + families = ["mvUsd"] scene_type = "usd" file_formats = ["usd", "usda", "usdz"] @@ -150,7 +150,6 @@ class ExtractMultiverseUsd(publish.Extractor): return options def get_default_options(self): - self.log.info("ExtractMultiverseUsd get_default_options") return self.default_options def filter_members(self, members): @@ -173,19 +172,19 @@ class ExtractMultiverseUsd(publish.Extractor): # Parse export options options = self.get_default_options() options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") - self.log.info('Collected objects: {}'.format(members)) + self.log.debug('Collected objects: {}'.format(members)) members = self.filter_members(members) if not members: self.log.error('No members!') return - self.log.info(' - filtered: {}'.format(members)) + self.log.debug(' - filtered: {}'.format(members)) import multiverse @@ -229,7 +228,7 @@ class ExtractMultiverseUsd(publish.Extractor): self.log.debug(" - {}={}".format(key, value)) setattr(asset_write_opts, key, value) - self.log.info('WriteAsset: {} / {}'.format(file_path, members)) + self.log.debug('WriteAsset: {} / {}'.format(file_path, members)) multiverse.WriteAsset(file_path, members, asset_write_opts) if "representations" not in instance.data: @@ -243,7 +242,7 @@ class ExtractMultiverseUsd(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index a62729c198..7966c4fa93 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -105,14 +105,14 @@ class ExtractMultiverseUsdComposition(publish.Extractor): # Parse export options options = self.default_options options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") - self.log.info('Collected object {}'.format(members)) + self.log.debug('Collected object {}'.format(members)) import multiverse @@ -175,5 +175,5 @@ class ExtractMultiverseUsdComposition(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( - instance.name, file_path)) + self.log.debug("Extracted instance {} to {}".format(instance.name, + file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index cf610ac6b4..e4a97db6e4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -87,10 +87,10 @@ class ExtractMultiverseUsdOverride(publish.Extractor): # Parse export options options = self.default_options - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") @@ -100,7 +100,7 @@ class ExtractMultiverseUsdOverride(publish.Extractor): type="mvUsdCompoundShape", noIntermediate=True, long=True) - self.log.info("Collected object {}".format(members)) + self.log.debug("Collected object {}".format(members)) # TODO: Deal with asset, composition, override with options. import multiverse @@ -153,5 +153,5 @@ class ExtractMultiverseUsdOverride(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_obj.py b/openpype/hosts/maya/plugins/publish/extract_obj.py index 518b0f0ff8..ca94130d09 100644 --- a/openpype/hosts/maya/plugins/publish/extract_obj.py +++ b/openpype/hosts/maya/plugins/publish/extract_obj.py @@ -30,7 +30,7 @@ class ExtractObj(publish.Extractor): # The export requires forward slashes because we need to # format it into a string in a mel expression - self.log.info("Extracting OBJ to: {0}".format(path)) + self.log.debug("Extracting OBJ to: {0}".format(path)) members = instance.data("setMembers") members = cmds.ls(members, @@ -39,8 +39,8 @@ class ExtractObj(publish.Extractor): type=("mesh", "nurbsCurve"), noIntermediate=True, long=True) - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) if not cmds.pluginInfo('objExport', query=True, loaded=True): cmds.loadPlugin('objExport') @@ -74,4 +74,4 @@ class ExtractObj(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract OBJ successful to: {0}".format(path)) + self.log.debug("Extract OBJ successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 9580c13841..cfab239da3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -48,7 +48,7 @@ class ExtractPlayblast(publish.Extractor): self.log.debug("playblast path {}".format(path)) def process(self, instance): - self.log.info("Extracting capture..") + self.log.debug("Extracting capture..") # get scene fps fps = instance.data.get("fps") or instance.context.data.get("fps") @@ -62,7 +62,7 @@ class ExtractPlayblast(publish.Extractor): if end is None: end = cmds.playbackOptions(query=True, animationEndTime=True) - self.log.info("start: {}, end: {}".format(start, end)) + self.log.debug("start: {}, end: {}".format(start, end)) # get cameras camera = instance.data["review_camera"] @@ -119,7 +119,7 @@ class ExtractPlayblast(publish.Extractor): filename = "{0}".format(instance.name) path = os.path.join(stagingdir, filename) - self.log.info("Outputting images to %s" % path) + self.log.debug("Outputting images to %s" % path) preset["filename"] = path preset["overwrite"] = True @@ -237,7 +237,7 @@ class ExtractPlayblast(publish.Extractor): self.log.debug("collection head {}".format(filebase)) if filebase in filename: frame_collection = collection - self.log.info( + self.log.debug( "we found collection of interest {}".format( str(frame_collection))) diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index 9537a11ee4..0cc802fa7a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -107,13 +107,14 @@ class ExtractAlembic(publish.Extractor): } instance.data["representations"].append(representation) - instance.context.data["cleanupFullPaths"].append(path) + if not instance.data.get("stagingDir_persistent", False): + instance.context.data["cleanupFullPaths"].append(path) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) # Extract proxy. if not instance.data.get("proxy"): - self.log.info("No proxy nodes found. Skipping proxy extraction.") + self.log.debug("No proxy nodes found. Skipping proxy extraction.") return path = path.replace(".abc", "_proxy.abc") diff --git a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py index cf6351fdca..d9bec87cfd 100644 --- a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py +++ b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py @@ -32,7 +32,7 @@ class ExtractProxyAlembic(publish.Extractor): attr_prefixes = instance.data.get("attrPrefix", "").split(";") attr_prefixes = [value for value in attr_prefixes if value.strip()] - self.log.info("Extracting Proxy Alembic..") + self.log.debug("Extracting Proxy Alembic..") dirname = self.staging_dir(instance) filename = "{name}.abc".format(**instance.data) @@ -80,9 +80,10 @@ class ExtractProxyAlembic(publish.Extractor): } instance.data["representations"].append(representation) - instance.context.data["cleanupFullPaths"].append(path) + if not instance.data.get("stagingDir_persistent", False): + instance.context.data["cleanupFullPaths"].append(path) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) # remove the bounding box bbox_master = cmds.ls("bbox_grp") cmds.delete(bbox_master) diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py index 834b335fc5..7fc8760a70 100644 --- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -50,16 +50,16 @@ class ExtractRedshiftProxy(publish.Extractor): # Padding is taken from number of digits of the end_frame. # Not sure where Redshift is taking it. repr_files = [ - "{}.{}{}".format(root, str(frame).rjust(4, "0"), ext) # noqa: E501 + "{}.{}{}".format(os.path.basename(root), str(frame).rjust(4, "0"), ext) # noqa: E501 for frame in range( int(start_frame), int(end_frame) + 1, - int(instance.data["step"]), + int(instance.data["step"]) )] # vertex_colors = instance.data.get("vertexColors", False) # Write out rs file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.file(file_path, @@ -82,5 +82,5 @@ class ExtractRedshiftProxy(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) diff --git a/openpype/hosts/maya/plugins/publish/extract_rendersetup.py b/openpype/hosts/maya/plugins/publish/extract_rendersetup.py index 5970c038a4..7e21f5282e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rendersetup.py +++ b/openpype/hosts/maya/plugins/publish/extract_rendersetup.py @@ -37,5 +37,5 @@ class ExtractRenderSetup(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance '%s' to: %s" % (instance.name, json_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig.py b/openpype/hosts/maya/plugins/publish/extract_rig.py index be57b9de07..1ffc9a7dae 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig.py @@ -27,7 +27,7 @@ class ExtractRig(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using '.{}' as scene type".format(self.scene_type)) break except AttributeError: @@ -39,7 +39,7 @@ class ExtractRig(publish.Extractor): path = os.path.join(dir_path, filename) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): cmds.select(instance, noExpand=True) cmds.file(path, @@ -63,4 +63,4 @@ class ExtractRig(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s", instance.name, path) diff --git a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py new file mode 100644 index 0000000000..50c1fb3bde --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +import os + +from maya import cmds # noqa +import pyblish.api + +from openpype.pipeline import publish +from openpype.pipeline.publish import OptionalPyblishPluginMixin +from openpype.hosts.maya.api import fbx + + +class ExtractSkeletonMesh(publish.Extractor, + OptionalPyblishPluginMixin): + """Extract Rig in FBX format from Maya. + + This extracts the rig in fbx with the constraints + and referenced asset content included. + This also optionally extract animated rig in fbx with + geometries included. + + """ + order = pyblish.api.ExtractorOrder + label = "Extract Skeleton Mesh" + hosts = ["maya"] + families = ["rig.fbx"] + + def process(self, instance): + if not self.is_active(instance.data): + return + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + + fbx_exporter = fbx.FBXExtractor(log=self.log) + out_set = instance.data.get("skeleton_mesh", []) + + instance.data["constraints"] = True + instance.data["skeletonDefinitions"] = True + + fbx_exporter.set_options_from_instance(instance) + + # Export + fbx_exporter.export(out_set, path) + + representations = instance.data.setdefault("representations", []) + representations.append({ + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir + }) + + self.log.debug("Extract FBX to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 4160ac4cb2..c0be3d77db 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -24,7 +24,7 @@ class ExtractThumbnail(publish.Extractor): families = ["review"] def process(self, instance): - self.log.info("Extracting capture..") + self.log.debug("Extracting capture..") camera = instance.data["review_camera"] @@ -92,11 +92,10 @@ class ExtractThumbnail(publish.Extractor): "Create temp directory {} for thumbnail".format(dst_staging) ) # Store new staging to cleanup paths - instance.context.data["cleanupFullPaths"].append(dst_staging) filename = "{0}".format(instance.name) path = os.path.join(dst_staging, filename) - self.log.info("Outputting images to %s" % path) + self.log.debug("Outputting images to %s" % path) preset["filename"] = path preset["overwrite"] = True @@ -159,7 +158,7 @@ class ExtractThumbnail(publish.Extractor): _, thumbnail = os.path.split(playblast) - self.log.info("file list {}".format(thumbnail)) + self.log.debug("file list {}".format(thumbnail)) if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py index 4a797eb462..780ed2377c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py @@ -13,16 +13,6 @@ from openpype.hosts.maya.api.lib import ( ) -@contextmanager -def renamed(original_name, renamed_name): - # type: (str, str) -> None - try: - cmds.rename(original_name, renamed_name) - yield - finally: - cmds.rename(renamed_name, original_name) - - class ExtractUnrealSkeletalMeshAbc(publish.Extractor): """Extract Unreal Skeletal Mesh as FBX from Maya. """ @@ -57,9 +47,9 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting ABC to: {0}".format(path)) - self.log.info("Members: {0}".format(nodes)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting ABC to: {0}".format(path)) + self.log.debug("Members: {0}".format(nodes)) + self.log.debug("Instance: {0}".format(instance[:])) options = { "step": instance.data.get("step", 1.0), @@ -74,7 +64,7 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): "worldSpace": instance.data.get("worldSpace", True) } - self.log.info("Options: {}".format(options)) + self.log.debug("Options: {}".format(options)) if int(cmds.about(version=True)) >= 2017: # Since Maya 2017 alembic supports multiple uv sets - write them. @@ -105,4 +95,4 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract ABC successful to: {0}".format(path)) + self.log.debug("Extract ABC successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py index b162ce47f7..4b36134694 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py @@ -46,9 +46,9 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) - self.log.info("Members: {0}".format(to_extract)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting FBX to: {0}".format(path)) + self.log.debug("Members: {0}".format(to_extract)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) @@ -62,6 +62,10 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): original_parent = to_extract[0].split("|")[1] parent_node = instance.data.get("asset") + # this needs to be done for AYON + # WARNING: since AYON supports duplicity of asset names, + # this needs to be refactored throughout the pipeline. + parent_node = parent_node.split("/")[-1] renamed_to_extract = [] for node in to_extract: @@ -70,7 +74,7 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): renamed_to_extract.append("|".join(node_path)) with renamed(original_parent, parent_node): - self.log.info("Extracting: {}".format(renamed_to_extract, path)) + self.log.debug("Extracting: {}".format(renamed_to_extract, path)) fbx_exporter.export(renamed_to_extract, path) if "representations" not in instance.data: @@ -84,4 +88,4 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 44f0615a27..26ab0827e4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -37,15 +37,15 @@ class ExtractUnrealStaticMesh(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting FBX to: {0}".format(path)) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) with maintained_selection(): with parent_nodes(members): - self.log.info("Un-parenting: {}".format(members)) + self.log.debug("Un-parenting: {}".format(members)) fbx_exporter.export(members, path) if "representations" not in instance.data: @@ -59,4 +59,4 @@ class ExtractUnrealStaticMesh(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py new file mode 100644 index 0000000000..963285093e --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py @@ -0,0 +1,61 @@ +import os + +from maya import cmds + +from openpype.pipeline import publish + + +class ExtractYetiCache(publish.Extractor): + """Producing Yeti cache files using scene time range. + + This will extract Yeti cache file sequence and fur settings. + """ + + label = "Extract Yeti Cache" + hosts = ["maya"] + families = ["yeticacheUE"] + + def process(self, instance): + + yeti_nodes = cmds.ls(instance, type="pgYetiMaya") + if not yeti_nodes: + raise RuntimeError("No pgYetiMaya nodes found in the instance") + + # Define extract output file path + dirname = self.staging_dir(instance) + + # Collect information for writing cache + start_frame = instance.data["frameStartHandle"] + end_frame = instance.data["frameEndHandle"] + preroll = instance.data["preroll"] + if preroll > 0: + start_frame -= preroll + + kwargs = {} + samples = instance.data.get("samples", 0) + if samples == 0: + kwargs.update({"sampleTimes": "0.0 1.0"}) + else: + kwargs.update({"samples": samples}) + + self.log.debug(f"Writing out cache {start_frame} - {end_frame}") + filename = f"{instance.name}.abc" + path = os.path.join(dirname, filename) + cmds.pgYetiCommand(yeti_nodes, + writeAlembic=path, + range=(start_frame, end_frame), + asUnrealAbc=True, + **kwargs) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'abc', + 'ext': 'abc', + 'files': filename, + 'stagingDir': dirname + } + instance.data["representations"].append(representation) + + self.log.debug(f"Extracted {instance} to {dirname}") diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py index df16c6c357..21dfcfffc5 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py @@ -43,7 +43,7 @@ class ExtractVRayProxy(publish.Extractor): vertex_colors = instance.data.get("vertexColors", False) # Write out vrmesh file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.vrayCreateProxy(exportType=1, @@ -68,5 +68,5 @@ class ExtractVRayProxy(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py index 8442df1611..b0615149a9 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py @@ -20,13 +20,13 @@ class ExtractVrayscene(publish.Extractor): def process(self, instance): """Plugin entry point.""" if instance.data.get("exportOnFarm"): - self.log.info("vrayscenes will be exported on farm.") + self.log.debug("vrayscenes will be exported on farm.") raise NotImplementedError( "exporting vrayscenes is not implemented") # handle sequence if instance.data.get("vraySceneMultipleFiles"): - self.log.info("vrayscenes will be exported on farm.") + self.log.debug("vrayscenes will be exported on farm.") raise NotImplementedError( "exporting vrayscene sequences not implemented yet") @@ -40,7 +40,6 @@ class ExtractVrayscene(publish.Extractor): layer_name = instance.data.get("layer") staging_dir = self.staging_dir(instance) - self.log.info("staging: {}".format(staging_dir)) template = cmds.getAttr("{}.vrscene_filename".format(node)) start_frame = instance.data.get( "frameStartHandle") if instance.data.get( @@ -56,21 +55,21 @@ class ExtractVrayscene(publish.Extractor): staging_dir, "vrayscene", *formatted_name.split("/")) # Write out vrscene file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): if "*" not in instance.data["setMembers"]: - self.log.info( + self.log.debug( "Exporting: {}".format(instance.data["setMembers"])) set_members = instance.data["setMembers"] cmds.select(set_members, noExpand=True) else: - self.log.info("Exporting all ...") + self.log.debug("Exporting all ...") set_members = cmds.ls( long=True, objectsOnly=True, geometry=True, lights=True, cameras=True) cmds.select(set_members, noExpand=True) - self.log.info("Appending layer name {}".format(layer_name)) + self.log.debug("Appending layer name {}".format(layer_name)) set_members.append(layer_name) export_in_rs_layer( @@ -93,8 +92,8 @@ class ExtractVrayscene(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) @staticmethod def format_vray_output_filename( diff --git a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py index 0d2a97bc4b..4bd01c2df2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py +++ b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py @@ -241,7 +241,7 @@ class ExtractWorkfileXgen(publish.Extractor): data[palette] = {attr: old_value} cmds.setAttr(node_attr, value, type="string") - self.log.info( + self.log.debug( "Setting \"{}\" on \"{}\"".format(value, node_attr) ) diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen.py b/openpype/hosts/maya/plugins/publish/extract_xgen.py index 3c9d0bd344..8409330e49 100644 --- a/openpype/hosts/maya/plugins/publish/extract_xgen.py +++ b/openpype/hosts/maya/plugins/publish/extract_xgen.py @@ -77,7 +77,7 @@ class ExtractXgen(publish.Extractor): xgenm.exportPalette( instance.data["xgmPalette"].replace("|", ""), temp_xgen_path ) - self.log.info("Extracted to {}".format(temp_xgen_path)) + self.log.debug("Extracted to {}".format(temp_xgen_path)) # Import xgen onto the duplicate. with maintained_selection(): @@ -118,7 +118,7 @@ class ExtractXgen(publish.Extractor): expressions=True ) - self.log.info("Extracted to {}".format(maya_filepath)) + self.log.debug("Extracted to {}".format(maya_filepath)) if os.path.exists(temp_xgen_path): os.remove(temp_xgen_path) diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py index b61f599cab..b113e02219 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py @@ -39,7 +39,7 @@ class ExtractYetiCache(publish.Extractor): else: kwargs.update({"samples": samples}) - self.log.info( + self.log.debug( "Writing out cache {} - {}".format(start_frame, end_frame)) # Start writing the files for snap shot # will be replace by the Yeti node name @@ -53,7 +53,7 @@ class ExtractYetiCache(publish.Extractor): cache_files = [x for x in os.listdir(dirname) if x.endswith(".fur")] - self.log.info("Writing metadata file") + self.log.debug("Writing metadata file") settings = instance.data["fursettings"] fursettings_path = os.path.join(dirname, "yeti.fursettings") with open(fursettings_path, "w") as fp: @@ -63,7 +63,7 @@ class ExtractYetiCache(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - self.log.info("cache files: {}".format(cache_files[0])) + self.log.debug("cache files: {}".format(cache_files[0])) # Workaround: We do not explicitly register these files with the # representation solely so that we can write multiple sequences @@ -87,4 +87,4 @@ class ExtractYetiCache(publish.Extractor): } ) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py index 9a46c31177..da67cb911f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py @@ -109,7 +109,7 @@ class ExtractYetiRig(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -127,7 +127,7 @@ class ExtractYetiRig(publish.Extractor): maya_path = os.path.join(dirname, "yeti_rig.{}".format(self.scene_type)) - self.log.info("Writing metadata file") + self.log.debug("Writing metadata file: {}".format(settings_path)) image_search_path = resources_dir = instance.data["resourcesDir"] @@ -147,7 +147,7 @@ class ExtractYetiRig(publish.Extractor): dst = os.path.join(image_search_path, os.path.basename(file)) instance.data['transfers'].append([src, dst]) - self.log.info("adding transfer {} -> {}". format(src, dst)) + self.log.debug("adding transfer {} -> {}". format(src, dst)) # Ensure the imageSearchPath is being remapped to the publish folder attr_value = {"%s.imageSearchPath" % n: str(image_search_path) for @@ -182,7 +182,7 @@ class ExtractYetiRig(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - self.log.info("rig file: {}".format(maya_path)) + self.log.debug("rig file: {}".format(maya_path)) instance.data["representations"].append( { 'name': self.scene_type, @@ -191,7 +191,7 @@ class ExtractYetiRig(publish.Extractor): 'stagingDir': dirname } ) - self.log.info("settings file: {}".format(settings_path)) + self.log.debug("settings file: {}".format(settings_path)) instance.data["representations"].append( { 'name': 'rigsettings', @@ -201,6 +201,6 @@ class ExtractYetiRig(publish.Extractor): } ) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) cmds.select(clear=True) diff --git a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py index d8e8554b68..759aa23258 100644 --- a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py +++ b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py @@ -23,7 +23,7 @@ class ResetXgenAttributes(pyblish.api.InstancePlugin): for palette, data in xgen_attributes.items(): for attr, value in data.items(): node_attr = "{}.{}".format(palette, attr) - self.log.info( + self.log.debug( "Setting \"{}\" on \"{}\"".format(value, node_attr) ) cmds.setAttr(node_attr, value, type="string") @@ -32,5 +32,5 @@ class ResetXgenAttributes(pyblish.api.InstancePlugin): # Need to save the scene, cause the attribute changes above does not # mark the scene as modified so user can exit without committing the # changes. - self.log.info("Saving changes.") + self.log.debug("Saving changes.") cmds.file(save=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py new file mode 100644 index 0000000000..4537892d6d --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -0,0 +1,66 @@ +import pyblish.api +import openpype.hosts.maya.api.action +from openpype.pipeline.publish import ( + PublishValidationError, + ValidateContentsOrder +) +from maya import cmds + + +class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): + """Validate all nodes in skeletonAnim_SET are referenced""" + + order = ValidateContentsOrder + hosts = ["maya"] + families = ["animation.fbx"] + label = "Animated Reference Rig" + accepted_controllers = ["transform", "locator"] + actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + + def process(self, instance): + animated_sets = instance.data.get("animated_skeleton", []) + if not animated_sets: + self.log.debug( + "No nodes found in skeletonAnim_SET. " + "Skipping validation of animated reference rig..." + ) + return + + for animated_reference in animated_sets: + is_referenced = cmds.referenceQuery( + animated_reference, isNodeReferenced=True) + if not bool(is_referenced): + raise PublishValidationError( + "All the content in skeletonAnim_SET" + " should be referenced nodes" + ) + invalid_controls = self.validate_controls(animated_sets) + if invalid_controls: + raise PublishValidationError( + "All the content in skeletonAnim_SET" + " should be transforms" + ) + + @classmethod + def validate_controls(self, set_members): + """Check if the controller set contains only accepted node types. + + Checks if all its set members are within the hierarchy of the root + Checks if the node types of the set members valid + + Args: + set_members: list of nodes of the skeleton_anim_set + hierarchy: list of nodes which reside under the root node + + Returns: + errors (list) + """ + + # Validate control types + invalid = [] + set_members = cmds.ls(set_members, long=True) + for node in set_members: + if cmds.nodeType(node) not in self.accepted_controllers: + invalid.append(node) + + return invalid diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_name.py b/openpype/hosts/maya/plugins/publish/validate_assembly_name.py index bcc40760e0..00588cd300 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_name.py @@ -20,7 +20,7 @@ class ValidateAssemblyName(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - cls.log.info("Checking name of {}".format(instance.name)) + cls.log.debug("Checking name of {}".format(instance.name)) content_instance = instance.data.get("setMembers", None) if not content_instance: diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py b/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py index 41ef78aab4..06577f38f7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py @@ -23,7 +23,7 @@ class ValidateAssemblyNamespaces(pyblish.api.InstancePlugin): def process(self, instance): - self.log.info("Checking namespace for %s" % instance.name) + self.log.debug("Checking namespace for %s" % instance.name) if self.get_invalid(instance): raise PublishValidationError("Nested namespaces found") diff --git a/openpype/hosts/maya/plugins/publish/validate_color_sets.py b/openpype/hosts/maya/plugins/publish/validate_color_sets.py index 766124cd9e..173fee4179 100644 --- a/openpype/hosts/maya/plugins/publish/validate_color_sets.py +++ b/openpype/hosts/maya/plugins/publish/validate_color_sets.py @@ -3,9 +3,10 @@ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( - RepairAction, ValidateMeshOrder, - OptionalPyblishPluginMixin + OptionalPyblishPluginMixin, + PublishValidationError, + RepairAction ) @@ -22,8 +23,9 @@ class ValidateColorSets(pyblish.api.Validator, hosts = ['maya'] families = ['model'] label = 'Mesh ColorSets' - actions = [openpype.hosts.maya.api.action.SelectInvalidAction, - RepairAction] + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction + ] optional = True @staticmethod @@ -48,8 +50,9 @@ class ValidateColorSets(pyblish.api.Validator, invalid = self.get_invalid(instance) if invalid: - raise ValueError("Meshes found with " - "Color Sets: {0}".format(invalid)) + raise PublishValidationError( + message="Meshes found with Color Sets: {0}".format(invalid) + ) @classmethod def repair(cls, instance): diff --git a/openpype/hosts/maya/plugins/publish/validate_frame_range.py b/openpype/hosts/maya/plugins/publish/validate_frame_range.py index c6184ed348..a7043b8407 100644 --- a/openpype/hosts/maya/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/maya/plugins/publish/validate_frame_range.py @@ -47,10 +47,10 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, context = instance.context if instance.data.get("tileRendering"): - self.log.info(( + self.log.debug( "Skipping frame range validation because " "tile rendering is enabled." - )) + ) return frame_start_handle = int(context.data.get("frameStartHandle")) diff --git a/openpype/hosts/maya/plugins/publish/validate_glsl_material.py b/openpype/hosts/maya/plugins/publish/validate_glsl_material.py index 10c48da404..3b386c3def 100644 --- a/openpype/hosts/maya/plugins/publish/validate_glsl_material.py +++ b/openpype/hosts/maya/plugins/publish/validate_glsl_material.py @@ -75,7 +75,7 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): """ meshes = cmds.ls(instance, type="mesh", long=True) - cls.log.info("meshes: {}".format(meshes)) + cls.log.debug("meshes: {}".format(meshes)) # load the glsl shader plugin cmds.loadPlugin("glslShader", quiet=True) @@ -96,8 +96,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): cls.log.warning("ogsfx shader file " "not found in {}".format(ogsfx_path)) - cls.log.info("Find the ogsfx shader file in " - "default maya directory...") + cls.log.debug("Searching the ogsfx shader file in " + "default maya directory...") # re-direct to search the ogsfx path in maya_dir ogsfx_path = os.getenv("MAYA_APP_DIR") + ogsfx_path if not os.path.exists(ogsfx_path): @@ -130,8 +130,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): @classmethod def pbs_shader_conversion(cls, main_shader, glsl): - cls.log.info("StringrayPBS detected " - "-> Can do texture conversion") + cls.log.debug("StringrayPBS detected " + "-> Can do texture conversion") for shader in main_shader: # get the file textures related to the PBS Shader @@ -168,8 +168,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): @classmethod def arnold_shader_conversion(cls, main_shader, glsl): - cls.log.info("aiStandardSurface detected " - "-> Can do texture conversion") + cls.log.debug("aiStandardSurface detected " + "-> Can do texture conversion") for shader in main_shader: # get the file textures related to the PBS Shader diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py b/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py deleted file mode 100644 index f870c9f8c4..0000000000 --- a/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py +++ /dev/null @@ -1,60 +0,0 @@ -from maya import cmds - -import pyblish.api -from openpype.pipeline.publish import ( - ValidateContentsOrder, PublishValidationError, RepairAction -) -from openpype.pipeline import discover_legacy_creator_plugins -from openpype.hosts.maya.api.lib import imprint - - -class ValidateInstanceAttributes(pyblish.api.InstancePlugin): - """Validate Instance Attributes. - - New attributes can be introduced as new features come in. Old instances - will need to be updated with these attributes for the documentation to make - sense, and users do not have to recreate the instances. - """ - - order = ValidateContentsOrder - hosts = ["maya"] - families = ["*"] - label = "Instance Attributes" - plugins_by_family = { - p.family: p for p in discover_legacy_creator_plugins() - } - actions = [RepairAction] - - @classmethod - def get_missing_attributes(self, instance): - plugin = self.plugins_by_family[instance.data["family"]] - subset = instance.data["subset"] - asset = instance.data["asset"] - objset = instance.data["objset"] - - missing_attributes = {} - for key, value in plugin(subset, asset).data.items(): - if not cmds.objExists("{}.{}".format(objset, key)): - missing_attributes[key] = value - - return missing_attributes - - def process(self, instance): - objset = instance.data.get("objset") - if objset is None: - self.log.debug( - "Skipping {} because no objectset found.".format(instance) - ) - return - - missing_attributes = self.get_missing_attributes(instance) - if missing_attributes: - raise PublishValidationError( - "Missing attributes on {}:\n{}".format( - objset, missing_attributes - ) - ) - - @classmethod - def repair(cls, instance): - imprint(instance.data["objset"], cls.get_missing_attributes(instance)) diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py index b257add7e8..4222e63898 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py +++ b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py @@ -3,94 +3,20 @@ from __future__ import absolute_import import pyblish.api +from openpype import AYON_SERVER_ENABLED +import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( - ValidateContentsOrder, PublishValidationError + RepairAction, + ValidateContentsOrder, + PublishValidationError, + OptionalPyblishPluginMixin ) from maya import cmds -class SelectInvalidInstances(pyblish.api.Action): - """Select invalid instances in Outliner.""" - - label = "Select Instances" - icon = "briefcase" - on = "failed" - - def process(self, context, plugin): - """Process invalid validators and select invalid instances.""" - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - if instances: - self.log.info( - "Selecting invalid nodes: %s" % ", ".join( - [str(x) for x in instances] - ) - ) - self.select(instances) - else: - self.log.info("No invalid nodes found.") - self.deselect() - - def select(self, instances): - cmds.select(instances, replace=True, noExpand=True) - - def deselect(self): - cmds.select(deselect=True) - - -class RepairSelectInvalidInstances(pyblish.api.Action): - """Repair the instance asset.""" - - label = "Repair" - icon = "wrench" - on = "failed" - - def process(self, context, plugin): - # Get the errored instances - failed = [] - for result in context.data["results"]: - if result["error"] is None: - continue - if result["instance"] is None: - continue - if result["instance"] in failed: - continue - if result["plugin"] != plugin: - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - context_asset = context.data["assetEntity"]["name"] - for instance in instances: - self.set_attribute(instance, context_asset) - - def set_attribute(self, instance, context_asset): - cmds.setAttr( - instance.data.get("name") + ".asset", - context_asset, - type="string" - ) - - -class ValidateInstanceInContext(pyblish.api.InstancePlugin): +class ValidateInstanceInContext(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Validator to check if instance asset match context asset. When working in per-shot style you always publish data in context of @@ -104,11 +30,53 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin): label = "Instance in same Context" optional = True hosts = ["maya"] - actions = [SelectInvalidInstances, RepairSelectInvalidInstances] + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction + ] def process(self, instance): + if not self.is_active(instance.data): + return + asset = instance.data.get("asset") - context_asset = instance.context.data["assetEntity"]["name"] - msg = "{} has asset {}".format(instance.name, asset) + context_asset = self.get_context_asset(instance) if asset != context_asset: - raise PublishValidationError(msg) + raise PublishValidationError( + message=( + "Instance '{}' publishes to different asset than current " + "context: {}. Current context: {}".format( + instance.name, asset, context_asset + ) + ), + description=( + "## Publishing to a different asset\n" + "There are publish instances present which are publishing " + "into a different asset than your current context.\n\n" + "Usually this is not what you want but there can be cases " + "where you might want to publish into another asset or " + "shot. If that's the case you can disable the validation " + "on the instance to ignore it." + ) + ) + + @classmethod + def get_invalid(cls, instance): + return [instance.data["instance_node"]] + + @classmethod + def repair(cls, instance): + context_asset = cls.get_context_asset(instance) + instance_node = instance.data["instance_node"] + if AYON_SERVER_ENABLED: + asset_name_attr = "folderPath" + else: + asset_name_attr = "asset" + cmds.setAttr( + "{}.{}".format(instance_node, asset_name_attr), + context_asset, + type="string" + ) + + @staticmethod + def get_context_asset(instance): + return instance.context.data["asset"] diff --git a/openpype/hosts/maya/plugins/publish/validate_instancer_content.py b/openpype/hosts/maya/plugins/publish/validate_instancer_content.py index 2f14693ef2..236adfb03d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instancer_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_instancer_content.py @@ -21,7 +21,7 @@ class ValidateInstancerContent(pyblish.api.InstancePlugin): members = instance.data['setMembers'] export_members = instance.data['exactExportMembers'] - self.log.info("Contents {0}".format(members)) + self.log.debug("Contents {0}".format(members)) if not len(members) == len(cmds.ls(members, type="instancer")): self.log.error("Instancer can only contain instancers") diff --git a/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py b/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py index fcfcdce8b6..714c6229d6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py +++ b/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py @@ -5,8 +5,6 @@ import pyblish.api from openpype.pipeline.publish import PublishValidationError -VERBOSE = False - def is_cache_resource(resource): """Return whether resource is a cacheFile resource""" @@ -73,9 +71,6 @@ class ValidateInstancerFrameRanges(pyblish.api.InstancePlugin): xml = all_files.pop(0) assert xml.endswith(".xml") - if VERBOSE: - cls.log.info("Checking: {0}".format(all_files)) - # Ensure all files exist (including ticks) # The remainder file paths should be the .mcx or .mcc files valdidate_files(all_files) @@ -129,8 +124,8 @@ class ValidateInstancerFrameRanges(pyblish.api.InstancePlugin): # for the frames required by the time range. if ticks: ticks = list(sorted(ticks)) - cls.log.info("Found ticks: {0} " - "(substeps: {1})".format(ticks, len(ticks))) + cls.log.debug("Found ticks: {0} " + "(substeps: {1})".format(ticks, len(ticks))) # Check all frames except the last since we don't # require subframes after our time range. diff --git a/openpype/hosts/maya/plugins/publish/validate_look_default_shaders_connections.py b/openpype/hosts/maya/plugins/publish/validate_look_default_shaders_connections.py index 0109f6ebd5..c3edf5f1c3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_default_shaders_connections.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_default_shaders_connections.py @@ -3,60 +3,76 @@ from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, + RepairContextAction, PublishValidationError ) -class ValidateLookDefaultShadersConnections(pyblish.api.InstancePlugin): +class ValidateLookDefaultShadersConnections(pyblish.api.ContextPlugin): """Validate default shaders in the scene have their default connections. - For example the lambert1 could potentially be disconnected from the - initialShadingGroup. As such it's not lambert1 that will be identified - as the default shader which can have unpredictable results. + For example the standardSurface1 or lambert1 (maya 2023 and before) could + potentially be disconnected from the initialShadingGroup. As such it's not + lambert1 that will be identified as the default shader which can have + unpredictable results. To fix the default connections need to be made again. See the logs for more details on which connections are missing. """ - order = ValidateContentsOrder + order = pyblish.api.ValidatorOrder - 0.4999 families = ['look'] hosts = ['maya'] label = 'Look Default Shader Connections' + actions = [RepairContextAction] # The default connections to check - DEFAULTS = [("initialShadingGroup.surfaceShader", "lambert1"), - ("initialParticleSE.surfaceShader", "lambert1"), - ("initialParticleSE.volumeShader", "particleCloud1") - ] + DEFAULTS = { + "initialShadingGroup.surfaceShader": ["standardSurface1.outColor", + "lambert1.outColor"], + "initialParticleSE.surfaceShader": ["standardSurface1.outColor", + "lambert1.outColor"], + "initialParticleSE.volumeShader": ["particleCloud1.outColor"] + } - def process(self, instance): + def process(self, context): - # Ensure check is run only once. We don't use ContextPlugin because - # of a bug where the ContextPlugin will always be visible. Even when - # the family is not present in an instance. - key = "__validate_look_default_shaders_connections_checked" - context = instance.context - is_run = context.data.get(key, False) - if is_run: - return - else: - context.data[key] = True + if self.get_invalid(): + raise PublishValidationError( + "Default shaders in your scene do not have their " + "default shader connections. Please repair them to continue." + ) + + @classmethod + def get_invalid(cls): # Process as usual invalid = list() - for plug, input_node in self.DEFAULTS: + for plug, valid_inputs in cls.DEFAULTS.items(): inputs = cmds.listConnections(plug, source=True, - destination=False) or None - - if not inputs or inputs[0] != input_node: - self.log.error("{0} is not connected to {1}. " - "This can result in unexpected behavior. " - "Please reconnect to continue.".format( - plug, - input_node)) + destination=False, + plugs=True) or None + if not inputs or inputs[0] not in valid_inputs: + cls.log.error( + "{0} is not connected to {1}. This can result in " + "unexpected behavior. Please reconnect to continue." + "".format(plug, " or ".join(valid_inputs)) + ) invalid.append(plug) - if invalid: - raise PublishValidationError("Invalid connections.") + return invalid + + @classmethod + def repair(cls, context): + invalid = cls.get_invalid() + for plug in invalid: + valid_inputs = cls.DEFAULTS[plug] + for valid_input in valid_inputs: + if cmds.objExists(valid_input): + cls.log.info( + "Connecting {} -> {}".format(valid_input, plug) + ) + cmds.connectAttr(valid_input, plug, force=True) + break diff --git a/openpype/hosts/maya/plugins/publish/validate_maya_units.py b/openpype/hosts/maya/plugins/publish/validate_maya_units.py index 1d5619795f..ae6dc093a9 100644 --- a/openpype/hosts/maya/plugins/publish/validate_maya_units.py +++ b/openpype/hosts/maya/plugins/publish/validate_maya_units.py @@ -37,7 +37,7 @@ class ValidateMayaUnits(pyblish.api.ContextPlugin): ) @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): """Apply project settings to creator""" settings = ( project_settings["maya"]["publish"]["ValidateMayaUnits"] diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 55624726ea..bde78a98b8 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -36,28 +36,34 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, optional = True - @classmethod - def apply_settings(cls, project_settings, system_settings): - # todo: this should not be done this way - attr = "defaultRenderGlobals.currentRenderer" - cls.active = cmds.getAttr(attr).lower() == "arnold" + # cache (will be `dict` when cached) + arnold_mesh_defaults = None @classmethod def get_default_attributes(cls): + + if cls.arnold_mesh_defaults is not None: + # Use from cache + return cls.arnold_mesh_defaults + # Get default arnold attribute values for mesh type. defaults = {} with delete_after() as tmp: - transform = cmds.createNode("transform") + transform = cmds.createNode("transform", skipSelect=True) tmp.append(transform) - mesh = cmds.createNode("mesh", parent=transform) - for attr in cmds.listAttr(mesh, string="ai*"): + mesh = cmds.createNode("mesh", parent=transform, skipSelect=True) + arnold_attributes = cmds.listAttr(mesh, + string="ai*", + fromPlugin=True) or [] + for attr in arnold_attributes: plug = "{}.{}".format(mesh, attr) try: defaults[attr] = get_attribute(plug) except PublishValidationError: cls.log.debug("Ignoring arnold attribute: {}".format(attr)) + cls.arnold_mesh_defaults = defaults # assign cache return defaults @classmethod @@ -109,6 +115,10 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return + if not cmds.pluginInfo("mtoa", query=True, loaded=True): + # Arnold attributes only exist if plug-in is loaded + return + invalid = self.get_invalid_attributes(instance, compute=True) if invalid: raise PublishValidationError( diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 6948dcf724..11f59bb439 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -67,13 +67,15 @@ class ValidateModelName(pyblish.api.InstancePlugin, regex = cls.top_level_regex r = re.compile(regex) m = r.match(top_group) + project_name = instance.context.data["projectName"] + current_asset_name = instance.context.data["asset"] if m is None: cls.log.error("invalid name on: {}".format(top_group)) cls.log.error("name doesn't match regex {}".format(regex)) invalid.append(top_group) else: if "asset" in r.groupindex: - if m.group("asset") != legacy_io.Session["AVALON_ASSET"]: + if m.group("asset") != current_asset_name: cls.log.error("Invalid asset name in top level group.") return top_group if "subset" in r.groupindex: @@ -81,7 +83,7 @@ class ValidateModelName(pyblish.api.InstancePlugin, cls.log.error("Invalid subset name in top level group.") return top_group if "project" in r.groupindex: - if m.group("project") != legacy_io.Session["AVALON_PROJECT"]: + if m.group("project") != project_name: cls.log.error("Invalid project name in top level group.") return top_group @@ -125,7 +127,7 @@ class ValidateModelName(pyblish.api.InstancePlugin, r = re.compile(regex) for obj in filtered: - cls.log.info("testing: {}".format(obj)) + cls.log.debug("testing: {}".format(obj)) m = r.match(obj) if m is None: cls.log.error("invalid name on: {}".format(obj)) diff --git a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py index 68784a165d..ad0fcafc56 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py @@ -35,12 +35,12 @@ class ValidateMvLookContents(pyblish.api.InstancePlugin, publishMipMap = instance.data["publishMipMap"] enforced = True if intent in self.enforced_intents: - self.log.info("This validation will be enforced: '{}'" - .format(intent)) + self.log.debug("This validation will be enforced: '{}'" + .format(intent)) else: enforced = False - self.log.info("This validation will NOT be enforced: '{}'" - .format(intent)) + self.log.debug("This validation will NOT be enforced: '{}'" + .format(intent)) if not instance[:]: raise PublishValidationError("Instance is empty") @@ -75,8 +75,9 @@ class ValidateMvLookContents(pyblish.api.InstancePlugin, self.log.warning(msg) if invalid: - raise PublishValidationError("'{}' has invalid look " - "content".format(instance.name)) + raise PublishValidationError( + "'{}' has invalid look content".format(instance.name) + ) def valid_file(self, fname): self.log.debug("Checking validity of '{}'".format(fname)) diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py index 78334cd01f..cb5c68e4ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py @@ -4,6 +4,8 @@ from maya import cmds import pyblish.api +from openpype.hosts.maya.api.lib import pairwise +from openpype.hosts.maya.api.action import SelectInvalidAction from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError @@ -19,31 +21,36 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): hosts = ['maya'] families = ["workfile"] label = "Plug-in Path Attributes" + actions = [SelectInvalidAction] - def get_invalid(self, instance): + # Attributes are defined in project settings + attribute = [] + + @classmethod + def get_invalid(cls, instance): invalid = list() - # get the project setting - validate_path = ( - instance.context.data["project_settings"]["maya"]["publish"] - ) - file_attr = validate_path["ValidatePluginPathAttributes"]["attribute"] - if not file_attr: + file_attrs = cls.attribute + if not file_attrs: return invalid - # get the nodes and file attributes - for node, attr in file_attr.items(): - # check the related nodes - targets = cmds.ls(type=node) + # Consider only valid node types to avoid "Unknown object type" warning + all_node_types = set(cmds.allNodeTypes()) + node_types = [ + key for key in file_attrs.keys() + if key in all_node_types + ] - for target in targets: - # get the filepath - file_attr = "{}.{}".format(target, attr) - filepath = cmds.getAttr(file_attr) + for node, node_type in pairwise(cmds.ls(type=node_types, + showType=True)): + # get the filepath + file_attr = "{}.{}".format(node, file_attrs[node_type]) + filepath = cmds.getAttr(file_attr) - if filepath and not os.path.exists(filepath): - self.log.error("File {0} not exists".format(filepath)) # noqa - invalid.append(target) + if filepath and not os.path.exists(filepath): + cls.log.error("{} '{}' uses non-existing filepath: {}" + .format(node_type, node, filepath)) + invalid.append(node) return invalid @@ -51,5 +58,16 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): """Process all directories Set as Filenames in Non-Maya Nodes""" invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError("Non-existent Path " - "found: {0}".format(invalid)) + raise PublishValidationError( + title="Plug-in Path Attributes", + message="Non-existent filepath found on nodes: {}".format( + ", ".join(invalid) + ), + description=( + "## Plug-in nodes use invalid filepaths\n" + "The workfile contains nodes from plug-ins that use " + "filepaths which do not exist.\n\n" + "Please make sure their filepaths are correct and the " + "files exist on disk." + ) + ) diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py index f9aa7f82d0..030e41ca1f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py @@ -1,4 +1,7 @@ +import os + import pyblish.api + from maya import cmds from openpype.pipeline.publish import ( @@ -22,8 +25,12 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): def process(self, instance): - required_images_rule = self.get_default_render_image_folder(instance) - current_images_rule = cmds.workspace(fileRuleEntry="images") + required_images_rule = os.path.normpath( + self.get_default_render_image_folder(instance) + ) + current_images_rule = os.path.normpath( + cmds.workspace(fileRuleEntry="images") + ) if current_images_rule != required_images_rule: raise PublishValidationError( @@ -42,8 +49,17 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): cmds.workspace(fileRule=("images", required_images_rule)) cmds.workspace(saveWorkspace=True) - @staticmethod - def get_default_render_image_folder(instance): + @classmethod + def get_default_render_image_folder(cls, instance): + staging_dir = instance.data.get("stagingDir") + if staging_dir: + cls.log.debug( + "Staging dir found: \"{}\". Ignoring setting from " + "`project_settings/maya/RenderSettings/" + "default_render_image_folder`.".format(staging_dir) + ) + return staging_dir + return instance.context.data.get('project_settings')\ .get('maya') \ .get('RenderSettings') \ diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index dccb4ade78..3409b4ec91 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -12,6 +12,7 @@ from openpype.pipeline.publish import ( PublishValidationError, ) from openpype.hosts.maya.api import lib +from openpype.hosts.maya.api.lib_rendersettings import RenderSettings def convert_to_int_or_float(string_value): @@ -129,13 +130,13 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): layer = instance.data['renderlayer'] cameras = instance.data.get("cameras", []) - # Get the node attributes for current renderer - attrs = lib.RENDER_ATTRS.get(renderer, lib.RENDER_ATTRS['default']) # Prefix attribute can return None when a value was never set prefix = lib.get_attr_in_layer(cls.ImagePrefixes[renderer], layer=layer) or "" - padding = lib.get_attr_in_layer("{node}.{padding}".format(**attrs), - layer=layer) + padding = lib.get_attr_in_layer( + attr=RenderSettings.get_padding_attr(renderer), + layer=layer + ) anim_override = lib.get_attr_in_layer("defaultRenderGlobals.animation", layer=layer) @@ -372,8 +373,6 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): lib.set_attribute(data["attribute"], data["values"][0], node) with lib.renderlayer(layer_node): - default = lib.RENDER_ATTRS['default'] - render_attrs = lib.RENDER_ATTRS.get(renderer, default) # Repair animation must be enabled cmds.setAttr("defaultRenderGlobals.animation", True) @@ -391,15 +390,13 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): default_prefix = default_prefix.replace(variant, "") if renderer != "renderman": - node = render_attrs["node"] - prefix_attr = render_attrs["prefix"] - + prefix_attr = RenderSettings.get_image_prefix_attr(renderer) fname_prefix = default_prefix cmds.setAttr("{}.{}".format(node, prefix_attr), fname_prefix, type="string") # Repair padding - padding_attr = render_attrs["padding"] + padding_attr = RenderSettings.get_padding_attr(renderer) cmds.setAttr("{}.{}".format(node, padding_attr), cls.DEFAULT_PADDING) else: diff --git a/openpype/hosts/maya/plugins/publish/validate_resolution.py b/openpype/hosts/maya/plugins/publish/validate_resolution.py new file mode 100644 index 0000000000..91b473b250 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_resolution.py @@ -0,0 +1,117 @@ +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from maya import cmds +from openpype.pipeline.publish import RepairAction +from openpype.hosts.maya.api import lib +from openpype.hosts.maya.api.lib import reset_scene_resolution + + +class ValidateResolution(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate the render resolution setting aligned with DB""" + + order = pyblish.api.ValidatorOrder + families = ["renderlayer"] + hosts = ["maya"] + label = "Validate Resolution" + actions = [RepairAction] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + invalid = self.get_invalid_resolution(instance) + if invalid: + raise PublishValidationError( + "Render resolution is invalid. See log for details.", + description=( + "Wrong render resolution setting. " + "Please use repair button to fix it.\n\n" + "If current renderer is V-Ray, " + "make sure vraySettings node has been created." + ) + ) + + @classmethod + def get_invalid_resolution(cls, instance): + width, height, pixelAspect = cls.get_db_resolution(instance) + current_renderer = instance.data["renderer"] + layer = instance.data["renderlayer"] + invalid = False + if current_renderer == "vray": + vray_node = "vraySettings" + if cmds.objExists(vray_node): + current_width = lib.get_attr_in_layer( + "{}.width".format(vray_node), layer=layer) + current_height = lib.get_attr_in_layer( + "{}.height".format(vray_node), layer=layer) + current_pixelAspect = lib.get_attr_in_layer( + "{}.pixelAspect".format(vray_node), layer=layer + ) + else: + cls.log.error( + "Can't detect VRay resolution because there is no node " + "named: `{}`".format(vray_node) + ) + return True + else: + current_width = lib.get_attr_in_layer( + "defaultResolution.width", layer=layer) + current_height = lib.get_attr_in_layer( + "defaultResolution.height", layer=layer) + current_pixelAspect = lib.get_attr_in_layer( + "defaultResolution.pixelAspect", layer=layer + ) + if current_width != width or current_height != height: + cls.log.error( + "Render resolution {}x{} does not match " + "asset resolution {}x{}".format( + current_width, current_height, + width, height + )) + invalid = True + if current_pixelAspect != pixelAspect: + cls.log.error( + "Render pixel aspect {} does not match " + "asset pixel aspect {}".format( + current_pixelAspect, pixelAspect + )) + invalid = True + return invalid + + @classmethod + def get_db_resolution(cls, instance): + asset_doc = instance.data["assetEntity"] + project_doc = instance.context.data["projectEntity"] + for data in [asset_doc["data"], project_doc["data"]]: + if ( + "resolutionWidth" in data and + "resolutionHeight" in data and + "pixelAspect" in data + ): + width = data["resolutionWidth"] + height = data["resolutionHeight"] + pixelAspect = data["pixelAspect"] + return int(width), int(height), float(pixelAspect) + + # Defaults if not found in asset document or project document + return 1920, 1080, 1.0 + + @classmethod + def repair(cls, instance): + # Usually without renderlayer overrides the renderlayers + # all share the same resolution value - so fixing the first + # will have fixed all the others too. It's much faster to + # check whether it's invalid first instead of switching + # into all layers individually + if not cls.get_invalid_resolution(instance): + cls.log.debug( + "Nothing to repair on instance: {}".format(instance) + ) + return + layer_node = instance.data['setMembers'] + with lib.renderlayer(layer_node): + reset_scene_resolution() diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 7b5392f8f9..106b4024e2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -1,8 +1,10 @@ import pyblish.api from maya import cmds - +import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( - PublishValidationError, ValidateContentsOrder) + PublishValidationError, + ValidateContentsOrder +) class ValidateRigContents(pyblish.api.InstancePlugin): @@ -18,76 +20,127 @@ class ValidateRigContents(pyblish.api.InstancePlugin): label = "Rig Contents" hosts = ["maya"] families = ["rig"] + action = [openpype.hosts.maya.api.action.SelectInvalidAction] accepted_output = ["mesh", "transform"] accepted_controllers = ["transform"] def process(self, instance): - - objectsets = ("controls_SET", "out_SET") - missing = [obj for obj in objectsets if obj not in instance] - assert not missing, ("%s is missing %s" % (instance, missing)) - - # Ensure there are at least some transforms or dag nodes - # in the rig instance - set_members = instance.data['setMembers'] - if not cmds.ls(set_members, type="dagNode", long=True): + invalid = self.get_invalid(instance) + if invalid: raise PublishValidationError( - ("No dag nodes in the pointcache instance. " - "(Empty instance?)")) + "Invalid rig content. See log for details.") + + @classmethod + def get_invalid(cls, instance): + + # Find required sets by suffix + required, rig_sets = cls.get_nodes(instance) + + cls.validate_missing_objectsets(instance, required, rig_sets) + + controls_set = rig_sets["controls_SET"] + out_set = rig_sets["out_SET"] # Ensure contents in sets and retrieve long path for all objects - output_content = cmds.sets("out_SET", query=True) or [] - assert output_content, "Must have members in rig out_SET" + output_content = cmds.sets(out_set, query=True) or [] + if not output_content: + raise PublishValidationError("Must have members in rig out_SET") output_content = cmds.ls(output_content, long=True) - controls_content = cmds.sets("controls_SET", query=True) or [] - assert controls_content, "Must have members in rig controls_SET" + controls_content = cmds.sets(controls_set, query=True) or [] + if not controls_content: + raise PublishValidationError( + "Must have members in rig controls_SET" + ) controls_content = cmds.ls(controls_content, long=True) - # Validate members are inside the hierarchy from root node - root_node = cmds.ls(set_members, assemblies=True) - hierarchy = cmds.listRelatives(root_node, allDescendents=True, - fullPath=True) - hierarchy = set(hierarchy) - - invalid_hierarchy = [] - for node in output_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - for node in controls_content: - if node not in hierarchy: - invalid_hierarchy.append(node) + rig_content = output_content + controls_content + invalid_hierarchy = cls.invalid_hierarchy(instance, rig_content) # Additional validations - invalid_geometry = self.validate_geometry(output_content) - invalid_controls = self.validate_controls(controls_content) + invalid_geometry = cls.validate_geometry(output_content) + invalid_controls = cls.validate_controls(controls_content) error = False if invalid_hierarchy: - self.log.error("Found nodes which reside outside of root group " + cls.log.error("Found nodes which reside outside of root group " "while they are set up for publishing." "\n%s" % invalid_hierarchy) error = True if invalid_controls: - self.log.error("Only transforms can be part of the controls_SET." + cls.log.error("Only transforms can be part of the controls_SET." "\n%s" % invalid_controls) error = True if invalid_geometry: - self.log.error("Only meshes can be part of the out_SET\n%s" + cls.log.error("Only meshes can be part of the out_SET\n%s" % invalid_geometry) error = True - if error: + return invalid_hierarchy + invalid_controls + invalid_geometry + + @classmethod + def validate_missing_objectsets(cls, instance, + required_objsets, rig_sets): + """Validate missing objectsets in rig sets + + Args: + instance (str): instance + required_objsets (list): list of objectset names + rig_sets (list): list of rig sets + + Raises: + PublishValidationError: When the error is raised, it will show + which instance has the missing object sets + """ + missing = [ + key for key in required_objsets if key not in rig_sets + ] + if missing: raise PublishValidationError( - "Invalid rig content. See log for details.") + "%s is missing sets: %s" % (instance, ", ".join(missing)) + ) - def validate_geometry(self, set_members): - """Check if the out set passes the validations + @classmethod + def invalid_hierarchy(cls, instance, content): + """ + Check if all rig set members are within the hierarchy of the rig root - Checks if all its set members are within the hierarchy of the root + Args: + instance (str): instance + content (list): list of content from rig sets + + Raises: + PublishValidationError: It means no dag nodes in + the rig instance + + Returns: + list: invalid hierarchy + """ + # Ensure there are at least some transforms or dag nodes + # in the rig instance + set_members = instance.data['setMembers'] + if not cmds.ls(set_members, type="dagNode", long=True): + raise PublishValidationError( + "No dag nodes in the rig instance. " + "(Empty instance?)" + ) + # Validate members are inside the hierarchy from root node + root_nodes = cmds.ls(set_members, assemblies=True, long=True) + hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, + fullPath=True) + root_nodes + hierarchy = set(hierarchy) + invalid_hierarchy = [] + for node in content: + if node not in hierarchy: + invalid_hierarchy.append(node) + return invalid_hierarchy + + @classmethod + def validate_geometry(cls, set_members): + """ Checks if the node types of the set members valid Args: @@ -106,15 +159,13 @@ class ValidateRigContents(pyblish.api.InstancePlugin): fullPath=True) or [] all_shapes = cmds.ls(set_members + shapes, long=True, shapes=True) for shape in all_shapes: - if cmds.nodeType(shape) not in self.accepted_output: + if cmds.nodeType(shape) not in cls.accepted_output: invalid.append(shape) - return invalid - - def validate_controls(self, set_members): - """Check if the controller set passes the validations - - Checks if all its set members are within the hierarchy of the root + @classmethod + def validate_controls(cls, set_members): + """ + Checks if the control set members are allowed node types. Checks if the node types of the set members valid Args: @@ -128,7 +179,80 @@ class ValidateRigContents(pyblish.api.InstancePlugin): # Validate control types invalid = [] for node in set_members: - if cmds.nodeType(node) not in self.accepted_controllers: + if cmds.nodeType(node) not in cls.accepted_controllers: invalid.append(node) return invalid + + @classmethod + def get_nodes(cls, instance): + """Get the target objectsets and rig sets nodes + + Args: + instance (str): instance + + Returns: + tuple: 2-tuple of list of objectsets, + list of rig sets nodes + """ + objectsets = ["controls_SET", "out_SET"] + rig_sets_nodes = instance.data.get("rig_sets", []) + return objectsets, rig_sets_nodes + + +class ValidateSkeletonRigContents(ValidateRigContents): + """Ensure skeleton rigs contains pipeline-critical content + + The rigs optionally contain at least two object sets: + "skeletonMesh_SET" - Set of the skinned meshes + with bone hierarchies + + """ + + order = ValidateContentsOrder + label = "Skeleton Rig Contents" + hosts = ["maya"] + families = ["rig.fbx"] + + @classmethod + def get_invalid(cls, instance): + objectsets, skeleton_mesh_nodes = cls.get_nodes(instance) + cls.validate_missing_objectsets( + instance, objectsets, instance.data["rig_sets"]) + + # Ensure contents in sets and retrieve long path for all objects + output_content = instance.data.get("skeleton_mesh", []) + output_content = cmds.ls(skeleton_mesh_nodes, long=True) + + invalid_hierarchy = cls.invalid_hierarchy( + instance, output_content) + invalid_geometry = cls.validate_geometry(output_content) + + error = False + if invalid_hierarchy: + cls.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + if invalid_geometry: + cls.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + if error: + return invalid_hierarchy + invalid_geometry + + @classmethod + def get_nodes(cls, instance): + """Get the target objectsets and rig sets nodes + + Args: + instance (str): instance + + Returns: + tuple: 2-tuple of list of objectsets, + list of rig sets nodes + """ + objectsets = ["skeletonMesh_SET"] + skeleton_mesh_nodes = instance.data.get("skeleton_mesh", []) + return objectsets, skeleton_mesh_nodes diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index 7bbf4257ab..82248c57b3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -52,22 +52,30 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError('{} failed, see log ' - 'information'.format(self.label)) + raise PublishValidationError( + '{} failed, see log information'.format(self.label) + ) @classmethod def get_invalid(cls, instance): - controllers_sets = [i for i in instance if i == "controls_SET"] - controls = cmds.sets(controllers_sets, query=True) - assert controls, "Must have 'controls_SET' in rig instance" + controls_set = cls.get_node(instance) + if not controls_set: + cls.log.error( + "Must have 'controls_SET' in rig instance" + ) + return [instance.data["instance_node"]] + + controls = cmds.sets(controls_set, query=True) # Ensure all controls are within the top group lookup = set(instance[:]) - assert all(control in lookup for control in cmds.ls(controls, - long=True)), ( - "All controls must be inside the rig's group." - ) + if not all(control in lookup for control in cmds.ls(controls, + long=True)): + cls.log.error( + "All controls must be inside the rig's group." + ) + return [controls_set] # Validate all controls has_connections = list() @@ -181,9 +189,17 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): + controls_set = cls.get_node(instance) + if not controls_set: + cls.log.error( + "Unable to repair because no 'controls_SET' found in rig " + "instance: {}".format(instance) + ) + return + # Use a single undo chunk with undo_chunk(): - controls = cmds.sets("controls_SET", query=True) + controls = cmds.sets(controls_set, query=True) for control in controls: # Lock visibility @@ -212,3 +228,64 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): default = cls.CONTROLLER_DEFAULTS[attr] cls.log.info("Setting %s to %s" % (plug, default)) cmds.setAttr(plug, default) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from controls_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from controls_SET + """ + return instance.data["rig_sets"].get("controls_SET") + + +class ValidateSkeletonRigControllers(ValidateRigControllers): + """Validate rig controller for skeletonAnim_SET + + Controls must have the transformation attributes on their default + values of translate zero, rotate zero and scale one when they are + unlocked attributes. + + Unlocked keyable attributes may not have any incoming connections. If + these connections are required for the rig then lock the attributes. + + The visibility attribute must be locked. + + Note that `repair` will: + - Lock all visibility attributes + - Reset all default values for translate, rotate, scale + - Break all incoming connections to keyable attributes + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Controllers" + hosts = ["maya"] + families = ["rig.fbx"] + + # Default controller values + CONTROLLER_DEFAULTS = { + "translateX": 0, + "translateY": 0, + "translateZ": 0, + "rotateX": 0, + "rotateY": 0, + "rotateZ": 0, + "scaleX": 1, + "scaleY": 1, + "scaleZ": 1 + } + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get("skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py index 842c1de01b..03f6a5f1ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py @@ -56,11 +56,11 @@ class ValidateRigControllersArnoldAttributes(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - controllers_sets = [i for i in instance if i == "controls_SET"] - if not controllers_sets: + controls_set = instance.data["rig_sets"].get("controls_SET") + if not controls_set: return [] - controls = cmds.sets(controllers_sets, query=True) or [] + controls = cmds.sets(controls_set, query=True) or [] if not controls: return [] diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index 39f0941faa..80ac0f27e6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -38,16 +38,19 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): # if a deformer has been created on the shape invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError("Nodes found with mismatching " - "IDs: {0}".format(invalid)) + raise PublishValidationError( + "Nodes found with mismatching IDs: {0}".format(invalid) + ) @classmethod def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" - invalid = [] + out_set = cls.get_node(instance) + if not out_set: + return [] - out_set = next(x for x in instance if x.endswith("out_SET")) + invalid = [] members = cmds.sets(out_set, query=True) shapes = cmds.ls(members, dag=True, @@ -82,3 +85,45 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): continue lib.set_id(node, sibling_id, overwrite=True) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from out_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from out_SET + """ + return instance.data["rig_sets"].get("out_SET") + + +class ValidateSkeletonRigOutSetNodeIds(ValidateRigOutSetNodeIds): + """Validate if deformed shapes have related IDs to the original shapes + from skeleton set. + + When a deformer is applied in the scene on a referenced mesh that already + had deformers then Maya will create a new shape node for the mesh that + does not have the original id. This validator checks whether the ids are + valid on all the shape nodes in the instance. + + """ + + order = ValidateContentsOrder + families = ["rig.fbx"] + hosts = ['maya'] + label = 'Skeleton Rig Out Set Node Ids' + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get( + "skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index cbc750bace..343d8e6924 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -47,7 +47,10 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): invalid = {} if compute: - out_set = next(x for x in instance if "out_SET" in x) + out_set = cls.get_node(instance) + if not out_set: + instance.data["mismatched_output_ids"] = invalid + return invalid instance_nodes = cmds.sets(out_set, query=True, nodesOnly=True) instance_nodes = cmds.ls(instance_nodes, long=True) @@ -112,3 +115,40 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): "Multiple matched ids found. Please repair manually: " "{}".format(multiple_ids_match) ) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from out_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from out_SET + """ + return instance.data["rig_sets"].get("out_SET") + + +class ValidateSkeletonRigOutputIds(ValidateRigOutputIds): + """Validate rig output ids from the skeleton sets. + + Ids must share the same id as similarly named nodes in the scene. This is + to ensure the id from the model is preserved through animation. + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Output Ids" + hosts = ["maya"] + families = ["rig.fbx"] + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get("skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_shader_name.py b/openpype/hosts/maya/plugins/publish/validate_shader_name.py index 36bb2c1fee..d6486dea7f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shader_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_shader_name.py @@ -51,7 +51,7 @@ class ValidateShaderName(pyblish.api.InstancePlugin, descendants = cmds.ls(descendants, noIntermediate=True, long=True) shapes = cmds.ls(descendants, type=["nurbsSurface", "mesh"], long=True) - asset_name = instance.data.get("asset", None) + asset_name = instance.data.get("asset") # Check the number of connected shadingEngines per shape regex_compile = re.compile(cls.regex) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index 7a7e9a0aee..c7af6a60db 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -7,6 +7,7 @@ from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, + PublishValidationError ) @@ -67,5 +68,30 @@ class ValidateShapeZero(pyblish.api.Validator): invalid = self.get_invalid(instance) if invalid: - raise ValueError("Shapes found with non-zero component tweaks: " - "{0}".format(invalid)) + raise PublishValidationError( + title="Shape Component Tweaks", + message="Shapes found with non-zero component tweaks: '{}'" + "".format(", ".join(invalid)), + description=( + "## Shapes found with component tweaks\n" + "Shapes were detected that have component tweaks on their " + "components. Please remove the component tweaks to " + "continue.\n\n" + "### Repair\n" + "The repair action will try to *freeze* the component " + "tweaks into the shapes, which is usually the correct fix " + "if the mesh has no construction history (= has its " + "history deleted)."), + detail=( + "Maya allows to store component tweaks within shape nodes " + "which are applied between its `inMesh` and `outMesh` " + "connections resulting in the output of a shape node " + "differing from the input. We usually want to avoid this " + "for published meshes (in particular for Maya scenes) as " + "it can have unintended results when using these meshes " + "as intermediate meshes since it applies positional " + "differences without being visible edits in the node " + "graph.\n\n" + "These tweaks are traditionally stored in the `.pnts` " + "attribute of shapes.") + ) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py index 398b6fb7bf..9084374c76 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py @@ -28,7 +28,7 @@ class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin): parent.split("|")[1] for parent in (joints_parents + geo_parents) } - self.log.info(parents_set) + self.log.debug(parents_set) if len(set(parents_set)) > 2: raise PublishXmlValidationError( diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py new file mode 100644 index 0000000000..1dbe1c454c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""Plugin for validating naming conventions.""" +from maya import cmds + +import pyblish.api + +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, + PublishValidationError +) + + +class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates top group hierarchy in the SETs + Make sure the object inside the SETs are always top + group of the hierarchy + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Top Group Hierarchy" + families = ["rig.fbx"] + + def process(self, instance): + invalid = [] + skeleton_mesh_data = instance.data("skeleton_mesh", []) + if skeleton_mesh_data: + invalid = self.get_top_hierarchy(skeleton_mesh_data) + if invalid: + raise PublishValidationError( + "The skeletonMesh_SET includes the object which " + "is not at the top hierarchy: {}".format(invalid)) + + def get_top_hierarchy(self, targets): + targets = cmds.ls(targets, long=True) # ensure long names + non_top_hierarchy_list = [ + target for target in targets if target.count("|") > 2 + ] + return non_top_hierarchy_list diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index b2cb2ebda2..42d3dc3ac8 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -69,11 +69,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid = [] - project_settings = get_project_settings( - legacy_io.Session["AVALON_PROJECT"] - ) collision_prefixes = ( - project_settings + instance.context.data["project_settings"] ["maya"] ["create"] ["CreateUnrealStaticMesh"] @@ -105,7 +102,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, cl_r = re.compile(regex_collision) - mesh_name = "{}{}".format(instance.data["asset"], + asset_name = instance.data["assetEntity"]["name"] + mesh_name = "{}{}".format(asset_name, instance.data.get("variant", [])) for obj in collision_set: @@ -140,12 +138,12 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, return if not self.validate_mesh and not self.validate_collision: - self.log.info("Validation of both mesh and collision names" - "is disabled.") + self.log.debug("Validation of both mesh and collision names" + "is disabled.") return if not instance.data.get("collisionMembers", None): - self.log.info("There are no collision objects to validate") + self.log.debug("There are no collision objects to validate") return invalid = self.get_invalid(instance) diff --git a/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py b/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py index a71849da00..14571203ea 100644 --- a/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py +++ b/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py @@ -52,6 +52,6 @@ class ValidateVRayDistributedRendering(pyblish.api.InstancePlugin): renderlayer = instance.data.get("renderlayer") with lib.renderlayer(renderlayer): - cls.log.info("Enabling Distributed Rendering " - "ignore in batch mode..") + cls.log.debug("Enabling Distributed Rendering " + "ignore in batch mode..") cmds.setAttr(cls.ignored_attr, True) diff --git a/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py b/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py index 06250f5779..a8085418e7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py +++ b/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py @@ -54,7 +54,7 @@ class ValidateYetiRenderScriptCallbacks(pyblish.api.InstancePlugin): # has any yeti callback set or not since if the callback # is there it wouldn't error and if it weren't then # nothing happens because there are no yeti nodes. - cls.log.info( + cls.log.debug( "Yeti is loaded but no yeti nodes were found. " "Callback validation skipped.." ) @@ -62,7 +62,7 @@ class ValidateYetiRenderScriptCallbacks(pyblish.api.InstancePlugin): renderer = instance.data["renderer"] if renderer == "redshift": - cls.log.info("Redshift ignores any pre and post render callbacks") + cls.log.debug("Redshift ignores any pre and post render callbacks") return False callback_lookup = cls.callbacks.get(renderer, {}) diff --git a/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py b/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py index 96fb475a0a..50a27589ad 100644 --- a/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py +++ b/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py @@ -37,8 +37,8 @@ class ValidateYetiRigInputShapesInInstance(pyblish.api.Validator): # Allow publish without input meshes. if not shapes: - cls.log.info("Found no input meshes for %s, skipping ..." - % instance) + cls.log.debug("Found no input meshes for %s, skipping ..." + % instance) return [] # check if input node is part of groomRig instance diff --git a/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py b/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py index 0ce2b21dcd..076b0047bb 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py +++ b/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py @@ -10,6 +10,7 @@ from openpype.client import get_last_version_by_subset_name from openpype.hosts.maya import api from . import lib from .alembic import get_alembic_ids_cache +from .usd import is_usd_lib_supported, get_usd_ids_cache log = logging.getLogger(__name__) @@ -74,6 +75,13 @@ def get_nodes_by_id(standin): # Support alembic files directly return get_alembic_ids_cache(path) + elif ( + is_usd_lib_supported and + any(path.endswith(ext) for ext in [".usd", ".usda", ".usdc"]) + ): + # Support usd files directly + return get_usd_ids_cache(path) + json_path = None for f in os.listdir(os.path.dirname(path)): if f.endswith(".json"): diff --git a/openpype/hosts/maya/tools/mayalookassigner/commands.py b/openpype/hosts/maya/tools/mayalookassigner/commands.py index a1290aa68d..86df502ecd 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/commands.py +++ b/openpype/hosts/maya/tools/mayalookassigner/commands.py @@ -4,7 +4,7 @@ from collections import defaultdict import maya.cmds as cmds -from openpype.client import get_assets +from openpype.client import get_assets, get_asset_name_identifier from openpype.pipeline import ( remove_container, registered_host, @@ -128,7 +128,8 @@ def create_items_from_nodes(nodes): project_name = get_current_project_name() asset_ids = set(id_hashes.keys()) - asset_docs = get_assets(project_name, asset_ids, fields=["name"]) + fields = {"_id", "name", "data.parents"} + asset_docs = get_assets(project_name, asset_ids, fields=fields) asset_docs_by_id = { str(asset_doc["_id"]): asset_doc for asset_doc in asset_docs @@ -138,8 +139,13 @@ def create_items_from_nodes(nodes): asset_doc = asset_docs_by_id.get(asset_id) # Skip if asset id is not found if not asset_doc: - log.warning("Id not found in the database, skipping '%s'." % _id) - log.warning("Nodes: %s" % id_nodes) + log.warning( + "Id found on {num} nodes for which no asset is found database," + " skipping '{asset_id}'".format( + num=len(nodes), + asset_id=asset_id + ) + ) continue # Collect available look subsets for this asset @@ -151,8 +157,9 @@ def create_items_from_nodes(nodes): namespace = get_namespace_from_node(node) namespaces.add(namespace) + label = get_asset_name_identifier(asset_doc) asset_view_items.append({ - "label": asset_doc["name"], + "label": label, "asset": asset_doc, "looks": looks, "namespaces": namespaces diff --git a/openpype/hosts/maya/tools/mayalookassigner/usd.py b/openpype/hosts/maya/tools/mayalookassigner/usd.py new file mode 100644 index 0000000000..6b5cb2f0f5 --- /dev/null +++ b/openpype/hosts/maya/tools/mayalookassigner/usd.py @@ -0,0 +1,38 @@ +from collections import defaultdict + +try: + from pxr import Usd + is_usd_lib_supported = True +except ImportError: + is_usd_lib_supported = False + + +def get_usd_ids_cache(path): + # type: (str) -> dict + """Build a id to node mapping in a USD file. + + Nodes without IDs are ignored. + + Returns: + dict: Mapping of id to nodes in the USD file. + + """ + if not is_usd_lib_supported: + raise RuntimeError("No pxr.Usd python library available.") + + stage = Usd.Stage.Open(path) + ids = {} + for prim in stage.Traverse(): + attr = prim.GetAttribute("userProperties:cbId") + if not attr.IsValid(): + continue + value = attr.Get() + if not value: + continue + path = str(prim.GetPath()) + ids[path] = value + + cache = defaultdict(list) + for path, value in ids.items(): + cache[value].append(path) + return dict(cache) diff --git a/openpype/hosts/maya/tools/mayalookassigner/widgets.py b/openpype/hosts/maya/tools/mayalookassigner/widgets.py index f2df17e68c..ef29a4c726 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/widgets.py +++ b/openpype/hosts/maya/tools/mayalookassigner/widgets.py @@ -3,6 +3,7 @@ from collections import defaultdict from qtpy import QtWidgets, QtCore +from openpype.client import get_asset_name_identifier from openpype.tools.utils.models import TreeModel from openpype.tools.utils.lib import ( preserve_expanded_rows, @@ -90,15 +91,13 @@ class AssetOutliner(QtWidgets.QWidget): def get_all_assets(self): """Add all items from the current scene""" - items = [] with preserve_expanded_rows(self.view): with preserve_selection(self.view): self.clear() nodes = commands.get_all_asset_nodes() items = commands.create_items_from_nodes(nodes) self.add_items(items) - - return len(items) > 0 + return len(items) > 0 def get_selected_assets(self): """Add all selected items from the current scene""" @@ -128,7 +127,7 @@ class AssetOutliner(QtWidgets.QWidget): asset_namespaces = defaultdict(set) for item in items: asset_id = str(item["asset"]["_id"]) - asset_name = item["asset"]["name"] + asset_name = get_asset_name_identifier(item["asset"]) asset_namespaces[asset_name].add(item.get("namespace")) if asset_name in assets: diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 1af5ff365d..c6ccd0baf1 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -50,6 +50,11 @@ from .utils import ( get_colorspace_list ) +from .actions import ( + SelectInvalidAction, + SelectInstanceNodeAction +) + __all__ = ( "file_extensions", "has_unsaved_changes", @@ -92,5 +97,8 @@ __all__ = ( "create_write_node", "colorspace_exists_on_node", - "get_colorspace_list" + "get_colorspace_list", + + "SelectInvalidAction", + "SelectInstanceNodeAction" ) diff --git a/openpype/hosts/nuke/api/actions.py b/openpype/hosts/nuke/api/actions.py index c955a85acc..995e6427af 100644 --- a/openpype/hosts/nuke/api/actions.py +++ b/openpype/hosts/nuke/api/actions.py @@ -20,33 +20,58 @@ class SelectInvalidAction(pyblish.api.Action): def process(self, context, plugin): - try: - import nuke - except ImportError: - raise ImportError("Current host is not Nuke") - errored_instances = get_errored_instances_from_context(context, plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") - invalid = list() + invalid = set() for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): - invalid.append(invalid_nodes[0]) + invalid.update(invalid_nodes) else: self.log.warning("Plug-in returned to be invalid, " "but has no selectable nodes.") - # Ensure unique (process each node only once) - invalid = list(set(invalid)) - if invalid: self.log.info("Selecting invalid nodes: {}".format(invalid)) reset_selection() select_nodes(invalid) else: self.log.info("No invalid nodes found.") + + +class SelectInstanceNodeAction(pyblish.api.Action): + """Select instance node for failed plugin.""" + label = "Select instance node" + on = "failed" # This action is only available on a failed plug-in + icon = "mdi.cursor-default-click" + + def process(self, context, plugin): + + # Get the errored instances for the plug-in + errored_instances = get_errored_instances_from_context( + context, plugin) + + # Get the invalid nodes for the plug-ins + self.log.info("Finding instance nodes..") + nodes = set() + for instance in errored_instances: + instance_node = instance.data.get("transientData", {}).get("node") + if not instance_node: + raise RuntimeError( + "No transientData['node'] found on instance: {}".format( + instance + ) + ) + nodes.add(instance_node) + + if nodes: + self.log.info("Selecting instance nodes: {}".format(nodes)) + reset_selection() + select_nodes(nodes) + else: + self.log.info("No instance nodes found.") diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 4a1e109b17..88c587faf6 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -13,6 +13,7 @@ from collections import OrderedDict import nuke from qtpy import QtCore, QtWidgets +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_project, get_asset_by_name, @@ -40,7 +41,6 @@ from openpype.settings import ( from openpype.modules import ModulesManager from openpype.pipeline.template_data import get_template_data_with_names from openpype.pipeline import ( - get_current_project_name, discover_legacy_creator_plugins, Anatomy, get_current_host_name, @@ -48,20 +48,15 @@ from openpype.pipeline import ( get_current_asset_name, ) from openpype.pipeline.context_tools import ( - get_current_project_asset, get_custom_workfile_template_from_session ) -from openpype.pipeline.colorspace import ( - get_imageio_config -) +from openpype.pipeline.colorspace import get_imageio_config from openpype.pipeline.workfile import BuildWorkfile from . import gizmo_menu from .constants import ASSIST -from .workio import ( - save_file, - open_file -) +from .workio import save_file +from .utils import get_node_outputs log = Logger.get_logger(__name__) @@ -126,7 +121,7 @@ def deprecated(new_destination): class Context: main_window = None - context_label = None + context_action_item = None project_name = os.getenv("AVALON_PROJECT") # Workfile related code workfiles_launched = False @@ -1104,26 +1099,6 @@ def check_subsetname_exists(nodes, subset_name): False) -def get_render_path(node): - ''' Generate Render path from presets regarding avalon knob data - ''' - avalon_knob_data = read_avalon_data(node) - - nuke_imageio_writes = get_imageio_node_setting( - node_class=avalon_knob_data["families"], - plugin_name=avalon_knob_data["creator"], - subset=avalon_knob_data["subset"] - ) - - data = { - "avalon": avalon_knob_data, - "nuke_imageio_writes": nuke_imageio_writes - } - - anatomy_filled = format_anatomy(data) - return anatomy_filled["render"]["path"].replace("\\", "/") - - def format_anatomy(data): ''' Helping function for formatting of anatomy paths @@ -1133,7 +1108,9 @@ def format_anatomy(data): Return: path (str) ''' - anatomy = Anatomy() + + project_name = get_current_project_name() + anatomy = Anatomy(project_name) log.debug("__ anatomy.templates: {}".format(anatomy.templates)) padding = None @@ -1151,8 +1128,10 @@ def format_anatomy(data): file = script_name() data["version"] = get_version_from_path(file) - project_name = anatomy.project_name - asset_name = data["asset"] + if AYON_SERVER_ENABLED: + asset_name = data["folderPath"] + else: + asset_name = data["asset"] task_name = data["task"] host_name = get_current_host_name() context_data = get_template_data_with_names( @@ -2041,6 +2020,7 @@ class WorkfileSettings(object): ) workfile_settings = imageio_host["workfile"] + viewer_process_settings = imageio_host["viewer"]["viewerProcess"] if not config_data: # TODO: backward compatibility for old projects - remove later @@ -2091,6 +2071,15 @@ class WorkfileSettings(object): workfile_settings.pop("colorManagement", None) workfile_settings.pop("OCIO_config", None) + # get monitor lut from settings respecting Nuke version differences + monitor_lut = workfile_settings.pop("monitorLut", None) + monitor_lut_data = self._get_monitor_settings( + viewer_process_settings, monitor_lut) + + # set monitor related knobs luts (MonitorOut, Thumbnails) + for knob, value_ in monitor_lut_data.items(): + workfile_settings[knob] = value_ + # then set the rest for knob, value_ in workfile_settings.items(): # skip unfilled ocio config path @@ -2107,8 +2096,9 @@ class WorkfileSettings(object): # set ocio config path if config_data: + config_path = config_data["path"].replace("\\", "/") log.info("OCIO config path found: `{}`".format( - config_data["path"])) + config_path)) # check if there's a mismatch between environment and settings correct_settings = self._is_settings_matching_environment( @@ -2118,6 +2108,40 @@ class WorkfileSettings(object): if correct_settings: self._set_ocio_config_path_to_workfile(config_data) + def _get_monitor_settings(self, viewer_lut, monitor_lut): + """ Get monitor settings from viewer and monitor lut + + Args: + viewer_lut (str): viewer lut string + monitor_lut (str): monitor lut string + + Returns: + dict: monitor settings + """ + output_data = {} + m_display, m_viewer = get_viewer_config_from_string(monitor_lut) + v_display, v_viewer = get_viewer_config_from_string(viewer_lut) + + # set monitor lut differently for nuke version 14 + if nuke.NUKE_VERSION_MAJOR >= 14: + output_data["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + output_data["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=False) + + if nuke.NUKE_VERSION_MAJOR == 13: + output_data["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + output_data["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=True) + if nuke.NUKE_VERSION_MAJOR <= 12: + output_data["monitorLut"] = create_viewer_profile_string( + m_viewer, m_display, path_like=True) + + return output_data + def _is_settings_matching_environment(self, config_data): """ Check if OCIO config path is different from environment @@ -2232,7 +2256,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. Returns: str: OCIO config path with environment variable TCL expression """ - config_path = config_data["path"] + config_path = config_data["path"].replace("\\", "/") config_template = config_data["template"] included_vars = self._get_included_vars(config_template) @@ -2270,27 +2294,53 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. ''' Adds correct colorspace to write node dict ''' - for node in nuke.allNodes(filter="Group"): + for node in nuke.allNodes(filter="Group", group=self._root_node): + log.info("Setting colorspace to `{}`".format(node.name())) # get data from avalon knob avalon_knob_data = read_avalon_data(node) + node_data = get_node_data(node, INSTANCE_DATA_KNOB) - if avalon_knob_data.get("id") != "pyblish.avalon.instance": + if ( + # backward compatibility + # TODO: remove this once old avalon data api will be removed + avalon_knob_data + and avalon_knob_data.get("id") != "pyblish.avalon.instance" + ): + continue + elif ( + node_data + and node_data.get("id") != "pyblish.avalon.instance" + ): continue - if "creator" not in avalon_knob_data: + if ( + # backward compatibility + # TODO: remove this once old avalon data api will be removed + avalon_knob_data + and "creator" not in avalon_knob_data + ): + continue + elif ( + node_data + and "creator_identifier" not in node_data + ): continue - # establish families - families = [avalon_knob_data["family"]] - if avalon_knob_data.get("families"): - families.append(avalon_knob_data.get("families")) + nuke_imageio_writes = None + if avalon_knob_data: + # establish families + families = [avalon_knob_data["family"]] + if avalon_knob_data.get("families"): + families.append(avalon_knob_data.get("families")) - nuke_imageio_writes = get_imageio_node_setting( - node_class=avalon_knob_data["families"], - plugin_name=avalon_knob_data["creator"], - subset=avalon_knob_data["subset"] - ) + nuke_imageio_writes = get_imageio_node_setting( + node_class=avalon_knob_data["families"], + plugin_name=avalon_knob_data["creator"], + subset=avalon_knob_data["subset"] + ) + elif node_data: + nuke_imageio_writes = get_write_node_template_attr(node) log.debug("nuke_imageio_writes: `{}`".format(nuke_imageio_writes)) @@ -2730,16 +2780,28 @@ def find_free_space_to_paste_nodes( @contextlib.contextmanager -def maintained_selection(): +def maintained_selection(exclude_nodes=None): """Maintain selection during context + Maintain selection during context and unselect + all nodes after context is done. + + Arguments: + exclude_nodes (list[nuke.Node]): list of nodes to be unselected + before context is done + Example: >>> with maintained_selection(): ... node["selected"].setValue(True) >>> print(node["selected"].value()) False """ + if exclude_nodes: + for node in exclude_nodes: + node["selected"].setValue(False) + previous_selection = nuke.selectedNodes() + try: yield finally: @@ -2751,6 +2813,51 @@ def maintained_selection(): select_nodes(previous_selection) +@contextlib.contextmanager +def swap_node_with_dependency(old_node, new_node): + """ Swap node with dependency + + Swap node with dependency and reconnect all inputs and outputs. + It removes old node. + + Arguments: + old_node (nuke.Node): node to be replaced + new_node (nuke.Node): node to replace with + + Example: + >>> old_node_name = old_node["name"].value() + >>> print(old_node_name) + old_node_name_01 + >>> with swap_node_with_dependency(old_node, new_node) as node_name: + ... new_node["name"].setValue(node_name) + >>> print(new_node["name"].value()) + old_node_name_01 + """ + # preserve position + xpos, ypos = old_node.xpos(), old_node.ypos() + # preserve selection after all is done + outputs = get_node_outputs(old_node) + inputs = old_node.dependencies() + node_name = old_node["name"].value() + + try: + nuke.delete(old_node) + + yield node_name + finally: + + # Reconnect inputs + for i, node in enumerate(inputs): + new_node.setInput(i, node) + # Reconnect outputs + if outputs: + for n, pipes in outputs.items(): + for i in pipes: + n.setInput(i, new_node) + # return to original position + new_node.setXYpos(xpos, ypos) + + def reset_selection(): """Deselect all selected nodes""" for node in nuke.selectedNodes(): @@ -2761,9 +2868,10 @@ def select_nodes(nodes): """Selects all inputted nodes Arguments: - nodes (list): nuke nodes to be selected + nodes (Union[list, tuple, set]): nuke nodes to be selected """ - assert isinstance(nodes, (list, tuple)), "nodes has to be list or tuple" + assert isinstance(nodes, (list, tuple, set)), \ + "nodes has to be list, tuple or set" for node in nodes: node["selected"].setValue(True) @@ -2847,13 +2955,13 @@ def process_workfile_builder(): "workfile_builder", {}) # get settings - createfv_on = workfile_builder.get("create_first_version") or None + create_fv_on = workfile_builder.get("create_first_version") or None builder_on = workfile_builder.get("builder_on_start") or None last_workfile_path = os.environ.get("AVALON_LAST_WORKFILE") # generate first version in file not existing and feature is enabled - if createfv_on and not os.path.exists(last_workfile_path): + if create_fv_on and not os.path.exists(last_workfile_path): # get custom template path if any custom_template_path = get_custom_workfile_template_from_session( project_settings=project_settings @@ -3320,11 +3428,11 @@ def get_viewer_config_from_string(input_string): display = split[0] elif "(" in viewer: pattern = r"([\w\d\s\.\-]+).*[(](.*)[)]" - result = re.findall(pattern, viewer) + result_ = re.findall(pattern, viewer) try: - result = result.pop() - display = str(result[1]).rstrip() - viewer = str(result[0]).rstrip() + result_ = result_.pop() + display = str(result_[1]).rstrip() + viewer = str(result_[0]).rstrip() except IndexError: raise IndexError(( "Viewer Input string is not correct. " @@ -3332,3 +3440,46 @@ def get_viewer_config_from_string(input_string): ).format(input_string)) return (display, viewer) + + +def create_viewer_profile_string(viewer, display=None, path_like=False): + """Convert viewer and display to string + + Args: + viewer (str): viewer name + display (Optional[str]): display name + path_like (Optional[bool]): if True, return path like string + + Returns: + str: viewer config string + """ + if not display: + return viewer + + if path_like: + return "{}/{}".format(display, viewer) + return "{} ({})".format(viewer, display) + + +def get_filenames_without_hash(filename, frame_start, frame_end): + """Get filenames without frame hash + i.e. "renderCompositingMain.baking.0001.exr" + + Args: + filename (str): filename with frame hash + frame_start (str): start of the frame + frame_end (str): end of the frame + + Returns: + list: filename per frame of the sequence + """ + filenames = [] + for frame in range(int(frame_start), (int(frame_end) + 1)): + if "#" in filename: + # use regex to convert #### to {:0>4} + def replace(match): + return "{{:0>{}}}".format(len(match.group())) + filename_without_hashes = re.sub("#+", replace, filename) + new_filename = filename_without_hashes.format(frame) + filenames.append(new_filename) + return filenames diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 65b4b91323..7bc17ff504 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -129,9 +129,6 @@ class NukeHost( register_event_callback("workio.open_file", check_inventory_versions) register_event_callback("taskChanged", change_context_label) - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled) - _install_menu() # add script menu @@ -239,9 +236,13 @@ def _install_menu(): if not ASSIST: label = get_context_label() - Context.context_label = label - context_action = menu.addCommand(label) - context_action.setEnabled(False) + context_action_item = menu.addCommand("Context") + context_action_item.setEnabled(False) + + Context.context_action_item = context_action_item + + context_action = context_action_item.action() + context_action.setText(label) # add separator after context label menu.addSeparator() @@ -351,26 +352,21 @@ def _install_menu(): def change_context_label(): - menubar = nuke.menu("Nuke") - menu = menubar.findItem(MENU_LABEL) + if ASSIST: + return - label = get_context_label() + context_action_item = Context.context_action_item + if context_action_item is None: + return + context_action = context_action_item.action() - rm_item = [ - (i, item) for i, item in enumerate(menu.items()) - if Context.context_label in item.name() - ][0] + old_label = context_action.text() + new_label = get_context_label() - menu.removeItem(rm_item[1].name()) - - context_action = menu.addCommand( - label, - index=(rm_item[0]) - ) - context_action.setEnabled(False) + context_action.setText(new_label) log.info("Task label changed from `{}` to `{}`".format( - Context.context_label, label)) + old_label, new_label)) def add_shortcuts_from_presets(): @@ -402,25 +398,6 @@ def add_shortcuts_from_presets(): log.error(e) -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle node passthrough states on instance toggles.""" - - log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( - instance, old_value, new_value)) - - # Whether instances should be passthrough based on new value - - with viewer_update_and_undo_stop(): - n = instance[0] - try: - n["publish"].value() - except ValueError: - n = add_publish_knob(n) - log.info(" `Publish` knob was added to write node..") - - n["publish"].setValue(new_value) - - def containerise(node, name, namespace, @@ -478,8 +455,6 @@ def parse_container(node): """ data = read_avalon_data(node) - # (TODO) Remove key validation when `ls` has re-implemented. - # # If not all required data return the empty container required = ["schema", "id", "name", "namespace", "loader", "representation"] @@ -487,7 +462,10 @@ def parse_container(node): return # Store the node's name - data["objectName"] = node["name"].value() + data.update({ + "objectName": node.fullName(), + "node": node, + }) return data @@ -543,6 +521,9 @@ def list_instances(creator_id=None): For SubsetManager + Args: + creator_id (Optional[str]): creator identifier + Returns: (list) of dictionaries matching instances format """ @@ -575,10 +556,13 @@ def list_instances(creator_id=None): if creator_id and instance_data["creator_identifier"] != creator_id: continue - if instance_data["instance_id"] in instance_ids: + instance_id = instance_data.get("instance_id") + if not instance_id: + pass + elif instance_id in instance_ids: instance_data.pop("instance_id") else: - instance_ids.add(instance_data["instance_id"]) + instance_ids.add(instance_id) # node name could change, so update subset name data _update_subset_name_data(instance_data, node) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 3975f2e57c..301b9533a9 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -21,6 +21,9 @@ from openpype.pipeline import ( CreatedInstance, get_current_task_name ) +from openpype.lib.transcoding import ( + VIDEO_EXTENSIONS +) from .lib import ( INSTANCE_DATA_KNOB, Knobby, @@ -35,7 +38,8 @@ from .lib import ( get_node_data, get_view_process_node, get_viewer_config_from_string, - deprecated + deprecated, + get_filenames_without_hash ) from .pipeline import ( list_instances, @@ -327,6 +331,7 @@ class NukeWriteCreator(NukeCreator): "frames": "Use existing frames" } if ("farm_rendering" in self.instance_attributes): + rendering_targets["frames_farm"] = "Use existing frames - farm" rendering_targets["farm"] = "Farm rendering" return EnumDef( @@ -378,11 +383,7 @@ class NukeWriteCreator(NukeCreator): sys.exc_info()[2] ) - def apply_settings( - self, - project_settings, - system_settings - ): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" # plugin settings @@ -536,6 +537,7 @@ class NukeLoader(LoaderPlugin): node.addKnob(knob) def clear_members(self, parent_node): + parent_class = parent_node.Class() members = self.get_members(parent_node) dependent_nodes = None @@ -548,6 +550,8 @@ class NukeLoader(LoaderPlugin): break for member in members: + if member.Class() == parent_class: + continue self.log.info("removing node: `{}".format(member.name())) nuke.delete(member) @@ -583,18 +587,25 @@ class ExporterReview(object): def get_file_info(self): if self.collection: # get path - self.fname = os.path.basename(self.collection.format( - "{head}{padding}{tail}")) + self.fname = os.path.basename( + self.collection.format("{head}{padding}{tail}") + ) self.fhead = self.collection.format("{head}") # get first and last frame self.first_frame = min(self.collection.indexes) self.last_frame = max(self.collection.indexes) + + # make sure slate frame is not included + frame_start_handle = self.instance.data["frameStartHandle"] + if frame_start_handle > self.first_frame: + self.first_frame = frame_start_handle + else: self.fname = os.path.basename(self.path_in) self.fhead = os.path.splitext(self.fname)[0] + "." - self.first_frame = self.instance.data.get("frameStartHandle", None) - self.last_frame = self.instance.data.get("frameEndHandle", None) + self.first_frame = self.instance.data["frameStartHandle"] + self.last_frame = self.instance.data["frameEndHandle"] if "#" in self.fhead: self.fhead = self.fhead.replace("#", "")[:-1] @@ -630,6 +641,10 @@ class ExporterReview(object): "frameStart": self.first_frame, "frameEnd": self.last_frame, }) + if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: + filenames = get_filenames_without_hash( + self.file, self.first_frame, self.last_frame) + repre["files"] = filenames if self.multiple_presets: repre["outputName"] = self.name @@ -803,7 +818,20 @@ class ExporterReviewMov(ExporterReview): self.log.info("File info was set...") - self.file = self.fhead + self.name + ".{}".format(self.ext) + if ".{}".format(self.ext) in VIDEO_EXTENSIONS: + self.file = "{}{}.{}".format( + self.fhead, self.name, self.ext) + else: + # Output is image (or image sequence) + # When the file is an image it's possible it + # has extra information after the `fhead` that + # we want to preserve, e.g. like frame numbers + # or frames hashes like `####` + filename_no_ext = os.path.splitext( + os.path.basename(self.path_in))[0] + after_head = filename_no_ext[len(self.fhead):] + self.file = "{}{}.{}.{}".format( + self.fhead, self.name, after_head, self.ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") @@ -872,6 +900,11 @@ class ExporterReviewMov(ExporterReview): r_node["origlast"].setValue(self.last_frame) r_node["colorspace"].setValue(self.write_colorspace) + # do not rely on defaults, set explicitly + # to be sure it is set correctly + r_node["frame_mode"].setValue("expression") + r_node["frame"].setValue("") + if read_raw: r_node["raw"].setValue(1) @@ -924,7 +957,6 @@ class ExporterReviewMov(ExporterReview): self.log.debug("Path: {}".format(self.path)) write_node["file"].setValue(str(self.path)) write_node["file_type"].setValue(str(self.ext)) - # Knobs `meta_codec` and `mov64_codec` are not available on centos. # TODO shouldn't this come from settings on outputs? try: diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index 9d7604c58d..ee9f75d10d 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -163,8 +163,10 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): ) return loaded_representation_ids - def _before_repre_load(self, placeholder, representation): + def _before_placeholder_load(self, placeholder): placeholder.data["nodes_init"] = nuke.allNodes() + + def _before_repre_load(self, placeholder, representation): placeholder.data["last_repre_id"] = str(representation["_id"]) def collect_placeholders(self): @@ -197,6 +199,13 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): return self.get_load_plugin_options(options) def post_placeholder_process(self, placeholder, failed): + """Cleanup placeholder after load of its corresponding representations. + + Args: + placeholder (PlaceholderItem): Item which was just used to load + representation. + failed (bool): Loading of representation failed. + """ # deselect all selected nodes placeholder_node = nuke.toNode(placeholder.scene_identifier) @@ -603,6 +612,13 @@ class NukePlaceholderCreatePlugin( return self.get_create_plugin_options(options) def post_placeholder_process(self, placeholder, failed): + """Cleanup placeholder after load of its corresponding representations. + + Args: + placeholder (PlaceholderItem): Item which was just used to load + representation. + failed (bool): Loading of representation failed. + """ # deselect all selected nodes placeholder_node = nuke.toNode(placeholder.scene_identifier) diff --git a/openpype/hosts/nuke/plugins/load/actions.py b/openpype/hosts/nuke/plugins/load/actions.py index 3227a7ed98..635318f53d 100644 --- a/openpype/hosts/nuke/plugins/load/actions.py +++ b/openpype/hosts/nuke/plugins/load/actions.py @@ -17,7 +17,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): "yeticache", "pointcache"] representations = ["*"] - extension = {"*"} + extensions = {"*"} label = "Set frame range" order = 11 diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index fe82d70b5e..54d37da203 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -27,7 +27,7 @@ class LoadBackdropNodes(load.LoaderPlugin): families = ["workfile", "nukenodes"] representations = ["*"] - extension = {"nk"} + extensions = {"nk"} label = "Import Nuke Nodes" order = 0 @@ -64,8 +64,7 @@ class LoadBackdropNodes(load.LoaderPlugin): data_imprint = { "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name + "colorspaceInput": colorspace } for k in add_keys: @@ -194,7 +193,7 @@ class LoadBackdropNodes(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") @@ -207,10 +206,11 @@ class LoadBackdropNodes(load.LoaderPlugin): add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -252,6 +252,6 @@ class LoadBackdropNodes(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index fec4ee556e..898c5e4e7b 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -26,7 +26,7 @@ class AlembicCameraLoader(load.LoaderPlugin): families = ["camera"] representations = ["*"] - extension = {"abc"} + extensions = {"abc"} label = "Load Alembic Camera" icon = "camera" @@ -48,10 +48,11 @@ class AlembicCameraLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -111,9 +112,7 @@ class AlembicCameraLoader(load.LoaderPlugin): project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] - # get corresponding node - camera_node = nuke.toNode(object_name) + object_name = container["node"] # get main variables version_data = version_doc.get("data", {}) @@ -126,11 +125,12 @@ class AlembicCameraLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -196,6 +196,6 @@ class AlembicCameraLoader(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 19038b168d..3a2ec3dbee 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -189,8 +189,6 @@ class LoadClip(plugin.NukeLoader): value_ = value_.replace("\\", "/") data_imprint[key] = value_ - data_imprint["objectName"] = read_name - if add_retime and version_data.get("retime", None): data_imprint["addRetime"] = True @@ -254,7 +252,7 @@ class LoadClip(plugin.NukeLoader): is_sequence = len(representation["files"]) > 1 - read_node = nuke.toNode(container['objectName']) + read_node = container["node"] if is_sequence: representation = self._representation_with_hash_in_frame( @@ -299,9 +297,6 @@ class LoadClip(plugin.NukeLoader): "Representation id `{}` is failing to load".format(repre_id)) return - read_name = self._get_node_name(representation) - - read_node["name"].setValue(read_name) read_node["file"].setValue(filepath) # to avoid multiple undo steps for rest of process @@ -356,7 +351,7 @@ class LoadClip(plugin.NukeLoader): self.set_as_member(read_node) def remove(self, container): - read_node = nuke.toNode(container['objectName']) + read_node = container["node"] assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 89597e76cc..cc048372d4 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -24,7 +24,7 @@ class LoadEffects(load.LoaderPlugin): families = ["effect"] representations = ["*"] - extension = {"json"} + extensions = {"json"} label = "Load Effects - nodes" order = 0 @@ -62,11 +62,12 @@ class LoadEffects(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -159,7 +160,7 @@ class LoadEffects(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -175,12 +176,13 @@ class LoadEffects(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -212,7 +214,7 @@ class LoadEffects(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -346,6 +348,6 @@ class LoadEffects(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index efe67be4aa..cdfdfef3b8 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -25,7 +25,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): families = ["effect"] representations = ["*"] - extension = {"json"} + extensions = {"json"} label = "Load Effects - Input Process" order = 0 @@ -63,11 +63,12 @@ class LoadEffectsInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -98,7 +99,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -164,28 +165,26 @@ class LoadEffectsInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") - name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) workfile_first_frame = int(nuke.root()["first_frame"].getValue()) - namespace = container['namespace'] colorspace = version_data.get("colorspace", None) - object_name = "{}_{}".format(name, namespace) add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -217,7 +216,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -251,11 +250,6 @@ class LoadEffectsInputProcess(load.LoaderPlugin): output = nuke.createNode("Output") output.setInput(0, pre_node) - # # try to place it under Viewer1 - # if not self.connect_active_viewer(GN): - # nuke.delete(GN) - # return - # get all versions in list last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] @@ -365,6 +359,6 @@ class LoadEffectsInputProcess(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 6b848ee276..19b5cca74e 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -12,7 +12,8 @@ from openpype.pipeline import ( from openpype.hosts.nuke.api.lib import ( maintained_selection, get_avalon_knob_data, - set_avalon_knob_data + set_avalon_knob_data, + swap_node_with_dependency, ) from openpype.hosts.nuke.api import ( containerise, @@ -26,7 +27,7 @@ class LoadGizmo(load.LoaderPlugin): families = ["gizmo"] representations = ["*"] - extension = {"gizmo"} + extensions = {"nk"} label = "Load Gizmo" order = 0 @@ -45,7 +46,7 @@ class LoadGizmo(load.LoaderPlugin): data (dict): compulsory attribute > not used Returns: - nuke node: containerised nuke node object + nuke node: containerized nuke node object """ # get main variables @@ -63,11 +64,12 @@ class LoadGizmo(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -83,12 +85,12 @@ class LoadGizmo(load.LoaderPlugin): # add group from nk nuke.nodePaste(file) - GN = nuke.selectedNode() + group_node = nuke.selectedNode() - GN["name"].setValue(object_name) + group_node["name"].setValue(object_name) return containerise( - node=GN, + node=group_node, name=name, namespace=namespace, context=context, @@ -110,7 +112,7 @@ class LoadGizmo(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -125,32 +127,35 @@ class LoadGizmo(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) + # capture pipeline metadata + avalon_data = get_avalon_knob_data(group_node) + # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() - with maintained_selection(): - xpos = GN.xpos() - ypos = GN.ypos() - avalon_data = get_avalon_knob_data(GN) - nuke.delete(GN) - # add group from nk + with maintained_selection([group_node]): + # insert nuke script to the script nuke.nodePaste(file) - - GN = nuke.selectedNode() - set_avalon_knob_data(GN, avalon_data) - GN.setXYpos(xpos, ypos) - GN["name"].setValue(object_name) + # convert imported to selected node + new_group_node = nuke.selectedNode() + # swap nodes with maintained connections + with swap_node_with_dependency( + group_node, new_group_node) as node_name: + new_group_node["name"].setValue(node_name) + # set updated pipeline metadata + set_avalon_knob_data(new_group_node, avalon_data) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] @@ -161,16 +166,17 @@ class LoadGizmo(load.LoaderPlugin): color_value = self.node_color else: color_value = "0xd88467ff" - GN["tile_color"].setValue(int(color_value, 16)) + + new_group_node["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) - return update_container(GN, data_imprint) + return update_container(new_group_node, data_imprint) def switch(self, container, representation): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index a8e1218cbe..5b4877678a 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -14,7 +14,8 @@ from openpype.hosts.nuke.api.lib import ( maintained_selection, create_backdrop, get_avalon_knob_data, - set_avalon_knob_data + set_avalon_knob_data, + swap_node_with_dependency, ) from openpype.hosts.nuke.api import ( containerise, @@ -28,7 +29,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): families = ["gizmo"] representations = ["*"] - extension = {"gizmo"} + extensions = {"nk"} label = "Load Gizmo - Input Process" order = 0 @@ -47,7 +48,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): data (dict): compulsory attribute > not used Returns: - nuke node: containerised nuke node object + nuke node: containerized nuke node object """ # get main variables @@ -65,11 +66,12 @@ class LoadGizmoInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -85,17 +87,17 @@ class LoadGizmoInputProcess(load.LoaderPlugin): # add group from nk nuke.nodePaste(file) - GN = nuke.selectedNode() + group_node = nuke.selectedNode() - GN["name"].setValue(object_name) + group_node["name"].setValue(object_name) # try to place it under Viewer1 - if not self.connect_active_viewer(GN): - nuke.delete(GN) + if not self.connect_active_viewer(group_node): + nuke.delete(group_node) return return containerise( - node=GN, + node=group_node, name=name, namespace=namespace, context=context, @@ -117,7 +119,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -132,32 +134,35 @@ class LoadGizmoInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) + # capture pipeline metadata + avalon_data = get_avalon_knob_data(group_node) + # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() - with maintained_selection(): - xpos = GN.xpos() - ypos = GN.ypos() - avalon_data = get_avalon_knob_data(GN) - nuke.delete(GN) - # add group from nk + with maintained_selection([group_node]): + # insert nuke script to the script nuke.nodePaste(file) - - GN = nuke.selectedNode() - set_avalon_knob_data(GN, avalon_data) - GN.setXYpos(xpos, ypos) - GN["name"].setValue(object_name) + # convert imported to selected node + new_group_node = nuke.selectedNode() + # swap nodes with maintained connections + with swap_node_with_dependency( + group_node, new_group_node) as node_name: + new_group_node["name"].setValue(node_name) + # set updated pipeline metadata + set_avalon_knob_data(new_group_node, avalon_data) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] @@ -168,11 +173,11 @@ class LoadGizmoInputProcess(load.LoaderPlugin): color_value = self.node_color else: color_value = "0xd88467ff" - GN["tile_color"].setValue(int(color_value, 16)) + new_group_node["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) - return update_container(GN, data_imprint) + return update_container(new_group_node, data_imprint) def connect_active_viewer(self, group_node): """ @@ -253,6 +258,6 @@ class LoadGizmoInputProcess(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index d8c0a82206..411a61d77b 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -96,7 +96,8 @@ class LoadImage(load.LoaderPlugin): file = file.replace("\\", "/") - repr_cont = context["representation"]["context"] + representation = context["representation"] + repr_cont = representation["context"] frame = repr_cont.get("frame") if frame: padding = len(frame) @@ -104,16 +105,7 @@ class LoadImage(load.LoaderPlugin): frame, format(frame_number, "0{}".format(padding))) - name_data = { - "asset": repr_cont["asset"], - "subset": repr_cont["subset"], - "representation": context["representation"]["name"], - "ext": repr_cont["representation"], - "id": context["representation"]["_id"], - "class_name": self.__class__.__name__ - } - - read_name = self.node_name_template.format(**name_data) + read_name = self._get_node_name(representation) # Create the Loader with the filename path set with viewer_update_and_undo_stop(): @@ -154,8 +146,6 @@ class LoadImage(load.LoaderPlugin): data_imprint.update( {k: context["version"]['data'].get(k, str(None))}) - data_imprint.update({"objectName": read_name}) - r["tile_color"].setValue(int("0x4ecd25ff", 16)) return containerise(r, @@ -176,7 +166,7 @@ class LoadImage(load.LoaderPlugin): inputs: """ - node = nuke.toNode(container["objectName"]) + node = container["node"] frame_number = node["first"].value() assert node.Class() == "Read", "Must be Read" @@ -245,8 +235,22 @@ class LoadImage(load.LoaderPlugin): self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] assert node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): nuke.delete(node) + + def _get_node_name(self, representation): + + repre_cont = representation["context"] + name_data = { + "asset": repre_cont["asset"], + "subset": repre_cont["subset"], + "representation": representation["name"], + "ext": repre_cont["representation"], + "id": representation["_id"], + "class_name": self.__class__.__name__ + } + + return self.node_name_template.format(**name_data) diff --git a/openpype/hosts/nuke/plugins/load/load_matchmove.py b/openpype/hosts/nuke/plugins/load/load_matchmove.py index f942422c00..14ddf20dc3 100644 --- a/openpype/hosts/nuke/plugins/load/load_matchmove.py +++ b/openpype/hosts/nuke/plugins/load/load_matchmove.py @@ -9,7 +9,7 @@ class MatchmoveLoader(load.LoaderPlugin): families = ["matchmove"] representations = ["*"] - extension = {"py"} + extensions = {"py"} defaults = ["Camera", "Object"] diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 0bdcd93dff..3fe92b74d0 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -24,7 +24,7 @@ class AlembicModelLoader(load.LoaderPlugin): families = ["model", "pointcache", "animation"] representations = ["*"] - extension = {"abc"} + extensions = {"abc"} label = "Load Alembic" icon = "cube" @@ -46,10 +46,11 @@ class AlembicModelLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -114,9 +115,9 @@ class AlembicModelLoader(load.LoaderPlugin): # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + # get corresponding node - model_node = nuke.toNode(object_name) + model_node = container["node"] # get main variables version_data = version_doc.get("data", {}) @@ -129,11 +130,12 @@ class AlembicModelLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -142,7 +144,6 @@ class AlembicModelLoader(load.LoaderPlugin): file = get_representation_path(representation).replace("\\", "/") with maintained_selection(): - model_node = nuke.toNode(object_name) model_node['selected'].setValue(True) # collect input output dependencies @@ -163,8 +164,10 @@ class AlembicModelLoader(load.LoaderPlugin): ypos = model_node.ypos() nuke.nodeCopy("%clipboard%") nuke.delete(model_node) + + # paste the node back and set the position nuke.nodePaste("%clipboard%") - model_node = nuke.toNode(object_name) + model_node = nuke.selectedNode() model_node.setXYpos(xpos, ypos) # link to original input nodes diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py new file mode 100644 index 0000000000..c0f8235253 --- /dev/null +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -0,0 +1,347 @@ +import os +import json +import secrets +import nuke +import six + +from openpype.client import ( + get_version_by_id, + get_last_version_by_subset_id +) +from openpype.pipeline import ( + load, + get_current_project_name, + get_representation_path, +) +from openpype.hosts.nuke.api import ( + containerise, + viewer_update_and_undo_stop, + update_container, +) + + +class LoadOcioLookNodes(load.LoaderPlugin): + """Loading Ocio look to the nuke.Node graph""" + + families = ["ociolook"] + representations = ["*"] + extensions = {"json"} + + label = "Load OcioLook [nodes]" + order = 0 + icon = "cc" + color = "white" + ignore_attr = ["useLifetime"] + + # plugin attributes + current_node_color = "0x4ecd91ff" + old_node_color = "0xd88467ff" + + # json file variables + schema_version = 1 + + def load(self, context, name, namespace, data): + """ + Loading function to get the soft effects to particular read node + + Arguments: + context (dict): context of version + name (str): name of the version + namespace (str): asset name + data (dict): compulsory attribute > not used + + Returns: + nuke.Node: containerized nuke.Node object + """ + namespace = namespace or context['asset']['name'] + suffix = secrets.token_hex(nbytes=4) + node_name = "{}_{}_{}".format( + name, namespace, suffix) + + # getting file path + filepath = self.filepath_from_context(context) + + json_f = self._load_json_data(filepath) + + group_node = self._create_group_node( + filepath, json_f["data"]) + # renaming group node + group_node["name"].setValue(node_name) + + self._node_version_color(context["version"], group_node) + + self.log.info( + "Loaded lut setup: `{}`".format(group_node["name"].value())) + + return containerise( + node=group_node, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__ + ) + + def _create_group_node( + self, + filepath, + data, + group_node=None + ): + """Creates group node with all the nodes inside. + + Creating mainly `OCIOFileTransform` nodes with `OCIOColorSpace` nodes + in between - in case those are needed. + + Arguments: + filepath (str): path to json file + data (dict): data from json file + group_node (Optional[nuke.Node]): group node or None + + Returns: + nuke.Node: group node with all the nodes inside + """ + # get corresponding node + + root_working_colorspace = nuke.root()["workingSpaceLUT"].value() + + dir_path = os.path.dirname(filepath) + all_files = os.listdir(dir_path) + + ocio_working_colorspace = _colorspace_name_by_type( + data["ocioLookWorkingSpace"]) + + # adding nodes to node graph + # just in case we are in group lets jump out of it + nuke.endGroup() + + input_node = None + output_node = None + if group_node: + # remove all nodes between Input and Output nodes + for node in group_node.nodes(): + if node.Class() not in ["Input", "Output"]: + nuke.delete(node) + elif node.Class() == "Input": + input_node = node + elif node.Class() == "Output": + output_node = node + else: + group_node = nuke.createNode( + "Group", + inpanel=False + ) + + # adding content to the group node + with group_node: + pre_colorspace = root_working_colorspace + + # reusing input node if it exists during update + if input_node: + pre_node = input_node + else: + pre_node = nuke.createNode("Input") + pre_node["name"].setValue("rgb") + + # Compare script working colorspace with ocio working colorspace + # found in json file and convert to json's if needed + if pre_colorspace != ocio_working_colorspace: + pre_node = _add_ocio_colorspace_node( + pre_node, + pre_colorspace, + ocio_working_colorspace + ) + pre_colorspace = ocio_working_colorspace + + for ocio_item in data["ocioLookItems"]: + input_space = _colorspace_name_by_type( + ocio_item["input_colorspace"]) + output_space = _colorspace_name_by_type( + ocio_item["output_colorspace"]) + + # making sure we are set to correct colorspace for otio item + if pre_colorspace != input_space: + pre_node = _add_ocio_colorspace_node( + pre_node, + pre_colorspace, + input_space + ) + + node = nuke.createNode("OCIOFileTransform") + + # file path from lut representation + extension = ocio_item["ext"] + item_name = ocio_item["name"] + + item_lut_file = next( + ( + file for file in all_files + if file.endswith(extension) + ), + None + ) + if not item_lut_file: + raise ValueError( + "File with extension '{}' not " + "found in directory".format(extension) + ) + + item_lut_path = os.path.join( + dir_path, item_lut_file).replace("\\", "/") + node["file"].setValue(item_lut_path) + node["name"].setValue(item_name) + node["direction"].setValue(ocio_item["direction"]) + node["interpolation"].setValue(ocio_item["interpolation"]) + node["working_space"].setValue(input_space) + + pre_node.autoplace() + node.setInput(0, pre_node) + node.autoplace() + # pass output space into pre_colorspace for next iteration + # or for output node comparison + pre_colorspace = output_space + pre_node = node + + # making sure we are back in script working colorspace + if pre_colorspace != root_working_colorspace: + pre_node = _add_ocio_colorspace_node( + pre_node, + pre_colorspace, + root_working_colorspace + ) + + # reusing output node if it exists during update + if not output_node: + output = nuke.createNode("Output") + else: + output = output_node + + output.setInput(0, pre_node) + + return group_node + + def update(self, container, representation): + + project_name = get_current_project_name() + version_doc = get_version_by_id(project_name, representation["parent"]) + + group_node = container["node"] + + filepath = get_representation_path(representation) + + json_f = self._load_json_data(filepath) + + group_node = self._create_group_node( + filepath, + json_f["data"], + group_node + ) + + self._node_version_color(version_doc, group_node) + + self.log.info("Updated lut setup: `{}`".format( + group_node["name"].value())) + + return update_container( + group_node, {"representation": str(representation["_id"])}) + + def _load_json_data(self, filepath): + # getting data from json file with unicode conversion + with open(filepath, "r") as _file: + json_f = {self._bytify(key): self._bytify(value) + for key, value in json.load(_file).items()} + + # check if the version in json_f is the same as plugin version + if json_f["version"] != self.schema_version: + raise KeyError( + "Version of json file is not the same as plugin version") + + return json_f + + def _bytify(self, input): + """ + Converts unicode strings to strings + It goes through all dictionary + + Arguments: + input (dict/str): input + + Returns: + dict: with fixed values and keys + + """ + + if isinstance(input, dict): + return {self._bytify(key): self._bytify(value) + for key, value in input.items()} + elif isinstance(input, list): + return [self._bytify(element) for element in input] + elif isinstance(input, six.text_type): + return str(input) + else: + return input + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + node = nuke.toNode(container['objectName']) + with viewer_update_and_undo_stop(): + nuke.delete(node) + + def _node_version_color(self, version, node): + """ Coloring a node by correct color by actual version""" + + project_name = get_current_project_name() + last_version_doc = get_last_version_by_subset_id( + project_name, version["parent"], fields=["_id"] + ) + + # change color of node + if version["_id"] == last_version_doc["_id"]: + color_value = self.current_node_color + else: + color_value = self.old_node_color + node["tile_color"].setValue(int(color_value, 16)) + + +def _colorspace_name_by_type(colorspace_data): + """ + Returns colorspace name by type + + Arguments: + colorspace_data (dict): colorspace data + + Returns: + str: colorspace name + """ + if colorspace_data["type"] == "colorspaces": + return colorspace_data["name"] + elif colorspace_data["type"] == "roles": + return colorspace_data["colorspace"] + else: + raise KeyError("Unknown colorspace type: {}".format( + colorspace_data["type"])) + + +def _add_ocio_colorspace_node(pre_node, input_space, output_space): + """ + Adds OCIOColorSpace node to the node graph + + Arguments: + pre_node (nuke.Node): node to connect to + input_space (str): input colorspace + output_space (str): output colorspace + + Returns: + nuke.Node: node with OCIOColorSpace node + """ + node = nuke.createNode("OCIOColorSpace") + node.setInput(0, pre_node) + node["in_colorspace"].setValue(input_space) + node["out_colorspace"].setValue(output_space) + + pre_node.autoplace() + node.setInput(0, pre_node) + node.autoplace() + + return node diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index 48d4a0900a..cbe19d217b 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -22,7 +22,7 @@ class LinkAsGroup(load.LoaderPlugin): families = ["workfile", "nukenodes"] representations = ["*"] - extension = {"nk"} + extensions = {"nk"} label = "Load Precomp" order = 0 @@ -46,8 +46,6 @@ class LinkAsGroup(load.LoaderPlugin): file = self.filepath_from_context(context).replace("\\", "/") self.log.info("file: {}\n".format(file)) - precomp_name = context["representation"]["context"]["subset"] - self.log.info("versionData: {}\n".format(context["version"]["data"])) # add additional metadata from the version to imprint to Avalon knob @@ -62,7 +60,6 @@ class LinkAsGroup(load.LoaderPlugin): } for k in add_keys: data_imprint.update({k: context["version"]['data'][k]}) - data_imprint.update({"objectName": precomp_name}) # group context is set to precomp, so back up one level. nuke.endGroup() @@ -118,7 +115,7 @@ class LinkAsGroup(load.LoaderPlugin): inputs: """ - node = nuke.toNode(container['objectName']) + node = container["node"] root = get_representation_path(representation).replace("\\", "/") @@ -159,6 +156,6 @@ class LinkAsGroup(load.LoaderPlugin): self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/publish/collect_backdrop.py b/openpype/hosts/nuke/plugins/publish/collect_backdrop.py index 7d51af7e9e..d04c1204e3 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/collect_backdrop.py @@ -57,4 +57,4 @@ class CollectBackdrops(pyblish.api.InstancePlugin): if version: instance.data['version'] = version - self.log.info("Backdrop instance collected: `{}`".format(instance)) + self.log.debug("Backdrop instance collected: `{}`".format(instance)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_context_data.py b/openpype/hosts/nuke/plugins/publish/collect_context_data.py index f1b4965205..b85e924f55 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_context_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_context_data.py @@ -64,4 +64,4 @@ class CollectContextData(pyblish.api.ContextPlugin): context.data["scriptData"] = script_data context.data.update(script_data) - self.log.info('Context from Nuke script collected') + self.log.debug('Context from Nuke script collected') diff --git a/openpype/hosts/nuke/plugins/publish/collect_gizmo.py b/openpype/hosts/nuke/plugins/publish/collect_gizmo.py index e3c40a7a90..c410de7c32 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/collect_gizmo.py @@ -43,4 +43,4 @@ class CollectGizmo(pyblish.api.InstancePlugin): "frameStart": first_frame, "frameEnd": last_frame }) - self.log.info("Gizmo instance collected: `{}`".format(instance)) + self.log.debug("Gizmo instance collected: `{}`".format(instance)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_model.py b/openpype/hosts/nuke/plugins/publish/collect_model.py index 3fdf376d0c..a099f06be0 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_model.py +++ b/openpype/hosts/nuke/plugins/publish/collect_model.py @@ -43,4 +43,4 @@ class CollectModel(pyblish.api.InstancePlugin): "frameStart": first_frame, "frameEnd": last_frame }) - self.log.info("Model instance collected: `{}`".format(instance)) + self.log.debug("Model instance collected: `{}`".format(instance)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_instance_data.py b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py similarity index 75% rename from openpype/hosts/nuke/plugins/publish/collect_instance_data.py rename to openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py index 3908aef4bc..449a1cc935 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_instance_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py @@ -3,10 +3,12 @@ import pyblish.api class CollectInstanceData(pyblish.api.InstancePlugin): - """Collect all nodes with Avalon knob.""" + """Collect Nuke instance data + + """ order = pyblish.api.CollectorOrder - 0.49 - label = "Collect Instance Data" + label = "Collect Nuke Instance Data" hosts = ["nuke", "nukeassist"] # presets @@ -40,5 +42,14 @@ class CollectInstanceData(pyblish.api.InstancePlugin): "pixelAspect": pixel_aspect }) + + # add creator attributes to instance + creator_attributes = instance.data["creator_attributes"] + instance.data.update(creator_attributes) + + # add review family if review activated on instance + if instance.data.get("review"): + instance.data["families"].append("review") + self.log.debug("Collected instance: {}".format( instance.data)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_slate_node.py b/openpype/hosts/nuke/plugins/publish/collect_slate_node.py index 5701087697..3baa0cd9b5 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_slate_node.py +++ b/openpype/hosts/nuke/plugins/publish/collect_slate_node.py @@ -5,7 +5,7 @@ import nuke class CollectSlate(pyblish.api.InstancePlugin): """Check if SLATE node is in scene and connected to rendering tree""" - order = pyblish.api.CollectorOrder + 0.09 + order = pyblish.api.CollectorOrder + 0.002 label = "Collect Slate Node" hosts = ["nuke"] families = ["render"] @@ -13,10 +13,14 @@ class CollectSlate(pyblish.api.InstancePlugin): def process(self, instance): node = instance.data["transientData"]["node"] - slate = next((n for n in nuke.allNodes() - if "slate" in n.name().lower() - if not n["disable"].getValue()), - None) + slate = next( + ( + n_ for n_ in nuke.allNodes() + if "slate" in n_.name().lower() + if not n_["disable"].getValue() + ), + None + ) if slate: # check if slate node is connected to write node tree @@ -35,7 +39,7 @@ class CollectSlate(pyblish.api.InstancePlugin): instance.data["slateNode"] = slate_node instance.data["slate"] = True instance.data["families"].append("slate") - self.log.info( + self.log.debug( "Slate node is in node graph: `{}`".format(slate.name())) self.log.debug( "__ instance.data: `{}`".format(instance.data)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_workfile.py b/openpype/hosts/nuke/plugins/publish/collect_workfile.py index 852042e6e9..0f03572f8b 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_workfile.py +++ b/openpype/hosts/nuke/plugins/publish/collect_workfile.py @@ -37,4 +37,6 @@ class CollectWorkfile(pyblish.api.InstancePlugin): # adding basic script data instance.data.update(script_data) - self.log.info("Collect script version") + self.log.debug( + "Collected current script version: {}".format(current_file) + ) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index 1eb1e1350f..6f9245f5b9 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -1,5 +1,4 @@ import os -from pprint import pformat import nuke import pyblish.api from openpype.hosts.nuke import api as napi @@ -15,30 +14,16 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, hosts = ["nuke", "nukeassist"] families = ["render", "prerender", "image"] + # cache + _write_nodes = {} + _frame_ranges = {} + def process(self, instance): - self.log.debug(pformat(instance.data)) - creator_attributes = instance.data["creator_attributes"] - instance.data.update(creator_attributes) group_node = instance.data["transientData"]["node"] render_target = instance.data["render_target"] - family = instance.data["family"] - families = instance.data["families"] - # add targeted family to families - instance.data["families"].append( - "{}.{}".format(family, render_target) - ) - if instance.data.get("review"): - instance.data["families"].append("review") - - child_nodes = napi.get_instance_group_node_childs(instance) - instance.data["transientData"]["childNodes"] = child_nodes - - write_node = None - for x in child_nodes: - if x.Class() == "Write": - write_node = x + write_node = self._write_node_helper(instance) if write_node is None: self.log.warning( @@ -48,113 +33,134 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, ) return - instance.data["writeNode"] = write_node - self.log.debug("checking instance: {}".format(instance)) + # get colorspace and add to version data + colorspace = napi.get_colorspace_from_node(write_node) - # Determine defined file type - ext = write_node["file_type"].value() + if render_target == "frames": + self._set_existing_files_data(instance, colorspace) - # Get frame range - handle_start = instance.context.data["handleStart"] - handle_end = instance.context.data["handleEnd"] - first_frame = int(nuke.root()["first_frame"].getValue()) - last_frame = int(nuke.root()["last_frame"].getValue()) - frame_length = int(last_frame - first_frame + 1) + elif render_target == "frames_farm": + collected_frames = self._set_existing_files_data( + instance, colorspace) - if write_node["use_limit"].getValue(): - first_frame = int(write_node["first"].getValue()) - last_frame = int(write_node["last"].getValue()) + self._set_expected_files(instance, collected_frames) + + self._add_farm_instance_data(instance) + + elif render_target == "farm": + self._add_farm_instance_data(instance) + + # set additional instance data + self._set_additional_instance_data(instance, render_target, colorspace) + + def _set_existing_files_data(self, instance, colorspace): + """Set existing files data to instance data. + + Args: + instance (pyblish.api.Instance): pyblish instance + colorspace (str): colorspace + + Returns: + list: collected frames + """ + collected_frames = self._get_collected_frames(instance) + + representation = self._get_existing_frames_representation( + instance, collected_frames + ) + + # inject colorspace data + self.set_representation_colorspace( + representation, instance.context, + colorspace=colorspace + ) + + instance.data["representations"].append(representation) + + return collected_frames + + def _set_expected_files(self, instance, collected_frames): + """Set expected files to instance data. + + Args: + instance (pyblish.api.Instance): pyblish instance + collected_frames (list): collected frames + """ + write_node = self._write_node_helper(instance) write_file_path = nuke.filename(write_node) output_dir = os.path.dirname(write_file_path) - # get colorspace and add to version data - colorspace = napi.get_colorspace_from_node(write_node) + instance.data["expectedFiles"] = [ + os.path.join(output_dir, source_file) + for source_file in collected_frames + ] - self.log.debug('output dir: {}'.format(output_dir)) + def _get_frame_range_data(self, instance): + """Get frame range data from instance. - if render_target == "frames": - representation = { - 'name': ext, - 'ext': ext, - "stagingDir": output_dir, - "tags": [] - } + Args: + instance (pyblish.api.Instance): pyblish instance - # get file path knob - node_file_knob = write_node["file"] - # list file paths based on input frames - expected_paths = list(sorted({ - node_file_knob.evaluate(frame) - for frame in range(first_frame, last_frame + 1) - })) + Returns: + tuple: first_frame, last_frame + """ - # convert only to base names - expected_filenames = [ - os.path.basename(filepath) - for filepath in expected_paths - ] + instance_name = instance.data["name"] - # make sure files are existing at folder - collected_frames = [ - filename - for filename in os.listdir(output_dir) - if filename in expected_filenames - ] + if self._frame_ranges.get(instance_name): + # return cashed write node + return self._frame_ranges[instance_name] - if collected_frames: - collected_frames_len = len(collected_frames) - frame_start_str = "%0{}d".format( - len(str(last_frame))) % first_frame - representation['frameStart'] = frame_start_str + write_node = self._write_node_helper(instance) - # in case slate is expected and not yet rendered - self.log.debug("_ frame_length: {}".format(frame_length)) - self.log.debug("_ collected_frames_len: {}".format( - collected_frames_len)) + # Get frame range from workfile + first_frame = int(nuke.root()["first_frame"].getValue()) + last_frame = int(nuke.root()["last_frame"].getValue()) - # this will only run if slate frame is not already - # rendered from previews publishes - if ( - "slate" in families - and frame_length == collected_frames_len - and family == "render" - ): - frame_slate_str = ( - "{{:0{}d}}".format(len(str(last_frame))) - ).format(first_frame - 1) + # Get frame range from write node if activated + if write_node["use_limit"].getValue(): + first_frame = int(write_node["first"].getValue()) + last_frame = int(write_node["last"].getValue()) - slate_frame = collected_frames[0].replace( - frame_start_str, frame_slate_str) - collected_frames.insert(0, slate_frame) + # add to cache + self._frame_ranges[instance_name] = (first_frame, last_frame) - if collected_frames_len == 1: - representation['files'] = collected_frames.pop() - else: - representation['files'] = collected_frames + return first_frame, last_frame - # inject colorspace data - self.set_representation_colorspace( - representation, instance.context, - colorspace=colorspace - ) + def _set_additional_instance_data( + self, instance, render_target, colorspace + ): + """Set additional instance data. - instance.data["representations"].append(representation) - self.log.info("Publishing rendered frames ...") + Args: + instance (pyblish.api.Instance): pyblish instance + render_target (str): render target + colorspace (str): colorspace + """ + family = instance.data["family"] - elif render_target == "farm": - farm_keys = ["farm_chunk", "farm_priority", "farm_concurrency"] - for key in farm_keys: - # Skip if key is not in creator attributes - if key not in creator_attributes: - continue - # Add farm attributes to instance - instance.data[key] = creator_attributes[key] + # add targeted family to families + instance.data["families"].append( + "{}.{}".format(family, render_target) + ) + self.log.debug("Appending render target to families: {}.{}".format( + family, render_target) + ) - # Farm rendering - instance.data["transfer"] = False - instance.data["farm"] = True - self.log.info("Farm rendering ON ...") + write_node = self._write_node_helper(instance) + + # Determine defined file type + ext = write_node["file_type"].value() + + # get frame range data + handle_start = instance.context.data["handleStart"] + handle_end = instance.context.data["handleEnd"] + first_frame, last_frame = self._get_frame_range_data(instance) + + # get output paths + write_file_path = nuke.filename(write_node) + output_dir = os.path.dirname(write_file_path) # TODO: remove this when we have proper colorspace support version_data = { @@ -188,10 +194,6 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, "frameEndHandle": last_frame, }) - # make sure rendered sequence on farm will - # be used for extract review - if not instance.data.get("review"): - instance.data["useSequenceForReview"] = False # TODO temporarily set stagingDir as persistent for backward # compatibility. This is mainly focused on `renders`folders which @@ -199,4 +201,201 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, # this logic should be removed and replaced with custom staging dir instance.data["stagingDir_persistent"] = True - self.log.debug("instance.data: {}".format(pformat(instance.data))) + def _write_node_helper(self, instance): + """Helper function to get write node from instance. + + Also sets instance transient data with child nodes. + + Args: + instance (pyblish.api.Instance): pyblish instance + + Returns: + nuke.Node: write node + """ + instance_name = instance.data["name"] + + if self._write_nodes.get(instance_name): + # return cashed write node + return self._write_nodes[instance_name] + + # get all child nodes from group node + child_nodes = napi.get_instance_group_node_childs(instance) + + # set child nodes to instance transient data + instance.data["transientData"]["childNodes"] = child_nodes + + write_node = None + for node_ in child_nodes: + if node_.Class() == "Write": + write_node = node_ + + if write_node: + # for slate frame extraction + instance.data["transientData"]["writeNode"] = write_node + # add to cache + self._write_nodes[instance_name] = write_node + + return self._write_nodes[instance_name] + + def _get_existing_frames_representation( + self, + instance, + collected_frames + ): + """Get existing frames representation. + + Args: + instance (pyblish.api.Instance): pyblish instance + collected_frames (list): collected frames + + Returns: + dict: representation + """ + + first_frame, last_frame = self._get_frame_range_data(instance) + + write_node = self._write_node_helper(instance) + + write_file_path = nuke.filename(write_node) + output_dir = os.path.dirname(write_file_path) + + # Determine defined file type + ext = write_node["file_type"].value() + + representation = { + "name": ext, + "ext": ext, + "stagingDir": output_dir, + "tags": [] + } + + frame_start_str = self._get_frame_start_str(first_frame, last_frame) + + representation['frameStart'] = frame_start_str + + # set slate frame + collected_frames = self._add_slate_frame_to_collected_frames( + instance, + collected_frames, + first_frame, + last_frame + ) + + if len(collected_frames) == 1: + representation['files'] = collected_frames.pop() + else: + representation['files'] = collected_frames + + return representation + + def _get_frame_start_str(self, first_frame, last_frame): + """Get frame start string. + + Args: + first_frame (int): first frame + last_frame (int): last frame + + Returns: + str: frame start string + """ + # convert first frame to string with padding + return ( + "{{:0{}d}}".format(len(str(last_frame))) + ).format(first_frame) + + def _add_slate_frame_to_collected_frames( + self, + instance, + collected_frames, + first_frame, + last_frame + ): + """Add slate frame to collected frames. + + Args: + instance (pyblish.api.Instance): pyblish instance + collected_frames (list): collected frames + first_frame (int): first frame + last_frame (int): last frame + + Returns: + list: collected frames + """ + frame_start_str = self._get_frame_start_str(first_frame, last_frame) + frame_length = int(last_frame - first_frame + 1) + + # this will only run if slate frame is not already + # rendered from previews publishes + if ( + "slate" in instance.data["families"] + and frame_length == len(collected_frames) + ): + frame_slate_str = self._get_frame_start_str( + first_frame - 1, + last_frame + ) + + slate_frame = collected_frames[0].replace( + frame_start_str, frame_slate_str) + collected_frames.insert(0, slate_frame) + + return collected_frames + + def _add_farm_instance_data(self, instance): + """Add farm publishing related instance data. + + Args: + instance (pyblish.api.Instance): pyblish instance + """ + + # make sure rendered sequence on farm will + # be used for extract review + if not instance.data.get("review"): + instance.data["useSequenceForReview"] = False + + # Farm rendering + instance.data.update({ + "transfer": False, + "farm": True # to skip integrate + }) + self.log.info("Farm rendering ON ...") + + def _get_collected_frames(self, instance): + """Get collected frames. + + Args: + instance (pyblish.api.Instance): pyblish instance + + Returns: + list: collected frames + """ + + first_frame, last_frame = self._get_frame_range_data(instance) + + write_node = self._write_node_helper(instance) + + write_file_path = nuke.filename(write_node) + output_dir = os.path.dirname(write_file_path) + + # get file path knob + node_file_knob = write_node["file"] + # list file paths based on input frames + expected_paths = list(sorted({ + node_file_knob.evaluate(frame) + for frame in range(first_frame, last_frame + 1) + })) + + # convert only to base names + expected_filenames = { + os.path.basename(filepath) + for filepath in expected_paths + } + + # make sure files are existing at folder + collected_frames = [ + filename + for filename in os.listdir(output_dir) + if filename in expected_filenames + ] + + return collected_frames diff --git a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py index 5166fa4b2c..2a6a5dee2a 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py @@ -56,8 +56,6 @@ class ExtractBackdropNode(publish.Extractor): # connect output node for n, output in connections_out.items(): opn = nuke.createNode("Output") - self.log.info(n.name()) - self.log.info(output.name()) output.setInput( next((i for i, d in enumerate(output.dependencies()) if d.name() in n.name()), 0), opn) @@ -102,5 +100,5 @@ class ExtractBackdropNode(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{}' to: {}".format( + self.log.debug("Extracted instance '{}' to: {}".format( instance.name, path)) diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index 4286f71e83..3ec85c1f11 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -11,9 +11,9 @@ from openpype.hosts.nuke.api.lib import maintained_selection class ExtractCamera(publish.Extractor): - """ 3D camera exctractor + """ 3D camera extractor """ - label = 'Exctract Camera' + label = 'Extract Camera' order = pyblish.api.ExtractorOrder families = ["camera"] hosts = ["nuke"] @@ -36,11 +36,8 @@ class ExtractCamera(publish.Extractor): step = 1 output_range = str(nuke.FrameRange(first_frame, last_frame, step)) - self.log.info("instance.data: `{}`".format( - pformat(instance.data))) - rm_nodes = [] - self.log.info("Crating additional nodes") + self.log.debug("Creating additional nodes for 3D Camera Extractor") subset = instance.data["subset"] staging_dir = self.staging_dir(instance) @@ -84,8 +81,6 @@ class ExtractCamera(publish.Extractor): for n in rm_nodes: nuke.delete(n) - self.log.info(file_path) - # create representation data if "representations" not in instance.data: instance.data["representations"] = [] @@ -112,7 +107,7 @@ class ExtractCamera(publish.Extractor): "frameEndHandle": last_frame, }) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, file_path)) diff --git a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py index b0b1a9f7b7..ecec0d6f80 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py @@ -85,8 +85,5 @@ class ExtractGizmo(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{}' to: {}".format( + self.log.debug("Extracted instance '{}' to: {}".format( instance.name, path)) - - self.log.info("Data {}".format( - instance.data)) diff --git a/openpype/hosts/nuke/plugins/publish/extract_model.py b/openpype/hosts/nuke/plugins/publish/extract_model.py index 814d404137..a8b37fb173 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_model.py +++ b/openpype/hosts/nuke/plugins/publish/extract_model.py @@ -11,9 +11,9 @@ from openpype.hosts.nuke.api.lib import ( class ExtractModel(publish.Extractor): - """ 3D model exctractor + """ 3D model extractor """ - label = 'Exctract Model' + label = 'Extract Model' order = pyblish.api.ExtractorOrder families = ["model"] hosts = ["nuke"] @@ -33,13 +33,13 @@ class ExtractModel(publish.Extractor): first_frame = int(nuke.root()["first_frame"].getValue()) last_frame = int(nuke.root()["last_frame"].getValue()) - self.log.info("instance.data: `{}`".format( + self.log.debug("instance.data: `{}`".format( pformat(instance.data))) rm_nodes = [] model_node = instance.data["transientData"]["node"] - self.log.info("Crating additional nodes") + self.log.debug("Creating additional nodes for Extract Model") subset = instance.data["subset"] staging_dir = self.staging_dir(instance) @@ -76,7 +76,7 @@ class ExtractModel(publish.Extractor): for n in rm_nodes: nuke.delete(n) - self.log.info(file_path) + self.log.debug("Filepath: {}".format(file_path)) # create representation data if "representations" not in instance.data: @@ -104,5 +104,5 @@ class ExtractModel(publish.Extractor): "frameEndHandle": last_frame, }) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, file_path)) diff --git a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py index e66cfd9018..3fe1443bb3 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py +++ b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py @@ -27,7 +27,7 @@ class CreateOutputNode(pyblish.api.ContextPlugin): if active_node: active_node = active_node.pop() - self.log.info(active_node) + self.log.debug("Active node: {}".format(active_node)) active_node['selected'].setValue(True) # select only instance render node diff --git a/openpype/hosts/nuke/plugins/publish/extract_render_local.py b/openpype/hosts/nuke/plugins/publish/extract_render_local.py index e2cf2addc5..ff04367e20 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_render_local.py +++ b/openpype/hosts/nuke/plugins/publish/extract_render_local.py @@ -119,7 +119,7 @@ class NukeRenderLocal(publish.Extractor, instance.data["representations"].append(repre) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, out_dir )) @@ -143,7 +143,7 @@ class NukeRenderLocal(publish.Extractor, instance.data["families"] = families collections, remainder = clique.assemble(filenames) - self.log.info('collections: {}'.format(str(collections))) + self.log.debug('collections: {}'.format(str(collections))) if collections: collection = collections[0] diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index e4b7b155cd..b007f90f6c 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -20,8 +20,7 @@ class ExtractReviewDataLut(publish.Extractor): hosts = ["nuke"] def process(self, instance): - families = instance.data["families"] - self.log.info("Creating staging dir...") + self.log.debug("Creating staging dir...") if "representations" in instance.data: staging_dir = instance.data[ "representations"][0]["stagingDir"].replace("\\", "/") @@ -34,7 +33,7 @@ class ExtractReviewDataLut(publish.Extractor): staging_dir = os.path.normpath(os.path.dirname(render_path)) instance.data["stagingDir"] = staging_dir - self.log.info( + self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) # generate data diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py similarity index 79% rename from openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py rename to openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py index 956d1a54a3..3ee166eb56 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py @@ -8,15 +8,16 @@ from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection -class ExtractReviewDataMov(publish.Extractor): - """Extracts movie and thumbnail with baked in luts +class ExtractReviewIntermediates(publish.Extractor): + """Extracting intermediate videos or sequences with + thumbnail for transcoding. must be run after extract_render_local.py """ order = pyblish.api.ExtractorOrder + 0.01 - label = "Extract Review Data Mov" + label = "Extract Review Intermediates" families = ["review"] hosts = ["nuke"] @@ -25,6 +26,24 @@ class ExtractReviewDataMov(publish.Extractor): viewer_lut_raw = None outputs = {} + @classmethod + def apply_settings(cls, project_settings): + """Apply the settings from the deprecated + ExtractReviewDataMov plugin for backwards compatibility + """ + nuke_publish = project_settings["nuke"]["publish"] + deprecated_setting = nuke_publish["ExtractReviewDataMov"] + current_setting = nuke_publish.get("ExtractReviewIntermediates") + if deprecated_setting["enabled"]: + # Use deprecated settings if they are still enabled + cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"] + cls.outputs = deprecated_setting["outputs"] + elif current_setting is None: + pass + elif current_setting["enabled"]: + cls.viewer_lut_raw = current_setting["viewer_lut_raw"] + cls.outputs = current_setting["outputs"] + def process(self, instance): families = set(instance.data["families"]) @@ -33,7 +52,7 @@ class ExtractReviewDataMov(publish.Extractor): task_type = instance.context.data["taskType"] subset = instance.data["subset"] - self.log.info("Creating staging dir...") + self.log.debug("Creating staging dir...") if "representations" not in instance.data: instance.data["representations"] = [] @@ -43,10 +62,10 @@ class ExtractReviewDataMov(publish.Extractor): instance.data["stagingDir"] = staging_dir - self.log.info( + self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) - self.log.info(self.outputs) + self.log.debug("Outputs: {}".format(self.outputs)) # generate data with maintained_selection(): @@ -85,9 +104,10 @@ class ExtractReviewDataMov(publish.Extractor): re.search(s, subset) for s in f_subsets): continue - self.log.info( + self.log.debug( "Baking output `{}` with settings: {}".format( - o_name, o_data)) + o_name, o_data) + ) # check if settings have more then one preset # so we dont need to add outputName to representation @@ -136,10 +156,10 @@ class ExtractReviewDataMov(publish.Extractor): instance.data["useSequenceForReview"] = False else: instance.data["families"].remove("review") - self.log.info(( + self.log.debug( "Removing `review` from families. " "Not available baking profile." - )) + ) self.log.debug(instance.data["families"]) self.log.debug( diff --git a/openpype/hosts/nuke/plugins/publish/extract_script_save.py b/openpype/hosts/nuke/plugins/publish/extract_script_save.py index 0c8e561fd7..e44e5686b6 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_script_save.py +++ b/openpype/hosts/nuke/plugins/publish/extract_script_save.py @@ -3,13 +3,12 @@ import pyblish.api class ExtractScriptSave(pyblish.api.Extractor): - """ - """ + """Save current Nuke workfile script""" label = 'Script Save' order = pyblish.api.Extractor.order - 0.1 hosts = ['nuke'] def process(self, instance): - self.log.info('saving script') + self.log.debug('Saving current script') nuke.scriptSave() diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index 06c086b10d..7befb7b7f3 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -48,7 +48,7 @@ class ExtractSlateFrame(publish.Extractor): if instance.data.get("bakePresets"): for o_name, o_data in instance.data["bakePresets"].items(): - self.log.info("_ o_name: {}, o_data: {}".format( + self.log.debug("_ o_name: {}, o_data: {}".format( o_name, pformat(o_data))) self.render_slate( instance, @@ -65,14 +65,14 @@ class ExtractSlateFrame(publish.Extractor): def _create_staging_dir(self, instance): - self.log.info("Creating staging dir...") + self.log.debug("Creating staging dir...") staging_dir = os.path.normpath( os.path.dirname(instance.data["path"])) instance.data["stagingDir"] = staging_dir - self.log.info( + self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) def _check_frames_exists(self, instance): @@ -249,7 +249,7 @@ class ExtractSlateFrame(publish.Extractor): # Add file to representation files # - get write node - write_node = instance.data["writeNode"] + write_node = instance.data["transientData"]["writeNode"] # - evaluate filepaths for first frame and slate frame first_filename = os.path.basename( write_node["file"].evaluate(first_frame)) @@ -275,10 +275,10 @@ class ExtractSlateFrame(publish.Extractor): break if not matching_repre: - self.log.info(( - "Matching reresentaion was not found." + self.log.info( + "Matching reresentation was not found." " Representation files were not filled with slate." - )) + ) return # Add frame to matching representation files @@ -345,7 +345,7 @@ class ExtractSlateFrame(publish.Extractor): try: node[key].setValue(value) - self.log.info("Change key \"{}\" to value \"{}\"".format( + self.log.debug("Change key \"{}\" to value \"{}\"".format( key, value )) except NameError: diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index 21eefda249..de7567c1b1 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -8,6 +8,7 @@ from openpype.hosts.nuke import api as napi from openpype.hosts.nuke.api.lib import set_node_knobs_from_settings +# Python 2/3 compatibility if sys.version_info[0] >= 3: unicode = str @@ -45,30 +46,30 @@ class ExtractThumbnail(publish.Extractor): for o_name, o_data in instance.data["bakePresets"].items(): self.render_thumbnail(instance, o_name, **o_data) else: - viewer_process_swithes = { + viewer_process_switches = { "bake_viewer_process": True, "bake_viewer_input_process": True } - self.render_thumbnail(instance, None, **viewer_process_swithes) + self.render_thumbnail( + instance, None, **viewer_process_switches) def render_thumbnail(self, instance, output_name=None, **kwargs): first_frame = instance.data["frameStartHandle"] last_frame = instance.data["frameEndHandle"] + colorspace = instance.data["colorspace"] # find frame range and define middle thumb frame mid_frame = int((last_frame - first_frame) / 2) # solve output name if any is set output_name = output_name or "" - if output_name: - output_name = "_" + output_name bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ "bake_viewer_input_process"] node = instance.data["transientData"]["node"] # group node - self.log.info("Creating staging dir...") + self.log.debug("Creating staging dir...") if "representations" not in instance.data: instance.data["representations"] = [] @@ -78,7 +79,7 @@ class ExtractThumbnail(publish.Extractor): instance.data["stagingDir"] = staging_dir - self.log.info( + self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) temporary_nodes = [] @@ -90,8 +91,6 @@ class ExtractThumbnail(publish.Extractor): if collection: # get path - fname = os.path.basename(collection.format( - "{head}{padding}{tail}")) fhead = collection.format("{head}") thumb_fname = list(collection)[mid_frame] @@ -112,8 +111,8 @@ class ExtractThumbnail(publish.Extractor): if self.use_rendered and os.path.isfile(path_render): # check if file exist otherwise connect to write node rnode = nuke.createNode("Read") - rnode["file"].setValue(path_render) + rnode["colorspace"].setValue(colorspace) # turn it raw if none of baking is ON if all([ @@ -167,26 +166,42 @@ class ExtractThumbnail(publish.Extractor): previous_node = dag_node temporary_nodes.append(dag_node) + thumb_name = "thumbnail" + # only add output name and + # if there are more than one bake preset + if ( + output_name + and len(instance.data.get("bakePresets", {}).keys()) > 1 + ): + thumb_name = "{}_{}".format(output_name, thumb_name) + # create write node write_node = nuke.createNode("Write") - file = fhead[:-1] + output_name + ".jpg" - name = "thumbnail" - path = os.path.join(staging_dir, file).replace("\\", "/") - instance.data["thumbnail"] = path - write_node["file"].setValue(path) + file = fhead[:-1] + thumb_name + ".jpg" + thumb_path = os.path.join(staging_dir, file).replace("\\", "/") + + # add thumbnail to cleanup + instance.context.data["cleanupFullPaths"].append(thumb_path) + + # make sure only one thumbnail path is set + # and it is existing file + instance_thumb_path = instance.data.get("thumbnailPath") + if not instance_thumb_path or not os.path.isfile(instance_thumb_path): + instance.data["thumbnailPath"] = thumb_path + + write_node["file"].setValue(thumb_path) write_node["file_type"].setValue("jpg") write_node["raw"].setValue(1) write_node.setInput(0, previous_node) temporary_nodes.append(write_node) - tags = ["thumbnail", "publish_on_farm"] repre = { - 'name': name, + 'name': thumb_name, 'ext': "jpg", - "outputName": "thumb", + "outputName": thumb_name, 'files': file, "stagingDir": staging_dir, - "tags": tags + "tags": ["thumbnail", "publish_on_farm", "delete"] } instance.data["representations"].append(repre) diff --git a/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml new file mode 100644 index 0000000000..d9394ae510 --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml @@ -0,0 +1,31 @@ + + + + Shot/Asset name + +## Publishing to a different asset context + +There are publish instances present which are publishing into a different asset than your current context. + +Usually this is not what you want but there can be cases where you might want to publish into another asset/shot or task. + +If that's the case you can disable the validation on the instance to ignore it. + +The wrong node's name is: \`{node_name}\` + +### Correct context keys and values: + +\`{correct_values}\` + +### Wrong keys and values: + +\`{wrong_values}\`. + + +## How to repair? + +1. Use \"Repair\" button. +2. Hit Reload button on the publisher. + + + diff --git a/openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml b/openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml deleted file mode 100644 index 0422917e9c..0000000000 --- a/openpype/hosts/nuke/plugins/publish/help/validate_asset_name.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - Shot/Asset name - -## Invalid Shot/Asset name in subset - -Following Node with name `{node_name}`: -Is in context of `{correct_name}` but Node _asset_ knob is set as `{wrong_name}`. - -### How to repair? - -1. Either use Repair or Select button. -2. If you chose Select then rename asset knob to correct name. -3. Hit Reload button on the publisher. - - - \ No newline at end of file diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_context.py b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py new file mode 100644 index 0000000000..731645a11c --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/validate_asset_context.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""Validate if instance asset is the same as context asset.""" +from __future__ import absolute_import + +import pyblish.api + +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishXmlValidationError, + OptionalPyblishPluginMixin +) +from openpype.hosts.nuke.api import SelectInstanceNodeAction + + +class ValidateCorrectAssetContext( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): + """Validator to check if instance asset context match context asset. + + When working in per-shot style you always publish data in context of + current asset (shot). This validator checks if this is so. It is optional + so it can be disabled when needed. + + Checking `asset` and `task` keys. + """ + order = ValidateContentsOrder + label = "Validate asset context" + hosts = ["nuke"] + actions = [ + RepairAction, + SelectInstanceNodeAction + ] + optional = True + + @classmethod + def apply_settings(cls, project_settings): + """Apply deprecated settings from project settings. + """ + nuke_publish = project_settings["nuke"]["publish"] + if "ValidateCorrectAssetName" in nuke_publish: + settings = nuke_publish["ValidateCorrectAssetName"] + else: + settings = nuke_publish["ValidateCorrectAssetContext"] + + cls.enabled = settings["enabled"] + cls.optional = settings["optional"] + cls.active = settings["active"] + + def process(self, instance): + if not self.is_active(instance.data): + return + + invalid_keys = self.get_invalid(instance) + + if not invalid_keys: + return + + message_values = { + "node_name": instance.data["transientData"]["node"].name(), + "correct_values": ", ".join([ + "{} > {}".format(_key, instance.context.data[_key]) + for _key in invalid_keys + ]), + "wrong_values": ", ".join([ + "{} > {}".format(_key, instance.data.get(_key)) + for _key in invalid_keys + ]) + } + + msg = ( + "Instance `{node_name}` has wrong context keys:\n" + "Correct: `{correct_values}` | Wrong: `{wrong_values}`").format( + **message_values) + + self.log.debug(msg) + + raise PublishXmlValidationError( + self, msg, formatting_data=message_values + ) + + @classmethod + def get_invalid(cls, instance): + """Get invalid keys from instance data and context data.""" + + invalid_keys = [] + testing_keys = ["asset", "task"] + for _key in testing_keys: + if _key not in instance.data: + invalid_keys.append(_key) + continue + if instance.data[_key] != instance.context.data[_key]: + invalid_keys.append(_key) + + return invalid_keys + + @classmethod + def repair(cls, instance): + """Repair instance data with context data.""" + invalid_keys = cls.get_invalid(instance) + + create_context = instance.context.data["create_context"] + + instance_id = instance.data.get("instance_id") + created_instance = create_context.get_instance_by_id( + instance_id + ) + for _key in invalid_keys: + created_instance[_key] = instance.context.data[_key] + + create_context.save_changes() diff --git a/openpype/hosts/nuke/plugins/publish/validate_asset_name.py b/openpype/hosts/nuke/plugins/publish/validate_asset_name.py deleted file mode 100644 index df05f76a5b..0000000000 --- a/openpype/hosts/nuke/plugins/publish/validate_asset_name.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate if instance asset is the same as context asset.""" -from __future__ import absolute_import - -import pyblish.api - -import openpype.hosts.nuke.api.lib as nlib - -from openpype.pipeline.publish import ( - ValidateContentsOrder, - PublishXmlValidationError, - OptionalPyblishPluginMixin -) - -class SelectInvalidInstances(pyblish.api.Action): - """Select invalid instances in Outliner.""" - - label = "Select" - icon = "briefcase" - on = "failed" - - def process(self, context, plugin): - """Process invalid validators and select invalid instances.""" - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - if instances: - self.deselect() - self.log.info( - "Selecting invalid nodes: %s" % ", ".join( - [str(x) for x in instances] - ) - ) - self.select(instances) - else: - self.log.info("No invalid nodes found.") - self.deselect() - - def select(self, instances): - for inst in instances: - if inst.data.get("transientData", {}).get("node"): - select_node = inst.data["transientData"]["node"] - select_node["selected"].setValue(True) - - def deselect(self): - nlib.reset_selection() - - -class RepairSelectInvalidInstances(pyblish.api.Action): - """Repair the instance asset.""" - - label = "Repair" - icon = "wrench" - on = "failed" - - def process(self, context, plugin): - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - self.log.debug(instances) - - context_asset = context.data["assetEntity"]["name"] - for instance in instances: - node = instance.data["transientData"]["node"] - node_data = nlib.get_node_data(node, nlib.INSTANCE_DATA_KNOB) - node_data["asset"] = context_asset - nlib.set_node_data(node, nlib.INSTANCE_DATA_KNOB, node_data) - - -class ValidateCorrectAssetName( - pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin -): - """Validator to check if instance asset match context asset. - - When working in per-shot style you always publish data in context of - current asset (shot). This validator checks if this is so. It is optional - so it can be disabled when needed. - - Action on this validator will select invalid instances in Outliner. - """ - order = ValidateContentsOrder - label = "Validate correct asset name" - hosts = ["nuke"] - actions = [ - SelectInvalidInstances, - RepairSelectInvalidInstances - ] - optional = True - - def process(self, instance): - if not self.is_active(instance.data): - return - - asset = instance.data.get("asset") - context_asset = instance.context.data["assetEntity"]["name"] - node = instance.data["transientData"]["node"] - - msg = ( - "Instance `{}` has wrong shot/asset name:\n" - "Correct: `{}` | Wrong: `{}`").format( - instance.name, asset, context_asset) - - self.log.debug(msg) - - if asset != context_asset: - raise PublishXmlValidationError( - self, msg, formatting_data={ - "node_name": node.name(), - "wrong_name": asset, - "correct_name": context_asset - } - ) diff --git a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py index ad60089952..761b080caa 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py @@ -43,8 +43,8 @@ class SelectCenterInNodeGraph(pyblish.api.Action): all_xC.append(xC) all_yC.append(yC) - self.log.info("all_xC: `{}`".format(all_xC)) - self.log.info("all_yC: `{}`".format(all_yC)) + self.log.debug("all_xC: `{}`".format(all_xC)) + self.log.debug("all_yC: `{}`".format(all_yC)) # zoom to nodes in node graph nuke.zoom(2, [min(all_xC), min(all_yC)]) diff --git a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py index dbcd216a84..ff6d73c6ec 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py +++ b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py @@ -23,7 +23,7 @@ class ValidateOutputResolution( order = pyblish.api.ValidatorOrder optional = True families = ["render"] - label = "Write resolution" + label = "Validate Write resolution" hosts = ["nuke"] actions = [RepairAction] @@ -104,9 +104,9 @@ class ValidateOutputResolution( _rfn["resize"].setValue(0) _rfn["black_outside"].setValue(1) - cls.log.info("I am adding reformat node") + cls.log.info("Adding reformat node") if cls.resolution_msg == invalid: reformat = cls.get_reformat(instance) reformat["format"].setValue(nuke.root()["format"].value()) - cls.log.info("I am fixing reformat to root.format") + cls.log.info("Fixing reformat to root.format") diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index 45c20412c8..64bf69b69b 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -14,27 +14,26 @@ class RepairActionBase(pyblish.api.Action): # Get the errored instances return get_errored_instances_from_context(context, plugin=plugin) - def repair_knob(self, instances, state): + def repair_knob(self, context, instances, state): + create_context = context.data["create_context"] for instance in instances: - node = instance.data["transientData"]["node"] - files_remove = [os.path.join(instance.data["outputDir"], f) - for r in instance.data.get("representations", []) - for f in r.get("files", []) - ] - self.log.info("Files to be removed: {}".format(files_remove)) - for f in files_remove: - os.remove(f) - self.log.debug("removing file: {}".format(f)) - node["render"].setValue(state) + # Reset the render knob + instance_id = instance.data.get("instance_id") + created_instance = create_context.get_instance_by_id( + instance_id + ) + created_instance.creator_attributes["render_target"] = state self.log.info("Rendering toggled to `{}`".format(state)) + create_context.save_changes() + class RepairCollectionActionToLocal(RepairActionBase): label = "Repair - rerender with \"Local\"" def process(self, context, plugin): instances = self.get_instance(context, plugin) - self.repair_knob(instances, "Local") + self.repair_knob(context, instances, "local") class RepairCollectionActionToFarm(RepairActionBase): @@ -42,7 +41,7 @@ class RepairCollectionActionToFarm(RepairActionBase): def process(self, context, plugin): instances = self.get_instance(context, plugin) - self.repair_knob(instances, "On farm") + self.repair_knob(context, instances, "farm") class ValidateRenderedFrames(pyblish.api.InstancePlugin): @@ -77,8 +76,8 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): return collections, remainder = clique.assemble(repre["files"]) - self.log.info("collections: {}".format(str(collections))) - self.log.info("remainder: {}".format(str(remainder))) + self.log.debug("collections: {}".format(str(collections))) + self.log.debug("remainder: {}".format(str(remainder))) collection = collections[0] @@ -104,15 +103,15 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): coll_start = min(collection.indexes) coll_end = max(collection.indexes) - self.log.info("frame_length: {}".format(frame_length)) - self.log.info("collected_frames_len: {}".format( + self.log.debug("frame_length: {}".format(frame_length)) + self.log.debug("collected_frames_len: {}".format( collected_frames_len)) - self.log.info("f_start_h-f_end_h: {}-{}".format( + self.log.debug("f_start_h-f_end_h: {}-{}".format( f_start_h, f_end_h)) - self.log.info( + self.log.debug( "coll_start-coll_end: {}-{}".format(coll_start, coll_end)) - self.log.info( + self.log.debug( "len(collection.indexes): {}".format(collected_frames_len) ) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index aeecea655f..0539c1188f 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -1,3 +1,5 @@ +from collections import defaultdict + import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context from openpype.hosts.nuke.api.lib import ( @@ -37,7 +39,7 @@ class RepairNukeWriteNodeAction(pyblish.api.Action): set_node_knobs_from_settings(write_node, correct_data["knobs"]) - self.log.info("Node attributes were fixed") + self.log.debug("Node attributes were fixed") class ValidateNukeWriteNode( @@ -80,18 +82,14 @@ class ValidateNukeWriteNode( correct_data = get_write_node_template_attr(write_group_node) check = [] - self.log.debug("__ write_node: {}".format( - write_node - )) - self.log.debug("__ correct_data: {}".format( - correct_data - )) + + # Collect key values of same type in a list. + values_by_name = defaultdict(list) + for knob_data in correct_data["knobs"]: + values_by_name[knob_data["name"]].append(knob_data["value"]) for knob_data in correct_data["knobs"]: knob_type = knob_data["type"] - self.log.debug("__ knob_type: {}".format( - knob_type - )) if ( knob_type == "__legacy__" @@ -105,34 +103,33 @@ class ValidateNukeWriteNode( ) key = knob_data["name"] - value = knob_data["value"] + values = values_by_name[key] node_value = write_node[key].value() # fix type differences - if type(node_value) in (int, float): - try: - if isinstance(value, list): - value = color_gui_to_int(value) - else: - value = float(value) - node_value = float(node_value) - except ValueError: + fixed_values = [] + for value in values: + if type(node_value) in (int, float): + try: + if isinstance(value, list): + value = color_gui_to_int(value) + else: + value = float(value) + node_value = float(node_value) + except ValueError: + value = str(value) + else: value = str(value) - else: - value = str(value) - node_value = str(node_value) + node_value = str(node_value) + + fixed_values.append(value) - self.log.debug("__ key: {} | value: {}".format( - key, value - )) if ( - node_value != value + node_value not in fixed_values and key != "file" and key != "tile_color" ): - check.append([key, value, write_node[key].value()]) - - self.log.info(check) + check.append([key, node_value, write_node[key].value()]) if check: self._make_error(check) diff --git a/openpype/hosts/photoshop/api/README.md b/openpype/hosts/photoshop/api/README.md index 7bd2bcb1bf..a207d21f2f 100644 --- a/openpype/hosts/photoshop/api/README.md +++ b/openpype/hosts/photoshop/api/README.md @@ -9,7 +9,7 @@ The Photoshop integration requires two components to work; `extension` and `serv To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). ``` -ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp +ExManCmd /install {path to addon}/api/extension.zxp ``` ### Server @@ -17,16 +17,16 @@ ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp The easiest way to get the server and Photoshop launch is with: ``` -python -c ^"import avalon.photoshop;avalon.photoshop.launch(""C:\Program Files\Adobe\Adobe Photoshop 2020\Photoshop.exe"")^" +python -c ^"import openpype.hosts.photoshop;openpype.hosts.photoshop.launch(""C:\Program Files\Adobe\Adobe Photoshop 2020\Photoshop.exe"")^" ``` `avalon.photoshop.launch` launches the application and server, and also closes the server when Photoshop exists. ## Usage -The Photoshop extension can be found under `Window > Extensions > Avalon`. Once launched you should be presented with a panel like this: +The Photoshop extension can be found under `Window > Extensions > Ayon`. Once launched you should be presented with a panel like this: -![Avalon Panel](panel.PNG "Avalon Panel") +![Ayon Panel](panel.png "AYON Panel") ## Developing @@ -37,7 +37,7 @@ When developing the extension you can load it [unsigned](https://github.com/Adob When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). ``` -ZXPSignCmd -selfSignedCert NA NA Avalon Avalon-Photoshop avalon extension.p12 +ZXPSignCmd -selfSignedCert NA NA Ayon Ayon-Photoshop Ayon extension.p12 ZXPSignCmd -sign {path to avalon-core}\avalon\photoshop\extension {path to avalon-core}\avalon\photoshop\extension.zxp extension.p12 avalon ``` diff --git a/openpype/hosts/photoshop/api/extension.zxp b/openpype/hosts/photoshop/api/extension.zxp index 39b766cd0d..26a73a37fd 100644 Binary files a/openpype/hosts/photoshop/api/extension.zxp and b/openpype/hosts/photoshop/api/extension.zxp differ diff --git a/openpype/hosts/photoshop/api/extension/.debug b/openpype/hosts/photoshop/api/extension/.debug index a0e2f3c9e0..4cea03cb41 100644 --- a/openpype/hosts/photoshop/api/extension/.debug +++ b/openpype/hosts/photoshop/api/extension/.debug @@ -1,9 +1,9 @@ - + - \ No newline at end of file + diff --git a/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml index 2089d06da1..16d85be9b4 100644 --- a/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml +++ b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml @@ -1,7 +1,7 @@ - + - + @@ -16,7 +16,7 @@ - + ./index.html @@ -32,7 +32,7 @@ Panel - OpenPype + AYON 300 @@ -44,7 +44,7 @@ - ./icons/avalon-logo-48.png + ./icons/ayon_logo.png diff --git a/openpype/hosts/photoshop/api/extension/extension.zxp b/openpype/hosts/photoshop/api/extension/extension.zxp deleted file mode 100644 index 39b766cd0d..0000000000 Binary files a/openpype/hosts/photoshop/api/extension/extension.zxp and /dev/null differ diff --git a/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png b/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png deleted file mode 100644 index 33fe2a606b..0000000000 Binary files a/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png and /dev/null differ diff --git a/openpype/hosts/photoshop/api/extension/icons/ayon_logo.png b/openpype/hosts/photoshop/api/extension/icons/ayon_logo.png new file mode 100644 index 0000000000..3a96f8e2b4 Binary files /dev/null and b/openpype/hosts/photoshop/api/extension/icons/ayon_logo.png differ diff --git a/openpype/hosts/photoshop/api/panel.PNG b/openpype/hosts/photoshop/api/panel.PNG deleted file mode 100644 index be5db3b8df..0000000000 Binary files a/openpype/hosts/photoshop/api/panel.PNG and /dev/null differ diff --git a/openpype/hosts/aftereffects/api/panel.PNG b/openpype/hosts/photoshop/api/panel.png similarity index 100% rename from openpype/hosts/aftereffects/api/panel.PNG rename to openpype/hosts/photoshop/api/panel.png diff --git a/openpype/hosts/photoshop/api/panel_failure.PNG b/openpype/hosts/photoshop/api/panel_failure.PNG deleted file mode 100644 index 67afc4e212..0000000000 Binary files a/openpype/hosts/photoshop/api/panel_failure.PNG and /dev/null differ diff --git a/openpype/hosts/photoshop/api/panel_failure.png b/openpype/hosts/photoshop/api/panel_failure.png new file mode 100644 index 0000000000..6e52a77d22 Binary files /dev/null and b/openpype/hosts/photoshop/api/panel_failure.png differ diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 56ae2a4c25..4e0dbcad06 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -48,11 +48,6 @@ class PhotoshopHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) register_event_callback("application.launched", on_application_launch) @@ -177,11 +172,6 @@ def on_application_launch(): check_inventory() -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - def ls(): """Yields containers from active Photoshop document diff --git a/openpype/hosts/photoshop/lib.py b/openpype/hosts/photoshop/lib.py index ae7a33b7b6..5c8dff947d 100644 --- a/openpype/hosts/photoshop/lib.py +++ b/openpype/hosts/photoshop/lib.py @@ -1,5 +1,9 @@ +import re + +from openpype import AYON_SERVER_ENABLED import openpype.hosts.photoshop.api as api from openpype.client import get_asset_by_name +from openpype.lib import prepare_template_data from openpype.pipeline import ( AutoCreator, CreatedInstance @@ -40,6 +44,14 @@ class PSAutoCreator(AutoCreator): asset_name = context.get_current_asset_name() task_name = context.get_current_task_name() host_name = context.host_name + + if existing_instance is None: + existing_instance_asset = None + elif AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance["folderPath"] + else: + existing_instance_asset = existing_instance["asset"] + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -47,10 +59,13 @@ class PSAutoCreator(AutoCreator): project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None @@ -67,7 +82,7 @@ class PSAutoCreator(AutoCreator): new_instance.data_to_store()) elif ( - existing_instance["asset"] != asset_name + existing_instance_asset != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -75,6 +90,23 @@ class PSAutoCreator(AutoCreator): self.default_variant, task_name, asset_doc, project_name, host_name ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name + + +def clean_subset_name(subset_name): + """Clean all variants leftover {layer} from subset name.""" + dynamic_data = prepare_template_data({"layer": "{layer}"}) + for value in dynamic_data.values(): + if value in subset_name: + subset_name = (subset_name.replace(value, "") + .replace("__", "_") + .replace("..", ".")) + # clean trailing separator as Main_ + pattern = r'[\W_]+$' + replacement = '' + return re.sub(pattern, replacement, subset_name) diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index 3bc61c8184..24be9df0e0 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -1,9 +1,11 @@ from openpype.pipeline import CreatedInstance +from openpype import AYON_SERVER_ENABLED from openpype.lib import BoolDef import openpype.hosts.photoshop.api as api -from openpype.hosts.photoshop.lib import PSAutoCreator +from openpype.hosts.photoshop.lib import PSAutoCreator, clean_subset_name from openpype.pipeline.create import get_subset_name +from openpype.lib import prepare_template_data from openpype.client import get_asset_by_name @@ -37,20 +39,25 @@ class AutoImageCreator(PSAutoCreator): asset_doc = get_asset_by_name(project_name, asset_name) if existing_instance is None: - subset_name = get_subset_name( - self.family, self.default_variant, task_name, asset_doc, + existing_instance_asset = None + elif AYON_SERVER_ENABLED: + existing_instance_asset = existing_instance["folderPath"] + else: + existing_instance_asset = existing_instance["asset"] + + if existing_instance is None: + subset_name = self.get_subset_name( + self.default_variant, task_name, asset_doc, project_name, host_name ) - publishable_ids = [layer.id for layer in api.stub().get_layers() - if layer.visible] data = { - "asset": asset_name, "task": task_name, - # ids are "virtual" layers, won't get grouped as 'members' do - # same difference in color coded layers in WP - "ids": publishable_ids } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name if not self.active_on_create: data["active"] = False @@ -66,15 +73,17 @@ class AutoImageCreator(PSAutoCreator): new_instance.data_to_store()) elif ( # existing instance from different context - existing_instance["asset"] != asset_name + existing_instance_asset != asset_name or existing_instance["task"] != task_name ): - subset_name = get_subset_name( - self.family, self.default_variant, task_name, asset_doc, + subset_name = self.get_subset_name( + self.default_variant, task_name, asset_doc, project_name, host_name ) - - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name @@ -98,7 +107,7 @@ class AutoImageCreator(PSAutoCreator): ) ] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["AutoImageCreator"] ) @@ -118,3 +127,19 @@ class AutoImageCreator(PSAutoCreator): Artist might disable this instance from publishing or from creating review for it though. """ + + def get_subset_name( + self, + variant, + task_name, + asset_doc, + project_name, + host_name=None, + instance=None + ): + dynamic_data = prepare_template_data({"layer": "{layer}"}) + subset_name = get_subset_name( + self.family, variant, task_name, asset_doc, + project_name, host_name, dynamic_data=dynamic_data + ) + return clean_subset_name(subset_name) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index f3165fca57..4f2e90886a 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -10,6 +10,7 @@ from openpype.pipeline import ( from openpype.lib import prepare_template_data from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances +from openpype.hosts.photoshop.lib import clean_subset_name class ImageCreator(Creator): @@ -88,18 +89,24 @@ class ImageCreator(Creator): layer_fill = prepare_template_data({"layer": layer_name}) subset_name = subset_name.format(**layer_fill) + subset_name = clean_subset_name(subset_name) if group.long_name: for directory in group.long_name[::-1]: name = self._clean_highlights(stub, directory) layer_names_in_hierarchy.append(name) - data.update({"subset": subset_name}) - data.update({"members": [str(group.id)]}) - data.update({"layer_name": layer_name}) - data.update({"long_name": "_".join(layer_names_in_hierarchy)}) + data_update = { + "subset": subset_name, + "members": [str(group.id)], + "layer_name": layer_name, + "long_name": "_".join(layer_names_in_hierarchy) + } + data.update(data_update) - creator_attributes = {"mark_for_review": self.mark_for_review} + mark_for_review = (pre_create_data.get("mark_for_review") or + self.mark_for_review) + creator_attributes = {"mark_for_review": mark_for_review} data.update({"creator_attributes": creator_attributes}) if not self.active_on_create: @@ -124,8 +131,6 @@ class ImageCreator(Creator): if creator_id == self.identifier: instance_data = self._handle_legacy(instance_data) - layer = api.stub().get_layer(instance_data["members"][0]) - instance_data["layer"] = layer instance = CreatedInstance.from_existing( instance_data, self ) @@ -171,7 +176,7 @@ class ImageCreator(Creator): ) ] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["ImageCreator"] ) @@ -181,7 +186,6 @@ class ImageCreator(Creator): self.mark_for_review = plugin_settings["mark_for_review"] self.enabled = plugin_settings["enabled"] - def get_detail_description(self): return """Creator for Image instances diff --git a/openpype/hosts/photoshop/plugins/create/create_review.py b/openpype/hosts/photoshop/plugins/create/create_review.py index 064485d465..63751d94e4 100644 --- a/openpype/hosts/photoshop/plugins/create/create_review.py +++ b/openpype/hosts/photoshop/plugins/create/create_review.py @@ -18,7 +18,7 @@ class ReviewCreator(PSAutoCreator): it will get recreated in next publish either way). """ - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["ReviewCreator"] ) diff --git a/openpype/hosts/photoshop/plugins/create/create_workfile.py b/openpype/hosts/photoshop/plugins/create/create_workfile.py index d498f0549c..1b255de3a3 100644 --- a/openpype/hosts/photoshop/plugins/create/create_workfile.py +++ b/openpype/hosts/photoshop/plugins/create/create_workfile.py @@ -19,7 +19,7 @@ class WorkfileCreator(PSAutoCreator): in next publish automatically). """ - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["WorkfileCreator"] ) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py index f1d8419608..4d7838c510 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py @@ -1,5 +1,6 @@ import pyblish.api +from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name @@ -16,19 +17,18 @@ class CollectAutoImage(pyblish.api.ContextPlugin): targets = ["automated"] def process(self, context): - family = "image" for instance in context: creator_identifier = instance.data.get("creator_identifier") if creator_identifier and creator_identifier == "auto_image": self.log.debug("Auto image instance found, won't create new") return - project_name = context.data["anatomyData"]["project"]["name"] + project_name = context.data["projectName"] proj_settings = context.data["project_settings"] - task_name = context.data["anatomyData"]["task"]["name"] + task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) auto_creator = proj_settings.get( "photoshop", {}).get( diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py new file mode 100644 index 0000000000..741fb0e9cd --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py @@ -0,0 +1,24 @@ +import pyblish.api + +from openpype.hosts.photoshop import api as photoshop + + +class CollectAutoImageRefresh(pyblish.api.ContextPlugin): + """Refreshes auto_image instance with currently visible layers.. + """ + + label = "Collect Auto Image Refresh" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.2 + + def process(self, context): + for instance in context: + creator_identifier = instance.data.get("creator_identifier") + if creator_identifier and creator_identifier == "auto_image": + self.log.debug("Auto image instance found, won't create new") + # refresh existing auto image instance with current visible + publishable_ids = [layer.id for layer in photoshop.stub().get_layers() # noqa + if layer.visible] + instance.data["ids"] = publishable_ids + return diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py index 82ba0ac09c..e5a2f326d7 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py @@ -7,6 +7,7 @@ Provides: """ import pyblish.api +from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name @@ -60,12 +61,13 @@ class CollectAutoReview(pyblish.api.ContextPlugin): variant = (context.data.get("variant") or auto_creator["default_variant"]) - project_name = context.data["anatomyData"]["project"]["name"] + project_name = context.data["projectName"] proj_settings = context.data["project_settings"] - task_name = context.data["anatomyData"]["task"]["name"] + task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] - asset_name = asset_doc["name"] + + asset_name = get_asset_name_identifier(asset_doc) subset_name = get_subset_name( family, diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py index 01dc50af40..9ccb8f4f85 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py @@ -1,6 +1,7 @@ import os import pyblish.api +from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name @@ -51,7 +52,7 @@ class CollectAutoWorkfile(pyblish.api.ContextPlugin): self.log.debug("Workfile instance disabled") return - project_name = context.data["anatomyData"]["project"]["name"] + project_name = context.data["projectName"] proj_settings = context.data["project_settings"] auto_creator = proj_settings.get( "photoshop", {}).get( @@ -66,11 +67,11 @@ class CollectAutoWorkfile(pyblish.api.ContextPlugin): variant = (context.data.get("variant") or auto_creator["default_variant"]) - task_name = context.data["anatomyData"]["task"]["name"] + task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) subset_name = get_subset_name( family, variant, diff --git a/openpype/hosts/photoshop/plugins/publish/collect_image.py b/openpype/hosts/photoshop/plugins/publish/collect_image.py new file mode 100644 index 0000000000..64727cef33 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_image.py @@ -0,0 +1,20 @@ +import pyblish.api + +from openpype.hosts.photoshop import api + + +class CollectImage(pyblish.api.InstancePlugin): + """Collect layer metadata into a instance. + + Used later in validation + """ + order = pyblish.api.CollectorOrder + 0.200 + label = 'Collect Image' + + hosts = ["photoshop"] + families = ["image"] + + def process(self, instance): + if instance.data.get("members"): + layer = api.stub().get_layer(instance.data["members"][0]) + instance.data["layer"] = layer diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index cdb28c742d..680f580cc0 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -45,9 +45,11 @@ class ExtractImage(pyblish.api.ContextPlugin): # Perform extraction files = {} ids = set() - layer = instance.data.get("layer") - if layer: - ids.add(layer.id) + # real layers and groups + members = instance.data("members") + if members: + ids.update(set([int(member) for member in members])) + # virtual groups collected by color coding or auto_image add_ids = instance.data.pop("ids", None) if add_ids: ids.update(set(add_ids)) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 4aa7a05bd1..d5dac417d7 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -1,4 +1,5 @@ import os +import shutil from PIL import Image from openpype.lib import ( @@ -55,6 +56,7 @@ class ExtractReview(publish.Extractor): } if instance.data["family"] != "review": + self.log.debug("Existing extracted file from image family used.") # enable creation of review, without this jpg review would clash # with jpg of the image family output_name = repre_name @@ -62,8 +64,15 @@ class ExtractReview(publish.Extractor): repre_skeleton.update({"name": repre_name, "outputName": output_name}) - if self.make_image_sequence and len(layers) > 1: - self.log.info("Extract layers to image sequence.") + img_file = self.output_seq_filename % 0 + self._prepare_file_for_image_family(img_file, instance, + staging_dir) + repre_skeleton.update({ + "files": img_file, + }) + processed_img_names = [img_file] + elif self.make_image_sequence and len(layers) > 1: + self.log.debug("Extract layers to image sequence.") img_list = self._save_sequence_images(staging_dir, layers) repre_skeleton.update({ @@ -72,17 +81,17 @@ class ExtractReview(publish.Extractor): "fps": fps, "files": img_list, }) - instance.data["representations"].append(repre_skeleton) processed_img_names = img_list else: - self.log.info("Extract layers to flatten image.") - img_list = self._save_flatten_image(staging_dir, layers) + self.log.debug("Extract layers to flatten image.") + img_file = self._save_flatten_image(staging_dir, layers) repre_skeleton.update({ - "files": img_list, + "files": img_file, }) - instance.data["representations"].append(repre_skeleton) - processed_img_names = [img_list] + processed_img_names = [img_file] + + instance.data["representations"].append(repre_skeleton) ffmpeg_args = get_ffmpeg_tool_args("ffmpeg") @@ -111,6 +120,39 @@ class ExtractReview(publish.Extractor): self.log.info(f"Extracted {instance} to {staging_dir}") + def _prepare_file_for_image_family(self, img_file, instance, staging_dir): + """Converts existing file for image family to .jpg + + Image instance could have its own separate review (instance per layer + for example). This uses extracted file instead of extracting again. + Args: + img_file (str): name of output file (with 0000 value for ffmpeg + later) + instance: + staging_dir (str): temporary folder where extracted file is located + """ + repre_file = instance.data["representations"][0] + source_file_path = os.path.join(repre_file["stagingDir"], + repre_file["files"]) + if not os.path.exists(source_file_path): + raise RuntimeError(f"{source_file_path} doesn't exist for " + "review to create from") + _, ext = os.path.splitext(repre_file["files"]) + if ext != ".jpg": + im = Image.open(source_file_path) + if (im.mode in ('RGBA', 'LA') or ( + im.mode == 'P' and 'transparency' in im.info)): + # without this it produces messy low quality jpg + rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff") + rgb_im.alpha_composite(im) + rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file)) + else: + im.save(os.path.join(staging_dir, img_file)) + else: + # handles already .jpg + shutil.copy(source_file_path, + os.path.join(staging_dir, img_file)) + def _generate_mov(self, ffmpeg_path, instance, fps, no_of_frames, source_files_pattern, staging_dir): """Generates .mov to upload to Ftrack. @@ -218,6 +260,11 @@ class ExtractReview(publish.Extractor): (list) of PSItem """ layers = [] + # creating review for existing 'image' instance + if instance.data["family"] == "image" and instance.data.get("layer"): + layers.append(instance.data["layer"]) + return layers + for image_instance in instance.context: if image_instance.data["family"] != "image": continue diff --git a/openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt b/openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt similarity index 89% rename from openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt rename to openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt index 98597a12cb..7d1d6edf61 100644 --- a/openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt +++ b/openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt @@ -1,4 +1,4 @@ -Updated as of 9 May 2022 +Updated as of 26 May 2023 ---------------------------- In this package, you will find a brief introduction to the Scripting API for DaVinci Resolve Studio. Apart from this README.txt file, this package contains folders containing the basic import modules for scripting access (DaVinciResolve.py) and some representative examples. @@ -19,7 +19,7 @@ DaVinci Resolve scripting requires one of the following to be installed (for all Lua 5.1 Python 2.7 64-bit - Python 3.6 64-bit + Python >= 3.6 64-bit Using a script @@ -171,6 +171,10 @@ Project GetRenderResolutions(format, codec) --> [{Resolution}] # Returns list of resolutions applicable for the given render format (string) and render codec (string). Returns full list of resolutions if no argument is provided. Each element in the list is a dictionary with 2 keys "Width" and "Height". RefreshLUTList() --> Bool # Refreshes LUT List GetUniqueId() --> string # Returns a unique ID for the project item + InsertAudioToCurrentTrackAtPlayhead(mediaPath, --> Bool # Inserts the media specified by mediaPath (string) with startOffsetInSamples (int) and durationInSamples (int) at the playhead on a selected track on the Fairlight page. Returns True if successful, otherwise False. + startOffsetInSamples, durationInSamples) + LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for project when supplied presetName (string). Returns true if successful. + ExportCurrentFrameAsStill(filePath) --> Bool # Exports current frame as still to supplied filePath. filePath must end in valid export file format. Returns True if succssful, False otherwise. MediaStorage GetMountedVolumeList() --> [paths...] # Returns list of folder paths corresponding to mounted volumes displayed in Resolve’s Media Storage. @@ -179,6 +183,7 @@ MediaStorage RevealInStorage(path) --> Bool # Expands and displays given file/folder path in Resolve’s Media Storage. AddItemListToMediaPool(item1, item2, ...) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is one or more file/folder paths. Returns a list of the MediaPoolItems created. AddItemListToMediaPool([items...]) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is an array of file/folder paths. Returns a list of the MediaPoolItems created. + AddItemListToMediaPool([{itemInfo}, ...]) --> [clips...] # Adds list of itemInfos specified as dict of "media", "startFrame" (int), "endFrame" (int) from Media Storage into current Media Pool folder. Returns a list of the MediaPoolItems created. AddClipMattesToMediaPool(MediaPoolItem, [paths], stereoEye) --> Bool # Adds specified media files as mattes for the specified MediaPoolItem. StereoEye is an optional argument for specifying which eye to add the matte to for stereo clips ("left" or "right"). Returns True if successful. AddTimelineMattesToMediaPool([paths]) --> [MediaPoolItems] # Adds specified media files as timeline mattes in current media pool folder. Returns a list of created MediaPoolItems. @@ -189,20 +194,22 @@ MediaPool CreateEmptyTimeline(name) --> Timeline # Adds new timeline with given name. AppendToTimeline(clip1, clip2, ...) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. AppendToTimeline([clips]) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. - AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only). Returns the list of appended timelineItems. + AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only), "trackIndex" (int) and "recordFrame" (int). Returns the list of appended timelineItems. CreateTimelineFromClips(name, clip1, clip2,...) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. CreateTimelineFromClips(name, [clips]) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. - CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int). - ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file and optional importOptions dict, with support for the keys: - # "timelineName": string, specifies the name of the timeline to be created - # "importSourceClips": Bool, specifies whether source clips should be imported, True by default + CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), "recordFrame" (int). + ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file (AAF/EDL/XML/FCPXML/DRT/ADL) and optional importOptions dict, with support for the keys: + # "timelineName": string, specifies the name of the timeline to be created. Not valid for DRT import + # "importSourceClips": Bool, specifies whether source clips should be imported, True by default. Not valid for DRT import # "sourceClipsPath": string, specifies a filesystem path to search for source clips if the media is inaccessible in their original path and if "importSourceClips" is True - # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False + # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False. Not valid for DRT import # "interlaceProcessing": Bool, specifies whether to enable interlace processing on the imported timeline being created. valid only for AAF import DeleteTimelines([timeline]) --> Bool # Deletes specified timelines in the media pool. GetCurrentFolder() --> Folder # Returns currently selected Folder. SetCurrentFolder(Folder) --> Bool # Sets current folder by given Folder. DeleteClips([clips]) --> Bool # Deletes specified clips or timeline mattes in the media pool + ImportFolderFromFile(filePath, sourceClipsPath="") --> Bool # Returns true if import from given DRB filePath is successful, false otherwise + # sourceClipsPath is a string that specifies a filesystem path to search for source clips if the media is inaccessible in their original path, empty by default DeleteFolders([subfolders]) --> Bool # Deletes specified subfolders in the media pool MoveClips([clips], targetFolder) --> Bool # Moves specified clips to target folder. MoveFolders([folders], targetFolder) --> Bool # Moves specified folders to target folder. @@ -225,6 +232,7 @@ Folder GetSubFolderList() --> [folders...] # Returns a list of subfolders in the folder. GetIsFolderStale() --> bool # Returns true if folder is stale in collaboration mode, false otherwise GetUniqueId() --> string # Returns a unique ID for the media pool folder + Export(filePath) --> bool # Returns true if export of DRB folder to filePath is successful, false otherwise MediaPoolItem GetName() --> string # Returns the clip name. @@ -257,6 +265,8 @@ MediaPoolItem UnlinkProxyMedia() --> Bool # Unlinks any proxy media associated with clip. ReplaceClip(filePath) --> Bool # Replaces the underlying asset and metadata of MediaPoolItem with the specified absolute clip path. GetUniqueId() --> string # Returns a unique ID for the media pool item + TranscribeAudio() --> Bool # Transcribes audio of the MediaPoolItem. Returns True if successful; False otherwise + ClearTranscription() --> Bool # Clears audio transcription of the MediaPoolItem. Returns True if successful; False otherwise. Timeline GetName() --> string # Returns the timeline name. @@ -266,6 +276,23 @@ Timeline SetStartTimecode(timecode) --> Bool # Set the start timecode of the timeline to the string 'timecode'. Returns true when the change is successful, false otherwise. GetStartTimecode() --> string # Returns the start timecode for the timeline. GetTrackCount(trackType) --> int # Returns the number of tracks for the given track type ("audio", "video" or "subtitle"). + AddTrack(trackType, optionalSubTrackType) --> Bool # Adds track of trackType ("video", "subtitle", "audio"). Second argument optionalSubTrackType is required for "audio" + # optionalSubTrackType can be one of {"mono", "stereo", "5.1", "5.1film", "7.1", "7.1film", "adaptive1", ... , "adaptive24"} + DeleteTrack(trackType, trackIndex) --> Bool # Deletes track of trackType ("video", "subtitle", "audio") and given trackIndex. 1 <= trackIndex <= GetTrackCount(trackType). + SetTrackEnable(trackType, trackIndex, Bool) --> Bool # Enables/Disables track with given trackType and trackIndex + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + GetIsTrackEnabled(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is enabled and False otherwise. + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + SetTrackLock(trackType, trackIndex, Bool) --> Bool # Locks/Unlocks track with given trackType and trackIndex + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + GetIsTrackLocked(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is locked and False otherwise. + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + DeleteClips([timelineItems], Bool) --> Bool # Deletes specified TimelineItems from the timeline, performing ripple delete if the second argument is True. Second argument is optional (The default for this is False) + SetClipsLinked([timelineItems], Bool) --> Bool # Links or unlinks the specified TimelineItems depending on second argument. GetItemListInTrack(trackType, index) --> [items...] # Returns a list of timeline items on that track (based on trackType and index). 1 <= index <= GetTrackCount(trackType). AddMarker(frameId, color, name, note, duration, --> Bool # Creates a new marker at given frameId position and with given marker information. 'customData' is optional and helps to attach user specific data to the marker. customData) @@ -301,7 +328,7 @@ Timeline # "sourceClipsFolders": string, list of Media Pool folder objects to search for source clips if the media is not present in current folder Export(fileName, exportType, exportSubtype) --> Bool # Exports timeline to 'fileName' as per input exportType & exportSubtype format. - # Refer to section "Looking up timeline exports properties" for information on the parameters. + # Refer to section "Looking up timeline export properties" for information on the parameters. GetSetting(settingName) --> string # Returns value of timeline setting (indicated by settingName : string). Check the section below for more information. SetSetting(settingName, settingValue) --> Bool # Sets timeline setting (indicated by settingName : string) to the value (settingValue : string). Check the section below for more information. InsertGeneratorIntoTimeline(generatorName) --> TimelineItem # Inserts a generator (indicated by generatorName : string) into the timeline. @@ -313,6 +340,8 @@ Timeline GrabStill() --> galleryStill # Grabs still from the current video clip. Returns a GalleryStill object. GrabAllStills(stillFrameSource) --> [galleryStill] # Grabs stills from all the clips of the timeline at 'stillFrameSource' (1 - First frame, 2 - Middle frame). Returns the list of GalleryStill objects. GetUniqueId() --> string # Returns a unique ID for the timeline + CreateSubtitlesFromAudio() --> Bool # Creates subtitles from audio for the timeline. Returns True on success, False otherwise. + DetectSceneCuts() --> Bool # Detects and makes scene cuts along the timeline. Returns True if successful, False otherwise. TimelineItem GetName() --> string # Returns the item name. @@ -362,6 +391,7 @@ TimelineItem GetStereoLeftFloatingWindowParams() --> {keyframes...} # For the LEFT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetStereoRightFloatingWindowParams() --> {keyframes...} # For the RIGHT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetNumNodes() --> int # Returns the number of nodes in the current graph for the timeline item + ApplyArriCdlLut() --> Bool # Applies ARRI CDL and LUT. Returns True if successful, False otherwise. SetLUT(nodeIndex, lutPath) --> Bool # Sets LUT on the node mapping the node index provided, 1 <= nodeIndex <= total number of nodes. # The lutPath can be an absolute path, or a relative path (based off custom LUT paths or the master LUT path). # The operation is successful for valid lut paths that Resolve has already discovered (see Project.RefreshLUTList). @@ -376,8 +406,16 @@ TimelineItem SelectTakeByIndex(idx) --> Bool # Selects a take by index, 1 <= idx <= number of takes. FinalizeTake() --> Bool # Finalizes take selection. CopyGrades([tgtTimelineItems]) --> Bool # Copies the current grade to all the items in tgtTimelineItems list. Returns True on success and False if any error occurred. + SetClipEnabled(Bool) --> Bool # Sets clip enabled based on argument. + GetClipEnabled() --> Bool # Gets clip enabled status. UpdateSidecar() --> Bool # Updates sidecar file for BRAW clips or RMD file for R3D clips. GetUniqueId() --> string # Returns a unique ID for the timeline item + LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for clip when supplied presetName (string). Returns true if successful. + GetNodeLabel(nodeIndex) --> string # Returns the label of the node at nodeIndex. + CreateMagicMask(mode) --> Bool # Returns True if magic mask was created successfully, False otherwise. mode can "F" (forward), "B" (backward), or "BI" (bidirection) + RegenerateMagicMask() --> Bool # Returns True if magic mask was regenerated successfully, False otherwise. + Stabilize() --> Bool # Returns True if stabilization was successful, False otherwise + SmartReframe() --> Bool # Performs Smart Reframe. Returns True if successful, False otherwise. Gallery GetAlbumName(galleryStillAlbum) --> string # Returns the name of the GalleryStillAlbum object 'galleryStillAlbum'. @@ -422,9 +460,11 @@ Invoke "Project:SetSetting", "Timeline:SetSetting" or "MediaPoolItem:SetClipProp ensure the success of the operation. You can troubleshoot the validity of keys and values by setting the desired result from the UI and checking property snapshots before and after the change. The following Project properties have specifically enumerated values: -"superScale" - the property value is an enumerated integer between 0 and 3 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. +"superScale" - the property value is an enumerated integer between 0 and 4 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. + for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: β€’ x = Project:GetSetting('superScale') and Project:SetSetting('superScale', x) +β€’ for '2x Enhanced' --> Project:SetSetting('superScale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] "timelineFrameRate" - the property value is one of the frame rates available to the user in project settings under "Timeline frame rate" option. Drop Frame can be configured for supported frame rates by appending the frame rate with "DF", e.g. "29.97 DF" will enable drop frame and "29.97" will disable drop frame @@ -432,9 +472,11 @@ Affects: β€’ x = Project:GetSetting('timelineFrameRate') and Project:SetSetting('timelineFrameRate', x) The following Clip properties have specifically enumerated values: -"superScale" - the property value is an enumerated integer between 1 and 3 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. +"Super Scale" - the property value is an enumerated integer between 1 and 4 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. + for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: β€’ x = MediaPoolItem:GetClipProperty('Super Scale') and MediaPoolItem:SetClipProperty('Super Scale', x) +β€’ for '2x Enhanced' --> MediaPoolItem:SetClipProperty('Super Scale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] Looking up Render Settings @@ -478,11 +520,6 @@ exportType can be one of the following constants: - resolve.EXPORT_DRT - resolve.EXPORT_EDL - resolve.EXPORT_FCP_7_XML - - resolve.EXPORT_FCPXML_1_3 - - resolve.EXPORT_FCPXML_1_4 - - resolve.EXPORT_FCPXML_1_5 - - resolve.EXPORT_FCPXML_1_6 - - resolve.EXPORT_FCPXML_1_7 - resolve.EXPORT_FCPXML_1_8 - resolve.EXPORT_FCPXML_1_9 - resolve.EXPORT_FCPXML_1_10 @@ -492,6 +529,8 @@ exportType can be one of the following constants: - resolve.EXPORT_TEXT_TAB - resolve.EXPORT_DOLBY_VISION_VER_2_9 - resolve.EXPORT_DOLBY_VISION_VER_4_0 + - resolve.EXPORT_DOLBY_VISION_VER_5_1 + - resolve.EXPORT_OTIO exportSubtype can be one of the following enums: - resolve.EXPORT_NONE - resolve.EXPORT_AAF_NEW @@ -504,6 +543,16 @@ When exportType is resolve.EXPORT_AAF, valid exportSubtype values are resolve.EX When exportType is resolve.EXPORT_EDL, valid exportSubtype values are resolve.EXPORT_CDL, resolve.EXPORT_SDL, resolve.EXPORT_MISSING_CLIPS and resolve.EXPORT_NONE. Note: Replace 'resolve.' when using the constants above, if a different Resolve class instance name is used. +Unsupported exportType types +--------------------------------- +Starting with DaVinci Resolve 18.1, the following export types are not supported: + - resolve.EXPORT_FCPXML_1_3 + - resolve.EXPORT_FCPXML_1_4 + - resolve.EXPORT_FCPXML_1_5 + - resolve.EXPORT_FCPXML_1_6 + - resolve.EXPORT_FCPXML_1_7 + + Looking up Timeline item properties ----------------------------------- This section covers additional notes for the function "TimelineItem:SetProperty" and "TimelineItem:GetProperty". These functions are used to get and set properties mentioned. diff --git a/openpype/hosts/resolve/api/__init__.py b/openpype/hosts/resolve/api/__init__.py index 2b4546f8d6..dba275e6c4 100644 --- a/openpype/hosts/resolve/api/__init__.py +++ b/openpype/hosts/resolve/api/__init__.py @@ -6,13 +6,10 @@ from .utils import ( ) from .pipeline import ( - install, - uninstall, + ResolveHost, ls, containerise, update_container, - publish, - launch_workfiles_app, maintained_selection, remove_instance, list_instances @@ -76,14 +73,10 @@ __all__ = [ "bmdvf", # pipeline - "install", - "uninstall", + "ResolveHost", "ls", "containerise", "update_container", - "reload_pipeline", - "publish", - "launch_workfiles_app", "maintained_selection", "remove_instance", "list_instances", diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index eaee3bb9ba..3866477c77 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -6,7 +6,10 @@ import contextlib from opentimelineio import opentime from openpype.lib import Logger -from openpype.pipeline.editorial import is_overlapping_otio_ranges +from openpype.pipeline.editorial import ( + is_overlapping_otio_ranges, + frames_to_timecode +) from ..otio import davinci_export as otio_export @@ -125,15 +128,19 @@ def get_any_timeline(): return project.GetTimelineByIndex(1) -def get_new_timeline(): +def get_new_timeline(timeline_name: str = None): """Get new timeline object. + Arguments: + timeline_name (str): New timeline name. + Returns: object: resolve.Timeline """ project = get_current_project() media_pool = project.GetMediaPool() - new_timeline = media_pool.CreateEmptyTimeline(self.pype_timeline_name) + new_timeline = media_pool.CreateEmptyTimeline( + timeline_name or self.pype_timeline_name) project.SetCurrentTimeline(new_timeline) return new_timeline @@ -179,53 +186,52 @@ def create_bin(name: str, root: object = None) -> object: return media_pool.GetCurrentFolder() -def create_media_pool_item(fpath: str, - root: object = None) -> object: +def remove_media_pool_item(media_pool_item: object) -> bool: + media_pool = get_current_project().GetMediaPool() + return media_pool.DeleteClips([media_pool_item]) + + +def create_media_pool_item( + files: list, + root: object = None, +) -> object: """ Create media pool item. Args: - fpath (str): absolute path to a file + files (list[str]): list of absolute paths to files root (resolve.Folder)[optional]: root folder / bin object Returns: object: resolve.MediaPoolItem """ # get all variables - media_storage = get_media_storage() media_pool = get_current_project().GetMediaPool() root_bin = root or media_pool.GetRootFolder() + # make sure files list is not empty and first available file exists + filepath = next((f for f in files if os.path.isfile(f)), None) + if not filepath: + raise FileNotFoundError("No file found in input files list") + # try to search in bin if the clip does not exist - existing_mpi = get_media_pool_item(fpath, root_bin) + existing_mpi = get_media_pool_item(filepath, root_bin) if existing_mpi: return existing_mpi - dirname, file = os.path.split(fpath) - _name, ext = os.path.splitext(file) + # add all data in folder to media pool + media_pool_items = media_pool.ImportMedia(files) - # add all data in folder to mediapool - media_pool_items = media_storage.AddItemListToMediaPool( - os.path.normpath(dirname)) - - if not media_pool_items: - return False - - # if any are added then look into them for the right extension - media_pool_item = [mpi for mpi in media_pool_items - if ext in mpi.GetClipProperty("File Path")] - - # return only first found - return media_pool_item.pop() + return media_pool_items.pop() if media_pool_items else False -def get_media_pool_item(fpath, root: object = None) -> object: +def get_media_pool_item(filepath, root: object = None) -> object: """ Return clip if found in folder with use of input file path. Args: - fpath (str): absolute path to a file + filepath (str): absolute path to a file root (resolve.Folder)[optional]: root folder / bin object Returns: @@ -233,7 +239,7 @@ def get_media_pool_item(fpath, root: object = None) -> object: """ media_pool = get_current_project().GetMediaPool() root = root or media_pool.GetRootFolder() - fname = os.path.basename(fpath) + fname = os.path.basename(filepath) for _mpi in root.GetClipList(): _mpi_name = _mpi.GetClipProperty("File Name") @@ -243,18 +249,22 @@ def get_media_pool_item(fpath, root: object = None) -> object: return None -def create_timeline_item(media_pool_item: object, - timeline: object = None, - source_start: int = None, - source_end: int = None) -> object: +def create_timeline_item( + media_pool_item: object, + timeline: object = None, + timeline_in: int = None, + source_start: int = None, + source_end: int = None, +) -> object: """ Add media pool item to current or defined timeline. Args: media_pool_item (resolve.MediaPoolItem): resolve's object - timeline (resolve.Timeline)[optional]: resolve's object - source_start (int)[optional]: media source input frame (sequence frame) - source_end (int)[optional]: media source output frame (sequence frame) + timeline (Optional[resolve.Timeline]): resolve's object + timeline_in (Optional[int]): timeline input frame (sequence frame) + source_start (Optional[int]): media source input frame (sequence frame) + source_end (Optional[int]): media source output frame (sequence frame) Returns: object: resolve.TimelineItem @@ -266,28 +276,45 @@ def create_timeline_item(media_pool_item: object, clip_name = _clip_property("File Name") timeline = timeline or get_current_timeline() + # timing variables + if all([timeline_in, source_start, source_end]): + fps = timeline.GetSetting("timelineFrameRate") + duration = source_end - source_start + timecode_in = frames_to_timecode(timeline_in, fps) + timecode_out = frames_to_timecode(timeline_in + duration, fps) + else: + timecode_in = None + timecode_out = None + # if timeline was used then switch it to current timeline with maintain_current_timeline(timeline): # Add input mediaPoolItem to clip data - clip_data = {"mediaPoolItem": media_pool_item} + clip_data = { + "mediaPoolItem": media_pool_item, + } - # add source time range if input was given - if source_start is not None: - clip_data.update({"startFrame": source_start}) - if source_end is not None: - clip_data.update({"endFrame": source_end}) + if source_start: + clip_data["startFrame"] = source_start + if source_end: + clip_data["endFrame"] = source_end + if timecode_in: + clip_data["recordFrame"] = timeline_in - print(clip_data) # add to timeline media_pool.AppendToTimeline([clip_data]) output_timeline_item = get_timeline_item( media_pool_item, timeline) - assert output_timeline_item, AssertionError( - "Track Item with name `{}` doesn't exist on the timeline: `{}`".format( - clip_name, timeline.GetName() - )) + assert output_timeline_item, AssertionError(( + "Clip name '{}' was't created on the timeline: '{}' \n\n" + "Please check if correct track position is activated, \n" + "or if a clip is not already at the timeline in \n" + "position: '{}' out: '{}'. \n\n" + "Clip data: {}" + ).format( + clip_name, timeline.GetName(), timecode_in, timecode_out, clip_data + )) return output_timeline_item @@ -394,14 +421,22 @@ def get_current_timeline_items( def get_pype_timeline_item_by_name(name: str) -> object: - track_itmes = get_current_timeline_items() - for _ti in track_itmes: - tag_data = get_timeline_item_pype_tag(_ti["clip"]["item"]) - tag_name = tag_data.get("name") + """Get timeline item by name. + + Args: + name (str): name of timeline item + + Returns: + object: resolve.TimelineItem + """ + for _ti_data in get_current_timeline_items(): + _ti_clip = _ti_data["clip"]["item"] + tag_data = get_timeline_item_pype_tag(_ti_clip) + tag_name = tag_data.get("namespace") if not tag_name: continue - if tag_data.get("name") in name: - return _ti + if tag_name in name: + return _ti_clip return None @@ -480,7 +515,7 @@ def imprint(timeline_item, data=None): Arguments: timeline_item (hiero.core.TrackItem): hiero track item object - data (dict): Any data which needst to be imprinted + data (dict): Any data which needs to be imprinted Examples: data = { @@ -544,12 +579,11 @@ def set_pype_marker(timeline_item, tag_data): def get_pype_marker(timeline_item): timeline_item_markers = timeline_item.GetMarkers() - for marker_frame in timeline_item_markers: - note = timeline_item_markers[marker_frame]["note"] - color = timeline_item_markers[marker_frame]["color"] - name = timeline_item_markers[marker_frame]["name"] - print(f"_ marker data: {marker_frame} | {name} | {color} | {note}") + for marker_frame, marker in timeline_item_markers.items(): + color = marker["color"] + name = marker["name"] if name == self.pype_marker_name and color == self.pype_marker_color: + note = marker["note"] self.temp_marker_frame = marker_frame return json.loads(note) @@ -618,7 +652,7 @@ def create_compound_clip(clip_data, name, folder): if c.GetName() in name), None) if cct: - print(f"_ cct exists: {cct}") + print(f"Compound clip exists: {cct}") else: # Create empty timeline in current folder and give name: cct = mp.CreateEmptyTimeline(name) @@ -627,7 +661,7 @@ def create_compound_clip(clip_data, name, folder): clips = folder.GetClipList() cct = next((c for c in clips if c.GetName() in name), None) - print(f"_ cct created: {cct}") + print(f"Compound clip created: {cct}") with maintain_current_timeline(cct, tl_origin): # Add input clip to the current timeline: diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index b3717e01ea..2210178a67 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -1,14 +1,13 @@ import os import sys -from qtpy import QtWidgets, QtCore +from qtpy import QtWidgets, QtCore, QtGui from openpype.tools.utils import host_tools +from openpype.pipeline import registered_host -from .pipeline import ( - publish, - launch_workfiles_app -) + +MENU_LABEL = os.environ["AVALON_LABEL"] def load_stylesheet(): @@ -43,7 +42,7 @@ class OpenPypeMenu(QtWidgets.QWidget): def __init__(self, *args, **kwargs): super(OpenPypeMenu, self).__init__(*args, **kwargs) - self.setObjectName("OpenPypeMenu") + self.setObjectName(f"{MENU_LABEL}Menu") self.setWindowFlags( QtCore.Qt.Window @@ -53,7 +52,8 @@ class OpenPypeMenu(QtWidgets.QWidget): | QtCore.Qt.WindowStaysOnTopHint ) - self.setWindowTitle("OpenPype") + self.setWindowTitle(f"{MENU_LABEL}") + save_current_btn = QtWidgets.QPushButton("Save current file", self) workfiles_btn = QtWidgets.QPushButton("Workfiles ...", self) create_btn = QtWidgets.QPushButton("Create ...", self) publish_btn = QtWidgets.QPushButton("Publish ...", self) @@ -75,6 +75,10 @@ class OpenPypeMenu(QtWidgets.QWidget): layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(10, 20, 10, 20) + layout.addWidget(save_current_btn) + + layout.addWidget(Spacer(15, self)) + layout.addWidget(workfiles_btn) layout.addWidget(create_btn) layout.addWidget(publish_btn) @@ -99,6 +103,8 @@ class OpenPypeMenu(QtWidgets.QWidget): self.setLayout(layout) + save_current_btn.clicked.connect(self.on_save_current_clicked) + save_current_btn.setShortcut(QtGui.QKeySequence.Save) workfiles_btn.clicked.connect(self.on_workfile_clicked) create_btn.clicked.connect(self.on_create_clicked) publish_btn.clicked.connect(self.on_publish_clicked) @@ -111,9 +117,21 @@ class OpenPypeMenu(QtWidgets.QWidget): # reset_resolution_btn.clicked.connect(self.on_set_resolution_clicked) experimental_btn.clicked.connect(self.on_experimental_clicked) + def on_save_current_clicked(self): + host = registered_host() + current_file = host.get_current_workfile() + if not current_file: + print("Current project is not saved. " + "Please save once first via workfiles tool.") + host_tools.show_workfiles() + return + + print(f"Saving current file to: {current_file}") + host.save_workfile(current_file) + def on_workfile_clicked(self): print("Clicked Workfile") - launch_workfiles_app() + host_tools.show_workfiles() def on_create_clicked(self): print("Clicked Create") @@ -121,7 +139,7 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_publish_clicked(self): print("Clicked Publish") - publish(None) + host_tools.show_publish(parent=None) def on_load_clicked(self): print("Clicked Load") diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 899cb825bb..93dec300fb 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -12,14 +12,24 @@ from openpype.pipeline import ( schema, register_loader_plugin_path, register_creator_plugin_path, - deregister_loader_plugin_path, - deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) -from openpype.tools.utils import host_tools +from openpype.host import ( + HostBase, + IWorkfileHost, + ILoadHost +) from . import lib from .utils import get_resolve_module +from .workio import ( + open_file, + save_file, + file_extensions, + has_unsaved_changes, + work_root, + current_file +) log = Logger.get_logger(__name__) @@ -32,53 +42,56 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") AVALON_CONTAINERS = ":AVALON_CONTAINERS" -def install(): - """Install resolve-specific functionality of avalon-core. +class ResolveHost(HostBase, IWorkfileHost, ILoadHost): + name = "resolve" - This is where you install menus and register families, data - and loaders into resolve. + def install(self): + """Install resolve-specific functionality of avalon-core. - It is called automatically when installing via `api.install(resolve)`. + This is where you install menus and register families, data + and loaders into resolve. - See the Maya equivalent for inspiration on how to implement this. + It is called automatically when installing via `api.install(resolve)`. - """ + See the Maya equivalent for inspiration on how to implement this. - log.info("openpype.hosts.resolve installed") + """ - pyblish.register_host("resolve") - pyblish.register_plugin_path(PUBLISH_PATH) - log.info("Registering DaVinci Resovle plug-ins..") + log.info("openpype.hosts.resolve installed") - register_loader_plugin_path(LOAD_PATH) - register_creator_plugin_path(CREATE_PATH) + pyblish.register_host(self.name) + pyblish.register_plugin_path(PUBLISH_PATH) + print("Registering DaVinci Resolve plug-ins..") - # register callback for switching publishable - pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + register_loader_plugin_path(LOAD_PATH) + register_creator_plugin_path(CREATE_PATH) - get_resolve_module() + # register callback for switching publishable + pyblish.register_callback("instanceToggled", + on_pyblish_instance_toggled) + get_resolve_module() -def uninstall(): - """Uninstall all that was installed + def open_workfile(self, filepath): + return open_file(filepath) - This is where you undo everything that was done in `install()`. - That means, removing menus, deregistering families and data - and everything. It should be as though `install()` was never run, - because odds are calling this function means the user is interested - in re-installing shortly afterwards. If, for example, he has been - modifying the menu or registered families. + def save_workfile(self, filepath=None): + return save_file(filepath) - """ - pyblish.deregister_host("resolve") - pyblish.deregister_plugin_path(PUBLISH_PATH) - log.info("Deregistering DaVinci Resovle plug-ins..") + def work_root(self, session): + return work_root(session) - deregister_loader_plugin_path(LOAD_PATH) - deregister_creator_plugin_path(CREATE_PATH) + def get_current_workfile(self): + return current_file() - # register callback for switching publishable - pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + def workfile_has_unsaved_changes(self): + return has_unsaved_changes() + + def get_workfile_extensions(self): + return file_extensions() + + def get_containers(self): + return ls() def containerise(timeline_item, @@ -114,10 +127,8 @@ def containerise(timeline_item, }) if data: - for k, v in data.items(): - data_imprint.update({k: v}) + data_imprint.update(data) - print("_ data_imprint: {}".format(data_imprint)) lib.set_timeline_item_pype_tag(timeline_item, data_imprint) return timeline_item @@ -206,15 +217,6 @@ def update_container(timeline_item, data=None): return bool(lib.set_timeline_item_pype_tag(timeline_item, container)) -def launch_workfiles_app(*args): - host_tools.show_workfiles() - - -def publish(parent): - """Shorthand to publish from within host""" - return host_tools.show_publish() - - @contextlib.contextmanager def maintained_selection(): """Maintain selection during context diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 59c27f29da..a00933405f 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -1,14 +1,15 @@ import re import uuid +import copy import qargparse from qtpy import QtWidgets, QtCore from openpype.settings import get_current_project_settings -from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline import ( LegacyCreator, LoaderPlugin, + Anatomy ) from . import lib @@ -18,7 +19,7 @@ from .menu import load_stylesheet class CreatorWidget(QtWidgets.QDialog): # output items - items = dict() + items = {} def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) @@ -100,7 +101,7 @@ class CreatorWidget(QtWidgets.QDialog): self.close() def value(self, data, new_data=None): - new_data = new_data or dict() + new_data = new_data or {} for k, v in data.items(): new_data[k] = { "target": None, @@ -289,29 +290,35 @@ class Spacer(QtWidgets.QWidget): class ClipLoader: active_bin = None - data = dict() + data = {} - def __init__(self, cls, context, path, **options): + def __init__(self, loader_obj, context, **options): """ Initialize object Arguments: - cls (openpype.pipeline.load.LoaderPlugin): plugin object + loader_obj (openpype.pipeline.load.LoaderPlugin): plugin object context (dict): loader plugin context options (dict)[optional]: possible keys: projectBinPath: "path/to/binItem" """ - self.__dict__.update(cls.__dict__) + self.__dict__.update(loader_obj.__dict__) self.context = context self.active_project = lib.get_current_project() - self.fname = path # try to get value from options or evaluate key value for `handles` - self.with_handles = options.get("handles") or bool( - options.get("handles") is True) + self.with_handles = options.get("handles") is True + # try to get value from options or evaluate key value for `load_to` - self.new_timeline = options.get("newTimeline") or bool( - "New timeline" in options.get("load_to", "")) + self.new_timeline = ( + options.get("newTimeline") or + options.get("load_to") == "New timeline" + ) + # try to get value from options or evaluate key value for `load_how` + self.sequential_load = ( + options.get("sequentially") or + options.get("load_how") == "Sequentially in order" + ) assert self._populate_data(), str( "Cannot Load selected data, look into database " @@ -319,54 +326,54 @@ class ClipLoader: # inject asset data to representation dict self._get_asset_data() - print("__init__ self.data: `{}`".format(self.data)) # add active components to class if self.new_timeline: - if options.get("timeline"): + loader_cls = loader_obj.__class__ + if loader_cls.timeline: # if multiselection is set then use options sequence - self.active_timeline = options["timeline"] + self.active_timeline = loader_cls.timeline else: # create new sequence - self.active_timeline = ( - lib.get_current_timeline() or - lib.get_new_timeline() + self.active_timeline = lib.get_new_timeline( + "{}_{}".format( + self.data["timeline_basename"], + str(uuid.uuid4())[:8] + ) ) + loader_cls.timeline = self.active_timeline + else: self.active_timeline = lib.get_current_timeline() - cls.timeline = self.active_timeline - def _populate_data(self): """ Gets context and convert it to self.data data structure: { "name": "assetName_subsetName_representationName" - "path": "path/to/file/created/by/get_repr..", "binPath": "projectBinPath", } """ # create name - repr = self.context["representation"] - repr_cntx = repr["context"] - asset = str(repr_cntx["asset"]) - subset = str(repr_cntx["subset"]) - representation = str(repr_cntx["representation"]) - self.data["clip_name"] = "_".join([asset, subset, representation]) + representation = self.context["representation"] + representation_context = representation["context"] + asset = str(representation_context["asset"]) + subset = str(representation_context["subset"]) + representation_name = str(representation_context["representation"]) + self.data["clip_name"] = "_".join([ + asset, + subset, + representation_name + ]) self.data["versionData"] = self.context["version"]["data"] - # gets file path - file = self.fname - if not file: - repr_id = repr["_id"] - print( - "Representation id `{}` is failing to load".format(repr_id)) - return None - self.data["path"] = file.replace("\\", "/") + + self.data["timeline_basename"] = "timeline_{}_{}".format( + subset, representation_name) # solve project bin structure path hierarchy = str("/".join(( "Loader", - repr_cntx["hierarchy"].replace("\\", "/"), + representation_context["hierarchy"].replace("\\", "/"), asset ))) @@ -380,67 +387,96 @@ class ClipLoader: joint `data` key with asset.data dict into the representation """ - asset_name = self.context["representation"]["context"]["asset"] - self.data["assetData"] = get_current_project_asset(asset_name)["data"] - def load(self): + self.data["assetData"] = copy.deepcopy(self.context["asset"]["data"]) + + def load(self, files): + """Load clip into timeline + + Arguments: + files (list[str]): list of files to load into timeline + """ # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) # create mediaItem in active project bin # create clip media - media_pool_item = lib.create_media_pool_item( - self.data["path"], self.active_bin) + files, + self.active_bin + ) _clip_property = media_pool_item.GetClipProperty - - # get handles - handle_start = self.data["versionData"].get("handleStart") - handle_end = self.data["versionData"].get("handleEnd") - if handle_start is None: - handle_start = int(self.data["assetData"]["handleStart"]) - if handle_end is None: - handle_end = int(self.data["assetData"]["handleEnd"]) - source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) + source_duration = int(_clip_property("Frames")) - if _clip_property("Type") == "Video": - source_in += handle_start - source_out -= handle_end - - # include handles - if self.with_handles: - source_in -= handle_start - source_out += handle_end + if not self.with_handles: + # Load file without the handles of the source media + # We remove the handles from the source in and source out + # so that the handles are excluded in the timeline handle_start = 0 handle_end = 0 + # get version data frame data from db + version_data = self.data["versionData"] + frame_start = version_data.get("frameStart") + frame_end = version_data.get("frameEnd") + + # The version data usually stored the frame range + handles of the + # media however certain representations may be shorter because they + # exclude those handles intentionally. Unfortunately the + # representation does not store that in the database currently; + # so we should compensate for those cases. If the media is shorter + # than the frame range specified in the database we assume it is + # without handles and thus we do not need to remove the handles + # from source and out + if frame_start is not None and frame_end is not None: + # Version has frame range data, so we can compare media length + handle_start = version_data.get("handleStart", 0) + handle_end = version_data.get("handleEnd", 0) + frame_start_handle = frame_start - handle_start + frame_end_handle = frame_start + handle_end + database_frame_duration = int( + frame_end_handle - frame_start_handle + 1 + ) + if source_duration >= database_frame_duration: + source_in += handle_start + source_out -= handle_end + + # get timeline in + timeline_start = self.active_timeline.GetStartFrame() + if self.sequential_load: + # set timeline start frame + timeline_in = int(timeline_start) + else: + # set timeline start frame + original clip in frame + timeline_in = int( + timeline_start + self.data["assetData"]["clipIn"]) + # make track item from source in bin as item timeline_item = lib.create_timeline_item( - media_pool_item, self.active_timeline, source_in, source_out) + media_pool_item, + self.active_timeline, + timeline_in, + source_in, + source_out, + ) print("Loading clips: `{}`".format(self.data["clip_name"])) return timeline_item - def update(self, timeline_item): + def update(self, timeline_item, files): # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) # create mediaItem in active project bin # create clip media media_pool_item = lib.create_media_pool_item( - self.data["path"], self.active_bin) + files, + self.active_bin + ) _clip_property = media_pool_item.GetClipProperty - # get handles - handle_start = self.data["versionData"].get("handleStart") - handle_end = self.data["versionData"].get("handleEnd") - if handle_start is None: - handle_start = int(self.data["assetData"]["handleStart"]) - if handle_end is None: - handle_end = int(self.data["assetData"]["handleEnd"]) - source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) @@ -465,7 +501,7 @@ class TimelineItemLoader(LoaderPlugin): """ options = [ - qargparse.Toggle( + qargparse.Boolean( "handles", label="Include handles", default=0, @@ -480,6 +516,16 @@ class TimelineItemLoader(LoaderPlugin): ], default=0, help="Where do you want clips to be loaded?" + ), + qargparse.Choice( + "load_how", + label="How to load clips", + items=[ + "Original timing", + "Sequentially in order" + ], + default="Original timing", + help="Would you like to place it at original timing?" ) ] @@ -540,8 +586,8 @@ class PublishClip: Returns: hiero.core.TrackItem: hiero track item object with openpype tag """ - vertical_clip_match = dict() - tag_data = dict() + vertical_clip_match = {} + tag_data = {} types = { "shot": "shot", "folder": "folder", @@ -617,15 +663,23 @@ class PublishClip: new_name = self.tag_data.pop("newClipName") if self.rename: - self.tag_data["asset"] = new_name + self.tag_data["asset_name"] = new_name else: - self.tag_data["asset"] = self.ti_name + self.tag_data["asset_name"] = self.ti_name + # AYON unique identifier + folder_path = "/{}/{}".format( + self.tag_data["hierarchy"], + self.tag_data["asset_name"] + ) + self.tag_data["folder_path"] = folder_path + + # create new name for track item if not lib.pype_marker_workflow: # create compound clip workflow lib.create_compound_clip( self.timeline_item_data, - self.tag_data["asset"], + self.tag_data["asset_name"], self.mp_folder ) @@ -659,8 +713,6 @@ class PublishClip: # define ui inputs if non gui mode was used self.shot_num = self.ti_index - print( - "____ self.shot_num: {}".format(self.shot_num)) # ui_inputs data or default values if gui was not used self.rename = self.ui_inputs.get( @@ -719,7 +771,7 @@ class PublishClip: # increasing steps by index of rename iteration self.count_steps *= self.rename_index - hierarchy_formatting_data = dict() + hierarchy_formatting_data = {} _data = self.timeline_item_default_data.copy() if self.ui_inputs: # adding tag metadata from ui @@ -808,14 +860,13 @@ class PublishClip: "parents": self.parents, "hierarchyData": hierarchy_formatting_data, "subset": self.subset, - "family": self.subset_family, - "families": ["clip"] + "family": self.subset_family } def _convert_to_entity(self, key): """ Converting input key to key with type. """ # convert to entity type - entity_type = self.types.get(key, None) + entity_type = self.types.get(key) assert entity_type, "Missing entity type for `{}`".format( key @@ -839,3 +890,12 @@ class PublishClip: for key in par_split: parent = self._convert_to_entity(key) self.parents.append(parent) + + +def get_representation_files(representation): + anatomy = Anatomy() + files = [] + for file_data in representation["files"]: + path = anatomy.fill_root(file_data["path"]) + files.append(path) + return files diff --git a/openpype/hosts/resolve/api/utils.py b/openpype/hosts/resolve/api/utils.py index 871b3af38d..851851a3b3 100644 --- a/openpype/hosts/resolve/api/utils.py +++ b/openpype/hosts/resolve/api/utils.py @@ -17,7 +17,7 @@ def get_resolve_module(): # dont run if already loaded if api.bmdvr: log.info(("resolve module is assigned to " - f"`pype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) + f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) return api.bmdvr try: """ @@ -41,6 +41,10 @@ def get_resolve_module(): ) elif sys.platform.startswith("linux"): expected_path = "/opt/resolve/libs/Fusion/Modules" + else: + raise NotImplementedError( + "Unsupported platform: {}".format(sys.platform) + ) # check if the default path has it... print(("Unable to find module DaVinciResolveScript from " @@ -74,6 +78,6 @@ def get_resolve_module(): api.bmdvr = bmdvr api.bmdvf = bmdvf log.info(("Assigning resolve module to " - f"`pype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) + f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) log.info(("Assigning resolve module to " - f"`pype.hosts.resolve.api.bmdvf`: {api.bmdvf}")) + f"`openpype.hosts.resolve.api.bmdvf`: {api.bmdvf}")) diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index 3a59ecea80..d3f83c7f24 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -1,13 +1,7 @@ -from copy import deepcopy - -from openpype.client import ( - get_version_by_id, - get_last_version_by_subset_id, -) -# from openpype.hosts import resolve +from openpype.client import get_last_version_by_subset_id from openpype.pipeline import ( - get_representation_path, - get_current_project_name, + get_representation_context, + get_current_project_name ) from openpype.hosts.resolve.api import lib, plugin from openpype.hosts.resolve.api.pipeline import ( @@ -48,48 +42,17 @@ class LoadClip(plugin.TimelineItemLoader): def load(self, context, name, namespace, options): - # in case loader uses multiselection - if self.timeline: - options.update({ - "timeline": self.timeline, - }) - # load clip to timeline and get main variables - path = self.filepath_from_context(context) + files = plugin.get_representation_files(context["representation"]) + timeline_item = plugin.ClipLoader( - self, context, path, **options).load() + self, context, **options).load(files) namespace = namespace or timeline_item.GetName() - version = context['version'] - version_data = version.get("data", {}) - version_name = version.get("name", None) - colorspace = version_data.get("colorspace", None) - object_name = "{}_{}".format(name, namespace) - - # add additional metadata from the version to imprint Avalon knob - add_keys = [ - "frameStart", "frameEnd", "source", "author", - "fps", "handleStart", "handleEnd" - ] - - # move all version data keys to tag data - data_imprint = {} - for key in add_keys: - data_imprint.update({ - key: version_data.get(key, str(None)) - }) - - # add variables related to version context - data_imprint.update({ - "version": version_name, - "colorspace": colorspace, - "objectName": object_name - }) # update color of clip regarding the version order - self.set_item_color(timeline_item, version) - - self.log.info("Loader done: `{}`".format(name)) + self.set_item_color(timeline_item, version=context["version"]) + data_imprint = self.get_tag_data(context, name, namespace) return containerise( timeline_item, name, namespace, context, @@ -103,53 +66,61 @@ class LoadClip(plugin.TimelineItemLoader): """ Updating previously loaded clips """ - # load clip to timeline and get main variables - context = deepcopy(representation["context"]) - context.update({"representation": representation}) + context = get_representation_context(representation) name = container['name'] namespace = container['namespace'] - timeline_item_data = lib.get_pype_timeline_item_by_name(namespace) - timeline_item = timeline_item_data["clip"]["item"] - project_name = get_current_project_name() - version = get_version_by_id(project_name, representation["parent"]) + timeline_item = container["_timeline_item"] + + media_pool_item = timeline_item.GetMediaPoolItem() + + files = plugin.get_representation_files(representation) + + loader = plugin.ClipLoader(self, context) + timeline_item = loader.update(timeline_item, files) + + # update color of clip regarding the version order + self.set_item_color(timeline_item, version=context["version"]) + + # if original media pool item has no remaining usages left + # remove it from the media pool + if int(media_pool_item.GetClipProperty("Usage")) == 0: + lib.remove_media_pool_item(media_pool_item) + + data_imprint = self.get_tag_data(context, name, namespace) + return update_container(timeline_item, data_imprint) + + def get_tag_data(self, context, name, namespace): + """Return data to be imprinted on the timeline item marker""" + + representation = context["representation"] + version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) - path = get_representation_path(representation) - context["version"] = {"data": version_data} - - loader = plugin.ClipLoader(self, context, path) - timeline_item = loader.update(timeline_item) # add additional metadata from the version to imprint Avalon knob - add_keys = [ + # move all version data keys to tag data + add_version_data_keys = [ "frameStart", "frameEnd", "source", "author", "fps", "handleStart", "handleEnd" ] - - # move all version data keys to tag data - data_imprint = {} - for key in add_keys: - data_imprint.update({ - key: version_data.get(key, str(None)) - }) + data = { + key: version_data.get(key, "None") for key in add_version_data_keys + } # add variables related to version context - data_imprint.update({ + data.update({ "representation": str(representation["_id"]), "version": version_name, "colorspace": colorspace, "objectName": object_name }) - - # update color of clip regarding the version order - self.set_item_color(timeline_item, version) - - return update_container(timeline_item, data_imprint) + return data @classmethod def set_item_color(cls, timeline_item, version): + """Color timeline item based on whether it is outdated or latest""" # define version name version_name = version.get("name", None) # get all versions in list @@ -169,3 +140,28 @@ class LoadClip(plugin.TimelineItemLoader): timeline_item.SetClipColor(cls.clip_color_last) else: timeline_item.SetClipColor(cls.clip_color) + + def remove(self, container): + timeline_item = container["_timeline_item"] + media_pool_item = timeline_item.GetMediaPoolItem() + timeline = lib.get_current_timeline() + + # DeleteClips function was added in Resolve 18.5+ + # by checking None we can detect whether the + # function exists in Resolve + if timeline.DeleteClips is not None: + timeline.DeleteClips([timeline_item]) + else: + # Resolve versions older than 18.5 can't delete clips via API + # so all we can do is just remove the pype marker to 'untag' it + if lib.get_pype_marker(timeline_item): + # Note: We must call `get_pype_marker` because + # `delete_pype_marker` uses a global variable set by + # `get_pype_marker` to delete the right marker + # TODO: Improve code to avoid the global `temp_marker_frame` + lib.delete_pype_marker(timeline_item) + + # if media pool item has no remaining usages left + # remove it from the media pool + if int(media_pool_item.GetClipProperty("Usage")) == 0: + lib.remove_media_pool_item(media_pool_item) diff --git a/openpype/hosts/resolve/plugins/publish/extract_workfile.py b/openpype/hosts/resolve/plugins/publish/extract_workfile.py index 535f879b58..db63487405 100644 --- a/openpype/hosts/resolve/plugins/publish/extract_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/extract_workfile.py @@ -26,6 +26,7 @@ class ExtractWorkfile(publish.Extractor): resolve_workfile_ext = ".drp" drp_file_name = name + resolve_workfile_ext + drp_file_path = os.path.normpath( os.path.join(staging_dir, drp_file_name)) diff --git a/openpype/hosts/resolve/plugins/publish/precollect_instances.py b/openpype/hosts/resolve/plugins/publish/precollect_instances.py index 8ec169ad65..bca6734848 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_instances.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_instances.py @@ -9,6 +9,7 @@ from openpype.hosts.resolve.api.lib import ( get_publish_attribute, get_otio_clip_instance_data, ) +from openpype import AYON_SERVER_ENABLED class PrecollectInstances(pyblish.api.ContextPlugin): @@ -29,7 +30,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): for timeline_item_data in selected_timeline_items: - data = dict() + data = {} timeline_item = timeline_item_data["clip"]["item"] # get pype tag data @@ -60,24 +61,24 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if k not in ("id", "applieswhole", "label") }) - asset = tag_data["asset"] + if AYON_SERVER_ENABLED: + asset = tag_data["folder_path"] + else: + asset = tag_data["asset_name"] + subset = tag_data["subset"] - # insert family into families - family = tag_data["family"] - families = [str(f) for f in tag_data["families"]] - families.insert(0, str(family)) - data.update({ - "name": "{} {} {}".format(asset, subset, families), + "name": "{}_{}".format(asset, subset), + "label": "{} {}".format(asset, subset), "asset": asset, "item": timeline_item, - "families": families, "publish": get_publish_attribute(timeline_item), "fps": context.data["fps"], "handleStart": handle_start, "handleEnd": handle_end, - "newAssetPublishing": True + "newAssetPublishing": True, + "families": ["clip"], }) # otio clip data @@ -135,7 +136,8 @@ class PrecollectInstances(pyblish.api.ContextPlugin): family = "shot" data.update({ - "name": "{} {} {}".format(asset, subset, family), + "name": "{}_{}".format(asset, subset), + "label": "{} {}".format(asset, subset), "subset": subset, "asset": asset, "family": family, diff --git a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py index a2f3eaed7a..ccc5fd86ff 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py @@ -1,7 +1,9 @@ import pyblish.api from pprint import pformat +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import get_current_asset_name + from openpype.hosts.resolve import api as rapi from openpype.hosts.resolve.otio import davinci_export @@ -13,9 +15,12 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): + current_asset_name = asset_name = get_current_asset_name() - asset = get_current_asset_name() - subset = "workfile" + if AYON_SERVER_ENABLED: + asset_name = current_asset_name.split("/")[-1] + + subset = "workfileMain" project = rapi.get_current_project() fps = project.GetSetting("timelineFrameRate") video_tracks = rapi.get_video_track_names() @@ -24,9 +29,10 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): otio_timeline = davinci_export.create_otio_timeline(project) instance_data = { - "name": "{}_{}".format(asset, subset), - "asset": asset, - "subset": "{}{}".format(asset, subset.capitalize()), + "name": "{}_{}".format(asset_name, subset), + "label": "{} {}".format(current_asset_name, subset), + "asset": current_asset_name, + "subset": subset, "item": project, "family": "workfile", "families": [] diff --git a/openpype/hosts/resolve/startup.py b/openpype/hosts/resolve/startup.py index e807a48f5a..5ac3c99524 100644 --- a/openpype/hosts/resolve/startup.py +++ b/openpype/hosts/resolve/startup.py @@ -27,7 +27,8 @@ def ensure_installed_host(): if host: return host - install_host(openpype.hosts.resolve.api) + host = openpype.hosts.resolve.api.ResolveHost() + install_host(host) return registered_host() @@ -37,10 +38,10 @@ def launch_menu(): openpype.hosts.resolve.api.launch_pype_menu() -def open_file(path): +def open_workfile(path): # Avoid the need to "install" the host host = ensure_installed_host() - host.open_file(path) + host.open_workfile(path) def main(): @@ -49,7 +50,7 @@ def main(): if workfile_path and os.path.exists(workfile_path): log.info(f"Opening last workfile: {workfile_path}") - open_file(workfile_path) + open_workfile(workfile_path) else: log.info("No last workfile set to open. Skipping..") diff --git a/openpype/hosts/resolve/utility_scripts/AYON__Menu.py b/openpype/hosts/resolve/utility_scripts/AYON__Menu.py new file mode 100644 index 0000000000..4f14927074 --- /dev/null +++ b/openpype/hosts/resolve/utility_scripts/AYON__Menu.py @@ -0,0 +1,22 @@ +import os +import sys + +from openpype.pipeline import install_host +from openpype.lib import Logger + +log = Logger.get_logger(__name__) + + +def main(env): + from openpype.hosts.resolve.api import ResolveHost, launch_pype_menu + + # activate resolve from openpype + host = ResolveHost() + install_host(host) + + launch_pype_menu() + + +if __name__ == "__main__": + result = main(os.environ) + sys.exit(not bool(result)) diff --git a/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py b/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py index 1087a7b7a0..4f14927074 100644 --- a/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py +++ b/openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py @@ -8,12 +8,13 @@ log = Logger.get_logger(__name__) def main(env): - import openpype.hosts.resolve.api as bmdvr + from openpype.hosts.resolve.api import ResolveHost, launch_pype_menu # activate resolve from openpype - install_host(bmdvr) + host = ResolveHost() + install_host(host) - bmdvr.launch_pype_menu() + launch_pype_menu() if __name__ == "__main__": diff --git a/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py b/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py deleted file mode 100644 index 92f2e43a72..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py +++ /dev/null @@ -1,49 +0,0 @@ -#! python3 -import os -import sys - -import opentimelineio as otio - -from openpype.pipeline import install_host - -import openpype.hosts.resolve.api as bmdvr -from openpype.hosts.resolve.api.testing_utils import TestGUI -from openpype.hosts.resolve.otio import davinci_export as otio_export - - -class ThisTestGUI(TestGUI): - extensions = [".exr", ".jpg", ".mov", ".png", ".mp4", ".ari", ".arx"] - - def __init__(self): - super(ThisTestGUI, self).__init__() - # activate resolve from openpype - install_host(bmdvr) - - def _open_dir_button_pressed(self, event): - # selected_path = self.fu.RequestFile(os.path.expanduser("~")) - selected_path = self.fu.RequestDir(os.path.expanduser("~")) - self._widgets["inputTestSourcesFolder"].Text = selected_path - - # main function - def process(self, event): - self.input_dir_path = self._widgets["inputTestSourcesFolder"].Text - project = bmdvr.get_current_project() - otio_timeline = otio_export.create_otio_timeline(project) - print(f"_ otio_timeline: `{otio_timeline}`") - edl_path = os.path.join(self.input_dir_path, "this_file_name.edl") - print(f"_ edl_path: `{edl_path}`") - # xml_string = otio_adapters.fcpx_xml.write_to_string(otio_timeline) - # print(f"_ xml_string: `{xml_string}`") - otio.adapters.write_to_file( - otio_timeline, edl_path, adapter_name="cmx_3600") - project = bmdvr.get_current_project() - media_pool = project.GetMediaPool() - timeline = media_pool.ImportTimelineFromFile(edl_path) - # at the end close the window - self._close_window(None) - - -if __name__ == "__main__": - test_gui = ThisTestGUI() - test_gui.show_gui() - sys.exit(not bool(True)) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py b/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py deleted file mode 100644 index 91a361ec08..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py +++ /dev/null @@ -1,73 +0,0 @@ -#! python3 -import os -import sys - -import clique - -from openpype.pipeline import install_host -from openpype.hosts.resolve.api.testing_utils import TestGUI -import openpype.hosts.resolve.api as bmdvr -from openpype.hosts.resolve.api.lib import ( - create_media_pool_item, - create_timeline_item, -) - - -class ThisTestGUI(TestGUI): - extensions = [".exr", ".jpg", ".mov", ".png", ".mp4", ".ari", ".arx"] - - def __init__(self): - super(ThisTestGUI, self).__init__() - # activate resolve from openpype - install_host(bmdvr) - - def _open_dir_button_pressed(self, event): - # selected_path = self.fu.RequestFile(os.path.expanduser("~")) - selected_path = self.fu.RequestDir(os.path.expanduser("~")) - self._widgets["inputTestSourcesFolder"].Text = selected_path - - # main function - def process(self, event): - self.input_dir_path = self._widgets["inputTestSourcesFolder"].Text - - self.dir_processing(self.input_dir_path) - - # at the end close the window - self._close_window(None) - - def dir_processing(self, dir_path): - collections, reminders = clique.assemble(os.listdir(dir_path)) - - # process reminders - for _rem in reminders: - _rem_path = os.path.join(dir_path, _rem) - - # go deeper if directory - if os.path.isdir(_rem_path): - print(_rem_path) - self.dir_processing(_rem_path) - else: - self.file_processing(_rem_path) - - # process collections - for _coll in collections: - _coll_path = os.path.join(dir_path, list(_coll).pop()) - self.file_processing(_coll_path) - - def file_processing(self, fpath): - print(f"_ fpath: `{fpath}`") - _base, ext = os.path.splitext(fpath) - # skip if unwanted extension - if ext not in self.extensions: - return - media_pool_item = create_media_pool_item(fpath) - print(media_pool_item) - - track_item = create_timeline_item(media_pool_item) - print(track_item) - - -if __name__ == "__main__": - test_gui = ThisTestGUI() - test_gui.show_gui() - sys.exit(not bool(True)) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py b/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py deleted file mode 100644 index 2e83188bde..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py +++ /dev/null @@ -1,24 +0,0 @@ -#! python3 -from openpype.pipeline import install_host -from openpype.hosts.resolve import api as bmdvr -from openpype.hosts.resolve.api.lib import ( - create_media_pool_item, - create_timeline_item, -) - - -def file_processing(fpath): - media_pool_item = create_media_pool_item(fpath) - print(media_pool_item) - - track_item = create_timeline_item(media_pool_item) - print(track_item) - - -if __name__ == "__main__": - path = "C:/CODE/__openpype_projects/jtest03dev/shots/sq01/mainsq01sh030/publish/plate/plateMain/v006/jt3d_mainsq01sh030_plateMain_v006.0996.exr" - - # activate resolve from openpype - install_host(bmdvr) - - file_processing(path) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py b/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py deleted file mode 100644 index b64714ab16..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_startup_script.py +++ /dev/null @@ -1,5 +0,0 @@ -#! python3 -from openpype.hosts.resolve.startup import main - -if __name__ == "__main__": - main() diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py b/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py deleted file mode 100644 index 8270496f64..0000000000 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_timeline_op.py +++ /dev/null @@ -1,13 +0,0 @@ -#! python3 -from openpype.pipeline import install_host -from openpype.hosts.resolve import api as bmdvr -from openpype.hosts.resolve.api.lib import get_current_project - -if __name__ == "__main__": - install_host(bmdvr) - project = get_current_project() - timeline_count = project.GetTimelineCount() - print(f"Timeline count: {timeline_count}") - timeline = project.GetTimelineByIndex(timeline_count) - print(f"Timeline name: {timeline.GetName()}") - print(timeline.GetTrackCount("video")) diff --git a/openpype/hosts/resolve/utils.py b/openpype/hosts/resolve/utils.py index 5e3003862f..9b91a14267 100644 --- a/openpype/hosts/resolve/utils.py +++ b/openpype/hosts/resolve/utils.py @@ -2,6 +2,7 @@ import os import shutil from openpype.lib import Logger, is_running_from_build +from openpype import AYON_SERVER_ENABLED RESOLVE_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -54,6 +55,14 @@ def setup(env): src = os.path.join(directory, script) dst = os.path.join(util_scripts_dir, script) + # TODO: remove this once we have a proper solution + if AYON_SERVER_ENABLED: + if "OpenPype__Menu.py" == script: + continue + else: + if "AYON__Menu.py" == script: + continue + # TODO: Make this a less hacky workaround if script == "openpype_startup.scriptlib": # Handle special case for scriptlib that needs to be a folder diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py index 48c36aa067..c435ca2096 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py @@ -60,6 +60,9 @@ class CollectHarmonyScenes(pyblish.api.InstancePlugin): # updating hierarchy data anatomy_data_new.update({ "asset": asset_data["name"], + "folder": { + "name": asset_data["name"], + }, "task": { "name": task, "type": task_type, diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py index 40a969f8df..d90215e767 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py @@ -56,6 +56,9 @@ class CollectHarmonyZips(pyblish.api.InstancePlugin): anatomy_data_new.update( { "asset": asset_data["name"], + "folder": { + "name": asset_data["name"], + }, "task": { "name": task, "type": task_type, diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index b99503b3c8..a2afd160fa 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -49,8 +49,6 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): else: first_filename = files - staging_dir = None - # Convert to jpeg if not yet full_input_path = os.path.join( thumbnail_repre["stagingDir"], first_filename diff --git a/openpype/hosts/substancepainter/api/pipeline.py b/openpype/hosts/substancepainter/api/pipeline.py index e96064b2bf..a13075127f 100644 --- a/openpype/hosts/substancepainter/api/pipeline.py +++ b/openpype/hosts/substancepainter/api/pipeline.py @@ -170,7 +170,8 @@ class SubstanceHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): parent = substance_painter.ui.get_main_window() - menu = QtWidgets.QMenu("OpenPype") + tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" + menu = QtWidgets.QMenu(tab_menu_label) action = menu.addAction("Create...") action.triggered.connect( diff --git a/openpype/hosts/substancepainter/plugins/create/create_workfile.py b/openpype/hosts/substancepainter/plugins/create/create_workfile.py index d7f31f9dcf..c73277e405 100644 --- a/openpype/hosts/substancepainter/plugins/create/create_workfile.py +++ b/openpype/hosts/substancepainter/plugins/create/create_workfile.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import CreatedInstance, AutoCreator from openpype.client import get_asset_by_name @@ -41,6 +42,13 @@ class CreateWorkfile(AutoCreator): if instance.creator_identifier == self.identifier ), None) + if current_instance is None: + current_instance_asset = None + elif AYON_SERVER_ENABLED: + current_instance_asset = current_instance["folderPath"] + else: + current_instance_asset = current_instance["asset"] + if current_instance is None: self.log.info("Auto-creating workfile instance...") asset_doc = get_asset_by_name(project_name, asset_name) @@ -48,22 +56,28 @@ class CreateWorkfile(AutoCreator): variant, task_name, asset_doc, project_name, host_name ) data = { - "asset": asset_name, "task": task_name, "variant": variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name current_instance = self.create_instance_in_context(subset_name, data) elif ( - current_instance["asset"] != asset_name - or current_instance["task"] != task_name + current_instance_asset != asset_name + or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) - current_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + current_instance["folderPath"] = asset_name + else: + current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name diff --git a/openpype/hosts/traypublisher/api/editorial.py b/openpype/hosts/traypublisher/api/editorial.py index e8f76bd314..613f1de768 100644 --- a/openpype/hosts/traypublisher/api/editorial.py +++ b/openpype/hosts/traypublisher/api/editorial.py @@ -53,11 +53,11 @@ class ShotMetadataSolver: try: # format to new shot name return shot_rename_template.format(**data) - except KeyError as _E: + except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct:: \n\n" f"From template string {shot_rename_template} > " - f"`{_E}` has no equivalent in \n" + f"`{_error}` has no equivalent in \n" f"{list(data.keys())} input formatting keys!" )) @@ -100,7 +100,7 @@ class ShotMetadataSolver: "at your project settings..." )) - # QUESTION:how to refactory `match[-1]` to some better way? + # QUESTION:how to refactor `match[-1]` to some better way? output_data[token_key] = match[-1] return output_data @@ -130,10 +130,10 @@ class ShotMetadataSolver: parent_token["name"]: parent_token["value"].format(**data) for parent_token in hierarchy_parents } - except KeyError as _E: + except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct : \n" - f"`{_E}` has no equivalent in \n{list(data.keys())}" + f"`{_error}` has no equivalent in \n{list(data.keys())}" )) _parent_tokens_type = { @@ -147,10 +147,10 @@ class ShotMetadataSolver: try: parent_name = _parent.format( **_parent_tokens_formatting_data) - except KeyError as _E: + except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct : \n\n" - f"`{_E}` from template string " + f"`{_error}` from template string " f"{shot_hierarchy['parents_path']}, " f" has no equivalent in \n" f"{list(_parent_tokens_formatting_data.keys())} parents" @@ -319,8 +319,16 @@ class ShotMetadataSolver: tasks = self._generate_tasks_from_settings( project_doc) + # generate hierarchy path from parents + hierarchy_path = self._create_hierarchy_path(parents) + if hierarchy_path: + folder_path = f"/{hierarchy_path}/{shot_name}" + else: + folder_path = f"/{shot_name}" + return shot_name, { - "hierarchy": self._create_hierarchy_path(parents), + "hierarchy": hierarchy_path, + "folderPath": folder_path, "parents": parents, "tasks": tasks } diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 36e041a32c..14c66fa08f 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -1,7 +1,9 @@ +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_assets, get_subsets, get_last_versions, + get_asset_name_identifier, ) from openpype.lib.attribute_definitions import ( FileDef, @@ -114,7 +116,10 @@ class SettingsCreator(TrayPublishCreator): # Fill 'version_to_use' if version control is enabled if self.allow_version_control: - asset_name = data["asset"] + if AYON_SERVER_ENABLED: + asset_name = data["folderPath"] + else: + asset_name = data["asset"] subset_docs_by_asset_id = self._prepare_next_versions( [asset_name], [subset_name]) version = subset_docs_by_asset_id[asset_name].get(subset_name) @@ -162,10 +167,10 @@ class SettingsCreator(TrayPublishCreator): asset_docs = get_assets( self.project_name, asset_names=asset_names, - fields=["_id", "name"] + fields=["_id", "name", "data.parents"] ) asset_names_by_id = { - asset_doc["_id"]: asset_doc["name"] + asset_doc["_id"]: get_asset_name_identifier(asset_doc) for asset_doc in asset_docs } subset_docs = list(get_subsets( diff --git a/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py new file mode 100644 index 0000000000..ac4c72a0ce --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +"""Creator of colorspace look files. + +This creator is used to publish colorspace look files thanks to +production type `ociolook`. All files are published as representation. +""" +from pathlib import Path + +from openpype import AYON_SERVER_ENABLED +from openpype.client import get_asset_by_name +from openpype.lib.attribute_definitions import ( + FileDef, EnumDef, TextDef, UISeparatorDef +) +from openpype.pipeline import ( + CreatedInstance, + CreatorError +) +from openpype.pipeline import colorspace +from openpype.hosts.traypublisher.api.plugin import TrayPublishCreator + + +class CreateColorspaceLook(TrayPublishCreator): + """Creates colorspace look files.""" + + identifier = "io.openpype.creators.traypublisher.colorspace_look" + label = "Colorspace Look" + family = "ociolook" + description = "Publishes color space look file." + extensions = [".cc", ".cube", ".3dl", ".spi1d", ".spi3d", ".csp", ".lut"] + enabled = False + + colorspace_items = [ + (None, "Not set") + ] + colorspace_attr_show = False + config_items = None + config_data = None + + def get_detail_description(self): + return """# Colorspace Look + +This creator publishes color space look file (LUT). + """ + + def get_icon(self): + return "mdi.format-color-fill" + + def create(self, subset_name, instance_data, pre_create_data): + repr_file = pre_create_data.get("luts_file") + if not repr_file: + raise CreatorError("No files specified") + + files = repr_file.get("filenames") + if not files: + # this should never happen + raise CreatorError("Missing files from representation") + + if AYON_SERVER_ENABLED: + asset_name = instance_data["folderPath"] + else: + asset_name = instance_data["asset"] + asset_doc = get_asset_by_name( + self.project_name, asset_name) + + subset_name = self.get_subset_name( + variant=instance_data["variant"], + task_name=instance_data["task"] or "Not set", + project_name=self.project_name, + asset_doc=asset_doc, + ) + + instance_data["creator_attributes"] = { + "abs_lut_path": ( + Path(repr_file["directory"]) / files[0]).as_posix() + } + + # Create new instance + new_instance = CreatedInstance(self.family, subset_name, + instance_data, self) + new_instance.transient_data["config_items"] = self.config_items + new_instance.transient_data["config_data"] = self.config_data + + self._store_new_instance(new_instance) + + def collect_instances(self): + super().collect_instances() + for instance in self.create_context.instances: + if instance.creator_identifier == self.identifier: + instance.transient_data["config_items"] = self.config_items + instance.transient_data["config_data"] = self.config_data + + def get_instance_attr_defs(self): + return [ + EnumDef( + "working_colorspace", + self.colorspace_items, + default="Not set", + label="Working Colorspace", + ), + UISeparatorDef( + label="Advanced1" + ), + TextDef( + "abs_lut_path", + label="LUT Path", + ), + EnumDef( + "input_colorspace", + self.colorspace_items, + default="Not set", + label="Input Colorspace", + ), + EnumDef( + "direction", + [ + (None, "Not set"), + ("forward", "Forward"), + ("inverse", "Inverse") + ], + default="Not set", + label="Direction" + ), + EnumDef( + "interpolation", + [ + (None, "Not set"), + ("linear", "Linear"), + ("tetrahedral", "Tetrahedral"), + ("best", "Best"), + ("nearest", "Nearest") + ], + default="Not set", + label="Interpolation" + ), + EnumDef( + "output_colorspace", + self.colorspace_items, + default="Not set", + label="Output Colorspace", + ), + ] + + def get_pre_create_attr_defs(self): + return [ + FileDef( + "luts_file", + folders=False, + extensions=self.extensions, + allow_sequences=False, + single_item=True, + label="Look Files", + ) + ] + + def apply_settings(self, project_settings, system_settings): + host = self.create_context.host + host_name = host.name + project_name = host.get_current_project_name() + config_data = colorspace.get_imageio_config( + project_name, host_name, + project_settings=project_settings + ) + + if not config_data: + self.enabled = False + return + + filepath = config_data["path"] + config_items = colorspace.get_ocio_config_colorspaces(filepath) + labeled_colorspaces = colorspace.get_colorspaces_enumerator_items( + config_items, + include_aliases=True, + include_roles=True + ) + self.config_items = config_items + self.config_data = config_data + self.colorspace_items.extend(labeled_colorspaces) + self.enabled = True diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 8640500b18..26cce35d55 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -1,6 +1,7 @@ import os from copy import deepcopy import opentimelineio as otio +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_asset_by_name, get_project @@ -101,14 +102,23 @@ class EditorialShotInstanceCreator(EditorialClipInstanceCreatorBase): label = "Editorial Shot" def get_instance_attr_defs(self): - attr_defs = [ - TextDef( - "asset_name", - label="Asset name", + instance_attributes = [] + if AYON_SERVER_ENABLED: + instance_attributes.append( + TextDef( + "folderPath", + label="Folder path" + ) ) - ] - attr_defs.extend(CLIP_ATTR_DEFS) - return attr_defs + else: + instance_attributes.append( + TextDef( + "shotName", + label="Shot name" + ) + ) + instance_attributes.extend(CLIP_ATTR_DEFS) + return instance_attributes class EditorialPlateInstanceCreator(EditorialClipInstanceCreatorBase): @@ -214,8 +224,11 @@ or updating already created. Publishing will create OTIO file. i["family"] for i in self._creator_settings["family_presets"] ] } - # Create otio editorial instance - asset_name = instance_data["asset"] + if AYON_SERVER_ENABLED: + asset_name = instance_data["folderPath"] + else: + asset_name = instance_data["asset"] + asset_doc = get_asset_by_name(self.project_name, asset_name) if pre_create_data["fps"] == "from_selection": @@ -595,19 +608,23 @@ or updating already created. Publishing will create OTIO file. Returns: str: label string """ - shot_name = instance_data["shotName"] + if AYON_SERVER_ENABLED: + asset_name = instance_data["creator_attributes"]["folderPath"] + else: + asset_name = instance_data["creator_attributes"]["shotName"] + variant_name = instance_data["variant"] family = preset["family"] - # get variant name from preset or from inharitance + # get variant name from preset or from inheritance _variant_name = preset.get("variant") or variant_name # subset name subset_name = "{}{}".format( family, _variant_name.capitalize() ) - label = "{}_{}".format( - shot_name, + label = "{} {}".format( + asset_name, subset_name ) @@ -666,7 +683,10 @@ or updating already created. Publishing will create OTIO file. } ) - self._validate_name_uniqueness(shot_name) + # It should be validated only in openpype since we are supporting + # publishing to AYON with folder path and uniqueness is not an issue + if not AYON_SERVER_ENABLED: + self._validate_name_uniqueness(shot_name) timing_data = self._get_timing_data( otio_clip, @@ -677,33 +697,43 @@ or updating already created. Publishing will create OTIO file. # create creator attributes creator_attributes = { - "asset_name": shot_name, - "Parent hierarchy path": shot_metadata["hierarchy"], + "workfile_start_frame": workfile_start_frame, "fps": fps, "handle_start": int(handle_start), "handle_end": int(handle_end) } + # add timing data creator_attributes.update(timing_data) - # create shared new instance data + # create base instance data base_instance_data = { "shotName": shot_name, "variant": variant_name, - - # HACK: just for temporal bug workaround - # TODO: should loockup shot name for update - "asset": parent_asset_name, "task": "", - "newAssetPublishing": True, - - # parent time properties "trackStartFrame": track_start_frame, "timelineOffset": timeline_offset, + # creator_attributes "creator_attributes": creator_attributes } + # update base instance data with context data + # and also update creator attributes with context data + if AYON_SERVER_ENABLED: + # TODO: this is here just to be able to publish + # to AYON with folder path + creator_attributes["folderPath"] = shot_metadata.pop("folderPath") + base_instance_data["folderPath"] = parent_asset_name + else: + creator_attributes.update({ + "shotName": shot_name, + "Parent hierarchy path": shot_metadata["hierarchy"] + }) + + base_instance_data["asset"] = parent_asset_name + # add creator attributes to shared instance data + base_instance_data["creator_attributes"] = creator_attributes # add hierarchy shot metadata base_instance_data.update(shot_metadata) diff --git a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py index 1bed07f785..8fa65c7fff 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py +++ b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py @@ -2,6 +2,8 @@ import copy import os import re +from openpype import AYON_SERVER_ENABLED +from openpype.client import get_asset_name_identifier from openpype.lib import ( FileDef, BoolDef, @@ -36,7 +38,7 @@ class BatchMovieCreator(TrayPublishCreator): # Position batch creator after simple creators order = 110 - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): creator_settings = ( project_settings["traypublisher"]["create"]["BatchMovieCreator"] ) @@ -64,8 +66,13 @@ class BatchMovieCreator(TrayPublishCreator): subset_name, task_name = self._get_subset_and_task( asset_doc, data["variant"], self.project_name) + asset_name = get_asset_name_identifier(asset_doc) + instance_data["task"] = task_name - instance_data["asset"] = asset_doc["name"] + if AYON_SERVER_ENABLED: + instance_data["folderPath"] = asset_name + else: + instance_data["asset"] = asset_name # Create new instance new_instance = CreatedInstance(self.family, subset_name, diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py new file mode 100644 index 0000000000..6aede099bf --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py @@ -0,0 +1,86 @@ +import os +from pprint import pformat +import pyblish.api +from openpype.pipeline import publish +from openpype.pipeline import colorspace + + +class CollectColorspaceLook(pyblish.api.InstancePlugin, + publish.OpenPypePyblishPluginMixin): + """Collect OCIO colorspace look from LUT file + """ + + label = "Collect Colorspace Look" + order = pyblish.api.CollectorOrder + hosts = ["traypublisher"] + families = ["ociolook"] + + def process(self, instance): + creator_attrs = instance.data["creator_attributes"] + + lut_repre_name = "LUTfile" + file_url = creator_attrs["abs_lut_path"] + file_name = os.path.basename(file_url) + base_name, ext = os.path.splitext(file_name) + + # set output name with base_name which was cleared + # of all symbols and all parts were capitalized + output_name = (base_name.replace("_", " ") + .replace(".", " ") + .replace("-", " ") + .title() + .replace(" ", "")) + + # get config items + config_items = instance.data["transientData"]["config_items"] + config_data = instance.data["transientData"]["config_data"] + + # get colorspace items + converted_color_data = {} + for colorspace_key in [ + "working_colorspace", + "input_colorspace", + "output_colorspace" + ]: + if creator_attrs[colorspace_key]: + color_data = colorspace.convert_colorspace_enumerator_item( + creator_attrs[colorspace_key], config_items) + converted_color_data[colorspace_key] = color_data + else: + converted_color_data[colorspace_key] = None + + # add colorspace to config data + if converted_color_data["working_colorspace"]: + config_data["colorspace"] = ( + converted_color_data["working_colorspace"]["name"] + ) + + # create lut representation data + lut_repre = { + "name": lut_repre_name, + "output": output_name, + "ext": ext.lstrip("."), + "files": file_name, + "stagingDir": os.path.dirname(file_url), + "tags": [] + } + instance.data.update({ + "representations": [lut_repre], + "source": file_url, + "ocioLookWorkingSpace": converted_color_data["working_colorspace"], + "ocioLookItems": [ + { + "name": lut_repre_name, + "ext": ext.lstrip("."), + "input_colorspace": converted_color_data[ + "input_colorspace"], + "output_colorspace": converted_color_data[ + "output_colorspace"], + "direction": creator_attrs["direction"], + "interpolation": creator_attrs["interpolation"], + "config_data": config_data + } + ], + }) + + self.log.debug(pformat(instance.data)) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py index eb7fbd87a0..5db2b0cbad 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py @@ -1,6 +1,8 @@ import pyblish.api -from openpype.pipeline import registered_host -from openpype.pipeline import publish +from openpype.pipeline import ( + publish, + registered_host +) from openpype.lib import EnumDef from openpype.pipeline import colorspace @@ -13,11 +15,14 @@ class CollectColorspace(pyblish.api.InstancePlugin, label = "Choose representation colorspace" order = pyblish.api.CollectorOrder + 0.49 hosts = ["traypublisher"] + families = ["render", "plate", "reference", "image", "online"] + enabled = False colorspace_items = [ (None, "Don't override") ] colorspace_attr_show = False + config_items = None def process(self, instance): values = self.get_attr_values_from_data(instance.data) @@ -48,10 +53,14 @@ class CollectColorspace(pyblish.api.InstancePlugin, if config_data: filepath = config_data["path"] config_items = colorspace.get_ocio_config_colorspaces(filepath) - cls.colorspace_items.extend(( - (name, name) for name in config_items.keys() - )) - cls.colorspace_attr_show = True + labeled_colorspaces = colorspace.get_colorspaces_enumerator_items( + config_items, + include_aliases=True, + include_roles=True + ) + cls.config_items = config_items + cls.colorspace_items.extend(labeled_colorspaces) + cls.enabled = True @classmethod def get_attribute_defs(cls): @@ -60,7 +69,6 @@ class CollectColorspace(pyblish.api.InstancePlugin, "colorspace", cls.colorspace_items, default="Don't override", - label="Override Colorspace", - hidden=not cls.colorspace_attr_show + label="Override Colorspace" ) ] diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py similarity index 77% rename from openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py rename to openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py index c18e10e438..e8a2cae16c 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py @@ -1,26 +1,21 @@ import pyblish.api -from openpype.pipeline import OptionalPyblishPluginMixin -class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Collect Frame Range data From Asset Entity +class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin): + """Collect Frame Data From AssetEntity found in context Frame range data will only be collected if the keys are not yet collected for the instance. """ order = pyblish.api.CollectorOrder + 0.491 - label = "Collect Frame Data From Asset Entity" + label = "Collect Missing Frame Data From Asset" families = ["plate", "pointcache", "vdbcache", "online", "render"] hosts = ["traypublisher"] - optional = True def process(self, instance): - if not self.is_active(instance.data): - return missing_keys = [] for key in ( "fps", diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py similarity index 60% rename from openpype/plugins/publish/collect_sequence_frame_data.py rename to openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py index c200b245e9..5e60a94927 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py @@ -1,33 +1,53 @@ import pyblish.api import clique +from openpype.pipeline import OptionalPyblishPluginMixin + + +class CollectSequenceFrameData( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): + """Collect Original Sequence Frame Data -class CollectSequenceFrameData(pyblish.api.InstancePlugin): - """Collect Sequence Frame Data If the representation includes files with frame numbers, then set `frameStart` and `frameEnd` for the instance to the start and end frame respectively """ - order = pyblish.api.CollectorOrder + 0.2 - label = "Collect Sequence Frame Data" + order = pyblish.api.CollectorOrder + 0.4905 + label = "Collect Original Sequence Frame Data" families = ["plate", "pointcache", "vdbcache", "online", "render"] hosts = ["traypublisher"] + optional = True def process(self, instance): + if not self.is_active(instance.data): + return + + # editorial would fail since they might not be in database yet + new_asset_publishing = instance.data.get("newAssetPublishing") + if new_asset_publishing: + self.log.debug("Instance is creating new asset. Skipping.") + return + frame_data = self.get_frame_data_from_repre_sequence(instance) + if not frame_data: # if no dict data skip collecting the frame range data return + for key, value in frame_data.items(): - if key not in instance.data: - instance.data[key] = value - self.log.debug(f"Collected Frame range data '{key}':{value} ") + instance.data[key] = value + self.log.debug(f"Collected Frame range data '{key}':{value} ") + def get_frame_data_from_repre_sequence(self, instance): repres = instance.data.get("representations") + asset_data = instance.data["assetEntity"]["data"] + if repres: first_repre = repres[0] if "ext" not in first_repre: @@ -36,7 +56,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): return files = first_repre["files"] - collections, remainder = clique.assemble(files) + collections, _ = clique.assemble(files) if not collections: # No sequences detected and we can't retrieve # frame range @@ -50,4 +70,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): return { "frameStart": repres_frames[0], "frameEnd": repres_frames[-1], + "handleStart": 0, + "handleEnd": 0, + "fps": asset_data["fps"] } diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py index 78c1f14e4e..e00ac64244 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py @@ -2,6 +2,8 @@ from pprint import pformat import pyblish.api import opentimelineio as otio +from openpype import AYON_SERVER_ENABLED + class CollectShotInstance(pyblish.api.InstancePlugin): """ Collect shot instances @@ -119,8 +121,7 @@ class CollectShotInstance(pyblish.api.InstancePlugin): frame_end = _cr_attrs["frameEnd"] frame_dur = frame_end - frame_start - return { - "asset": _cr_attrs["asset_name"], + data = { "fps": float(_cr_attrs["fps"]), "handleStart": _cr_attrs["handle_start"], "handleEnd": _cr_attrs["handle_end"], @@ -133,6 +134,12 @@ class CollectShotInstance(pyblish.api.InstancePlugin): "sourceOut": _cr_attrs["sourceOut"], "workfileFrameStart": workfile_start_frame } + if AYON_SERVER_ENABLED: + data["asset"] = _cr_attrs["folderPath"] + else: + data["asset"] = _cr_attrs["shotName"] + + return data def _solve_hierarchy_context(self, instance): """ Adding hierarchy data to context shared data. @@ -148,7 +155,7 @@ class CollectShotInstance(pyblish.api.InstancePlugin): else {} ) - name = instance.data["asset"] + asset_name = instance.data["asset"] # get handles handle_start = int(instance.data["handleStart"]) @@ -170,7 +177,7 @@ class CollectShotInstance(pyblish.api.InstancePlugin): parents = instance.data.get('parents', []) - actual = {name: in_info} + actual = {asset_name: in_info} for parent in reversed(parents): parent_name = parent["entity_name"] diff --git a/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py new file mode 100644 index 0000000000..f94bbc7a49 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py @@ -0,0 +1,45 @@ +import os +import json +import pyblish.api +from openpype.pipeline import publish + + +class ExtractColorspaceLook(publish.Extractor, + publish.OpenPypePyblishPluginMixin): + """Extract OCIO colorspace look from LUT file + """ + + label = "Extract Colorspace Look" + order = pyblish.api.ExtractorOrder + hosts = ["traypublisher"] + families = ["ociolook"] + + def process(self, instance): + ociolook_items = instance.data["ocioLookItems"] + ociolook_working_color = instance.data["ocioLookWorkingSpace"] + staging_dir = self.staging_dir(instance) + + # create ociolook file attributes + ociolook_file_name = "ocioLookFile.json" + ociolook_file_content = { + "version": 1, + "data": { + "ocioLookItems": ociolook_items, + "ocioLookWorkingSpace": ociolook_working_color + } + } + + # write ociolook content into json file saved in staging dir + file_url = os.path.join(staging_dir, ociolook_file_name) + with open(file_url, "w") as f_: + json.dump(ociolook_file_content, f_, indent=4) + + # create lut representation data + ociolook_repre = { + "name": "ocioLookFile", + "ext": "json", + "files": ociolook_file_name, + "stagingDir": staging_dir, + "tags": [] + } + instance.data["representations"].append(ociolook_repre) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py index 75b41cf606..03f9f299b2 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py @@ -18,6 +18,7 @@ class ValidateColorspace(pyblish.api.InstancePlugin, label = "Validate representation colorspace" order = pyblish.api.ValidatorOrder hosts = ["traypublisher"] + families = ["render", "plate", "reference", "image", "online"] def process(self, instance): diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py new file mode 100644 index 0000000000..548ce9d15a --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py @@ -0,0 +1,89 @@ +import pyblish.api + +from openpype.pipeline import ( + publish, + PublishValidationError +) + + +class ValidateColorspaceLook(pyblish.api.InstancePlugin, + publish.OpenPypePyblishPluginMixin): + """Validate colorspace look attributes""" + + label = "Validate colorspace look attributes" + order = pyblish.api.ValidatorOrder + hosts = ["traypublisher"] + families = ["ociolook"] + + def process(self, instance): + create_context = instance.context.data["create_context"] + created_instance = create_context.get_instance_by_id( + instance.data["instance_id"]) + creator_defs = created_instance.creator_attribute_defs + + ociolook_working_color = instance.data.get("ocioLookWorkingSpace") + ociolook_items = instance.data.get("ocioLookItems", []) + + creator_defs_by_key = {_def.key: _def.label for _def in creator_defs} + + not_set_keys = {} + if not ociolook_working_color: + not_set_keys["working_colorspace"] = creator_defs_by_key[ + "working_colorspace"] + + for ociolook_item in ociolook_items: + item_not_set_keys = self.validate_colorspace_set_attrs( + ociolook_item, creator_defs_by_key) + if item_not_set_keys: + not_set_keys[ociolook_item["name"]] = item_not_set_keys + + if not_set_keys: + message = ( + "Colorspace look attributes are not set: \n" + ) + for key, value in not_set_keys.items(): + if isinstance(value, list): + values_string = "\n\t- ".join(value) + message += f"\n\t{key}:\n\t- {values_string}" + else: + message += f"\n\t{value}" + + raise PublishValidationError( + title="Colorspace Look attributes", + message=message, + description=message + ) + + def validate_colorspace_set_attrs( + self, + ociolook_item, + creator_defs_by_key + ): + """Validate colorspace look attributes""" + + self.log.debug(f"Validate colorspace look attributes: {ociolook_item}") + + check_keys = [ + "input_colorspace", + "output_colorspace", + "direction", + "interpolation" + ] + + not_set_keys = [] + for key in check_keys: + if ociolook_item[key]: + # key is set and it is correct + continue + + def_label = creator_defs_by_key.get(key) + + if not def_label: + # raise since key is not recognized by creator defs + raise KeyError( + f"Colorspace look attribute '{key}' is not " + f"recognized by creator attributes: {creator_defs_by_key}" + ) + not_set_keys.append(def_label) + + return not_set_keys diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py index b962ea464a..56389a927f 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py @@ -15,7 +15,7 @@ class ValidateFrameRange(OptionalPyblishPluginMixin, label = "Validate Frame Range" hosts = ["traypublisher"] - families = ["render"] + families = ["render", "plate"] order = ValidateContentsOrder optional = True @@ -30,6 +30,12 @@ class ValidateFrameRange(OptionalPyblishPluginMixin, if not self.is_active(instance.data): return + # editorial would fail since they might not be in database yet + new_asset_publishing = instance.data.get("newAssetPublishing") + if new_asset_publishing: + self.log.debug("Instance is creating new asset. Skipping.") + return + if (self.skip_timelines_check and any(re.search(pattern, instance.data["task"]) for pattern in self.skip_timelines_check)): diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index 6f76c25e0c..2c4d8160a6 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -11,7 +11,7 @@ import filecmp import tempfile import threading import shutil -from queue import Queue + from contextlib import closing from aiohttp import web @@ -21,6 +21,7 @@ from aiohttp_json_rpc.protocol import ( ) from aiohttp_json_rpc.exceptions import RpcError +from openpype import AYON_SERVER_ENABLED from openpype.lib import emit_event from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path @@ -319,19 +320,19 @@ class QtTVPaintRpc(BaseTVPaintRpc): async def workfiles_tool(self): log.info("Triggering Workfile tool") item = MainThreadItem(self.tools_helper.show_workfiles) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def loader_tool(self): log.info("Triggering Loader tool") item = MainThreadItem(self.tools_helper.show_loader) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def publish_tool(self): log.info("Triggering Publish tool") item = MainThreadItem(self.tools_helper.show_publisher_tool) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def scene_inventory_tool(self): @@ -350,13 +351,13 @@ class QtTVPaintRpc(BaseTVPaintRpc): async def library_loader_tool(self): log.info("Triggering Library loader tool") item = MainThreadItem(self.tools_helper.show_library_loader) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def experimental_tools(self): log.info("Triggering Library loader tool") item = MainThreadItem(self.tools_helper.show_experimental_tools_dialog) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def _async_execute_in_main_thread(self, item, **kwargs): @@ -834,8 +835,12 @@ class BaseCommunicator: class QtCommunicator(BaseCommunicator): + label = os.getenv("AVALON_LABEL") + if not label: + label = "AYON" if AYON_SERVER_ENABLED else "OpenPype" + title = "{} Tools".format(label) menu_definitions = { - "title": "OpenPype Tools", + "title": title, "menu_items": [ { "callback": "workfiles_tool", @@ -867,7 +872,7 @@ class QtCommunicator(BaseCommunicator): def __init__(self, qt_app): super().__init__() - self.callback_queue = Queue() + self.callback_queue = collections.deque() self.qt_app = qt_app def _create_routes(self): @@ -880,14 +885,14 @@ class QtCommunicator(BaseCommunicator): def execute_in_main_thread(self, main_thread_item, wait=True): """Add `MainThreadItem` to callback queue and wait for result.""" - self.callback_queue.put(main_thread_item) + self.callback_queue.append(main_thread_item) if wait: return main_thread_item.wait() return async def async_execute_in_main_thread(self, main_thread_item, wait=True): """Add `MainThreadItem` to callback queue and wait for result.""" - self.callback_queue.put(main_thread_item) + self.callback_queue.append(main_thread_item) if wait: return await main_thread_item.async_wait() @@ -904,9 +909,9 @@ class QtCommunicator(BaseCommunicator): self._exit() return None - if self.callback_queue.empty(): - return None - return self.callback_queue.get() + if self.callback_queue: + return self.callback_queue.popleft() + return None def _on_client_connect(self): super()._on_client_connect() diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 58fbd09545..c125da1533 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -7,7 +7,7 @@ import requests import pyblish.api -from openpype.client import get_project, get_asset_by_name +from openpype.client import get_asset_by_name from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost from openpype.hosts.tvpaint import TVPAINT_ROOT_DIR from openpype.settings import get_current_project_settings @@ -84,10 +84,6 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_loader_plugin_path(load_dir) register_creator_plugin_path(create_dir) - registered_callbacks = ( - pyblish.api.registered_callbacks().get("instanceToggled") or [] - ) - register_event_callback("application.launched", self.initial_launch) register_event_callback("application.exit", self.application_exit) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render.py b/openpype/hosts/tvpaint/plugins/create/create_render.py index 2369c7329f..667103432e 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render.py @@ -37,7 +37,8 @@ Todos: import collections from typing import Any, Optional, Union -from openpype.client import get_asset_by_name +from openpype import AYON_SERVER_ENABLED +from openpype.client import get_asset_by_name, get_asset_name_identifier from openpype.lib import ( prepare_template_data, AbstractAttrDef, @@ -139,7 +140,7 @@ class CreateRenderlayer(TVPaintCreator): # - Mark by default instance for review mark_for_review = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_layer"] ) @@ -387,7 +388,7 @@ class CreateRenderPass(TVPaintCreator): # Settings mark_for_review = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_pass"] ) @@ -690,7 +691,7 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): group_idx_offset = 10 group_idx_padding = 3 - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings ["tvpaint"] @@ -784,18 +785,25 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): project_name, host_name=self.create_context.host_name, ) + asset_name = get_asset_name_identifier(asset_doc) if existing_instance is not None: - existing_instance["asset"] = asset_doc["name"] + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name return existing_instance instance_data: dict[str, str] = { - "asset": asset_doc["name"], "task": task_name, "family": creator.family, "variant": variant } + if AYON_SERVER_ENABLED: + instance_data["folderPath"] = asset_name + else: + instance_data["asset"] = asset_name pre_create_data: dict[str, str] = { "group_id": group_id, "mark_for_review": mark_for_review @@ -820,6 +828,8 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): for layer_name in render_pass["layer_names"]: render_pass_by_layer_name[layer_name] = render_pass + asset_name = get_asset_name_identifier(asset_doc) + for layer in layers: layer_name = layer["name"] variant = layer_name @@ -838,17 +848,25 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): ) if render_pass is not None: - render_pass["asset"] = asset_doc["name"] + if AYON_SERVER_ENABLED: + render_pass["folderPath"] = asset_name + else: + render_pass["asset"] = asset_name + render_pass["task"] = task_name render_pass["subset"] = subset_name continue instance_data: dict[str, str] = { - "asset": asset_doc["name"], "task": task_name, "family": creator.family, "variant": variant } + if AYON_SERVER_ENABLED: + instance_data["folderPath"] = asset_name + else: + instance_data["asset"] = asset_name + pre_create_data: dict[str, Any] = { "render_layer_instance_id": render_layer_instance.id, "layer_names": [layer_name], @@ -882,9 +900,13 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): def create(self, subset_name, instance_data, pre_create_data): project_name: str = self.create_context.get_current_project_name() - asset_name: str = instance_data["asset"] + if AYON_SERVER_ENABLED: + asset_name: str = instance_data["folderPath"] + else: + asset_name: str = instance_data["asset"] task_name: str = instance_data["task"] - asset_doc: dict[str, Any] = get_asset_by_name(project_name, asset_name) + asset_doc: dict[str, Any] = get_asset_by_name( + project_name, asset_name) render_layers_by_group_id: dict[int, CreatedInstance] = {} render_passes_by_render_layer_id: dict[int, list[CreatedInstance]] = ( @@ -1029,7 +1051,7 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): mark_for_review = True active_on_create = False - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_scene"] ) @@ -1061,7 +1083,6 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant, "creator_attributes": { @@ -1073,6 +1094,10 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): self.default_pass_name ) } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name if not self.active_on_create: data["active"] = False @@ -1101,8 +1126,14 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() + existing_name = None + if AYON_SERVER_ENABLED: + existing_name = existing_instance.get("folderPath") + if existing_name is None: + existing_name = existing_instance["asset"] + if ( - existing_instance["asset"] != asset_name + existing_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -1114,7 +1145,10 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): host_name, existing_instance ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/tvpaint/plugins/create/create_review.py b/openpype/hosts/tvpaint/plugins/create/create_review.py index 886dae7c39..5caf20f27d 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_review.py +++ b/openpype/hosts/tvpaint/plugins/create/create_review.py @@ -1,3 +1,4 @@ +from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import CreatedInstance from openpype.hosts.tvpaint.api.plugin import TVPaintAutoCreator @@ -12,7 +13,7 @@ class TVPaintReviewCreator(TVPaintAutoCreator): # Settings active_on_create = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_review"] ) @@ -33,6 +34,13 @@ class TVPaintReviewCreator(TVPaintAutoCreator): asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() + if existing_instance is None: + existing_asset_name = None + elif AYON_SERVER_ENABLED: + existing_asset_name = existing_instance["folderPath"] + else: + existing_asset_name = existing_instance["asset"] + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -43,10 +51,14 @@ class TVPaintReviewCreator(TVPaintAutoCreator): host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name + if not self.active_on_create: data["active"] = False @@ -59,7 +71,7 @@ class TVPaintReviewCreator(TVPaintAutoCreator): self._add_instance_to_context(new_instance) elif ( - existing_instance["asset"] != asset_name + existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -71,6 +83,9 @@ class TVPaintReviewCreator(TVPaintAutoCreator): host_name, existing_instance ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/tvpaint/plugins/create/create_workfile.py b/openpype/hosts/tvpaint/plugins/create/create_workfile.py index 41347576d5..4ce5d7fc96 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_workfile.py +++ b/openpype/hosts/tvpaint/plugins/create/create_workfile.py @@ -1,3 +1,4 @@ +from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import CreatedInstance from openpype.hosts.tvpaint.api.plugin import TVPaintAutoCreator @@ -9,7 +10,7 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): label = "Workfile" icon = "fa.file-o" - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_workfile"] ) @@ -29,6 +30,13 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() + if existing_instance is None: + existing_asset_name = None + elif AYON_SERVER_ENABLED: + existing_asset_name = existing_instance["folderPath"] + else: + existing_asset_name = existing_instance["asset"] + if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( @@ -39,10 +47,13 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): host_name ) data = { - "asset": asset_name, "task": task_name, "variant": self.default_variant } + if AYON_SERVER_ENABLED: + data["folderPath"] = asset_name + else: + data["asset"] = asset_name new_instance = CreatedInstance( self.family, subset_name, data, self @@ -53,7 +64,7 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): self._add_instance_to_context(new_instance) elif ( - existing_instance["asset"] != asset_name + existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) @@ -65,6 +76,9 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): host_name, existing_instance ) - existing_instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + existing_instance["folderPath"] = asset_name + else: + existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name diff --git a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py index edc116a8e4..53061c6885 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py @@ -171,7 +171,7 @@ class LoadImage(plugin.Loader): george_script = "\n".join(george_script_lines) execute_george_through_file(george_script) - def _remove_container(self, container, members=None): + def _remove_container(self, container): if not container: return representation = container["representation"] @@ -190,7 +190,7 @@ class LoadImage(plugin.Loader): if pop_idx is None: self.log.warning( - "Didn't found container in workfile containers. {}".format( + "Didn't find container in workfile containers. {}".format( container ) ) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index 95a5cd77bd..56b51c812a 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -69,7 +69,6 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): "asset_name": context.data["asset"], "task_name": context.data["task"] } - context.data["previous_context"] = current_context self.log.debug("Current context is: {}".format(current_context)) # Collect context from workfile metadata diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 8a610cf388..fd568b2826 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -6,7 +6,10 @@ from PIL import Image import pyblish.api -from openpype.pipeline.publish import KnownPublishError +from openpype.pipeline.publish import ( + KnownPublishError, + get_publish_instance_families, +) from openpype.hosts.tvpaint.api.lib import ( execute_george, execute_george_through_file, @@ -63,7 +66,6 @@ class ExtractSequence(pyblish.api.Extractor): "ignoreLayersTransparency", False ) - family_lowered = instance.data["family"].lower() mark_in = instance.context.data["sceneMarkIn"] mark_out = instance.context.data["sceneMarkOut"] @@ -76,11 +78,9 @@ class ExtractSequence(pyblish.api.Extractor): # Frame start/end may be stored as float frame_start = int(instance.data["frameStart"]) - frame_end = int(instance.data["frameEnd"]) # Handles are not stored per instance but on Context handle_start = instance.context.data["handleStart"] - handle_end = instance.context.data["handleEnd"] scene_bg_color = instance.context.data["sceneBgColor"] @@ -143,8 +143,9 @@ class ExtractSequence(pyblish.api.Extractor): ) # Fill tags and new families from project settings + instance_families = get_publish_instance_families(instance) tags = [] - if "review" in instance.data["families"]: + if "review" in instance_families: tags.append("review") # Sequence of one frame diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py index 9347960d3f..dc29e6c278 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py @@ -1,4 +1,5 @@ import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin, @@ -24,12 +25,19 @@ class FixAssetNames(pyblish.api.Action): old_instance_items = list_instances() new_instance_items = [] for instance_item in old_instance_items: - instance_asset_name = instance_item.get("asset") + if AYON_SERVER_ENABLED: + instance_asset_name = instance_item.get("folderPath") + else: + instance_asset_name = instance_item.get("asset") + if ( instance_asset_name and instance_asset_name != context_asset_name ): - instance_item["asset"] = context_asset_name + if AYON_SERVER_ENABLED: + instance_item["folderPath"] = context_asset_name + else: + instance_item["asset"] = context_asset_name new_instance_items.append(instance_item) write_instances(new_instance_items) diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp index 88106bc770..ec45a45123 100644 --- a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp @@ -573,56 +573,6 @@ void FAR PASCAL PI_Close( PIFilter* iFilter ) } -/**************************************************************************************/ -// we have something to do ! - -int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg ) -{ - if( !iArg ) - { - - // If the requester is not open, we open it. - if( Data.mReq == 0) - { - // Create empty requester because menu items are defined with - // `define_menu` callback - DWORD req = TVOpenFilterReqEx( - iFilter, - 185, - 20, - NULL, - NULL, - PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ, - FILTERREQ_NO_TBAR - ); - if( req == 0 ) - { - TVWarning( iFilter, TXT_REQUESTER_ERROR ); - return 0; - } - - - Data.mReq = req; - // This is a very simple requester, so we create it's content right here instead - // of waiting for the PICBREQ_OPEN message... - // Not recommended for more complex requesters. (see the other examples) - - // Sets the title of the requester. - TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER ); - // Request to listen to ticks - TVGrabTicks(iFilter, req, PITICKS_FLAG_ON); - } - else - { - // If it is already open, we just put it on front of all other requesters. - TVReqToFront( iFilter, Data.mReq ); - } - } - - return 1; -} - - int newMenuItemsProcess(PIFilter* iFilter) { // Menu items defined with `define_menu` should be propagated. @@ -702,6 +652,62 @@ int newMenuItemsProcess(PIFilter* iFilter) { return 1; } + +/**************************************************************************************/ +// we have something to do ! + +int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg ) +{ + if( !iArg ) + { + + // If the requester is not open, we open it. + if( Data.mReq == 0) + { + // Create empty requester because menu items are defined with + // `define_menu` callback + DWORD req = TVOpenFilterReqEx( + iFilter, + 185, + 20, + NULL, + NULL, + PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ, + FILTERREQ_NO_TBAR + ); + if( req == 0 ) + { + TVWarning( iFilter, TXT_REQUESTER_ERROR ); + return 0; + } + + Data.mReq = req; + + // This is a very simple requester, so we create it's content right here instead + // of waiting for the PICBREQ_OPEN message... + // Not recommended for more complex requesters. (see the other examples) + + // Sets the title of the requester. + TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER ); + // Request to listen to ticks + TVGrabTicks(iFilter, req, PITICKS_FLAG_ON); + + if ( Data.firstParams == true ) { + Data.firstParams = false; + } else { + newMenuItemsProcess(iFilter); + } + } + else + { + // If it is already open, we just put it on front of all other requesters. + TVReqToFront( iFilter, Data.mReq ); + } + } + + return 1; +} + /**************************************************************************************/ // something happened that needs our attention. // Global variable where current button up data are stored diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll index 7081778bee..9c6e969e24 100644 Binary files a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll and b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll differ diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll index 0f2afec245..b573476a21 100644 Binary files a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll and b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll differ diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 72816c9b81..f2d7b5f73e 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -13,8 +13,10 @@ from openpype.client import get_asset_by_name, get_assets from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, + register_inventory_action_path, deregister_loader_plugin_path, deregister_creator_plugin_path, + deregister_inventory_action_path, AYON_CONTAINER_ID, legacy_io, ) @@ -28,6 +30,7 @@ import unreal # noqa logger = logging.getLogger("openpype.hosts.unreal") AYON_CONTAINERS = "AyonContainers" +AYON_ASSET_DIR = "/Game/Ayon/Assets" CONTEXT_CONTAINER = "Ayon/context.json" UNREAL_VERSION = semver.VersionInfo( *os.getenv("AYON_UNREAL_VERSION").split(".") @@ -127,6 +130,7 @@ def install(): pyblish.api.register_plugin_path(str(PUBLISH_PATH)) register_loader_plugin_path(str(LOAD_PATH)) register_creator_plugin_path(str(CREATE_PATH)) + register_inventory_action_path(str(INVENTORY_PATH)) _register_callbacks() _register_events() @@ -136,6 +140,7 @@ def uninstall(): pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) deregister_loader_plugin_path(str(LOAD_PATH)) deregister_creator_plugin_path(str(CREATE_PATH)) + deregister_inventory_action_path(str(INVENTORY_PATH)) def _register_callbacks(): @@ -649,6 +654,141 @@ def generate_sequence(h, h_dir): return sequence, (min_frame, max_frame) +def _get_comps_and_assets( + component_class, asset_class, old_assets, new_assets, selected +): + eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + + components = [] + if selected: + sel_actors = eas.get_selected_level_actors() + for actor in sel_actors: + comps = actor.get_components_by_class(component_class) + components.extend(comps) + else: + comps = eas.get_all_level_actors_components() + components = [ + c for c in comps if isinstance(c, component_class) + ] + + # Get all the static meshes among the old assets in a dictionary with + # the name as key + selected_old_assets = {} + for a in old_assets: + asset = unreal.EditorAssetLibrary.load_asset(a) + if isinstance(asset, asset_class): + selected_old_assets[asset.get_name()] = asset + + # Get all the static meshes among the new assets in a dictionary with + # the name as key + selected_new_assets = {} + for a in new_assets: + asset = unreal.EditorAssetLibrary.load_asset(a) + if isinstance(asset, asset_class): + selected_new_assets[asset.get_name()] = asset + + return components, selected_old_assets, selected_new_assets + + +def replace_static_mesh_actors(old_assets, new_assets, selected): + smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) + + static_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets( + unreal.StaticMeshComponent, + unreal.StaticMesh, + old_assets, + new_assets, + selected + ) + + for old_name, old_mesh in old_meshes.items(): + new_mesh = new_meshes.get(old_name) + + if not new_mesh: + continue + + smes.replace_mesh_components_meshes( + static_mesh_comps, old_mesh, new_mesh) + + +def replace_skeletal_mesh_actors(old_assets, new_assets, selected): + skeletal_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets( + unreal.SkeletalMeshComponent, + unreal.SkeletalMesh, + old_assets, + new_assets, + selected + ) + + for old_name, old_mesh in old_meshes.items(): + new_mesh = new_meshes.get(old_name) + + if not new_mesh: + continue + + for comp in skeletal_mesh_comps: + if comp.get_skeletal_mesh_asset() == old_mesh: + comp.set_skeletal_mesh_asset(new_mesh) + + +def replace_geometry_cache_actors(old_assets, new_assets, selected): + geometry_cache_comps, old_caches, new_caches = _get_comps_and_assets( + unreal.GeometryCacheComponent, + unreal.GeometryCache, + old_assets, + new_assets, + selected + ) + + for old_name, old_mesh in old_caches.items(): + new_mesh = new_caches.get(old_name) + + if not new_mesh: + continue + + for comp in geometry_cache_comps: + if comp.get_editor_property("geometry_cache") == old_mesh: + comp.set_geometry_cache(new_mesh) + + +def delete_asset_if_unused(container, asset_content): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + references = set() + + for asset_path in asset_content: + asset = ar.get_asset_by_object_path(asset_path) + refs = ar.get_referencers( + asset.package_name, + unreal.AssetRegistryDependencyOptions( + include_soft_package_references=False, + include_hard_package_references=True, + include_searchable_names=False, + include_soft_management_references=False, + include_hard_management_references=False + )) + if not refs: + continue + references = references.union(set(refs)) + + # Filter out references that are in the Temp folder + cleaned_references = { + ref for ref in references if not str(ref).startswith("/Temp/")} + + # Check which of the references are Levels + for ref in cleaned_references: + loaded_asset = unreal.EditorAssetLibrary.load_asset(ref) + if isinstance(loaded_asset, unreal.World): + # If there is at least a level, we can stop, we don't want to + # delete the container + return + + unreal.log("Previous version unused, deleting...") + + # No levels, delete the asset + unreal.EditorAssetLibrary.delete_directory(container["namespace"]) + + @contextmanager def maintained_selection(): """Stub to be either implemented or replaced. diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 202d7854f6..a635bd4cab 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -2,6 +2,8 @@ """Hook to launch Unreal and prepare projects.""" import os import copy +import shutil +import tempfile from pathlib import Path from qtpy import QtCore @@ -224,10 +226,24 @@ class UnrealPrelaunchHook(PreLaunchHook): project_file = project_path / unreal_project_filename if not project_file.is_file(): - self.exec_ue_project_gen(engine_version, - unreal_project_name, - engine_path, - project_path) + with tempfile.TemporaryDirectory() as temp_dir: + self.exec_ue_project_gen(engine_version, + unreal_project_name, + engine_path, + Path(temp_dir)) + try: + self.log.info(( + f"Moving from {temp_dir} to " + f"{project_path.as_posix()}" + )) + shutil.copytree( + temp_dir, project_path, dirs_exist_ok=True) + + except shutil.Error as e: + raise ApplicationLaunchFailed(( + f"{self.signature} Cannot copy directory {temp_dir} " + f"to {project_path.as_posix()} - {e}" + )) from e self.launch_context.env["AYON_UNREAL_VERSION"] = engine_version # Append project file to launch arguments diff --git a/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py b/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py new file mode 100644 index 0000000000..8320e3c92d --- /dev/null +++ b/openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py @@ -0,0 +1,66 @@ +import unreal + +from openpype.hosts.unreal.api.tools_ui import qt_app_context +from openpype.hosts.unreal.api.pipeline import delete_asset_if_unused +from openpype.pipeline import InventoryAction + + +class DeleteUnusedAssets(InventoryAction): + """Delete all the assets that are not used in any level. + """ + + label = "Delete Unused Assets" + icon = "trash" + color = "red" + order = 1 + + dialog = None + + def _delete_unused_assets(self, containers): + allowed_families = ["model", "rig"] + + for container in containers: + container_dir = container.get("namespace") + if container.get("family") not in allowed_families: + unreal.log_warning( + f"Container {container_dir} is not supported.") + continue + + asset_content = unreal.EditorAssetLibrary.list_assets( + container_dir, recursive=True, include_folder=False + ) + + delete_asset_if_unused(container, asset_content) + + def _show_confirmation_dialog(self, containers): + from qtpy import QtCore + from openpype.widgets import popup + from openpype.style import load_stylesheet + + dialog = popup.Popup() + dialog.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.WindowStaysOnTopHint + ) + dialog.setFocusPolicy(QtCore.Qt.StrongFocus) + dialog.setWindowTitle("Delete all unused assets") + dialog.setMessage( + "You are about to delete all the assets in the project that \n" + "are not used in any level. Are you sure you want to continue?" + ) + dialog.setButtonText("Delete") + + dialog.on_clicked.connect( + lambda: self._delete_unused_assets(containers) + ) + + dialog.show() + dialog.raise_() + dialog.activateWindow() + dialog.setStyleSheet(load_stylesheet()) + + self.dialog = dialog + + def process(self, containers): + with qt_app_context(): + self._show_confirmation_dialog(containers) diff --git a/openpype/hosts/unreal/plugins/inventory/update_actors.py b/openpype/hosts/unreal/plugins/inventory/update_actors.py new file mode 100644 index 0000000000..b0d941ba80 --- /dev/null +++ b/openpype/hosts/unreal/plugins/inventory/update_actors.py @@ -0,0 +1,84 @@ +import unreal + +from openpype.hosts.unreal.api.pipeline import ( + ls, + replace_static_mesh_actors, + replace_skeletal_mesh_actors, + replace_geometry_cache_actors, +) +from openpype.pipeline import InventoryAction + + +def update_assets(containers, selected): + allowed_families = ["model", "rig"] + + # Get all the containers in the Unreal Project + all_containers = ls() + + for container in containers: + container_dir = container.get("namespace") + if container.get("family") not in allowed_families: + unreal.log_warning( + f"Container {container_dir} is not supported.") + continue + + # Get all containers with same asset_name but different objectName. + # These are the containers that need to be updated in the level. + sa_containers = [ + i + for i in all_containers + if ( + i.get("asset_name") == container.get("asset_name") and + i.get("objectName") != container.get("objectName") + ) + ] + + asset_content = unreal.EditorAssetLibrary.list_assets( + container_dir, recursive=True, include_folder=False + ) + + # Update all actors in level + for sa_cont in sa_containers: + sa_dir = sa_cont.get("namespace") + old_content = unreal.EditorAssetLibrary.list_assets( + sa_dir, recursive=True, include_folder=False + ) + + if container.get("family") == "rig": + replace_skeletal_mesh_actors( + old_content, asset_content, selected) + replace_static_mesh_actors( + old_content, asset_content, selected) + elif container.get("family") == "model": + if container.get("loader") == "PointCacheAlembicLoader": + replace_geometry_cache_actors( + old_content, asset_content, selected) + else: + replace_static_mesh_actors( + old_content, asset_content, selected) + + unreal.EditorLevelLibrary.save_current_level() + + +class UpdateAllActors(InventoryAction): + """Update all the Actors in the current level to the version of the asset + selected in the scene manager. + """ + + label = "Replace all Actors in level to this version" + icon = "arrow-up" + + def process(self, containers): + update_assets(containers, False) + + +class UpdateSelectedActors(InventoryAction): + """Update only the selected Actors in the current level to the version + of the asset selected in the scene manager. + """ + + label = "Replace selected Actors in level to this version" + icon = "arrow-up" + + def process(self, containers): + update_assets(containers, True) diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py index cb60197a4c..0328d2ae9f 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py @@ -69,18 +69,23 @@ class AnimationAlembicLoader(plugin.Loader): """ # Create directory for asset and ayon container - root = "/Game/Ayon/Assets" + root = unreal_pipeline.AYON_ASSET_DIR asset = context.get('asset').get('name') suffix = "_CON" if asset: asset_name = "{}_{}".format(asset, name) else: asset_name = "{}".format(name) - version = context.get('version').get('name') + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_v{version:03d}", suffix="") + f"{root}/{asset}/{name_version}", suffix="") container_name += suffix diff --git a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py index 13ba236a7d..ec9c52b9fb 100644 --- a/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py @@ -7,7 +7,11 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, + create_container, + imprint, +) import unreal # noqa @@ -21,8 +25,11 @@ class PointCacheAlembicLoader(plugin.Loader): icon = "cube" color = "orange" + root = AYON_ASSET_DIR + + @staticmethod def get_task( - self, filename, asset_dir, asset_name, replace, + filename, asset_dir, asset_name, replace, frame_start=None, frame_end=None ): task = unreal.AssetImportTask() @@ -38,8 +45,6 @@ class PointCacheAlembicLoader(plugin.Loader): task.set_editor_property('automated', True) task.set_editor_property('save', True) - # set import options here - # Unreal 4.24 ignores the settings. It works with Unreal 4.26 options.set_editor_property( 'import_type', unreal.AlembicImportType.GEOMETRY_CACHE) @@ -64,13 +69,42 @@ class PointCacheAlembicLoader(plugin.Loader): return task - def load(self, context, name, namespace, data): - """Load and containerise representation into Content Browser. + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name, + frame_start, frame_end + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. + task = self.get_task( + filepath, asset_dir, asset_name, False, frame_start, frame_end) + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + # Create Asset Container + create_container(container=container_name, path=asset_dir) + + def imprint( + self, asset, asset_dir, container_name, asset_name, representation, + frame_start, frame_end + ): + data = { + "schema": "ayon:container-2.0", + "id": AYON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"], + "frame_start": frame_start, + "frame_end": frame_end + } + imprint(f"{asset_dir}/{container_name}", data) + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. Args: context (dict): application context @@ -79,30 +113,28 @@ class PointCacheAlembicLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + data (dict): Those would be data to be imprinted. Returns: list(str): list of container content - """ # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" asset = context.get('asset').get('name') suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" else: - asset_name = "{}".format(name) + name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - "{}/{}/{}".format(root, asset, name), suffix="") + f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix - unreal.EditorAssetLibrary.make_directory(asset_dir) - frame_start = context.get('asset').get('data').get('frameStart') frame_end = context.get('asset').get('data').get('frameEnd') @@ -111,30 +143,16 @@ class PointCacheAlembicLoader(plugin.Loader): if frame_start == frame_end: frame_end += 1 - path = self.filepath_from_context(context) - task = self.get_task( - path, asset_dir, asset_name, False, frame_start, frame_end) + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = self.filepath_from_context(context) - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + self.import_and_containerize( + path, asset_dir, asset_name, container_name, + frame_start, frame_end) - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) - - data = { - "schema": "ayon:container-2.0", - "id": AYON_CONTAINER_ID, - "asset": asset, - "namespace": asset_dir, - "container_name": container_name, - "asset_name": asset_name, - "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] - } - unreal_pipeline.imprint( - "{}/{}".format(asset_dir, container_name), data) + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"], frame_start, frame_end) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True @@ -146,27 +164,43 @@ class PointCacheAlembicLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] - representation["context"] + context = representation.get("context", {}) - task = self.get_task(source_path, destination_path, name, False) - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + unreal.log_warning(context) - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + if not context: + raise RuntimeError("No context found in representation") + + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") + + container_name += suffix + + frame_start = int(container.get("frame_start")) + frame_end = int(container.get("frame_end")) + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) + + self.import_and_containerize( + path, asset_dir, asset_name, container_name, + frame_start, frame_end) + + self.imprint( + asset, asset_dir, container_name, asset_name, representation, + frame_start, frame_end) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py index 0b0030ff77..8ebd9a82b6 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py @@ -7,7 +7,11 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, + create_container, + imprint, +) import unreal # noqa @@ -20,10 +24,12 @@ class SkeletalMeshAlembicLoader(plugin.Loader): icon = "cube" color = "orange" - def get_task(self, filename, asset_dir, asset_name, replace): + root = AYON_ASSET_DIR + + @staticmethod + def get_task(filename, asset_dir, asset_name, replace, default_conversion): task = unreal.AssetImportTask() options = unreal.AbcImportSettings() - sm_settings = unreal.AbcStaticMeshSettings() conversion_settings = unreal.AbcConversionSettings( preset=unreal.AbcConversionPreset.CUSTOM, flip_u=False, flip_v=False, @@ -37,67 +43,38 @@ class SkeletalMeshAlembicLoader(plugin.Loader): task.set_editor_property('automated', True) task.set_editor_property('save', True) - # set import options here - # Unreal 4.24 ignores the settings. It works with Unreal 4.26 options.set_editor_property( 'import_type', unreal.AlembicImportType.SKELETAL) - options.static_mesh_settings = sm_settings - options.conversion_settings = conversion_settings + if not default_conversion: + conversion_settings = unreal.AbcConversionSettings( + preset=unreal.AbcConversionPreset.CUSTOM, + flip_u=False, flip_v=False, + rotation=[0.0, 0.0, 0.0], + scale=[1.0, 1.0, 1.0]) + options.conversion_settings = conversion_settings + task.options = options return task - def load(self, context, name, namespace, data): - """Load and containerise representation into Content Browser. + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name, + default_conversion=False + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. + task = self.get_task( + filepath, asset_dir, asset_name, False, default_conversion) - Args: - context (dict): application context - name (str): subset name - namespace (str): in Unreal this is basically path to container. - This is not passed here, so namespace is set - by `containerise()` because only then we know - real path. - data (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - Returns: - list(str): list of container content - """ - - # Create directory for asset and ayon container - root = "/Game/Ayon/Assets" - asset = context.get('asset').get('name') - suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) - version = context.get('version').get('name') - - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_v{version:03d}", suffix="") - - container_name += suffix - - if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): - unreal.EditorAssetLibrary.make_directory(asset_dir) - - path = self.filepath_from_context(context) - task = self.get_task(path, asset_dir, asset_name, False) - - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) + # Create Asset Container + create_container(container=container_name, path=asset_dir) + def imprint( + self, asset, asset_dir, container_name, asset_name, representation + ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, @@ -106,12 +83,57 @@ class SkeletalMeshAlembicLoader(plugin.Loader): "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"] } - unreal_pipeline.imprint( - f"{asset_dir}/{container_name}", data) + imprint(f"{asset_dir}/{container_name}", data) + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. + + Returns: + list(str): list of container content + """ + # Create directory for asset and ayon container + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version.get('name'):03d}" + + default_conversion = False + if options.get("default_conversion"): + default_conversion = options.get("default_conversion") + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") + + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = self.filepath_from_context(context) + + self.import_and_containerize(path, asset_dir, asset_name, + container_name, default_conversion) + + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True @@ -123,26 +145,36 @@ class SkeletalMeshAlembicLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] + context = representation.get("context", {}) - task = self.get_task(source_path, destination_path, name, True) + if not context: + raise RuntimeError("No context found in representation") - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") + + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) + + self.import_and_containerize(path, asset_dir, asset_name, + container_name) + + self.imprint( + asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py index 09cd37b9db..a5a8730732 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py @@ -7,7 +7,11 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, + create_container, + imprint, +) import unreal # noqa @@ -20,89 +24,62 @@ class SkeletalMeshFBXLoader(plugin.Loader): icon = "cube" color = "orange" - def load(self, context, name, namespace, options): - """Load and containerise representation into Content Browser. + root = AYON_ASSET_DIR - This is a two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. + @staticmethod + def get_task(filename, asset_dir, asset_name, replace): + task = unreal.AssetImportTask() + options = unreal.FbxImportUI() - Args: - context (dict): application context - name (str): subset name - namespace (str): in Unreal this is basically path to container. - This is not passed here, so namespace is set - by `containerise()` because only then we know - real path. - options (dict): Those would be data to be imprinted. This is not - used now, data are imprinted by `containerise()`. + task.set_editor_property('filename', filename) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', replace) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) - Returns: - list(str): list of container content + options.set_editor_property( + 'automated_import_should_detect_type', False) + options.set_editor_property('import_as_skeletal', True) + options.set_editor_property('import_animations', False) + options.set_editor_property('import_mesh', True) + options.set_editor_property('import_materials', False) + options.set_editor_property('import_textures', False) + options.set_editor_property('skeleton', None) + options.set_editor_property('create_physics_asset', False) - """ - # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" - if options and options.get("asset_dir"): - root = options["asset_dir"] - asset = context.get('asset').get('name') - suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) - version = context.get('version').get('name') + options.set_editor_property( + 'mesh_type_to_import', + unreal.FBXImportType.FBXIT_SKELETAL_MESH) - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_v{version:03d}", suffix="") + options.skeletal_mesh_import_data.set_editor_property( + 'import_content_type', + unreal.FBXImportContentType.FBXICT_ALL) - container_name += suffix + options.skeletal_mesh_import_data.set_editor_property( + 'normal_import_method', + unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) - if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): - unreal.EditorAssetLibrary.make_directory(asset_dir) + task.options = options - task = unreal.AssetImportTask() + return task - path = self.filepath_from_context(context) - task.set_editor_property('filename', path) - task.set_editor_property('destination_path', asset_dir) - task.set_editor_property('destination_name', asset_name) - task.set_editor_property('replace_existing', False) - task.set_editor_property('automated', True) - task.set_editor_property('save', False) + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) - # set import options here - options = unreal.FbxImportUI() - options.set_editor_property('import_as_skeletal', True) - options.set_editor_property('import_animations', False) - options.set_editor_property('import_mesh', True) - options.set_editor_property('import_materials', False) - options.set_editor_property('import_textures', False) - options.set_editor_property('skeleton', None) - options.set_editor_property('create_physics_asset', False) + task = self.get_task( + filepath, asset_dir, asset_name, False) - options.set_editor_property( - 'mesh_type_to_import', - unreal.FBXImportType.FBXIT_SKELETAL_MESH) + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - options.skeletal_mesh_import_data.set_editor_property( - 'import_content_type', - unreal.FBXImportContentType.FBXICT_ALL) - # set to import normals, otherwise Unreal will compute them - # and it will take a long time, depending on the size of the mesh - options.skeletal_mesh_import_data.set_editor_property( - 'normal_import_method', - unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) - - task.options = options - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) + # Create Asset Container + create_container(container=container_name, path=asset_dir) + def imprint( + self, asset, asset_dir, container_name, asset_name, representation + ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, @@ -111,12 +88,54 @@ class SkeletalMeshFBXLoader(plugin.Loader): "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"] } - unreal_pipeline.imprint( - f"{asset_dir}/{container_name}", data) + imprint(f"{asset_dir}/{container_name}", data) + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. + + Returns: + list(str): list of container content + """ + # Create directory for asset and Ayon container + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version.get('name'):03d}" + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="" + ) + + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = self.filepath_from_context(context) + + self.import_and_containerize( + path, asset_dir, asset_name, container_name) + + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True @@ -128,58 +147,36 @@ class SkeletalMeshFBXLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] + context = representation.get("context", {}) - task = unreal.AssetImportTask() + if not context: + raise RuntimeError("No context found in representation") - task.set_editor_property('filename', source_path) - task.set_editor_property('destination_path', destination_path) - task.set_editor_property('destination_name', name) - task.set_editor_property('replace_existing', True) - task.set_editor_property('automated', True) - task.set_editor_property('save', True) + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") - # set import options here - options = unreal.FbxImportUI() - options.set_editor_property('import_as_skeletal', True) - options.set_editor_property('import_animations', False) - options.set_editor_property('import_mesh', True) - options.set_editor_property('import_materials', True) - options.set_editor_property('import_textures', True) - options.set_editor_property('skeleton', None) - options.set_editor_property('create_physics_asset', False) + container_name += suffix - options.set_editor_property('mesh_type_to_import', - unreal.FBXImportType.FBXIT_SKELETAL_MESH) + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) - options.skeletal_mesh_import_data.set_editor_property( - 'import_content_type', - unreal.FBXImportContentType.FBXICT_ALL - ) - # set to import normals, otherwise Unreal will compute them - # and it will take a long time, depending on the size of the mesh - options.skeletal_mesh_import_data.set_editor_property( - 'normal_import_method', - unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS - ) + self.import_and_containerize( + path, asset_dir, asset_name, container_name) - task.options = options - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + self.imprint( + asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index 98e6d962b1..019a95a9bf 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -7,7 +7,11 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, + create_container, + imprint, +) import unreal # noqa @@ -20,6 +24,8 @@ class StaticMeshAlembicLoader(plugin.Loader): icon = "cube" color = "orange" + root = AYON_ASSET_DIR + @staticmethod def get_task(filename, asset_dir, asset_name, replace, default_conversion): task = unreal.AssetImportTask() @@ -53,61 +59,23 @@ class StaticMeshAlembicLoader(plugin.Loader): return task - def load(self, context, name, namespace, options): - """Load and containerise representation into Content Browser. + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name, + default_conversion=False + ): + unreal.EditorAssetLibrary.make_directory(asset_dir) - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. + task = self.get_task( + filepath, asset_dir, asset_name, False, default_conversion) - Args: - context (dict): application context - name (str): subset name - namespace (str): in Unreal this is basically path to container. - This is not passed here, so namespace is set - by `containerise()` because only then we know - real path. - data (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - Returns: - list(str): list of container content - - """ - # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" - asset = context.get('asset').get('name') - suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) - version = context.get('version').get('name') - - default_conversion = False - if options.get("default_conversion"): - default_conversion = options.get("default_conversion") - - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_v{version:03d}", suffix="") - - container_name += suffix - - if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): - unreal.EditorAssetLibrary.make_directory(asset_dir) - - path = self.filepath_from_context(context) - task = self.get_task( - path, asset_dir, asset_name, False, default_conversion) - - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 - - # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) + # Create Asset Container + create_container(container=container_name, path=asset_dir) + def imprint( + self, asset, asset_dir, container_name, asset_name, representation + ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, @@ -116,14 +84,60 @@ class StaticMeshAlembicLoader(plugin.Loader): "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"] } - unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) + imprint(f"{asset_dir}/{container_name}", data) + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. + + Returns: + list(str): list of container content + """ + # Create directory for asset and Ayon container + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version.get('name'):03d}" + + default_conversion = False + if options.get("default_conversion"): + default_conversion = options.get("default_conversion") + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") + + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = self.filepath_from_context(context) + + self.import_and_containerize(path, asset_dir, asset_name, + container_name, default_conversion) + + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( - asset_dir, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: @@ -132,27 +146,36 @@ class StaticMeshAlembicLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] + context = representation.get("context", {}) - task = self.get_task(source_path, destination_path, name, True, False) + if not context: + raise RuntimeError("No context found in representation") - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) + + self.import_and_containerize(path, asset_dir, asset_name, + container_name) + + self.imprint( + asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index fa26e252f5..66088d793c 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -7,7 +7,11 @@ from openpype.pipeline import ( AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin -from openpype.hosts.unreal.api import pipeline as unreal_pipeline +from openpype.hosts.unreal.api.pipeline import ( + AYON_ASSET_DIR, + create_container, + imprint, +) import unreal # noqa @@ -20,6 +24,8 @@ class StaticMeshFBXLoader(plugin.Loader): icon = "cube" color = "orange" + root = AYON_ASSET_DIR + @staticmethod def get_task(filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() @@ -46,57 +52,22 @@ class StaticMeshFBXLoader(plugin.Loader): return task - def load(self, context, name, namespace, options): - """Load and containerise representation into Content Browser. - - This is two step process. First, import FBX to temporary path and - then call `containerise()` on it - this moves all content to new - directory and then it will create AssetContainer there and imprint it - with metadata. This will mark this path as container. - - Args: - context (dict): application context - name (str): subset name - namespace (str): in Unreal this is basically path to container. - This is not passed here, so namespace is set - by `containerise()` because only then we know - real path. - options (dict): Those would be data to be imprinted. This is not - used now, data are imprinted by `containerise()`. - - Returns: - list(str): list of container content - """ - - # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" - if options and options.get("asset_dir"): - root = options["asset_dir"] - asset = context.get('asset').get('name') - suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) - else: - asset_name = "{}".format(name) - - tools = unreal.AssetToolsHelpers().get_asset_tools() - asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}", suffix="" - ) - - container_name += suffix - + def import_and_containerize( + self, filepath, asset_dir, asset_name, container_name + ): unreal.EditorAssetLibrary.make_directory(asset_dir) - path = self.filepath_from_context(context) - task = self.get_task(path, asset_dir, asset_name, False) + task = self.get_task( + filepath, asset_dir, asset_name, False) - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container - unreal_pipeline.create_container( - container=container_name, path=asset_dir) + create_container(container=container_name, path=asset_dir) + def imprint( + self, asset, asset_dir, container_name, asset_name, representation + ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, @@ -105,11 +76,54 @@ class StaticMeshFBXLoader(plugin.Loader): "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), - "representation": context["representation"]["_id"], - "parent": context["representation"]["parent"], - "family": context["representation"]["context"]["family"] + "representation": representation["_id"], + "parent": representation["parent"], + "family": representation["context"]["family"] } - unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) + imprint(f"{asset_dir}/{container_name}", data) + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + options (dict): Those would be data to be imprinted. + + Returns: + list(str): list of container content + """ + # Create directory for asset and Ayon container + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version.get('name'):03d}" + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="" + ) + + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = self.filepath_from_context(context) + + self.import_and_containerize( + path, asset_dir, asset_name, container_name) + + self.imprint( + asset, asset_dir, container_name, asset_name, + context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True @@ -121,27 +135,36 @@ class StaticMeshFBXLoader(plugin.Loader): return asset_content def update(self, container, representation): - name = container["asset_name"] - source_path = get_representation_path(representation) - destination_path = container["namespace"] + context = representation.get("context", {}) - task = self.get_task(source_path, destination_path, name, True) + if not context: + raise RuntimeError("No context found in representation") - # do import fbx and replace existing data - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + # Create directory for asset and Ayon container + asset = context.get('asset') + name = context.get('subset') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + # Check if version is hero version and use different name + name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{self.root}/{asset}/{name_version}", suffix="") - container_path = "{}/{}".format(container["namespace"], - container["objectName"]) - # update metadata - unreal_pipeline.imprint( - container_path, - { - "representation": str(representation["_id"]), - "parent": str(representation["parent"]) - }) + container_name += suffix + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + path = get_representation_path(representation) + + self.import_and_containerize( + path, asset_dir, asset_name, container_name) + + self.imprint( + asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( - destination_path, recursive=True, include_folder=True + asset_dir, recursive=True, include_folder=False ) for a in asset_content: diff --git a/openpype/hosts/unreal/plugins/load/load_uasset.py b/openpype/hosts/unreal/plugins/load/load_uasset.py index 88aaac41e8..dfd92d2fe5 100644 --- a/openpype/hosts/unreal/plugins/load/load_uasset.py +++ b/openpype/hosts/unreal/plugins/load/load_uasset.py @@ -41,7 +41,7 @@ class UAssetLoader(plugin.Loader): """ # Create directory for asset and Ayon container - root = "/Game/Ayon/Assets" + root = unreal_pipeline.AYON_ASSET_DIR asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" diff --git a/openpype/hosts/unreal/plugins/load/load_yeticache.py b/openpype/hosts/unreal/plugins/load/load_yeticache.py new file mode 100644 index 0000000000..780ed7c484 --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_yeticache.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +"""Loader for Yeti Cache.""" +import os +import json + +from openpype.pipeline import ( + get_representation_path, + AYON_CONTAINER_ID +) +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa + + +class YetiLoader(plugin.Loader): + """Load Yeti Cache""" + + families = ["yeticacheUE"] + label = "Import Yeti" + representations = ["abc"] + icon = "pagelines" + color = "orange" + + @staticmethod + def get_task(filename, asset_dir, asset_name, replace): + task = unreal.AssetImportTask() + options = unreal.AbcImportSettings() + + task.set_editor_property('filename', filename) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', replace) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + task.options = options + + return task + + @staticmethod + def is_groom_module_active(): + """ + Check if Groom plugin is active. + + This is a workaround, because the Unreal python API don't have + any method to check if plugin is active. + """ + prj_file = unreal.Paths.get_project_file_path() + + with open(prj_file, "r") as fp: + data = json.load(fp) + + plugins = data.get("Plugins") + + if not plugins: + return False + + plugin_names = [p.get("Name") for p in plugins] + + return "HairStrands" in plugin_names + + def load(self, context, name, namespace, options): + """Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + + """ + # Check if Groom plugin is active + if not self.is_groom_module_active(): + raise RuntimeError("Groom plugin is not activated.") + + # Create directory for asset and Ayon container + root = unreal_pipeline.AYON_ASSET_DIR + asset = context.get('asset').get('name') + suffix = "_CON" + asset_name = f"{asset}_{name}" if asset else f"{name}" + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + f"{root}/{asset}/{name}", suffix="") + + unique_number = 1 + while unreal.EditorAssetLibrary.does_directory_exist( + f"{asset_dir}_{unique_number:02}" + ): + unique_number += 1 + + asset_dir = f"{asset_dir}_{unique_number:02}" + container_name = f"{container_name}_{unique_number:02}{suffix}" + + if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): + unreal.EditorAssetLibrary.make_directory(asset_dir) + + path = self.filepath_from_context(context) + task = self.get_task(path, asset_dir, asset_name, False) + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + + # Create Asset Container + unreal_pipeline.create_container( + container=container_name, path=asset_dir) + + data = { + "schema": "ayon:container-2.0", + "id": AYON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) + + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + name = container["asset_name"] + source_path = get_representation_path(representation) + destination_path = container["namespace"] + + task = self.get_task(source_path, destination_path, name, True) + + # do import fbx and replace existing data + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + container_path = f'{container["namespace"]}/{container["objectName"]}' + # update metadata + unreal_pipeline.imprint( + container_path, + { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + }) + + asset_content = unreal.EditorAssetLibrary.list_assets( + destination_path, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + def remove(self, container): + path = container["namespace"] + parent_path = os.path.dirname(path) + + unreal.EditorAssetLibrary.delete_directory(path) + + asset_content = unreal.EditorAssetLibrary.list_assets( + parent_path, recursive=False + ) + + if len(asset_content) == 0: + unreal.EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/publish/extract_uasset.py b/openpype/hosts/unreal/plugins/publish/extract_uasset.py index 48b62faa97..0dd7ff4a0d 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_uasset.py +++ b/openpype/hosts/unreal/plugins/publish/extract_uasset.py @@ -19,9 +19,8 @@ class ExtractUAsset(publish.Extractor): "umap" if "umap" in instance.data.get("families") else "uasset") ar = unreal.AssetRegistryHelpers.get_asset_registry() - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") staging_dir = self.staging_dir(instance) - filename = f"{instance.name}.{extension}" members = instance.data.get("members", []) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 76bb25fac3..06acbf0992 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -1,6 +1,9 @@ import clique +import os +import re import pyblish.api +from openpype.pipeline.publish import PublishValidationError class ValidateSequenceFrames(pyblish.api.InstancePlugin): @@ -21,22 +24,56 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): representations = instance.data.get("representations") for repr in representations: data = instance.data.get("assetEntity", {}).get("data", {}) - patterns = [clique.PATTERNS["frames"]] + repr_files = repr["files"] + if isinstance(repr_files, str): + continue + + ext = repr.get("ext") + if not ext: + _, ext = os.path.splitext(repr_files[0]) + elif not ext.startswith("."): + ext = ".{}".format(ext) + pattern = r"\D?(?P(?P0*)\d+){}$".format( + re.escape(ext)) + patterns = [pattern] + collections, remainder = clique.assemble( repr["files"], minimum_items=1, patterns=patterns) - assert not remainder, "Must not have remainder" - assert len(collections) == 1, "Must detect single collection" + if remainder: + raise PublishValidationError( + "Some files have been found outside a sequence. " + f"Invalid files: {remainder}") + if not collections: + raise PublishValidationError( + "We have been unable to find a sequence in the " + "files. Please ensure the files are named " + "appropriately. " + f"Files: {repr_files}") + if len(collections) > 1: + raise PublishValidationError( + "Multiple collections detected. There should be a single " + "collection per representation. " + f"Collections identified: {collections}") + collection = collections[0] frames = list(collection.indexes) + if instance.data.get("slate"): + # Slate is not part of the frame range + frames = frames[1:] + current_range = (frames[0], frames[-1]) required_range = (data["clipIn"], data["clipOut"]) if current_range != required_range: - raise ValueError(f"Invalid frame range: {current_range} - " - f"expected: {required_range}") + raise PublishValidationError( + f"Invalid frame range: {current_range} - " + f"expected: {required_range}") missing = collection.holes().indexes - assert not missing, "Missing frames: %s" % (missing,) + if missing: + raise PublishValidationError( + "Missing frames have been detected. " + f"Missing frames: {missing}") diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 1416255083..6bb67ef260 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -156,8 +156,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): self.log.debug("frameEnd:: {}".format( instance.data["frameEnd"])) except Exception: - self.log.warning("Unable to count frames " - "duration {}".format(no_of_frames)) + self.log.warning("Unable to count frames duration.") instance.data["handleStart"] = asset_doc["data"]["handleStart"] instance.data["handleEnd"] = asset_doc["data"]["handleEnd"] diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index e56f245d27..20d585e906 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -280,13 +280,14 @@ class BatchPublishEndpoint(WebpublishApiEndpoint): for key, value in add_args.items(): # Skip key values where value is None - if value is not None: - args.append("--{}".format(key)) - # Extend list into arguments (targets can be a list) - if isinstance(value, (tuple, list)): - args.extend(value) - else: - args.append(value) + if value is None: + continue + arg_key = "--{}".format(key) + if not isinstance(value, (tuple, list)): + value = [value] + + for item in value: + args += [arg_key, item] log.info("args:: {}".format(args)) if add_to_queue: diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index ff5e27c122..4d75a01e1d 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -12,6 +12,7 @@ from abc import ABCMeta, abstractmethod import six from openpype import AYON_SERVER_ENABLED, PACKAGE_DIR +from openpype.client import get_asset_name_identifier from openpype.settings import ( get_system_settings, get_project_settings, @@ -1728,7 +1729,9 @@ def prepare_context_environments(data, env_group=None, modules_manager=None): "AVALON_APP_NAME": app.full_name } if asset_doc: - context_env["AVALON_ASSET"] = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) + context_env["AVALON_ASSET"] = asset_name + if task_name: context_env["AVALON_TASK"] = task_name diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 6054d2a92a..3dd284b8e4 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -237,8 +237,13 @@ class UISeparatorDef(UIDef): class UILabelDef(UIDef): type = "label" - def __init__(self, label): - super(UILabelDef, self).__init__(label=label) + def __init__(self, label, key=None): + super(UILabelDef, self).__init__(label=label, key=key) + + def __eq__(self, other): + if not super(UILabelDef, self).__eq__(other): + return False + return self.label == other.label # --------------------------------------- @@ -424,17 +429,25 @@ class TextDef(AbstractAttrDef): class EnumDef(AbstractAttrDef): - """Enumeration of single item from items. + """Enumeration of items. + + Enumeration of single item from items. Or list of items if multiselection + is enabled. Args: - items: Items definition that can be converted using - 'prepare_enum_items'. - default: Default value. Must be one key(value) from passed items. + items (Union[list[str], list[dict[str, Any]]): Items definition that + can be converted using 'prepare_enum_items'. + default (Optional[Any]): Default value. Must be one key(value) from + passed items or list of values for multiselection. + multiselection (Optional[bool]): If True, multiselection is allowed. + Output is list of selected items. """ type = "enum" - def __init__(self, key, items, default=None, **kwargs): + def __init__( + self, key, items, default=None, multiselection=False, **kwargs + ): if not items: raise ValueError(( "Empty 'items' value. {} must have" @@ -443,30 +456,44 @@ class EnumDef(AbstractAttrDef): items = self.prepare_enum_items(items) item_values = [item["value"] for item in items] - if default not in item_values: - for value in item_values: - default = value - break + item_values_set = set(item_values) + if multiselection: + if default is None: + default = [] + default = list(item_values_set.intersection(default)) + + elif default not in item_values: + default = next(iter(item_values), None) super(EnumDef, self).__init__(key, default=default, **kwargs) self.items = items - self._item_values = set(item_values) + self._item_values = item_values_set + self.multiselection = multiselection def __eq__(self, other): if not super(EnumDef, self).__eq__(other): return False - return self.items == other.items + return ( + self.items == other.items + and self.multiselection == other.multiselection + ) def convert_value(self, value): - if value in self._item_values: - return value - return self.default + if not self.multiselection: + if value in self._item_values: + return value + return self.default + + if value is None: + return copy.deepcopy(self.default) + return list(self._item_values.intersection(value)) def serialize(self): data = super(EnumDef, self).serialize() data["items"] = copy.deepcopy(self.items) + data["multiselection"] = self.multiselection return data @staticmethod diff --git a/openpype/lib/events.py b/openpype/lib/events.py index dca58fcf93..496b765a05 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -3,6 +3,7 @@ import os import re import copy import inspect +import collections import logging import weakref from uuid import uuid4 @@ -340,8 +341,8 @@ class EventSystem(object): event.emit() return event - def emit_event(self, event): - """Emit event object. + def _process_event(self, event): + """Process event topic and trigger callbacks. Args: event (Event): Prepared event with topic and data. @@ -356,6 +357,91 @@ class EventSystem(object): for callback in invalid_callbacks: self._registered_callbacks.remove(callback) + def emit_event(self, event): + """Emit event object. + + Args: + event (Event): Prepared event with topic and data. + """ + + self._process_event(event) + + +class QueuedEventSystem(EventSystem): + """Events are automatically processed in queue. + + If callback triggers another event, the event is not processed until + all callbacks of previous event are processed. + + Allows to implement custom event process loop by changing 'auto_execute'. + + Note: + This probably should be default behavior of 'EventSystem'. Changing it + now could cause problems in existing code. + + Args: + auto_execute (Optional[bool]): If 'True', events are processed + automatically. Custom loop calling 'process_next_event' + must be implemented when set to 'False'. + """ + + def __init__(self, auto_execute=True): + super(QueuedEventSystem, self).__init__() + self._event_queue = collections.deque() + self._current_event = None + self._auto_execute = auto_execute + + def __len__(self): + return self.count() + + def count(self): + """Get number of events in queue. + + Returns: + int: Number of events in queue. + """ + + return len(self._event_queue) + + def process_next_event(self): + """Process next event in queue. + + Should be used only if 'auto_execute' is set to 'False'. Only single + event is processed. + + Returns: + Union[Event, None]: Processed event. + """ + + if self._current_event is not None: + raise ValueError("An event is already in progress.") + + if not self._event_queue: + return None + event = self._event_queue.popleft() + self._current_event = event + self._process_event(event) + self._current_event = None + return event + + def emit_event(self, event): + """Emit event object. + + Args: + event (Event): Prepared event with topic and data. + """ + + if not self._auto_execute or self._current_event is not None: + self._event_queue.append(event) + return + + self._event_queue.append(event) + while self._event_queue: + event = self._event_queue.popleft() + self._current_event = event + self._process_event(event) + self._current_event = None + class GlobalEventSystem: """Event system living in global scope of process. diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 3fb35a7e7b..ea42d2f0b5 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -36,6 +36,7 @@ from openpype.settings import ( ) from openpype.client.mongo import validate_mongo_connection +from openpype.client import get_ayon_server_api_connection _PLACEHOLDER = object() @@ -494,10 +495,18 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry): """ def __init__(self, name=None): - self.vendor = "pypeclub" - self.product = "openpype" + if AYON_SERVER_ENABLED: + vendor = "Ynput" + product = "AYON" + default_name = "AYON_settings" + else: + vendor = "pypeclub" + product = "openpype" + default_name = "openpype_settings" + self.vendor = vendor + self.product = product if not name: - name = "openpype_settings" + name = default_name path = appdirs.user_data_dir(self.product, self.vendor) super(OpenPypeSettingsRegistry, self).__init__(name, path) @@ -603,6 +612,11 @@ def get_openpype_username(): settings and last option is to use `getpass.getuser()` which returns machine username. """ + + if AYON_SERVER_ENABLED: + con = get_ayon_server_api_connection() + return con.get_user()["name"] + username = os.environ.get("OPENPYPE_USERNAME") if not username: local_settings = get_local_settings() diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index a10263f991..bedf19562d 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -270,8 +270,8 @@ def is_func_signature_supported(func, *args, **kwargs): Args: func (function): A function where the signature should be tested. - *args (tuple[Any]): Positional arguments for function signature. - **kwargs (dict[str, Any]): Keyword arguments for function signature. + *args (Any): Positional arguments for function signature. + **kwargs (Any): Keyword arguments for function signature. Returns: bool: Function can pass in arguments. diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 2bae28786e..334ea25ea4 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -315,6 +315,92 @@ def parse_oiio_xml_output(xml_string, logger=None): return output +def get_review_info_by_layer_name(channel_names): + """Get channels info grouped by layer name. + + Finds all layers in channel names and returns list of dictionaries with + information about channels in layer. + Example output (not real world example): + [ + { + "name": "Main", + "review_channels": { + "R": "Main.red", + "G": "Main.green", + "B": "Main.blue", + "A": None, + } + }, + { + "name": "Composed", + "review_channels": { + "R": "Composed.R", + "G": "Composed.G", + "B": "Composed.B", + "A": "Composed.A", + } + }, + ... + ] + + Args: + channel_names (list[str]): List of channel names. + + Returns: + list[dict]: List of channels information. + """ + + layer_names_order = [] + rgba_by_layer_name = collections.defaultdict(dict) + channels_by_layer_name = collections.defaultdict(dict) + + for channel_name in channel_names: + layer_name = "" + last_part = channel_name + if "." in channel_name: + layer_name, last_part = channel_name.rsplit(".", 1) + + channels_by_layer_name[layer_name][channel_name] = last_part + if last_part.lower() not in { + "r", "red", + "g", "green", + "b", "blue", + "a", "alpha" + }: + continue + + if layer_name not in layer_names_order: + layer_names_order.append(layer_name) + # R, G, B or A + channel = last_part[0].upper() + rgba_by_layer_name[layer_name][channel] = channel_name + + # Put empty layer to the beginning of the list + # - if input has R, G, B, A channels they should be used for review + if "" in layer_names_order: + layer_names_order.remove("") + layer_names_order.insert(0, "") + + output = [] + for layer_name in layer_names_order: + rgba_layer_info = rgba_by_layer_name[layer_name] + red = rgba_layer_info.get("R") + green = rgba_layer_info.get("G") + blue = rgba_layer_info.get("B") + if not red or not green or not blue: + continue + output.append({ + "name": layer_name, + "review_channels": { + "R": red, + "G": green, + "B": blue, + "A": rgba_layer_info.get("A"), + } + }) + return output + + def get_convert_rgb_channels(channel_names): """Get first available RGB(A) group from channels info. @@ -323,7 +409,7 @@ def get_convert_rgb_channels(channel_names): # Ideal situation channels_info: [ "R", "G", "B", "A" - } + ] ``` Result will be `("R", "G", "B", "A")` @@ -331,50 +417,60 @@ def get_convert_rgb_channels(channel_names): # Not ideal situation channels_info: [ "beauty.red", - "beuaty.green", + "beauty.green", "beauty.blue", "depth.Z" ] ``` Result will be `("beauty.red", "beauty.green", "beauty.blue", None)` + Args: + channel_names (list[str]): List of channel names. + Returns: - NoneType: There is not channel combination that matches RGB - combination. - tuple: Tuple of 4 channel names defying channel names for R, G, B, A - where A can be None. + Union[NoneType, tuple[str, str, str, Union[str, None]]]: Tuple of + 4 channel names defying channel names for R, G, B, A or None + if there is not any layer with RGB combination. """ - rgb_by_main_name = collections.defaultdict(dict) - main_name_order = [""] - for channel_name in channel_names: - name_parts = channel_name.split(".") - rgb_part = name_parts.pop(-1).lower() - main_name = ".".join(name_parts) - if rgb_part in ("r", "red"): - rgb_by_main_name[main_name]["R"] = channel_name - elif rgb_part in ("g", "green"): - rgb_by_main_name[main_name]["G"] = channel_name - elif rgb_part in ("b", "blue"): - rgb_by_main_name[main_name]["B"] = channel_name - elif rgb_part in ("a", "alpha"): - rgb_by_main_name[main_name]["A"] = channel_name - else: - continue - if main_name not in main_name_order: - main_name_order.append(main_name) - output = None - for main_name in main_name_order: - colors = rgb_by_main_name.get(main_name) or {} - red = colors.get("R") - green = colors.get("G") - blue = colors.get("B") - alpha = colors.get("A") - if red is not None and green is not None and blue is not None: - output = (red, green, blue, alpha) - break + channels_info = get_review_info_by_layer_name(channel_names) + for item in channels_info: + review_channels = item["review_channels"] + return ( + review_channels["R"], + review_channels["G"], + review_channels["B"], + review_channels["A"] + ) + return None - return output + +def get_review_layer_name(src_filepath): + """Find layer name that could be used for review. + + Args: + src_filepath (str): Path to input file. + + Returns: + Union[str, None]: Layer name of None. + """ + + ext = os.path.splitext(src_filepath)[-1].lower() + if ext != ".exr": + return None + + # Load info about file from oiio tool + input_info = get_oiio_info_for_input(src_filepath) + if not input_info: + return None + + channel_names = input_info["channelnames"] + channels_info = get_review_info_by_layer_name(channel_names) + for item in channels_info: + # Layer name can be '', when review channels are 'R', 'G', 'B' + # without layer + return item["name"] or None + return None def should_convert_for_ffmpeg(src_filepath): @@ -395,7 +491,7 @@ def should_convert_for_ffmpeg(src_filepath): if not is_oiio_supported(): return None - # Load info about info from oiio tool + # Load info about file from oiio tool input_info = get_oiio_info_for_input(src_filepath) if not input_info: return None @@ -440,7 +536,7 @@ def convert_for_ffmpeg( input_frame_end=None, logger=None ): - """Contert source file to format supported in ffmpeg. + """Convert source file to format supported in ffmpeg. Currently can convert only exrs. @@ -496,29 +592,7 @@ def convert_for_ffmpeg( oiio_cmd.extend(["--compression", compression]) # Collect channels to export - channel_names = input_info["channelnames"] - review_channels = get_convert_rgb_channels(channel_names) - if review_channels is None: - raise ValueError( - "Couldn't find channels that can be used for conversion." - ) - - red, green, blue, alpha = review_channels - input_channels = [red, green, blue] - channels_arg = "R={},G={},B={}".format(red, green, blue) - if alpha is not None: - channels_arg += ",A={}".format(alpha) - input_channels.append(alpha) - input_channels_str = ",".join(input_channels) - - subimages = input_info.get("subimages") - input_arg = "-i" - if subimages is None or subimages == 1: - # Tell oiiotool which channels should be loaded - # - other channels are not loaded to memory so helps to avoid memory - # leak issues - # - this option is crashing if used on multipart/subimages exrs - input_arg += ":ch={}".format(input_channels_str) + input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) oiio_cmd.extend([ input_arg, first_input_path, @@ -539,7 +613,7 @@ def convert_for_ffmpeg( continue # Remove attributes that have string value longer than allowed length - # for ffmpeg or when contain unallowed symbols + # for ffmpeg or when contain prohibited symbols erase_reason = "Missing reason" erase_attribute = False if len(attr_value) > MAX_FFMPEG_STRING_LEN: @@ -581,6 +655,47 @@ def convert_for_ffmpeg( run_subprocess(oiio_cmd, logger=logger) +def get_oiio_input_and_channel_args(oiio_input_info): + """Get input and channel arguments for oiiotool. + + Args: + oiio_input_info (dict): Information about input from oiio tool. + Should be output of function `get_oiio_info_for_input`. + + Returns: + tuple[str, str]: Tuple of input and channel arguments. + """ + channel_names = oiio_input_info["channelnames"] + review_channels = get_convert_rgb_channels(channel_names) + + if review_channels is None: + raise ValueError( + "Couldn't find channels that can be used for conversion." + ) + + red, green, blue, alpha = review_channels + input_channels = [red, green, blue] + + # TODO find subimage where rgba is available for multipart exrs + channels_arg = "R={},G={},B={}".format(red, green, blue) + if alpha is not None: + channels_arg += ",A={}".format(alpha) + input_channels.append(alpha) + + input_channels_str = ",".join(input_channels) + + subimages = oiio_input_info.get("subimages") + input_arg = "-i" + if subimages is None or subimages == 1: + # Tell oiiotool which channels should be loaded + # - other channels are not loaded to memory so helps to avoid memory + # leak issues + # - this option is crashing if used on multipart exrs + input_arg += ":ch={}".format(input_channels_str) + + return input_arg, channels_arg + + def convert_input_paths_for_ffmpeg( input_paths, output_dir, @@ -599,7 +714,7 @@ def convert_input_paths_for_ffmpeg( Args: input_paths (str): Paths that should be converted. It is expected that - contains single file or image sequence of samy type. + contains single file or image sequence of same type. output_dir (str): Path to directory where output will be rendered. Must not be same as input's directory. logger (logging.Logger): Logger used for logging. @@ -613,6 +728,7 @@ def convert_input_paths_for_ffmpeg( first_input_path = input_paths[0] ext = os.path.splitext(first_input_path)[1].lower() + if ext != ".exr": raise ValueError(( "Function 'convert_for_ffmpeg' currently support only" @@ -628,30 +744,7 @@ def convert_input_paths_for_ffmpeg( compression = "none" # Collect channels to export - channel_names = input_info["channelnames"] - review_channels = get_convert_rgb_channels(channel_names) - if review_channels is None: - raise ValueError( - "Couldn't find channels that can be used for conversion." - ) - - red, green, blue, alpha = review_channels - input_channels = [red, green, blue] - # TODO find subimage inder where rgba is available for multipart exrs - channels_arg = "R={},G={},B={}".format(red, green, blue) - if alpha is not None: - channels_arg += ",A={}".format(alpha) - input_channels.append(alpha) - input_channels_str = ",".join(input_channels) - - subimages = input_info.get("subimages") - input_arg = "-i" - if subimages is None or subimages == 1: - # Tell oiiotool which channels should be loaded - # - other channels are not loaded to memory so helps to avoid memory - # leak issues - # - this option is crashing if used on multipart exrs - input_arg += ":ch={}".format(input_channels_str) + input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) for input_path in input_paths: # Prepare subprocess arguments @@ -678,7 +771,7 @@ def convert_input_paths_for_ffmpeg( continue # Remove attributes that have string value longer than allowed - # length for ffmpeg or when containing unallowed symbols + # length for ffmpeg or when containing prohibited symbols erase_reason = "Missing reason" erase_attribute = False if len(attr_value) > MAX_FFMPEG_STRING_LEN: @@ -724,7 +817,7 @@ def get_ffprobe_data(path_to_file, logger=None): """ if not logger: logger = logging.getLogger(__name__) - logger.info( + logger.debug( "Getting information about input \"{}\".".format(path_to_file) ) ffprobe_args = get_ffmpeg_tool_args("ffprobe") @@ -925,9 +1018,7 @@ def _ffmpeg_h264_codec_args(stream_data, source_ffmpeg_cmd): if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) - output.extend(["-intra"]) - output.extend(["-g", "1"]) - + output.extend(["-intra", "-g", "1"]) return output @@ -1054,7 +1145,7 @@ def convert_colorspace( view=None, display=None, additional_command_args=None, - logger=None + logger=None, ): """Convert source file from one color space to another. @@ -1073,6 +1164,7 @@ def convert_colorspace( view (str): name for viewer space (ocio valid) both 'view' and 'display' must be filled (if 'target_colorspace') display (str): name for display-referred reference space (ocio valid) + both 'view' and 'display' must be filled (if 'target_colorspace') additional_command_args (list): arguments for oiiotool (like binary depth for .dpx) logger (logging.Logger): Logger used for logging. @@ -1082,14 +1174,28 @@ def convert_colorspace( if logger is None: logger = logging.getLogger(__name__) + input_info = get_oiio_info_for_input(input_path, logger=logger) + + # Collect channels to export + input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) + + # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", - input_path, # Don't add any additional attributes "--nosoftwareattrib", "--colorconfig", config_path ) + oiio_cmd.extend([ + input_arg, input_path, + # Tell oiiotool which channels should be put to top stack + # (and output) + "--ch", channels_arg, + # Use first subimage + "--subimage", "0" + ]) + if all([target_colorspace, view, display]): raise ValueError("Colorspace and both screen and display" " cannot be set together." diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index 1f345feea9..3097805353 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -10,6 +10,7 @@ from .interfaces import ( ) from .base import ( + AYONAddon, OpenPypeModule, OpenPypeAddOn, @@ -35,6 +36,7 @@ __all__ = ( "ISettingsChangeListener", "IHostAddon", + "AYONAddon", "OpenPypeModule", "OpenPypeAddOn", diff --git a/openpype/modules/asset_reporter/__init__.py b/openpype/modules/asset_reporter/__init__.py new file mode 100644 index 0000000000..6267b4824b --- /dev/null +++ b/openpype/modules/asset_reporter/__init__.py @@ -0,0 +1,8 @@ +from .module import ( + AssetReporterAction +) + + +__all__ = ( + "AssetReporterAction", +) diff --git a/openpype/modules/asset_reporter/module.py b/openpype/modules/asset_reporter/module.py new file mode 100644 index 0000000000..8c754cc3c0 --- /dev/null +++ b/openpype/modules/asset_reporter/module.py @@ -0,0 +1,27 @@ +import os.path + +from openpype import AYON_SERVER_ENABLED +from openpype.modules import OpenPypeModule, ITrayAction +from openpype.lib import run_detached_process, get_openpype_execute_args + + +class AssetReporterAction(OpenPypeModule, ITrayAction): + + label = "Asset Usage Report" + name = "asset_reporter" + + def tray_init(self): + pass + + def initialize(self, modules_settings): + self.enabled = not AYON_SERVER_ENABLED + + def on_action_trigger(self): + args = get_openpype_execute_args() + args += ["run", + os.path.join( + os.path.dirname(__file__), + "window.py")] + + print(" ".join(args)) + run_detached_process(args) diff --git a/openpype/modules/asset_reporter/window.py b/openpype/modules/asset_reporter/window.py new file mode 100644 index 0000000000..ed3bc298e1 --- /dev/null +++ b/openpype/modules/asset_reporter/window.py @@ -0,0 +1,418 @@ +"""Tool for generating asset usage report. + +This tool is used to generate asset usage report for a project. +It is using links between published version to find out where +the asset is used. + +""" + +import csv +import time + +import appdirs +import qtawesome +from pymongo.collection import Collection +from qtpy import QtCore, QtWidgets +from qtpy.QtGui import QClipboard, QColor + +from openpype import style +from openpype.client import OpenPypeMongoConnection +from openpype.lib import JSONSettingRegistry +from openpype.tools.utils import PlaceholderLineEdit, get_openpype_qt_app +from openpype.tools.utils.constants import PROJECT_NAME_ROLE +from openpype.tools.utils.models import ProjectModel, ProjectSortFilterProxy + + +class AssetReporterRegistry(JSONSettingRegistry): + """Class handling OpenPype general settings registry. + + This is used to store last selected project. + + Attributes: + vendor (str): Name used for path construction. + product (str): Additional name used for path construction. + + """ + + def __init__(self): + self.vendor = "ynput" + self.product = "openpype" + name = "asset_usage_reporter" + path = appdirs.user_data_dir(self.product, self.vendor) + super(AssetReporterRegistry, self).__init__(name, path) + + +class OverlayWidget(QtWidgets.QFrame): + """Overlay widget for choosing project. + + This code is taken from the Tray Publisher tool. + """ + project_selected = QtCore.Signal(str) + + def __init__(self, publisher_window): + super(OverlayWidget, self).__init__(publisher_window) + self.setObjectName("OverlayFrame") + + middle_frame = QtWidgets.QFrame(self) + middle_frame.setObjectName("ChooseProjectFrame") + + content_widget = QtWidgets.QWidget(middle_frame) + + header_label = QtWidgets.QLabel("Choose project", content_widget) + header_label.setObjectName("ChooseProjectLabel") + # Create project models and view + projects_model = ProjectModel() + projects_proxy = ProjectSortFilterProxy() + projects_proxy.setSourceModel(projects_model) + projects_proxy.setFilterKeyColumn(0) + + projects_view = QtWidgets.QListView(content_widget) + projects_view.setObjectName("ChooseProjectView") + projects_view.setModel(projects_proxy) + projects_view.setEditTriggers( + QtWidgets.QAbstractItemView.NoEditTriggers + ) + + confirm_btn = QtWidgets.QPushButton("Confirm", content_widget) + cancel_btn = QtWidgets.QPushButton("Cancel", content_widget) + cancel_btn.setVisible(False) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(cancel_btn, 0) + btns_layout.addWidget(confirm_btn, 0) + + txt_filter = PlaceholderLineEdit(content_widget) + txt_filter.setPlaceholderText("Quick filter projects..") + txt_filter.setClearButtonEnabled(True) + txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"), + QtWidgets.QLineEdit.LeadingPosition) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(20) + content_layout.addWidget(header_label, 0) + content_layout.addWidget(txt_filter, 0) + content_layout.addWidget(projects_view, 1) + content_layout.addLayout(btns_layout, 0) + + middle_layout = QtWidgets.QHBoxLayout(middle_frame) + middle_layout.setContentsMargins(30, 30, 10, 10) + middle_layout.addWidget(content_widget) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addStretch(1) + main_layout.addWidget(middle_frame, 2) + main_layout.addStretch(1) + + projects_view.doubleClicked.connect(self._on_double_click) + confirm_btn.clicked.connect(self._on_confirm_click) + cancel_btn.clicked.connect(self._on_cancel_click) + txt_filter.textChanged.connect(self._on_text_changed) + + self._projects_view = projects_view + self._projects_model = projects_model + self._projects_proxy = projects_proxy + self._cancel_btn = cancel_btn + self._confirm_btn = confirm_btn + self._txt_filter = txt_filter + + self._publisher_window = publisher_window + self._project_name = None + + def showEvent(self, event): + self._projects_model.refresh() + # Sort projects after refresh + self._projects_proxy.sort(0) + + setting_registry = AssetReporterRegistry() + try: + project_name = str(setting_registry.get_item("project_name")) + except ValueError: + project_name = None + + if project_name: + index = None + src_index = self._projects_model.find_project(project_name) + if src_index is not None: + index = self._projects_proxy.mapFromSource(src_index) + + if index is not None: + selection_model = self._projects_view.selectionModel() + selection_model.select( + index, + QtCore.QItemSelectionModel.SelectCurrent + ) + self._projects_view.setCurrentIndex(index) + + self._cancel_btn.setVisible(self._project_name is not None) + super(OverlayWidget, self).showEvent(event) + + def _on_double_click(self): + self.set_selected_project() + + def _on_confirm_click(self): + self.set_selected_project() + + def _on_cancel_click(self): + self._set_project(self._project_name) + + def _on_text_changed(self): + self._projects_proxy.setFilterRegularExpression( + self._txt_filter.text()) + + def set_selected_project(self): + index = self._projects_view.currentIndex() + + if project_name := index.data(PROJECT_NAME_ROLE): + self._set_project(project_name) + + def _set_project(self, project_name): + self._project_name = project_name + self.setVisible(False) + self.project_selected.emit(project_name) + + setting_registry = AssetReporterRegistry() + setting_registry.set_item("project_name", project_name) + + +class AssetReporterWindow(QtWidgets.QDialog): + default_width = 1000 + default_height = 800 + _content = None + + def __init__(self, parent=None, controller=None, reset_on_show=None): + super(AssetReporterWindow, self).__init__(parent) + + self._result = {} + self.setObjectName("AssetReporterWindow") + + self.setWindowTitle("Asset Usage Reporter") + + if parent is None: + on_top_flag = QtCore.Qt.WindowStaysOnTopHint + else: + on_top_flag = QtCore.Qt.Dialog + + self.setWindowFlags( + QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowMaximizeButtonHint + | QtCore.Qt.WindowMinimizeButtonHint + | QtCore.Qt.WindowCloseButtonHint + | on_top_flag + ) + self.table = QtWidgets.QTableWidget(self) + self.table.setColumnCount(3) + self.table.setColumnWidth(0, 400) + self.table.setColumnWidth(1, 300) + self.table.setHorizontalHeaderLabels(["Subset", "Used in", "Version"]) + + # self.text_area = QtWidgets.QTextEdit(self) + self.copy_button = QtWidgets.QPushButton('Copy to Clipboard', self) + self.save_button = QtWidgets.QPushButton('Save to CSV File', self) + + self.copy_button.clicked.connect(self.copy_to_clipboard) + self.save_button.clicked.connect(self.save_to_file) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.table) + # layout.addWidget(self.text_area) + layout.addWidget(self.copy_button) + layout.addWidget(self.save_button) + + self.resize(self.default_width, self.default_height) + self.setStyleSheet(style.load_stylesheet()) + + overlay_widget = OverlayWidget(self) + overlay_widget.project_selected.connect(self._on_project_select) + self._overlay_widget = overlay_widget + + def _on_project_select(self, project_name: str): + """Generate table when project is selected. + + This will generate the table and fill it with data. + Source data are held in memory in `_result` attribute that + is used to transform them into clipboard or csv file. + """ + self._project_name = project_name + self.process() + if not self._result: + self.set_content("no result generated") + return + + rows = sum(len(value) for key, value in self._result.items()) + self.table.setRowCount(rows) + + row = 0 + content = [] + for key, value in self._result.items(): + item = QtWidgets.QTableWidgetItem(key) + # this doesn't work as it is probably overriden by stylesheet? + # item.setBackground(QColor(32, 32, 32)) + self.table.setItem(row, 0, item) + for source in value: + self.table.setItem( + row, 1, QtWidgets.QTableWidgetItem(source["name"])) + self.table.setItem( + row, 2, QtWidgets.QTableWidgetItem( + str(source["version"]))) + row += 1 + + # generate clipboard content + content.append(key) + content.extend( + f"\t{source['name']} (v{source['version']})" for source in value # noqa: E501 + ) + self.set_content("\n".join(content)) + + def copy_to_clipboard(self): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._content, QClipboard.Clipboard) + + def save_to_file(self): + file_name, _ = QtWidgets.QFileDialog.getSaveFileName(self, 'Save File') + if file_name: + self._write_csv(file_name) + + def set_content(self, content): + self._content = content + + def get_content(self): + return self._content + + def _resize_overlay(self): + self._overlay_widget.resize( + self.width(), + self.height() + ) + + def resizeEvent(self, event): + super(AssetReporterWindow, self).resizeEvent(event) + self._resize_overlay() + + def _get_subset(self, version_id, project: Collection): + pipeline = [ + { + "$match": { + "_id": version_id + }, + }, { + "$lookup": { + "from": project.name, + "localField": "parent", + "foreignField": "_id", + "as": "parents" + } + } + ] + + result = project.aggregate(pipeline) + doc = next(result) + # print(doc) + return { + "name": f'{"/".join(doc["parents"][0]["data"]["parents"])}/{doc["parents"][0]["name"]}/{doc["name"]}', # noqa: E501 + "family": doc["data"].get("family") or doc["data"].get("families")[0] # noqa: E501 + } + + def process(self): + """Generate asset usage report data. + + This is the main method of the tool. It is using MongoDB + aggregation pipeline to find all published versions that + are used as input for other published versions. Then it + generates a map of assets and their usage. + + """ + start = time.perf_counter() + project = self._project_name + + # get all versions of published workfiles that has non-empty + # inputLinks and connect it with their respective documents + # using ID. + pipeline = [ + { + "$match": { + "data.inputLinks": { + "$exists": True, + "$ne": [] + }, + "data.families": {"$in": ["workfile"]} + } + }, { + "$lookup": { + "from": project, + "localField": "data.inputLinks.id", + "foreignField": "_id", + "as": "linked_docs" + } + } + ] + + client = OpenPypeMongoConnection.get_mongo_client() + db = client["avalon"] + + result = db[project].aggregate(pipeline) + + asset_map = [] + # this is creating the map - for every workfile and its linked + # documents, create a dictionary with "source" and "refs" keys + # and resolve the subset name and version from the document + for doc in result: + source = { + "source": self._get_subset(doc["parent"], db[project]), + } + source["source"].update({"version": doc["name"]}) + refs = [] + version = '' + for linked in doc["linked_docs"]: + try: + version = f'v{linked["name"]}' + except KeyError: + if linked["type"] == "hero_version": + version = "hero" + finally: + refs.append({ + "subset": self._get_subset( + linked["parent"], db[project]), + "version": version + }) + + source["refs"] = refs + asset_map.append(source) + + grouped = {} + + # this will group the assets by subset name and version + for asset in asset_map: + for ref in asset["refs"]: + key = f'{ref["subset"]["name"]} ({ref["version"]})' + if key in grouped: + grouped[key].append(asset["source"]) + else: + grouped[key] = [asset["source"]] + self._result = grouped + + end = time.perf_counter() + + print(f"Finished in {end - start:0.4f} seconds", 2) + + def _write_csv(self, file_name: str) -> None: + """Write CSV file with results.""" + with open(file_name, "w", newline="") as csvfile: + writer = csv.writer(csvfile, delimiter=";") + writer.writerow(["Subset", "Used in", "Version"]) + for key, value in self._result.items(): + writer.writerow([key, "", ""]) + for source in value: + writer.writerow(["", source["name"], source["version"]]) + + +def main(): + app_instance = get_openpype_qt_app() + window = AssetReporterWindow() + window.show() + app_instance.exec_() + + +if __name__ == "__main__": + main() diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index a0226ecc5c..57754793c4 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -1,5 +1,6 @@ import os +from openpype import AYON_SERVER_ENABLED from openpype.modules import OpenPypeModule, ITrayModule @@ -75,20 +76,11 @@ class AvalonModule(OpenPypeModule, ITrayModule): def show_library_loader(self): if self._library_loader_window is None: - from qtpy import QtCore - from openpype.tools.libraryloader import LibraryLoaderWindow from openpype.pipeline import install_openpype_plugins - - libraryloader = LibraryLoaderWindow( - show_projects=True, - show_libraries=True - ) - # Remove always on top flag for tray - window_flags = libraryloader.windowFlags() - if window_flags | QtCore.Qt.WindowStaysOnTopHint: - window_flags ^= QtCore.Qt.WindowStaysOnTopHint - libraryloader.setWindowFlags(window_flags) - self._library_loader_window = libraryloader + if AYON_SERVER_ENABLED: + self._init_ayon_loader() + else: + self._init_library_loader() install_openpype_plugins() @@ -106,3 +98,25 @@ class AvalonModule(OpenPypeModule, ITrayModule): if self.tray_initialized: from .rest_api import AvalonRestApiResource self.rest_api_obj = AvalonRestApiResource(self, server_manager) + + def _init_library_loader(self): + from qtpy import QtCore + from openpype.tools.libraryloader import LibraryLoaderWindow + + libraryloader = LibraryLoaderWindow( + show_projects=True, + show_libraries=True + ) + # Remove always on top flag for tray + window_flags = libraryloader.windowFlags() + if window_flags | QtCore.Qt.WindowStaysOnTopHint: + window_flags ^= QtCore.Qt.WindowStaysOnTopHint + libraryloader.setWindowFlags(window_flags) + self._library_loader_window = libraryloader + + def _init_ayon_loader(self): + from openpype.tools.ayon_loader.ui import LoaderWindow + + libraryloader = LoaderWindow() + + self._library_loader_window = libraryloader diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 9b3637c48a..1a2513b4b4 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Base class for Pype Modules.""" +"""Base class for AYON addons.""" import copy import os import sys @@ -11,14 +11,15 @@ import platform import threading import collections import traceback + from uuid import uuid4 from abc import ABCMeta, abstractmethod import six import appdirs -import ayon_api from openpype import AYON_SERVER_ENABLED +from openpype.client import get_ayon_server_api_connection from openpype.settings import ( get_system_settings, SYSTEM_SETTINGS_KEY, @@ -29,7 +30,11 @@ from openpype.settings import ( from openpype.settings.lib import ( get_studio_system_settings_overrides, - load_json_file + load_json_file, +) +from openpype.settings.ayon_settings import ( + is_dev_mode_enabled, + get_ayon_settings, ) from openpype.lib import ( @@ -37,7 +42,6 @@ from openpype.lib import ( import_filepath, import_module_from_dirpath, ) -from openpype.lib.openpype_version import is_staging_enabled from .interfaces import ( OpenPypeInterface, @@ -47,11 +51,11 @@ from .interfaces import ( ITrayService ) -# Files that will be always ignored on modules import +# Files that will be always ignored on addons import IGNORED_FILENAMES = ( "__pycache__", ) -# Files ignored on modules import from "./openpype/modules" +# Files ignored on addons import from "./openpype/modules" IGNORED_DEFAULT_FILENAMES = ( "__init__.py", "base.py", @@ -59,6 +63,19 @@ IGNORED_DEFAULT_FILENAMES = ( "example_addons", "default_modules", ) +# Addons that won't be loaded in AYON mode from "./openpype/modules" +# - the same addons are ignored in "./server_addon/create_ayon_addons.py" +IGNORED_FILENAMES_IN_AYON = { + "ftrack", + "shotgrid", + "sync_server", + "slack", + "kitsu", +} +IGNORED_HOSTS_IN_AYON = { + "flame", + "harmony", +} # Inherit from `object` for Python 2 hosts @@ -97,7 +114,7 @@ class _ModuleClass(object): if attr_name in self.__attributes__: self.log.warning( "Duplicated name \"{}\" in {}. Overriding.".format( - self.name, attr_name + attr_name, self.name ) ) self.__attributes__[attr_name] = value @@ -309,21 +326,13 @@ def load_modules(force=False): time.sleep(0.1) -def _get_ayon_addons_information(): - """Receive information about addons to use from server. +def _get_ayon_bundle_data(): + con = get_ayon_server_api_connection() + bundles = con.get_bundles()["bundles"] - Todos: - Actually ask server for the information. - Allow project name as optional argument to be able to query information - about used addons for specific project. - Returns: - List[Dict[str, Any]]: List of addon information to use. - """ - - output = [] bundle_name = os.getenv("AYON_BUNDLE_NAME") - bundles = ayon_api.get_bundles()["bundles"] - final_bundle = next( + + return next( ( bundle for bundle in bundles @@ -331,11 +340,24 @@ def _get_ayon_addons_information(): ), None ) - if final_bundle is None: - return output - bundle_addons = final_bundle["addons"] - addons = ayon_api.get_addons_info()["addons"] + +def _get_ayon_addons_information(bundle_info): + """Receive information about addons to use from server. + + Todos: + Actually ask server for the information. + Allow project name as optional argument to be able to query information + about used addons for specific project. + + Returns: + List[Dict[str, Any]]: List of addon information to use. + """ + + output = [] + bundle_addons = bundle_info["addons"] + con = get_ayon_server_api_connection() + addons = con.get_addons_info()["addons"] for addon in addons: name = addon["name"] versions = addon.get("versions") @@ -370,36 +392,77 @@ def _load_ayon_addons(openpype_modules, modules_key, log): v3_addons_to_skip = [] - addons_info = _get_ayon_addons_information() + bundle_info = _get_ayon_bundle_data() + addons_info = _get_ayon_addons_information(bundle_info) if not addons_info: return v3_addons_to_skip - addons_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "addons" - ) - if not os.path.exists(addons_dir): + + addons_dir = os.environ.get("AYON_ADDONS_DIR") + if not addons_dir: + addons_dir = os.path.join( + appdirs.user_data_dir("AYON", "Ynput"), + "addons" + ) + + dev_mode_enabled = is_dev_mode_enabled() + dev_addons_info = {} + if dev_mode_enabled: + # Get dev addons info only when dev mode is enabled + dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info) + + addons_dir_exists = os.path.exists(addons_dir) + if not addons_dir_exists: log.warning("Addons directory does not exists. Path \"{}\"".format( addons_dir )) - return v3_addons_to_skip for addon_info in addons_info: addon_name = addon_info["name"] addon_version = addon_info["version"] - folder_name = "{}_{}".format(addon_name, addon_version) - addon_dir = os.path.join(addons_dir, folder_name) - if not os.path.exists(addon_dir): - log.warning(( - "Directory for addon {} {} does not exists. Path \"{}\"" - ).format(addon_name, addon_version, addon_dir)) + # OpenPype addon does not have any addon object + if addon_name == "openpype": + continue + + dev_addon_info = dev_addons_info.get(addon_name, {}) + use_dev_path = dev_addon_info.get("enabled", False) + + addon_dir = None + if use_dev_path: + addon_dir = dev_addon_info["path"] + if not addon_dir or not os.path.exists(addon_dir): + log.warning(( + "Dev addon {} {} path does not exists. Path \"{}\"" + ).format(addon_name, addon_version, addon_dir)) + continue + + elif addons_dir_exists: + folder_name = "{}_{}".format(addon_name, addon_version) + addon_dir = os.path.join(addons_dir, folder_name) + if not os.path.exists(addon_dir): + log.debug(( + "No localized client code found for addon {} {}." + ).format(addon_name, addon_version)) + continue + + if not addon_dir: continue sys.path.insert(0, addon_dir) imported_modules = [] for name in os.listdir(addon_dir): + # Ignore of files is implemented to be able to run code from code + # where usually is more files than just the addon + # Ignore start and setup scripts + if name in ("setup.py", "start.py", "__pycache__"): + continue + path = os.path.join(addon_dir, name) basename, ext = os.path.splitext(name) + # Ignore folders/files with dot in name + # - dot names cannot be imported in Python + if "." in basename: + continue is_dir = os.path.isdir(path) is_py_file = ext.lower() == ".py" if not is_py_file and not is_dir: @@ -407,7 +470,15 @@ def _load_ayon_addons(openpype_modules, modules_key, log): try: mod = __import__(basename, fromlist=("",)) - imported_modules.append(mod) + for attr_name in dir(mod): + attr = getattr(mod, attr_name) + if ( + inspect.isclass(attr) + and issubclass(attr, AYONAddon) + ): + imported_modules.append(mod) + break + except BaseException: log.warning( "Failed to import \"{}\"".format(basename), @@ -420,19 +491,26 @@ def _load_ayon_addons(openpype_modules, modules_key, log): )) continue - if len(imported_modules) == 1: - mod = imported_modules[0] - addon_alias = getattr(mod, "V3_ALIAS", None) - if not addon_alias: - addon_alias = addon_name - v3_addons_to_skip.append(addon_alias) - new_import_str = "{}.{}".format(modules_key, addon_alias) + if len(imported_modules) > 1: + log.warning(( + "Skipping addon '{}'." + " Multiple modules were found ({}) in dir {}." + ).format( + addon_name, + ", ".join([m.__name__ for m in imported_modules]), + addon_dir, + )) + continue - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) + mod = imported_modules[0] + addon_alias = getattr(mod, "V3_ALIAS", None) + if not addon_alias: + addon_alias = addon_name + v3_addons_to_skip.append(addon_alias) + new_import_str = "{}.{}".format(modules_key, addon_alias) - else: - log.info("More then one module was imported") + sys.modules[new_import_str] = mod + setattr(openpype_modules, addon_alias, mod) return v3_addons_to_skip @@ -466,6 +544,11 @@ def _load_modules(): addons_dir = os.path.join(os.path.dirname(current_dir), "addons") module_dirs.append(addons_dir) + ignored_host_names = set(IGNORED_HOSTS_IN_AYON) + ignored_current_dir_filenames = set(IGNORED_DEFAULT_FILENAMES) + if AYON_SERVER_ENABLED: + ignored_current_dir_filenames |= IGNORED_FILENAMES_IN_AYON + processed_paths = set() for dirpath in frozenset(module_dirs): # Skip already processed paths @@ -481,6 +564,7 @@ def _load_modules(): is_in_current_dir = dirpath == current_dir is_in_host_dir = dirpath == hosts_dir + for filename in os.listdir(dirpath): # Ignore filenames if filename in IGNORED_FILENAMES: @@ -488,7 +572,13 @@ def _load_modules(): if ( is_in_current_dir - and filename in IGNORED_DEFAULT_FILENAMES + and filename in ignored_current_dir_filenames + ): + continue + + if ( + is_in_host_dir + and filename in ignored_host_names ): continue @@ -559,26 +649,22 @@ def _load_modules(): @six.add_metaclass(ABCMeta) -class OpenPypeModule: - """Base class of pype module. +class AYONAddon(object): + """Base class of AYON addon. Attributes: - id (UUID): Module's id. - enabled (bool): Is module enabled. - name (str): Module name. - manager (ModulesManager): Manager that created the module. + id (UUID): Addon object id. + enabled (bool): Is addon enabled. + name (str): Addon name. + + Args: + manager (ModulesManager): Manager object who discovered addon. + settings (dict[str, Any]): AYON settings. """ - # Disable by default - enabled = False + enabled = True _id = None - @property - @abstractmethod - def name(self): - """Module's name.""" - pass - def __init__(self, manager, settings): self.manager = manager @@ -588,22 +674,45 @@ class OpenPypeModule: @property def id(self): + """Random id of addon object. + + Returns: + str: Object id. + """ + if self._id is None: self._id = uuid4() return self._id + @property @abstractmethod - def initialize(self, module_settings): - """Initialization of module attributes. + def name(self): + """Addon name. - It is not recommended to override __init__ that's why specific method - was implemented. + Returns: + str: Addon name. """ pass - def connect_with_modules(self, enabled_modules): - """Connect with other enabled modules.""" + def initialize(self, settings): + """Initialization of module attributes. + + It is not recommended to override __init__ that's why specific method + was implemented. + + Args: + settings (dict[str, Any]): Settings. + """ + + pass + + def connect_with_modules(self, enabled_addons): + """Connect with other enabled addons. + + Args: + enabled_addons (list[AYONAddon]): Addons that are enabled. + """ pass @@ -611,6 +720,9 @@ class OpenPypeModule: """Get global environments values of module. Environment variables that can be get only from system settings. + + Returns: + dict[str, str]: Environment variables. """ return {} @@ -623,7 +735,7 @@ class OpenPypeModule: Args: application (Application): Application that is launched. - env (dict): Current environment variables. + env (dict[str, str]): Current environment variables. """ pass @@ -639,7 +751,8 @@ class OpenPypeModule: to receive from 'host' object. Args: - host (ModuleType): Access to installed/registered host object. + host (Union[ModuleType, HostBase]): Access to installed/registered + host object. host_name (str): Name of host. project_name (str): Project name which is main part of host context. @@ -653,47 +766,66 @@ class OpenPypeModule: The best practise is to create click group for whole module which is used to separate commands. - class MyPlugin(OpenPypeModule): - ... - def cli(self, module_click_group): - module_click_group.add_command(cli_main) + Example: + class MyPlugin(AYONAddon): + ... + def cli(self, module_click_group): + module_click_group.add_command(cli_main) - @click.group(, help="") - def cli_main(): - pass + @click.group(, help="") + def cli_main(): + pass - @cli_main.command() - def mycommand(): - print("my_command") + @cli_main.command() + def mycommand(): + print("my_command") + + Args: + module_click_group (click.Group): Group to which can be added + commands. """ pass +class OpenPypeModule(AYONAddon): + """Base class of OpenPype module. + + Instead of 'AYONAddon' are passed in module settings. + + Args: + manager (ModulesManager): Manager object who discovered addon. + settings (dict[str, Any]): OpenPype settings. + """ + + # Disable by default + enabled = False + + class OpenPypeAddOn(OpenPypeModule): # Enable Addon by default enabled = True - def initialize(self, module_settings): - """Initialization is not be required for most of addons.""" - pass - class ModulesManager: """Manager of Pype modules helps to load and prepare them to work. Args: - modules_settings(dict): To be able create module manager with specified - data. For settings changes callbacks and testing purposes. + system_settings (Optional[dict[str, Any]]): OpenPype system settings. + ayon_settings (Optional[dict[str, Any]]): AYON studio settings. """ + # Helper attributes for report _report_total_key = "Total" + _system_settings = None + _ayon_settings = None - def __init__(self, _system_settings=None): + def __init__(self, system_settings=None, ayon_settings=None): self.log = logging.getLogger(self.__class__.__name__) - self._system_settings = _system_settings + self._system_settings = system_settings + self._ayon_settings = ayon_settings self.modules = [] self.modules_by_id = {} @@ -715,8 +847,9 @@ class ModulesManager: default (Any): Default output if module is not available. Returns: - Union[OpenPypeModule, None]: Module found by name or None. + Union[AYONAddon, None]: Module found by name or None. """ + return self.modules_by_name.get(module_name, default) def get_enabled_module(self, module_name, default=None): @@ -730,7 +863,7 @@ class ModulesManager: not enabled. Returns: - Union[OpenPypeModule, None]: Enabled module found by name or None. + Union[AYONAddon, None]: Enabled module found by name or None. """ module = self.get(module_name) @@ -745,11 +878,20 @@ class ModulesManager: import openpype_modules - self.log.debug("*** Pype modules initialization.") + self.log.debug("*** {} initialization.".format( + "AYON addons" + if AYON_SERVER_ENABLED + else "OpenPype modules" + )) # Prepare settings for modules - system_settings = getattr(self, "_system_settings", None) + system_settings = self._system_settings if system_settings is None: system_settings = get_system_settings() + + ayon_settings = self._ayon_settings + if AYON_SERVER_ENABLED and ayon_settings is None: + ayon_settings = get_ayon_settings() + modules_settings = system_settings["modules"] report = {} @@ -762,12 +904,13 @@ class ModulesManager: for name in dir(module): modules_item = getattr(module, name, None) # Filter globals that are not classes which inherit from - # OpenPypeModule + # AYONAddon if ( not inspect.isclass(modules_item) + or modules_item is AYONAddon or modules_item is OpenPypeModule or modules_item is OpenPypeAddOn - or not issubclass(modules_item, OpenPypeModule) + or not issubclass(modules_item, AYONAddon) ): continue @@ -792,10 +935,14 @@ class ModulesManager: module_classes.append(modules_item) for modules_item in module_classes: + is_openpype_module = issubclass(modules_item, OpenPypeModule) + settings = ( + modules_settings if is_openpype_module else ayon_settings + ) + name = modules_item.__name__ try: - name = modules_item.__name__ # Try initialize module - module = modules_item(self, modules_settings) + module = modules_item(self, settings) # Store initialized object self.modules.append(module) self.modules_by_id[module.id] = module @@ -850,8 +997,9 @@ class ModulesManager: """Enabled modules initialized by the manager. Returns: - list: Initialized and enabled modules. + list[AYONAddon]: Initialized and enabled modules. """ + return [ module for module in self.modules @@ -946,7 +1094,18 @@ class ModulesManager: continue method = getattr(module, method_name) - paths = method(*args, **kwargs) + try: + paths = method(*args, **kwargs) + except Exception: + self.log.warning( + ( + "Failed to get plugin paths from module" + " '{}' using '{}'." + ).format(module.__class__.__name__, method_name), + exc_info=True + ) + continue + if paths: # Convert to list if value is not list if not isinstance(paths, (list, tuple, set)): @@ -1023,7 +1182,7 @@ class ModulesManager: host_name (str): Host name for which is found host module. Returns: - OpenPypeModule: Found host module by name. + AYONAddon: Found host module by name. None: There was not found module inheriting IHostAddon which has host name set to passed 'host_name'. """ @@ -1044,12 +1203,11 @@ class ModulesManager: inheriting 'IHostAddon'. """ - host_names = { + return { module.host_name for module in self.get_enabled_modules() if isinstance(module, IHostAddon) } - return host_names def print_report(self): """Print out report of time spent on modules initialization parts. @@ -1205,6 +1363,10 @@ class TrayModulesManager(ModulesManager): callback can be defined with `doubleclick_callback` attribute. Missing feature how to define default callback. + + Args: + addon (AYONAddon): Addon object. + callback (FunctionType): Function callback. """ callback_name = "_".join([module.name, callback.__name__]) if callback_name not in self.doubleclick_callbacks: @@ -1225,11 +1387,17 @@ class TrayModulesManager(ModulesManager): self.tray_menu(tray_menu) def get_enabled_tray_modules(self): - output = [] - for module in self.modules: - if module.enabled and isinstance(module, ITrayModule): - output.append(module) - return output + """Enabled tray modules. + + Returns: + list[AYONAddon]: Enabled addons that inherit from tray interface. + """ + + return [ + module + for module in self.modules + if module.enabled and isinstance(module, ITrayModule) + ] def restart_tray(self): if self.tray_manager: diff --git a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index eadfc3c83e..1d3dad769f 100644 --- a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -5,9 +5,8 @@ This is resolving index of server lists stored in `deadlineServers` instance attribute or using default server if that attribute doesn't exists. """ -from maya import cmds - import pyblish.api +from openpype.pipeline.publish import KnownPublishError class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): @@ -23,7 +22,7 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): instance.data["deadlineUrl"] = self._collect_deadline_url(instance) instance.data["deadlineUrl"] = \ instance.data["deadlineUrl"].strip().rstrip("/") - self.log.info( + self.log.debug( "Using {} for submission.".format(instance.data["deadlineUrl"])) def _collect_deadline_url(self, render_instance): @@ -43,7 +42,8 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): str: Selected Deadline Webservice URL. """ - + # Not all hosts can import this module. + from maya import cmds deadline_settings = ( render_instance.context.data ["system_settings"] @@ -81,13 +81,14 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): if k in default_servers } - msg = ( - "\"{}\" server on instance is not enabled in project settings." - " Enabled project servers:\n{}".format( - instance_server, project_enabled_servers + if instance_server not in project_enabled_servers: + msg = ( + "\"{}\" server on instance is not enabled in project settings." + " Enabled project servers:\n{}".format( + instance_server, project_enabled_servers + ) ) - ) - assert instance_server in project_enabled_servers, msg + raise KnownPublishError(msg) self.log.debug("Using project approved server.") return project_enabled_servers[instance_server] diff --git a/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml b/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml index 0e7d72910e..aa21df3734 100644 --- a/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml +++ b/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml @@ -1,31 +1,31 @@ - Scene setting + Deadline Pools - ## Invalid Deadline pools found +## Invalid Deadline pools found - Configured pools don't match what is set in Deadline. +Configured pools don't match available pools in Deadline. - {invalid_value_str} +### How to repair? - ### How to repair? +If your instance had deadline pools set on creation, remove or +change them. - If your instance had deadline pools set on creation, remove or - change them. +In other cases inform admin to change them in Settings. - In other cases inform admin to change them in Settings. +Available deadline pools: + +{pools_str} - Available deadline pools {pools_str}. - ### __Detailed Info__ +### __Detailed Info__ - This error is shown when deadline pool is not on Deadline anymore. It - could happen in case of republish old workfile which was created with - previous deadline pools, - or someone changed pools on Deadline side, but didn't modify Openpype - Settings. +This error is shown when a configured pool is not available on Deadline. It +can happen when publishing old workfiles which were created with previous +deadline pools, or someone changed the available pools in Deadline, +but didn't modify Openpype Settings to match the changes. \ No newline at end of file diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py new file mode 100644 index 0000000000..8f9e9a7425 --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +"""Submitting render job to Deadline.""" + +import os +import getpass +import attr +from datetime import datetime + +from openpype.lib import ( + is_running_from_build, + BoolDef, + NumberDef, + TextDef, +) +from openpype.pipeline import legacy_io +from openpype.pipeline.publish import OpenPypePyblishPluginMixin +from openpype.pipeline.farm.tools import iter_expected_files +from openpype.tests.lib import is_in_tests + +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo + + +@attr.s +class BlenderPluginInfo(): + SceneFile = attr.ib(default=None) # Input + Version = attr.ib(default=None) # Mandatory for Deadline + SaveFile = attr.ib(default=True) + + +class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, + OpenPypePyblishPluginMixin): + label = "Submit Render to Deadline" + hosts = ["blender"] + families = ["render"] + + use_published = True + priority = 50 + chunk_size = 1 + jobInfo = {} + pluginInfo = {} + group = None + job_delay = "00:00:00:00" + + def get_job_info(self): + job_info = DeadlineJobInfo(Plugin="Blender") + + job_info.update(self.jobInfo) + + instance = self._instance + context = instance.context + + # Always use the original work file name for the Job name even when + # rendering is done from the published Work File. The original work + # file name is clearer because it can also have subversion strings, + # etc. which are stripped for the published file. + src_filepath = context.data["currentFile"] + src_filename = os.path.basename(src_filepath) + + if is_in_tests(): + src_filename += datetime.now().strftime("%d%m%Y%H%M%S") + + job_info.Name = f"{src_filename} - {instance.name}" + job_info.BatchName = src_filename + instance.data.get("blenderRenderPlugin", "Blender") + job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) + + # Deadline requires integers in frame range + frames = "{start}-{end}x{step}".format( + start=int(instance.data["frameStartHandle"]), + end=int(instance.data["frameEndHandle"]), + step=int(instance.data["byFrameStep"]), + ) + job_info.Frames = frames + + job_info.Pool = instance.data.get("primaryPool") + job_info.SecondaryPool = instance.data.get("secondaryPool") + job_info.Comment = instance.data.get("comment") + + if self.group != "none" and self.group: + job_info.Group = self.group + + attr_values = self.get_attr_values_from_data(instance.data) + render_globals = instance.data.setdefault("renderGlobals", {}) + machine_list = attr_values.get("machineList", "") + if machine_list: + if attr_values.get("whitelist", True): + machine_list_key = "Whitelist" + else: + machine_list_key = "Blacklist" + render_globals[machine_list_key] = machine_list + + job_info.ChunkSize = attr_values.get("chunkSize", self.chunk_size) + job_info.Priority = attr_values.get("priority", self.priority) + job_info.ScheduledType = "Once" + job_info.JobDelay = attr_values.get("job_delay", self.job_delay) + + # Add options from RenderGlobals + render_globals = instance.data.get("renderGlobals", {}) + job_info.update(render_globals) + + keys = [ + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "OPENPYPE_SG_USER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV" + "IS_TEST" + ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + + # Add mongo url if it's enabled + if self._instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") + + environment = dict({key: os.environ[key] for key in keys + if key in os.environ}, **legacy_io.Session) + + for key in keys: + value = environment.get(key) + if not value: + continue + job_info.EnvironmentKeyValue[key] = value + + # to recognize job from PYPE for turning Event On/Off + job_info.add_render_job_env_var() + job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" + + # Adding file dependencies. + if self.asset_dependencies: + dependencies = instance.context.data["fileDependencies"] + for dependency in dependencies: + job_info.AssetDependency += dependency + + # Add list of expected files to job + # --------------------------------- + exp = instance.data.get("expectedFiles") + for filepath in iter_expected_files(exp): + job_info.OutputDirectory += os.path.dirname(filepath) + job_info.OutputFilename += os.path.basename(filepath) + + return job_info + + def get_plugin_info(self): + # Not all hosts can import this module. + import bpy + + plugin_info = BlenderPluginInfo( + SceneFile=self.scene_path, + Version=bpy.app.version_string, + SaveFile=True, + ) + + plugin_payload = attr.asdict(plugin_info) + + # Patching with pluginInfo from settings + for key, value in self.pluginInfo.items(): + plugin_payload[key] = value + + return plugin_payload + + def process_submission(self): + instance = self._instance + + expected_files = instance.data["expectedFiles"] + if not expected_files: + raise RuntimeError("No Render Elements found!") + + first_file = next(iter_expected_files(expected_files)) + output_dir = os.path.dirname(first_file) + instance.data["outputDir"] = output_dir + instance.data["toBeRenderedOn"] = "deadline" + + payload = self.assemble_payload() + return self.submit(payload) + + def from_published_scene(self): + """ + This is needed to set the correct path for the json metadata. Because + the rendering path is set in the blend file during the collection, + and the path is adjusted to use the published scene, this ensures that + the metadata and the rendered files are in the same location. + """ + return super().from_published_scene(False) + + @classmethod + def get_attribute_defs(cls): + defs = super(BlenderSubmitDeadline, cls).get_attribute_defs() + defs.extend([ + BoolDef("use_published", + default=cls.use_published, + label="Use Published Scene"), + + NumberDef("priority", + minimum=1, + maximum=250, + decimals=0, + default=cls.priority, + label="Priority"), + + NumberDef("chunkSize", + minimum=1, + maximum=50, + decimals=0, + default=cls.chunk_size, + label="Frame Per Task"), + + TextDef("group", + default=cls.group, + label="Group Name"), + + TextDef("job_delay", + default=cls.job_delay, + label="Job Delay", + placeholder="dd:hh:mm:ss", + tooltip="Delay the job by the specified amount of time. " + "Timecode: dd:hh:mm:ss."), + ]) + + return defs diff --git a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py index ee28612b44..47a0a25755 100644 --- a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py @@ -27,7 +27,7 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): deadline_job_delay = "00:00:08:00" def process(self, instance): - instance.data["toBeRenderedOn"] = "deadline" + context = instance.context # get default deadline webservice url from deadline module @@ -183,10 +183,10 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): } plugin = payload["JobInfo"]["Plugin"] - self.log.info("using render plugin : {}".format(plugin)) + self.log.debug("using render plugin : {}".format(plugin)) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # adding expectied files to instance.data self.expected_files(instance, render_path) diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py index a48596c6bf..9a718aa089 100644 --- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py @@ -6,13 +6,15 @@ import requests import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin ) from openpype.lib import ( BoolDef, - NumberDef + NumberDef, + is_running_from_build ) @@ -34,6 +36,8 @@ class FusionSubmitDeadline( targets = ["local"] # presets + plugin = None + priority = 50 chunk_size = 1 concurrent_tasks = 1 @@ -173,7 +177,7 @@ class FusionSubmitDeadline( "SecondaryPool": instance.data.get("secondaryPool"), "Group": self.group, - "Plugin": "Fusion", + "Plugin": self.plugin, "Frames": "{start}-{end}".format( start=int(instance.data["frameStartHandle"]), end=int(instance.data["frameEndHandle"]) @@ -216,16 +220,34 @@ class FusionSubmitDeadline( # Include critical variables with submission keys = [ - # TODO: This won't work if the slaves don't have access to - # these paths, such as if slaves are running Linux and the - # submitter is on Windows. - "PYTHONPATH", - "OFX_PLUGIN_PATH", - "FUSION9_MasterPrefs" + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV", + "OPENPYPE_LOG_NO_COLORS", + "IS_TEST" ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) + # to recognize render jobs + if AYON_SERVER_ENABLED: + environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] + render_job_label = "AYON_RENDER_JOB" + else: + render_job_label = "OPENPYPE_RENDER_JOB" + + environment[render_job_label] = "1" + payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, @@ -233,8 +255,8 @@ class FusionSubmitDeadline( ) for index, key in enumerate(environment) }) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs url = "{}/api/jobs".format(deadline_url) diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index 2c37268f04..17e672334c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -265,7 +265,7 @@ class HarmonySubmitDeadline( job_info.SecondaryPool = self._instance.data.get("secondaryPool") job_info.ChunkSize = self.chunk_size batch_name = os.path.basename(self._instance.data["source"]) - if is_in_tests: + if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") job_info.BatchName = batch_name job_info.Department = self.department @@ -369,7 +369,7 @@ class HarmonySubmitDeadline( # rendering, we need to unzip it. published_scene = Path( self.from_published_scene(False)) - self.log.info(f"Processing {published_scene.as_posix()}") + self.log.debug(f"Processing {published_scene.as_posix()}") xstage_path = self._unzip_scene_file(published_scene) render_path = xstage_path.parent / "renders" diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_cache_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_cache_deadline.py new file mode 100644 index 0000000000..ada69575a8 --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_cache_deadline.py @@ -0,0 +1,194 @@ +import os +import getpass +from datetime import datetime + +import attr +import pyblish.api +from openpype.lib import ( + TextDef, + NumberDef, +) +from openpype.pipeline import ( + legacy_io, + OpenPypePyblishPluginMixin +) +from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo + + +@attr.s +class HoudiniPluginInfo(object): + Build = attr.ib(default=None) + IgnoreInputs = attr.ib(default=True) + ScriptJob = attr.ib(default=True) + SceneFile = attr.ib(default=None) # Input + SaveFile = attr.ib(default=True) + ScriptFilename = attr.ib(default=None) + OutputDriver = attr.ib(default=None) + Version = attr.ib(default=None) # Mandatory for Deadline + ProjectPath = attr.ib(default=None) + + +class HoudiniCacheSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, # noqa + OpenPypePyblishPluginMixin): + """Submit Houdini scene to perform a local publish in Deadline. + + Publishing in Deadline can be helpful for scenes that publish very slow. + This way it can process in the background on another machine without the + Artist having to wait for the publish to finish on their local machine. + + Submission is done through the Deadline Web Service as + supplied via the environment variable AVALON_DEADLINE. + + """ + + label = "Submit Scene to Deadline" + order = pyblish.api.IntegratorOrder + hosts = ["houdini"] + families = ["publish.hou"] + targets = ["local"] + + priority = 50 + jobInfo = {} + pluginInfo = {} + group = None + + def get_job_info(self): + job_info = DeadlineJobInfo(Plugin="Houdini") + + job_info.update(self.jobInfo) + instance = self._instance + context = instance.context + assert all( + result["success"] for result in context.data["results"] + ), "Errors found, aborting integration.." + + # Deadline connection + AVALON_DEADLINE = legacy_io.Session.get( + "AVALON_DEADLINE", "http://localhost:8082" + ) + assert AVALON_DEADLINE, "Requires AVALON_DEADLINE" + + project_name = instance.context.data["projectName"] + filepath = context.data["currentFile"] + scenename = os.path.basename(filepath) + job_name = "{scene} - {instance} [PUBLISH]".format( + scene=scenename, instance=instance.name) + batch_name = "{code} - {scene}".format(code=project_name, + scene=scenename) + if is_in_tests(): + batch_name += datetime.now().strftime("%d%m%Y%H%M%S") + + job_info.Name = job_name + job_info.BatchName = batch_name + job_info.Plugin = instance.data["plugin"] + job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) + rop_node = self.get_rop_node(instance) + if rop_node.type().name() != "alembic": + frames = "{start}-{end}x{step}".format( + start=int(instance.data["frameStart"]), + end=int(instance.data["frameEnd"]), + step=int(instance.data["byFrameStep"]), + ) + + job_info.Frames = frames + + job_info.Pool = instance.data.get("primaryPool") + job_info.SecondaryPool = instance.data.get("secondaryPool") + + attr_values = self.get_attr_values_from_data(instance.data) + + job_info.ChunkSize = instance.data["chunkSize"] + job_info.Comment = context.data.get("comment") + job_info.Priority = attr_values.get("priority", self.priority) + job_info.Group = attr_values.get("group", self.group) + + keys = [ + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "OPENPYPE_SG_USER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV", + "OPENPYPE_LOG_NO_COLORS", + ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + # Add mongo url if it's enabled + if self._instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") + + environment = dict({key: os.environ[key] for key in keys + if key in os.environ}, **legacy_io.Session) + + for key in keys: + value = environment.get(key) + if not value: + continue + job_info.EnvironmentKeyValue[key] = value + # to recognize render jobs + job_info.add_render_job_env_var() + + return job_info + + def get_plugin_info(self): + # Not all hosts can import this module. + import hou + + instance = self._instance + version = hou.applicationVersionString() + version = ".".join(version.split(".")[:2]) + rop = self.get_rop_node(instance) + plugin_info = HoudiniPluginInfo( + Build=None, + IgnoreInputs=True, + ScriptJob=True, + SceneFile=self.scene_path, + SaveFile=True, + OutputDriver=rop.path(), + Version=version, + ProjectPath=os.path.dirname(self.scene_path) + ) + + plugin_payload = attr.asdict(plugin_info) + + return plugin_payload + + def process(self, instance): + super(HoudiniCacheSubmitDeadline, self).process(instance) + output_dir = os.path.dirname(instance.data["files"][0]) + instance.data["outputDir"] = output_dir + instance.data["toBeRenderedOn"] = "deadline" + + def get_rop_node(self, instance): + # Not all hosts can import this module. + import hou + + rop = instance.data.get("instance_node") + rop_node = hou.node(rop) + + return rop_node + + @classmethod + def get_attribute_defs(cls): + defs = super(HoudiniCacheSubmitDeadline, cls).get_attribute_defs() + defs.extend([ + NumberDef("priority", + minimum=1, + maximum=250, + decimals=0, + default=cls.priority, + label="Priority"), + TextDef("group", + default=cls.group, + label="Group Name"), + ]) + + return defs diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py index 68aa653804..0bee42c4cb 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py @@ -3,7 +3,6 @@ import json from datetime import datetime import requests -import hou import pyblish.api @@ -31,6 +30,8 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): targets = ["deadline"] def process(self, context): + # Not all hosts can import this module. + import hou # Ensure no errors so far assert all( @@ -162,7 +163,7 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): ) # Submit - self.log.info("Submitting..") + self.log.debug("Submitting..") self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs 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 58d5736878..0c75f632cb 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -1,9 +1,8 @@ -import hou - import os import attr import getpass from datetime import datetime + import pyblish.api from openpype.pipeline import legacy_io, OpenPypePyblishPluginMixin @@ -163,9 +162,11 @@ class HoudiniSubmitDeadline( job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S") # Deadline requires integers in frame range + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] frames = "{start}-{end}x{step}".format( - start=int(instance.data["frameStart"]), - end=int(instance.data["frameEnd"]), + start=int(start), + end=int(end), step=int(instance.data["byFrameStep"]), ) job_info.Frames = frames @@ -234,6 +235,8 @@ class HoudiniSubmitDeadline( return job_info def get_plugin_info(self, job_type=None): + # Not all hosts can import this module. + import hou instance = self._instance context = instance.context @@ -280,4 +283,3 @@ class HoudiniSubmitDeadline( # Store output dir for unified publisher (filesequence) output_dir = os.path.dirname(instance.data["files"][0]) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 8e05582962..23d4183132 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -1,8 +1,8 @@ import os import getpass import copy - import attr + from openpype.lib import ( TextDef, BoolDef, @@ -12,12 +12,9 @@ from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin ) -from openpype.settings import get_project_settings -from openpype.hosts.max.api.lib import ( - get_current_renderer, - get_multipass_setting +from openpype.pipeline.publish.lib import ( + replace_with_published_scene_path ) -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 @@ -174,7 +171,6 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, first_file = next(self._iter_expected_files(files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" filename = os.path.basename(filepath) @@ -190,6 +186,13 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, self.submit(self.assemble_payload(job_info, plugin_info)) def _use_published_name(self, data, project_settings): + # Not all hosts can import these modules. + from openpype.hosts.max.api.lib import ( + get_current_renderer, + get_multipass_setting + ) + from openpype.hosts.max.api.lib_rendersettings import RenderSettings + instance = self._instance job_info = copy.deepcopy(self.job_info) plugin_info = copy.deepcopy(self.plugin_info) @@ -236,7 +239,11 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, if renderer == "Redshift_Renderer": plugin_data["redshift_SeparateAovFiles"] = instance.data.get( "separateAovFiles") - + if instance.data["cameras"]: + camera = instance.data["cameras"][0] + plugin_info["Camera0"] = camera + plugin_info["Camera"] = camera + plugin_info["Camera1"] = camera self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) @@ -247,7 +254,8 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, if instance.data["renderer"] == "Redshift_Renderer": self.log.debug("Using Redshift...published scene wont be used..") replace_in_path = False - return replace_in_path + return replace_with_published_scene_path( + instance, replace_in_path) @staticmethod def _iter_expected_files(exp): diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 75d24b28f0..26a605a744 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -28,8 +28,6 @@ from collections import OrderedDict import attr -from maya import cmds - from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin @@ -134,6 +132,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, cls.group = settings.get("group", cls.group) cls.strict_error_checking = settings.get("strict_error_checking", cls.strict_error_checking) + cls.jobInfo = settings.get("jobInfo", cls.jobInfo) + cls.pluginInfo = settings.get("pluginInfo", cls.pluginInfo) def get_job_info(self): job_info = DeadlineJobInfo(Plugin="MayaBatch") @@ -246,6 +246,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info def get_plugin_info(self): + # Not all hosts can import this module. + from maya import cmds instance = self._instance context = instance.context @@ -288,9 +290,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return plugin_payload def process_submission(self): - + from maya import cmds instance = self._instance - context = instance.context filepath = self.scene_path # publish if `use_publish` else workfile @@ -300,20 +301,17 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, first_file = next(iter_expected_files(expected_files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" # Patch workfile (only when use_published is enabled) if self.use_published: self._patch_workfile() # Gather needed data ------------------------------------------------ - workspace = context.data["workspaceDir"] - default_render_file = instance.context.data.get('project_settings')\ - .get('maya')\ - .get('RenderSettings')\ - .get('default_render_image_folder') filename = os.path.basename(filepath) - dirname = os.path.join(workspace, default_render_file) + dirname = os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="images") + ) # Fill in common data to payload ------------------------------------ # TODO: Replace these with collected data from CollectRender @@ -335,12 +333,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, payload = self._get_vray_render_payload(payload_data) - elif "assscene" in instance.data["families"]: - self.log.debug("Submitting Arnold .ass standalone render..") - ass_export_payload = self._get_arnold_export_payload(payload_data) - export_job = self.submit(ass_export_payload) - - payload = self._get_arnold_render_payload(payload_data) else: self.log.debug("Submitting MayaBatch render..") payload = self._get_maya_payload(payload_data) @@ -434,7 +426,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, new_job_info.update(tiles_data["JobInfo"]) new_plugin_info.update(tiles_data["PluginInfo"]) - self.log.info("hashing {} - {}".format(file_index, file)) + self.log.debug("hashing {} - {}".format(file_index, file)) job_hash = hashlib.sha256( ("{}_{}".format(file_index, file)).encode("utf-8")) @@ -450,7 +442,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, ) file_index += 1 - self.log.info( + self.log.debug( "Submitting tile job(s) [{}] ...".format(len(frame_payloads))) # Submit frame tile jobs @@ -560,7 +552,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, assembly_job_ids = [] num_assemblies = len(assembly_payloads) for i, payload in enumerate(assembly_payloads): - self.log.info( + self.log.debug( "submitting assembly job {} of {}".format(i + 1, num_assemblies) ) @@ -636,53 +628,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info, attr.asdict(plugin_info) - def _get_arnold_export_payload(self, data): - - try: - from openpype.scripts import export_maya_ass_job - except Exception: - raise AssertionError( - "Expected module 'export_maya_ass_job' to be available") - - module_path = export_maya_ass_job.__file__ - if module_path.endswith(".pyc"): - module_path = module_path[: -len(".pyc")] + ".py" - - script = os.path.normpath(module_path) - - job_info = copy.deepcopy(self.job_info) - job_info.Name = self._job_info_label("Export") - - # Force a single frame Python job - job_info.Plugin = "Python" - job_info.Frames = 1 - - renderlayer = self._instance.data["setMembers"] - - # add required env vars for the export script - envs = { - "AVALON_APP_NAME": os.environ.get("AVALON_APP_NAME"), - "OPENPYPE_ASS_EXPORT_RENDER_LAYER": renderlayer, - "OPENPYPE_ASS_EXPORT_SCENE_FILE": self.scene_path, - "OPENPYPE_ASS_EXPORT_OUTPUT": job_info.OutputFilename[0], - "OPENPYPE_ASS_EXPORT_START": int(self._instance.data["frameStartHandle"]), # noqa - "OPENPYPE_ASS_EXPORT_END": int(self._instance.data["frameEndHandle"]), # noqa - "OPENPYPE_ASS_EXPORT_STEP": 1 - } - for key, value in envs.items(): - if not value: - continue - job_info.EnvironmentKeyValue[key] = value - - plugin_info = PythonPluginInfo( - ScriptFile=script, - Version="3.6", - Arguments="", - SingleFrameOnly="True" - ) - - return job_info, attr.asdict(plugin_info) - def _get_vray_render_payload(self, data): # Job Info @@ -705,7 +650,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info, attr.asdict(plugin_info) def _get_arnold_render_payload(self, data): - + from maya import cmds # Job Info job_info = copy.deepcopy(self.job_info) job_info.Name = self._job_info_label("Render") @@ -732,7 +677,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, str """ - + from maya import cmds # "vrayscene//_/" vray_settings = cmds.ls(type="VRaySettingsNode") node = vray_settings[0] diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 0d23f44333..41a2a64ab5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -2,8 +2,6 @@ import os import attr from datetime import datetime -from maya import cmds - from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.tests.lib import is_in_tests @@ -127,7 +125,8 @@ class MayaSubmitRemotePublishDeadline( job_info.EnvironmentKeyValue[key] = value def get_plugin_info(self): - + # Not all hosts can import this module. + from maya import cmds scene = self._instance.context.data["currentFile"] plugin_info = MayaPluginInfo() diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 93c6ad8139..fb3ab2710d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -7,8 +7,6 @@ from datetime import datetime import requests import pyblish.api -import nuke - from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( @@ -48,6 +46,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, use_gpu = False env_allowed_keys = [] env_search_replace_values = {} + workfile_dependency = True @classmethod def get_attribute_defs(cls): @@ -83,6 +82,11 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "suspend_publish", default=False, label="Suspend publish" + ), + BoolDef( + "workfile_dependency", + default=True, + label="Workfile Dependency" ) ] @@ -90,7 +94,6 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return - instance.data["attributeValues"] = self.get_attr_values_from_data( instance.data) @@ -98,7 +101,6 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, instance.data["suspend_publish"] = instance.data["attributeValues"][ "suspend_publish"] - instance.data["toBeRenderedOn"] = "deadline" families = instance.data["families"] node = instance.data["transientData"]["node"] @@ -123,13 +125,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, render_path = instance.data['path'] script_path = context.data["currentFile"] - for item in context: - if "workfile" in item.data["families"]: - msg = "Workfile (scene) must be published along" - assert item.data["publish"] is True, msg - - template_data = item.data.get("anatomyData") - rep = item.data.get("representations")[0].get("name") + 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 @@ -141,19 +140,24 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "Using published scene for render {}".format(script_path) ) - response = self.payload_submit( - instance, - script_path, - render_path, - node.name(), - submit_frame_start, - submit_frame_end - ) - # Store output dir for unified publisher (filesequence) - instance.data["deadlineSubmissionJob"] = response.json() - instance.data["outputDir"] = os.path.dirname( - render_path).replace("\\", "/") - instance.data["publishJobState"] = "Suspended" + # only add main rendering job if target is not frames_farm + r_job_response_json = None + if instance.data["render_target"] != "frames_farm": + r_job_response = self.payload_submit( + instance, + script_path, + render_path, + node.name(), + submit_frame_start, + submit_frame_end + ) + r_job_response_json = r_job_response.json() + instance.data["deadlineSubmissionJob"] = r_job_response_json + + # Store output dir for unified publisher (filesequence) + instance.data["outputDir"] = os.path.dirname( + render_path).replace("\\", "/") + instance.data["publishJobState"] = "Suspended" if instance.data.get("bakingNukeScripts"): for baking_script in instance.data["bakingNukeScripts"]: @@ -161,18 +165,20 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, script_path = baking_script["bakeScriptPath"] exe_node_name = baking_script["bakeWriteNodeName"] - resp = self.payload_submit( + b_job_response = self.payload_submit( instance, script_path, render_path, exe_node_name, submit_frame_start, submit_frame_end, - response.json() + r_job_response_json, + baking_submission=True ) # Store output dir for unified publisher (filesequence) - instance.data["deadlineSubmissionJob"] = resp.json() + instance.data["deadlineSubmissionJob"] = b_job_response.json() + instance.data["publishJobState"] = "Suspended" # add to list of job Id @@ -180,7 +186,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, instance.data["bakingSubmissionJobs"] = [] instance.data["bakingSubmissionJobs"].append( - resp.json()["_id"]) + b_job_response.json()["_id"]) # redefinition of families if "render" in instance.data["family"]: @@ -199,15 +205,35 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, exe_node_name, start_frame, end_frame, - response_data=None + response_data=None, + baking_submission=False, ): + """Submit payload to Deadline + + Args: + instance (pyblish.api.Instance): pyblish instance + script_path (str): path to nuke script + render_path (str): path to rendered images + exe_node_name (str): name of the node to render + start_frame (int): start frame + end_frame (int): end frame + response_data Optional[dict]: response data from + previous submission + baking_submission Optional[bool]: if it's baking submission + + Returns: + requests.Response + """ render_dir = os.path.normpath(os.path.dirname(render_path)) - batch_name = os.path.basename(script_path) - jobname = "%s - %s" % (batch_name, instance.name) + + # batch name + src_filepath = instance.context.data["currentFile"] + batch_name = os.path.basename(src_filepath) + job_name = os.path.basename(render_path) + if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") - output_filename_0 = self.preview_fname(render_path) if not response_data: @@ -221,18 +247,15 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, # resolve any limit groups limit_groups = self.get_limit_groups() - self.log.info("Limit groups: `{}`".format(limit_groups)) + self.log.debug("Limit groups: `{}`".format(limit_groups)) payload = { "JobInfo": { # Top-level group name "BatchName": batch_name, - # Asset dependency to wait for at least the scene file to sync. - # "AssetDependency0": script_path, - # Job name, as seen in Monitor - "Name": jobname, + "Name": job_name, # Arbitrary username, for visualisation in Monitor "UserName": self._deadline_user, @@ -294,12 +317,24 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "AuxFiles": [] } - if response_data.get("_id"): + # Add workfile dependency. + workfile_dependency = instance.data["attributeValues"].get( + "workfile_dependency", self.workfile_dependency + ) + if workfile_dependency: + payload["JobInfo"].update({"AssetDependency0": script_path}) + + # TODO: rewrite for baking with sequences + if baking_submission: payload["JobInfo"].update({ "JobType": "Normal", + "ChunkSize": 99999999 + }) + + if response_data.get("_id"): + payload["JobInfo"].update({ "BatchName": response_data["Props"]["Batch"], "JobDependency0": response_data["_id"], - "ChunkSize": 99999999 }) # Include critical environment variables with submission @@ -362,10 +397,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, }) plugin = payload["JobInfo"]["Plugin"] - self.log.info("using render plugin : {}".format(plugin)) + self.log.debug("using render plugin : {}".format(plugin)) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # adding expectied files to instance.data self.expected_files( @@ -461,6 +496,9 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, Returning: list: captured groups list """ + # Not all hosts can import this module. + import nuke + captured_groups = [] for lg_name, list_node_class in self.limit_groups.items(): for node_class in list_node_class: diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_cache_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_cache_job.py new file mode 100644 index 0000000000..b2ebebc303 --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/submit_publish_cache_job.py @@ -0,0 +1,506 @@ +# -*- coding: utf-8 -*- +"""Submit publishing job to farm.""" +import os +import json +import re +from copy import deepcopy +import requests + +import pyblish.api + +from openpype import AYON_SERVER_ENABLED +from openpype.client import ( + get_last_version_by_subset_name, +) +from openpype.pipeline import publish, legacy_io +from openpype.lib import EnumDef, is_running_from_build +from openpype.tests.lib import is_in_tests +from openpype.pipeline.version_start import get_versioning_start + +from openpype.pipeline.farm.pyblish_functions import ( + create_skeleton_instance_cache, + create_instances_for_cache, + attach_instances_to_subset, + prepare_cache_representations, + create_metadata_path +) + + +class ProcessSubmittedCacheJobOnFarm(pyblish.api.InstancePlugin, + publish.OpenPypePyblishPluginMixin, + publish.ColormanagedPyblishPluginMixin): + """Process Cache Job submitted on farm + This is replicated version of submit publish job + specifically for cache(s). + + These jobs are dependent on a deadline job + submission prior to this plug-in. + + - In case of Deadline, it creates dependent job on farm publishing + rendered image sequence. + + Options in instance.data: + - deadlineSubmissionJob (dict, Required): The returned .json + data from the job submission to deadline. + + - outputDir (str, Required): The output directory where the metadata + file should be generated. It's assumed that this will also be + final folder containing the output files. + + - ext (str, Optional): The extension (including `.`) that is required + in the output filename to be picked up for image sequence + publishing. + + - expectedFiles (list or dict): explained below + + """ + + label = "Submit cache jobs to Deadline" + order = pyblish.api.IntegratorOrder + 0.2 + icon = "tractor" + + targets = ["local"] + + hosts = ["houdini"] + + families = ["publish.hou"] + + environ_job_filter = [ + "OPENPYPE_METADATA_FILE" + ] + + environ_keys = [ + "FTRACK_API_USER", + "FTRACK_API_KEY", + "FTRACK_SERVER", + "AVALON_APP_NAME", + "OPENPYPE_USERNAME", + "OPENPYPE_SG_USER", + "KITSU_LOGIN", + "KITSU_PWD" + ] + + # custom deadline attributes + deadline_department = "" + deadline_pool = "" + deadline_pool_secondary = "" + deadline_group = "" + deadline_chunk_size = 1 + deadline_priority = None + + # regex for finding frame number in string + R_FRAME_NUMBER = re.compile(r'.+\.(?P[0-9]+)\..+') + + plugin_pype_version = "3.0" + + # script path for publish_filesequence.py + publishing_script = None + + def _submit_deadline_post_job(self, instance, job): + """Submit publish job to Deadline. + + Deadline specific code separated from :meth:`process` for sake of + more universal code. Muster post job is sent directly by Muster + submitter, so this type of code isn't necessary for it. + + Returns: + (str): deadline_publish_job_id + """ + data = instance.data.copy() + subset = data["subset"] + job_name = "Publish - {subset}".format(subset=subset) + + anatomy = instance.context.data['anatomy'] + + # instance.data.get("subset") != instances[0]["subset"] + # 'Main' vs 'renderMain' + override_version = None + instance_version = instance.data.get("version") # take this if exists + if instance_version != 1: + override_version = instance_version + + output_dir = self._get_publish_folder( + anatomy, + deepcopy(instance.data["anatomyData"]), + instance.data.get("asset"), + instance.data["subset"], + instance.context, + instance.data["family"], + override_version + ) + + # Transfer the environment from the original job to this dependent + # job so they use the same environment + metadata_path, rootless_metadata_path = \ + create_metadata_path(instance, anatomy) + + environment = { + "AVALON_PROJECT": instance.context.data["projectName"], + "AVALON_ASSET": instance.context.data["asset"], + "AVALON_TASK": instance.context.data["task"], + "OPENPYPE_USERNAME": instance.context.data["user"], + "OPENPYPE_LOG_NO_COLORS": "1", + "IS_TEST": str(int(is_in_tests())) + } + + if AYON_SERVER_ENABLED: + environment["AYON_PUBLISH_JOB"] = "1" + environment["AYON_RENDER_JOB"] = "0" + environment["AYON_REMOTE_PUBLISH"] = "0" + environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] + deadline_plugin = "Ayon" + else: + environment["OPENPYPE_PUBLISH_JOB"] = "1" + environment["OPENPYPE_RENDER_JOB"] = "0" + environment["OPENPYPE_REMOTE_PUBLISH"] = "0" + deadline_plugin = "OpenPype" + # Add OpenPype version if we are running from build. + if is_running_from_build(): + self.environ_keys.append("OPENPYPE_VERSION") + + # add environments from self.environ_keys + for env_key in self.environ_keys: + if os.getenv(env_key): + environment[env_key] = os.environ[env_key] + + # pass environment keys from self.environ_job_filter + job_environ = job["Props"].get("Env", {}) + for env_j_key in self.environ_job_filter: + if job_environ.get(env_j_key): + environment[env_j_key] = job_environ[env_j_key] + + # Add mongo url if it's enabled + if instance.context.data.get("deadlinePassMongoUrl"): + mongo_url = os.environ.get("OPENPYPE_MONGO") + if mongo_url: + environment["OPENPYPE_MONGO"] = mongo_url + + priority = self.deadline_priority or instance.data.get("priority", 50) + + instance_settings = self.get_attr_values_from_data(instance.data) + initial_status = instance_settings.get("publishJobState", "Active") + # TODO: Remove this backwards compatibility of `suspend_publish` + if instance.data.get("suspend_publish"): + initial_status = "Suspended" + + args = [ + "--headless", + 'publish', + '"{}"'.format(rootless_metadata_path), + "--targets", "deadline", + "--targets", "farm" + ] + + if is_in_tests(): + args.append("--automatic-tests") + + # Generate the payload for Deadline submission + secondary_pool = ( + self.deadline_pool_secondary or instance.data.get("secondaryPool") + ) + payload = { + "JobInfo": { + "Plugin": deadline_plugin, + "BatchName": job["Props"]["Batch"], + "Name": job_name, + "UserName": job["Props"]["User"], + "Comment": instance.context.data.get("comment", ""), + + "Department": self.deadline_department, + "ChunkSize": self.deadline_chunk_size, + "Priority": priority, + "InitialStatus": initial_status, + + "Group": self.deadline_group, + "Pool": self.deadline_pool or instance.data.get("primaryPool"), + "SecondaryPool": secondary_pool, + # ensure the outputdirectory with correct slashes + "OutputDirectory0": output_dir.replace("\\", "/") + }, + "PluginInfo": { + "Version": self.plugin_pype_version, + "Arguments": " ".join(args), + "SingleFrameOnly": "True", + }, + # Mandatory for Deadline, may be empty + "AuxFiles": [], + } + + if job.get("_id"): + payload["JobInfo"]["JobDependency0"] = job["_id"] + + for index, (key_, value_) in enumerate(environment.items()): + payload["JobInfo"].update( + { + "EnvironmentKeyValue%d" + % index: "{key}={value}".format( + key=key_, value=value_ + ) + } + ) + # remove secondary pool + payload["JobInfo"].pop("SecondaryPool", None) + + self.log.debug("Submitting Deadline publish job ...") + + url = "{}/api/jobs".format(self.deadline_url) + response = requests.post(url, json=payload, timeout=10) + if not response.ok: + raise Exception(response.text) + + deadline_publish_job_id = response.json()["_id"] + + return deadline_publish_job_id + + def process(self, instance): + # type: (pyblish.api.Instance) -> None + """Process plugin. + + Detect type of render farm submission and create and post dependent + job in case of Deadline. It creates json file with metadata needed for + publishing in directory of render. + + Args: + instance (pyblish.api.Instance): Instance data. + + """ + if not instance.data.get("farm"): + self.log.debug("Skipping local instance.") + return + + anatomy = instance.context.data["anatomy"] + + instance_skeleton_data = create_skeleton_instance_cache(instance) + """ + if content of `expectedFiles` list are dictionaries, we will handle + it as list of AOVs, creating instance for every one of them. + + Example: + -------- + + expectedFiles = [ + { + "beauty": [ + "foo_v01.0001.exr", + "foo_v01.0002.exr" + ], + + "Z": [ + "boo_v01.0001.exr", + "boo_v01.0002.exr" + ] + } + ] + + This will create instances for `beauty` and `Z` subset + adding those files to their respective representations. + + If we have only list of files, we collect all file sequences. + More then one doesn't probably make sense, but we'll handle it + like creating one instance with multiple representations. + + Example: + -------- + + expectedFiles = [ + "foo_v01.0001.exr", + "foo_v01.0002.exr", + "xxx_v01.0001.exr", + "xxx_v01.0002.exr" + ] + + This will result in one instance with two representations: + `foo` and `xxx` + """ + + if isinstance(instance.data.get("expectedFiles")[0], dict): + instances = create_instances_for_cache( + instance, instance_skeleton_data) + else: + representations = prepare_cache_representations( + instance_skeleton_data, + instance.data.get("expectedFiles"), + anatomy + ) + + if "representations" not in instance_skeleton_data.keys(): + instance_skeleton_data["representations"] = [] + + # add representation + instance_skeleton_data["representations"] += representations + instances = [instance_skeleton_data] + + # attach instances to subset + if instance.data.get("attachTo"): + instances = attach_instances_to_subset( + instance.data.get("attachTo"), instances + ) + + r''' SUBMiT PUBLiSH JOB 2 D34DLiN3 + ____ + ' ' .---. .---. .--. .---. .--..--..--..--. .---. + | | --= \ | . \/ _|/ \| . \ || || \ |/ _| + | JOB | --= / | | || __| .. | | | |;_ || \ || __| + | | |____./ \.__|._||_.|___./|_____|||__|\__|\.___| + ._____. + + ''' + + render_job = None + submission_type = "" + if instance.data.get("toBeRenderedOn") == "deadline": + render_job = instance.data.pop("deadlineSubmissionJob", None) + submission_type = "deadline" + + if not render_job: + import getpass + + render_job = {} + self.log.debug("Faking job data ...") + render_job["Props"] = {} + # Render job doesn't exist because we do not have prior submission. + # We still use data from it so lets fake it. + # + # Batch name reflect original scene name + + if instance.data.get("assemblySubmissionJobs"): + render_job["Props"]["Batch"] = instance.data.get( + "jobBatchName") + else: + batch = os.path.splitext(os.path.basename( + instance.context.data.get("currentFile")))[0] + render_job["Props"]["Batch"] = batch + # User is deadline user + render_job["Props"]["User"] = instance.context.data.get( + "deadlineUser", getpass.getuser()) + + deadline_publish_job_id = None + if submission_type == "deadline": + # get default deadline webservice url from deadline module + self.deadline_url = instance.context.data["defaultDeadline"] + # if custom one is set in instance, use that + if instance.data.get("deadlineUrl"): + self.deadline_url = instance.data.get("deadlineUrl") + assert self.deadline_url, "Requires Deadline Webservice URL" + + deadline_publish_job_id = \ + self._submit_deadline_post_job(instance, render_job) + + # Inject deadline url to instances. + for inst in instances: + inst["deadlineUrl"] = self.deadline_url + + # publish job file + publish_job = { + "asset": instance_skeleton_data["asset"], + "frameStart": instance_skeleton_data["frameStart"], + "frameEnd": instance_skeleton_data["frameEnd"], + "fps": instance_skeleton_data["fps"], + "source": instance_skeleton_data["source"], + "user": instance.context.data["user"], + "version": instance.context.data["version"], # workfile version + "intent": instance.context.data.get("intent"), + "comment": instance.context.data.get("comment"), + "job": render_job or None, + "session": legacy_io.Session.copy(), + "instances": instances + } + + if deadline_publish_job_id: + publish_job["deadline_publish_job_id"] = deadline_publish_job_id + + metadata_path, rootless_metadata_path = \ + create_metadata_path(instance, anatomy) + + with open(metadata_path, "w") as f: + json.dump(publish_job, f, indent=4, sort_keys=True) + + def _get_publish_folder(self, anatomy, template_data, + asset, subset, context, + family, version=None): + """ + Extracted logic to pre-calculate real publish folder, which is + calculated in IntegrateNew inside of Deadline process. + This should match logic in: + 'collect_anatomy_instance_data' - to + get correct anatomy, family, version for subset and + 'collect_resources_path' + get publish_path + + Args: + anatomy (openpype.pipeline.anatomy.Anatomy): + template_data (dict): pre-calculated collected data for process + asset (string): asset name + subset (string): subset name (actually group name of subset) + family (string): for current deadline process it's always 'render' + TODO - for generic use family needs to be dynamically + calculated like IntegrateNew does + version (int): override version from instance if exists + + Returns: + (string): publish folder where rendered and published files will + be stored + based on 'publish' template + """ + + project_name = context.data["projectName"] + if not version: + version = get_last_version_by_subset_name( + project_name, + subset, + asset_name=asset + ) + if version: + version = int(version["name"]) + 1 + else: + version = get_versioning_start( + project_name, + template_data["app"], + task_name=template_data["task"]["name"], + task_type=template_data["task"]["type"], + family="render", + subset=subset, + project_settings=context.data["project_settings"] + ) + + host_name = context.data["hostName"] + task_info = template_data.get("task") or {} + + template_name = publish.get_publish_template_name( + project_name, + host_name, + family, + task_info.get("name"), + task_info.get("type"), + ) + + template_data["subset"] = subset + template_data["family"] = family + template_data["version"] = version + + render_templates = anatomy.templates_obj[template_name] + if "folder" in render_templates: + publish_folder = render_templates["folder"].format_strict( + template_data + ) + else: + # solve deprecated situation when `folder` key is not underneath + # `publish` anatomy + self.log.warning(( + "Deprecation warning: Anatomy does not have set `folder`" + " key underneath `publish` (in global of for project `{}`)." + ).format(project_name)) + + file_path = render_templates["path"].format_strict(template_data) + publish_folder = os.path.dirname(file_path) + + return publish_folder + + @classmethod + def get_attribute_defs(cls): + return [ + EnumDef("publishJobState", + label="Publish Job State", + items=["Active", "Suspended"], + default="Active") + ] diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 5e8c005d07..c9019b496b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -96,9 +96,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, targets = ["local"] hosts = ["fusion", "max", "maya", "nuke", "houdini", - "celaction", "aftereffects", "harmony"] + "celaction", "aftereffects", "harmony", "blender"] - families = ["render.farm", "prerender.farm", + families = ["render.farm", "render.frames_farm", + "prerender.farm", "prerender.frames_farm", "renderlayer", "imagesequence", "vrayscene", "maxrender", "arnold_rop", "mantra_rop", @@ -106,6 +107,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "redshift_rop"] aov_filter = {"maya": [r".*([Bb]eauty).*"], + "blender": [r".*([Bb]eauty).*"], "aftereffects": [r".*"], # for everything from AE "harmony": [r".*"], # for everything from AE "celaction": [r".*"], @@ -121,7 +123,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", - "OPENPYPE_SG_USER" + "OPENPYPE_SG_USER", + "KITSU_LOGIN", + "KITSU_PWD" ] # custom deadline attributes @@ -211,7 +215,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, environment["OPENPYPE_PUBLISH_JOB"] = "1" environment["OPENPYPE_RENDER_JOB"] = "0" environment["OPENPYPE_REMOTE_PUBLISH"] = "0" - deadline_plugin = "Openpype" + deadline_plugin = "OpenPype" # Add OpenPype version if we are running from build. if is_running_from_build(): self.environ_keys.append("OPENPYPE_VERSION") @@ -299,7 +303,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, payload["JobInfo"]["JobDependency{}".format( job_index)] = assembly_id # noqa: E501 job_index += 1 - else: + elif job.get("_id"): payload["JobInfo"]["JobDependency0"] = job["_id"] for index, (key_, value_) in enumerate(environment.items()): @@ -314,7 +318,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, # remove secondary pool payload["JobInfo"].pop("SecondaryPool", None) - self.log.info("Submitting Deadline job ...") + self.log.debug("Submitting Deadline publish job ...") url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) @@ -342,6 +346,151 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, self.log.debug("Skipping local instance.") return + data = instance.data.copy() + context = instance.context + self.context = context + self.anatomy = instance.context.data["anatomy"] + + asset = data.get("asset") or context.data["asset"] + subset = data.get("subset") + + start = instance.data.get("frameStart") + if start is None: + start = context.data["frameStart"] + + end = instance.data.get("frameEnd") + if end is None: + end = context.data["frameEnd"] + + handle_start = instance.data.get("handleStart") + if handle_start is None: + handle_start = context.data["handleStart"] + + handle_end = instance.data.get("handleEnd") + if handle_end is None: + handle_end = context.data["handleEnd"] + + fps = instance.data.get("fps") + if fps is None: + fps = context.data["fps"] + + if data.get("extendFrames", False): + start, end = self._extend_frames( + asset, + subset, + start, + end, + data["overrideExistingFrame"]) + + try: + source = data["source"] + except KeyError: + source = context.data["currentFile"] + + success, rootless_path = ( + self.anatomy.find_root_template_from_path(source) + ) + if success: + source = rootless_path + + else: + # `rootless_path` is not set to `source` if none of roots match + self.log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues." + ).format(source)) + + family = "render" + if ("prerender" in instance.data["families"] or + "prerender.farm" in instance.data["families"]): + family = "prerender" + families = [family] + + # pass review to families if marked as review + do_not_add_review = False + if data.get("review"): + families.append("review") + elif data.get("review") is False: + self.log.debug("Instance has review explicitly disabled.") + do_not_add_review = True + + instance_skeleton_data = { + "family": family, + "subset": subset, + "families": families, + "asset": asset, + "frameStart": start, + "frameEnd": end, + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStartHandle": start - handle_start, + "frameEndHandle": end + handle_end, + "comment": instance.data["comment"], + "fps": fps, + "source": source, + "extendFrames": data.get("extendFrames"), + "overrideExistingFrame": data.get("overrideExistingFrame"), + "pixelAspect": data.get("pixelAspect", 1), + "resolutionWidth": data.get("resolutionWidth", 1920), + "resolutionHeight": data.get("resolutionHeight", 1080), + "multipartExr": data.get("multipartExr", False), + "jobBatchName": data.get("jobBatchName", ""), + "useSequenceForReview": data.get("useSequenceForReview", True), + # map inputVersions `ObjectId` -> `str` so json supports it + "inputVersions": list(map(str, data.get("inputVersions", []))), + "colorspace": instance.data.get("colorspace"), + "stagingDir_persistent": instance.data.get( + "stagingDir_persistent", False + ) + } + + # skip locking version if we are creating v01 + instance_version = instance.data.get("version") # take this if exists + if instance_version != 1: + instance_skeleton_data["version"] = instance_version + + # transfer specific families from original instance to new render + for item in self.families_transfer: + if item in instance.data.get("families", []): + instance_skeleton_data["families"] += [item] + + # transfer specific properties from original instance based on + # mapping dictionary `instance_transfer` + for key, values in self.instance_transfer.items(): + if key in instance.data.get("families", []): + for v in values: + instance_skeleton_data[v] = instance.data.get(v) + + # look into instance data if representations are not having any + # which are having tag `publish_on_farm` and include them + for repre in instance.data.get("representations", []): + staging_dir = repre.get("stagingDir") + if staging_dir: + success, rootless_staging_dir = ( + self.anatomy.find_root_template_from_path( + staging_dir + ) + ) + if success: + repre["stagingDir"] = rootless_staging_dir + else: + self.log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues on farm." + ).format(staging_dir)) + repre["stagingDir"] = staging_dir + + if "publish_on_farm" in repre.get("tags"): + # create representations attribute of not there + if "representations" not in instance_skeleton_data.keys(): + instance_skeleton_data["representations"] = [] + + instance_skeleton_data["representations"].append(repre) + + instances = None + assert data.get("expectedFiles"), ("Submission from old Pype version" + " - missing expectedFiles") + anatomy = instance.context.data["anatomy"] instance_skeleton_data = create_skeleton_instance( @@ -451,7 +600,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, import getpass render_job = {} - self.log.info("Faking job data ...") + self.log.debug("Faking job data ...") render_job["Props"] = {} # Render job doesn't exist because we do not have prior submission. # We still use data from it so lets fake it. @@ -475,6 +624,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER": os.environ.get("FTRACK_SERVER"), } + deadline_publish_job_id = None if submission_type == "deadline": # get default deadline webservice url from deadline module self.deadline_url = instance.context.data["defaultDeadline"] @@ -558,6 +708,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, """ project_name = context.data["projectName"] + host_name = context.data["hostName"] if not version: version = get_last_version_by_subset_name( project_name, @@ -569,7 +720,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, else: version = get_versioning_start( project_name, - template_data["app"], + host_name, task_name=template_data["task"]["name"], task_type=template_data["task"]["type"], family="render", diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py index d5016a4d82..a7b300beff 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py @@ -1,8 +1,7 @@ -import os -import requests - import pyblish.api +from openpype_modules.deadline.abstract_submit_deadline import requests_get + class ValidateDeadlineConnection(pyblish.api.InstancePlugin): """Validate Deadline Web Service is running""" @@ -10,7 +9,10 @@ class ValidateDeadlineConnection(pyblish.api.InstancePlugin): label = "Validate Deadline Web Service" order = pyblish.api.ValidatorOrder hosts = ["maya", "nuke"] - families = ["renderlayer"] + families = ["renderlayer", "render"] + + # cache + responses = {} def process(self, instance): # get default deadline webservice url from deadline module @@ -18,28 +20,16 @@ class ValidateDeadlineConnection(pyblish.api.InstancePlugin): # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") - self.log.info( - "We have deadline URL on instance {}".format( - deadline_url)) + self.log.debug( + "We have deadline URL on instance {}".format(deadline_url) + ) assert deadline_url, "Requires Deadline Webservice URL" - # Check response - response = self._requests_get(deadline_url) + if deadline_url not in self.responses: + self.responses[deadline_url] = requests_get(deadline_url) + + response = self.responses[deadline_url] assert response.ok, "Response must be ok" assert response.text.startswith("Deadline Web Service "), ( "Web service did not respond with 'Deadline Web Service'" ) - - def _requests_get(self, *args, **kwargs): - """ Wrapper for requests, disabling SSL certificate validation if - DONT_VERIFY_SSL environment variable is found. This is useful when - Deadline or Muster server are running with self-signed certificates - and their certificate is not added to trusted certificates on - client machines. - - WARNING: disabling SSL certificate validation is defeating one line - of defense SSL is providing and it is not recommended. - """ - if 'verify' not in kwargs: - kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa - return requests.get(*args, **kwargs) diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index e1c0595830..90b8241803 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -19,38 +19,65 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, order = pyblish.api.ValidatorOrder families = ["rendering", "render.farm", + "render.frames_farm", "renderFarm", "renderlayer", - "maxrender"] + "maxrender", + "publish.hou"] optional = True + # cache + pools_per_url = {} + def process(self, instance): + if not self.is_active(instance.data): + return + if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return - # get default deadline webservice url from deadline module - deadline_url = instance.context.data["defaultDeadline"] - self.log.info("deadline_url::{}".format(deadline_url)) - pools = DeadlineModule.get_deadline_pools(deadline_url, log=self.log) - self.log.info("pools::{}".format(pools)) - - formatting_data = { - "pools_str": ",".join(pools) - } + deadline_url = self.get_deadline_url(instance) + pools = self.get_pools(deadline_url) + invalid_pools = {} primary_pool = instance.data.get("primaryPool") if primary_pool and primary_pool not in pools: - msg = "Configured primary '{}' not present on Deadline".format( - instance.data["primaryPool"]) - formatting_data["invalid_value_str"] = msg - raise PublishXmlValidationError(self, msg, - formatting_data=formatting_data) + invalid_pools["primary"] = primary_pool secondary_pool = instance.data.get("secondaryPool") if secondary_pool and secondary_pool not in pools: - msg = "Configured secondary '{}' not present on Deadline".format( - instance.data["secondaryPool"]) - formatting_data["invalid_value_str"] = msg - raise PublishXmlValidationError(self, msg, - formatting_data=formatting_data) + invalid_pools["secondary"] = secondary_pool + + if invalid_pools: + message = "\n".join( + "{} pool '{}' not available on Deadline".format(key.title(), + pool) + for key, pool in invalid_pools.items() + ) + raise PublishXmlValidationError( + plugin=self, + message=message, + formatting_data={"pools_str": ", ".join(pools)} + ) + + def get_deadline_url(self, instance): + # get default deadline webservice url from deadline module + deadline_url = instance.context.data["defaultDeadline"] + if instance.data.get("deadlineUrl"): + # if custom one is set in instance, use that + deadline_url = instance.data.get("deadlineUrl") + return deadline_url + + def get_pools(self, deadline_url): + if deadline_url not in self.pools_per_url: + self.log.debug( + "Querying available pools for Deadline url: {}".format( + deadline_url) + ) + pools = DeadlineModule.get_deadline_pools(deadline_url, + log=self.log) + self.log.info("Available pools: {}".format(pools)) + self.pools_per_url[deadline_url] = pools + + return self.pools_per_url[deadline_url] diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index ff4be677e7..5d37e7357e 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -20,8 +20,19 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): allow_user_override = True def process(self, instance): - self.instance = instance - frame_list = self._get_frame_list(instance.data["render_job_id"]) + """Process all the nodes in the instance""" + + # get dependency jobs ids for retrieving frame list + dependent_job_ids = self._get_dependent_job_ids(instance) + + if not dependent_job_ids: + self.log.warning("No dependent jobs found for instance: {}" + "".format(instance)) + return + + # get list of frames from dependent jobs + frame_list = self._get_dependent_jobs_frames( + instance, dependent_job_ids) for repre in instance.data["representations"]: expected_files = self._get_expected_files(repre) @@ -59,7 +70,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # Update the representation expected files self.log.info("Update range from actual job range " "to frame list: {}".format(frame_list)) - repre["files"] = sorted(job_expected_files) + # single item files must be string not list + repre["files"] = (sorted(job_expected_files) + if len(job_expected_files) > 1 else + list(job_expected_files)[0]) # Update the expected files expected_files = job_expected_files @@ -78,26 +92,45 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): ) ) - def _get_frame_list(self, original_job_id): + def _get_dependent_job_ids(self, instance): + """Returns list of dependent job ids from instance metadata.json + + Args: + instance (pyblish.api.Instance): pyblish instance + + Returns: + (list): list of dependent job ids + + """ + dependent_job_ids = [] + + # job_id collected from metadata.json + original_job_id = instance.data["render_job_id"] + + dependent_job_ids_env = os.environ.get("RENDER_JOB_IDS") + if dependent_job_ids_env: + dependent_job_ids = dependent_job_ids_env.split(',') + elif original_job_id: + dependent_job_ids = [original_job_id] + + return dependent_job_ids + + def _get_dependent_jobs_frames(self, instance, dependent_job_ids): """Returns list of frame ranges from all render job. Render job might be re-submitted so job_id in metadata.json could be invalid. GlobalJobPreload injects current job id to RENDER_JOB_IDS. Args: - original_job_id (str) + instance (pyblish.api.Instance): pyblish instance + dependent_job_ids (list): list of dependent job ids Returns: (list) """ all_frame_lists = [] - render_job_ids = os.environ.get("RENDER_JOB_IDS") - if render_job_ids: - render_job_ids = render_job_ids.split(',') - else: # fallback - render_job_ids = [original_job_id] - for job_id in render_job_ids: - job_info = self._get_job_info(job_id) + for job_id in dependent_job_ids: + job_info = self._get_job_info(instance, job_id) frame_list = job_info["Props"].get("Frames") if frame_list: all_frame_lists.extend(frame_list.split(',')) @@ -152,18 +185,25 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): return file_name_template, frame_placeholder - def _get_job_info(self, job_id): + def _get_job_info(self, instance, job_id): """Calls DL for actual job info for 'job_id' Might be different than job info saved in metadata.json if user manually changes job pre/during rendering. + Args: + instance (pyblish.api.Instance): pyblish instance + job_id (str): Deadline job id + + Returns: + (dict): Job info from Deadline + """ # get default deadline webservice url from deadline module - deadline_url = self.instance.context.data["defaultDeadline"] + deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that - if self.instance.data.get("deadlineUrl"): - deadline_url = self.instance.data.get("deadlineUrl") + if instance.data.get("deadlineUrl"): + deadline_url = instance.data.get("deadlineUrl") assert deadline_url, "Requires Deadline Webservice URL" url = "{}/api/jobs?JobID={}".format(deadline_url, job_id) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index 16149d7e20..a1f752605d 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -38,6 +38,7 @@ class AyonDeadlinePlugin(DeadlinePlugin): for publish process. """ def __init__(self): + super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument @@ -84,21 +85,27 @@ class AyonDeadlinePlugin(DeadlinePlugin): } for env, val in environment.items(): - self.SetProcessEnvironmentVariable(env, val) + self.SetEnvironmentVariable(env, val) exe_list = self.GetConfigEntry("AyonExecutable") # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") - exe = FileUtils.SearchFileList(exe_list) + + expanded_paths = [] + for path in exe_list.split(";"): + if path.startswith("~"): + path = os.path.expanduser(path) + expanded_paths.append(path) + exe = FileUtils.SearchFileList(";".join(expanded_paths)) if exe == "": self.FailRender( - "Ayon executable was not found " + - "in the semicolon separated list " + - "\"" + ";".join(exe_list) + "\". " + - "The path to the render executable can be configured " + - "from the Plugin Configuration in the Deadline Monitor.") + "Ayon executable was not found in the semicolon separated " + "list: \"{}\". The path to the render executable can be " + "configured from the Plugin Configuration in the Deadline " + "Monitor.".format(exe_list) + ) return exe def RenderArgument(self): diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 5f7e1f1032..642608f991 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -385,6 +385,12 @@ def inject_openpype_environment(deadlinePlugin): for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) + if "PATH" in contents: + # Set os.environ[PATH] so studio settings' path entries + # can be used to define search path for executables. + print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") + os.environ["PATH"] = contents["PATH"] + script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: script_url = script_url.format(**contents).replace("\\", "/") @@ -489,7 +495,10 @@ def inject_ayon_environment(deadlinePlugin): "AYON_BUNDLE_NAME": ayon_bundle_name, } for env, val in environment.items(): + # Add the env var for the Render Plugin that is about to render deadlinePlugin.SetEnvironmentVariable(env, val) + # Add the env var for current calls to `DeadlinePlugin.RunProcess` + deadlinePlugin.SetProcessEnvironmentVariable(env, val) args_str = subprocess.list2cmdline(args) print(">>> Executing: {} {}".format(exe, args_str)) @@ -509,6 +518,12 @@ def inject_ayon_environment(deadlinePlugin): for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) + if "PATH" in contents: + # Set os.environ[PATH] so studio settings' path entries + # can be used to define search path for executables. + print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") + os.environ["PATH"] = contents["PATH"] + script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: script_url = script_url.format(**contents).replace("\\", "/") @@ -547,7 +562,14 @@ def get_ayon_executable(): # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") - return exe_list + + # Expand user paths + expanded_paths = [] + for path in exe_list.split(";"): + if path.startswith("~"): + path = os.path.expanduser(path) + expanded_paths.append(path) + return ";".join(expanded_paths) def inject_render_job_id(deadlinePlugin): diff --git a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param index ff2949766c..43a54a464e 100644 --- a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param +++ b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param @@ -77,4 +77,22 @@ CategoryOrder=0 Index=4 Label=Harmony 20 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 20 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 20 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_20/lnx86_64/bin/HarmonyPremium \ No newline at end of file +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 20 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 20 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_20/lnx86_64/bin/HarmonyPremium + +[Harmony_RenderExecutable_21] +Type=multilinemultifilename +Category=Render Executables +CategoryOrder=0 +Index=4 +Label=Harmony 21 Render Executable +Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 21 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 21 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_21/lnx86_64/bin/HarmonyPremium + +[Harmony_RenderExecutable_22] +Type=multilinemultifilename +Category=Render Executables +CategoryOrder=0 +Index=4 +Label=Harmony 22 Render Executable +Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 22 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 22 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_22/lnx86_64/bin/HarmonyPremium diff --git a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py index 0615af95dd..32ed76b58d 100644 --- a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py +++ b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 from System import * from System.Diagnostics import * from System.IO import * @@ -8,13 +9,14 @@ from Deadline.Scripting import * def GetDeadlinePlugin(): return HarmonyOpenPypePlugin() - + def CleanupDeadlinePlugin( deadlinePlugin ): deadlinePlugin.Cleanup() - + class HarmonyOpenPypePlugin( DeadlinePlugin ): def __init__( self ): + super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument @@ -24,11 +26,11 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): print("Cleanup") for stdoutHandler in self.StdoutHandlers: del stdoutHandler.HandleCallback - + del self.InitializeProcessCallback del self.RenderExecutableCallback del self.RenderArgumentCallback - + def CheckExitCode( self, exitCode ): print("check code") if exitCode != 0: @@ -36,20 +38,20 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): self.LogInfo( "Renderer reported an error with error code 100. This will be ignored, since the option to ignore it is specified in the Job Properties." ) else: self.FailRender( "Renderer returned non-zero error code %d. Check the renderer's output." % exitCode ) - + def InitializeProcess( self ): self.PluginType = PluginType.Simple self.StdoutHandling = True self.PopupHandling = True - + self.AddStdoutHandlerCallback( "Rendered frame ([0-9]+)" ).HandleCallback += self.HandleStdoutProgress - + def HandleStdoutProgress( self ): startFrame = self.GetStartFrame() endFrame = self.GetEndFrame() if( endFrame - startFrame + 1 != 0 ): self.SetProgress( 100 * ( int(self.GetRegexMatch(1)) - startFrame + 1 ) / ( endFrame - startFrame + 1 ) ) - + def RenderExecutable( self ): version = int( self.GetPluginInfoEntry( "Version" ) ) exe = "" @@ -58,7 +60,7 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): if( exe == "" ): self.FailRender( "Harmony render executable was not found in the configured separated list \"" + exeList + "\". The path to the render executable can be configured from the Plugin Configuration in the Deadline Monitor." ) return exe - + def RenderArgument( self ): renderArguments = "-batch" @@ -72,20 +74,20 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): resolutionX = self.GetIntegerPluginInfoEntryWithDefault( "ResolutionX", -1 ) resolutionY = self.GetIntegerPluginInfoEntryWithDefault( "ResolutionY", -1 ) fov = self.GetFloatPluginInfoEntryWithDefault( "FieldOfView", -1 ) - + if resolutionX > 0 and resolutionY > 0 and fov > 0: renderArguments += " -res " + str( resolutionX ) + " " + str( resolutionY ) + " " + str( fov ) - + camera = self.GetPluginInfoEntryWithDefault( "Camera", "" ) - + if not camera == "": renderArguments += " -camera " + camera - + startFrame = str( self.GetStartFrame() ) endFrame = str( self.GetEndFrame() ) - + renderArguments += " -frames " + startFrame + " " + endFrame - + if not self.GetBooleanPluginInfoEntryWithDefault( "IsDatabase", False ): sceneFilename = self.GetPluginInfoEntryWithDefault( "SceneFile", self.GetDataFilename() ) sceneFilename = RepositoryUtils.CheckPathMapping( sceneFilename ) @@ -99,12 +101,12 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): renderArguments += " -scene " + scene version = self.GetPluginInfoEntryWithDefault( "SceneVersion", "" ) renderArguments += " -version " + version - + #tempSceneDirectory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber()) ) - #preRenderScript = + #preRenderScript = rendernodeNum = 0 scriptBuilder = StringBuilder() - + while True: nodeName = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Node", "" ) if nodeName == "": @@ -115,35 +117,35 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): nodeLeadingZero = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "LeadingZero", "" ) nodeFormat = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Format", "" ) nodeStartFrame = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "StartFrame", "" ) - + if not nodePath == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"drawingName\", 1, \"" + nodePath + "\" );") - + if not nodeLeadingZero == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"leadingZeros\", 1, \"" + nodeLeadingZero + "\" );") - + if not nodeFormat == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"drawingType\", 1, \"" + nodeFormat + "\" );") - + if not nodeStartFrame == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"start\", 1, \"" + nodeStartFrame + "\" );") - + if nodeType == "Movie": nodePath = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Path", "" ) if not nodePath == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"moviePath\", 1, \"" + nodePath + "\" );") - + rendernodeNum += 1 - + tempDirectory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber()) ) preRenderScriptName = Path.Combine( tempDirectory, "preRenderScript.txt" ) - + File.WriteAllText( preRenderScriptName, scriptBuilder.ToString() ) - + preRenderInlineScript = self.GetPluginInfoEntryWithDefault( "PreRenderInlineScript", "" ) if preRenderInlineScript: renderArguments += " -preRenderInlineScript \"" + preRenderInlineScript +"\"" - + renderArguments += " -preRenderScript \"" + preRenderScriptName +"\"" - + return renderArguments diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py index 6e1b973fb9..004c58d346 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py @@ -38,6 +38,7 @@ class OpenPypeDeadlinePlugin(DeadlinePlugin): for publish process. """ def __init__(self): + super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument @@ -107,7 +108,7 @@ class OpenPypeDeadlinePlugin(DeadlinePlugin): "Scanning for compatible requested " f"version {requested_version}")) dir_list = self.GetConfigEntry("OpenPypeInstallationDirs") - + # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": dir_list = dir_list.replace("\\ ", " ") diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index b51daffbc8..9641c16d20 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -249,6 +249,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): def __init__(self): """Init.""" + super().__init__() self.InitializeProcessCallback += self.initialize_process self.RenderExecutableCallback += self.render_executable self.RenderArgumentCallback += self.render_argument diff --git a/openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py b/openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py index f6899843a3..1d73318f6e 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py +++ b/openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py @@ -66,7 +66,7 @@ class TransferHierarchicalValues(ServerAction): "items": [{ "type": "label", "value": ( - "Didn't found custom attributes" + "Didn't find custom attributes" " that can be transferred." ) }] diff --git a/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py index 8ef333effd..2ac02f233e 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py @@ -1,3 +1,6 @@ +import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -73,8 +76,21 @@ class FirstVersionStatus(BaseEvent): if not self.task_status_map: return - entities_info = self.filter_event_ents(event) - if not entities_info: + filtered_entities_info = self.filter_entities_info(event) + if not filtered_entities_info: + return + + for project_id, entities_info in filtered_entities_info.items(): + self.process_by_project(session, event, project_id, entities_info) + + def process_by_project(self, session, event, project_id, entities_info): + project_name = self.get_project_name_from_event( + session, event, project_id + ) + if get_project(project_name) is None: + self.log.debug( + f"Project '{project_name}' not found in OpenPype. Skipping" + ) return entity_ids = [] @@ -154,18 +170,18 @@ class FirstVersionStatus(BaseEvent): exc_info=True ) - def filter_event_ents(self, event): - filtered_ents = [] - for entity in event["data"].get("entities", []): + def filter_entities_info(self, event): + filtered_entities_info = collections.defaultdict(list) + for entity_info in event["data"].get("entities", []): # Care only about add actions - if entity.get("action") != "add": + if entity_info.get("action") != "add": continue # Filter AssetVersions - if entity["entityType"] != "assetversion": + if entity_info["entityType"] != "assetversion": continue - entity_changes = entity.get("changes") or {} + entity_changes = entity_info.get("changes") or {} # Check if version of Asset Version is `1` version_num = entity_changes.get("version", {}).get("new") @@ -177,9 +193,18 @@ class FirstVersionStatus(BaseEvent): if not task_id: continue - filtered_ents.append(entity) + project_id = None + for parent_item in reversed(entity_info["parents"]): + if parent_item["entityType"] == "show": + project_id = parent_item["entityId"] + break - return filtered_ents + if project_id is None: + continue + + filtered_entities_info[project_id].append(entity_info) + + return filtered_entities_info def register(session): diff --git a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py index a100c34f67..8632f038b8 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py +++ b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -99,6 +101,10 @@ class NextTaskUpdate(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name @@ -251,7 +257,7 @@ class NextTaskUpdate(BaseEvent): new_task_name = mapping.get(old_status_name) if not new_task_name: self.log.debug( - "Didn't found mapping for status \"{}\".".format( + "Didn't find mapping for status \"{}\".".format( task_status["name"] ) ) diff --git a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py index ed630ad59d..65c3c1a69a 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py @@ -3,6 +3,8 @@ import copy from typing import Any import ftrack_api + +from openpype.client import get_project from openpype_modules.ftrack.lib import ( BaseEvent, query_custom_attributes, @@ -139,6 +141,10 @@ class PushHierValuesToNonHierEvent(BaseEvent): project_name: str = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return set(), set() + # Load settings project_settings: dict[str, Any] = ( self.get_project_settings_from_event(event, project_name) diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 0aa0b9f9f5..d4dc53b655 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -387,7 +387,7 @@ class SyncToAvalonEvent(BaseEvent): if not data: # TODO logging self.log.warning( - "Didn't found entity by key/value \"{}\" / \"{}\"".format( + "Didn't find entity by key/value \"{}\" / \"{}\"".format( key, value ) ) diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py index 25fa3b0535..d2b395a1a3 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -60,6 +62,10 @@ class TaskStatusToParent(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py index b77849c678..91ee2410d7 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -102,6 +104,10 @@ class TaskToVersionStatus(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py index 64673f792c..318e69f414 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py +++ b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -22,6 +24,10 @@ class ThumbnailEvents(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py index fb40fd6417..fbe44bcba7 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py +++ b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py @@ -1,3 +1,4 @@ +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -50,6 +51,10 @@ class VersionToTaskStatus(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_user/action_component_open.py b/openpype/modules/ftrack/event_handlers_user/action_component_open.py index c731713c10..0efade9d8f 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_component_open.py +++ b/openpype/modules/ftrack/event_handlers_user/action_component_open.py @@ -51,7 +51,7 @@ class ComponentOpen(BaseAction): else: return { 'success': False, - 'message': "Didn't found file: " + fpath + 'message': "Didn't find file: " + fpath } return { diff --git a/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py b/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py index 72a5efbcfe..e1df8e1537 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py @@ -169,7 +169,7 @@ class DeleteAssetSubset(BaseAction): return { "success": True, "message": ( - "Didn't found entities in avalon." + "Didn't find entities in avalon." " You can use Ftrack's Delete button for the selection." ) } diff --git a/openpype/modules/ftrack/event_handlers_user/action_delivery.py b/openpype/modules/ftrack/event_handlers_user/action_delivery.py index 559de3a24d..c198389b98 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delivery.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delivery.py @@ -61,7 +61,7 @@ class Delivery(BaseAction): return { "success": False, "message": ( - "Didn't found project \"{}\" in avalon." + "Didn't find project \"{}\" in avalon." ).format(project_name) } diff --git a/openpype/modules/ftrack/event_handlers_user/action_job_killer.py b/openpype/modules/ftrack/event_handlers_user/action_job_killer.py index dd68c75f84..250670f016 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_job_killer.py +++ b/openpype/modules/ftrack/event_handlers_user/action_job_killer.py @@ -29,7 +29,7 @@ class JobKiller(BaseAction): if not jobs: return { "success": True, - "message": "Didn't found any running jobs" + "message": "Didn't find any running jobs" } # Collect user ids from jobs diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py index fe3275ce2c..bea76718ca 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py @@ -44,19 +44,25 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): self.log.debug("Project found: {0}".format(project_entity)) + task_object_type = session.query( + "ObjectType where name is 'Task'").one() + task_object_type_id = task_object_type["id"] asset_entity = None if asset_name: # Find asset entity entity_query = ( - 'TypedContext where project_id is "{0}"' - ' and name is "{1}"' - ).format(project_entity["id"], asset_name) + "TypedContext where project_id is '{}'" + " and name is '{}'" + " and object_type_id != '{}'" + ).format( + project_entity["id"], + asset_name, + task_object_type_id + ) self.log.debug("Asset entity query: < {0} >".format(entity_query)) asset_entities = [] for entity in session.query(entity_query).all(): - # Skip tasks - if entity.entity_type.lower() != "task": - asset_entities.append(entity) + asset_entities.append(entity) if len(asset_entities) == 0: raise AssertionError(( @@ -103,10 +109,19 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): context.data["ftrackEntity"] = asset_entity context.data["ftrackTask"] = task_entity - self.per_instance_process(context, asset_entity, task_entity) + self.per_instance_process( + context, + asset_entity, + task_entity, + task_object_type_id + ) def per_instance_process( - self, context, context_asset_entity, context_task_entity + self, + context, + context_asset_entity, + context_task_entity, + task_object_type_id ): context_task_name = None context_asset_name = None @@ -182,23 +197,27 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): session = context.data["ftrackSession"] project_entity = context.data["ftrackProject"] - asset_names = set() - for asset_name in instance_by_asset_and_task.keys(): - asset_names.add(asset_name) + asset_names = set(instance_by_asset_and_task.keys()) joined_asset_names = ",".join([ "\"{}\"".format(name) for name in asset_names ]) - entities = session.query(( - "TypedContext where project_id is \"{}\" and name in ({})" - ).format(project_entity["id"], joined_asset_names)).all() + entities = session.query( + ( + "TypedContext where project_id is \"{}\" and name in ({})" + " and object_type_id != '{}'" + ).format( + project_entity["id"], + joined_asset_names, + task_object_type_id + ) + ).all() entities_by_name = { entity["name"]: entity for entity in entities } - for asset_name, by_task_data in instance_by_asset_and_task.items(): entity = entities_by_name.get(asset_name) task_entity_by_name = {} diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index 6ca5d1d4ef..858c0bb2d6 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -27,8 +27,8 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): def process(self, instance): component_list = instance.data.get("ftrackComponentsList") if not component_list: - self.log.info( - "Instance don't have components to integrate to Ftrack." + self.log.debug( + "Instance doesn't have components to integrate to Ftrack." " Skipping." ) return @@ -37,7 +37,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): task_entity, parent_entity = self.get_instance_entities( instance, context) if parent_entity is None: - self.log.info(( + self.log.debug(( "Skipping ftrack integration. Instance \"{}\" does not" " have specified ftrack entities." ).format(str(instance))) @@ -323,7 +323,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): "type_id": asset_type_id, "context_id": parent_id } - self.log.info("Created new Asset with data: {}.".format(asset_data)) + self.log.debug("Created new Asset with data: {}.".format(asset_data)) session.create("Asset", asset_data) session.commit() return self._query_asset(session, asset_name, asset_type_id, parent_id) @@ -353,7 +353,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): status_name = asset_version_data.pop("status_name", None) # Try query asset version by criteria (asset id and version) - version = asset_version_data.get("version") or 0 + version = asset_version_data.get("version") or "0" asset_version_entity = self._query_asset_version( session, version, asset_id ) @@ -384,7 +384,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): if comment: new_asset_version_data["comment"] = comment - self.log.info("Created new AssetVersion with data {}".format( + self.log.debug("Created new AssetVersion with data {}".format( new_asset_version_data )) session.create("AssetVersion", new_asset_version_data) @@ -555,7 +555,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): location=location ) data["component"] = component_entity - self.log.info( + self.log.debug( ( "Created new Component with path: {0}, data: {1}," " metadata: {2}, location: {3}" diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py index 6ed02bc8b6..ceaff8ff54 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py @@ -40,7 +40,7 @@ class IntegrateFtrackDescription(pyblish.api.InstancePlugin): comment = instance.data["comment"] if not comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is set to `{}`".format(comment)) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 75f43cb22f..edad0b0132 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -61,6 +61,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): additional_metadata_keys = [] def process(self, instance): + # QUESTION: should this be operating even for `farm` target? self.log.debug("instance {}".format(instance)) instance_repres = instance.data.get("representations") @@ -143,70 +144,93 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): unmanaged_location_name = "ftrack.unmanaged" ftrack_server_location_name = "ftrack.server" + # check if any outputName keys are in review_representations + # also check if any outputName keys are in thumbnail_representations + synced_multiple_output_names = [] + for review_repre in review_representations: + review_output_name = review_repre.get("outputName") + if not review_output_name: + continue + for thumb_repre in thumbnail_representations: + thumb_output_name = thumb_repre.get("outputName") + if not thumb_output_name: + continue + if ( + thumb_output_name == review_output_name + # output name can be added also as tags during intermediate + # files creation + or thumb_output_name in review_repre.get("tags", []) + ): + synced_multiple_output_names.append( + thumb_repre["outputName"]) + self.log.debug("Multiple output names: {}".format( + synced_multiple_output_names + )) + multiple_synced_thumbnails = len(synced_multiple_output_names) > 1 + # Components data component_list = [] - # Components that will be duplicated to unmanaged location - src_components_to_add = [] + thumbnail_data_items = [] # Create thumbnail components - # TODO what if there is multiple thumbnails? - first_thumbnail_component = None - first_thumbnail_component_repre = None - - if not review_representations or has_movie_review: - for repre in thumbnail_representations: - repre_path = get_publish_repre_path(instance, repre, False) - if not repre_path: - self.log.warning( - "Published path is not set and source was removed." - ) - continue - - # Create copy of base comp item and append it - thumbnail_item = copy.deepcopy(base_component_item) - thumbnail_item["component_path"] = repre_path - thumbnail_item["component_data"] = { - "name": "thumbnail" - } - thumbnail_item["thumbnail"] = True - - # Create copy of item before setting location - if "delete" not in repre.get("tags", []): - src_components_to_add.append(copy.deepcopy(thumbnail_item)) - # Create copy of first thumbnail - if first_thumbnail_component is None: - first_thumbnail_component_repre = repre - first_thumbnail_component = thumbnail_item - # Set location - thumbnail_item["component_location_name"] = ( - ftrack_server_location_name - ) - - # Add item to component list - component_list.append(thumbnail_item) - - if first_thumbnail_component is not None: - metadata = self._prepare_image_component_metadata( - first_thumbnail_component_repre, - first_thumbnail_component["component_path"] + for repre in thumbnail_representations: + # get repre path from representation + # and return published_path if available + # the path is validated and if it does not exists it returns None + repre_path = get_publish_repre_path( + instance, + repre, + only_published=False ) + if not repre_path: + self.log.warning( + "Published path is not set or source was removed." + ) + continue - if metadata: - component_data = first_thumbnail_component["component_data"] - component_data["metadata"] = metadata + # Create copy of base comp item and append it + thumbnail_item = copy.deepcopy(base_component_item) + thumbnail_item.update({ + "component_path": repre_path, + "component_data": { + "name": ( + "thumbnail" if review_representations + else "ftrackreview-image" + ), + "metadata": self._prepare_image_component_metadata( + repre, + repre_path + ) + }, + "thumbnail": True, + "component_location_name": ftrack_server_location_name + }) - if review_representations: - component_data["name"] = "thumbnail" - else: - component_data["name"] = "ftrackreview-image" + # add thumbnail data to items for future synchronization + current_item_data = { + "sync_key": repre.get("outputName"), + "representation": repre, + "item": thumbnail_item + } + # Create copy of item before setting location + if "delete" not in repre.get("tags", []): + src_comp = self._create_src_component( + instance, + repre, + copy.deepcopy(thumbnail_item), + unmanaged_location_name + ) + component_list.append(src_comp) + + current_item_data["src_component"] = src_comp + + # Add item to component list + thumbnail_data_items.append(current_item_data) # Create review components # Change asset name of each new component for review - is_first_review_repre = True - not_first_components = [] - extended_asset_name = "" multiple_reviewable = len(review_representations) > 1 - for repre in review_representations: + for index, repre in enumerate(review_representations): if not self._is_repre_video(repre) and has_movie_review: self.log.debug("Movie repre has priority " "from {}".format(repre)) @@ -222,45 +246,50 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Create copy of base comp item and append it review_item = copy.deepcopy(base_component_item) - # get asset name and define extended name variant - asset_name = review_item["asset_data"]["name"] - extended_asset_name = "_".join( - (asset_name, repre["name"]) + # get first or synchronize thumbnail item + sync_thumbnail_item = None + sync_thumbnail_item_src = None + sync_thumbnail_data = self._get_matching_thumbnail_item( + repre, + thumbnail_data_items, + multiple_synced_thumbnails ) + if sync_thumbnail_data: + sync_thumbnail_item = sync_thumbnail_data.get("item") + sync_thumbnail_item_src = sync_thumbnail_data.get( + "src_component") - # reset extended if no need for extended asset name - if ( - self.keep_first_subset_name_for_review - and is_first_review_repre - ): - extended_asset_name = "" - else: - # only rename if multiple reviewable - if multiple_reviewable: - review_item["asset_data"]["name"] = extended_asset_name - else: - extended_asset_name = "" + """ + Renaming asset name only to those components which are explicitly + allowed in settings. Usually clients wanted to keep first component + as untouched product name with version and any other assetVersion + to be named with extended form. The renaming will only happen if + there is more than one reviewable component and extended name is + not empty. + """ + extended_asset_name = self._make_extended_component_name( + base_component_item, repre, index) - # rename all already created components - # only if first repre and extended name available - if is_first_review_repre and extended_asset_name: - # and rename all already created components - for _ci in component_list: - _ci["asset_data"]["name"] = extended_asset_name + if multiple_reviewable and extended_asset_name: + review_item["asset_data"]["name"] = extended_asset_name + # rename also thumbnail + if sync_thumbnail_item: + sync_thumbnail_item["asset_data"]["name"] = ( + extended_asset_name + ) + # rename also src_thumbnail + if sync_thumbnail_item_src: + sync_thumbnail_item_src["asset_data"]["name"] = ( + extended_asset_name + ) - # and rename all already created src components - for _sci in src_components_to_add: - _sci["asset_data"]["name"] = extended_asset_name - - # rename also first thumbnail component if any - if first_thumbnail_component is not None: - first_thumbnail_component[ - "asset_data"]["name"] = extended_asset_name - - # Change location - review_item["component_path"] = repre_path - # Change component data + # adding thumbnail component to component list + if sync_thumbnail_item: + component_list.append(copy.deepcopy(sync_thumbnail_item)) + if sync_thumbnail_item_src: + component_list.append(copy.deepcopy(sync_thumbnail_item_src)) + # add metadata to review component if self._is_repre_video(repre): component_name = "ftrackreview-mp4" metadata = self._prepare_video_component_metadata( @@ -273,28 +302,29 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): ) review_item["thumbnail"] = True - review_item["component_data"] = { - # Default component name is "main". - "name": component_name, - "metadata": metadata - } - - if is_first_review_repre: - is_first_review_repre = False - else: - # later detection for thumbnail duplication - not_first_components.append(review_item) + review_item.update({ + "component_path": repre_path, + "component_data": { + "name": component_name, + "metadata": metadata + }, + "component_location_name": ftrack_server_location_name + }) # Create copy of item before setting location if "delete" not in repre.get("tags", []): - src_components_to_add.append(copy.deepcopy(review_item)) + src_comp = self._create_src_component( + instance, + repre, + copy.deepcopy(review_item), + unmanaged_location_name + ) + component_list.append(src_comp) - # Set location - review_item["component_location_name"] = ( - ftrack_server_location_name - ) # Add item to component list component_list.append(review_item) + + if self.upload_reviewable_with_origin_name: origin_name_component = copy.deepcopy(review_item) filename = os.path.basename(repre_path) @@ -303,34 +333,6 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): ) component_list.append(origin_name_component) - # Duplicate thumbnail component for all not first reviews - if first_thumbnail_component is not None: - for component_item in not_first_components: - asset_name = component_item["asset_data"]["name"] - new_thumbnail_component = copy.deepcopy( - first_thumbnail_component - ) - new_thumbnail_component["asset_data"]["name"] = asset_name - new_thumbnail_component["component_location_name"] = ( - ftrack_server_location_name - ) - component_list.append(new_thumbnail_component) - - # Add source components for review and thubmnail components - for copy_src_item in src_components_to_add: - # Make sure thumbnail is disabled - copy_src_item["thumbnail"] = False - # Set location - copy_src_item["component_location_name"] = unmanaged_location_name - # Modify name of component to have suffix "_src" - component_data = copy_src_item["component_data"] - component_name = component_data["name"] - component_data["name"] = component_name + "_src" - component_data["metadata"] = self._prepare_component_metadata( - instance, repre, copy_src_item["component_path"], False - ) - component_list.append(copy_src_item) - # Add others representations as component for repre in other_representations: published_path = get_publish_repre_path(instance, repre, True) @@ -346,15 +348,17 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): ): other_item["asset_data"]["name"] = extended_asset_name - component_data = { - "name": repre["name"], - "metadata": self._prepare_component_metadata( - instance, repre, published_path, False - ) - } - other_item["component_data"] = component_data - other_item["component_location_name"] = unmanaged_location_name - other_item["component_path"] = published_path + other_item.update({ + "component_path": published_path, + "component_data": { + "name": repre["name"], + "metadata": self._prepare_component_metadata( + instance, repre, published_path, False + ) + }, + "component_location_name": unmanaged_location_name, + }) + component_list.append(other_item) def json_obj_parser(obj): @@ -370,6 +374,124 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): )) instance.data["ftrackComponentsList"] = component_list + def _get_matching_thumbnail_item( + self, + review_representation, + thumbnail_data_items, + are_multiple_synced_thumbnails + ): + """Return matching thumbnail item from list of thumbnail items. + + If a thumbnail item already exists, this should return it. + The benefit is that if an `outputName` key is found in + representation and is also used as a `sync_key` in a thumbnail + data item, it can sync with that item. + + Args: + review_representation (dict): Review representation + thumbnail_data_items (list): List of thumbnail data items + are_multiple_synced_thumbnails (bool): If there are multiple synced + thumbnails + + Returns: + dict: Thumbnail data item or empty dict + """ + output_name = review_representation.get("outputName") + tags = review_representation.get("tags", []) + matching_thumbnail_item = {} + for thumb_item in thumbnail_data_items: + if ( + are_multiple_synced_thumbnails + and ( + thumb_item["sync_key"] == output_name + # intermediate files can have preset name in tags + # this is usually aligned with `outputName` distributed + # during thumbnail creation in `need_thumbnail` tagging + # workflow + or thumb_item["sync_key"] in tags + ) + ): + # return only synchronized thumbnail if multiple + matching_thumbnail_item = thumb_item + break + elif not are_multiple_synced_thumbnails: + # return any first found thumbnail since we need thumbnail + # but dont care which one + matching_thumbnail_item = thumb_item + break + + if not matching_thumbnail_item: + # WARNING: this can only happen if multiple thumbnails + # workflow is broken, since it found multiple matching outputName + # in representation but they do not align with any thumbnail item + self.log.warning( + "No matching thumbnail item found for output name " + "'{}'".format(output_name) + ) + if not thumbnail_data_items: + self.log.warning( + "No thumbnail data items found" + ) + return {} + # as fallback return first thumbnail item + return thumbnail_data_items[0] + + return matching_thumbnail_item + + def _make_extended_component_name( + self, component_item, repre, iteration_index): + """ Returns the extended component name + + Name is based on the asset name and representation name. + + Args: + component_item (dict): The component item dictionary. + repre (dict): The representation dictionary. + iteration_index (int): The index of the iteration. + + Returns: + str: The extended component name. + + """ + # reset extended if no need for extended asset name + if self.keep_first_subset_name_for_review and iteration_index == 0: + return + + # get asset name and define extended name variant + asset_name = component_item["asset_data"]["name"] + return "_".join( + (asset_name, repre["name"]) + ) + + def _create_src_component( + self, instance, repre, component_item, location): + """Create src component for thumbnail. + + This will replicate the input component and change its name to + have suffix "_src". + + Args: + instance (pyblish.api.Instance): Instance + repre (dict): Representation + component_item (dict): Component item + location (str): Location name + + Returns: + dict: Component item + """ + # Make sure thumbnail is disabled + component_item["thumbnail"] = False + # Set location + component_item["component_location_name"] = location + # Modify name of component to have suffix "_src" + component_data = component_item["component_data"] + component_name = component_data["name"] + component_data["name"] = component_name + "_src" + component_data["metadata"] = self._prepare_component_metadata( + instance, repre, component_item["component_path"], False + ) + return component_item + def _collect_additional_metadata(self, streams): pass @@ -472,9 +594,6 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): stream_width = tmp_width stream_height = tmp_height - self.log.debug("FPS from stream is {} and duration is {}".format( - input_framerate, stream_duration - )) frame_out = float(stream_duration) * stream_fps break diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py index 6e82897d89..10b7932cdf 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py @@ -47,7 +47,7 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin): app_label = context.data["appLabel"] comment = instance.data["comment"] if not comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is set to `{}`".format(comment)) @@ -127,14 +127,14 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin): note_text = StringTemplate.format_template(template, format_data) if not note_text.solved: - self.log.warning(( + self.log.debug(( "Note template require more keys then can be provided." "\nTemplate: {}\nMissing values for keys:{}\nData: {}" ).format(template, note_text.missing_keys, format_data)) continue if not note_text: - self.log.info(( + self.log.debug(( "Note for AssetVersion {} would be empty. Skipping." "\nTemplate: {}\nData: {}" ).format(asset_version["id"], template, format_data)) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 6e5dd056f3..b66e1f01e0 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -121,7 +121,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): publish_comment = self.format_publish_comment(instance) if not publish_comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is `{}`".format(publish_comment)) diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py index c4331b6094..4f0674c94f 100644 --- a/openpype/modules/launcher_action.py +++ b/openpype/modules/launcher_action.py @@ -1,3 +1,6 @@ +import os + +from openpype import PLUGINS_DIR, AYON_SERVER_ENABLED from openpype.modules import ( OpenPypeModule, ITrayAction, @@ -13,36 +16,66 @@ class LauncherAction(OpenPypeModule, ITrayAction): self.enabled = True # Tray attributes - self.window = None + self._window = None def tray_init(self): - self.create_window() + self._create_window() - self.add_doubleclick_callback(self.show_launcher) + self.add_doubleclick_callback(self._show_launcher) def tray_start(self): return def connect_with_modules(self, enabled_modules): # Register actions - if self.tray_initialized: - from openpype.tools.launcher import actions - actions.register_config_actions() - actions_paths = self.manager.collect_plugin_paths()["actions"] - actions.register_actions_from_paths(actions_paths) - actions.register_environment_actions() - - def create_window(self): - if self.window: + if not self.tray_initialized: return - from openpype.tools.launcher import LauncherWindow - self.window = LauncherWindow() + + from openpype.pipeline.actions import register_launcher_action_path + + actions_dir = os.path.join(PLUGINS_DIR, "actions") + if os.path.exists(actions_dir): + register_launcher_action_path(actions_dir) + + actions_paths = self.manager.collect_plugin_paths()["actions"] + for path in actions_paths: + if path and os.path.exists(path): + register_launcher_action_path(path) + + paths_str = os.environ.get("AVALON_ACTIONS") or "" + if paths_str: + self.log.warning( + "WARNING: 'AVALON_ACTIONS' is deprecated. Support of this" + " environment variable will be removed in future versions." + " Please consider using 'OpenPypeModule' to define custom" + " action paths. Planned version to drop the support" + " is 3.17.2 or 3.18.0 ." + ) + + for path in paths_str.split(os.pathsep): + if path and os.path.exists(path): + register_launcher_action_path(path) def on_action_trigger(self): - self.show_launcher() + """Implementation for ITrayAction interface. - def show_launcher(self): - if self.window: - self.window.show() - self.window.raise_() - self.window.activateWindow() + Show launcher tool on action trigger. + """ + + self._show_launcher() + + def _create_window(self): + if self._window: + return + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_launcher.ui import LauncherWindow + else: + from openpype.tools.launcher import LauncherWindow + self._window = LauncherWindow() + + def _show_launcher(self): + if self._window is None: + return + self._window.show() + self._window.raise_() + self._window.activateWindow() diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/modules/muster/plugins/publish/submit_maya_muster.py similarity index 97% rename from openpype/hosts/maya/plugins/publish/submit_maya_muster.py rename to openpype/modules/muster/plugins/publish/submit_maya_muster.py index 8e219eae85..f6b3bfbbfd 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/modules/muster/plugins/publish/submit_maya_muster.py @@ -10,6 +10,7 @@ from maya import cmds import pyblish.api from openpype.lib import requests_post from openpype.hosts.maya.api import lib +from openpype.hosts.maya.api.lib_rendersettings import RenderSettings from openpype.pipeline import legacy_io from openpype.settings import get_system_settings @@ -25,6 +26,7 @@ def _get_template_id(renderer): :rtype: int """ + # TODO: Use settings from context? templates = get_system_settings()["modules"]["muster"]["templates_mapping"] if not templates: raise RuntimeError(("Muster template mapping missing in " @@ -67,10 +69,8 @@ def get_renderer_variables(renderlayer=None): """ renderer = lib.get_renderer(renderlayer or lib.get_current_renderlayer()) - render_attrs = lib.RENDER_ATTRS.get(renderer, lib.RENDER_ATTRS["default"]) - padding = cmds.getAttr("{}.{}".format(render_attrs["node"], - render_attrs["padding"])) + padding = cmds.getAttr(RenderSettings.get_padding_attr(renderer)) filename_0 = cmds.renderSettings(fullPath=True, firstImageName=True)[0] @@ -215,9 +215,9 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): :rtype: int :raises: Exception if template ID isn't found """ - self.log.info("Trying to find template for [{}]".format(renderer)) + self.log.debug("Trying to find template for [{}]".format(renderer)) mapped = _get_template_id(renderer) - self.log.info("got id [{}]".format(mapped)) + self.log.debug("got id [{}]".format(mapped)) return self._templates.get(mapped) def _submit(self, payload): @@ -249,7 +249,6 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): Authenticate with Muster, collect all data, prepare path for post render publish job and submit job to farm. """ - instance.data["toBeRenderedOn"] = "muster" # setup muster environment self.MUSTER_REST_URL = os.environ.get("MUSTER_REST_URL") @@ -454,8 +453,8 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): self.preflight_check(instance) - self.log.info("Submitting ...") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting ...") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) response = self._submit(payload) # response = requests.post(url, json=payload) diff --git a/openpype/modules/python_console_interpreter/window/widgets.py b/openpype/modules/python_console_interpreter/window/widgets.py index b670352f44..49a5f62165 100644 --- a/openpype/modules/python_console_interpreter/window/widgets.py +++ b/openpype/modules/python_console_interpreter/window/widgets.py @@ -8,6 +8,7 @@ import appdirs from qtpy import QtCore, QtWidgets, QtGui from openpype import resources +from openpype import AYON_SERVER_ENABLED from openpype.style import load_stylesheet from openpype.lib import JSONSettingRegistry @@ -339,7 +340,9 @@ class PythonInterpreterWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(PythonInterpreterWidget, self).__init__(parent) - self.setWindowTitle("OpenPype Console") + self.setWindowTitle("{} Console".format( + "AYON" if AYON_SERVER_ENABLED else "OpenPype" + )) self.setWindowIcon(QtGui.QIcon(resources.get_openpype_icon_filepath())) self.ansi_escape = re.compile( diff --git a/openpype/modules/royalrender/lib.py b/openpype/modules/royalrender/lib.py index 4708d25eed..9c4221d9cd 100644 --- a/openpype/modules/royalrender/lib.py +++ b/openpype/modules/royalrender/lib.py @@ -1,23 +1,28 @@ # -*- coding: utf-8 -*- """Submitting render job to RoyalRender.""" import os -import re +import json import platform +import re +import tempfile +import uuid from datetime import datetime import pyblish.api -from openpype.tests.lib import is_in_tests -from openpype.pipeline.publish.lib import get_published_workfile_instance -from openpype.pipeline.publish import KnownPublishError + +from openpype.lib import BoolDef, NumberDef, is_running_from_build +from openpype.lib.execute import run_openpype_process from openpype.modules.royalrender.api import Api as rrApi from openpype.modules.royalrender.rr_job import ( - RRJob, CustomAttribute, get_rr_platform) -from openpype.lib import ( - is_running_from_build, - BoolDef, - NumberDef, + CustomAttribute, + RRJob, + RREnvList, + get_rr_platform, ) from openpype.pipeline import OpenPypePyblishPluginMixin +from openpype.pipeline.publish import KnownPublishError +from openpype.pipeline.publish.lib import get_published_workfile_instance +from openpype.tests.lib import is_in_tests class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, @@ -302,3 +307,68 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, path = path.replace(first_frame, "#" * padding) return path + + def inject_environment(self, instance, job): + # type: (pyblish.api.Instance, RRJob) -> RRJob + """Inject environment variables for RR submission. + + This function mimics the behaviour of the Deadline + integration. It is just temporary solution until proper + runtime environment injection is implemented in RR. + + Args: + instance (pyblish.api.Instance): Publishing instance + job (RRJob): RRJob instance to be injected. + + Returns: + RRJob: Injected RRJob instance. + + Throws: + RuntimeError: If any of the required env vars is missing. + + """ + + temp_file_name = "{}_{}.json".format( + datetime.utcnow().strftime('%Y%m%d%H%M%S%f'), + str(uuid.uuid1()) + ) + + export_url = os.path.join(tempfile.gettempdir(), temp_file_name) + print(">>> Temporary path: {}".format(export_url)) + + args = [ + "--headless", + "extractenvironments", + export_url + ] + + anatomy_data = instance.context.data["anatomyData"] + + add_kwargs = { + "project": anatomy_data["project"]["name"], + "asset": instance.context.data["asset"], + "task": anatomy_data["task"]["name"], + "app": instance.context.data.get("appName"), + "envgroup": "farm" + } + + if os.getenv('IS_TEST'): + args.append("--automatic-tests") + + if not all(add_kwargs.values()): + raise RuntimeError(( + "Missing required env vars: AVALON_PROJECT, AVALON_ASSET," + " AVALON_TASK, AVALON_APP_NAME" + )) + + for key, value in add_kwargs.items(): + args.extend([f"--{key}", value]) + self.log.debug("Executing: {}".format(" ".join(args))) + run_openpype_process(*args, logger=self.log) + + self.log.debug("Loading file ...") + with open(export_url) as fp: + contents = json.load(fp) + + job.rrEnvList = RREnvList(contents).serialize() + return job diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 22d910b7cd..775a2964fd 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -2,7 +2,7 @@ """Submitting render job to RoyalRender.""" import os -from maya.OpenMaya import MGlobal +from maya.OpenMaya import MGlobal # noqa: F401 from openpype.modules.royalrender import lib from openpype.pipeline.farm.tools import iter_expected_files @@ -38,5 +38,6 @@ class CreateMayaRoyalRenderJob(lib.BaseCreateRoyalRenderJob): job = self.get_job(instance, self.scene_path, first_file_path, layer_name) job = self.update_job_with_host_specific(instance, job) + job = self.inject_environment(instance, job) instance.data["rrJobs"].append(job) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py index 71daa6edf8..4f589e56f8 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py @@ -25,6 +25,7 @@ class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): jobs = self.create_jobs(instance) for job in jobs: job = self.update_job_with_host_specific(instance, job) + job = self.inject_environment(instance, job) instance.data["rrJobs"].append(job) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 3eb49a39ee..d4af1c2aee 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -189,7 +189,7 @@ class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin, environment = RREnvList({ "AVALON_PROJECT": anatomy_data["project"]["name"], - "AVALON_ASSET": anatomy_data["asset"], + "AVALON_ASSET": instance.context.data["asset"], "AVALON_TASK": anatomy_data["task"]["name"], "OPENPYPE_USERNAME": anatomy_data["user"] }) @@ -205,6 +205,9 @@ class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin, jobs_pre_ids = [] for job in instance.data["rrJobs"]: # type: RRJob if job.rrEnvList: + if len(job.rrEnvList) > 2000: + self.log.warning(("Job environment is too long " + f"{len(job.rrEnvList)} > 2000")) job_environ.update( dict(RREnvList.parse(job.rrEnvList)) ) diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index b85ac592f8..62a82d45e8 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -32,7 +32,7 @@ class RREnvList(dict): """Parse rrEnvList string and return it as RREnvList object.""" out = RREnvList() for var in data.split("~~~"): - k, v = var.split("=") + k, v = var.split("=", maxsplit=1) out[k] = v return out @@ -172,7 +172,7 @@ class RRJob(object): # Environment # only used in RR 8.3 and newer - rrEnvList = attr.ib(default=None) # type: str + rrEnvList = attr.ib(default=None, type=str) # type: str class SubmitterParameter: diff --git a/openpype/modules/slack/plugins/publish/collect_slack_family.py b/openpype/modules/slack/plugins/publish/collect_slack_family.py index b3e7bbdcec..cbed2d1012 100644 --- a/openpype/modules/slack/plugins/publish/collect_slack_family.py +++ b/openpype/modules/slack/plugins/publish/collect_slack_family.py @@ -38,7 +38,7 @@ class CollectSlackFamilies(pyblish.api.InstancePlugin, "families": family, "tasks": task_data.get("name"), "task_types": task_data.get("type"), - "hosts": instance.data["anatomyData"]["app"], + "hosts": instance.context.data["hostName"], "subsets": instance.data["subset"] } profile = filter_profiles(self.profiles, key_values, diff --git a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py index 77f6933756..bdb4b109a1 100644 --- a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py +++ b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py @@ -1,5 +1,6 @@ import os import shutil +import filecmp from openpype.client.entities import get_representations from openpype.lib.applications import PreLaunchHook, LaunchTypes @@ -116,6 +117,18 @@ class CopyLastPublishedWorkfile(PreLaunchHook): "task": {"name": task_name, "type": task_type} } + # Add version filter + workfile_version = self.launch_context.data.get("workfile_version", -1) + if workfile_version > 0 and workfile_version not in {None, "last"}: + context_filters["version"] = self.launch_context.data[ + "workfile_version" + ] + + # Only one version will be matched + version_index = 0 + else: + version_index = workfile_version + workfile_representations = list(get_representations( project_name, context_filters=context_filters @@ -133,9 +146,10 @@ class CopyLastPublishedWorkfile(PreLaunchHook): lambda r: r["context"].get("version") is not None, workfile_representations ) - workfile_representation = max( + # Get workfile version + workfile_representation = sorted( filtered_repres, key=lambda r: r["context"]["version"] - ) + )[version_index] # Copy file and substitute path last_published_workfile_path = download_last_published_workfile( @@ -181,3 +195,69 @@ class CopyLastPublishedWorkfile(PreLaunchHook): self.data["last_workfile_path"] = local_workfile_path # Keep source filepath for further path conformation self.data["source_filepath"] = last_published_workfile_path + + # Get resources directory + resources_dir = os.path.join( + os.path.dirname(local_workfile_path), 'resources' + ) + # Make resource directory if it doesn't exist + if not os.path.exists(resources_dir): + os.mkdir(resources_dir) + + # Copy resources to the local resources directory + for file in workfile_representation['files']: + # Get resource main path + resource_main_path = anatomy.fill_root(file["path"]) + + # Get resource file basename + resource_basename = os.path.basename(resource_main_path) + + # Only copy if the resource file exists, and it's not the workfile + if ( + not os.path.exists(resource_main_path) + or resource_basename == os.path.basename( + last_published_workfile_path + ) + ): + continue + + # Get resource path in workfile folder + resource_work_path = os.path.join( + resources_dir, resource_basename + ) + + # Check if the resource file already exists in the resources folder + if os.path.exists(resource_work_path): + # Check if both files are the same + if filecmp.cmp(resource_main_path, resource_work_path): + self.log.warning( + 'Resource "{}" already exists.' + .format(resource_basename) + ) + continue + else: + # Add `.old` to existing resource path + resource_path_old = resource_work_path + '.old' + if os.path.exists(resource_work_path + '.old'): + for i in range(1, 100): + p = resource_path_old + '%02d' % i + if not os.path.exists(p): + # Rename existing resource file to + # `resource_name.old` + 2 digits + shutil.move(resource_work_path, p) + break + else: + self.log.warning( + 'There are a hundred old files for ' + 'resource "{}". ' + 'Perhaps is it time to clean up your ' + 'resources folder' + .format(resource_basename) + ) + continue + else: + # Rename existing resource file to `resource_name.old` + shutil.move(resource_work_path, resource_path_old) + + # Copy resource file to workfile resources folder + shutil.copy(resource_main_path, resources_dir) diff --git a/openpype/modules/timers_manager/plugins/publish/start_timer.py b/openpype/modules/timers_manager/plugins/publish/start_timer.py index 6408327ca1..19a67292f5 100644 --- a/openpype/modules/timers_manager/plugins/publish/start_timer.py +++ b/openpype/modules/timers_manager/plugins/publish/start_timer.py @@ -6,8 +6,6 @@ Requires: import pyblish.api -from openpype.pipeline import legacy_io - class StartTimer(pyblish.api.ContextPlugin): label = "Start Timer" @@ -25,9 +23,9 @@ class StartTimer(pyblish.api.ContextPlugin): self.log.debug("Publish is not affecting running timers.") return - project_name = legacy_io.active_project() - asset_name = legacy_io.Session.get("AVALON_ASSET") - task_name = legacy_io.Session.get("AVALON_TASK") + project_name = context.data["projectName"] + asset_name = context.data.get("asset") + task_name = context.data.get("task") if not project_name or not asset_name or not task_name: self.log.info(( "Current context does not contain all" diff --git a/openpype/modules/timers_manager/timers_manager.py b/openpype/modules/timers_manager/timers_manager.py index 43286f7da4..674d834a1d 100644 --- a/openpype/modules/timers_manager/timers_manager.py +++ b/openpype/modules/timers_manager/timers_manager.py @@ -247,7 +247,7 @@ class TimersManager( return { "project_name": project_name, "asset_id": str(asset_doc["_id"]), - "asset_name": asset_doc["name"], + "asset_name": asset_name, "task_name": task_name, "task_type": task_type, "hierarchy": hierarchy_items diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py index b488fe3e1f..68533f7485 100644 --- a/openpype/pipeline/actions.py +++ b/openpype/pipeline/actions.py @@ -7,6 +7,8 @@ from openpype.pipeline.plugin_discover import ( deregister_plugin_path ) +from .load.utils import get_representation_path_from_context + class LauncherAction(object): """A custom action available""" @@ -20,7 +22,13 @@ class LauncherAction(object): log.propagate = True def is_compatible(self, session): - """Return whether the class is compatible with the Session.""" + """Return whether the class is compatible with the Session. + + Args: + session (dict[str, Union[str, None]]): Session data with + AVALON_PROJECT, AVALON_ASSET and AVALON_TASK. + """ + return True def process(self, session, **kwargs): @@ -94,6 +102,10 @@ class InventoryAction(object): """ return True + @classmethod + def filepath_from_context(cls, context): + return get_representation_path_from_context(context) + # Launcher action def discover_launcher_actions(): diff --git a/openpype/pipeline/anatomy.py b/openpype/pipeline/anatomy.py index 029b5cc1ff..0e5ab1d42e 100644 --- a/openpype/pipeline/anatomy.py +++ b/openpype/pipeline/anatomy.py @@ -5,7 +5,6 @@ import platform import collections import numbers -import ayon_api import six import time @@ -16,7 +15,7 @@ from openpype.settings.lib import ( from openpype.settings.constants import ( DEFAULT_PROJECT_KEY ) -from openpype.client import get_project +from openpype.client import get_project, get_ayon_server_api_connection from openpype.lib import Logger, get_local_site_id from openpype.lib.path_templates import ( TemplateUnsolved, @@ -479,7 +478,8 @@ class Anatomy(BaseAnatomy): if AYON_SERVER_ENABLED: if not project_name: return - return ayon_api.get_project_roots_for_site( + con = get_ayon_server_api_connection() + return con.get_project_roots_for_site( project_name, get_local_site_id() ) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 731132911a..9f720f6ae9 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -1,10 +1,13 @@ -from copy import deepcopy import re import os import json -import platform import contextlib +import functools +import platform import tempfile +import warnings +from copy import deepcopy + from openpype import PACKAGE_DIR from openpype.settings import get_project_settings from openpype.lib import ( @@ -13,12 +16,65 @@ from openpype.lib import ( Logger ) from openpype.pipeline import Anatomy +from openpype.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS + log = Logger.get_logger(__name__) -class CashedData: +class CachedData: remapping = None + has_compatible_ocio_package = None + config_version_data = {} + ocio_config_colorspaces = {} + allowed_exts = { + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + } + + +class DeprecatedWarning(DeprecationWarning): + pass + + +def deprecated(new_destination): + """Mark functions as deprecated. + + It will result in a warning being emitted when the function is used. + """ + + func = None + if callable(new_destination): + func = new_destination + new_destination = None + + def _decorator(decorated_func): + if new_destination is None: + warning_message = ( + " Please check content of deprecated function to figure out" + " possible replacement." + ) + else: + warning_message = " Please replace your usage with '{}'.".format( + new_destination + ) + + @functools.wraps(decorated_func) + def wrapper(*args, **kwargs): + warnings.simplefilter("always", DeprecatedWarning) + warnings.warn( + ( + "Call to deprecated function '{}'" + "\nFunction was moved or removed.{}" + ).format(decorated_func.__name__, warning_message), + category=DeprecatedWarning, + stacklevel=4 + ) + return decorated_func(*args, **kwargs) + return wrapper + + if func is None: + return _decorator + return _decorator(func) @contextlib.contextmanager @@ -64,124 +120,267 @@ def get_ocio_config_script_path(): ) -def get_imageio_colorspace_from_filepath( - path, host_name, project_name, +def get_colorspace_name_from_filepath( + filepath, host_name, project_name, config_data=None, file_rules=None, project_settings=None, validate=True ): """Get colorspace name from filepath - ImageIO Settings file rules are tested for matching rule. - Args: - path (str): path string, file rule pattern is tested on it + filepath (str): path string, file rule pattern is tested on it host_name (str): host name project_name (str): project name - config_data (dict, optional): config path and template in dict. + config_data (Optional[dict]): config path and template in dict. Defaults to None. - file_rules (dict, optional): file rule data from settings. + file_rules (Optional[dict]): file rule data from settings. Defaults to None. - project_settings (dict, optional): project settings. Defaults to None. - validate (bool, optional): should resulting colorspace be validated - with config file? Defaults to True. + project_settings (Optional[dict]): project settings. Defaults to None. + validate (Optional[bool]): should resulting colorspace be validated + with config file? Defaults to True. Returns: str: name of colorspace """ - if not any([config_data, file_rules]): - project_settings = project_settings or get_project_settings( - project_name - ) - config_data = get_imageio_config( - project_name, host_name, project_settings) + project_settings, config_data, file_rules = _get_context_settings( + host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) - # in case host color management is not enabled - if not config_data: - return None + if not config_data: + # in case global or host color management is not enabled + return None - file_rules = get_imageio_file_rules( - project_name, host_name, project_settings) + # use ImageIO file rules + colorspace_name = get_imageio_file_rules_colorspace_from_filepath( + filepath, host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) - # match file rule from path - colorspace_name = None - for _frule_name, file_rule in file_rules.items(): - pattern = file_rule["pattern"] - extension = file_rule["ext"] - ext_match = re.match( - r".*(?=.{})".format(extension), path - ) - file_match = re.search( - pattern, path - ) + # try to get colorspace from OCIO v2 file rules + if ( + not colorspace_name + and compatibility_check_config_version(config_data["path"], major=2) + ): + colorspace_name = get_config_file_rules_colorspace_from_filepath( + config_data["path"], filepath) - if ext_match and file_match: - colorspace_name = file_rule["colorspace"] + # use parse colorspace from filepath as fallback + colorspace_name = colorspace_name or parse_colorspace_from_filepath( + filepath, config_path=config_data["path"] + ) if not colorspace_name: log.info("No imageio file rule matched input path: '{}'".format( - path + filepath )) return None # validate matching colorspace with config - if validate and config_data: + if validate: validate_imageio_colorspace_in_config( config_data["path"], colorspace_name) return colorspace_name -def parse_colorspace_from_filepath( - path, host_name, project_name, - config_data=None, +# TODO: remove this in future - backward compatibility +@deprecated("get_imageio_file_rules_colorspace_from_filepath") +def get_imageio_colorspace_from_filepath(*args, **kwargs): + return get_imageio_file_rules_colorspace_from_filepath(*args, **kwargs) + +# TODO: remove this in future - backward compatibility +@deprecated("get_imageio_file_rules_colorspace_from_filepath") +def get_colorspace_from_filepath(*args, **kwargs): + return get_imageio_file_rules_colorspace_from_filepath(*args, **kwargs) + + +def _get_context_settings( + host_name, project_name, + config_data=None, file_rules=None, project_settings=None +): + project_settings = project_settings or get_project_settings( + project_name + ) + + config_data = config_data or get_imageio_config( + project_name, host_name, project_settings) + + # in case host color management is not enabled + if not config_data: + return (None, None, None) + + file_rules = file_rules or get_imageio_file_rules( + project_name, host_name, project_settings) + + return project_settings, config_data, file_rules + + +def get_imageio_file_rules_colorspace_from_filepath( + filepath, host_name, project_name, + config_data=None, file_rules=None, + project_settings=None +): + """Get colorspace name from filepath + + ImageIO Settings file rules are tested for matching rule. + + Args: + filepath (str): path string, file rule pattern is tested on it + host_name (str): host name + project_name (str): project name + config_data (Optional[dict]): config path and template in dict. + Defaults to None. + file_rules (Optional[dict]): file rule data from settings. + Defaults to None. + project_settings (Optional[dict]): project settings. Defaults to None. + + Returns: + str: name of colorspace + """ + project_settings, config_data, file_rules = _get_context_settings( + host_name, project_name, + config_data=config_data, file_rules=file_rules, + project_settings=project_settings + ) + + if not config_data: + # in case global or host color management is not enabled + return None + + # match file rule from path + colorspace_name = None + for file_rule in file_rules.values(): + pattern = file_rule["pattern"] + extension = file_rule["ext"] + ext_match = re.match( + r".*(?=.{})".format(extension), filepath + ) + file_match = re.search( + pattern, filepath + ) + + if ext_match and file_match: + colorspace_name = file_rule["colorspace"] + + return colorspace_name + + +def get_config_file_rules_colorspace_from_filepath(config_path, filepath): + """Get colorspace from file path wrapper. + + Wrapper function for getting colorspace from file path + with use of OCIO v2 file-rules. + + Args: + config_path (str): path leading to config.ocio file + filepath (str): path leading to a file + + Returns: + Any[str, None]: matching colorspace name + """ + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + result_data = _get_wrapped_with_subprocess( + "colorspace", "get_config_file_rules_colorspace_from_filepath", + config_path=config_path, + filepath=filepath + ) + if result_data: + return result_data[0] + + # TODO: refactor this so it is not imported but part of this file + from openpype.scripts.ocio_wrapper import _get_config_file_rules_colorspace_from_filepath # noqa: E501 + + result_data = _get_config_file_rules_colorspace_from_filepath( + config_path, filepath) + + if result_data: + return result_data[0] + + +def parse_colorspace_from_filepath( + filepath, colorspaces=None, config_path=None ): """Parse colorspace name from filepath An input path can have colorspace name used as part of name or as folder name. + Example: + >>> config_path = "path/to/config.ocio" + >>> colorspaces = get_ocio_config_colorspaces(config_path) + >>> colorspace = parse_colorspace_from_filepath( + "path/to/file/acescg/file.exr", + colorspaces=colorspaces + ) + >>> print(colorspace) + acescg + Args: - path (str): path string - host_name (str): host name - project_name (str): project name - config_data (dict, optional): config path and template in dict. - Defaults to None. - project_settings (dict, optional): project settings. Defaults to None. + filepath (str): path string + colorspaces (Optional[dict[str]]): list of colorspaces + config_path (Optional[str]): path to config.ocio file Returns: str: name of colorspace """ - if not config_data: - project_settings = project_settings or get_project_settings( - project_name + def _get_colorspace_match_regex(colorspaces): + """Return a regex pattern + + Allows to search a colorspace match in a filename + + Args: + colorspaces (list): List of colorspace names + + Returns: + re.Pattern: regex pattern + """ + pattern = "|".join( + # Allow to match spaces also as underscores because the + # integrator replaces spaces with underscores in filenames + re.escape(colorspace) for colorspace in + # Sort by longest first so the regex matches longer matches + # over smaller matches, e.g. matching 'Output - sRGB' over 'sRGB' + sorted(colorspaces, key=len, reverse=True) ) - config_data = get_imageio_config( - project_name, host_name, project_settings) + return re.compile(pattern) - config_path = config_data["path"] + if not colorspaces and not config_path: + raise ValueError( + "Must provide `config_path` if `colorspaces` is not provided." + ) - # match file rule from path - colorspace_name = None - colorspaces = get_ocio_config_colorspaces(config_path) - for colorspace_key in colorspaces: - # check underscored variant of colorspace name - # since we are reformatting it in integrate.py - if colorspace_key.replace(" ", "_") in path: - colorspace_name = colorspace_key - break - if colorspace_key in path: - colorspace_name = colorspace_key - break + colorspaces = ( + colorspaces + or get_ocio_config_colorspaces(config_path)["colorspaces"] + ) + underscored_colorspaces = { + key.replace(" ", "_"): key for key in colorspaces + if " " in key + } - if not colorspace_name: - log.info("No matching colorspace in config '{}' for path: '{}'".format( - config_path, path - )) - return None + # match colorspace from filepath + regex_pattern = _get_colorspace_match_regex( + list(colorspaces) + list(underscored_colorspaces)) + match = regex_pattern.search(filepath) + colorspace = match.group(0) if match else None - return colorspace_name + if colorspace in underscored_colorspaces: + return underscored_colorspaces[colorspace] + + if colorspace: + return colorspace + + log.info("No matching colorspace in config '{}' for path: '{}'".format( + config_path, filepath + )) + return None def validate_imageio_colorspace_in_config(config_path, colorspace_name): @@ -197,7 +396,7 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): Returns: bool: True if exists """ - colorspaces = get_ocio_config_colorspaces(config_path) + colorspaces = get_ocio_config_colorspaces(config_path)["colorspaces"] if colorspace_name not in colorspaces: raise KeyError( "Missing colorspace '{}' in config file '{}'".format( @@ -206,49 +405,101 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): return True +# TODO: remove this in future - backward compatibility +@deprecated("_get_wrapped_with_subprocess") def get_data_subprocess(config_path, data_type): - """Get data via subprocess + """[Deprecated] Get data via subprocess Wrapper for Python 2 hosts. Args: config_path (str): path leading to config.ocio file """ + return _get_wrapped_with_subprocess( + "config", data_type, in_path=config_path, + ) + + +def _get_wrapped_with_subprocess(command_group, command, **kwargs): + """Get data via subprocess + + Wrapper for Python 2 hosts. + + Args: + command_group (str): command group name + command (str): command name + **kwargs: command arguments + + Returns: + Any[dict, None]: data + """ with _make_temp_json_file() as tmp_json_path: # Prepare subprocess arguments args = [ "run", get_ocio_config_script_path(), - "config", data_type, - "--in_path", config_path, - "--out_path", tmp_json_path - + command_group, command ] + + for key_, value_ in kwargs.items(): + args.extend(("--{}".format(key_), value_)) + + args.append("--out_path") + args.append(tmp_json_path) + log.info("Executing: {}".format(" ".join(args))) - process_kwargs = { - "logger": log - } - - run_openpype_process(*args, **process_kwargs) + run_openpype_process(*args, logger=log) # return all colorspaces - return_json_data = open(tmp_json_path).read() - return json.loads(return_json_data) + with open(tmp_json_path, "r") as f_: + return json.load(f_) +# TODO: this should be part of ocio_wrapper.py def compatibility_check(): - """checking if user has a compatible PyOpenColorIO >= 2. + """Making sure PyOpenColorIO is importable""" + if CachedData.has_compatible_ocio_package is not None: + return CachedData.has_compatible_ocio_package - It's achieved by checking if PyOpenColorIO is importable - and calling any version 2 specific function - """ try: - import PyOpenColorIO + import PyOpenColorIO # noqa: F401 + CachedData.has_compatible_ocio_package = True + except ImportError: + CachedData.has_compatible_ocio_package = False - # ocio versions lower than 2 will raise AttributeError - PyOpenColorIO.GetVersion() - except (ImportError, AttributeError): + # compatible + return CachedData.has_compatible_ocio_package + + +# TODO: this should be part of ocio_wrapper.py +def compatibility_check_config_version(config_path, major=1, minor=None): + """Making sure PyOpenColorIO config version is compatible""" + + if not CachedData.config_version_data.get(config_path): + if compatibility_check(): + # TODO: refactor this so it is not imported but part of this file + from openpype.scripts.ocio_wrapper import _get_version_data + + CachedData.config_version_data[config_path] = \ + _get_version_data(config_path) + + else: + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + CachedData.config_version_data[config_path] = \ + _get_wrapped_with_subprocess( + "config", "get_version", config_path=config_path + ) + + # check major version + if CachedData.config_version_data[config_path]["major"] != major: return False + + # check minor version + if minor and CachedData.config_version_data[config_path]["minor"] != minor: + return False + + # compatible return True @@ -264,18 +515,179 @@ def get_ocio_config_colorspaces(config_path): Returns: dict: colorspace and family in couple """ - if not compatibility_check(): - # python environment is not compatible with PyOpenColorIO - # needs to be run in subprocess - return get_colorspace_data_subprocess(config_path) + if not CachedData.ocio_config_colorspaces.get(config_path): + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + CachedData.ocio_config_colorspaces[config_path] = \ + _get_wrapped_with_subprocess( + "config", "get_colorspace", in_path=config_path + ) + else: + # TODO: refactor this so it is not imported but part of this file + from openpype.scripts.ocio_wrapper import _get_colorspace_data - from openpype.scripts.ocio_wrapper import _get_colorspace_data + CachedData.ocio_config_colorspaces[config_path] = \ + _get_colorspace_data(config_path) - return _get_colorspace_data(config_path) + return CachedData.ocio_config_colorspaces[config_path] +def convert_colorspace_enumerator_item( + colorspace_enum_item, + config_items +): + """Convert colorspace enumerator item to dictionary + + Args: + colorspace_item (str): colorspace and family in couple + config_items (dict[str,dict]): colorspace data + + Returns: + dict: colorspace data + """ + if "::" not in colorspace_enum_item: + return None + + # split string with `::` separator and set first as key and second as value + item_type, item_name = colorspace_enum_item.split("::") + + item_data = None + if item_type == "aliases": + # loop through all colorspaces and find matching alias + for name, _data in config_items.get("colorspaces", {}).items(): + if item_name in _data.get("aliases", []): + item_data = deepcopy(_data) + item_data.update({ + "name": name, + "type": "colorspace" + }) + break + else: + # find matching colorspace item found in labeled_colorspaces + item_data = config_items.get(item_type, {}).get(item_name) + if item_data: + item_data = deepcopy(item_data) + item_data.update({ + "name": item_name, + "type": item_type + }) + + # raise exception if item is not found + if not item_data: + message_config_keys = ", ".join( + "'{}':{}".format( + key, + set(config_items.get(key, {}).keys()) + ) for key in config_items.keys() + ) + raise KeyError( + "Missing colorspace item '{}' in config data: [{}]".format( + colorspace_enum_item, message_config_keys + ) + ) + + return item_data + + +def get_colorspaces_enumerator_items( + config_items, + include_aliases=False, + include_looks=False, + include_roles=False, + include_display_views=False +): + """Get all colorspace data with labels + + Wrapper function for aggregating all names and its families. + Families can be used for building menu and submenus in gui. + + Args: + config_items (dict[str,dict]): colorspace data coming from + `get_ocio_config_colorspaces` function + include_aliases (bool): include aliases in result + include_looks (bool): include looks in result + include_roles (bool): include roles in result + + Returns: + list[tuple[str,str]]: colorspace and family in couple + """ + labeled_colorspaces = [] + aliases = set() + colorspaces = set() + looks = set() + roles = set() + display_views = set() + for items_type, colorspace_items in config_items.items(): + if items_type == "colorspaces": + for color_name, color_data in colorspace_items.items(): + if color_data.get("aliases"): + aliases.update([ + ( + "aliases::{}".format(alias_name), + "[alias] {} ({})".format(alias_name, color_name) + ) + for alias_name in color_data["aliases"] + ]) + colorspaces.add(( + "{}::{}".format(items_type, color_name), + "[colorspace] {}".format(color_name) + )) + + elif items_type == "looks": + looks.update([ + ( + "{}::{}".format(items_type, name), + "[look] {} ({})".format(name, role_data["process_space"]) + ) + for name, role_data in colorspace_items.items() + ]) + + elif items_type == "displays_views": + display_views.update([ + ( + "{}::{}".format(items_type, name), + "[view (display)] {}".format(name) + ) + for name, _ in colorspace_items.items() + ]) + + elif items_type == "roles": + roles.update([ + ( + "{}::{}".format(items_type, name), + "[role] {} ({})".format(name, role_data["colorspace"]) + ) + for name, role_data in colorspace_items.items() + ]) + + if roles and include_roles: + roles = sorted(roles, key=lambda x: x[0]) + labeled_colorspaces.extend(roles) + + # add colorspaces as second so it is not first in menu + colorspaces = sorted(colorspaces, key=lambda x: x[0]) + labeled_colorspaces.extend(colorspaces) + + if aliases and include_aliases: + aliases = sorted(aliases, key=lambda x: x[0]) + labeled_colorspaces.extend(aliases) + + if looks and include_looks: + looks = sorted(looks, key=lambda x: x[0]) + labeled_colorspaces.extend(looks) + + if display_views and include_display_views: + display_views = sorted(display_views, key=lambda x: x[0]) + labeled_colorspaces.extend(display_views) + + return labeled_colorspaces + + +# TODO: remove this in future - backward compatibility +@deprecated("_get_wrapped_with_subprocess") def get_colorspace_data_subprocess(config_path): - """Get colorspace data via subprocess + """[Deprecated] Get colorspace data via subprocess Wrapper for Python 2 hosts. @@ -285,7 +697,9 @@ def get_colorspace_data_subprocess(config_path): Returns: dict: colorspace and family in couple """ - return get_data_subprocess(config_path, "get_colorspace") + return _get_wrapped_with_subprocess( + "config", "get_colorspace", in_path=config_path + ) def get_ocio_config_views(config_path): @@ -303,15 +717,20 @@ def get_ocio_config_views(config_path): if not compatibility_check(): # python environment is not compatible with PyOpenColorIO # needs to be run in subprocess - return get_views_data_subprocess(config_path) + return _get_wrapped_with_subprocess( + "config", "get_views", in_path=config_path + ) + # TODO: refactor this so it is not imported but part of this file from openpype.scripts.ocio_wrapper import _get_views_data return _get_views_data(config_path) +# TODO: remove this in future - backward compatibility +@deprecated("_get_wrapped_with_subprocess") def get_views_data_subprocess(config_path): - """Get viewers data via subprocess + """[Deprecated] Get viewers data via subprocess Wrapper for Python 2 hosts. @@ -321,7 +740,9 @@ def get_views_data_subprocess(config_path): Returns: dict: `display/viewer` and viewer data """ - return get_data_subprocess(config_path, "get_views") + return _get_wrapped_with_subprocess( + "config", "get_views", in_path=config_path + ) def get_imageio_config( @@ -546,15 +967,15 @@ def get_remapped_colorspace_to_native( Union[str, None]: native colorspace name defined in remapping or None """ - CashedData.remapping.setdefault(host_name, {}) - if CashedData.remapping[host_name].get("to_native") is None: + CachedData.remapping.setdefault(host_name, {}) + if CachedData.remapping[host_name].get("to_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name]["to_native"] = { + CachedData.remapping[host_name]["to_native"] = { rule["ocio_name"]: rule["host_native_name"] for rule in remapping_rules } - return CashedData.remapping[host_name]["to_native"].get( + return CachedData.remapping[host_name]["to_native"].get( ocio_colorspace_name) @@ -572,15 +993,15 @@ def get_remapped_colorspace_from_native( Union[str, None]: Ocio colorspace name defined in remapping or None. """ - CashedData.remapping.setdefault(host_name, {}) - if CashedData.remapping[host_name].get("from_native") is None: + CachedData.remapping.setdefault(host_name, {}) + if CachedData.remapping[host_name].get("from_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name]["from_native"] = { + CachedData.remapping[host_name]["from_native"] = { rule["host_native_name"]: rule["ocio_name"] for rule in remapping_rules } - return CashedData.remapping[host_name]["from_native"].get( + return CachedData.remapping[host_name]["from_native"].get( host_native_colorspace_name) @@ -601,3 +1022,173 @@ def _get_imageio_settings(project_settings, host_name): imageio_host = project_settings.get(host_name, {}).get("imageio", {}) return imageio_global, imageio_host + + +def get_colorspace_settings_from_publish_context(context_data): + """Returns solved settings for the host context. + + Args: + context_data (publish.Context.data): publishing context data + + Returns: + tuple | bool: config, file rules or None + """ + if "imageioSettings" in context_data and context_data["imageioSettings"]: + return context_data["imageioSettings"] + + project_name = context_data["projectName"] + host_name = context_data["hostName"] + anatomy_data = context_data["anatomyData"] + project_settings_ = context_data["project_settings"] + + config_data = get_imageio_config( + project_name, host_name, + project_settings=project_settings_, + anatomy_data=anatomy_data + ) + + # caching invalid state, so it's not recalculated all the time + file_rules = None + if config_data: + file_rules = get_imageio_file_rules( + project_name, host_name, + project_settings=project_settings_ + ) + + # caching settings for future instance processing + context_data["imageioSettings"] = (config_data, file_rules) + + return config_data, file_rules + + +def set_colorspace_data_to_representation( + representation, context_data, + colorspace=None, + log=None +): + """Sets colorspace data to representation. + + Args: + representation (dict): publishing representation + context_data (publish.Context.data): publishing context data + colorspace (str, optional): colorspace name. Defaults to None. + log (logging.Logger, optional): logger instance. Defaults to None. + + Example: + ``` + { + # for other publish plugins and loaders + "colorspace": "linear", + "config": { + # for future references in case need + "path": "/abs/path/to/config.ocio", + # for other plugins within remote publish cases + "template": "{project[root]}/path/to/config.ocio" + } + } + ``` + + """ + log = log or Logger.get_logger(__name__) + + file_ext = representation["ext"] + + # check if `file_ext` in lower case is in CachedData.allowed_exts + if file_ext.lstrip(".").lower() not in CachedData.allowed_exts: + log.debug( + "Extension '{}' is not in allowed extensions.".format(file_ext) + ) + return + + # get colorspace settings + config_data, file_rules = get_colorspace_settings_from_publish_context( + context_data) + + # in case host color management is not enabled + if not config_data: + log.warning("Host's colorspace management is disabled.") + return + + log.debug("Config data is: `{}`".format(config_data)) + + project_name = context_data["projectName"] + host_name = context_data["hostName"] + project_settings = context_data["project_settings"] + + # get one filename + filename = representation["files"] + if isinstance(filename, list): + filename = filename[0] + + # get matching colorspace from rules + colorspace = colorspace or get_imageio_colorspace_from_filepath( + filename, host_name, project_name, + config_data=config_data, + file_rules=file_rules, + project_settings=project_settings + ) + + # infuse data to representation + if colorspace: + colorspace_data = { + "colorspace": colorspace, + "config": config_data + } + + # update data key + representation["colorspaceData"] = colorspace_data + + +def get_display_view_colorspace_name(config_path, display, view): + """Returns the colorspace attribute of the (display, view) pair. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ + + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + return get_display_view_colorspace_subprocess(config_path, + display, view) + + from openpype.scripts.ocio_wrapper import _get_display_view_colorspace_name # noqa + + return _get_display_view_colorspace_name(config_path, display, view) + + +def get_display_view_colorspace_subprocess(config_path, display, view): + """Returns the colorspace attribute of the (display, view) pair + via subprocess. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ + + with _make_temp_json_file() as tmp_json_path: + # Prepare subprocess arguments + args = [ + "run", get_ocio_config_script_path(), + "config", "get_display_view_colorspace_name", + "--in_path", config_path, + "--out_path", tmp_json_path, + "--display", display, + "--view", view + ] + log.debug("Executing: {}".format(" ".join(args))) + + run_openpype_process(*args, logger=log) + + # return default view colorspace name + with open(tmp_json_path, "r") as f: + return json.load(f) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index f567118062..a607c90912 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -11,12 +11,15 @@ import pyblish.api from pyblish.lib import MessageHandler import openpype +from openpype import AYON_SERVER_ENABLED from openpype.host import HostBase from openpype.client import ( get_project, get_asset_by_id, get_asset_by_name, version_is_latest, + get_asset_name_identifier, + get_ayon_server_api_connection, ) from openpype.lib.events import emit_event from openpype.modules import load_modules, ModulesManager @@ -42,7 +45,7 @@ from . import ( _is_installed = False _process_id = None -_registered_root = {"_": ""} +_registered_root = {"_": {}} _registered_host = {"_": None} # Keep modules manager (and it's modules) in memory # - that gives option to register modules' callbacks @@ -83,15 +86,22 @@ def register_root(path): def registered_root(): - """Return currently registered root""" - root = _registered_root["_"] - if root: - return root + """Return registered roots from current project anatomy. - root = legacy_io.Session.get("AVALON_PROJECTS") - if root: - return os.path.normpath(root) - return "" + Consider this does return roots only for current project and current + platforms, only if host was installer using 'install_host'. + + Deprecated: + Please use project 'Anatomy' to get roots. This function is still used + at current core functions of load logic, but that will change + in future and this function will be removed eventually. Using this + function at new places can cause problems in the future. + + Returns: + dict[str, str]: Root paths. + """ + + return _registered_root["_"] def install_host(host): @@ -105,6 +115,10 @@ def install_host(host): _is_installed = True + # Make sure global AYON connection has set site id and version + if AYON_SERVER_ENABLED: + get_ayon_server_api_connection() + legacy_io.install() modules_manager = _get_modules_manager() @@ -480,6 +494,27 @@ def get_template_data_from_session(session=None, system_settings=None): ) +def get_current_context_template_data(system_settings=None): + """Prepare template data for current context. + + Args: + system_settings (Optional[Dict[str, Any]]): Prepared system settings. + + Returns: + Dict[str, Any] Template data for current context. + """ + + context = get_current_context() + project_name = context["project_name"] + asset_name = context["asset_name"] + task_name = context["task_name"] + host_name = get_current_host_name() + + return get_template_data_with_names( + project_name, asset_name, task_name, host_name, system_settings + ) + + def get_workdir_from_session(session=None, template_key=None): """Template data for template fill from session keys. @@ -565,14 +600,12 @@ def compute_session_changes( Dict[str, str]: Changes in the Session dictionary. """ - changes = {} - # Get asset document and asset if not asset_doc: task_name = None asset_name = None else: - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) # Detect any changes compared session mapping = { diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py index 6755224c19..94d575a776 100644 --- a/openpype/pipeline/create/__init__.py +++ b/openpype/pipeline/create/__init__.py @@ -2,6 +2,7 @@ from .constants import ( SUBSET_NAME_ALLOWED_SYMBOLS, DEFAULT_SUBSET_TEMPLATE, PRE_CREATE_THUMBNAIL_KEY, + DEFAULT_VARIANT_VALUE, ) from .utils import ( @@ -50,6 +51,7 @@ __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", "DEFAULT_SUBSET_TEMPLATE", "PRE_CREATE_THUMBNAIL_KEY", + "DEFAULT_VARIANT_VALUE", "get_last_versions_for_instances", "get_next_versions_for_instances", diff --git a/openpype/pipeline/create/constants.py b/openpype/pipeline/create/constants.py index 375cfc4a12..7d1d0154e9 100644 --- a/openpype/pipeline/create/constants.py +++ b/openpype/pipeline/create/constants.py @@ -1,10 +1,12 @@ SUBSET_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_." DEFAULT_SUBSET_TEMPLATE = "{family}{Variant}" PRE_CREATE_THUMBNAIL_KEY = "thumbnail_source" +DEFAULT_VARIANT_VALUE = "Main" __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", "DEFAULT_SUBSET_TEMPLATE", "PRE_CREATE_THUMBNAIL_KEY", + "DEFAULT_VARIANT_VALUE", ) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 3076efcde7..683699a0d1 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -11,7 +11,12 @@ from contextlib import contextmanager import pyblish.logic import pyblish.api -from openpype.client import get_assets, get_asset_by_name +from openpype import AYON_SERVER_ENABLED +from openpype.client import ( + get_assets, + get_asset_by_name, + get_asset_name_identifier, +) from openpype.settings import ( get_system_settings, get_project_settings @@ -758,7 +763,7 @@ class PublishAttributes: yield name def mark_as_stored(self): - self._origin_data = copy.deepcopy(self._data) + self._origin_data = copy.deepcopy(self.data_to_store()) def data_to_store(self): """Convert attribute values to "data to store".""" @@ -912,18 +917,30 @@ class CreatedInstance: # Create a copy of passed data to avoid changing them on the fly data = copy.deepcopy(data or {}) - # Store original value of passed data - self._orig_data = copy.deepcopy(data) - - # Pop family and subset to prevent unexpected changes - data.pop("family", None) - data.pop("subset", None) # Pop dictionary values that will be converted to objects to be able # catch changes orig_creator_attributes = data.pop("creator_attributes", None) or {} orig_publish_attributes = data.pop("publish_attributes", None) or {} + # Store original value of passed data + self._orig_data = copy.deepcopy(data) + + # Pop family and subset to prevent unexpected changes + # TODO change to 'productType' and 'productName' in AYON + data.pop("family", None) + data.pop("subset", None) + + if AYON_SERVER_ENABLED: + asset_name = data.pop("asset", None) + if "folderPath" not in data: + data["folderPath"] = asset_name + + elif "folderPath" in data: + asset_name = data.pop("folderPath").split("/")[-1] + if "asset" not in data: + data["asset"] = asset_name + # QUESTION Does it make sense to have data stored as ordered dict? self._data = collections.OrderedDict() # QUESTION Do we need this "id" information on instance? @@ -1039,7 +1056,10 @@ class CreatedInstance: @property def origin_data(self): - return copy.deepcopy(self._orig_data) + output = copy.deepcopy(self._orig_data) + output["creator_attributes"] = self.creator_attributes.origin_data + output["publish_attributes"] = self.publish_attributes.origin_data + return output @property def creator_identifier(self): @@ -1095,7 +1115,7 @@ class CreatedInstance: def changes(self): """Calculate and return changes.""" - return TrackChangesItem(self._orig_data, self.data_to_store()) + return TrackChangesItem(self.origin_data, self.data_to_store()) def mark_as_stored(self): """Should be called when instance data are stored. @@ -1211,7 +1231,7 @@ class CreatedInstance: publish_attributes = self.publish_attributes.serialize_attributes() return { "data": self.data_to_store(), - "orig_data": copy.deepcopy(self._orig_data), + "orig_data": self.origin_data, "creator_attr_defs": creator_attr_defs, "publish_attributes": publish_attributes, "creator_label": self._creator_label, @@ -1251,7 +1271,7 @@ class CreatedInstance: creator_identifier=creator_identifier, creator_label=creator_label, group_label=group_label, - creator_attributes=creator_attr_defs + creator_attr_defs=creator_attr_defs ) obj._orig_data = serialized_data["orig_data"] obj.publish_attributes.deserialize_attributes(publish_attributes) @@ -1263,6 +1283,8 @@ class CreatedInstance: def has_set_asset(self): """Asset name is set in data.""" + if AYON_SERVER_ENABLED: + return "folderPath" in self._data return "asset" in self._data @property @@ -1774,7 +1796,7 @@ class CreateContext: self.creator_discover_result = report for creator_class in report.plugins: if inspect.isabstract(creator_class): - self.log.info( + self.log.debug( "Skipping abstract Creator {}".format(str(creator_class)) ) continue @@ -1804,6 +1826,7 @@ class CreateContext: self, self.headless ) + if not creator.enabled: disabled_creators[creator_identifier] = creator continue @@ -1997,8 +2020,14 @@ class CreateContext: project_name, self.host_name ) + asset_name = get_asset_name_identifier(asset_doc) + if AYON_SERVER_ENABLED: + asset_name_key = "folderPath" + else: + asset_name_key = "asset" + instance_data = { - "asset": asset_doc["name"], + asset_name_key: asset_name, "task": task_name, "family": creator.family, "variant": variant @@ -2223,34 +2252,51 @@ class CreateContext: task_names_by_asset_name = {} for instance in instances: task_name = instance.get("task") - asset_name = instance.get("asset") + if AYON_SERVER_ENABLED: + asset_name = instance.get("folderPath") + else: + asset_name = instance.get("asset") if asset_name: task_names_by_asset_name[asset_name] = set() if task_name: task_names_by_asset_name[asset_name].add(task_name) - asset_names = [ + asset_names = { asset_name for asset_name in task_names_by_asset_name.keys() if asset_name is not None - ] + } + fields = {"name", "data.tasks"} + if AYON_SERVER_ENABLED: + fields |= {"data.parents"} asset_docs = list(get_assets( self.project_name, asset_names=asset_names, - fields=["name", "data.tasks"] + fields=fields )) task_names_by_asset_name = {} + asset_docs_by_name = collections.defaultdict(list) for asset_doc in asset_docs: - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) tasks = asset_doc.get("data", {}).get("tasks") or {} task_names_by_asset_name[asset_name] = set(tasks.keys()) + asset_docs_by_name[asset_doc["name"]].append(asset_doc) for instance in instances: if not instance.has_valid_asset or not instance.has_valid_task: continue - asset_name = instance["asset"] + if AYON_SERVER_ENABLED: + asset_name = instance["folderPath"] + if asset_name and "/" not in asset_name: + asset_docs = asset_docs_by_name.get(asset_name) + if len(asset_docs) == 1: + asset_name = get_asset_name_identifier(asset_docs[0]) + instance["folderPath"] = asset_name + else: + asset_name = instance["asset"] + if asset_name not in task_names_by_asset_name: instance.set_asset_invalid(True) continue @@ -2330,6 +2376,10 @@ class CreateContext: identifier, label, exc_info, add_traceback ) ) + else: + for update_data in update_list: + instance = update_data.instance + instance.mark_as_stored() if failed_info: raise CreatorsSaveFailed(failed_info) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index c9edbbfd71..b51f69379c 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -1,17 +1,13 @@ -import os +# -*- coding: utf-8 -*- import copy import collections -from abc import ( - ABCMeta, - abstractmethod, - abstractproperty -) +from abc import ABCMeta, abstractmethod import six from openpype.settings import get_system_settings, get_project_settings -from openpype.lib import Logger +from openpype.lib import Logger, is_func_signature_supported from openpype.pipeline.plugin_discover import ( discover, register_plugin, @@ -20,6 +16,7 @@ from openpype.pipeline.plugin_discover import ( deregister_plugin_path ) +from .constants import DEFAULT_VARIANT_VALUE from .subset_name import get_subset_name from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator @@ -84,7 +81,8 @@ class SubsetConvertorPlugin(object): def host(self): return self._create_context.host - @abstractproperty + @property + @abstractmethod def identifier(self): """Converted identifier. @@ -161,7 +159,6 @@ class BaseCreator: Args: project_settings (Dict[str, Any]): Project settings. - system_settings (Dict[str, Any]): System settings. create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ @@ -197,6 +194,12 @@ class BaseCreator: # QUESTION make this required? host_name = None + # Settings auto-apply helpers + # Root key in project settings (mandatory for auto-apply to work) + settings_category = None + # Name of plugin in create settings > class name is used if not set + settings_name = None + def __init__( self, project_settings, system_settings, create_context, headless=False ): @@ -208,12 +211,119 @@ class BaseCreator: # - we may use UI inside processing this attribute should be checked self.headless = headless - self.apply_settings(project_settings, system_settings) + expect_system_settings = False + if is_func_signature_supported( + self.apply_settings, project_settings + ): + self.apply_settings(project_settings) + else: + expect_system_settings = True + # Backwards compatibility for system settings + self.apply_settings(project_settings, system_settings) - def apply_settings(self, project_settings, system_settings): - """Method called on initialization of plugin to apply settings.""" + init_use_base = any( + self.__class__.__init__ is cls.__init__ + for cls in { + BaseCreator, + Creator, + HiddenCreator, + AutoCreator, + } + ) + if not init_use_base or expect_system_settings: + self.log.warning(( + "WARNING: Source - Create plugin {}." + " System settings argument will not be passed to" + " '__init__' and 'apply_settings' methods in future versions" + " of OpenPype. Planned version to drop the support" + " is 3.16.6 or 3.17.0. Please contact Ynput core team if you" + " need to keep system settings." + ).format(self.__class__.__name__)) + + @staticmethod + def _get_settings_values(project_settings, category_name, plugin_name): + """Helper method to get settings values. + + Args: + project_settings (dict[str, Any]): Project settings. + category_name (str): Category of settings. + plugin_name (str): Name of settings. + + Returns: + Union[dict[str, Any], None]: Settings values or None. + """ + + settings = project_settings.get(category_name) + if not settings: + return None + + create_settings = settings.get("create") + if not create_settings: + return None + + return create_settings.get(plugin_name) + + def apply_settings(self, project_settings): + """Method called on initialization of plugin to apply settings. + + Default implementation tries to auto-apply settings values if are + in expected hierarchy. + + Data hierarchy to auto-apply settings: + β”œβ”€ {self.settings_category} - Root key in settings + β”‚ └─ "create" - Hardcoded key + β”‚ └─ {self.settings_name} | {class name} - Name of plugin + β”‚ β”œβ”€ ... attribute values... - Attribute/value pair + + It is mandatory to define 'settings_category' attribute. Attribute + 'settings_name' is optional and class name is used if is not defined. + + Example data: + ProjectSettings { + "maya": { # self.settings_category + "create": { # Hardcoded key + "CreateAnimation": { # self.settings_name / class name + "enabled": True, # --- Attributes to set --- + "optional": True,# + "active": True, # + "fps": 25, # ------------------------- + }, + ... + }, + ... + }, + ... + } + + Args: + project_settings (dict[str, Any]): Project settings. + """ + + settings_category = self.settings_category + if not settings_category: + return + + cls_name = self.__class__.__name__ + settings_name = self.settings_name or cls_name + + settings = self._get_settings_values( + project_settings, settings_category, settings_name + ) + if settings is None: + self.log.debug("No settings found for {}".format(cls_name)) + return + + for key, value in settings.items(): + # Log out attributes that are not defined on plugin object + # - those may be potential dangerous typos in settings + if not hasattr(self, key): + self.log.debug(( + "Applying settings to unknown attribute '{}' on '{}'." + ).format( + key, cls_name + )) + setattr(self, key, value) - pass @property def identifier(self): @@ -224,7 +334,8 @@ class BaseCreator: return self.family - @abstractproperty + @property + @abstractmethod def family(self): """Family that plugin represents.""" @@ -517,7 +628,7 @@ class Creator(BaseCreator): default_variants = [] # Default variant used in 'get_default_variant' - default_variant = None + _default_variant = None # Short description of family # - may not be used if `get_description` is overriden @@ -543,6 +654,21 @@ class Creator(BaseCreator): # - similar to instance attribute definitions pre_create_attr_defs = [] + def __init__(self, *args, **kwargs): + cls = self.__class__ + + # Fix backwards compatibility for plugins which override + # 'default_variant' attribute directly + if not isinstance(cls.default_variant, property): + # Move value from 'default_variant' to '_default_variant' + self._default_variant = self.default_variant + # Create property 'default_variant' on the class + cls.default_variant = property( + cls._get_default_variant_wrap, + cls._set_default_variant_wrap + ) + super(Creator, self).__init__(*args, **kwargs) + @property def show_order(self): """Order in which is creator shown in UI. @@ -595,10 +721,10 @@ class Creator(BaseCreator): def get_default_variants(self): """Default variant values for UI tooltips. - Replacement of `defatults` attribute. Using method gives ability to - have some "logic" other than attribute values. + Replacement of `default_variants` attribute. Using method gives + ability to have some "logic" other than attribute values. - By default returns `default_variants` value. + By default, returns `default_variants` value. Returns: List[str]: Whisper variants for user input. @@ -606,17 +732,63 @@ class Creator(BaseCreator): return copy.deepcopy(self.default_variants) - def get_default_variant(self): + def get_default_variant(self, only_explicit=False): """Default variant value that will be used to prefill variant input. This is for user input and value may not be content of result from `get_default_variants`. - Can return `None`. In that case first element from - `get_default_variants` should be used. + Note: + This method does not allow to have empty string as + default variant. + + Args: + only_explicit (Optional[bool]): If True, only explicit default + variant from '_default_variant' will be returned. + + Returns: + str: Variant value. """ - return self.default_variant + if only_explicit or self._default_variant: + return self._default_variant + + for variant in self.get_default_variants(): + return variant + return DEFAULT_VARIANT_VALUE + + def _get_default_variant_wrap(self): + """Default variant value that will be used to prefill variant input. + + Wrapper for 'get_default_variant'. + + Notes: + This method is wrapper for 'get_default_variant' + for 'default_variant' property, so creator can override + the method. + + Returns: + str: Variant value. + """ + + return self.get_default_variant() + + def _set_default_variant_wrap(self, variant): + """Set default variant value. + + This method is needed for automated settings overrides which are + changing attributes based on keys in settings. + + Args: + variant (str): New default variant value. + """ + + self._default_variant = variant + + default_variant = property( + _get_default_variant_wrap, + _set_default_variant_wrap + ) def get_pre_create_attr_defs(self): """Plugin attribute definitions needed for creation. diff --git a/openpype/pipeline/create/subset_name.py b/openpype/pipeline/create/subset_name.py index 3f0692b46a..00025b19b8 100644 --- a/openpype/pipeline/create/subset_name.py +++ b/openpype/pipeline/create/subset_name.py @@ -14,6 +14,13 @@ class TaskNotSetError(KeyError): super(TaskNotSetError, self).__init__(msg) +class TemplateFillError(Exception): + def __init__(self, msg=None): + if not msg: + msg = "Creator's subset name template is missing key value." + super(TemplateFillError, self).__init__(msg) + + def get_subset_name_template( project_name, family, @@ -112,6 +119,10 @@ def get_subset_name( for project. Settings are queried if not passed. family_filter (Optional[str]): Use different family for subset template filtering. Value of 'family' is used when not passed. + + Raises: + TemplateFillError: If filled template contains placeholder key which is not + collected. """ if not family: @@ -154,4 +165,10 @@ def get_subset_name( for key, value in dynamic_data.items(): fill_pairs[key] = value - return template.format(**prepare_template_data(fill_pairs)) + try: + return template.format(**prepare_template_data(fill_pairs)) + except KeyError as exp: + raise TemplateFillError( + "Value for {} key is missing in template '{}'." + " Available values are {}".format(str(exp), template, fill_pairs) + ) diff --git a/openpype/pipeline/create/utils.py b/openpype/pipeline/create/utils.py index 2ef1f02bd6..ce4af8f474 100644 --- a/openpype/pipeline/create/utils.py +++ b/openpype/pipeline/create/utils.py @@ -1,6 +1,11 @@ import collections -from openpype.client import get_assets, get_subsets, get_last_versions +from openpype.client import ( + get_assets, + get_subsets, + get_last_versions, + get_asset_name_identifier, +) def get_last_versions_for_instances( @@ -52,10 +57,10 @@ def get_last_versions_for_instances( asset_docs = get_assets( project_name, asset_names=subset_names_by_asset_name.keys(), - fields=["name", "_id"] + fields=["name", "_id", "data.parents"] ) asset_names_by_id = { - asset_doc["_id"]: asset_doc["name"] + asset_doc["_id"]: get_asset_name_identifier(asset_doc) for asset_doc in asset_docs } if not asset_names_by_id: diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 9278b0efc5..ce0ef4d343 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -107,17 +107,18 @@ def get_time_data_from_instance_or_context(instance): TimeData: dataclass holding time information. """ + context = instance.context return TimeData( - start=(instance.data.get("frameStart") or - instance.context.data.get("frameStart")), - end=(instance.data.get("frameEnd") or - instance.context.data.get("frameEnd")), - fps=(instance.data.get("fps") or - instance.context.data.get("fps")), - handle_start=(instance.data.get("handleStart") or - instance.context.data.get("handleStart")), # noqa: E501 - handle_end=(instance.data.get("handleStart") or - instance.context.data.get("handleStart")) + start=instance.data.get("frameStart", context.data.get("frameStart")), + end=instance.data.get("frameEnd", context.data.get("frameEnd")), + fps=instance.data.get("fps", context.data.get("fps")), + step=instance.data.get("byFrameStep", instance.data.get("step", 1)), + handle_start=instance.data.get( + "handleStart", context.data.get("handleStart") + ), + handle_end=instance.data.get( + "handleEnd", context.data.get("handleEnd") + ) ) @@ -139,7 +140,7 @@ def get_transferable_representations(instance): to_transfer = [] for representation in instance.data.get("representations", []): - if "publish_on_farm" not in representation.get("tags"): + if "publish_on_farm" not in representation.get("tags", []): continue trans_rep = representation.copy() @@ -265,8 +266,7 @@ def create_skeleton_instance( instance_skeleton_data[v] = instance.data.get(v) representations = get_transferable_representations(instance) - instance_skeleton_data["representations"] = [] - instance_skeleton_data["representations"] += representations + instance_skeleton_data["representations"] = representations persistent = instance.data.get("stagingDir_persistent") is True instance_skeleton_data["stagingDir_persistent"] = persistent @@ -568,9 +568,15 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, col = list(cols[0]) # create subset name `familyTaskSubset_AOV` - group_name = 'render{}{}{}{}'.format( - task[0].upper(), task[1:], - subset[0].upper(), subset[1:]) + # TODO refactor/remove me + family = skeleton["family"] + if not subset.startswith(family): + group_name = '{}{}{}{}{}'.format( + family, + task[0].upper(), task[1:], + subset[0].upper(), subset[1:]) + else: + group_name = subset # if there are multiple cameras, we need to add camera name if isinstance(col, (list, tuple)): @@ -739,6 +745,238 @@ def get_resources(project_name, version, extension=None): return resources +def create_skeleton_instance_cache(instance): + # type: (pyblish.api.Instance, list, dict) -> dict + """Create skeleton instance from original instance data. + + This will create dictionary containing skeleton + - common - data used for publishing rendered instances. + This skeleton instance is then extended with additional data + and serialized to be processed by farm job. + + Args: + instance (pyblish.api.Instance): Original instance to + be used as a source of data. + + Returns: + dict: Dictionary with skeleton instance data. + + """ + # list of family names to transfer to new family if present + + context = instance.context + data = instance.data.copy() + anatomy = instance.context.data["anatomy"] # type: Anatomy + + # get time related data from instance (or context) + time_data = get_time_data_from_instance_or_context(instance) + + if data.get("extendFrames", False): + time_data.start, time_data.end = extend_frames( + data["asset"], + data["subset"], + time_data.start, + time_data.end, + ) + + source = data.get("source") or context.data.get("currentFile") + success, rootless_path = ( + anatomy.find_root_template_from_path(source) + ) + if success: + source = rootless_path + else: + # `rootless_path` is not set to `source` if none of roots match + log = Logger.get_logger("farm_publishing") + log.warning(("Could not find root path for remapping \"{}\". " + "This may cause issues.").format(source)) + + family = instance.data["family"] + # Make sure "render" is in the families to go through + # validating expected and rendered files + # during publishing job. + families = ["render", family] + + instance_skeleton_data = { + "family": family, + "subset": data["subset"], + "families": families, + "asset": data["asset"], + "frameStart": time_data.start, + "frameEnd": time_data.end, + "handleStart": time_data.handle_start, + "handleEnd": time_data.handle_end, + "frameStartHandle": time_data.start - time_data.handle_start, + "frameEndHandle": time_data.end + time_data.handle_end, + "comment": data.get("comment"), + "fps": time_data.fps, + "source": source, + "extendFrames": data.get("extendFrames"), + "overrideExistingFrame": data.get("overrideExistingFrame"), + "jobBatchName": data.get("jobBatchName", ""), + # map inputVersions `ObjectId` -> `str` so json supports it + "inputVersions": list(map(str, data.get("inputVersions", []))), + } + + # skip locking version if we are creating v01 + instance_version = data.get("version") # take this if exists + if instance_version != 1: + instance_skeleton_data["version"] = instance_version + + representations = get_transferable_representations(instance) + instance_skeleton_data["representations"] = representations + + persistent = instance.data.get("stagingDir_persistent") is True + instance_skeleton_data["stagingDir_persistent"] = persistent + + return instance_skeleton_data + + +def prepare_cache_representations(skeleton_data, exp_files, anatomy): + """Create representations for file sequences. + + This will return representations of expected files if they are not + in hierarchy of aovs. There should be only one sequence of files for + most cases, but if not - we create representation from each of them. + + Arguments: + skeleton_data (dict): instance data for which we are + setting representations + exp_files (list): list of expected files + anatomy (Anatomy) + Returns: + list of representations + + """ + representations = [] + collections, remainders = clique.assemble(exp_files) + + log = Logger.get_logger("farm_publishing") + + # create representation for every collected sequence + for collection in collections: + ext = collection.tail.lstrip(".") + + staging = os.path.dirname(list(collection)[0]) + success, rootless_staging_dir = ( + anatomy.find_root_template_from_path(staging) + ) + if success: + staging = rootless_staging_dir + else: + log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues on farm." + ).format(staging)) + + frame_start = int(skeleton_data.get("frameStartHandle")) + rep = { + "name": ext, + "ext": ext, + "files": [os.path.basename(f) for f in list(collection)], + "frameStart": frame_start, + "frameEnd": int(skeleton_data.get("frameEndHandle")), + # If expectedFile are absolute, we need only filenames + "stagingDir": staging, + "fps": skeleton_data.get("fps") + } + + representations.append(rep) + + return representations + + +def create_instances_for_cache(instance, skeleton): + """Create instance for cache. + + This will create new instance for every AOV it can detect in expected + files list. + + Args: + instance (pyblish.api.Instance): Original instance. + skeleton (dict): Skeleton data for instance (those needed) later + by collector. + + + Returns: + list of instances + + Throws: + ValueError: + + """ + anatomy = instance.context.data["anatomy"] + subset = skeleton["subset"] + family = skeleton["family"] + exp_files = instance.data["expectedFiles"] + log = Logger.get_logger("farm_publishing") + + instances = [] + # go through AOVs in expected files + for _, files in exp_files[0].items(): + cols, rem = clique.assemble(files) + # we shouldn't have any reminders. And if we do, it should + # be just one item for single frame renders. + if not cols and rem: + if len(rem) != 1: + raise ValueError("Found multiple non related files " + "to render, don't know what to do " + "with them.") + col = rem[0] + ext = os.path.splitext(col)[1].lstrip(".") + else: + # but we really expect only one collection. + # Nothing else make sense. + if len(cols) != 1: + raise ValueError("Only one image sequence type is expected.") # noqa: E501 + ext = cols[0].tail.lstrip(".") + col = list(cols[0]) + + if isinstance(col, (list, tuple)): + staging = os.path.dirname(col[0]) + else: + staging = os.path.dirname(col) + + try: + staging = remap_source(staging, anatomy) + except ValueError as e: + log.warning(e) + + new_instance = deepcopy(skeleton) + + new_instance["subset"] = subset + log.info("Creating data for: {}".format(subset)) + new_instance["family"] = family + new_instance["families"] = skeleton["families"] + # create representation + if isinstance(col, (list, tuple)): + files = [os.path.basename(f) for f in col] + else: + files = os.path.basename(col) + + rep = { + "name": ext, + "ext": ext, + "files": files, + "frameStart": int(skeleton["frameStartHandle"]), + "frameEnd": int(skeleton["frameEndHandle"]), + # If expectedFile are absolute, we need only filenames + "stagingDir": staging, + "fps": new_instance.get("fps"), + "tags": [], + } + + new_instance["representations"] = [rep] + + # if extending frames from existing version, copy files from there + # into our destination directory + if new_instance.get("extendFrames", False): + copy_extend_frames(new_instance, rep) + instances.append(new_instance) + log.debug("instances:{}".format(instances)) + return instances + + def copy_extend_frames(instance, representation): """Copy existing frames from latest version. diff --git a/openpype/pipeline/legacy_io.py b/openpype/pipeline/legacy_io.py index 60fa035c22..864102dff9 100644 --- a/openpype/pipeline/legacy_io.py +++ b/openpype/pipeline/legacy_io.py @@ -30,7 +30,7 @@ def install(): session = session_data_from_environment(context_keys=True) - session["schema"] = "openpype:session-3.0" + session["schema"] = "openpype:session-4.0" try: schema.validate(session) except schema.ValidationError as e: diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index 7320a9f0e8..ca11b26211 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -32,6 +32,7 @@ from .utils import ( loaders_from_repre_context, loaders_from_representation, + filter_repre_contexts_by_loader, any_outdated_containers, get_outdated_containers, @@ -85,6 +86,7 @@ __all__ = ( "loaders_from_repre_context", "loaders_from_representation", + "filter_repre_contexts_by_loader", "any_outdated_containers", "get_outdated_containers", diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index f87fb3312d..8acfcfdb6c 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -234,6 +234,19 @@ class LoaderPlugin(list): """ return cls.options or [] + @property + def fname(self): + """Backwards compatibility with deprecation warning""" + + self.log.warning(( + "DEPRECATION WARNING: Source - Loader plugin {}." + " The 'fname' property on the Loader plugin will be removed in" + " future versions of OpenPype. Planned version to drop the support" + " is 3.16.6 or 3.17.0." + ).format(self.__class__.__name__)) + if hasattr(self, "_fname"): + return self._fname + class SubsetLoaderPlugin(LoaderPlugin): """Load subset into host application diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 42418be40e..c81aeff6bd 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -318,7 +318,8 @@ def load_with_repre_context( # Backwards compatibility: Originally the loader's __init__ required the # representation context to set `fname` attribute to the filename to load - loader.fname = get_representation_path_from_context(repre_context) + # Deprecated - to be removed in OpenPype 3.16.6 or 3.17.0. + loader._fname = get_representation_path_from_context(repre_context) return loader.load(repre_context, name, namespace, options) @@ -789,6 +790,24 @@ def loaders_from_repre_context(loaders, repre_context): ] +def filter_repre_contexts_by_loader(repre_contexts, loader): + """Filter representation contexts for loader. + + Args: + repre_contexts (list[dict[str, Ant]]): Representation context. + loader (LoaderPlugin): Loader plugin to filter contexts for. + + Returns: + list[dict[str, Any]]: Filtered representation contexts. + """ + + return [ + repre_context + for repre_context in repre_contexts + if is_compatible_loader(loader, repre_context) + ] + + def loaders_from_representation(loaders, representation): """Return all compatible loaders for a representation.""" diff --git a/openpype/pipeline/mongodb.py b/openpype/pipeline/mongodb.py index 41a44c7373..c948983c3d 100644 --- a/openpype/pipeline/mongodb.py +++ b/openpype/pipeline/mongodb.py @@ -62,8 +62,6 @@ def auto_reconnect(func): SESSION_CONTEXT_KEYS = ( - # Root directory of projects on disk - "AVALON_PROJECTS", # Name of current Project "AVALON_PROJECT", # Name of current Asset diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py index 0c57915c05..3a82d6f565 100644 --- a/openpype/pipeline/publish/__init__.py +++ b/openpype/pipeline/publish/__init__.py @@ -40,6 +40,7 @@ from .lib import ( apply_plugin_settings_automatically, get_plugin_settings, get_publish_instance_label, + get_publish_instance_families, ) from .abstract_expected_files import ExpectedFiles @@ -87,6 +88,7 @@ __all__ = ( "apply_plugin_settings_automatically", "get_plugin_settings", "get_publish_instance_label", + "get_publish_instance_families", "ExpectedFiles", diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py index 6877d556c3..764532cadb 100644 --- a/openpype/pipeline/publish/abstract_collect_render.py +++ b/openpype/pipeline/publish/abstract_collect_render.py @@ -11,7 +11,6 @@ import six import pyblish.api -from openpype.pipeline import legacy_io from .publish_plugins import AbstractMetaContextPlugin @@ -31,7 +30,7 @@ class RenderInstance(object): label = attr.ib() # label to show in GUI subset = attr.ib() # subset name task = attr.ib() # task name - asset = attr.ib() # asset name (AVALON_ASSET) + asset = attr.ib() # asset name attachTo = attr.ib() # subset name to attach render to setMembers = attr.ib() # list of nodes/members producing render output publish = attr.ib() # bool, True to publish instance @@ -75,7 +74,6 @@ class RenderInstance(object): tilesY = attr.ib(default=0) # number of tiles in Y # submit_publish_job - toBeRenderedOn = attr.ib(default=None) deadlineSubmissionJob = attr.ib(default=None) anatomyData = attr.ib(default=None) outputDir = attr.ib(default=None) @@ -130,7 +128,6 @@ class AbstractCollectRender(pyblish.api.ContextPlugin): """Constructor.""" super(AbstractCollectRender, self).__init__(*args, **kwargs) self._file_path = None - self._asset = legacy_io.Session["AVALON_ASSET"] self._context = None def process(self, context): diff --git a/openpype/pipeline/publish/constants.py b/openpype/pipeline/publish/constants.py index dcd3445200..92e3fb089f 100644 --- a/openpype/pipeline/publish/constants.py +++ b/openpype/pipeline/publish/constants.py @@ -5,3 +5,7 @@ ValidatePipelineOrder = pyblish.api.ValidatorOrder + 0.05 ValidateContentsOrder = pyblish.api.ValidatorOrder + 0.1 ValidateSceneOrder = pyblish.api.ValidatorOrder + 0.2 ValidateMeshOrder = pyblish.api.ValidatorOrder + 0.3 + +DEFAULT_PUBLISH_TEMPLATE = "publish" +DEFAULT_HERO_PUBLISH_TEMPLATE = "hero" +TRANSIENT_DIR_TEMPLATE = "transient" diff --git a/openpype/pipeline/publish/contants.py b/openpype/pipeline/publish/contants.py deleted file mode 100644 index c5296afe9a..0000000000 --- a/openpype/pipeline/publish/contants.py +++ /dev/null @@ -1,3 +0,0 @@ -DEFAULT_PUBLISH_TEMPLATE = "publish" -DEFAULT_HERO_PUBLISH_TEMPLATE = "hero" -TRANSIENT_DIR_TEMPLATE = "transient" diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index ada12800a9..4ea2f932f1 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -25,7 +25,7 @@ from openpype.pipeline import ( ) from openpype.pipeline.plugin_discover import DiscoverResult -from .contants import ( +from .constants import ( DEFAULT_PUBLISH_TEMPLATE, DEFAULT_HERO_PUBLISH_TEMPLATE, TRANSIENT_DIR_TEMPLATE @@ -464,9 +464,8 @@ def apply_plugin_settings_automatically(plugin, settings, logger=None): for option, value in settings.items(): if logger: - logger.debug("Plugin {} - Attr: {} -> {}".format( - option, value, plugin.__name__ - )) + logger.debug("Plugin %s - Attr: %s -> %s", + plugin.__name__, option, value) setattr(plugin, option, value) @@ -953,6 +952,7 @@ def replace_with_published_scene_path(instance, replace_in_path=True): return file_path + def add_repre_files_for_cleanup(instance, repre): """ Explicitly mark repre files to be deleted. @@ -961,7 +961,16 @@ def add_repre_files_for_cleanup(instance, repre): """ files = repre["files"] staging_dir = repre.get("stagingDir") - if not staging_dir or instance.data.get("stagingDir_persistent"): + + # first make sure representation level is not persistent + if ( + not staging_dir + or repre.get("stagingDir_persistent") + ): + return + + # then look into instance level if it's not persistent + if instance.data.get("stagingDir_persistent"): return if isinstance(files, str): @@ -993,3 +1002,27 @@ def get_publish_instance_label(instance): or instance.data.get("name") or str(instance) ) + + +def get_publish_instance_families(instance): + """Get all families of the instance. + + Look for families under 'family' and 'families' keys in instance data. + Value of 'family' is used as first family and then all other families + in random order. + + Args: + pyblish.api.Instance: Instance to get families from. + + Returns: + list[str]: List of families. + """ + + family = instance.data.get("family") + families = set(instance.data.get("families") or []) + output = [] + if family: + output.append(family) + families.discard(family) + output.extend(families) + return output diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index ba3be6397e..ae6cbc42d1 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -1,6 +1,5 @@ import inspect from abc import ABCMeta -from pprint import pformat import pyblish.api from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin from openpype.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS @@ -14,9 +13,8 @@ from .lib import ( ) from openpype.pipeline.colorspace import ( - get_imageio_colorspace_from_filepath, - get_imageio_config, - get_imageio_file_rules + get_colorspace_settings_from_publish_context, + set_colorspace_data_to_representation ) @@ -306,12 +304,8 @@ class ColormanagedPyblishPluginMixin(object): matching colorspace from rules. Finally, it infuses this data into the representation. """ - allowed_ext = set( - ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) - ) - @staticmethod - def get_colorspace_settings(context): + def get_colorspace_settings(self, context): """Returns solved settings for the host context. Args: @@ -320,50 +314,18 @@ class ColormanagedPyblishPluginMixin(object): Returns: tuple | bool: config, file rules or None """ - if "imageioSettings" in context.data: - return context.data["imageioSettings"] - - project_name = context.data["projectName"] - host_name = context.data["hostName"] - anatomy_data = context.data["anatomyData"] - project_settings_ = context.data["project_settings"] - - config_data = get_imageio_config( - project_name, host_name, - project_settings=project_settings_, - anatomy_data=anatomy_data - ) - - # in case host color management is not enabled - if not config_data: - return None - - file_rules = get_imageio_file_rules( - project_name, host_name, - project_settings=project_settings_ - ) - - # caching settings for future instance processing - context.data["imageioSettings"] = (config_data, file_rules) - - return config_data, file_rules + return get_colorspace_settings_from_publish_context(context.data) def set_representation_colorspace( self, representation, context, colorspace=None, - colorspace_settings=None ): """Sets colorspace data to representation. Args: representation (dict): publishing representation context (publish.Context): publishing context - config_data (dict): host resolved config data - file_rules (dict): host resolved file rules data colorspace (str, optional): colorspace name. Defaults to None. - colorspace_settings (tuple[dict, dict], optional): - Settings for config_data and file_rules. - Defaults to None. Example: ``` @@ -380,64 +342,10 @@ class ColormanagedPyblishPluginMixin(object): ``` """ - ext = representation["ext"] - # check extension - self.log.debug("__ ext: `{}`".format(ext)) - # check if ext in lower case is in self.allowed_ext - if ext.lstrip(".").lower() not in self.allowed_ext: - self.log.debug( - "Extension '{}' is not in allowed extensions.".format(ext) - ) - return - - if colorspace_settings is None: - colorspace_settings = self.get_colorspace_settings(context) - - # in case host color management is not enabled - if not colorspace_settings: - self.log.warning("Host's colorspace management is disabled.") - return - - # unpack colorspace settings - config_data, file_rules = colorspace_settings - - if not config_data: - # warn in case no colorspace path was defined - self.log.warning("No colorspace management was defined") - return - - self.log.debug("Config data is: `{}`".format(config_data)) - - project_name = context.data["projectName"] - host_name = context.data["hostName"] - project_settings = context.data["project_settings"] - - # get one filename - filename = representation["files"] - if isinstance(filename, list): - filename = filename[0] - - self.log.debug("__ filename: `{}`".format(filename)) - - # get matching colorspace from rules - colorspace = colorspace or get_imageio_colorspace_from_filepath( - filename, host_name, project_name, - config_data=config_data, - file_rules=file_rules, - project_settings=project_settings + # using cached settings if available + set_colorspace_data_to_representation( + representation, context.data, + colorspace, + log=self.log ) - self.log.debug("__ colorspace: `{}`".format(colorspace)) - - # infuse data to representation - if colorspace: - colorspace_data = { - "colorspace": colorspace, - "config": config_data - } - - # update data key - representation["colorspaceData"] = colorspace_data - - self.log.debug("__ colorspace_data: `{}`".format( - pformat(colorspace_data))) diff --git a/schema/session-3.0.json b/openpype/pipeline/schema/session-4.0.json similarity index 61% rename from schema/session-3.0.json rename to openpype/pipeline/schema/session-4.0.json index 9f785939e4..0dab48aa46 100644 --- a/schema/session-3.0.json +++ b/openpype/pipeline/schema/session-4.0.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "title": "openpype:session-3.0", + "title": "openpype:session-4.0", "description": "The Avalon environment", "type": "object", @@ -9,16 +9,10 @@ "additionalProperties": true, "required": [ - "AVALON_PROJECT", - "AVALON_ASSET" + "AVALON_PROJECT" ], "properties": { - "AVALON_PROJECTS": { - "description": "Absolute path to root of project directories", - "type": "string", - "example": "/nas/projects" - }, "AVALON_PROJECT": { "description": "Name of project", "type": "string", @@ -28,7 +22,7 @@ "AVALON_ASSET": { "description": "Name of asset", "type": "string", - "pattern": "^\\w*$", + "pattern": "^[\\/\\w]*$", "example": "Bruce" }, "AVALON_TASK": { @@ -41,7 +35,7 @@ "description": "Name of host", "type": "string", "pattern": "^\\w*$", - "example": "maya2016" + "example": "maya" }, "AVALON_DB": { "description": "Name of database", @@ -53,7 +47,7 @@ "AVALON_LABEL": { "description": "Nice name of Avalon, used in e.g. graphical user interfaces", "type": "string", - "example": "Mindbender", + "example": "MyLabel", "default": "Avalon" }, "AVALON_TIMEOUT": { @@ -62,20 +56,6 @@ "pattern": "^[0-9]*$", "default": "1000", "example": "1000" - }, - "AVALON_INSTANCE_ID": { - "description": "Unique identifier for instances in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.instance", - "example": "avalon.instance" - }, - "AVALON_CONTAINER_ID": { - "description": "Unique identifier for a loaded representation in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.container", - "example": "avalon.container" } } } diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py index b2b3679450..14fb8b06fc 100644 --- a/openpype/pipeline/thumbnail.py +++ b/openpype/pipeline/thumbnail.py @@ -4,7 +4,7 @@ import logging from openpype import AYON_SERVER_ENABLED from openpype.lib import Logger -from openpype.client import get_project +from openpype.client import get_project, get_ayon_server_api_connection from . import legacy_io from .anatomy import Anatomy from .plugin_discover import ( @@ -153,8 +153,6 @@ class ServerThumbnailResolver(ThumbnailResolver): if not entity_type or not entity_id: return None - import ayon_api - project_name = self.dbcon.active_project() thumbnail_id = thumbnail_entity["_id"] @@ -166,8 +164,12 @@ class ServerThumbnailResolver(ThumbnailResolver): # This is new way how thumbnails can be received from server # - output is 'ThumbnailContent' object - if hasattr(ayon_api, "get_thumbnail_by_id"): - result = ayon_api.get_thumbnail_by_id(thumbnail_id) + # NOTE Use 'get_server_api_connection' because public function + # 'get_thumbnail_by_id' does not return output of 'ServerAPI' + # method. + con = get_ayon_server_api_connection() + if hasattr(con, "get_thumbnail_by_id"): + result = con.get_thumbnail_by_id(thumbnail_id) if result.is_valid: filepath = cache.store_thumbnail( project_name, @@ -178,7 +180,7 @@ class ServerThumbnailResolver(ThumbnailResolver): else: # Backwards compatibility for ayon api where 'get_thumbnail_by_id' # is not implemented and output is filepath - filepath = ayon_api.get_thumbnail( + filepath = con.get_thumbnail( project_name, entity_type, entity_id, thumbnail_id ) diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index b218a34868..9dc833061a 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -19,6 +19,7 @@ from abc import ABCMeta, abstractmethod import six +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_asset_by_name, get_linked_assets, @@ -1272,31 +1273,54 @@ class PlaceholderLoadMixin(object): # Sort for readability families = list(sorted(families)) - return [ + if AYON_SERVER_ENABLED: + builder_type_enum_items = [ + {"label": "Current folder", "value": "context_folder"}, + # TODO implement linked folders + # {"label": "Linked folders", "value": "linked_folders"}, + {"label": "All folders", "value": "all_folders"}, + ] + build_type_label = "Folder Builder Type" + build_type_help = ( + "Folder Builder Type\n" + "\nBuilder type describe what template loader will look" + " for." + "\nCurrent Folder: Template loader will look for products" + " of current context folder (Folder /assets/bob will" + " find asset)" + "\nAll folders: All folders matching the regex will be" + " used." + ) + else: + builder_type_enum_items = [ + {"label": "Current asset", "value": "context_asset"}, + {"label": "Linked assets", "value": "linked_asset"}, + {"label": "All assets", "value": "all_assets"}, + ] + build_type_label = "Asset Builder Type" + build_type_help = ( + "Asset Builder Type\n" + "\nBuilder type describe what template loader will look" + " for." + "\ncontext_asset : Template loader will look for subsets" + " of current context asset (Asset bob will find asset)" + "\nlinked_asset : Template loader will look for assets" + " linked to current context asset." + "\nLinked asset are looked in database under" + " field \"inputLinks\"" + ) + + attr_defs = [ attribute_definitions.UISeparatorDef(), attribute_definitions.UILabelDef("Main attributes"), attribute_definitions.UISeparatorDef(), attribute_definitions.EnumDef( "builder_type", - label="Asset Builder Type", + label=build_type_label, default=options.get("builder_type"), - items=[ - {"label": "Current asset", "value": "context_asset"}, - {"label": "Linked assets", "value": "linked_asset"}, - {"label": "All assets", "value": "all_assets"}, - ], - tooltip=( - "Asset Builder Type\n" - "\nBuilder type describe what template loader will look" - " for." - "\ncontext_asset : Template loader will look for subsets" - " of current context asset (Asset bob will find asset)" - "\nlinked_asset : Template loader will look for assets" - " linked to current context asset." - "\nLinked asset are looked in database under" - " field \"inputLinks\"" - ) + items=builder_type_enum_items, + tooltip=build_type_help ), attribute_definitions.EnumDef( "family", @@ -1352,34 +1376,63 @@ class PlaceholderLoadMixin(object): attribute_definitions.UISeparatorDef(), attribute_definitions.UILabelDef("Optional attributes"), attribute_definitions.UISeparatorDef(), - attribute_definitions.TextDef( - "asset", - label="Asset filter", - default=options.get("asset"), - placeholder="regex filtering by asset name", - tooltip=( - "Filtering assets by matching field regex to asset's name" - ) - ), - attribute_definitions.TextDef( - "subset", - label="Subset filter", - default=options.get("subset"), - placeholder="regex filtering by subset name", - tooltip=( - "Filtering assets by matching field regex to subset's name" - ) - ), - attribute_definitions.TextDef( - "hierarchy", - label="Hierarchy filter", - default=options.get("hierarchy"), - placeholder="regex filtering by asset's hierarchy", - tooltip=( - "Filtering assets by matching field asset's hierarchy" - ) - ) ] + if AYON_SERVER_ENABLED: + attr_defs.extend([ + attribute_definitions.TextDef( + "folder_path", + label="Folder filter", + default=options.get("folder_path"), + placeholder="regex filtering by folder path", + tooltip=( + "Filtering assets by matching" + " field regex to folder path" + ) + ), + attribute_definitions.TextDef( + "product_name", + label="Product filter", + default=options.get("product_name"), + placeholder="regex filtering by product name", + tooltip=( + "Filtering assets by matching" + " field regex to product name" + ) + ), + ]) + else: + attr_defs.extend([ + attribute_definitions.TextDef( + "asset", + label="Asset filter", + default=options.get("asset"), + placeholder="regex filtering by asset name", + tooltip=( + "Filtering assets by matching" + " field regex to asset's name" + ) + ), + attribute_definitions.TextDef( + "subset", + label="Subset filter", + default=options.get("subset"), + placeholder="regex filtering by subset name", + tooltip=( + "Filtering assets by matching" + " field regex to subset's name" + ) + ), + attribute_definitions.TextDef( + "hierarchy", + label="Hierarchy filter", + default=options.get("hierarchy"), + placeholder="regex filtering by asset's hierarchy", + tooltip=( + "Filtering assets by matching field asset's hierarchy" + ) + ) + ]) + return attr_defs def parse_loader_args(self, loader_args): """Helper function to parse string of loader arugments. @@ -1409,6 +1462,117 @@ class PlaceholderLoadMixin(object): return {} + def _query_by_folder_regex(self, project_name, folder_regex): + """Query folders by folder path regex. + + WARNING: + This method will be removed once the same functionality is + available in ayon-python-api. + + Args: + project_name (str): Project name. + folder_regex (str): Regex for folder path. + + Returns: + list[str]: List of folder paths. + """ + + from ayon_api.graphql_queries import folders_graphql_query + from openpype.client import get_ayon_server_api_connection + + query = folders_graphql_query({"id"}) + + folders_field = None + for child in query._children: + if child.path != "project": + continue + + for project_child in child._children: + if project_child.path == "project/folders": + folders_field = project_child + break + if folders_field: + break + + if "folderPathRegex" not in query._variables: + folder_path_regex_var = query.add_variable( + "folderPathRegex", "String!" + ) + folders_field.set_filter("pathEx", folder_path_regex_var) + + query.set_variable_value("projectName", project_name) + if folder_regex: + query.set_variable_value("folderPathRegex", folder_regex) + + api = get_ayon_server_api_connection() + for parsed_data in query.continuous_query(api): + for folder in parsed_data["project"]["folders"]: + yield folder["id"] + + def _get_representations_ayon(self, placeholder): + # An OpenPype placeholder loaded in AYON + if "asset" in placeholder.data: + return [] + + representation_name = placeholder.data["representation"] + if not representation_name: + return [] + + project_name = self.builder.project_name + current_asset_doc = self.builder.current_asset_doc + + folder_path_regex = placeholder.data["folder_path"] + product_name_regex_value = placeholder.data["product_name"] + product_name_regex = None + if product_name_regex_value: + product_name_regex = re.compile(product_name_regex_value) + product_type = placeholder.data["family"] + + builder_type = placeholder.data["builder_type"] + folder_ids = [] + if builder_type == "context_folder": + folder_ids = [current_asset_doc["_id"]] + + elif builder_type == "all_folders": + folder_ids = list(self._query_by_folder_regex( + project_name, folder_path_regex + )) + + if not folder_ids: + return [] + + from ayon_api import get_products, get_last_versions + + products = list(get_products( + project_name, + folder_ids=folder_ids, + product_types=[product_type], + fields={"id", "name"} + )) + filtered_product_ids = set() + for product in products: + if ( + product_name_regex is None + or product_name_regex.match(product["name"]) + ): + filtered_product_ids.add(product["id"]) + + if not filtered_product_ids: + return [] + + version_ids = set( + version["id"] + for version in get_last_versions( + project_name, filtered_product_ids, fields={"id"} + ).values() + ) + return list(get_representations( + project_name, + representation_names=[representation_name], + version_ids=version_ids + )) + + def _get_representations(self, placeholder): """Prepared query of representations based on load options. @@ -1428,6 +1592,13 @@ class PlaceholderLoadMixin(object): from placeholder data. """ + if AYON_SERVER_ENABLED: + return self._get_representations_ayon(placeholder) + + # An AYON placeholder loaded in OpenPype + if "folder_path" in placeholder.data: + return [] + project_name = self.builder.project_name current_asset_doc = self.builder.current_asset_doc linked_asset_docs = self.builder.linked_asset_docs @@ -1472,8 +1643,15 @@ class PlaceholderLoadMixin(object): context_filters=context_filters )) + def _before_placeholder_load(self, placeholder): + """Can be overridden. It's called before placeholder representations + are loaded. + """ + + pass + def _before_repre_load(self, placeholder, representation): - """Can be overriden. Is called before representation is loaded.""" + """Can be overridden. It's called before representation is loaded.""" pass @@ -1506,7 +1684,7 @@ class PlaceholderLoadMixin(object): return output def populate_load_placeholder(self, placeholder, ignore_repre_ids=None): - """Load placeholder is goind to load matching representations. + """Load placeholder is going to load matching representations. Note: Ignore repre ids is to avoid loading the same representation again @@ -1528,7 +1706,7 @@ class PlaceholderLoadMixin(object): # TODO check loader existence loader_name = placeholder.data["loader"] - loader_args = placeholder.data["loader_args"] + loader_args = self.parse_loader_args(placeholder.data["loader_args"]) placeholder_representations = self._get_representations(placeholder) @@ -1550,6 +1728,11 @@ class PlaceholderLoadMixin(object): self.project_name, filtered_representations ) loaders_by_name = self.builder.get_loaders_by_name() + self._before_placeholder_load( + placeholder + ) + + failed = False for repre_load_context in repre_load_contexts.values(): representation = repre_load_context["representation"] repre_context = representation["context"] @@ -1562,24 +1745,24 @@ class PlaceholderLoadMixin(object): repre_context["subset"], repre_context["asset"], loader_name, - loader_args + placeholder.data["loader_args"], ) ) try: container = load_with_repre_context( loaders_by_name[loader_name], repre_load_context, - options=self.parse_loader_args(loader_args) + options=loader_args ) except Exception: - failed = True self.load_failed(placeholder, representation) - + failed = True else: - failed = False self.load_succeed(placeholder, container) - self.post_placeholder_process(placeholder, failed) + + # Run post placeholder process after load of all representations + self.post_placeholder_process(placeholder, failed) if failed: self.log.debug( @@ -1599,10 +1782,7 @@ class PlaceholderLoadMixin(object): placeholder.load_succeed(container) def post_placeholder_process(self, placeholder, failed): - """Cleanup placeholder after load of single representation. - - Can be called multiple times during placeholder item populating and is - called even if loading failed. + """Cleanup placeholder after load of its corresponding representations. Args: placeholder (PlaceholderItem): Item which was just used to load @@ -1801,10 +1981,7 @@ class PlaceholderCreateMixin(object): placeholder.create_succeed(creator_instance) def post_placeholder_process(self, placeholder, failed): - """Cleanup placeholder after load of single representation. - - Can be called multiple times during placeholder item populating and is - called even if loading failed. + """Cleanup placeholder after load of its corresponding representations. Args: placeholder (PlaceholderItem): Item which was just used to load diff --git a/openpype/plugins/actions/open_file_explorer.py b/openpype/plugins/actions/open_file_explorer.py index e4fbd91143..1568c41fbd 100644 --- a/openpype/plugins/actions/open_file_explorer.py +++ b/openpype/plugins/actions/open_file_explorer.py @@ -83,10 +83,6 @@ class OpenTaskPath(LauncherAction): if os.path.exists(valid_workdir): return valid_workdir - # If task was selected, try to find asset path only to asset - if not task_name: - raise AssertionError("Folder does not exist.") - data.pop("task", None) workdir = anatomy.templates_obj["work"]["folder"].format(data) valid_workdir = self._find_first_filled_path(workdir) @@ -95,7 +91,7 @@ class OpenTaskPath(LauncherAction): valid_workdir = os.path.normpath(valid_workdir) if os.path.exists(valid_workdir): return valid_workdir - raise AssertionError("Folder does not exist.") + raise AssertionError("Folder does not exist yet.") @staticmethod def open_in_explorer(path): diff --git a/openpype/plugins/load/push_to_library.py b/openpype/plugins/load/push_to_library.py index dd7291e686..5befc5eb9d 100644 --- a/openpype/plugins/load/push_to_library.py +++ b/openpype/plugins/load/push_to_library.py @@ -1,6 +1,6 @@ import os -from openpype import PACKAGE_DIR +from openpype import PACKAGE_DIR, AYON_SERVER_ENABLED from openpype.lib import get_openpype_execute_args, run_detached_process from openpype.pipeline import load from openpype.pipeline.load import LoadError @@ -32,12 +32,22 @@ class PushToLibraryProject(load.SubsetLoaderPlugin): raise LoadError("Please select only one item") context = tuple(filtered_contexts)[0] - push_tool_script_path = os.path.join( - PACKAGE_DIR, - "tools", - "push_to_project", - "app.py" - ) + + if AYON_SERVER_ENABLED: + push_tool_script_path = os.path.join( + PACKAGE_DIR, + "tools", + "ayon_push_to_project", + "main.py" + ) + else: + push_tool_script_path = os.path.join( + PACKAGE_DIR, + "tools", + "push_to_project", + "app.py" + ) + project_doc = context["project"] version_doc = context["version"] project_name = project_doc["name"] diff --git a/openpype/plugins/publish/cleanup.py b/openpype/plugins/publish/cleanup.py index 573cd829e4..6c122ddf09 100644 --- a/openpype/plugins/publish/cleanup.py +++ b/openpype/plugins/publish/cleanup.py @@ -69,7 +69,7 @@ class CleanUp(pyblish.api.InstancePlugin): skip_cleanup_filepaths.add(os.path.normpath(path)) if self.remove_temp_renders: - self.log.info("Cleaning renders new...") + self.log.debug("Cleaning renders new...") self.clean_renders(instance, skip_cleanup_filepaths) if [ef for ef in self.exclude_families @@ -95,10 +95,12 @@ class CleanUp(pyblish.api.InstancePlugin): return if instance.data.get("stagingDir_persistent"): - self.log.info("Staging dir: %s should be persistent" % staging_dir) + self.log.debug( + "Staging dir {} should be persistent".format(staging_dir) + ) return - self.log.info("Removing staging directory {}".format(staging_dir)) + self.log.debug("Removing staging directory {}".format(staging_dir)) shutil.rmtree(staging_dir) def clean_renders(self, instance, skip_cleanup_filepaths): diff --git a/openpype/plugins/publish/cleanup_explicit.py b/openpype/plugins/publish/cleanup_explicit.py index 983c9223c6..cc6b99e8f8 100644 --- a/openpype/plugins/publish/cleanup_explicit.py +++ b/openpype/plugins/publish/cleanup_explicit.py @@ -58,21 +58,21 @@ class ExplicitCleanUp(pyblish.api.ContextPlugin): # Store failed paths with exception failed = [] # Store removed filepaths for logging - succeded_files = set() + succeeded_files = set() # Remove file by file for filepath in filepaths: try: os.remove(filepath) - succeded_files.add(filepath) + succeeded_files.add(filepath) except Exception as exc: failed.append((filepath, exc)) - if succeded_files: + if succeeded_files: self.log.info( - "Removed files:\n{}".format("\n".join(succeded_files)) + "Removed files:\n{}".format("\n".join(sorted(succeeded_files))) ) - # Delete folders with it's content + # Delete folders with its content succeeded = set() for dirpath in dirpaths: # Check if directory still exists @@ -87,17 +87,21 @@ class ExplicitCleanUp(pyblish.api.ContextPlugin): if succeeded: self.log.info( - "Removed directories:\n{}".format("\n".join(succeeded)) + "Removed directories:\n{}".format( + "\n".join(sorted(succeeded)) + ) ) - # Prepare lines for report of failed removements + # Prepare lines for report of failed removals lines = [] for filepath, exc in failed: lines.append("{}: {}".format(filepath, str(exc))) if lines: self.log.warning( - "Failed to remove filepaths:\n{}".format("\n".join(lines)) + "Failed to remove filepaths:\n{}".format( + "\n".join(sorted(lines)) + ) ) def _remove_empty_dirs(self, empty_dirpaths): @@ -134,8 +138,8 @@ class ExplicitCleanUp(pyblish.api.ContextPlugin): if to_skip_dirpaths: self.log.debug( - "Skipped directories because contain files:\n{}".format( - "\n".join(to_skip_dirpaths) + "Skipped directories because they contain files:\n{}".format( + "\n".join(sorted(to_skip_dirpaths)) ) ) @@ -147,6 +151,6 @@ class ExplicitCleanUp(pyblish.api.ContextPlugin): if to_delete_dirpaths: self.log.debug( "Deleted empty directories:\n{}".format( - "\n".join(to_delete_dirpaths) + "\n".join(sorted(to_delete_dirpaths)) ) ) diff --git a/openpype/plugins/publish/cleanup_farm.py b/openpype/plugins/publish/cleanup_farm.py index 8052f13734..e655437ced 100644 --- a/openpype/plugins/publish/cleanup_farm.py +++ b/openpype/plugins/publish/cleanup_farm.py @@ -26,10 +26,10 @@ class CleanUpFarm(pyblish.api.ContextPlugin): # Skip process if is not in list of source hosts in which this # plugin should run if src_host_name not in self.allowed_hosts: - self.log.info(( + self.log.debug( "Source host \"{}\" is not in list of enabled hosts {}." - " Skipping" - ).format(str(src_host_name), str(self.allowed_hosts))) + " Skipping".format(src_host_name, self.allowed_hosts) + ) return self.log.debug("Preparing filepaths to remove") @@ -47,7 +47,7 @@ class CleanUpFarm(pyblish.api.ContextPlugin): dirpaths_to_remove.add(os.path.normpath(staging_dir)) if not dirpaths_to_remove: - self.log.info("Nothing to remove. Skipping") + self.log.debug("Nothing to remove. Skipping") return self.log.debug("Filepaths to remove are:\n{}".format( diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index b4f4d6a16a..1b4b44e40e 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -30,7 +30,8 @@ import pyblish.api from openpype.client import ( get_assets, get_subsets, - get_last_versions + get_last_versions, + get_asset_name_identifier, ) from openpype.pipeline.version_start import get_versioning_start @@ -60,6 +61,9 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): self.log.debug("Querying asset documents for instances.") context_asset_doc = context.data.get("assetEntity") + context_asset_name = None + if context_asset_doc: + context_asset_name = get_asset_name_identifier(context_asset_doc) instances_with_missing_asset_doc = collections.defaultdict(list) for instance in context: @@ -68,15 +72,15 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # There is possibility that assetEntity on instance is already set # which can happen in standalone publisher - if ( - instance_asset_doc - and instance_asset_doc["name"] == _asset_name - ): - continue + if instance_asset_doc: + instance_asset_name = get_asset_name_identifier( + instance_asset_doc) + if instance_asset_name == _asset_name: + continue # Check if asset name is the same as what is in context # - they may be different, e.g. in NukeStudio - if context_asset_doc and context_asset_doc["name"] == _asset_name: + if context_asset_name and context_asset_name == _asset_name: instance.data["assetEntity"] = context_asset_doc else: @@ -93,7 +97,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): asset_docs = get_assets(project_name, asset_names=asset_names) asset_docs_by_name = { - asset_doc["name"]: asset_doc + get_asset_name_identifier(asset_doc): asset_doc for asset_doc in asset_docs } @@ -183,35 +187,29 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): self.log.debug("Storing anatomy data to instance data.") project_doc = context.data["projectEntity"] - context_asset_doc = context.data.get("assetEntity") - project_task_types = project_doc["config"]["tasks"] for instance in context: + asset_doc = instance.data.get("assetEntity") anatomy_updates = { - "asset": instance.data["asset"], - "folder": { - "name": instance.data["asset"], - }, "family": instance.data["family"], "subset": instance.data["subset"], } - - # Hierarchy - asset_doc = instance.data.get("assetEntity") - if ( - asset_doc - and ( - not context_asset_doc - or asset_doc["_id"] != context_asset_doc["_id"] - ) - ): + if asset_doc: parents = asset_doc["data"].get("parents") or list() parent_name = project_doc["name"] if parents: parent_name = parents[-1] - anatomy_updates["hierarchy"] = "/".join(parents) - anatomy_updates["parent"] = parent_name + + hierarchy = "/".join(parents) + anatomy_updates.update({ + "asset": asset_doc["name"], + "hierarchy": hierarchy, + "parent": parent_name, + "folder": { + "name": asset_doc["name"], + }, + }) # Task task_type = None diff --git a/openpype/plugins/publish/collect_audio.py b/openpype/plugins/publish/collect_audio.py index 3a0ddb3281..734a625852 100644 --- a/openpype/plugins/publish/collect_audio.py +++ b/openpype/plugins/publish/collect_audio.py @@ -6,6 +6,7 @@ from openpype.client import ( get_subsets, get_last_versions, get_representations, + get_asset_name_identifier, ) from openpype.pipeline.load import get_representation_path_with_anatomy @@ -53,8 +54,8 @@ class CollectAudio(pyblish.api.ContextPlugin): ): # Skip instances that already have audio filled if instance.data.get("audio"): - self.log.info( - "Skipping Audio collecion. It is already collected" + self.log.debug( + "Skipping Audio collection. It is already collected" ) continue filtered_instances.append(instance) @@ -70,7 +71,7 @@ class CollectAudio(pyblish.api.ContextPlugin): instances_by_asset_name[asset_name].append(instance) asset_names = set(instances_by_asset_name.keys()) - self.log.info(( + self.log.debug(( "Searching for audio subset '{subset}' in assets {assets}" ).format( subset=self.audio_subset_name, @@ -100,7 +101,7 @@ class CollectAudio(pyblish.api.ContextPlugin): "offset": 0, "filename": repre_path }] - self.log.info("Audio Data added to instance ...") + self.log.debug("Audio Data added to instance ...") def query_representations(self, project_name, asset_names): """Query representations related to audio subsets for passed assets. @@ -121,12 +122,13 @@ class CollectAudio(pyblish.api.ContextPlugin): asset_docs = get_assets( project_name, asset_names=asset_names, - fields=["_id", "name"] + fields=["_id", "name", "data.parents"] ) - asset_id_by_name = {} - for asset_doc in asset_docs: - asset_id_by_name[asset_doc["name"]] = asset_doc["_id"] + asset_id_by_name = { + get_asset_name_identifier(asset_doc): asset_doc["_id"] + for asset_doc in asset_docs + } asset_ids = set(asset_id_by_name.values()) # Query subsets with name define by 'audio_subset_name' attr diff --git a/openpype/plugins/publish/collect_comment.py b/openpype/plugins/publish/collect_comment.py index 9f41e37f22..38d61a7071 100644 --- a/openpype/plugins/publish/collect_comment.py +++ b/openpype/plugins/publish/collect_comment.py @@ -103,10 +103,10 @@ class CollectComment( instance.data["comment"] = instance_comment if instance_comment: - msg_end = " has comment set to: \"{}\"".format( + msg_end = "has comment set to: \"{}\"".format( instance_comment) else: - msg_end = " does not have set comment" + msg_end = "does not have set comment" self.log.debug("Instance {} {}".format(instance_label, msg_end)) def cleanup_comment(self, comment): diff --git a/openpype/plugins/publish/collect_current_context.py b/openpype/plugins/publish/collect_current_context.py index 166d75e5de..8b12a3f77f 100644 --- a/openpype/plugins/publish/collect_current_context.py +++ b/openpype/plugins/publish/collect_current_context.py @@ -39,5 +39,12 @@ class CollectCurrentContext(pyblish.api.ContextPlugin): # - 'task' -> 'taskName' self.log.info(( - "Collected project context\nProject: {}\nAsset: {}\nTask: {}" - ).format(project_name, asset_name, task_name)) + "Collected project context\n" + "Project: {project_name}\n" + "Asset: {asset_name}\n" + "Task: {task_name}" + ).format( + project_name=context.data["projectName"], + asset_name=context.data["asset"], + task_name=context.data["task"] + )) diff --git a/openpype/plugins/publish/collect_current_pype_user.py b/openpype/plugins/publish/collect_current_pype_user.py index 2d507ba292..5c0c4fc82e 100644 --- a/openpype/plugins/publish/collect_current_pype_user.py +++ b/openpype/plugins/publish/collect_current_pype_user.py @@ -1,4 +1,6 @@ import pyblish.api + +from openpype import AYON_SERVER_ENABLED from openpype.lib import get_openpype_username @@ -7,7 +9,11 @@ class CollectCurrentUserPype(pyblish.api.ContextPlugin): # Order must be after default pyblish-base CollectCurrentUser order = pyblish.api.CollectorOrder + 0.001 - label = "Collect Pype User" + label = ( + "Collect AYON User" + if AYON_SERVER_ENABLED + else "Collect OpenPype User" + ) def process(self, context): user = get_openpype_username() diff --git a/openpype/plugins/publish/collect_farm_target.py b/openpype/plugins/publish/collect_farm_target.py new file mode 100644 index 0000000000..adcd842b48 --- /dev/null +++ b/openpype/plugins/publish/collect_farm_target.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +import pyblish.api + + +class CollectFarmTarget(pyblish.api.InstancePlugin): + """Collects the render target for the instance + """ + + order = pyblish.api.CollectorOrder + 0.499 + label = "Collect Farm Target" + targets = ["local"] + + def process(self, instance): + if not instance.data.get("farm"): + return + + context = instance.context + + farm_name = "" + op_modules = context.data.get("openPypeModules") + + for farm_renderer in ["deadline", "royalrender", "muster"]: + op_module = op_modules.get(farm_renderer, False) + + if op_module and op_module.enabled: + farm_name = farm_renderer + elif not op_module: + self.log.error("Cannot get OpenPype {0} module.".format( + farm_renderer)) + + if farm_name: + self.log.debug("Collected render target: {0}".format(farm_name)) + instance.data["toBeRenderedOn"] = farm_name + else: + AssertionError("No OpenPype renderer module found") diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index 8806a13ca0..84f6141069 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -4,6 +4,7 @@ import os import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.host import IPublishHost from openpype.pipeline import legacy_io, registered_host from openpype.pipeline.create import CreateContext @@ -38,6 +39,8 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): for created_instance in create_context.instances: instance_data = created_instance.data_to_store() + if AYON_SERVER_ENABLED: + instance_data["asset"] = instance_data.pop("folderPath") if instance_data["active"]: thumbnail_path = thumbnail_paths_by_instance_id.get( created_instance.id diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index 687397be8a..b5fd1e4bb9 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -24,7 +24,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): final_context[project_name]['entity_type'] = 'Project' for instance in context: - self.log.info("Processing instance: `{}` ...".format(instance)) + self.log.debug("Processing instance: `{}` ...".format(instance)) # shot data dict shot_data = {} diff --git a/openpype/plugins/publish/collect_input_representations_to_versions.py b/openpype/plugins/publish/collect_input_representations_to_versions.py index 54a3214647..2b8c745d3d 100644 --- a/openpype/plugins/publish/collect_input_representations_to_versions.py +++ b/openpype/plugins/publish/collect_input_representations_to_versions.py @@ -46,3 +46,10 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): version_id = representation_id_to_version_id.get(repre_id) if version_id: input_versions.append(version_id) + else: + self.log.debug( + "Representation id {} skipped because its version is " + "not found in current project. Likely it is loaded " + "from a library project or uses a deleted " + "representation or version.".format(repre_id) + ) diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index 9a68b6e43d..4b130b0e03 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -5,15 +5,9 @@ Requires: masterLayer -> instance data attribute otioClipRange -> instance data attribute """ -# import os -import opentimelineio as otio -import pyblish.api from pprint import pformat -from openpype.pipeline.editorial import ( - get_media_range_with_retimes, - otio_range_to_frame_range, - otio_range_with_handles -) + +import pyblish.api class CollectOtioFrameRanges(pyblish.api.InstancePlugin): @@ -27,6 +21,14 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): hosts = ["resolve", "hiero", "flame", "traypublisher"] def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + get_media_range_with_retimes, + otio_range_to_frame_range, + otio_range_with_handles + ) + # get basic variables otio_clip = instance.data["otioClip"] workfile_start = instance.data["workfileFrameStart"] diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index f0157282a1..0e4d596213 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -11,10 +11,10 @@ Provides: instance -> families (adding ["review", "ftrack"]) """ -import opentimelineio as otio -import pyblish.api from pprint import pformat +import pyblish.api + class CollectOtioReview(pyblish.api.InstancePlugin): """Get matching otio track from defined review layer""" @@ -25,6 +25,9 @@ class CollectOtioReview(pyblish.api.InstancePlugin): hosts = ["resolve", "hiero", "flame"] def process(self, instance): + # Not all hosts can import this module. + import opentimelineio as otio + # get basic variables otio_review_clips = [] otio_timeline = instance.context.data["otioTimeline"] diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index f659791d95..739f5bb726 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -6,18 +6,15 @@ Provides: instance -> otioReviewClips """ import os + import clique -import opentimelineio as otio import pyblish.api -from openpype.pipeline.editorial import ( - get_media_range_with_retimes, - range_from_frames, - make_sequence_collection -) + from openpype.pipeline.publish import ( get_publish_template_name ) + class CollectOtioSubsetResources(pyblish.api.InstancePlugin): """Get Resources for a subset version""" @@ -26,8 +23,14 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): families = ["clip"] hosts = ["resolve", "hiero", "flame"] - def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + get_media_range_with_retimes, + range_from_frames, + make_sequence_collection + ) if "audio" in instance.data["family"]: return diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 6c8d1e9ca5..6160b4f5c8 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -54,8 +54,21 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): staging_dir = data_object.get("stagingDir") if staging_dir: data_object["stagingDir"] = anatomy.fill_root(staging_dir) + self.log.debug("Filling stagingDir with root to: %s", + data_object["stagingDir"]) def _process_path(self, data, anatomy): + """Process data of a single JSON publish metadata file. + + Args: + data: The loaded metadata from the JSON file + anatomy: Anatomy for the current context + + Returns: + bool: Whether any instance of this particular metadata file + has a persistent staging dir. + + """ # validate basic necessary data data_err = "invalid json file - missing data" required = ["asset", "user", "comment", @@ -89,27 +102,33 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): os.environ["FTRACK_SERVER"] = ftrack["FTRACK_SERVER"] # now we can just add instances from json file and we are done + any_staging_dir_persistent = False for instance_data in data.get("instances"): - self.log.info(" - processing instance for {}".format( + self.log.debug(" - processing instance for {}".format( instance_data.get("subset"))) instance = self._context.create_instance( instance_data.get("subset") ) - self.log.info("Filling stagingDir...") self._fill_staging_dir(instance_data, anatomy) instance.data.update(instance_data) # stash render job id for later validation instance.data["render_job_id"] = data.get("job").get("_id") + staging_dir_persistent = instance.data.get( + "stagingDir_persistent", False + ) + if staging_dir_persistent: + any_staging_dir_persistent = True representations = [] for repre_data in instance_data.get("representations") or []: self._fill_staging_dir(repre_data, anatomy) representations.append(repre_data) - add_repre_files_for_cleanup(instance, repre_data) + if not staging_dir_persistent: + add_repre_files_for_cleanup(instance, repre_data) instance.data["representations"] = representations @@ -121,9 +140,11 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): "offset": 0 }] }) - self.log.info( + self.log.debug( f"Adding audio to instance: {instance.data['audio']}") + return any_staging_dir_persistent + def process(self, context): self._context = context @@ -137,11 +158,11 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # Using already collected Anatomy anatomy = context.data["anatomy"] - self.log.info("Getting root setting for project \"{}\"".format( + self.log.debug("Getting root setting for project \"{}\"".format( anatomy.project_name )) - self.log.info("anatomy: {}".format(anatomy.roots)) + self.log.debug("Anatomy roots: {}".format(anatomy.roots)) try: session_is_set = False for path in paths: @@ -156,13 +177,16 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): if remapped: session_data["AVALON_WORKDIR"] = remapped - self.log.info("Setting session using data from file") + self.log.debug("Setting session using data from file") legacy_io.Session.update(session_data) os.environ.update(session_data) session_is_set = True - self._process_path(data, anatomy) - context.data["cleanupFullPaths"].append(path) - context.data["cleanupEmptyDirs"].append(os.path.dirname(path)) + staging_dir_persistent = self._process_path(data, anatomy) + if not staging_dir_persistent: + context.data["cleanupFullPaths"].append(path) + context.data["cleanupEmptyDirs"].append( + os.path.dirname(path) + ) except Exception as e: self.log.error(e, exc_info=True) raise Exception("Error") from e diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index f96dd0ae18..a7f12bdfdb 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -62,10 +62,18 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "effect", "staticMesh", "skeletalMesh", - "xgen" + "xgen", + "yeticacheUE", + "tycache" ] def process(self, instance): + # editorial would fail since they might not be in database yet + new_asset_publishing = instance.data.get("newAssetPublishing") + if new_asset_publishing: + self.log.debug("Instance is creating new asset. Skipping.") + return + anatomy = instance.context.data["anatomy"] template_data = copy.deepcopy(instance.data["anatomyData"]) diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 70a0aca296..f870ae9ad7 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -24,6 +24,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "hiero", "houdini", "maya", + "max", "nuke", "photoshop", "resolve", @@ -63,4 +64,6 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "filename: {}".format(filename)) context.data['version'] = int(version) - self.log.info('Scene Version: %s' % context.data.get('version')) + self.log.debug( + "Collected scene version: {}".format(context.data.get('version')) + ) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 4a64711bfd..9a978ed286 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -53,8 +53,8 @@ class ExtractBurnin(publish.Extractor): "flame", "houdini", "max", - "blender" - # "resolve" + "blender", + "unreal" ] optional = True @@ -83,7 +83,7 @@ class ExtractBurnin(publish.Extractor): return if not instance.data.get("representations"): - self.log.info( + self.log.debug( "Instance does not have filled representations. Skipping") return @@ -135,11 +135,11 @@ class ExtractBurnin(publish.Extractor): burnin_defs, repre["tags"] ) if not repre_burnin_defs: - self.log.info(( + self.log.debug( "Skipped representation. All burnin definitions from" - " selected profile does not match to representation's" - " tags. \"{}\"" - ).format(str(repre["tags"]))) + " selected profile do not match to representation's" + " tags. \"{}\"".format(repre["tags"]) + ) continue filtered_repres.append((repre, repre_burnin_defs)) @@ -164,19 +164,17 @@ class ExtractBurnin(publish.Extractor): logger=self.log) if not profile: - self.log.info(( + self.log.debug(( "Skipped instance. None of profiles in presets are for" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Task type \"{}\" | Subset \"{}\" " ).format(host_name, family, task_name, task_type, subset)) return - self.log.debug("profile: {}".format(profile)) - # Pre-filter burnin definitions by instance families burnin_defs = self.filter_burnins_defs(profile, instance) if not burnin_defs: - self.log.info(( + self.log.debug(( "Skipped instance. Burnin definitions are not set for profile" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Profile \"{}\"" @@ -223,10 +221,10 @@ class ExtractBurnin(publish.Extractor): # If result is None the requirement of conversion can't be # determined if do_convert is None: - self.log.info(( + self.log.debug( "Can't determine if representation requires conversion." " Skipped." - )) + ) continue # Do conversion if needed @@ -450,7 +448,7 @@ class ExtractBurnin(publish.Extractor): filling burnin strings. `temp_data` are for repre pre-process preparation. """ - self.log.debug("Prepring basic data for burnins") + self.log.debug("Preparing basic data for burnins") context = instance.context version = instance.data.get("version") diff --git a/openpype/plugins/publish/extract_color_transcode.py b/openpype/plugins/publish/extract_color_transcode.py index f7c8af9318..faacb7af2e 100644 --- a/openpype/plugins/publish/extract_color_transcode.py +++ b/openpype/plugins/publish/extract_color_transcode.py @@ -320,13 +320,12 @@ class ExtractOIIOTranscode(publish.Extractor): logger=self.log) if not profile: - self.log.info(( + self.log.debug(( "Skipped instance. None of profiles in presets are for" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Task type \"{}\" | Subset \"{}\" " ).format(host_name, family, task_name, task_type, subset)) - self.log.debug("profile: {}".format(profile)) return profile def _repre_is_valid(self, repre): diff --git a/openpype/plugins/publish/extract_colorspace_data.py b/openpype/plugins/publish/extract_colorspace_data.py index 363df28fb5..8873dcd637 100644 --- a/openpype/plugins/publish/extract_colorspace_data.py +++ b/openpype/plugins/publish/extract_colorspace_data.py @@ -30,7 +30,7 @@ class ExtractColorspaceData(publish.Extractor, def process(self, instance): representations = instance.data.get("representations") if not representations: - self.log.info("No representations at instance : `{}`".format( + self.log.debug("No representations at instance : `{}`".format( instance)) return diff --git a/openpype/plugins/publish/extract_hierarchy_avalon.py b/openpype/plugins/publish/extract_hierarchy_avalon.py index 1d57545bc0..d70f0cbdd7 100644 --- a/openpype/plugins/publish/extract_hierarchy_avalon.py +++ b/openpype/plugins/publish/extract_hierarchy_avalon.py @@ -21,7 +21,7 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): return if "hierarchyContext" not in context.data: - self.log.info("skipping IntegrateHierarchyToAvalon") + self.log.debug("skipping ExtractHierarchyToAvalon") return if not legacy_io.Session: diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index 915650ae41..ef69369d67 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -8,6 +8,11 @@ from ayon_api import slugify_string from ayon_api.entity_hub import EntityHub from openpype import AYON_SERVER_ENABLED +from openpype.client import get_assets, get_asset_name_identifier +from openpype.pipeline.template_data import ( + get_asset_template_data, + get_task_template_data, +) def _default_json_parse(value): @@ -27,13 +32,51 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): hierarchy_context = context.data.get("hierarchyContext") if not hierarchy_context: - self.log.info("Skipping") + self.log.debug("Skipping ExtractHierarchyToAYON") return project_name = context.data["projectName"] + self._create_hierarchy(context, project_name) + self._fill_instance_entities(context, project_name) + + def _fill_instance_entities(self, context, project_name): + instances_by_asset_name = collections.defaultdict(list) + for instance in context: + if instance.data.get("publish") is False: + continue + + instance_entity = instance.data.get("assetEntity") + if instance_entity: + continue + + # Skip if instance asset does not match + instance_asset_name = instance.data.get("asset") + instances_by_asset_name[instance_asset_name].append(instance) + + project_doc = context.data["projectEntity"] + asset_docs = get_assets( + project_name, asset_names=instances_by_asset_name.keys() + ) + asset_docs_by_name = { + get_asset_name_identifier(asset_doc): asset_doc + for asset_doc in asset_docs + } + for asset_name, instances in instances_by_asset_name.items(): + asset_doc = asset_docs_by_name[asset_name] + asset_data = get_asset_template_data(asset_doc, project_name) + for instance in instances: + task_name = instance.data.get("task") + template_data = get_task_template_data( + project_doc, asset_doc, task_name) + template_data.update(copy.deepcopy(asset_data)) + + instance.data["anatomyData"].update(template_data) + instance.data["assetEntity"] = asset_doc + + def _create_hierarchy(self, context, project_name): hierarchy_context = self._filter_hierarchy(context) if not hierarchy_context: - self.log.info("All folders were filtered out") + self.log.debug("All folders were filtered out") return self.log.debug("Hierarchy_context: {}".format( @@ -148,15 +191,15 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): """ # filter only the active publishing instances - active_folder_names = set() + active_folder_paths = set() for instance in context: if instance.data.get("publish") is not False: - active_folder_names.add(instance.data.get("asset")) + active_folder_paths.add(instance.data.get("asset")) - active_folder_names.discard(None) + active_folder_paths.discard(None) - self.log.debug("Active folder names: {}".format(active_folder_names)) - if not active_folder_names: + self.log.debug("Active folder paths: {}".format(active_folder_paths)) + if not active_folder_paths: return None project_item = None @@ -187,12 +230,13 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): if not children_context: continue - for asset_name, asset_info in children_context.items(): + for asset, asset_info in children_context.items(): if ( - asset_name not in active_folder_names + asset not in active_folder_paths and not asset_info.get("childs") ): continue + asset_name = asset.split("/")[-1] item_id = uuid.uuid4().hex new_item = copy.deepcopy(asset_info) new_item["name"] = asset_name @@ -209,7 +253,7 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): items_by_id[item_id] = new_item parent_id_by_item_id[item_id] = parent_id - if asset_name in active_folder_names: + if asset in active_folder_paths: valid_ids.add(item_id) hierarchy_queue.append((item_id, new_children_context)) diff --git a/openpype/plugins/publish/extract_otio_audio_tracks.py b/openpype/plugins/publish/extract_otio_audio_tracks.py index 4f17731452..4b73321f02 100644 --- a/openpype/plugins/publish/extract_otio_audio_tracks.py +++ b/openpype/plugins/publish/extract_otio_audio_tracks.py @@ -1,11 +1,12 @@ import os +import tempfile + import pyblish + from openpype.lib import ( get_ffmpeg_tool_args, run_subprocess ) -import tempfile -import opentimelineio as otio class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): @@ -155,6 +156,9 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): Returns: list: list of audio clip dictionaries """ + # Not all hosts can import this module. + import opentimelineio as otio + output = [] # go trough all audio tracks for otio_track in otio_timeline.tracks: diff --git a/openpype/plugins/publish/extract_otio_file.py b/openpype/plugins/publish/extract_otio_file.py index 1a6a82117d..7f1cac33d7 100644 --- a/openpype/plugins/publish/extract_otio_file.py +++ b/openpype/plugins/publish/extract_otio_file.py @@ -1,6 +1,6 @@ import os + import pyblish.api -import opentimelineio as otio from openpype.pipeline import publish @@ -16,6 +16,9 @@ class ExtractOTIOFile(publish.Extractor): hosts = ["resolve", "hiero", "traypublisher"] def process(self, instance): + # Not all hosts can import this module. + import opentimelineio as otio + if not instance.context.data.get("otioTimeline"): return # create representation data diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index 699207df8a..ad4c807091 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -15,8 +15,8 @@ Provides: """ import os + import clique -import opentimelineio as otio from pyblish import api from openpype.lib import ( @@ -24,13 +24,6 @@ from openpype.lib import ( run_subprocess, ) from openpype.pipeline import publish -from openpype.pipeline.editorial import ( - otio_range_to_frame_range, - trim_media_range, - range_from_frames, - frames_to_seconds, - make_sequence_collection -) class ExtractOTIOReview(publish.Extractor): @@ -62,6 +55,13 @@ class ExtractOTIOReview(publish.Extractor): output_ext = ".jpg" def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + otio_range_to_frame_range, + make_sequence_collection + ) + # TODO: convert resulting image sequence to mp4 # get otio clip and other time info from instance clip @@ -281,6 +281,12 @@ class ExtractOTIOReview(publish.Extractor): Returns: otio.time.TimeRange: trimmed available range """ + # Not all hosts can import these modules. + from openpype.pipeline.editorial import ( + trim_media_range, + range_from_frames + ) + avl_start = int(avl_range.start_time.value) src_start = int(avl_start + start) avl_durtation = int(avl_range.duration.value) @@ -338,6 +344,8 @@ class ExtractOTIOReview(publish.Extractor): Returns: otio.time.TimeRange: trimmed available range """ + # Not all hosts can import this module. + from openpype.pipeline.editorial import frames_to_seconds # create path and frame start to destination output_path, out_frame_start = self._get_ffmpeg_output() diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index 67ff6c538c..2020fcde93 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -15,7 +15,6 @@ from openpype.lib import ( run_subprocess, ) from openpype.pipeline import publish -from openpype.pipeline.editorial import frames_to_seconds class ExtractOTIOTrimmingVideo(publish.Extractor): @@ -75,6 +74,8 @@ class ExtractOTIOTrimmingVideo(publish.Extractor): otio_range (opentime.TimeRange): range to trim to """ + # Not all hosts can import this module. + from openpype.pipeline.editorial import frames_to_seconds # create path to destination output_path = self._get_ffmpeg_output(input_file_path) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 9cc456872e..cd0f78530a 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -21,6 +21,7 @@ from openpype.lib.transcoding import ( IMAGE_EXTENSIONS, get_ffprobe_streams, should_convert_for_ffmpeg, + get_review_layer_name, convert_input_paths_for_ffmpeg, get_transcode_temp_directory, ) @@ -67,7 +68,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx"] + image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga"] video_exts = ["mov", "mp4"] supported_exts = image_exts + video_exts @@ -142,7 +143,7 @@ class ExtractReview(pyblish.api.InstancePlugin): custom_tags = repre.get("custom_tags") if "review" not in tags: self.log.debug(( - "Repre: {} - Didn't found \"review\" in tags. Skipping" + "Repre: {} - Didn't find \"review\" in tags. Skipping" ).format(repre_name)) continue @@ -266,6 +267,8 @@ class ExtractReview(pyblish.api.InstancePlugin): )) continue + layer_name = get_review_layer_name(first_input_path) + # Do conversion if needed # - change staging dir of source representation # - must be set back after output definitions processing @@ -284,7 +287,8 @@ class ExtractReview(pyblish.api.InstancePlugin): instance, repre, src_repre_staging_dir, - filtered_output_defs + filtered_output_defs, + layer_name ) finally: @@ -298,7 +302,12 @@ class ExtractReview(pyblish.api.InstancePlugin): shutil.rmtree(new_staging_dir) def _render_output_definitions( - self, instance, repre, src_repre_staging_dir, output_definitions + self, + instance, + repre, + src_repre_staging_dir, + output_definitions, + layer_name ): fill_data = copy.deepcopy(instance.data["anatomyData"]) for _output_def in output_definitions: @@ -370,7 +379,12 @@ class ExtractReview(pyblish.api.InstancePlugin): try: # temporary until oiiotool is supported cross platform ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data, fill_data + output_def, + instance, + new_repre, + temp_data, + fill_data, + layer_name, ) except ZeroDivisionError: # TODO recalculate width and height using OIIO before @@ -531,7 +545,13 @@ class ExtractReview(pyblish.api.InstancePlugin): } def _ffmpeg_arguments( - self, output_def, instance, new_repre, temp_data, fill_data + self, + output_def, + instance, + new_repre, + temp_data, + fill_data, + layer_name ): """Prepares ffmpeg arguments for expected extraction. @@ -599,6 +619,10 @@ class ExtractReview(pyblish.api.InstancePlugin): duration_seconds = float(output_frames_len / temp_data["fps"]) + # Define which layer should be used + if layer_name: + ffmpeg_input_args.extend(["-layer", layer_name]) + if temp_data["input_is_sequence"]: # Set start frame of input sequence (just frame in filename) # - definition of input filepath diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 886384fee6..d89fbb90c4 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -15,6 +15,7 @@ from openpype.lib import ( get_ffmpeg_format_args, ) from openpype.pipeline import publish +from openpype.pipeline.publish import KnownPublishError class ExtractReviewSlate(publish.Extractor): @@ -46,7 +47,7 @@ class ExtractReviewSlate(publish.Extractor): "*": inst_data["slateFrame"] } - self.log.info("_ slates_data: {}".format(pformat(slates_data))) + self.log.debug("_ slates_data: {}".format(pformat(slates_data))) if "reviewToWidth" in inst_data: use_legacy_code = True @@ -76,7 +77,7 @@ class ExtractReviewSlate(publish.Extractor): ) # get slate data slate_path = self._get_slate_path(input_file, slates_data) - self.log.info("_ slate_path: {}".format(slate_path)) + self.log.debug("_ slate_path: {}".format(slate_path)) slate_width, slate_height = self._get_slates_resolution(slate_path) @@ -93,9 +94,10 @@ class ExtractReviewSlate(publish.Extractor): # Raise exception of any stream didn't define input resolution if input_width is None: - raise AssertionError(( + raise KnownPublishError( "FFprobe couldn't read resolution from input file: \"{}\"" - ).format(input_path)) + .format(input_path) + ) ( audio_codec, diff --git a/openpype/plugins/publish/extract_scanline_exr.py b/openpype/plugins/publish/extract_scanline_exr.py index 9f22794a79..747155689b 100644 --- a/openpype/plugins/publish/extract_scanline_exr.py +++ b/openpype/plugins/publish/extract_scanline_exr.py @@ -29,24 +29,24 @@ class ExtractScanlineExr(pyblish.api.InstancePlugin): representations_new = [] for repre in representations: - self.log.info( + self.log.debug( "Processing representation {}".format(repre.get("name"))) tags = repre.get("tags", []) if "toScanline" not in tags: - self.log.info(" - missing toScanline tag") + self.log.debug(" - missing toScanline tag") continue # run only on exrs if repre.get("ext") != "exr": - self.log.info("- not EXR files") + self.log.debug("- not EXR files") continue if not isinstance(repre['files'], (list, tuple)): input_files = [repre['files']] - self.log.info("We have a single frame") + self.log.debug("We have a single frame") else: input_files = repre['files'] - self.log.info("We have a sequence") + self.log.debug("We have a sequence") stagingdir = os.path.normpath(repre.get("stagingDir")) @@ -68,7 +68,7 @@ class ExtractScanlineExr(pyblish.api.InstancePlugin): ] subprocess_exr = " ".join(oiio_cmd) - self.log.info(f"running: {subprocess_exr}") + self.log.debug(f"running: {subprocess_exr}") run_subprocess(subprocess_exr, logger=self.log) # raise error if there is no ouptput diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py index b72a6d02ad..c6112b3cdf 100644 --- a/openpype/plugins/publish/extract_thumbnail.py +++ b/openpype/plugins/publish/extract_thumbnail.py @@ -5,19 +5,19 @@ import tempfile import pyblish.api from openpype.lib import ( get_ffmpeg_tool_args, - get_oiio_tool_args, is_oiio_supported, run_subprocess, path_to_subprocess_arg, ) +from openpype.lib.transcoding import convert_colorspace class ExtractThumbnail(pyblish.api.InstancePlugin): """Create jpg thumbnail from sequence using ffmpeg""" label = "Extract Thumbnail" - order = pyblish.api.ExtractorOrder + order = pyblish.api.ExtractorOrder + 0.49 families = [ "imagesequence", "render", "render2d", "prerender", "source", "clip", "take", "online", "image" @@ -25,7 +25,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): hosts = ["shell", "fusion", "resolve", "traypublisher", "substancepainter"] enabled = False - # presetable attribute + # attribute presets from settings + oiiotool_defaults = None ffmpeg_args = None def process(self, instance): @@ -43,12 +44,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # Skip if instance have 'review' key in data set to 'False' if not self._is_review_instance(instance): - self.log.info("Skipping - no review set on instance.") + self.log.debug("Skipping - no review set on instance.") return # Check if already has thumbnail created if self._already_has_thumbnail(instance_repres): - self.log.info("Thumbnail representation already present.") + self.log.debug("Thumbnail representation already present.") return # skip crypto passes. @@ -58,15 +59,15 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # representation that can be determined much earlier and # with better precision. if "crypto" in subset_name.lower(): - self.log.info("Skipping crypto passes.") + self.log.debug("Skipping crypto passes.") return filtered_repres = self._get_filtered_repres(instance) if not filtered_repres: - self.log.info(( - "Instance don't have representations" - " that can be used as source for thumbnail. Skipping" - )) + self.log.info( + "Instance doesn't have representations that can be used " + "as source for thumbnail. Skipping thumbnail extraction." + ) return # Create temp directory for thumbnail @@ -94,23 +95,32 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): filename = os.path.splitext(input_file)[0] jpeg_file = filename + "_thumb.jpg" full_output_path = os.path.join(dst_staging, jpeg_file) + colorspace_data = repre.get("colorspaceData") - if oiio_supported: - self.log.debug("Trying to convert with OIIO") + # only use OIIO if it is supported and representation has + # colorspace data + if oiio_supported and colorspace_data: + self.log.debug( + "Trying to convert with OIIO " + "with colorspace data: {}".format(colorspace_data) + ) # If the input can read by OIIO then use OIIO method for # conversion otherwise use ffmpeg thumbnail_created = self.create_thumbnail_oiio( - full_input_path, full_output_path + full_input_path, + full_output_path, + colorspace_data ) # Try to use FFMPEG if OIIO is not supported or for cases when - # oiiotool isn't available + # oiiotool isn't available or representation is not having + # colorspace data if not thumbnail_created: if oiio_supported: - self.log.info(( + self.log.debug( "Converting with FFMPEG because input" " can't be read by OIIO." - )) + ) thumbnail_created = self.create_thumbnail_ffmpeg( full_input_path, full_output_path @@ -138,7 +148,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): break if not thumbnail_created: - self.log.warning("Thumbanil has not been created.") + self.log.warning("Thumbnail has not been created.") def _is_review_instance(self, instance): # TODO: We should probably handle "not creating" of thumbnail @@ -165,25 +175,74 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): continue if not repre.get("files"): - self.log.info(( - "Representation \"{}\" don't have files. Skipping" + self.log.debug(( + "Representation \"{}\" doesn't have files. Skipping" ).format(repre["name"])) continue filtered_repres.append(repre) return filtered_repres - def create_thumbnail_oiio(self, src_path, dst_path): + def create_thumbnail_oiio( + self, + src_path, + dst_path, + colorspace_data, + ): + """Create thumbnail using OIIO tool oiiotool + + Args: + src_path (str): path to source file + dst_path (str): path to destination file + colorspace_data (dict): colorspace data from representation + keys: + colorspace (str) + config (dict) + display (Optional[str]) + view (Optional[str]) + + Returns: + str: path to created thumbnail + """ self.log.info("Extracting thumbnail {}".format(dst_path)) - oiio_cmd = get_oiio_tool_args( - "oiiotool", - "-a", src_path, - "-o", dst_path - ) - self.log.debug("running: {}".format(" ".join(oiio_cmd))) + + repre_display = colorspace_data.get("display") + repre_view = colorspace_data.get("view") + oiio_default_type = None + oiio_default_display = None + oiio_default_view = None + oiio_default_colorspace = None + # first look into representation colorspaceData, perhaps it has + # display and view + if all([repre_display, repre_view]): + self.log.info( + "Using Display & View from " + "representation: '{} ({})'".format( + repre_view, + repre_display + ) + ) + # if representation doesn't have display and view then use + # oiiotool_defaults + elif self.oiiotool_defaults: + oiio_default_type = self.oiiotool_defaults["type"] + if "colorspace" in oiio_default_type: + oiio_default_colorspace = self.oiiotool_defaults["colorspace"] + else: + oiio_default_display = self.oiiotool_defaults["display"] + oiio_default_view = self.oiiotool_defaults["view"] + try: - run_subprocess(oiio_cmd, logger=self.log) - return True + convert_colorspace( + src_path, + dst_path, + colorspace_data["config"]["path"], + colorspace_data["colorspace"], + display=repre_display or oiio_default_display, + view=repre_view or oiio_default_view, + target_colorspace=oiio_default_colorspace, + logger=self.log, + ) except Exception: self.log.warning( "Failed to create thumbnail using oiiotool", @@ -191,8 +250,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) return False + return True + def create_thumbnail_ffmpeg(self, src_path, dst_path): - self.log.info("outputting {}".format(dst_path)) + self.log.debug("Extracting thumbnail with FFMPEG: {}".format(dst_path)) ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg") ffmpeg_args = self.ffmpeg_args or {} @@ -225,7 +286,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return True except Exception: self.log.warning( - "Failed to create thubmnail using ffmpeg", + "Failed to create thumbnail using ffmpeg", exc_info=True ) return False diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py index 54622bb84e..401a5d615d 100644 --- a/openpype/plugins/publish/extract_thumbnail_from_source.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -49,7 +49,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # Check if already has thumbnail created if self._instance_has_thumbnail(instance): - self.log.info("Thumbnail representation already present.") + self.log.debug("Thumbnail representation already present.") return dst_filepath = self._create_thumbnail( @@ -98,7 +98,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): thumbnail_created = False oiio_supported = is_oiio_supported() - self.log.info("Thumbnail source: {}".format(thumbnail_source)) + self.log.debug("Thumbnail source: {}".format(thumbnail_source)) src_basename = os.path.basename(thumbnail_source) dst_filename = os.path.splitext(src_basename)[0] + "_thumb.jpg" full_output_path = os.path.join(dst_staging, dst_filename) @@ -115,10 +115,10 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # oiiotool isn't available if not thumbnail_created: if oiio_supported: - self.log.info(( + self.log.info( "Converting with FFMPEG because input" " can't be read by OIIO." - )) + ) thumbnail_created = self.create_thumbnail_ffmpeg( thumbnail_source, full_output_path @@ -128,7 +128,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): if thumbnail_created: return full_output_path - self.log.warning("Thumbanil has not been created.") + self.log.warning("Thumbnail has not been created.") def _instance_has_thumbnail(self, instance): if "representations" not in instance.data: @@ -143,19 +143,20 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return False def create_thumbnail_oiio(self, src_path, dst_path): - self.log.info("outputting {}".format(dst_path)) + self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) oiio_cmd = get_oiio_tool_args( "oiiotool", "-a", src_path, + "--ch", "R,G,B", "-o", dst_path ) - self.log.info("Running: {}".format(" ".join(oiio_cmd))) + self.log.debug("Running: {}".format(" ".join(oiio_cmd))) try: run_subprocess(oiio_cmd, logger=self.log) return True except Exception: self.log.warning( - "Failed to create thubmnail using oiiotool", + "Failed to create thumbnail using oiiotool", exc_info=True ) return False @@ -172,13 +173,13 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): dst_path ) - self.log.info("Running: {}".format(" ".join(ffmpeg_cmd))) + self.log.debug("Running: {}".format(" ".join(ffmpeg_cmd))) try: run_subprocess(ffmpeg_cmd, logger=self.log) return True except Exception: self.log.warning( - "Failed to create thubmnail using ffmpeg", + "Failed to create thumbnail using ffmpeg", exc_info=True ) return False diff --git a/openpype/plugins/publish/extract_trim_video_audio.py b/openpype/plugins/publish/extract_trim_video_audio.py index 2907ae1839..5e00cfc96f 100644 --- a/openpype/plugins/publish/extract_trim_video_audio.py +++ b/openpype/plugins/publish/extract_trim_video_audio.py @@ -36,7 +36,7 @@ class ExtractTrimVideoAudio(publish.Extractor): # get staging dir staging_dir = self.staging_dir(instance) - self.log.info("Staging dir set to: `{}`".format(staging_dir)) + self.log.debug("Staging dir set to: `{}`".format(staging_dir)) # Generate mov file. fps = instance.data["fps"] @@ -59,7 +59,7 @@ class ExtractTrimVideoAudio(publish.Extractor): extensions = [output_file_type] for ext in extensions: - self.log.info("Processing ext: `{}`".format(ext)) + self.log.debug("Processing ext: `{}`".format(ext)) if not ext.startswith("."): ext = "." + ext @@ -98,7 +98,7 @@ class ExtractTrimVideoAudio(publish.Extractor): ffmpeg_args.append(clip_trimed_path) joined_args = " ".join(ffmpeg_args) - self.log.info(f"Processing: {joined_args}") + self.log.debug(f"Processing: {joined_args}") run_subprocess( ffmpeg_args, logger=self.log ) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index be07cffe72..5bb51a3049 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -107,6 +107,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "rig", "plate", "look", + "ociolook", "audio", "yetiRig", "yeticache", @@ -139,7 +140,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "simpleUnrealTexture", "online", "uasset", - "blendScene" + "blendScene", + "yeticacheUE", + "tycache" ] default_template_name = "publish" @@ -155,13 +158,13 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Instance should be integrated on a farm if instance.data.get("farm"): - self.log.info( + self.log.debug( "Instance is marked to be processed on farm. Skipping") return # Instance is marked to not get integrated if not instance.data.get("integrate", True): - self.log.info("Instance is marked to skip integrating. Skipping") + self.log.debug("Instance is marked to skip integrating. Skipping") return filtered_repres = self.filter_representations(instance) @@ -306,7 +309,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # increase if the file transaction takes a long time. op_session.commit() - self.log.info("Subset {subset[name]} and Version {version[name]} " + self.log.info("Subset '{subset[name]}' version {version[name]} " "written to database..".format(subset=subset, version=version)) @@ -392,8 +395,13 @@ class IntegrateAsset(pyblish.api.InstancePlugin): p["representation"]["_id"]: p for p in prepared_representations } - self.log.info("Registered {} representations" - "".format(len(prepared_representations))) + self.log.info( + "Registered {} representations: {}".format( + len(prepared_representations), + ", ".join(p["representation"]["name"] + for p in prepared_representations) + ) + ) def prepare_subset(self, instance, op_session, project_name): asset_doc = instance.data["assetEntity"] diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 6c21664b78..9f0f7fe7f3 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -275,10 +275,10 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): backup_hero_publish_dir = _backup_hero_publish_dir break except Exception: - self.log.info(( + self.log.info( "Could not remove previous backup folder." - " Trying to add index to folder name" - )) + " Trying to add index to folder name." + ) _backup_hero_publish_dir = ( backup_hero_publish_dir + str(idx) diff --git a/openpype/plugins/publish/integrate_inputlinks_ayon.py b/openpype/plugins/publish/integrate_inputlinks_ayon.py index 180524cd08..28684aa889 100644 --- a/openpype/plugins/publish/integrate_inputlinks_ayon.py +++ b/openpype/plugins/publish/integrate_inputlinks_ayon.py @@ -1,7 +1,11 @@ import collections import pyblish.api -from ayon_api import create_link, make_sure_link_type_exists +from ayon_api import ( + create_link, + make_sure_link_type_exists, + get_versions_links, +) from openpype import AYON_SERVER_ENABLED @@ -124,6 +128,33 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): version_entity["_id"], ) + def _get_existing_links(self, project_name, link_type, entity_ids): + """Find all existing links for given version ids. + + Args: + project_name (str): Name of project. + link_type (str): Type of link. + entity_ids (set[str]): Set of version ids. + + Returns: + dict[str, set[str]]: Existing links by version id. + """ + + output = collections.defaultdict(set) + if not entity_ids: + return output + + existing_in_links = get_versions_links( + project_name, entity_ids, [link_type], "output" + ) + + for entity_id, links in existing_in_links.items(): + if not links: + continue + for link in links: + output[entity_id].add(link["entityId"]) + return output + def create_links_on_server(self, context, new_links): """Create new links on server. @@ -144,16 +175,32 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): # Create link themselves for link_type, items in new_links.items(): + mapping = collections.defaultdict(set) + # Make sure there are no duplicates of src > dst ids for item in items: - input_id, output_id = item - create_link( - project_name, - link_type, - input_id, - "version", - output_id, - "version" - ) + _input_id, _output_id = item + mapping[_input_id].add(_output_id) + + existing_links_by_in_id = self._get_existing_links( + project_name, link_type, set(mapping.keys()) + ) + + for input_id, output_ids in mapping.items(): + existing_links = existing_links_by_in_id[input_id] + for output_id in output_ids: + # Skip creation of link if already exists + # NOTE: AYON server does not support + # to have same links + if output_id in existing_links: + continue + create_link( + project_name, + link_type, + input_id, + "version", + output_id, + "version" + ) if not AYON_SERVER_ENABLED: diff --git a/openpype/plugins/publish/integrate_thumbnail.py b/openpype/plugins/publish/integrate_thumbnail.py index 9929d8f754..b154940469 100644 --- a/openpype/plugins/publish/integrate_thumbnail.py +++ b/openpype/plugins/publish/integrate_thumbnail.py @@ -41,7 +41,9 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): def process(self, context): if AYON_SERVER_ENABLED: - self.log.info("AYON is enabled. Skipping v3 thumbnail integration") + self.log.debug( + "AYON is enabled. Skipping v3 thumbnail integration" + ) return # Filter instances which can be used for integration @@ -74,14 +76,14 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): thumbnail_template = anatomy.templates["publish"]["thumbnail"] if not thumbnail_template: - self.log.info("Thumbnail template is not filled. Skipping.") + self.log.debug("Thumbnail template is not filled. Skipping.") return if ( not thumbnail_root and thumbnail_root_format_key in thumbnail_template ): - self.log.warning(("{} is not set. Skipping.").format(env_key)) + self.log.warning("{} is not set. Skipping.".format(env_key)) return # Collect verion ids from all filtered instance @@ -198,7 +200,7 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): if thumb_repre_doc is None: self.log.debug( - "There is not representation with name \"thumbnail\"" + "There is no representation with name \"thumbnail\"" ) return None diff --git a/openpype/plugins/publish/integrate_thumbnail_ayon.py b/openpype/plugins/publish/integrate_thumbnail_ayon.py index ba5664c69f..2c8fb5b692 100644 --- a/openpype/plugins/publish/integrate_thumbnail_ayon.py +++ b/openpype/plugins/publish/integrate_thumbnail_ayon.py @@ -35,13 +35,13 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): def process(self, context): if not AYON_SERVER_ENABLED: - self.log.info("AYON is not enabled. Skipping") + self.log.debug("AYON is not enabled. Skipping") return # Filter instances which can be used for integration filtered_instance_items = self._prepare_instances(context) if not filtered_instance_items: - self.log.info( + self.log.debug( "All instances were filtered. Thumbnail integration skipped." ) return @@ -92,8 +92,10 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): continue # Find thumbnail path on instance - thumbnail_path = self._get_instance_thumbnail_path( - published_repres) + thumbnail_source = instance.data.get("thumbnailSource") + thumbnail_path = (thumbnail_source or + self._get_instance_thumbnail_path( + published_repres)) if thumbnail_path: self.log.debug(( "Found thumbnail path for instance \"{}\"." @@ -110,7 +112,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): # Skip instance if thumbnail path is not available for it if not thumbnail_path: - self.log.info(( + self.log.debug(( "Skipping thumbnail integration for instance \"{}\"." " Instance and context" " thumbnail paths are not available." @@ -137,7 +139,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): if thumb_repre_doc is None: self.log.debug( - "There is not representation with name \"thumbnail\"" + "There is no representation with name \"thumbnail\"" ) return None @@ -157,8 +159,8 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): ): from openpype.client.server.operations import create_thumbnail - op_session = OperationsSession() - + # Make sure each entity id has defined only one thumbnail id + thumbnail_info_by_entity_id = {} for instance_item in filtered_instance_items: instance, thumbnail_path, version_id = instance_item instance_label = self._get_instance_label(instance) @@ -172,12 +174,10 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): thumbnail_id = create_thumbnail(project_name, thumbnail_path) # Set thumbnail id for version - op_session.update_entity( - project_name, - version_doc["type"], - version_doc["_id"], - {"data.thumbnail_id": thumbnail_id} - ) + thumbnail_info_by_entity_id[version_id] = { + "thumbnail_id": thumbnail_id, + "entity_type": version_doc["type"], + } if version_doc["type"] == "hero_version": version_name = "Hero" else: @@ -187,16 +187,23 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): )) asset_entity = instance.data["assetEntity"] - op_session.update_entity( - project_name, - asset_entity["type"], - asset_entity["_id"], - {"data.thumbnail_id": thumbnail_id} - ) + thumbnail_info_by_entity_id[asset_entity["_id"]] = { + "thumbnail_id": thumbnail_id, + "entity_type": "asset", + } self.log.debug("Setting thumbnail for asset \"{}\" <{}>".format( asset_entity["name"], version_id )) + op_session = OperationsSession() + for entity_id, thumbnail_info in thumbnail_info_by_entity_id.items(): + thumbnail_id = thumbnail_info["thumbnail_id"] + op_session.update_entity( + project_name, + thumbnail_info["entity_type"], + entity_id, + {"data.thumbnail_id": thumbnail_id} + ) op_session.commit() def _get_instance_label(self, instance): diff --git a/openpype/plugins/publish/preintegrate_thumbnail_representation.py b/openpype/plugins/publish/preintegrate_thumbnail_representation.py index 1c95b82c97..77bf2edba5 100644 --- a/openpype/plugins/publish/preintegrate_thumbnail_representation.py +++ b/openpype/plugins/publish/preintegrate_thumbnail_representation.py @@ -29,13 +29,12 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): if not repres: return - thumbnail_repre = None + thumbnail_repres = [] for repre in repres: - if repre["name"] == "thumbnail": - thumbnail_repre = repre - break + if "thumbnail" in repre.get("tags", []): + thumbnail_repres.append(repre) - if not thumbnail_repre: + if not thumbnail_repres: return family = instance.data["family"] @@ -60,14 +59,15 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): if not found_profile: return - thumbnail_repre.setdefault("tags", []) + for thumbnail_repre in thumbnail_repres: + thumbnail_repre.setdefault("tags", []) - if not found_profile["integrate_thumbnail"]: - if "delete" not in thumbnail_repre["tags"]: - thumbnail_repre["tags"].append("delete") - else: - if "delete" in thumbnail_repre["tags"]: - thumbnail_repre["tags"].remove("delete") + if not found_profile["integrate_thumbnail"]: + if "delete" not in thumbnail_repre["tags"]: + thumbnail_repre["tags"].append("delete") + else: + if "delete" in thumbnail_repre["tags"]: + thumbnail_repre["tags"].remove("delete") - self.log.debug( - "Thumbnail repre tags {}".format(thumbnail_repre["tags"])) + self.log.debug( + "Thumbnail repre tags {}".format(thumbnail_repre["tags"])) diff --git a/openpype/plugins/publish/validate_asset_docs.py b/openpype/plugins/publish/validate_asset_docs.py index 9a1ca5b8de..8dfd783c39 100644 --- a/openpype/plugins/publish/validate_asset_docs.py +++ b/openpype/plugins/publish/validate_asset_docs.py @@ -22,11 +22,11 @@ class ValidateAssetDocs(pyblish.api.InstancePlugin): return if instance.data.get("assetEntity"): - self.log.info("Instance has set asset document in its data.") + self.log.debug("Instance has set asset document in its data.") elif instance.data.get("newAssetPublishing"): # skip if it is editorial - self.log.info("Editorial instance is no need to check...") + self.log.debug("Editorial instance has no need to check...") else: raise PublishValidationError(( diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py index 4f8a1abf2e..b5afc49f2e 100644 --- a/openpype/plugins/publish/validate_editorial_asset_name.py +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -2,7 +2,7 @@ from pprint import pformat import pyblish.api -from openpype.client import get_assets +from openpype.client import get_assets, get_asset_name_identifier class ValidateEditorialAssetName(pyblish.api.ContextPlugin): @@ -34,8 +34,11 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): self.log.debug("__ db_assets: {}".format(db_assets)) asset_db_docs = { - str(e["name"]): [str(p) for p in e["data"]["parents"]] - for e in db_assets} + get_asset_name_identifier(asset_doc): list( + asset_doc["data"]["parents"] + ) + for asset_doc in db_assets + } self.log.debug("__ project_entities: {}".format( pformat(asset_db_docs))) @@ -56,7 +59,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): } continue - self.log.info("correct asset: {}".format(asset)) + self.log.debug("correct asset: {}".format(asset)) if assets_missing_name: wrong_names = {} diff --git a/openpype/plugins/publish/validate_file_saved.py b/openpype/plugins/publish/validate_file_saved.py index 448eaccf57..94aadc9358 100644 --- a/openpype/plugins/publish/validate_file_saved.py +++ b/openpype/plugins/publish/validate_file_saved.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError + class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): """File must be saved before publishing""" @@ -12,4 +14,4 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): current_file = context.data["currentFile"] if not current_file: - raise RuntimeError("File not saved") + raise PublishValidationError("File not saved") diff --git a/openpype/plugins/publish/validate_filesequences.py b/openpype/plugins/publish/validate_filesequences.py index 8a877d79bb..0ac281022d 100644 --- a/openpype/plugins/publish/validate_filesequences.py +++ b/openpype/plugins/publish/validate_filesequences.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError + class ValidateFileSequences(pyblish.api.ContextPlugin): """Validates whether any file sequences were collected.""" @@ -10,4 +12,5 @@ class ValidateFileSequences(pyblish.api.ContextPlugin): label = "Validate File Sequences" def process(self, context): - assert context, "Nothing collected." + if not context: + raise PublishValidationError("Nothing collected.") diff --git a/openpype/plugins/publish/validate_intent.py b/openpype/plugins/publish/validate_intent.py index 23d57bb2b7..832c7cc0a1 100644 --- a/openpype/plugins/publish/validate_intent.py +++ b/openpype/plugins/publish/validate_intent.py @@ -1,7 +1,7 @@ -import os import pyblish.api from openpype.lib import filter_profiles +from openpype.pipeline.publish import PublishValidationError class ValidateIntent(pyblish.api.ContextPlugin): @@ -51,12 +51,10 @@ class ValidateIntent(pyblish.api.ContextPlugin): )) return - msg = ( - "Please make sure that you select the intent of this publish." - ) - intent = context.data.get("intent") or {} self.log.debug(str(intent)) intent_value = intent.get("value") if not intent_value: - raise AssertionError(msg) + raise PublishValidationError( + "Please make sure that you select the intent of this publish." + ) diff --git a/openpype/plugins/publish/validate_publish_dir.py b/openpype/plugins/publish/validate_publish_dir.py index 2f41127548..0eb93da583 100644 --- a/openpype/plugins/publish/validate_publish_dir.py +++ b/openpype/plugins/publish/validate_publish_dir.py @@ -7,12 +7,12 @@ from openpype.pipeline.publish import ( class ValidatePublishDir(pyblish.api.InstancePlugin): - """Validates if 'publishDir' is a project directory + """Validates if files are being published into a project directory - 'publishDir' is collected based on publish templates. In specific cases - ('source' template) source folder of items is used as a 'publishDir', this - validates if it is inside any project dir for the project. - (eg. files are not published from local folder, unaccessible for studio' + In specific cases ('source' template - in place publishing) source folder + of published items is used as a regular `publish` dir. + This validates if it is inside any project dir for the project. + (eg. files are not published from local folder, inaccessible for studio') """ @@ -44,23 +44,27 @@ class ValidatePublishDir(pyblish.api.InstancePlugin): anatomy = instance.context.data["anatomy"] + # original_dirname must be convertable to rootless path + # in other case it is path inside of root folder for the project success, _ = anatomy.find_root_template_from_path(original_dirname) - - formatting_data = { - "original_dirname": original_dirname, - } - msg = "Path '{}' not in project folder.".format(original_dirname) + \ - " Please publish from inside of project folder." if not success: - raise PublishXmlValidationError(self, msg, key="not_in_dir", - formatting_data=formatting_data) + raise PublishXmlValidationError( + plugin=self, + message=( + "Path '{}' not in project folder. Please publish from " + "inside of project folder.".format(original_dirname) + ), + key="not_in_dir", + formatting_data={"original_dirname": original_dirname} + ) def _get_template_name_from_instance(self, instance): + """Find template which will be used during integration.""" project_name = instance.context.data["projectName"] host_name = instance.context.data["hostName"] anatomy_data = instance.data["anatomyData"] family = anatomy_data["family"] - family = self.family_mapping.get("family") or family + family = self.family_mapping.get(family) or family task_info = anatomy_data.get("task") or {} return get_publish_template_name( diff --git a/openpype/plugins/publish/validate_version.py b/openpype/plugins/publish/validate_version.py index 2b919a3119..84d52fab73 100644 --- a/openpype/plugins/publish/validate_version.py +++ b/openpype/plugins/publish/validate_version.py @@ -25,16 +25,16 @@ class ValidateVersion(pyblish.api.InstancePlugin): # TODO: Remove full non-html version upon drop of old publisher msg = ( "Version '{0}' from instance '{1}' that you are " - " trying to publish is lower or equal to an existing version " - " in the database. Version in database: '{2}'." + "trying to publish is lower or equal to an existing version " + "in the database. Version in database: '{2}'." "Please version up your workfile to a higher version number " "than: '{2}'." ).format(version, instance.data["name"], latest_version) msg_html = ( "Version {0} from instance {1} that you are " - " trying to publish is lower or equal to an existing version " - " in the database. Version in database: {2}.

" + "trying to publish is lower or equal to an existing version " + "in the database. Version in database: {2}.

" "Please version up your workfile to a higher version number " "than: {2}." ).format(version, instance.data["name"], latest_version) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 7f1c3b01e2..b6535e0835 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -186,6 +186,7 @@ class PypeCommands: app, env_group=env_group, launch_type=LaunchTypes.farm_render, + env={} ) else: env = os.environ.copy() @@ -213,7 +214,8 @@ class PypeCommands: pass def run_tests(self, folder, mark, pyargs, - test_data_folder, persist, app_variant, timeout, setup_only): + test_data_folder, persist, app_variant, timeout, setup_only, + mongo_url, app_group): """ Runs tests from 'folder' @@ -226,6 +228,10 @@ class PypeCommands: end app_variant (str): variant (eg 2020 for AE), empty if use latest installed version + timeout (int): explicit timeout for single test + setup_only (bool): if only preparation steps should be + triggered, no tests (useful for debugging/development) + mongo_url (str): url to Openpype Mongo database """ print("run_tests") if folder: @@ -255,6 +261,9 @@ class PypeCommands: if persist: args.extend(["--persist", persist]) + if app_group: + args.extend(["--app_group", app_group]) + if app_variant: args.extend(["--app_variant", app_variant]) @@ -264,6 +273,9 @@ class PypeCommands: if setup_only: args.extend(["--setup_only", setup_only]) + if mongo_url: + args.extend(["--mongo_url", mongo_url]) + print("run_tests args: {}".format(args)) import pytest pytest.main(args) diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index b8671f517a..b33d1bf023 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -55,6 +55,9 @@ def get_openpype_staging_icon_filepath(): def get_openpype_icon_filepath(staging=None): + if AYON_SERVER_ENABLED and os.getenv("AYON_USE_DEV") == "1": + return get_resource("icons", "AYON_icon_dev.png") + if staging is None: staging = is_running_staging() @@ -68,7 +71,9 @@ def get_openpype_splash_filepath(staging=None): staging = is_running_staging() if AYON_SERVER_ENABLED: - if staging: + if os.getenv("AYON_USE_DEV") == "1": + splash_file_name = "AYON_splash_dev.png" + elif staging: splash_file_name = "AYON_splash_staging.png" else: splash_file_name = "AYON_splash.png" diff --git a/openpype/resources/app_icons/wrap.png b/openpype/resources/app_icons/wrap.png new file mode 100644 index 0000000000..34ae1d68ed Binary files /dev/null and b/openpype/resources/app_icons/wrap.png differ diff --git a/openpype/resources/icons/AYON_icon_dev.png b/openpype/resources/icons/AYON_icon_dev.png new file mode 100644 index 0000000000..3e867ef372 Binary files /dev/null and b/openpype/resources/icons/AYON_icon_dev.png differ diff --git a/openpype/resources/icons/AYON_splash_dev.png b/openpype/resources/icons/AYON_splash_dev.png new file mode 100644 index 0000000000..3b366e755d Binary files /dev/null and b/openpype/resources/icons/AYON_splash_dev.png differ diff --git a/openpype/scripts/export_maya_ass_job.py b/openpype/scripts/export_maya_ass_job.py deleted file mode 100644 index 16e841ce96..0000000000 --- a/openpype/scripts/export_maya_ass_job.py +++ /dev/null @@ -1,105 +0,0 @@ -"""This module is used for command line exporting of ASS files. - -WARNING: -This need to be rewriten to be able use it in Pype 3! -""" - -import os -import argparse -import logging -import subprocess -import platform - -try: - from shutil import which -except ImportError: - # we are in python < 3.3 - def which(command): - path = os.getenv('PATH') - for p in path.split(os.path.pathsep): - p = os.path.join(p, command) - if os.path.exists(p) and os.access(p, os.X_OK): - return p - -handler = logging.basicConfig() -log = logging.getLogger("Publish Image Sequences") -log.setLevel(logging.DEBUG) - -error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" - - -def __main__(): - parser = argparse.ArgumentParser() - parser.add_argument("--paths", - nargs="*", - default=[], - help="The filepaths to publish. This can be a " - "directory or a path to a .json publish " - "configuration.") - parser.add_argument("--gui", - default=False, - action="store_true", - help="Whether to run Pyblish in GUI mode.") - - parser.add_argument("--pype", help="Pype root") - - kwargs, args = parser.parse_known_args() - - print("Running pype ...") - auto_pype_root = os.path.dirname(os.path.abspath(__file__)) - auto_pype_root = os.path.abspath(auto_pype_root + "../../../../..") - - auto_pype_root = os.environ.get('OPENPYPE_SETUP_PATH') or auto_pype_root - if os.environ.get('OPENPYPE_SETUP_PATH'): - print("Got Pype location from environment: {}".format( - os.environ.get('OPENPYPE_SETUP_PATH'))) - - pype_command = "openpype.ps1" - if platform.system().lower() == "linux": - pype_command = "pype" - elif platform.system().lower() == "windows": - pype_command = "openpype.bat" - - if kwargs.pype: - pype_root = kwargs.pype - else: - # test if pype.bat / pype is in the PATH - # if it is, which() will return its path and we use that. - # if not, we use auto_pype_root path. Caveat of that one is - # that it can be UNC path and that will not work on windows. - - pype_path = which(pype_command) - - if pype_path: - pype_root = os.path.dirname(pype_path) - else: - pype_root = auto_pype_root - - print("Set pype root to: {}".format(pype_root)) - print("Paths: {}".format(kwargs.paths or [os.getcwd()])) - - # paths = kwargs.paths or [os.environ.get("OPENPYPE_METADATA_FILE")] or [os.getcwd()] # noqa - - mayabatch = os.environ.get("AVALON_APP_NAME").replace("maya", "mayabatch") - args = [ - os.path.join(pype_root, pype_command), - "launch", - "--app", - mayabatch, - "-script", - os.path.join(pype_root, "repos", "pype", - "pype", "scripts", "export_maya_ass_sequence.mel") - ] - - print("Pype command: {}".format(" ".join(args))) - # Forcing forwaring the environment because environment inheritance does - # not always work. - # Cast all values in environment to str to be safe - env = {k: str(v) for k, v in os.environ.items()} - exit_code = subprocess.call(args, env=env) - if exit_code != 0: - raise RuntimeError("Publishing failed.") - - -if __name__ == '__main__': - __main__() diff --git a/openpype/scripts/export_maya_ass_sequence.mel b/openpype/scripts/export_maya_ass_sequence.mel deleted file mode 100644 index b3b9a8543e..0000000000 --- a/openpype/scripts/export_maya_ass_sequence.mel +++ /dev/null @@ -1,67 +0,0 @@ -/* - Script to export specified layer as ass files. - -Attributes: - - scene_file (str): Name of the scene to load. - start (int): Start frame. - end (int): End frame. - step (int): Step size. - output_path (str): File output path. - render_layer (str): Name of render layer. - -*/ - -$scene_file=`getenv "OPENPYPE_ASS_EXPORT_SCENE_FILE"`; -$step=`getenv "OPENPYPE_ASS_EXPORT_STEP"`; -$start=`getenv "OPENPYPE_ASS_EXPORT_START"`; -$end=`getenv "OPENPYPE_ASS_EXPORT_END"`; -$file_path=`getenv "OPENPYPE_ASS_EXPORT_OUTPUT"`; -$render_layer = `getenv "OPENPYPE_ASS_EXPORT_RENDER_LAYER"`; - -print("*** ASS Export Plugin\n"); - -if ($scene_file == "") { - print("!!! cannot determine scene file\n"); - quit -a -ex -1; -} - -if ($step == "") { - print("!!! cannot determine step size\n"); - quit -a -ex -1; -} - -if ($start == "") { - print("!!! cannot determine start frame\n"); - quit -a -ex -1; -} - -if ($end == "") { - print("!!! cannot determine end frame\n"); - quit -a -ex -1; -} - -if ($file_path == "") { - print("!!! cannot determine output file\n"); - quit -a -ex -1; -} - -if ($render_layer == "") { - print("!!! cannot determine render layer\n"); - quit -a -ex -1; -} - - -print(">>> Opening Scene [ " + $scene_file + " ]\n"); - -// open scene -file -o -f $scene_file; - -// switch to render layer -print(">>> Switching layer [ "+ $render_layer + " ]\n"); -editRenderLayerGlobals -currentRenderLayer $render_layer; - -// export -print(">>> Exporting to [ " + $file_path + " ]\n"); -arnoldExportAss -mask 255 -sl 1 -ll 1 -bb 1 -sf $start -se $end -b -fs $step; -print("--- Done\n"); diff --git a/openpype/scripts/fusion_switch_shot.py b/openpype/scripts/fusion_switch_shot.py deleted file mode 100644 index 1cc728226f..0000000000 --- a/openpype/scripts/fusion_switch_shot.py +++ /dev/null @@ -1,241 +0,0 @@ -import os -import re -import sys -import logging - -from openpype.client import get_asset_by_name, get_versions - -# Pipeline imports -from openpype.hosts.fusion import api -import openpype.hosts.fusion.api.lib as fusion_lib - -# Config imports -from openpype.lib import version_up -from openpype.pipeline import ( - install_host, - registered_host, - legacy_io, - get_current_project_name, -) - -from openpype.pipeline.context_tools import get_workdir_from_session -from openpype.pipeline.version_start import get_versioning_start - -log = logging.getLogger("Update Slap Comp") - - -def _format_version_folder(folder): - """Format a version folder based on the filepath - - Args: - folder: file path to a folder - - Returns: - str: new version folder name - """ - - new_version = get_versioning_start( - get_current_project_name(), - "fusion", - family="workfile" - ) - if os.path.isdir(folder): - re_version = re.compile(r"v\d+$") - versions = [i for i in os.listdir(folder) if os.path.isdir(i) - and re_version.match(i)] - if versions: - # ensure the "v" is not included - new_version = int(max(versions)[1:]) + 1 - - version_folder = "v{:03d}".format(new_version) - - return version_folder - - -def _get_fusion_instance(): - fusion = getattr(sys.modules["__main__"], "fusion", None) - if fusion is None: - try: - # Support for FuScript.exe, BlackmagicFusion module for py2 only - import BlackmagicFusion as bmf - fusion = bmf.scriptapp("Fusion") - except ImportError: - raise RuntimeError("Could not find a Fusion instance") - return fusion - - -def _format_filepath(session): - - project = session["AVALON_PROJECT"] - asset = session["AVALON_ASSET"] - - # Save updated slap comp - work_path = get_workdir_from_session(session) - walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") - slapcomp_dir = os.path.abspath(walk_to_dir) - - # Ensure destination exists - if not os.path.isdir(slapcomp_dir): - log.warning("Folder did not exist, creating folder structure") - os.makedirs(slapcomp_dir) - - # Compute output path - new_filename = "{}_{}_slapcomp_v001.comp".format(project, asset) - new_filepath = os.path.join(slapcomp_dir, new_filename) - - # Create new unqiue filepath - if os.path.exists(new_filepath): - new_filepath = version_up(new_filepath) - - return new_filepath - - -def _update_savers(comp, session): - """Update all savers of the current comp to ensure the output is correct - - Args: - comp (object): current comp instance - session (dict): the current Avalon session - - Returns: - None - """ - - new_work = get_workdir_from_session(session) - renders = os.path.join(new_work, "renders") - version_folder = _format_version_folder(renders) - renders_version = os.path.join(renders, version_folder) - - comp.Print("New renders to: %s\n" % renders) - - with api.comp_lock_and_undo_chunk(comp): - savers = comp.GetToolList(False, "Saver").values() - for saver in savers: - filepath = saver.GetAttrs("TOOLST_Clip_Name")[1.0] - filename = os.path.basename(filepath) - new_path = os.path.join(renders_version, filename) - saver["Clip"] = new_path - - -def update_frame_range(comp, representations): - """Update the frame range of the comp and render length - - The start and end frame are based on the lowest start frame and the highest - end frame - - Args: - comp (object): current focused comp - representations (list) collection of dicts - - Returns: - None - - """ - - version_ids = [r["parent"] for r in representations] - project_name = get_current_project_name() - versions = list(get_versions(project_name, version_ids=version_ids)) - - start = min(v["data"]["frameStart"] for v in versions) - end = max(v["data"]["frameEnd"] for v in versions) - - fusion_lib.update_frame_range(start, end, comp=comp) - - -def switch(asset_name, filepath=None, new=True): - """Switch the current containers of the file to the other asset (shot) - - Args: - filepath (str): file path of the comp file - asset_name (str): name of the asset (shot) - new (bool): Save updated comp under a different name - - Returns: - comp path (str): new filepath of the updated comp - - """ - - # If filepath provided, ensure it is valid absolute path - if filepath is not None: - if not os.path.isabs(filepath): - filepath = os.path.abspath(filepath) - - assert os.path.exists(filepath), "%s must exist " % filepath - - # Assert asset name exists - # It is better to do this here then to wait till switch_shot does it - project_name = get_current_project_name() - asset = get_asset_by_name(project_name, asset_name) - assert asset, "Could not find '%s' in the database" % asset_name - - # Go to comp - if not filepath: - current_comp = api.get_current_comp() - assert current_comp is not None, "Could not find current comp" - else: - fusion = _get_fusion_instance() - current_comp = fusion.LoadComp(filepath, quiet=True) - assert current_comp is not None, "Fusion could not load '%s'" % filepath - - host = registered_host() - containers = list(host.ls()) - assert containers, "Nothing to update" - - representations = [] - for container in containers: - try: - representation = fusion_lib.switch_item(container, - asset_name=asset_name) - representations.append(representation) - except Exception as e: - current_comp.Print("Error in switching! %s\n" % e.message) - - message = "Switched %i Loaders of the %i\n" % (len(representations), - len(containers)) - current_comp.Print(message) - - # Build the session to switch to - switch_to_session = legacy_io.Session.copy() - switch_to_session["AVALON_ASSET"] = asset['name'] - - if new: - comp_path = _format_filepath(switch_to_session) - - # Update savers output based on new session - _update_savers(current_comp, switch_to_session) - else: - comp_path = version_up(filepath) - - current_comp.Print(comp_path) - - current_comp.Print("\nUpdating frame range") - update_frame_range(current_comp, representations) - - current_comp.Save(comp_path) - - return comp_path - - -if __name__ == '__main__': - - import argparse - - parser = argparse.ArgumentParser(description="Switch to a shot within an" - "existing comp file") - - parser.add_argument("--file_path", - type=str, - default=True, - help="File path of the comp to use") - - parser.add_argument("--asset_name", - type=str, - default=True, - help="Name of the asset (shot) to switch") - - args, unknown = parser.parse_args() - - install_host(api) - switch(args.asset_name, args.file_path) - - sys.exit(0) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 16558642c6..fa231cd047 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -27,7 +27,7 @@ import PyOpenColorIO as ocio @click.group() def main(): - pass + pass # noqa: WPS100 @main.group() @@ -37,7 +37,17 @@ def config(): Example of use: > pyton.exe ./ocio_wrapper.py config *args """ - pass + pass # noqa: WPS100 + + +@main.group() +def colorspace(): + """Colorspace related commands group + + Example of use: + > pyton.exe ./ocio_wrapper.py config *args + """ + pass # noqa: WPS100 @config.command( @@ -70,8 +80,8 @@ def get_colorspace(in_path, out_path): out_data = _get_colorspace_data(in_path) - with open(json_path, "w") as f: - json.dump(out_data, f) + with open(json_path, "w") as f_: + json.dump(out_data, f_) print(f"Colorspace data are saved to '{json_path}'") @@ -96,11 +106,47 @@ def _get_colorspace_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) - return { - c.getName(): c.getFamily() - for c in config.getColorSpaces() + colorspace_data = { + "roles": {}, + "colorspaces": { + color.getName(): { + "family": color.getFamily(), + "categories": list(color.getCategories()), + "aliases": list(color.getAliases()), + "equalitygroup": color.getEqualityGroup(), + } + for color in config.getColorSpaces() + }, + "displays_views": { + f"{view} ({display})": { + "display": display, + "view": view + + } + for display in config.getDisplays() + for view in config.getViews(display) + }, + "looks": {} } + # add looks + looks = config.getLooks() + if looks: + colorspace_data["looks"] = { + look.getName(): {"process_space": look.getProcessSpace()} + for look in looks + } + + # add roles + roles = config.getRoles() + if roles: + colorspace_data["roles"] = { + role: {"colorspace": colorspace} + for (role, colorspace) in roles + } + + return colorspace_data + @config.command( name="get_views", @@ -132,8 +178,8 @@ def get_views(in_path, out_path): out_data = _get_views_data(in_path) - with open(json_path, "w") as f: - json.dump(out_data, f) + with open(json_path, "w") as f_: + json.dump(out_data, f_) print(f"Viewer data are saved to '{json_path}'") @@ -157,7 +203,7 @@ def _get_views_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) - data = {} + data_ = {} for display in config.getDisplays(): for view in config.getViews(display): colorspace = config.getDisplayViewColorSpaceName(display, view) @@ -165,14 +211,223 @@ def _get_views_data(config_path): if colorspace == "": colorspace = display - data[f"{display}/{view}"] = { + data_[f"{display}/{view}"] = { "display": display, "view": view, "colorspace": colorspace } - return data + return data_ +@config.command( + name="get_version", + help=( + "return major and minor version from config file " + "--config_path input arg is required" + "--out_path input arg is required" + ) +) +@click.option("--config_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +def get_version(config_path, out_path): + """Get version of config. + + Python 2 wrapped console command + + Args: + config_path (str): ocio config file path string + out_path (str): temp json file path string + + Example of use: + > pyton.exe ./ocio_wrapper.py config get_version \ + --config_path= --out_path= + """ + json_path = Path(out_path) + + out_data = _get_version_data(config_path) + + with open(json_path, "w") as f_: + json.dump(out_data, f_) + + print(f"Config version data are saved to '{json_path}'") + + +def _get_version_data(config_path): + """Return major and minor version info. + + Args: + config_path (str): path string leading to config.ocio + + Raises: + IOError: Input config does not exist. + + Returns: + dict: minor and major keys with values + """ + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError("Input path should be `config.ocio` file") + + config = ocio.Config().CreateFromFile(str(config_path)) + + return { + "major": config.getMajorVersion(), + "minor": config.getMinorVersion() + } + + +@colorspace.command( + name="get_config_file_rules_colorspace_from_filepath", + help=( + "return colorspace from filepath " + "--config_path - ocio config file path (input arg is required) " + "--filepath - any file path (input arg is required) " + "--out_path - temp json file path (input arg is required)" + ) +) +@click.option("--config_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--filepath", required=True, + help="path to file to get colorspace from", + type=click.Path()) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +def get_config_file_rules_colorspace_from_filepath( + config_path, filepath, out_path +): + """Get colorspace from file path wrapper. + + Python 2 wrapped console command + + Args: + config_path (str): config file path string + filepath (str): path string leading to file + out_path (str): temp json file path string + + Example of use: + > pyton.exe ./ocio_wrapper.py \ + colorspace get_config_file_rules_colorspace_from_filepath \ + --config_path= --filepath= --out_path= + """ + json_path = Path(out_path) + + colorspace = _get_config_file_rules_colorspace_from_filepath( + config_path, filepath) + + with open(json_path, "w") as f_: + json.dump(colorspace, f_) + + print(f"Colorspace name is saved to '{json_path}'") + + +def _get_config_file_rules_colorspace_from_filepath(config_path, filepath): + """Return found colorspace data found in v2 file rules. + + Args: + config_path (str): path string leading to config.ocio + filepath (str): path string leading to v2 file rules + + Raises: + IOError: Input config does not exist. + + Returns: + dict: aggregated available colorspaces + """ + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError( + f"Input path `{config_path}` should be `config.ocio` file") + + config = ocio.Config().CreateFromFile(str(config_path)) + + # TODO: use `parseColorSpaceFromString` instead if ocio v1 + colorspace = config.getColorSpaceFromFilepath(str(filepath)) + + return colorspace + + +def _get_display_view_colorspace_name(config_path, display, view): + """Returns the colorspace attribute of the (display, view) pair. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + + Raises: + IOError: Input config does not exist. + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ + + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError("Input path should be `config.ocio` file") + + config = ocio.Config.CreateFromFile(str(config_path)) + colorspace = config.getDisplayViewColorSpaceName(display, view) + + return colorspace + + +@config.command( + name="get_display_view_colorspace_name", + help=( + "return default view colorspace name " + "for the given display and view " + "--path input arg is required" + ) +) +@click.option("--in_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +@click.option("--display", required=True, + help="display name", + type=click.STRING) +@click.option("--view", required=True, + help="view name", + type=click.STRING) +def get_display_view_colorspace_name(in_path, out_path, + display, view): + """Aggregate view colorspace name to file. + + Wrapper command for processes without access to OpenColorIO + + Args: + in_path (str): config file path string + out_path (str): temp json file path string + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Example of use: + > pyton.exe ./ocio_wrapper.py config \ + get_display_view_colorspace_name --in_path= \ + --out_path= --display= --view= + """ + + out_data = _get_display_view_colorspace_name(in_path, + display, + view) + + with open(out_path, "w") as f: + json.dump(out_data, f) + + print(f"Display view colorspace saved to '{out_path}'") + if __name__ == '__main__': main() diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 50abfe4839..e626368ad1 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -20,7 +20,8 @@ import copy import time import six -import ayon_api + +from openpype.client import get_ayon_server_api_connection def _convert_color(color_value): @@ -290,6 +291,16 @@ def _convert_modules_system( modules_settings[key] = value +def is_dev_mode_enabled(): + """Dev mode is enabled in AYON. + + Returns: + bool: True if dev mode is enabled. + """ + + return os.getenv("AYON_USE_DEV") == "1" + + def convert_system_settings(ayon_settings, default_settings, addon_versions): default_settings = copy.deepcopy(default_settings) output = { @@ -561,6 +572,27 @@ def _convert_maya_project_settings(ayon_settings, output): for item in viewport_options["pluginObjects"] } + ayon_playblast_settings = ayon_publish["ExtractPlayblast"]["profiles"] + if ayon_playblast_settings: + for setting in ayon_playblast_settings: + capture_preset = setting["capture_preset"] + display_options = capture_preset["DisplayOptions"] + for key in ("background", "backgroundBottom", "backgroundTop"): + display_options[key] = _convert_color(display_options[key]) + + for src_key, dst_key in ( + ("DisplayOptions", "Display Options"), + ("ViewportOptions", "Viewport Options"), + ("CameraOptions", "Camera Options"), + ): + capture_preset[dst_key] = capture_preset.pop(src_key) + + viewport_options = capture_preset["Viewport Options"] + viewport_options["pluginObjects"] = { + item["name"]: item["value"] + for item in viewport_options["pluginObjects"] + } + # Extract Camera Alembic bake attributes try: bake_attributes = json.loads( @@ -616,6 +648,40 @@ def _convert_maya_project_settings(ayon_settings, output): output["maya"] = ayon_maya +def _convert_3dsmax_project_settings(ayon_settings, output): + if "max" not in ayon_settings: + return + + ayon_max = ayon_settings["max"] + _convert_host_imageio(ayon_max) + if "PointCloud" in ayon_max: + point_cloud_attribute = ayon_max["PointCloud"]["attribute"] + new_point_cloud_attribute = { + item["name"]: item["value"] + for item in point_cloud_attribute + } + ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute + # --- Publish (START) --- + ayon_publish = ayon_max["publish"] + if "ValidateAttributes" in ayon_publish: + try: + attributes = json.loads( + ayon_publish["ValidateAttributes"]["attributes"] + ) + except ValueError: + attributes = {} + ayon_publish["ValidateAttributes"]["attributes"] = attributes + + if "ValidateLoadedPlugin" in ayon_publish: + loaded_plugin = ( + ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] + ) + for item in loaded_plugin: + item["families"] = item.pop("product_types") + + output["max"] = ayon_max + + def _convert_nuke_knobs(knobs): new_knobs = [] for knob in knobs: @@ -731,15 +797,44 @@ def _convert_nuke_project_settings(ayon_settings, output): ) new_review_data_outputs = {} - for item in ayon_publish["ExtractReviewDataMov"]["outputs"]: + outputs_settings = [] + # Check deprecated ExtractReviewDataMov + # settings for backwards compatibility + deprecrated_review_settings = ayon_publish["ExtractReviewDataMov"] + current_review_settings = ( + ayon_publish.get("ExtractReviewIntermediates") + ) + if deprecrated_review_settings["enabled"]: + outputs_settings = deprecrated_review_settings["outputs"] + elif current_review_settings is None: + pass + elif current_review_settings["enabled"]: + outputs_settings = current_review_settings["outputs"] + + for item in outputs_settings: item_filter = item["filter"] if "product_names" in item_filter: item_filter["subsets"] = item_filter.pop("product_names") item_filter["families"] = item_filter.pop("product_types") + reformat_nodes_config = item.get("reformat_nodes_config") or {} + reposition_nodes = reformat_nodes_config.get( + "reposition_nodes") or [] + + for reposition_node in reposition_nodes: + if "knobs" not in reposition_node: + continue + reposition_node["knobs"] = _convert_nuke_knobs( + reposition_node["knobs"] + ) + name = item.pop("name") new_review_data_outputs[name] = item - ayon_publish["ExtractReviewDataMov"]["outputs"] = new_review_data_outputs + + if deprecrated_review_settings["enabled"]: + deprecrated_review_settings["outputs"] = new_review_data_outputs + elif current_review_settings["enabled"]: + current_review_settings["outputs"] = new_review_data_outputs collect_instance_data = ayon_publish["CollectInstanceData"] if "sync_workfile_version_on_product_types" in collect_instance_data: @@ -773,9 +868,43 @@ def _convert_nuke_project_settings(ayon_settings, output): # NOTE 'monitorOutLut' is maybe not yet in v3 (ut should be) _convert_host_imageio(ayon_nuke) ayon_imageio = ayon_nuke["imageio"] - for item in ayon_imageio["nodes"]["requiredNodes"]: + + # workfile + imageio_workfile = ayon_imageio["workfile"] + workfile_keys_mapping = ( + ("color_management", "colorManagement"), + ("native_ocio_config", "OCIO_config"), + ("working_space", "workingSpaceLUT"), + ("thumbnail_space", "monitorLut"), + ) + for src, dst in workfile_keys_mapping: + if ( + src in imageio_workfile + and dst not in imageio_workfile + ): + imageio_workfile[dst] = imageio_workfile.pop(src) + + # regex inputs + if "regex_inputs" in ayon_imageio: + ayon_imageio["regexInputs"] = ayon_imageio.pop("regex_inputs") + + # nodes + ayon_imageio_nodes = ayon_imageio["nodes"] + if "required_nodes" in ayon_imageio_nodes: + ayon_imageio_nodes["requiredNodes"] = ( + ayon_imageio_nodes.pop("required_nodes")) + if "override_nodes" in ayon_imageio_nodes: + ayon_imageio_nodes["overrideNodes"] = ( + ayon_imageio_nodes.pop("override_nodes")) + + for item in ayon_imageio_nodes["requiredNodes"]: + if "nuke_node_class" in item: + item["nukeNodeClass"] = item.pop("nuke_node_class") item["knobs"] = _convert_nuke_knobs(item["knobs"]) - for item in ayon_imageio["nodes"]["overrideNodes"]: + + for item in ayon_imageio_nodes["overrideNodes"]: + if "nuke_node_class" in item: + item["nukeNodeClass"] = item.pop("nuke_node_class") item["knobs"] = _convert_nuke_knobs(item["knobs"]) output["nuke"] = ayon_nuke @@ -832,6 +961,23 @@ def _convert_photoshop_project_settings(ayon_settings, output): output["photoshop"] = ayon_photoshop +def _convert_substancepainter_project_settings(ayon_settings, output): + if "substancepainter" not in ayon_settings: + return + + ayon_substance_painter = ayon_settings["substancepainter"] + _convert_host_imageio(ayon_substance_painter) + if "shelves" in ayon_substance_painter: + shelves_items = ayon_substance_painter["shelves"] + new_shelves_items = { + item["name"]: item["value"] + for item in shelves_items + } + ayon_substance_painter["shelves"] = new_shelves_items + + output["substancepainter"] = ayon_substance_painter + + def _convert_tvpaint_project_settings(ayon_settings, output): if "tvpaint" not in ayon_settings: return @@ -921,10 +1067,14 @@ def _convert_traypublisher_project_settings(ayon_settings, output): item["family"] = item.pop("product_type") shot_add_tasks = ayon_editorial_simple["shot_add_tasks"] + + # TODO: backward compatibility and remove in future if isinstance(shot_add_tasks, dict): shot_add_tasks = [] + + # aggregate shot_add_tasks items new_shot_add_tasks = { - item["name"]: item["task_type"] + item["name"]: {"type": item["task_type"]} for item in shot_add_tasks } ayon_editorial_simple["shot_add_tasks"] = new_shot_add_tasks @@ -1074,7 +1224,7 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): "studio_name", "studio_code", ): - ayon_core.pop(key) + ayon_core.pop(key, None) # Publish conversion ayon_publish = ayon_core["publish"] @@ -1112,6 +1262,27 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): profile["outputs"] = new_outputs + # ExtractOIIOTranscode plugin + extract_oiio_transcode = ayon_publish["ExtractOIIOTranscode"] + extract_oiio_transcode_profiles = extract_oiio_transcode["profiles"] + for profile in extract_oiio_transcode_profiles: + new_outputs = {} + name_counter = {} + for profile_output in profile["outputs"]: + if "name" in profile_output: + name = profile_output.pop("name") + else: + # Backwards compatibility for setting without 'name' in model + name = profile_output["extension"] + if name in new_outputs: + name_counter[name] += 1 + name = "{}_{}".format(name, name_counter[name]) + else: + name_counter[name] = 0 + + new_outputs[name] = profile_output + profile["outputs"] = new_outputs + # Extract Burnin plugin extract_burnin = ayon_publish["ExtractBurnin"] extract_burnin_options = extract_burnin["options"] @@ -1261,9 +1432,11 @@ def convert_project_settings(ayon_settings, default_settings): _convert_flame_project_settings(ayon_settings, output) _convert_fusion_project_settings(ayon_settings, output) _convert_maya_project_settings(ayon_settings, output) + _convert_3dsmax_project_settings(ayon_settings, output) _convert_nuke_project_settings(ayon_settings, output) _convert_hiero_project_settings(ayon_settings, output) _convert_photoshop_project_settings(ayon_settings, output) + _convert_substancepainter_project_settings(ayon_settings, output) _convert_tvpaint_project_settings(ayon_settings, output) _convert_traypublisher_project_settings(ayon_settings, output) _convert_webpublisher_project_settings(ayon_settings, output) @@ -1323,8 +1496,12 @@ class _AyonSettingsCache: @classmethod def _use_bundles(cls): if _AyonSettingsCache.use_bundles is None: - major, minor, _, _, _ = ayon_api.get_server_version_tuple() - _AyonSettingsCache.use_bundles = major == 0 and minor >= 3 + con = get_ayon_server_api_connection() + major, minor, _, _, _ = con.get_server_version_tuple() + use_bundles = True + if (major, minor) < (0, 3): + use_bundles = False + _AyonSettingsCache.use_bundles = use_bundles return _AyonSettingsCache.use_bundles @classmethod @@ -1332,33 +1509,67 @@ class _AyonSettingsCache: if _AyonSettingsCache.variant is None: from openpype.lib.openpype_version import is_staging_enabled - _AyonSettingsCache.variant = ( - "staging" if is_staging_enabled() else "production" - ) + variant = "production" + if is_dev_mode_enabled(): + variant = cls._get_dev_mode_settings_variant() + elif is_staging_enabled(): + variant = "staging" + + # Cache variant + _AyonSettingsCache.variant = variant + + # Set the variant to global ayon api connection + con = get_ayon_server_api_connection() + con.set_default_settings_variant(variant) return _AyonSettingsCache.variant @classmethod def _get_bundle_name(cls): return os.environ["AYON_BUNDLE_NAME"] + @classmethod + def _get_dev_mode_settings_variant(cls): + """Develop mode settings variant. + + Returns: + str: Name of settings variant. + """ + + con = get_ayon_server_api_connection() + bundles = con.get_bundles() + user = con.get_user() + username = user["name"] + for bundle in bundles["bundles"]: + if ( + bundle.get("isDev") + and bundle.get("activeUser") == username + ): + return bundle["name"] + # Return fake variant - distribution logic will tell user that he + # does not have set any dev bundle + return "dev" + @classmethod def get_value_by_project(cls, project_name): cache_item = _AyonSettingsCache.cache_by_project_name[project_name] if cache_item.is_outdated: + con = get_ayon_server_api_connection() if cls._use_bundles(): - value = ayon_api.get_addons_settings( + value = con.get_addons_settings( bundle_name=cls._get_bundle_name(), - project_name=project_name + project_name=project_name, + variant=cls._get_variant() ) else: - value = ayon_api.get_addons_settings(project_name) + value = con.get_addons_settings(project_name) cache_item.update_value(value) return cache_item.get_value() @classmethod def _get_addon_versions_from_bundle(cls): + con = get_ayon_server_api_connection() expected_bundle = cls._get_bundle_name() - bundles = ayon_api.get_bundles()["bundles"] + bundles = con.get_bundles()["bundles"] bundle = next( ( bundle @@ -1378,8 +1589,11 @@ class _AyonSettingsCache: if cls._use_bundles(): addons = cls._get_addon_versions_from_bundle() else: - settings_data = ayon_api.get_addons_settings( - only_values=False, variant=cls._get_variant()) + con = get_ayon_server_api_connection() + settings_data = con.get_addons_settings( + only_values=False, + variant=cls._get_variant() + ) addons = settings_data["versions"] cache_item.update_value(addons) @@ -1398,3 +1612,18 @@ def get_ayon_system_settings(default_values): return convert_system_settings( ayon_settings, default_values, addon_versions ) + + +def get_ayon_settings(project_name=None): + """AYON studio settings. + + Raw AYON settings values. + + Args: + project_name (Optional[str]): Project name. + + Returns: + dict[str, Any]: AYON settings. + """ + + return _AyonSettingsCache.get_value_by_project(project_name) diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index e5e535bf19..5766a09100 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -53,6 +53,11 @@ "file": "{originalBasename}<.{@frame}><_{udim}>.{ext}", "path": "{@folder}/{@file}" }, + "tycache": { + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", + "file": "{originalBasename}.{ext}", + "path": "{@folder}/{@file}" + }, "source": { "folder": "{root[work]}/{originalDirname}", "file": "{originalBasename}.{ext}", @@ -66,6 +71,7 @@ "simpleUnrealTextureHero": "Simple Unreal Texture - Hero", "simpleUnrealTexture": "Simple Unreal Texture", "online": "online", + "tycache": "tycache", "source": "source", "transient": "transient" } diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index df865adeba..385e97ef91 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -17,6 +17,14 @@ "rules": {} } }, + "RenderSettings": { + "default_render_image_folder": "renders/blender", + "aov_separator": "underscore", + "image_format": "exr", + "multilayer_exr": true, + "aov_list": [], + "custom_passes": [] + }, "workfile_builder": { "create_first_version": false, "custom_templates": [] @@ -27,6 +35,22 @@ "optional": true, "active": true }, + "ValidateFileSaved": { + "enabled": true, + "optional": false, + "active": true, + "exclude_families": [] + }, + "ValidateRenderCameraIsSet": { + "enabled": true, + "optional": false, + "active": true + }, + "ValidateDeadlinePublish": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateMeshHasUvs": { "enabled": true, "optional": true, @@ -47,6 +71,11 @@ "optional": false, "active": true }, + "ValidateInstanceEmpty": { + "enabled": true, + "optional": false, + "active": true + }, "ExtractBlend": { "enabled": true, "optional": true, @@ -65,10 +94,10 @@ "optional": true, "active": false }, - "ExtractABC": { + "ExtractModelABC": { "enabled": true, "optional": true, - "active": false + "active": true }, "ExtractBlendAnimation": { "enabled": true, diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 1b8c8397d7..a19464a5c1 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -52,7 +52,8 @@ "priority": 50, "chunk_size": 10, "concurrent_tasks": 1, - "group": "" + "group": "", + "plugin": "Fusion" }, "NukeSubmitDeadline": { "enabled": true, @@ -99,6 +100,24 @@ "deadline_chunk_size": 10, "deadline_job_delay": "00:00:00:00" }, + "BlenderSubmitDeadline": { + "enabled": true, + "optional": false, + "active": true, + "use_published": true, + "priority": 50, + "chunk_size": 10, + "group": "none", + "job_delay": "00:00:00:00" + }, + "ProcessSubmittedCacheJobOnFarm": { + "enabled": true, + "deadline_department": "", + "deadline_pool": "", + "deadline_group": "", + "deadline_chunk_size": 1, + "deadline_priority": 50 + }, "ProcessSubmittedJobOnFarm": { "enabled": true, "deadline_department": "", @@ -112,6 +131,9 @@ "maya": [ ".*([Bb]eauty).*" ], + "blender": [ + ".*([Bb]eauty).*" + ], "aftereffects": [ ".*" ], diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json index 0ee7d6127d..ab24727db5 100644 --- a/openpype/settings/defaults/project_settings/fusion.json +++ b/openpype/settings/defaults/project_settings/fusion.json @@ -27,5 +27,12 @@ "farm_rendering" ] } + }, + "publish": { + "ValidateSaverResolution": { + "enabled": true, + "optional": true, + "active": true + } } } diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 06a595d1c5..959faf14fa 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -70,6 +70,12 @@ }, "ExtractThumbnail": { "enabled": true, + "oiiotool_defaults": { + "type": "colorspace", + "colorspace": "color_picking", + "view": "sRGB", + "display": "default" + }, "ffmpeg_args": { "input": [ "-apply_trc gamma22" @@ -546,6 +552,17 @@ "task_types": [], "task_names": [], "template_name": "online" + }, + { + "families": [ + "tycache" + ], + "hosts": [ + "max" + ], + "task_types": [], + "task_names": [], + "template_name": "tycache" } ], "hero_template_name_profiles": [ diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 9d047c28bd..0001b23967 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -1,4 +1,17 @@ { + "general": { + "add_self_publish_button": false, + "update_houdini_var_context": { + "enabled": true, + "houdini_vars":[ + { + "var": "JOB", + "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", + "is_directory": true + } + ] + } + }, "imageio": { "activate_host_color_management": true, "ocio_config": { @@ -12,6 +25,12 @@ }, "shelves": [], "create": { + "CreateAlembicCamera": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, "CreateArnoldAss": { "enabled": true, "default_variants": [ @@ -19,7 +38,7 @@ ], "ext": ".ass" }, - "CreateAlembicCamera": { + "CreateArnoldRop": { "enabled": true, "default_variants": [ "Main" @@ -31,19 +50,74 @@ "Main" ] }, + "CreateHDA": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateKarmaROP": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateMantraROP": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, "CreatePointCache": { "enabled": true, "default_variants": [ "Main" ] }, + "CreateBGEO": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateRedshiftProxy": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, "CreateRedshiftROP": { "enabled": true, "default_variants": [ "Main" ] }, - "CreateRemotePublish": { + "CreateReview": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateStaticMesh": { + "enabled": true, + "default_variants": [ + "Main" + ], + "static_mesh_prefix": "S", + "collision_prefixes": [ + "UBX", + "UCP", + "USP", + "UCX" + ] + }, + "CreateUSD": { + "enabled": true, + "default_variants": [ + "Main" + ] + }, + "CreateUSDRender": { "enabled": true, "default_variants": [ "Main" @@ -55,32 +129,47 @@ "Main" ] }, - "CreateUSD": { - "enabled": false, - "default_variants": [ - "Main" - ] - }, - "CreateUSDModel": { - "enabled": false, - "default_variants": [ - "Main" - ] - }, - "USDCreateShadingWorkspace": { - "enabled": false, - "default_variants": [ - "Main" - ] - }, - "CreateUSDRender": { - "enabled": false, + "CreateVrayROP": { + "enabled": true, "default_variants": [ "Main" ] } }, "publish": { + "CollectChunkSize": { + "enabled": true, + "optional": true, + "chunk_size": 999999 + }, + "CollectAssetHandles": { + "use_asset_handles": true + }, + "ValidateContainers": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateMeshIsStatic": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateReviewColorspace": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateSubsetName": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateUnrealStaticMeshName": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateWorkfilePaths": { "enabled": true, "optional": true, @@ -92,11 +181,6 @@ "$HIP", "$JOB" ] - }, - "ValidateContainers": { - "enabled": true, - "optional": true, - "active": true } } } diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index bfb1aa4aeb..97fcf69e31 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -16,6 +16,17 @@ "image_format": "exr", "multipass": true }, + "CreateReview": { + "review_width": 1920, + "review_height": 1080, + "percentSize": 100.0, + "keep_images": false, + "image_format": "png", + "visual_style": "Realistic", + "viewport_preset": "Quality", + "anti_aliasing": "None", + "vp_texture": true + }, "PointCloud": { "attribute": { "Age": "age", @@ -36,6 +47,15 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateAttributes": { + "enabled": false, + "attributes": {} + }, + "ValidateLoadedPlugin": { + "enabled": false, + "optional": true, + "family_plugins_mapping": [] } } } diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 38f14ec022..7719a5e255 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -707,6 +707,9 @@ "CollectMayaRender": { "sync_workfile_version": false }, + "CollectFbxAnimation": { + "enabled": true + }, "CollectFbxCamera": { "enabled": false }, @@ -826,6 +829,11 @@ "redshift_render_attributes": [], "renderman_render_attributes": [] }, + "ValidateResolution": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateCurrentRenderLayerIsRenderable": { "enabled": true, "optional": false, @@ -1120,6 +1128,11 @@ "optional": true, "active": true }, + "ValidateAnimatedReferenceRig": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateAnimationContent": { "enabled": true, "optional": false, @@ -1140,6 +1153,16 @@ "optional": false, "active": true }, + "ValidateSkeletonRigContents": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateSkeletonRigControllers": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateSkinclusterDeformerSet": { "enabled": true, "optional": false, @@ -1150,6 +1173,21 @@ "optional": false, "allow_history_only": false }, + "ValidateSkeletonRigOutSetNodeIds": { + "enabled": false, + "optional": false, + "allow_history_only": false + }, + "ValidateSkeletonRigOutputIds": { + "enabled": false, + "optional": true, + "active": true + }, + "ValidateSkeletonTopGroupHierarchy": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateCameraAttributes": { "enabled": false, "optional": true, @@ -1338,6 +1376,12 @@ "active": true, "bake_attributes": [] }, + "ExtractCameraMayaScene": { + "enabled": true, + "optional": true, + "active": true, + "keep_image_planes": false + }, "ExtractGLB": { "enabled": true, "active": true, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index b736c462ff..20df0ad5c2 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -19,20 +19,16 @@ "rules": {} }, "viewer": { - "viewerProcess": "sRGB" + "viewerProcess": "sRGB (default)" }, "baking": { - "viewerProcess": "rec709" + "viewerProcess": "rec709 (default)" }, "workfile": { - "colorManagement": "Nuke", + "colorManagement": "OCIO", "OCIO_config": "nuke-default", - "workingSpaceLUT": "linear", - "monitorLut": "sRGB", - "int8Lut": "sRGB", - "int16Lut": "sRGB", - "logLut": "Cineon", - "floatLut": "linear" + "workingSpaceLUT": "scene_linear", + "monitorLut": "sRGB (default)" }, "nodes": { "requiredNodes": [ @@ -80,7 +76,7 @@ { "type": "text", "name": "colorspace", - "value": "linear" + "value": "scene_linear" }, { "type": "bool", @@ -133,7 +129,7 @@ { "type": "text", "name": "colorspace", - "value": "linear" + "value": "scene_linear" }, { "type": "bool", @@ -181,7 +177,7 @@ { "type": "text", "name": "colorspace", - "value": "sRGB" + "value": "texture_paint" }, { "type": "bool", @@ -197,7 +193,7 @@ "inputs": [ { "regex": "(beauty).*(?=.exr)", - "colorspace": "linear" + "colorspace": "scene_linear" } ] } @@ -345,7 +341,7 @@ "write" ] }, - "ValidateCorrectAssetName": { + "ValidateCorrectAssetContext": { "enabled": true, "optional": true, "active": true @@ -378,7 +374,7 @@ "optional": true, "active": true }, - "ValidateScript": { + "ValidateScriptAttributes": { "enabled": true, "optional": true, "active": true @@ -505,6 +501,60 @@ } } }, + "ExtractReviewIntermediates": { + "enabled": true, + "viewer_lut_raw": false, + "outputs": { + "baking": { + "filter": { + "task_types": [], + "families": [], + "subsets": [] + }, + "read_raw": false, + "viewer_process_override": "", + "bake_viewer_process": true, + "bake_viewer_input_process": true, + "reformat_nodes_config": { + "enabled": false, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": [ + { + "type": "text", + "name": "type", + "value": "to format" + }, + { + "type": "text", + "name": "format", + "value": "HD_1080" + }, + { + "type": "text", + "name": "filter", + "value": "Lanczos6" + }, + { + "type": "bool", + "name": "black_outside", + "value": true + }, + { + "type": "bool", + "name": "pbb", + "value": false + } + ] + } + ] + }, + "extension": "mov", + "add_custom_tags": [] + } + } + }, "ExtractSlateFrame": { "viewer_lut_raw": false, "key_value_mapping": { diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index dda958ebcd..e13de11414 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -256,6 +256,23 @@ "allow_multiple_items": true, "allow_version_control": false, "extensions": [] + }, + { + "family": "audio", + "identifier": "", + "label": "Audio ", + "icon": "fa5s.file-audio", + "default_variants": [ + "Main" + ], + "description": "Audio product", + "detailed_description": "Audio files for review or final delivery", + "allow_sequences": false, + "allow_multiple_items": false, + "allow_version_control": false, + "extensions": [ + ".wav" + ] } ], "editorial_creators": { @@ -329,10 +346,10 @@ } }, "publish": { - "CollectFrameDataFromAssetEntity": { + "CollectSequenceFrameData": { "enabled": true, "optional": true, - "active": true + "active": false }, "ValidateFrameRange": { "enabled": true, diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index f2fc7d933a..a5283751e9 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -12,6 +12,26 @@ "LC_ALL": "C" }, "variants": { + "2024": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\maya.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/maya" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": { + "MAYA_VERSION": "2024" + } + }, "2023": { "use_python_2": false, "executables": { @@ -51,66 +71,65 @@ "environment": { "MAYA_VERSION": "2022" } - }, - "2020": { - "use_python_2": true, + } + } + }, + "mayapy": { + "enabled": true, + "label": "MayaPy", + "icon": "{}/app_icons/maya.png", + "host_name": "maya", + "environment": { + "MAYA_DISABLE_CLIC_IPM": "Yes", + "MAYA_DISABLE_CIP": "Yes", + "MAYA_DISABLE_CER": "Yes", + "PYMEL_SKIP_MEL_INIT": "Yes", + "LC_ALL": "C" + }, + "variants": { + "2024": { + "use_python_2": false, "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe" + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe" ], "darwin": [], "linux": [ - "/usr/autodesk/maya2020/bin/maya" + "/usr/autodesk/maya2024/bin/mayapy" ] }, "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": { - "MAYA_VERSION": "2020" - } - }, - "2019": { - "use_python_2": true, - "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe" + "-I" ], "darwin": [], "linux": [ - "/usr/autodesk/maya2019/bin/maya" + "-I" ] }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": { - "MAYA_VERSION": "2019" - } + "environment": {} }, - "2018": { - "use_python_2": true, + "2023": { + "use_python_2": false, "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2018\\bin\\maya.exe" + "C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe" ], "darwin": [], "linux": [ - "/usr/autodesk/maya2018/bin/maya" + "/usr/autodesk/maya2023/bin/mayapy" ] }, "arguments": { - "windows": [], + "windows": [ + "-I" + ], "darwin": [], - "linux": [] + "linux": [ + "-I" + ] }, - "environment": { - "MAYA_VERSION": "2018" - } + "environment": {} } } }, @@ -325,13 +344,30 @@ }, "environment": {} }, + "11-0": { + "use_python_2": true, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke11.0v4\\Nuke11.0.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, "__dynamic_keys_labels__": { "13-2": "13.2", "13-0": "13.0", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", - "11-2": "11.2" + "11-2": "11.2", + "11-0": "11.0" } } }, diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index f524f01d45..bb943524f1 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -210,5 +210,8 @@ "darwin": "", "linux": "" } + }, + "asset_reporter": { + "enabled": false } } diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 88d2dc8296..f26d86e6df 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -352,7 +352,7 @@ class DictConditionalEntity(ItemEntity): break if result_key is None: - raise ValueError("Didn't found child {}".format(child_obj)) + raise ValueError("Didn't find child {}".format(child_obj)) return "/".join([self.path, result_key]) diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index 0209681e95..a25c22aa19 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -232,7 +232,7 @@ class DictImmutableKeysEntity(ItemEntity): break if result_key is None: - raise ValueError("Didn't found child {}".format(child_obj)) + raise ValueError("Didn't find child {}".format(child_obj)) return "/".join([self.path, result_key]) diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index e6d332b9ad..c11a7cf059 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -284,7 +284,7 @@ class DictMutableKeysEntity(EndpointEntity): break if result_key is None: - raise ValueError("Didn't found child {}".format(child_obj)) + raise ValueError("Didn't find child {}".format(child_obj)) return "/".join([self.path, result_key]) diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index 3b756e4ede..c888cf3b78 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -295,7 +295,7 @@ class ListStrictEntity(ItemEntity): break if result_idx is None: - raise ValueError("Didn't found child {}".format(child_obj)) + raise ValueError("Didn't find child {}".format(child_obj)) return "/".join([self.path, str(result_idx)]) diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index 5d6a64b3ea..d9a18e0177 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -258,7 +258,7 @@ class ListEntity(EndpointEntity): break if result_idx is None: - raise ValueError("Didn't found child {}".format(child_obj)) + raise ValueError("Didn't find child {}".format(child_obj)) return "/".join([self.path, str(result_idx)]) diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index f2e24fb522..bd617c6b7c 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -270,7 +270,7 @@ class RootEntity(BaseItemEntity): for key, _child_entity in self.non_gui_children.items(): if _child_entity is child_entity: return key - raise ValueError("Didn't found child {}".format(child_entity)) + raise ValueError("Didn't find child {}".format(child_entity)) @property def value(self): diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index aeb70dfd8c..535d9434a3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -54,6 +54,110 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "RenderSettings", + "label": "Render Settings", + "children": [ + { + "type": "text", + "key": "default_render_image_folder", + "label": "Default render image folder" + }, + { + "key": "aov_separator", + "label": "AOV Separator Character", + "type": "enum", + "multiselection": false, + "defaults": "underscore", + "enum_items": [ + {"dash": "- (dash)"}, + {"underscore": "_ (underscore)"}, + {"dot": ". (dot)"} + ] + }, + { + "key": "image_format", + "label": "Output Image Format", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"exr": "OpenEXR"}, + {"bmp": "BMP"}, + {"rgb": "Iris"}, + {"png": "PNG"}, + {"jpg": "JPEG"}, + {"jp2": "JPEG 2000"}, + {"tga": "Targa"}, + {"tif": "TIFF"} + ] + }, + { + "key": "multilayer_exr", + "type": "boolean", + "label": "Multilayer (EXR)" + }, + { + "type": "label", + "label": "Note: Multilayer EXR is only used when output format type set to EXR." + }, + { + "key": "aov_list", + "label": "AOVs to create", + "type": "enum", + "multiselection": true, + "defaults": "empty", + "enum_items": [ + {"empty": "< empty >"}, + {"combined": "Combined"}, + {"z": "Z"}, + {"mist": "Mist"}, + {"normal": "Normal"}, + {"diffuse_light": "Diffuse Light"}, + {"diffuse_color": "Diffuse Color"}, + {"specular_light": "Specular Light"}, + {"specular_color": "Specular Color"}, + {"volume_light": "Volume Light"}, + {"emission": "Emission"}, + {"environment": "Environment"}, + {"shadow": "Shadow"}, + {"ao": "Ambient Occlusion"}, + {"denoising": "Denoising"}, + {"volume_direct": "Direct Volumetric Scattering"}, + {"volume_indirect": "Indirect Volumetric Scattering"} + ] + }, + { + "type": "label", + "label": "Add custom AOVs. They are added to the view layer and in the Compositing Nodetree,\nbut they need to be added manually to the Shader Nodetree." + }, + { + "type": "dict-modifiable", + "store_as_list": true, + "key": "custom_passes", + "label": "Custom Passes", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "type", + "label": "Type", + "type": "enum", + "multiselection": false, + "default": "COLOR", + "enum_items": [ + {"COLOR": "Color"}, + {"VALUE": "Value"} + ] + } + ] + } + } + ] + }, { "type": "schema_template", "name": "template_workfile_options", 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 6d59b5a92b..1aea778e32 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -289,6 +289,15 @@ "type": "text", "key": "group", "label": "Group Name" + }, + { + "type": "enum", + "key": "plugin", + "label": "Deadline Plugin", + "enum_items": [ + {"Fusion": "Fusion"}, + {"FusionCmd": "FusionCmd"} + ] } ] }, @@ -531,6 +540,95 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "BlenderSubmitDeadline", + "label": "Blender Submit to Deadline", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "boolean", + "key": "use_published", + "label": "Use Published scene" + }, + { + "type": "number", + "key": "priority", + "label": "Priority" + }, + { + "type": "number", + "key": "chunk_size", + "label": "Frame per Task" + }, + { + "type": "text", + "key": "group", + "label": "Group Name" + }, + { + "type": "text", + "key": "job_delay", + "label": "Delay job (timecode dd:hh:mm:ss)" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ProcessSubmittedCacheJobOnFarm", + "label": "ProcessSubmittedCacheJobOnFarm", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "text", + "key": "deadline_department", + "label": "Deadline department" + }, + { + "type": "text", + "key": "deadline_pool", + "label": "Deadline Pool" + }, + { + "type": "text", + "key": "deadline_group", + "label": "Deadline Group" + }, + { + "type": "number", + "key": "deadline_chunk_size", + "label": "Deadline Chunk Size" + }, + { + "type": "number", + "key": "deadline_priority", + "label": "Deadline Priotity" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json index 656c50dd98..342411f8a5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json @@ -84,6 +84,24 @@ ] } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateSaverResolution", + "label": "Validate Saver Resolution" + } + ] + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json index 7f782e3647..d4d0565ec9 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json @@ -5,6 +5,10 @@ "label": "Houdini", "is_file": true, "children": [ + { + "type": "schema", + "name": "schema_houdini_general" + }, { "key": "imageio", "type": "dict", 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 e314174dff..78cca357a3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json @@ -65,6 +65,104 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CreateReview", + "label": "Create Review", + "children": [ + { + "type": "number", + "key": "review_width", + "label": "Review Width" + }, + { + "type": "number", + "key": "review_height", + "label": "Review Height" + }, + { + "type": "number", + "key": "percentSize", + "label": "Percent of Output" + }, + { + "type": "boolean", + "key": "keep_images", + "label": "Keep Image Sequences" + }, + { + "key": "image_format", + "label": "Image Format Options", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"exr": "exr"}, + {"jpg": "jpg"}, + {"png": "png"}, + {"tga": "tga"} + ] + }, + { + "key": "visual_style", + "label": "Preference", + "type": "enum", + "multiselection": false, + "defaults": "Realistic", + "enum_items": [ + {"Realistic": "Realistic"}, + {"Shaded": "Shaded"}, + {"Facets": "Facets"}, + {"ConsistentColors": "ConsistentColors"}, + {"HiddenLine": "HiddenLine"}, + {"Wireframe": "Wireframe"}, + {"BoundingBox": "BoundingBox"}, + {"Ink": "Ink"}, + {"ColorInk": "ColorInk"}, + {"Acrylic": "Acrylic"}, + {"Tech": "Tech"}, + {"Graphite": "Graphite"}, + {"ColorPencil": "ColorPencil"}, + {"Pastel": "Pastel"}, + {"Clay": "Clay"}, + {"ModelAssist": "ModelAssist"} + ] + }, + { + "key": "viewport_preset", + "label": "Pre-View Preset", + "type": "enum", + "multiselection": false, + "defaults": "Quality", + "enum_items": [ + {"Quality": "Quality"}, + {"Standard": "Standard"}, + {"Performance": "Performance"}, + {"DXMode": "DXMode"}, + {"Customize": "Customize"} + ] + }, + { + "key": "anti_aliasing", + "label": "Anti-aliasing Quality", + "type": "enum", + "multiselection": false, + "defaults": "None", + "enum_items": [ + {"None": "None"}, + {"2X": "2X"}, + {"4X": "4X"}, + {"8X": "8X"} + ] + }, + { + "type": "boolean", + "key": "vp_texture", + "label": "Viewport Texture" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json index 184fc657be..93e6325b5a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -350,8 +350,8 @@ "name": "template_validate_plugin", "template_data": [ { - "key": "CollectFrameDataFromAssetEntity", - "label": "Collect frame range from asset entity" + "key": "CollectSequenceFrameData", + "label": "Collect Original Sequence Frame Data" }, { "key": "ValidateFrameRange", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json index 2f0bf0a831..e4f1096223 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json @@ -18,6 +18,39 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateFileSaved", + "label": "Validate File Saved", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "splitter" + }, + { + "key": "exclude_families", + "label": "Exclude Families", + "type": "list", + "object_type": "text" + } + ] + }, { "type": "collapsible-wrap", "label": "Model", @@ -46,6 +79,82 @@ } ] }, + { + "type": "collapsible-wrap", + "label": "BlendScene", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateInstanceEmpty", + "label": "Validate Instance is not Empty" + } + ] + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Render", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "type": "dict", + "collapsible": true, + "key": "ValidateRenderCameraIsSet", + "label": "Validate Render Camera Is Set", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateDeadlinePublish", + "label": "Validate Render Output for Deadline", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] + } + ] + } + ] + }, { "type": "splitter" }, @@ -88,12 +197,12 @@ "name": "template_publish_plugin", "template_data": [ { - "key": "ExtractFBX", - "label": "Extract FBX (model and rig)" + "key": "ExtractModelABC", + "label": "Extract ABC (model)" }, { - "key": "ExtractABC", - "label": "Extract ABC (model and pointcache)" + "key": "ExtractFBX", + "label": "Extract FBX (model and rig)" }, { "key": "ExtractBlendAnimation", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index c7e91fd22d..a850cb68ed 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 @@ -202,6 +202,38 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "dict", + "collapsible": true, + "key": "oiiotool_defaults", + "label": "OIIOtool defaults", + "children": [ + { + "type": "enum", + "key": "type", + "label": "Target type", + "enum_items": [ + { "colorspace": "Colorspace" }, + { "display_and_view": "Display & View" } + ] + }, + { + "type": "text", + "key": "colorspace", + "label": "Colorspace" + }, + { + "type": "text", + "key": "view", + "label": "View" + }, + { + "type": "text", + "key": "display", + "label": "Display" + } + ] + }, { "type": "dict", "key": "ffmpeg_args", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json index 799bc0e81a..f37738c4ec 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json @@ -4,6 +4,16 @@ "key": "create", "label": "Creator plugins", "children": [ + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateAlembicCamera", + "label": "Create Alembic Camera" + } + ] + }, { "type": "dict", "collapsible": true, @@ -44,44 +54,97 @@ "name": "template_create_plugin", "template_data": [ { - "key": "CreateAlembicCamera", - "label": "Create Alembic Camera" + "key": "CreateArnoldRop", + "label": "Create Arnold ROP" }, { "key": "CreateCompositeSequence", "label": "Create Composite (Image Sequence)" }, + { + "key": "CreateHDA", + "label": "Create Houdini Digital Asset" + }, + { + "key": "CreateKarmaROP", + "label": "Create Karma ROP" + }, + { + "key": "CreateMantraROP", + "label": "Create Mantra ROP" + }, { "key": "CreatePointCache", - "label": "Create Point Cache" + "label": "Create PointCache (Abc)" + }, + { + "key": "CreateBGEO", + "label": "Create PointCache (Bgeo)" + }, + { + "key": "CreateRedshiftProxy", + "label": "Create Redshift Proxy" }, { "key": "CreateRedshiftROP", "label": "Create Redshift ROP" }, { - "key": "CreateRemotePublish", - "label": "Create Remote Publish" + "key": "CreateReview", + "label": "Create Review" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "CreateStaticMesh", + "label": "Create Static Mesh", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Variants", + "object_type": "text" + }, + { + "type": "text", + "key": "static_mesh_prefix", + "label": "Static Mesh Prefix" + }, + { + "type": "list", + "key": "collision_prefixes", + "label": "Collision Mesh Prefixes", + "object_type": "text" + } + ] + }, + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateUSD", + "label": "Create USD (experimental)" + }, + { + "key": "CreateUSDRender", + "label": "Create USD render (experimental)" }, { "key": "CreateVDBCache", "label": "Create VDB Cache" }, { - "key": "CreateUSD", - "label": "Create USD" - }, - { - "key": "CreateUSDModel", - "label": "Create USD Model" - }, - { - "key": "USDCreateShadingWorkspace", - "label": "Create USD Shading Workspace" - }, - { - "key": "CreateUSDRender", - "label": "Create USD Render" + "key": "CreateVrayROP", + "label": "Create VRay ROP" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json new file mode 100644 index 0000000000..e118f83d21 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_general.json @@ -0,0 +1,58 @@ +{ + "type": "dict", + "key": "general", + "label": "General", + "collapsible": true, + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "add_self_publish_button", + "label": "Add Self Publish Button" + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "update_houdini_var_context", + "label": "Update Houdini Vars on context change", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Sync vars with context changes.
If a value is treated as a directory on update it will be ensured the folder exists" + }, + { + "type": "list", + "key": "houdini_vars", + "label": "Houdini Vars", + "collapsible": false, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "var", + "label": "Var" + }, + { + "type": "text", + "key": "value", + "label": "Value" + }, + { + "type": "boolean", + "key": "is_directory", + "label": "Treat as directory" + } + ] + } + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index aa6eaf5164..935c2a4c35 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -4,6 +4,82 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type":"label", + "label":"Collectors" + }, + { + "type": "dict", + "collapsible": true, + "key": "CollectAssetHandles", + "label": "Collect Asset Handles", + "children": [ + { + "type": "label", + "label": "Disable this if you want the publisher to ignore start and end handles specified in the asset data for publish instances" + }, + { + "type": "boolean", + "key": "use_asset_handles", + "label": "Use asset handles" + } + ] + }, + { + "type": "label", + "label": "Validators" + }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateContainers", + "label": "Validate Containers" + }, + { + "key": "ValidateMeshIsStatic", + "label": "Validate Mesh is Static" + }, + { + "key": "ValidateReviewColorspace", + "label": "Validate Review Colorspace" + }, + { + "key": "ValidateSubsetName", + "label": "Validate Subset Name" + }, + { + "key": "ValidateUnrealStaticMeshName", + "label": "Validate Unreal Static Mesh Name" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "CollectChunkSize", + "label": "Collect Chunk Size", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "number", + "key": "chunk_size", + "label": "Frames Per Task" + } + ] + }, { "type": "dict", "collapsible": true, @@ -35,16 +111,6 @@ "object_type": "text" } ] - }, - { - "type": "schema_template", - "name": "template_publish_plugin", - "template_data": [ - { - "key": "ValidateContainers", - "label": "ValidateContainers" - } - ] } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index bab9b604b4..cee04b73e5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -5,67 +5,100 @@ "is_group": true, "use_label_wrap": true, "object_type": { - "type": "dict", - "children": [ + "type": "dict-conditional", + "enum_key": "options", + "enum_label": "Options", + "enum_children": [ { - "type": "text", - "key": "shelf_set_name", - "label": "Shelf Set Name" - }, - { - "type": "path", - "key": "shelf_set_source_path", - "label": "Shelf Set Path (optional)", - "multipath": false, - "multiplatform": true - }, - { - "type": "list", - "key": "shelf_definition", - "label": "Shelves", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "shelf_name", - "label": "Shelf Name" - }, - { - "type": "list", - "key": "tools_list", - "label": "Tools", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "label", - "label": "Name" - }, - { - "type": "path", - "key": "script", - "label": "Script" - }, - { - "type": "path", - "key": "icon", - "label": "Icon" - }, - { - "type": "text", - "key": "help", - "label": "Help" - } - ] + + "key": "add_shelf_file", + "label": "Add a .shelf file", + "children": [ + { + "type": "dict", + "key": "add_shelf_file", + "label": "Add a .shelf file", + "children": [ + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path", + "multipath": false, + "multiplatform": true } - } - ] - } + ] + } + ] + }, + { + "key": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions", + "children": [ + { + "key": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions", + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_set_name", + "label": "Shelf Set Name" + }, + { + "type": "list", + "key": "shelf_definition", + "label": "Shelves Definitions", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_name", + "label": "Shelf Name" + }, + { + "type": "list", + "key": "tools_list", + "label": "Tools", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "label", + "label": "Name and Script Path are mandatory." + }, + { + "type": "text", + "key": "label", + "label": "Name" + }, + { + "type": "path", + "key": "script", + "label": "Script" + }, + { + "type": "path", + "key": "icon", + "label": "Icon" + }, + { + "type": "text", + "key": "help", + "label": "Help" + } + ] + } + } + ] + } + } + ] + } + ] } ] } -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index ea08c735a6..c6d37ae993 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -28,6 +28,68 @@ "label": "Active" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateAttributes", + "label": "ValidateAttributes", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "raw-json", + "key": "attributes", + "label": "Attributes" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateLoadedPlugin", + "label": "Validate Loaded Plugin", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "list", + "collapsible": true, + "key": "family_plugins_mapping", + "label": "Family Plugins Mapping", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "families", + "label": "Famiies", + "type": "list", + "object_type": "text" + }, + { + "key": "plugins", + "label": "Plugins", + "type": "list", + "object_type": "text" + } + ] + } + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index b115ee3faa..d2e7c51e24 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -21,6 +21,20 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectFbxAnimation", + "label": "Collect Fbx Animation", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "dict", "collapsible": true, @@ -417,6 +431,10 @@ "type": "schema_template", "name": "template_publish_plugin", "template_data": [ + { + "key": "ValidateResolution", + "label": "Validate Resolution Settings" + }, { "key": "ValidateCurrentRenderLayerIsRenderable", "label": "Validate Current Render Layer Has Renderable Camera" @@ -793,6 +811,10 @@ "key": "ValidateRigControllers", "label": "Validate Rig Controllers" }, + { + "key": "ValidateAnimatedReferenceRig", + "label": "Validate Animated Reference Rig" + }, { "key": "ValidateAnimationContent", "label": "Validate Animation Content" @@ -809,9 +831,51 @@ "key": "ValidateSkeletalMeshHierarchy", "label": "Validate Skeletal Mesh Top Node" }, - { + { + "key": "ValidateSkeletonRigContents", + "label": "Validate Skeleton Rig Contents" + }, + { + "key": "ValidateSkeletonRigControllers", + "label": "Validate Skeleton Rig Controllers" + }, + { "key": "ValidateSkinclusterDeformerSet", "label": "Validate Skincluster Deformer Relationships" + }, + { + "key": "ValidateSkeletonRigOutputIds", + "label": "Validate Skeleton Rig Output Ids" + }, + { + "key": "ValidateSkeletonTopGroupHierarchy", + "label": "Validate Skeleton Top Group Hierarchy" + } + ] + }, + + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateRigOutSetNodeIds", + "label": "Validate Rig Out Set Node Ids", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "allow_history_only", + "label": "Allow history only" } ] }, @@ -819,8 +883,8 @@ "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "ValidateRigOutSetNodeIds", - "label": "Validate Rig Out Set Node Ids", + "key": "ValidateSkeletonRigOutSetNodeIds", + "label": "Validate Skeleton Rig Out Set Node Ids", "is_group": true, "children": [ { @@ -978,6 +1042,35 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ExtractCameraMayaScene", + "label": "Extract camera to Maya scene", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "boolean", + "key": "keep_image_planes", + "label": "Export Image planes" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json index 636dfa114c..fc4e750e3b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json @@ -12,7 +12,7 @@ { "type": "text", "key": "default_render_image_folder", - "label": "Default render image folder" + "label": "Default render image folder. This setting can be\noverwritten by custom staging directory profile;\n\"project_settings/global/tools/publish\n/custom_staging_dir_profiles\"." }, { "type": "boolean", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json index d4cd332ef8..af826fcf46 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json @@ -106,26 +106,6 @@ "type": "text", "key": "monitorLut", "label": "monitor" - }, - { - "type": "text", - "key": "int8Lut", - "label": "8-bit files" - }, - { - "type": "text", - "key": "int16Lut", - "label": "16-bit files" - }, - { - "type": "text", - "key": "logLut", - "label": "log files" - }, - { - "type": "text", - "key": "floatLut", - "label": "float files" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index f006392bef..e0cd086119 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -61,7 +61,7 @@ "name": "template_publish_plugin", "template_data": [ { - "key": "ValidateCorrectAssetName", + "key": "ValidateCorrectAssetContext", "label": "Validate Correct Asset Name" } ] @@ -113,8 +113,8 @@ "label": "Validate Gizmo (Group)" }, { - "key": "ValidateScript", - "label": "Validate script settings" + "key": "ValidateScriptAttributes", + "label": "Validate workfile attributes" } ] }, @@ -371,6 +371,151 @@ ] }, + { + "type": "label", + "label": "^ Settings and for ExtractReviewDataMov is deprecated and will be soon removed.
Please use ExtractReviewIntermediates instead." + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ExtractReviewIntermediates", + "label": "ExtractReviewIntermediates", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "viewer_lut_raw", + "label": "Viewer LUT raw" + }, + { + "key": "outputs", + "label": "Output Definitions", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "dict", + "collapsible": false, + "key": "filter", + "label": "Filtering", + "children": [ + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "subsets", + "label": "Subsets", + "type": "list", + "object_type": "text" + } + ] + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "read_raw", + "label": "Read colorspace RAW", + "default": false + }, + { + "type": "text", + "key": "viewer_process_override", + "label": "Viewer Process colorspace profile override" + }, + { + "type": "boolean", + "key": "bake_viewer_process", + "label": "Bake Viewer Process" + }, + { + "type": "boolean", + "key": "bake_viewer_input_process", + "label": "Bake Viewer Input Process (LUTs)" + }, + { + "type": "separator" + }, + { + "key": "reformat_nodes_config", + "type": "dict", + "label": "Reformat Nodes", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Reposition knobs supported only.
You can add multiple reformat nodes
and set their knobs. Order of reformat
nodes is important. First reformat node
will be applied first and last reformat
node will be applied last." + }, + { + "key": "reposition_nodes", + "type": "list", + "label": "Reposition nodes", + "object_type": { + "type": "dict", + "children": [ + { + "key": "node_class", + "label": "Node class", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } + } + ] + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "extension", + "label": "Write node file type" + }, + { + "key": "add_custom_tags", + "label": "Add custom tags", + "type": "list", + "object_type": "text" + } + ] + } + } + + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json new file mode 100644 index 0000000000..bbdc7e13b0 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_mayapy.json @@ -0,0 +1,39 @@ +{ + "type": "dict", + "key": "mayapy", + "label": "Autodesk MayaPy", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json" + }, + { + "type": "dict-modifiable", + "key": "variants", + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items" + } + ] + } + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json index abea37a9ab..7965c344ae 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -9,6 +9,10 @@ "type": "schema", "name": "schema_maya" }, + { + "type": "schema", + "name": "schema_mayapy" + }, { "type": "schema", "name": "schema_3dsmax" diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 952b38040c..5b189eae88 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -355,6 +355,20 @@ { "type": "dynamic_schema", "name": "system_settings/modules" + }, + { + "type": "dict", + "key": "asset_reporter", + "label": "Asset Usage Reporter", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] } ] } diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index ce62dde43f..d62e50d3c7 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -172,7 +172,7 @@ def save_studio_settings(data): clear_metadata_from_settings(new_data) changes = calculate_changes(old_data, new_data) - modules_manager = ModulesManager(_system_settings=new_data) + modules_manager = ModulesManager(new_data) warnings = [] for module in modules_manager.get_enabled_modules(): diff --git a/openpype/style/style.css b/openpype/style/style.css index 5ce55aa658..ca368f84f8 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1427,6 +1427,10 @@ CreateNextPageOverlay { background: rgba(0, 0, 0, 127); } +#OverlayFrameLabel { + font-size: 15pt; +} + #BreadcrumbsPathInput { padding: 2px; font-size: 9pt; diff --git a/openpype/tests/test_lib_restructuralization.py b/openpype/tests/test_lib_restructuralization.py index 669706d470..a91d65f7a8 100644 --- a/openpype/tests/test_lib_restructuralization.py +++ b/openpype/tests/test_lib_restructuralization.py @@ -18,8 +18,6 @@ def test_backward_compatibility(printer): from openpype.lib import get_ffprobe_streams - from openpype.hosts.fusion.lib import switch_item - from openpype.lib import source_hash from openpype.lib import run_subprocess diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index d46c238da1..7dea01e0a8 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -19,6 +19,7 @@ from openpype.tools.utils import ( CustomTextComboBox, FocusSpinBox, FocusDoubleSpinBox, + MultiSelectionComboBox, ) from openpype.widgets.nice_checkbox import NiceCheckbox @@ -250,6 +251,30 @@ class LabelAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) +class ClickableLineEdit(QtWidgets.QLineEdit): + clicked = QtCore.Signal() + + def __init__(self, text, parent): + super(ClickableLineEdit, self).__init__(parent) + self.setText(text) + self.setReadOnly(True) + + self._mouse_pressed = False + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(ClickableLineEdit, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + + super(ClickableLineEdit, self).mouseReleaseEvent(event) + + class NumberAttrWidget(_BaseAttrDefWidget): def _ui_init(self): decimals = self.attr_def.decimals @@ -269,20 +294,38 @@ class NumberAttrWidget(_BaseAttrDefWidget): input_widget.setButtonSymbols( QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons ) + input_line_edit = input_widget.lineEdit() + input_widget.installEventFilter(self) + + multisel_widget = ClickableLineEdit("< Multiselection >", self) + multisel_widget.setVisible(False) input_widget.valueChanged.connect(self._on_value_change) + multisel_widget.clicked.connect(self._on_multi_click) self._input_widget = input_widget + self._input_line_edit = input_line_edit + self._multisel_widget = multisel_widget + self._last_multivalue = None + self._multivalue = False self.main_layout.addWidget(input_widget, 0) + self.main_layout.addWidget(multisel_widget, 0) - def _on_value_change(self, new_value): - self.value_changed.emit(new_value, self.attr_def.id) + def eventFilter(self, obj, event): + if ( + self._multivalue + and obj is self._input_widget + and event.type() == QtCore.QEvent.FocusOut + ): + self._set_multiselection_visible(True) + return False def current_value(self): return self._input_widget.value() def set_value(self, value, multivalue=False): + self._last_multivalue = None if multivalue: set_value = set(value) if None in set_value: @@ -290,13 +333,47 @@ class NumberAttrWidget(_BaseAttrDefWidget): set_value.add(self.attr_def.default) if len(set_value) > 1: - self._input_widget.setSpecialValueText("Multiselection") + self._last_multivalue = next(iter(set_value), None) + self._set_multiselection_visible(True) + self._multivalue = True return value = tuple(set_value)[0] + self._multivalue = False + self._set_multiselection_visible(False) + if self.current_value != value: self._input_widget.setValue(value) + def _on_value_change(self, new_value): + self._multivalue = False + self.value_changed.emit(new_value, self.attr_def.id) + + def _on_multi_click(self): + self._set_multiselection_visible(False, True) + + def _set_multiselection_visible(self, visible, change_focus=False): + self._input_widget.setVisible(not visible) + self._multisel_widget.setVisible(visible) + if visible: + return + + # Change value once user clicked on the input field + if self._last_multivalue is None: + value = self.attr_def.default + else: + value = self._last_multivalue + self._input_widget.blockSignals(True) + self._input_widget.setValue(value) + self._input_widget.blockSignals(False) + if not change_focus: + return + # Change focus to input field and move cursor to the end + self._input_widget.setFocus(QtCore.Qt.MouseFocusReason) + self._input_line_edit.setCursorPosition( + len(self._input_line_edit.text()) + ) + class TextAttrWidget(_BaseAttrDefWidget): def _ui_init(self): @@ -343,6 +420,7 @@ class TextAttrWidget(_BaseAttrDefWidget): return self._input_widget.text() def set_value(self, value, multivalue=False): + block_signals = False if multivalue: set_value = set(value) if None in set_value: @@ -352,13 +430,18 @@ class TextAttrWidget(_BaseAttrDefWidget): if len(set_value) == 1: value = tuple(set_value)[0] else: + block_signals = True value = "< Multiselection >" if value != self.current_value(): + if block_signals: + self._input_widget.blockSignals(True) if self.multiline: self._input_widget.setPlainText(value) else: self._input_widget.setText(value) + if block_signals: + self._input_widget.blockSignals(False) class BoolAttrWidget(_BaseAttrDefWidget): @@ -391,7 +474,9 @@ class BoolAttrWidget(_BaseAttrDefWidget): set_value.add(self.attr_def.default) if len(set_value) > 1: + self._input_widget.blockSignals(True) self._input_widget.setCheckState(QtCore.Qt.PartiallyChecked) + self._input_widget.blockSignals(False) return value = tuple(set_value)[0] @@ -404,10 +489,19 @@ class EnumAttrWidget(_BaseAttrDefWidget): self._multivalue = False super(EnumAttrWidget, self).__init__(*args, **kwargs) + @property + def multiselection(self): + return self.attr_def.multiselection + def _ui_init(self): - input_widget = CustomTextComboBox(self) - combo_delegate = QtWidgets.QStyledItemDelegate(input_widget) - input_widget.setItemDelegate(combo_delegate) + if self.multiselection: + input_widget = MultiSelectionComboBox(self) + + else: + input_widget = CustomTextComboBox(self) + combo_delegate = QtWidgets.QStyledItemDelegate(input_widget) + input_widget.setItemDelegate(combo_delegate) + self._combo_delegate = combo_delegate if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) @@ -419,9 +513,11 @@ class EnumAttrWidget(_BaseAttrDefWidget): if idx >= 0: input_widget.setCurrentIndex(idx) - input_widget.currentIndexChanged.connect(self._on_value_change) + if self.multiselection: + input_widget.value_changed.connect(self._on_value_change) + else: + input_widget.currentIndexChanged.connect(self._on_value_change) - self._combo_delegate = combo_delegate self._input_widget = input_widget self.main_layout.addWidget(input_widget, 0) @@ -434,17 +530,40 @@ class EnumAttrWidget(_BaseAttrDefWidget): self.value_changed.emit(new_value, self.attr_def.id) def current_value(self): + if self.multiselection: + return self._input_widget.value() idx = self._input_widget.currentIndex() return self._input_widget.itemData(idx) + def _multiselection_multivalue_prep(self, values): + final = None + multivalue = False + for value in values: + value = set(value) + if final is None: + final = value + elif multivalue or final != value: + final |= value + multivalue = True + return list(final), multivalue + def set_value(self, value, multivalue=False): if multivalue: - set_value = set(value) - if len(set_value) == 1: - multivalue = False - value = tuple(set_value)[0] + if self.multiselection: + value, multivalue = self._multiselection_multivalue_prep( + value) + else: + set_value = set(value) + if len(set_value) == 1: + multivalue = False + value = tuple(set_value)[0] - if not multivalue: + if self.multiselection: + self._input_widget.blockSignals(True) + self._input_widget.set_value(value) + self._input_widget.blockSignals(False) + + elif not multivalue: idx = self._input_widget.findData(value) cur_idx = self._input_widget.currentIndex() if idx != cur_idx and idx >= 0: @@ -489,7 +608,7 @@ class UnknownAttrWidget(_BaseAttrDefWidget): class HiddenAttrWidget(_BaseAttrDefWidget): def _ui_init(self): self.setVisible(False) - self._value = None + self._value = self.attr_def.default self._multivalue = False def setVisible(self, visible): diff --git a/openpype/tools/ayon_launcher/abstract.py b/openpype/tools/ayon_launcher/abstract.py new file mode 100644 index 0000000000..95fe2b2c8d --- /dev/null +++ b/openpype/tools/ayon_launcher/abstract.py @@ -0,0 +1,307 @@ +from abc import ABCMeta, abstractmethod + +import six + + +@six.add_metaclass(ABCMeta) +class AbstractLauncherCommon(object): + @abstractmethod + def register_event_callback(self, topic, callback): + """Register event callback. + + Listen for events with given topic. + + Args: + topic (str): Name of topic. + callback (Callable): Callback that will be called when event + is triggered. + """ + + pass + + +class AbstractLauncherBackend(AbstractLauncherCommon): + @abstractmethod + def emit_event(self, topic, data=None, source=None): + """Emit event. + + Args: + topic (str): Event topic used for callbacks filtering. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + + pass + + @abstractmethod + def get_project_settings(self, project_name): + """Project settings for current project. + + Args: + project_name (Union[str, None]): Project name. + + Returns: + dict[str, Any]: Project settings. + """ + + pass + + @abstractmethod + def get_project_entity(self, project_name): + """Get project entity by name. + + Args: + project_name (str): Project name. + + Returns: + dict[str, Any]: Project entity data. + """ + + pass + + @abstractmethod + def get_folder_entity(self, project_name, folder_id): + """Get folder entity by id. + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + + Returns: + dict[str, Any]: Folder entity data. + """ + + pass + + @abstractmethod + def get_task_entity(self, project_name, task_id): + """Get task entity by id. + + Args: + project_name (str): Project name. + task_id (str): Task id. + + Returns: + dict[str, Any]: Task entity data. + """ + + pass + + +class AbstractLauncherFrontEnd(AbstractLauncherCommon): + # Entity items for UI + @abstractmethod + def get_project_items(self, sender=None): + """Project items for all projects. + + This function may trigger events 'projects.refresh.started' and + 'projects.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of project items in UI elements. + + Args: + sender (str): Who requested folder items. + + Returns: + list[ProjectItem]: Minimum possible information needed + for visualisation of folder hierarchy. + """ + + pass + + @abstractmethod + def get_folder_items(self, project_name, sender=None): + """Folder items to visualize project hierarchy. + + This function may trigger events 'folders.refresh.started' and + 'folders.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of folder items in UI elements. + + Args: + project_name (str): Project name. + sender (str): Who requested folder items. + + Returns: + list[FolderItem]: Minimum possible information needed + for visualisation of folder hierarchy. + """ + + pass + + @abstractmethod + def get_task_items(self, project_name, folder_id, sender=None): + """Task items. + + This function may trigger events 'tasks.refresh.started' and + 'tasks.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of task items in UI elements. + + Args: + project_name (str): Project name. + folder_id (str): Folder ID for which are tasks requested. + sender (str): Who requested folder items. + + Returns: + list[TaskItem]: Minimum possible information needed + for visualisation of tasks. + """ + + pass + + @abstractmethod + def get_selected_project_name(self): + """Selected project name. + + Returns: + Union[str, None]: Selected project name. + """ + + pass + + @abstractmethod + def get_selected_folder_id(self): + """Selected folder id. + + Returns: + Union[str, None]: Selected folder id. + """ + + pass + + @abstractmethod + def get_selected_task_id(self): + """Selected task id. + + Returns: + Union[str, None]: Selected task id. + """ + + pass + + @abstractmethod + def get_selected_task_name(self): + """Selected task name. + + Returns: + Union[str, None]: Selected task name. + """ + + pass + + @abstractmethod + def get_selected_context(self): + """Get whole selected context. + + Example: + { + "project_name": self.get_selected_project_name(), + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + } + + Returns: + dict[str, Union[str, None]]: Selected context. + """ + + pass + + @abstractmethod + def set_selected_project(self, project_name): + """Change selected folder. + + Args: + project_name (Union[str, None]): Project nameor None if no project + is selected. + """ + + pass + + @abstractmethod + def set_selected_folder(self, folder_id): + """Change selected folder. + + Args: + folder_id (Union[str, None]): Folder id or None if no folder + is selected. + """ + + pass + + @abstractmethod + def set_selected_task(self, task_id, task_name): + """Change selected task. + + Args: + task_id (Union[str, None]): Task id or None if no task + is selected. + task_name (Union[str, None]): Task name or None if no task + is selected. + """ + + pass + + # Actions + @abstractmethod + def get_action_items(self, project_name, folder_id, task_id): + """Get action items for given context. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[ActionItem]: List of action items that should be shown + for given context. + """ + + pass + + @abstractmethod + def trigger_action(self, project_name, folder_id, task_id, action_id): + """Trigger action on given context. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + action_id (str): Action identifier. + """ + + pass + + @abstractmethod + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_ids, enabled + ): + """This is application action related to force not open last workfile. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + action_id (Iterable[str]): Action identifiers. + enabled (bool): New value of force not open workfile. + """ + + pass + + @abstractmethod + def refresh(self): + """Refresh everything, models, ui etc. + + Triggers 'controller.refresh.started' event at the beginning and + 'controller.refresh.finished' at the end. + """ + + pass + + @abstractmethod + def refresh_actions(self): + """Refresh actions and all related data. + + Triggers 'controller.refresh.actions.started' event at the beginning + and 'controller.refresh.actions.finished' at the end. + """ + + pass diff --git a/openpype/tools/ayon_launcher/control.py b/openpype/tools/ayon_launcher/control.py new file mode 100644 index 0000000000..36c0536422 --- /dev/null +++ b/openpype/tools/ayon_launcher/control.py @@ -0,0 +1,161 @@ +from openpype.lib import Logger +from openpype.lib.events import QueuedEventSystem +from openpype.settings import get_project_settings +from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel + +from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend +from .models import LauncherSelectionModel, ActionsModel + + +class BaseLauncherController( + AbstractLauncherFrontEnd, AbstractLauncherBackend +): + def __init__(self): + self._project_settings = {} + self._event_system = None + self._log = None + + self._selection_model = LauncherSelectionModel(self) + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._actions_model = ActionsModel(self) + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + @property + def event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + # Events system + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self.event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self.event_system.add_callback(topic, callback) + + # Entity items for UI + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender) + + # Project settings for applications actions + def get_project_settings(self, project_name): + if project_name in self._project_settings: + return self._project_settings[project_name] + settings = get_project_settings(project_name) + self._project_settings[project_name] = settings + return settings + + # Entity for backend + def get_project_entity(self, project_name): + return self._projects_model.get_project_entity(project_name) + + def get_folder_entity(self, project_name, folder_id): + return self._hierarchy_model.get_folder_entity( + project_name, folder_id) + + def get_task_entity(self, project_name, task_id): + return self._hierarchy_model.get_task_entity(project_name, task_id) + + # Selection methods + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + def get_selected_context(self): + return { + "project_name": self.get_selected_project_name(), + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + } + + # Actions + def get_action_items(self, project_name, folder_id, task_id): + return self._actions_model.get_action_items( + project_name, folder_id, task_id) + + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_ids, enabled + ): + self._actions_model.set_application_force_not_open_workfile( + project_name, folder_id, task_id, action_ids, enabled + ) + + def trigger_action(self, project_name, folder_id, task_id, identifier): + self._actions_model.trigger_action( + project_name, folder_id, task_id, identifier) + + # General methods + def refresh(self): + self._emit_event("controller.refresh.started") + + self._project_settings = {} + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._actions_model.refresh() + self._projects_model.refresh() + + self._emit_event("controller.refresh.finished") + + def refresh_actions(self): + self._emit_event("controller.refresh.actions.started") + + # Refresh project settings (used for actions discovery) + self._project_settings = {} + # Refresh projects - they define applications + self._projects_model.reset() + # Refresh actions + self._actions_model.refresh() + + self._emit_event("controller.refresh.actions.finished") + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") diff --git a/openpype/tools/ayon_launcher/models/__init__.py b/openpype/tools/ayon_launcher/models/__init__.py new file mode 100644 index 0000000000..1bc60c85f0 --- /dev/null +++ b/openpype/tools/ayon_launcher/models/__init__.py @@ -0,0 +1,8 @@ +from .actions import ActionsModel +from .selection import LauncherSelectionModel + + +__all__ = ( + "ActionsModel", + "LauncherSelectionModel", +) diff --git a/openpype/tools/ayon_launcher/models/actions.py b/openpype/tools/ayon_launcher/models/actions.py new file mode 100644 index 0000000000..d7c4219dc2 --- /dev/null +++ b/openpype/tools/ayon_launcher/models/actions.py @@ -0,0 +1,509 @@ +import os + +from openpype import resources +from openpype.lib import Logger, OpenPypeSettingsRegistry +from openpype.pipeline.actions import ( + discover_launcher_actions, + LauncherAction, +) + + +# class Action: +# def __init__(self, label, icon=None, identifier=None): +# self._label = label +# self._icon = icon +# self._callbacks = [] +# self._identifier = identifier or uuid.uuid4().hex +# self._checked = True +# self._checkable = False +# +# def set_checked(self, checked): +# self._checked = checked +# +# def set_checkable(self, checkable): +# self._checkable = checkable +# +# def set_label(self, label): +# self._label = label +# +# def add_callback(self, callback): +# self._callbacks = callback +# +# +# class Menu: +# def __init__(self, label, icon=None): +# self.label = label +# self.icon = icon +# self._actions = [] +# +# def add_action(self, action): +# self._actions.append(action) + + +class ApplicationAction(LauncherAction): + """Action to launch an application. + + Application action based on 'ApplicationManager' system. + + Handling of applications in launcher is not ideal and should be completely + redone from scratch. This is just a temporary solution to keep backwards + compatibility with OpenPype launcher. + + Todos: + Move handling of errors to frontend. + """ + + # Application object + application = None + # Action attributes + name = None + label = None + label_variant = None + group = None + icon = None + color = None + order = 0 + data = {} + project_settings = {} + project_entities = {} + + _log = None + required_session_keys = ( + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK" + ) + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + def is_compatible(self, session): + for key in self.required_session_keys: + if not session.get(key): + return False + + project_name = session["AVALON_PROJECT"] + project_entity = self.project_entities[project_name] + apps = project_entity["attrib"].get("applications") + if not apps or self.application.full_name not in apps: + return False + + project_settings = self.project_settings[project_name] + only_available = project_settings["applications"]["only_available"] + if only_available and not self.application.find_executable(): + return False + return True + + def _show_message_box(self, title, message, details=None): + from qtpy import QtWidgets, QtGui + from openpype import style + + dialog = QtWidgets.QMessageBox() + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + dialog.setWindowIcon(icon) + dialog.setStyleSheet(style.load_stylesheet()) + dialog.setWindowTitle(title) + dialog.setText(message) + if details: + dialog.setDetailedText(details) + dialog.exec_() + + def process(self, session, **kwargs): + """Process the full Application action""" + + from openpype.lib import ( + ApplictionExecutableNotFound, + ApplicationLaunchFailed, + ) + + project_name = session["AVALON_PROJECT"] + asset_name = session["AVALON_ASSET"] + task_name = session["AVALON_TASK"] + try: + self.application.launch( + project_name=project_name, + asset_name=asset_name, + task_name=task_name, + **self.data + ) + + except ApplictionExecutableNotFound as exc: + details = exc.details + msg = exc.msg + log_msg = str(msg) + if details: + log_msg += "\n" + details + self.log.warning(log_msg) + self._show_message_box( + "Application executable not found", msg, details + ) + + except ApplicationLaunchFailed as exc: + msg = str(exc) + self.log.warning(msg, exc_info=True) + self._show_message_box("Application launch failed", msg) + + +class ActionItem: + """Item representing single action to trigger. + + Todos: + Get rid of application specific logic. + + Args: + identifier (str): Unique identifier of action item. + label (str): Action label. + variant_label (Union[str, None]): Variant label, full label is + concatenated with space. Actions are grouped under single + action if it has same 'label' and have set 'variant_label'. + icon (dict[str, str]): Icon definition. + order (int): Action ordering. + is_application (bool): Is action application action. + force_not_open_workfile (bool): Force not open workfile. Application + related. + full_label (Optional[str]): Full label, if not set it is generated + from 'label' and 'variant_label'. + """ + + def __init__( + self, + identifier, + label, + variant_label, + icon, + order, + is_application, + force_not_open_workfile, + full_label=None + ): + self.identifier = identifier + self.label = label + self.variant_label = variant_label + self.icon = icon + self.order = order + self.is_application = is_application + self.force_not_open_workfile = force_not_open_workfile + self._full_label = full_label + + def copy(self): + return self.from_data(self.to_data()) + + @property + def full_label(self): + if self._full_label is None: + if self.variant_label: + self._full_label = " ".join([self.label, self.variant_label]) + else: + self._full_label = self.label + return self._full_label + + def to_data(self): + return { + "identifier": self.identifier, + "label": self.label, + "variant_label": self.variant_label, + "icon": self.icon, + "order": self.order, + "is_application": self.is_application, + "force_not_open_workfile": self.force_not_open_workfile, + "full_label": self._full_label, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +def get_action_icon(action): + """Get action icon info. + + Args: + action (LacunherAction): Action instance. + + Returns: + dict[str, str]: Icon info. + """ + + icon = action.icon + if not icon: + return { + "type": "awesome-font", + "name": "fa.cube", + "color": "white" + } + + if isinstance(icon, dict): + return icon + + icon_path = resources.get_resource(icon) + if not os.path.exists(icon_path): + try: + icon_path = icon.format(resources.RESOURCES_DIR) + except Exception: + pass + + if os.path.exists(icon_path): + return { + "type": "path", + "path": icon_path, + } + + return { + "type": "awesome-font", + "name": icon, + "color": action.color or "white" + } + + +class ActionsModel: + """Actions model. + + Args: + controller (AbstractLauncherBackend): Controller instance. + """ + + _not_open_workfile_reg_key = "force_not_open_workfile" + + def __init__(self, controller): + self._controller = controller + + self._log = None + + self._discovered_actions = None + self._actions = None + self._action_items = {} + + self._launcher_tool_reg = OpenPypeSettingsRegistry("launcher_tool") + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + def refresh(self): + self._discovered_actions = None + self._actions = None + self._action_items = {} + + self._controller.emit_event("actions.refresh.started") + self._get_action_objects() + self._controller.emit_event("actions.refresh.finished") + + def get_action_items(self, project_name, folder_id, task_id): + """Get actions for project. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[ActionItem]: List of actions. + """ + + not_open_workfile_actions = self._get_no_last_workfile_for_context( + project_name, folder_id, task_id) + session = self._prepare_session(project_name, folder_id, task_id) + output = [] + action_items = self._get_action_items(project_name) + for identifier, action in self._get_action_objects().items(): + if not action.is_compatible(session): + continue + + action_item = action_items[identifier] + # Handling of 'force_not_open_workfile' for applications + if action_item.is_application: + action_item = action_item.copy() + action_item.force_not_open_workfile = ( + not_open_workfile_actions.get(identifier, False) + ) + + output.append(action_item) + return output + + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_ids, enabled + ): + no_workfile_reg_data = self._get_no_last_workfile_reg_data() + project_data = no_workfile_reg_data.setdefault(project_name, {}) + folder_data = project_data.setdefault(folder_id, {}) + task_data = folder_data.setdefault(task_id, {}) + for action_id in action_ids: + task_data[action_id] = enabled + self._launcher_tool_reg.set_item( + self._not_open_workfile_reg_key, no_workfile_reg_data + ) + + def trigger_action(self, project_name, folder_id, task_id, identifier): + session = self._prepare_session(project_name, folder_id, task_id) + failed = False + error_message = None + action_label = identifier + action_items = self._get_action_items(project_name) + try: + action = self._actions[identifier] + action_item = action_items[identifier] + action_label = action_item.full_label + self._controller.emit_event( + "action.trigger.started", + { + "identifier": identifier, + "full_label": action_label, + } + ) + if isinstance(action, ApplicationAction): + per_action = self._get_no_last_workfile_for_context( + project_name, folder_id, task_id + ) + force_not_open_workfile = per_action.get(identifier, False) + if force_not_open_workfile: + action.data["start_last_workfile"] = False + else: + action.data.pop("start_last_workfile", None) + action.process(session) + except Exception as exc: + self.log.warning("Action trigger failed.", exc_info=True) + failed = True + error_message = str(exc) + + self._controller.emit_event( + "action.trigger.finished", + { + "identifier": identifier, + "failed": failed, + "error_message": error_message, + "full_label": action_label, + } + ) + + def _get_no_last_workfile_reg_data(self): + try: + no_workfile_reg_data = self._launcher_tool_reg.get_item( + self._not_open_workfile_reg_key) + except ValueError: + no_workfile_reg_data = {} + self._launcher_tool_reg.set_item( + self._not_open_workfile_reg_key, no_workfile_reg_data) + return no_workfile_reg_data + + def _get_no_last_workfile_for_context( + self, project_name, folder_id, task_id + ): + not_open_workfile_reg_data = self._get_no_last_workfile_reg_data() + return ( + not_open_workfile_reg_data + .get(project_name, {}) + .get(folder_id, {}) + .get(task_id, {}) + ) + + def _prepare_session(self, project_name, folder_id, task_id): + folder_path = None + if folder_id: + folder = self._controller.get_folder_entity( + project_name, folder_id) + if folder: + folder_path = folder["path"] + + task_name = None + if task_id: + task = self._controller.get_task_entity(project_name, task_id) + if task: + task_name = task["name"] + + return { + "AVALON_PROJECT": project_name, + "AVALON_ASSET": folder_path, + "AVALON_TASK": task_name, + } + + def _get_discovered_action_classes(self): + if self._discovered_actions is None: + self._discovered_actions = ( + discover_launcher_actions() + + self._get_applications_action_classes() + ) + return self._discovered_actions + + def _get_action_objects(self): + if self._actions is None: + actions = {} + for cls in self._get_discovered_action_classes(): + obj = cls() + identifier = getattr(obj, "identifier", None) + if identifier is None: + identifier = cls.__name__ + actions[identifier] = obj + self._actions = actions + return self._actions + + def _get_action_items(self, project_name): + action_items = self._action_items.get(project_name) + if action_items is not None: + return action_items + + project_entity = None + if project_name: + project_entity = self._controller.get_project_entity(project_name) + project_settings = self._controller.get_project_settings(project_name) + + action_items = {} + for identifier, action in self._get_action_objects().items(): + is_application = isinstance(action, ApplicationAction) + if is_application: + action.project_entities[project_name] = project_entity + action.project_settings[project_name] = project_settings + label = action.label or identifier + variant_label = getattr(action, "label_variant", None) + icon = get_action_icon(action) + item = ActionItem( + identifier, + label, + variant_label, + icon, + action.order, + is_application, + False + ) + action_items[identifier] = item + self._action_items[project_name] = action_items + return action_items + + def _get_applications_action_classes(self): + from openpype.lib.applications import ( + CUSTOM_LAUNCH_APP_GROUPS, + ApplicationManager, + ) + + actions = [] + + manager = ApplicationManager() + for full_name, application in manager.applications.items(): + if ( + application.group.name in CUSTOM_LAUNCH_APP_GROUPS + or not application.enabled + ): + continue + + action = type( + "app_{}".format(full_name), + (ApplicationAction,), + { + "identifier": "application.{}".format(full_name), + "application": application, + "name": application.name, + "label": application.group.label, + "label_variant": application.label, + "group": None, + "icon": application.icon, + "color": getattr(application, "color", None), + "order": getattr(application, "order", None) or 0, + "data": {} + } + ) + actions.append(action) + return actions diff --git a/openpype/tools/ayon_launcher/models/selection.py b/openpype/tools/ayon_launcher/models/selection.py new file mode 100644 index 0000000000..b156d2084c --- /dev/null +++ b/openpype/tools/ayon_launcher/models/selection.py @@ -0,0 +1,72 @@ +class LauncherSelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "launcher.selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_name = None + self._task_id = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if project_name == self._project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) diff --git a/openpype/tools/ayon_launcher/ui/__init__.py b/openpype/tools/ayon_launcher/ui/__init__.py new file mode 100644 index 0000000000..da30c84656 --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import LauncherWindow + + +__all__ = ( + "LauncherWindow", +) diff --git a/openpype/tools/ayon_launcher/ui/actions_widget.py b/openpype/tools/ayon_launcher/ui/actions_widget.py new file mode 100644 index 0000000000..2a1a06695d --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/actions_widget.py @@ -0,0 +1,476 @@ +import time +import collections + +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.flickcharm import FlickCharm +from openpype.tools.ayon_utils.widgets import get_qt_icon + +from .resources import get_options_image_path + +ANIMATION_LEN = 7 + +ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 +ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2 +ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 +FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 + + +def _variant_label_sort_getter(action_item): + """Get variant label value for sorting. + + Make sure the output value is a string. + + Args: + action_item (ActionItem): Action item. + + Returns: + str: Variant label or empty string. + """ + + return action_item.variant_label or "" + + +class ActionsQtModel(QtGui.QStandardItemModel): + """Qt model for actions. + + Args: + controller (AbstractLauncherFrontEnd): Controller instance. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(ActionsQtModel, self).__init__() + + controller.register_event_callback( + "selection.project.changed", + self._on_selection_project_changed, + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_selection_folder_changed, + ) + controller.register_event_callback( + "selection.task.changed", + self._on_selection_task_changed, + ) + + self._controller = controller + + self._items_by_id = {} + self._action_items_by_id = {} + self._groups_by_id = {} + + self._selected_project_name = None + self._selected_folder_id = None + self._selected_task_id = None + + def get_selected_project_name(self): + return self._selected_project_name + + def get_selected_folder_id(self): + return self._selected_folder_id + + def get_selected_task_id(self): + return self._selected_task_id + + def get_group_items(self, action_id): + return self._groups_by_id[action_id] + + def get_item_by_id(self, action_id): + return self._items_by_id.get(action_id) + + def get_action_item_by_id(self, action_id): + return self._action_items_by_id.get(action_id) + + def _clear_items(self): + self._items_by_id = {} + self._action_items_by_id = {} + self._groups_by_id = {} + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + + def refresh(self): + items = self._controller.get_action_items( + self._selected_project_name, + self._selected_folder_id, + self._selected_task_id, + ) + if not items: + self._clear_items() + self.refreshed.emit() + return + + root_item = self.invisibleRootItem() + + all_action_items_info = [] + items_by_label = collections.defaultdict(list) + for item in items: + if not item.variant_label: + all_action_items_info.append((item, False)) + else: + items_by_label[item.label].append(item) + + groups_by_id = {} + for action_items in items_by_label.values(): + action_items.sort(key=_variant_label_sort_getter, reverse=True) + first_item = next(iter(action_items)) + all_action_items_info.append((first_item, len(action_items) > 1)) + groups_by_id[first_item.identifier] = action_items + + new_items = [] + items_by_id = {} + action_items_by_id = {} + for action_item_info in all_action_items_info: + action_item, is_group = action_item_info + icon = get_qt_icon(action_item.icon) + if is_group: + label = action_item.label + else: + label = action_item.full_label + + item = self._items_by_id.get(action_item.identifier) + if item is None: + item = QtGui.QStandardItem() + item.setData(action_item.identifier, ACTION_ID_ROLE) + new_items.append(item) + + item.setFlags(QtCore.Qt.ItemIsEnabled) + item.setData(label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(is_group, ACTION_IS_GROUP_ROLE) + item.setData(action_item.order, ACTION_SORT_ROLE) + item.setData( + action_item.is_application, ACTION_IS_APPLICATION_ROLE) + item.setData( + action_item.force_not_open_workfile, + FORCE_NOT_OPEN_WORKFILE_ROLE) + items_by_id[action_item.identifier] = item + action_items_by_id[action_item.identifier] = action_item + + if new_items: + root_item.appendRows(new_items) + + to_remove = set(self._items_by_id.keys()) - set(items_by_id.keys()) + for identifier in to_remove: + item = self._items_by_id.pop(identifier) + self._action_items_by_id.pop(identifier) + root_item.removeRow(item.row()) + + self._groups_by_id = groups_by_id + self._items_by_id = items_by_id + self._action_items_by_id = action_items_by_id + self.refreshed.emit() + + def _on_selection_project_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = None + self._selected_task_id = None + self.refresh() + + def _on_selection_folder_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = None + self.refresh() + + def _on_selection_task_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + self.refresh() + + +class ActionDelegate(QtWidgets.QStyledItemDelegate): + _cached_extender = {} + + def __init__(self, *args, **kwargs): + super(ActionDelegate, self).__init__(*args, **kwargs) + self._anim_start_color = QtGui.QColor(178, 255, 246) + self._anim_end_color = QtGui.QColor(5, 44, 50) + + def _draw_animation(self, painter, option, index): + grid_size = option.widget.gridSize() + x_offset = int( + (grid_size.width() / 2) + - (option.rect.width() / 2) + ) + item_x = option.rect.x() - x_offset + rect_offset = grid_size.width() / 20 + size = grid_size.width() - (rect_offset * 2) + anim_rect = QtCore.QRect( + item_x + rect_offset, + option.rect.y() + rect_offset, + size, + size + ) + + painter.save() + + painter.setBrush(QtCore.Qt.transparent) + + gradient = QtGui.QConicalGradient() + gradient.setCenter(QtCore.QPointF(anim_rect.center())) + gradient.setColorAt(0, self._anim_start_color) + gradient.setColorAt(1, self._anim_end_color) + + time_diff = time.time() - index.data(ANIMATION_START_ROLE) + + # Repeat 4 times + part_anim = 2.5 + part_time = time_diff % part_anim + offset = (part_time / part_anim) * 360 + angle = (offset + 90) % 360 + + gradient.setAngle(-angle) + + pen = QtGui.QPen(QtGui.QBrush(gradient), rect_offset) + pen.setCapStyle(QtCore.Qt.RoundCap) + painter.setPen(pen) + painter.drawArc( + anim_rect, + -16 * (angle + 10), + -16 * offset + ) + + painter.restore() + + @classmethod + def _get_extender_pixmap(cls, size): + pix = cls._cached_extender.get(size) + if pix is not None: + return pix + pix = QtGui.QPixmap(get_options_image_path()).scaled( + size, size, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + cls._cached_extender[size] = pix + return pix + + def paint(self, painter, option, index): + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + if index.data(ANIMATION_STATE_ROLE): + self._draw_animation(painter, option, index) + + super(ActionDelegate, self).paint(painter, option, index) + + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + rect = QtCore.QRectF( + option.rect.x(), option.rect.height(), 5, 5) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(QtGui.QColor(200, 0, 0)) + painter.drawEllipse(rect) + + if not index.data(ACTION_IS_GROUP_ROLE): + return + + grid_size = option.widget.gridSize() + x_offset = int( + (grid_size.width() / 2) + - (option.rect.width() / 2) + ) + item_x = option.rect.x() - x_offset + + tenth_size = int(grid_size.width() / 10) + extender_size = int(tenth_size * 2.4) + + extender_x = item_x + tenth_size + extender_y = option.rect.y() + tenth_size + + pix = self._get_extender_pixmap(extender_size) + painter.drawPixmap(extender_x, extender_y, pix) + + +class ActionsWidget(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(ActionsWidget, self).__init__(parent) + + self._controller = controller + + view = QtWidgets.QListView(self) + view.setProperty("mode", "icon") + view.setObjectName("IconView") + view.setViewMode(QtWidgets.QListView.IconMode) + view.setResizeMode(QtWidgets.QListView.Adjust) + view.setSelectionMode(QtWidgets.QListView.NoSelection) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + view.setWrapping(True) + view.setGridSize(QtCore.QSize(70, 75)) + view.setIconSize(QtCore.QSize(30, 30)) + view.setSpacing(0) + view.setWordWrap(True) + + # Make view flickable + flick = FlickCharm(parent=view) + flick.activateOn(view) + + model = ActionsQtModel(controller) + + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setSortRole(ACTION_SORT_ROLE) + + proxy_model.setSourceModel(model) + view.setModel(proxy_model) + + delegate = ActionDelegate(self) + view.setItemDelegate(delegate) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view) + + animation_timer = QtCore.QTimer() + animation_timer.setInterval(40) + animation_timer.timeout.connect(self._on_animation) + + view.clicked.connect(self._on_clicked) + view.customContextMenuRequested.connect(self._on_context_menu) + model.refreshed.connect(self._on_model_refresh) + + self._animated_items = set() + self._animation_timer = animation_timer + + self._context_menu = None + + self._flick = flick + self._view = view + self._model = model + self._proxy_model = proxy_model + + self._set_row_height(1) + + def refresh(self): + self._model.refresh() + + def _set_row_height(self, rows): + self.setMinimumHeight(rows * 75) + + def _on_model_refresh(self): + self._proxy_model.sort(0) + + def _on_animation(self): + time_now = time.time() + for action_id in tuple(self._animated_items): + item = self._model.get_item_by_id(action_id) + if item is None: + self._animated_items.discard(action_id) + continue + + start_time = item.data(ANIMATION_START_ROLE) + if start_time is None or (time_now - start_time) > ANIMATION_LEN: + item.setData(0, ANIMATION_STATE_ROLE) + self._animated_items.discard(action_id) + + if not self._animated_items: + self._animation_timer.stop() + + self.update() + + def _start_animation(self, index): + # Offset refresh timout + model_index = self._proxy_model.mapToSource(index) + if not model_index.isValid(): + return + action_id = model_index.data(ACTION_ID_ROLE) + self._model.setData(model_index, time.time(), ANIMATION_START_ROLE) + self._model.setData(model_index, 1, ANIMATION_STATE_ROLE) + self._animated_items.add(action_id) + self._animation_timer.start() + + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self._view.indexAt(point) + if not index.isValid(): + return + + if not index.data(ACTION_IS_APPLICATION_ROLE): + return + + menu = QtWidgets.QMenu(self._view) + checkbox = QtWidgets.QCheckBox( + "Skip opening last workfile.", menu) + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + checkbox.setChecked(True) + + action_id = index.data(ACTION_ID_ROLE) + is_group = index.data(ACTION_IS_GROUP_ROLE) + if is_group: + action_items = self._model.get_group_items(action_id) + else: + action_items = [self._model.get_action_item_by_id(action_id)] + action_ids = {action_item.identifier for action_item in action_items} + checkbox.stateChanged.connect( + lambda: self._on_checkbox_changed( + action_ids, checkbox.isChecked() + ) + ) + action = QtWidgets.QWidgetAction(menu) + action.setDefaultWidget(checkbox) + + menu.addAction(action) + + self._context_menu = menu + global_point = self.mapToGlobal(point) + menu.exec_(global_point) + self._context_menu = None + + def _on_checkbox_changed(self, action_ids, is_checked): + if self._context_menu is not None: + self._context_menu.close() + + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + self._controller.set_application_force_not_open_workfile( + project_name, folder_id, task_id, action_ids, is_checked) + self._model.refresh() + + def _on_clicked(self, index): + if not index or not index.isValid(): + return + + is_group = index.data(ACTION_IS_GROUP_ROLE) + action_id = index.data(ACTION_ID_ROLE) + + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + + if not is_group: + self._controller.trigger_action( + project_name, folder_id, task_id, action_id + ) + self._start_animation(index) + return + + action_items = self._model.get_group_items(action_id) + + menu = QtWidgets.QMenu(self) + actions_mapping = {} + + for action_item in action_items: + menu_action = QtWidgets.QAction(action_item.full_label) + menu.addAction(menu_action) + actions_mapping[menu_action] = action_item + + result = menu.exec_(QtGui.QCursor.pos()) + if not result: + return + + action_item = actions_mapping[result] + + self._controller.trigger_action( + project_name, folder_id, task_id, action_item.identifier + ) + self._start_animation(index) diff --git a/openpype/tools/ayon_launcher/ui/hierarchy_page.py b/openpype/tools/ayon_launcher/ui/hierarchy_page.py new file mode 100644 index 0000000000..d56d43fdec --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/hierarchy_page.py @@ -0,0 +1,106 @@ +import qtawesome +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import ( + PlaceholderLineEdit, + SquareButton, + RefreshButton, +) +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) + + +class HierarchyPage(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(HierarchyPage, self).__init__(parent) + + # Header + header_widget = QtWidgets.QWidget(self) + + btn_back_icon = qtawesome.icon("fa.angle-left", color="white") + btn_back = SquareButton(header_widget) + btn_back.setIcon(btn_back_icon) + + projects_combobox = ProjectsCombobox(controller, header_widget) + + refresh_btn = RefreshButton(header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(btn_back, 0) + header_layout.addWidget(projects_combobox, 1) + header_layout.addWidget(refresh_btn, 0) + + # Body - Folders + Tasks selection + content_body = QtWidgets.QSplitter(self) + content_body.setContentsMargins(0, 0, 0, 0) + content_body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + content_body.setOrientation(QtCore.Qt.Horizontal) + + # - Folders widget with filter + folders_wrapper = QtWidgets.QWidget(content_body) + + folders_filter_text = PlaceholderLineEdit(folders_wrapper) + folders_filter_text.setPlaceholderText("Filter folders...") + + folders_widget = FoldersWidget(controller, folders_wrapper) + + folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper) + folders_wrapper_layout.setContentsMargins(0, 0, 0, 0) + folders_wrapper_layout.addWidget(folders_filter_text, 0) + folders_wrapper_layout.addWidget(folders_widget, 1) + + # - Tasks widget + tasks_widget = TasksWidget(controller, content_body) + + content_body.addWidget(folders_wrapper) + content_body.addWidget(tasks_widget) + content_body.setStretchFactor(0, 100) + content_body.setStretchFactor(1, 65) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(header_widget, 0) + main_layout.addWidget(content_body, 1) + + btn_back.clicked.connect(self._on_back_clicked) + refresh_btn.clicked.connect(self._on_refreh_clicked) + folders_filter_text.textChanged.connect(self._on_filter_text_changed) + + self._is_visible = False + self._controller = controller + + self._btn_back = btn_back + self._projects_combobox = projects_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + + # Post init + projects_combobox.set_listen_to_selection_change(self._is_visible) + + def set_page_visible(self, visible, project_name=None): + if self._is_visible == visible: + return + self._is_visible = visible + self._projects_combobox.set_listen_to_selection_change(visible) + if visible and project_name: + self._projects_combobox.set_selection(project_name) + + def refresh(self): + self._folders_widget.refresh() + self._tasks_widget.refresh() + + def _on_back_clicked(self): + self._controller.set_selected_project(None) + + def _on_refreh_clicked(self): + self._controller.refresh() + + def _on_filter_text_changed(self, text): + self._folders_widget.set_name_filter(text) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py new file mode 100644 index 0000000000..38c7f62bd5 --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -0,0 +1,153 @@ +from qtpy import QtWidgets, QtCore + +from openpype.tools.flickcharm import FlickCharm +from openpype.tools.utils import PlaceholderLineEdit, RefreshButton +from openpype.tools.ayon_utils.widgets import ( + ProjectsQtModel, + ProjectSortFilterProxy, +) +from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER + + +class ProjectIconView(QtWidgets.QListView): + """Styled ListView that allows to toggle between icon and list mode. + + Toggling between the two modes is done by Right Mouse Click. + """ + + IconMode = 0 + ListMode = 1 + + def __init__(self, parent=None, mode=ListMode): + super(ProjectIconView, self).__init__(parent=parent) + + # Workaround for scrolling being super slow or fast when + # toggling between the two visual modes + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.setObjectName("IconView") + + self._mode = None + self.set_mode(mode) + + def set_mode(self, mode): + if mode == self._mode: + return + + self._mode = mode + + if mode == self.IconMode: + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setWrapping(True) + self.setWordWrap(True) + self.setGridSize(QtCore.QSize(151, 90)) + self.setIconSize(QtCore.QSize(50, 50)) + self.setSpacing(0) + self.setAlternatingRowColors(False) + + self.setProperty("mode", "icon") + self.style().polish(self) + + self.verticalScrollBar().setSingleStep(30) + + elif self.ListMode: + self.setProperty("mode", "list") + self.style().polish(self) + + self.setViewMode(QtWidgets.QListView.ListMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setWrapping(False) + self.setWordWrap(False) + self.setIconSize(QtCore.QSize(20, 20)) + self.setGridSize(QtCore.QSize(100, 25)) + self.setSpacing(0) + self.setAlternatingRowColors(False) + + self.verticalScrollBar().setSingleStep(34) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.RightButton: + self.set_mode(int(not self._mode)) + return super(ProjectIconView, self).mousePressEvent(event) + + +class ProjectsWidget(QtWidgets.QWidget): + """Projects Page""" + + refreshed = QtCore.Signal() + + def __init__(self, controller, parent=None): + super(ProjectsWidget, self).__init__(parent=parent) + + header_widget = QtWidgets.QWidget(self) + + projects_filter_text = PlaceholderLineEdit(header_widget) + projects_filter_text.setPlaceholderText("Filter projects...") + + refresh_btn = RefreshButton(header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(projects_filter_text, 1) + header_layout.addWidget(refresh_btn, 0) + + projects_view = ProjectIconView(parent=self) + projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) + flick = FlickCharm(parent=self) + flick.activateOn(projects_view) + projects_model = ProjectsQtModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + + projects_view.setModel(projects_proxy_model) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(header_widget, 0) + main_layout.addWidget(projects_view, 1) + + projects_view.clicked.connect(self._on_view_clicked) + projects_model.refreshed.connect(self.refreshed) + projects_filter_text.textChanged.connect( + self._on_project_filter_change) + refresh_btn.clicked.connect(self._on_refresh_clicked) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + + self._controller = controller + + self._projects_view = projects_view + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + + def has_content(self): + """Model has at least one project. + + Returns: + bool: True if there is any content in the model. + """ + + return self._projects_model.has_content() + + def _on_view_clicked(self, index): + if not index.isValid(): + return + model = index.model() + flags = model.flags(index) + if not flags & QtCore.Qt.ItemIsEnabled: + return + project_name = index.data(QtCore.Qt.DisplayRole) + self._controller.set_selected_project(project_name) + + def _on_project_filter_change(self, text): + self._projects_proxy_model.setFilterFixedString(text) + + def _on_refresh_clicked(self): + self._controller.refresh() + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() diff --git a/openpype/tools/ayon_launcher/ui/resources/__init__.py b/openpype/tools/ayon_launcher/ui/resources/__init__.py new file mode 100644 index 0000000000..27c59af2ba --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/resources/__init__.py @@ -0,0 +1,7 @@ +import os + +RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def get_options_image_path(): + return os.path.join(RESOURCES_DIR, "options.png") diff --git a/openpype/tools/ayon_launcher/ui/resources/options.png b/openpype/tools/ayon_launcher/ui/resources/options.png new file mode 100644 index 0000000000..a9617d0d19 Binary files /dev/null and b/openpype/tools/ayon_launcher/ui/resources/options.png differ diff --git a/openpype/tools/ayon_launcher/ui/window.py b/openpype/tools/ayon_launcher/ui/window.py new file mode 100644 index 0000000000..ffc74a2fdc --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/window.py @@ -0,0 +1,312 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype import style +from openpype import resources + +from openpype.tools.ayon_launcher.control import BaseLauncherController + +from .projects_widget import ProjectsWidget +from .hierarchy_page import HierarchyPage +from .actions_widget import ActionsWidget + + +class LauncherWindow(QtWidgets.QWidget): + """Launcher interface""" + message_interval = 5000 + refresh_interval = 10000 + page_side_anim_interval = 250 + + def __init__(self, controller=None, parent=None): + super(LauncherWindow, self).__init__(parent) + + if controller is None: + controller = BaseLauncherController() + + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("Launcher") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + + self.setStyleSheet(style.load_stylesheet()) + + # Allow minimize + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowMinimizeButtonHint + | QtCore.Qt.WindowCloseButtonHint + ) + + self._controller = controller + + # Main content - Pages & Actions + content_body = QtWidgets.QSplitter(self) + + # Pages + pages_widget = QtWidgets.QWidget(content_body) + + # - First page - Projects + projects_page = ProjectsWidget(controller, pages_widget) + + # - Second page - Hierarchy (folders & tasks) + hierarchy_page = HierarchyPage(controller, pages_widget) + + pages_layout = QtWidgets.QHBoxLayout(pages_widget) + pages_layout.setContentsMargins(0, 0, 0, 0) + pages_layout.addWidget(projects_page, 1) + pages_layout.addWidget(hierarchy_page, 1) + + # Actions + actions_widget = ActionsWidget(controller, content_body) + + # Vertically split Pages and Actions + content_body.setContentsMargins(0, 0, 0, 0) + content_body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + content_body.setOrientation(QtCore.Qt.Vertical) + content_body.addWidget(pages_widget) + content_body.addWidget(actions_widget) + + # Set useful default sizes and set stretch + # for the pages so that is the only one that + # stretches on UI resize. + content_body.setStretchFactor(0, 10) + content_body.setSizes([580, 160]) + + # Footer + footer_widget = QtWidgets.QWidget(self) + + # - Message label + message_label = QtWidgets.QLabel(footer_widget) + + # action_history = ActionHistory(footer_widget) + # action_history.setStatusTip("Show Action History") + + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addWidget(message_label, 1) + # footer_layout.addWidget(action_history, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(content_body, 1) + layout.addWidget(footer_widget, 0) + + message_timer = QtCore.QTimer() + message_timer.setInterval(self.message_interval) + message_timer.setSingleShot(True) + + actions_refresh_timer = QtCore.QTimer() + actions_refresh_timer.setInterval(self.refresh_interval) + + page_slide_anim = QtCore.QVariantAnimation(self) + page_slide_anim.setDuration(self.page_side_anim_interval) + page_slide_anim.setStartValue(0.0) + page_slide_anim.setEndValue(1.0) + page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) + + projects_page.refreshed.connect(self._on_projects_refresh) + message_timer.timeout.connect(self._on_message_timeout) + actions_refresh_timer.timeout.connect( + self._on_actions_refresh_timeout) + page_slide_anim.valueChanged.connect( + self._on_page_slide_value_changed) + page_slide_anim.finished.connect(self._on_page_slide_finished) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change, + ) + controller.register_event_callback( + "action.trigger.started", + self._on_action_trigger_started, + ) + controller.register_event_callback( + "action.trigger.finished", + self._on_action_trigger_finished, + ) + + self._controller = controller + + self._is_on_projects_page = True + self._window_is_active = False + self._refresh_on_activate = False + self._selected_project_name = None + + self._pages_widget = pages_widget + self._pages_layout = pages_layout + self._projects_page = projects_page + self._hierarchy_page = hierarchy_page + self._actions_widget = actions_widget + + self._message_label = message_label + # self._action_history = action_history + + self._message_timer = message_timer + self._actions_refresh_timer = actions_refresh_timer + self._page_slide_anim = page_slide_anim + + hierarchy_page.setVisible(not self._is_on_projects_page) + self.resize(520, 740) + + def showEvent(self, event): + super(LauncherWindow, self).showEvent(event) + self._window_is_active = True + if not self._actions_refresh_timer.isActive(): + self._actions_refresh_timer.start() + self._controller.refresh() + + def closeEvent(self, event): + super(LauncherWindow, self).closeEvent(event) + self._window_is_active = False + self._actions_refresh_timer.stop() + + def changeEvent(self, event): + if event.type() in ( + QtCore.QEvent.Type.WindowStateChange, + QtCore.QEvent.ActivationChange, + ): + is_active = self.isActiveWindow() and not self.isMinimized() + self._window_is_active = is_active + if is_active and self._refresh_on_activate: + self._refresh_on_activate = False + self._on_actions_refresh_timeout() + self._actions_refresh_timer.start() + + super(LauncherWindow, self).changeEvent(event) + + def _on_actions_refresh_timeout(self): + # Stop timer if widget is not visible + if self._window_is_active: + self._controller.refresh_actions() + else: + self._refresh_on_activate = True + + def _echo(self, message): + self._message_label.setText(str(message)) + self._message_timer.start() + + def _on_message_timeout(self): + self._message_label.setText("") + + def _on_project_selection_change(self, event): + project_name = event["project_name"] + self._selected_project_name = project_name + if not project_name: + self._go_to_projects_page() + + elif self._is_on_projects_page: + self._go_to_hierarchy_page(project_name) + + def _on_projects_refresh(self): + # There is nothing to do, we're on projects page + if self._is_on_projects_page: + return + + # No projects were found -> go back to projects page + if not self._projects_page.has_content(): + self._go_to_projects_page() + return + + self._hierarchy_page.refresh() + self._actions_widget.refresh() + + def _on_action_trigger_started(self, event): + self._echo("Running action: {}".format(event["full_label"])) + + def _on_action_trigger_finished(self, event): + if not event["failed"]: + return + self._echo("Failed: {}".format(event["error_message"])) + + def _is_page_slide_anim_running(self): + return ( + self._page_slide_anim.state() == QtCore.QAbstractAnimation.Running + ) + + def _go_to_projects_page(self): + if self._is_on_projects_page: + return + self._is_on_projects_page = True + self._hierarchy_page.set_page_visible(False) + + self._start_page_slide_animation() + + def _go_to_hierarchy_page(self, project_name): + if not self._is_on_projects_page: + return + self._is_on_projects_page = False + self._hierarchy_page.set_page_visible(True, project_name) + + self._start_page_slide_animation() + + def _start_page_slide_animation(self): + if self._is_on_projects_page: + direction = QtCore.QAbstractAnimation.Backward + else: + direction = QtCore.QAbstractAnimation.Forward + self._page_slide_anim.setDirection(direction) + if self._is_page_slide_anim_running(): + return + + layout_spacing = self._pages_layout.spacing() + if self._is_on_projects_page: + hierarchy_geo = self._hierarchy_page.geometry() + projects_geo = QtCore.QRect(hierarchy_geo) + projects_geo.moveRight( + hierarchy_geo.left() - (layout_spacing + 1)) + + self._projects_page.setVisible(True) + + else: + projects_geo = self._projects_page.geometry() + hierarchy_geo = QtCore.QRect(projects_geo) + hierarchy_geo.moveLeft(projects_geo.right() + layout_spacing) + self._hierarchy_page.setVisible(True) + + while self._pages_layout.count(): + self._pages_layout.takeAt(0) + + self._projects_page.setGeometry(projects_geo) + self._hierarchy_page.setGeometry(hierarchy_geo) + + self._page_slide_anim.start() + + def _on_page_slide_value_changed(self, value): + layout_spacing = self._pages_layout.spacing() + content_width = self._pages_widget.width() - layout_spacing + content_height = self._pages_widget.height() + + # Visible widths of other widgets + hierarchy_width = int(content_width * value) + + hierarchy_geo = QtCore.QRect( + content_width - hierarchy_width, 0, content_width, content_height + ) + projects_geo = QtCore.QRect(hierarchy_geo) + projects_geo.moveRight(hierarchy_geo.left() - (layout_spacing + 1)) + + self._projects_page.setGeometry(projects_geo) + self._hierarchy_page.setGeometry(hierarchy_geo) + + def _on_page_slide_finished(self): + self._pages_layout.addWidget(self._projects_page, 1) + self._pages_layout.addWidget(self._hierarchy_page, 1) + self._projects_page.setVisible(self._is_on_projects_page) + self._hierarchy_page.setVisible(not self._is_on_projects_page) + + # def _on_history_action(self, history_data): + # action, session = history_data + # app = QtWidgets.QApplication.instance() + # modifiers = app.keyboardModifiers() + # + # is_control_down = QtCore.Qt.ControlModifier & modifiers + # if is_control_down: + # # Revert to that "session" location + # self.set_session(session) + # else: + # # User is holding control, rerun the action + # self.run_action(action, session=session) diff --git a/openpype/tools/ayon_loader/__init__.py b/openpype/tools/ayon_loader/__init__.py new file mode 100644 index 0000000000..09ecf65f3a --- /dev/null +++ b/openpype/tools/ayon_loader/__init__.py @@ -0,0 +1,6 @@ +from .control import LoaderController + + +__all__ = ( + "LoaderController", +) diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py new file mode 100644 index 0000000000..bf3e81d485 --- /dev/null +++ b/openpype/tools/ayon_loader/abstract.py @@ -0,0 +1,944 @@ +from abc import ABCMeta, abstractmethod +import six + +from openpype.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) + + +class ProductTypeItem: + """Item representing product type. + + Args: + name (str): Product type name. + icon (dict[str, Any]): Product type icon definition. + checked (bool): Is product type checked for filtering. + """ + + def __init__(self, name, icon, checked): + self.name = name + self.icon = icon + self.checked = checked + + def to_data(self): + return { + "name": self.name, + "icon": self.icon, + "checked": self.checked, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ProductItem: + """Product item with it versions. + + Args: + product_id (str): Product id. + product_type (str): Product type. + product_name (str): Product name. + product_icon (dict[str, Any]): Product icon definition. + product_type_icon (dict[str, Any]): Product type icon definition. + product_in_scene (bool): Is product in scene (only when used in DCC). + group_name (str): Group name. + folder_id (str): Folder id. + folder_label (str): Folder label. + version_items (dict[str, VersionItem]): Version items by id. + """ + + def __init__( + self, + product_id, + product_type, + product_name, + product_icon, + product_type_icon, + product_in_scene, + group_name, + folder_id, + folder_label, + version_items, + ): + self.product_id = product_id + self.product_type = product_type + self.product_name = product_name + self.product_icon = product_icon + self.product_type_icon = product_type_icon + self.product_in_scene = product_in_scene + self.group_name = group_name + self.folder_id = folder_id + self.folder_label = folder_label + self.version_items = version_items + + def to_data(self): + return { + "product_id": self.product_id, + "product_type": self.product_type, + "product_name": self.product_name, + "product_icon": self.product_icon, + "product_type_icon": self.product_type_icon, + "product_in_scene": self.product_in_scene, + "group_name": self.group_name, + "folder_id": self.folder_id, + "folder_label": self.folder_label, + "version_items": { + version_id: version_item.to_data() + for version_id, version_item in self.version_items.items() + }, + } + + @classmethod + def from_data(cls, data): + version_items = { + version_id: VersionItem.from_data(version) + for version_id, version in data["version_items"].items() + } + data["version_items"] = version_items + return cls(**data) + + +class VersionItem: + """Version item. + + Object have implemented comparison operators to be sortable. + + Args: + version_id (str): Version id. + version (int): Version. Can be negative when is hero version. + is_hero (bool): Is hero version. + product_id (str): Product id. + thumbnail_id (Union[str, None]): Thumbnail id. + published_time (Union[str, None]): Published time in format + '%Y%m%dT%H%M%SZ'. + author (Union[str, None]): Author. + frame_range (Union[str, None]): Frame range. + duration (Union[int, None]): Duration. + handles (Union[str, None]): Handles. + step (Union[int, None]): Step. + comment (Union[str, None]): Comment. + source (Union[str, None]): Source. + """ + + def __init__( + self, + version_id, + version, + is_hero, + product_id, + thumbnail_id, + published_time, + author, + frame_range, + duration, + handles, + step, + comment, + source, + ): + self.version_id = version_id + self.product_id = product_id + self.thumbnail_id = thumbnail_id + self.version = version + self.is_hero = is_hero + self.published_time = published_time + self.author = author + self.frame_range = frame_range + self.duration = duration + self.handles = handles + self.step = step + self.comment = comment + self.source = source + + def __eq__(self, other): + if not isinstance(other, VersionItem): + return False + return ( + self.is_hero == other.is_hero + and self.version == other.version + and self.version_id == other.version_id + and self.product_id == other.product_id + ) + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + if not isinstance(other, VersionItem): + return False + if ( + other.version == self.version + and self.is_hero + ): + return True + return other.version < self.version + + def to_data(self): + return { + "version_id": self.version_id, + "product_id": self.product_id, + "thumbnail_id": self.thumbnail_id, + "version": self.version, + "is_hero": self.is_hero, + "published_time": self.published_time, + "author": self.author, + "frame_range": self.frame_range, + "duration": self.duration, + "handles": self.handles, + "step": self.step, + "comment": self.comment, + "source": self.source, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class RepreItem: + """Representation item. + + Args: + representation_id (str): Representation id. + representation_name (str): Representation name. + representation_icon (dict[str, Any]): Representation icon definition. + product_name (str): Product name. + folder_label (str): Folder label. + """ + + def __init__( + self, + representation_id, + representation_name, + representation_icon, + product_name, + folder_label + ): + self.representation_id = representation_id + self.representation_name = representation_name + self.representation_icon = representation_icon + self.product_name = product_name + self.folder_label = folder_label + + def to_data(self): + return { + "representation_id": self.representation_id, + "representation_name": self.representation_name, + "representation_icon": self.representation_icon, + "product_name": self.product_name, + "folder_label": self.folder_label, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ActionItem: + """Action item that can be triggered. + + Action item is defined for a specific context. To trigger the action + use 'identifier' and context, it necessary also use 'options'. + + Args: + identifier (str): Action identifier. + label (str): Action label. + icon (dict[str, Any]): Action icon definition. + tooltip (str): Action tooltip. + options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): + Action options. Note: 'qargparse' is considered as deprecated. + order (int): Action order. + project_name (str): Project name. + folder_ids (list[str]): Folder ids. + product_ids (list[str]): Product ids. + version_ids (list[str]): Version ids. + representation_ids (list[str]): Representation ids. + """ + + def __init__( + self, + identifier, + label, + icon, + tooltip, + options, + order, + project_name, + folder_ids, + product_ids, + version_ids, + representation_ids, + ): + self.identifier = identifier + self.label = label + self.icon = icon + self.tooltip = tooltip + self.options = options + self.order = order + self.project_name = project_name + self.folder_ids = folder_ids + self.product_ids = product_ids + self.version_ids = version_ids + self.representation_ids = representation_ids + + def _options_to_data(self): + options = self.options + if not options: + return options + if isinstance(options[0], AbstractAttrDef): + return serialize_attr_defs(options) + # NOTE: Data conversion is not used by default in loader tool. But for + # future development of detached UI tools it would be better to be + # prepared for it. + raise NotImplementedError( + "{}.to_data is not implemented. Use Attribute definitions" + " from 'openpype.lib' instead of 'qargparse'.".format( + self.__class__.__name__ + ) + ) + + def to_data(self): + options = self._options_to_data() + return { + "identifier": self.identifier, + "label": self.label, + "icon": self.icon, + "tooltip": self.tooltip, + "options": options, + "order": self.order, + "project_name": self.project_name, + "folder_ids": self.folder_ids, + "product_ids": self.product_ids, + "version_ids": self.version_ids, + "representation_ids": self.representation_ids, + } + + @classmethod + def from_data(cls, data): + options = data["options"] + if options: + options = deserialize_attr_defs(options) + data["options"] = options + return cls(**data) + + +@six.add_metaclass(ABCMeta) +class _BaseLoaderController(object): + """Base loader controller abstraction. + + Abstract base class that is required for both frontend and backed. + """ + + @abstractmethod + def get_current_context(self): + """Current context is a context of the current scene. + + Example output: + { + "project_name": "MyProject", + "folder_id": "0011223344-5566778-99", + "task_name": "Compositing", + } + + Returns: + dict[str, Union[str, None]]: Context data. + """ + + pass + + @abstractmethod + def reset(self): + """Reset all cached data to reload everything. + + Triggers events "controller.reset.started" and + "controller.reset.finished". + """ + + pass + + # Model wrappers + @abstractmethod + def get_folder_items(self, project_name, sender=None): + """Folder items for a project. + + Args: + project_name (str): Project name. + sender (Optional[str]): Sender who requested the name. + + Returns: + list[FolderItem]: Folder items for the project. + """ + + pass + + # Expected selection helpers + @abstractmethod + def get_expected_selection_data(self): + """Full expected selection information. + + Expected selection is a selection that may not be yet selected in UI + e.g. because of refreshing, this data tell the UI what should be + selected when they finish their refresh. + + Returns: + dict[str, Any]: Expected selection data. + """ + + pass + + @abstractmethod + def set_expected_selection(self, project_name, folder_id): + """Set expected selection. + + Args: + project_name (str): Name of project to be selected. + folder_id (str): Id of folder to be selected. + """ + + pass + + +class BackendLoaderController(_BaseLoaderController): + """Backend loader controller abstraction. + + What backend logic requires from a controller for proper logic. + """ + + @abstractmethod + def emit_event(self, topic, data=None, source=None): + """Emit event with a certain topic, data and source. + + The event should be sent to both frontend and backend. + + Args: + topic (str): Event topic name. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + + pass + + @abstractmethod + def get_loaded_product_ids(self): + """Return set of loaded product ids. + + Returns: + set[str]: Set of loaded product ids. + """ + + pass + + +class FrontendLoaderController(_BaseLoaderController): + @abstractmethod + def register_event_callback(self, topic, callback): + """Register callback for an event topic. + + Args: + topic (str): Event topic name. + callback (func): Callback triggered when the event is emitted. + """ + + pass + + # Expected selection helpers + @abstractmethod + def expected_project_selected(self, project_name): + """Expected project was selected in frontend. + + Args: + project_name (str): Project name. + """ + + pass + + @abstractmethod + def expected_folder_selected(self, folder_id): + """Expected folder was selected in frontend. + + Args: + folder_id (str): Folder id. + """ + + pass + + # Model wrapper calls + @abstractmethod + def get_project_items(self, sender=None): + """Items for all projects available on server. + + Triggers event topics "projects.refresh.started" and + "projects.refresh.finished" with data: + { + "sender": sender + } + + Notes: + Filtering of projects is done in UI. + + Args: + sender (Optional[str]): Sender who requested the items. + + Returns: + list[ProjectItem]: List of project items. + """ + + pass + + @abstractmethod + def get_product_items(self, project_name, folder_ids, sender=None): + """Product items for folder ids. + + Triggers event topics "products.refresh.started" and + "products.refresh.finished" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender + } + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[ProductItem]: List of product items. + """ + + pass + + @abstractmethod + def get_product_item(self, project_name, product_id): + """Receive single product item. + + Args: + project_name (str): Project name. + product_id (str): Product id. + + Returns: + Union[ProductItem, None]: Product info or None if not found. + """ + + pass + + @abstractmethod + def get_product_type_items(self, project_name): + """Product type items for a project. + + Product types have defined if are checked for filtering or not. + + Returns: + list[ProductTypeItem]: List of product type items for a project. + """ + + pass + + @abstractmethod + def get_representation_items( + self, project_name, version_ids, sender=None + ): + """Representation items for version ids. + + Triggers event topics "model.representations.refresh.started" and + "model.representations.refresh.finished" with data: + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender + } + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + list[RepreItem]: List of representation items. + """ + + pass + + @abstractmethod + def get_version_thumbnail_ids(self, project_name, version_ids): + """Get thumbnail ids for version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + dict[str, Union[str, Any]]: Thumbnail id by version id. + """ + + pass + + @abstractmethod + def get_folder_thumbnail_ids(self, project_name, folder_ids): + """Get thumbnail ids for folder ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Union[str, Any]]: Thumbnail id by folder id. + """ + + pass + + @abstractmethod + def get_versions_representation_count( + self, project_name, version_ids, sender=None + ): + """ + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + dict[str, int]: Representation count by version id. + """ + + pass + + @abstractmethod + def get_thumbnail_path(self, project_name, thumbnail_id): + """Get thumbnail path for thumbnail id. + + This method should get a path to a thumbnail based on thumbnail id. + Which probably means to download the thumbnail from server and store + it locally. + + Args: + project_name (str): Project name. + thumbnail_id (str): Thumbnail id. + + Returns: + Union[str, None]: Thumbnail path or None if not found. + """ + + pass + + # Selection model wrapper calls + @abstractmethod + def get_selected_project_name(self): + """Get selected project name. + + The information is based on last selection from UI. + + Returns: + Union[str, None]: Selected project name. + """ + + pass + + @abstractmethod + def get_selected_folder_ids(self): + """Get selected folder ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected folder ids. + """ + + pass + + @abstractmethod + def get_selected_version_ids(self): + """Get selected version ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected version ids. + """ + + pass + + @abstractmethod + def get_selected_representation_ids(self): + """Get selected representation ids. + + The information is based on last selection from UI. + + Returns: + list[str]: Selected representation ids. + """ + + pass + + @abstractmethod + def set_selected_project(self, project_name): + """Set selected project. + + Project selection changed in UI. Method triggers event with topic + "selection.project.changed" with data: + { + "project_name": self._project_name + } + + Args: + project_name (Union[str, None]): Selected project name. + """ + + pass + + @abstractmethod + def set_selected_folders(self, folder_ids): + """Set selected folders. + + Folder selection changed in UI. Method triggers event with topic + "selection.folders.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids + } + + Args: + folder_ids (Iterable[str]): Selected folder ids. + """ + + pass + + @abstractmethod + def set_selected_versions(self, version_ids): + """Set selected versions. + + Version selection changed in UI. Method triggers event with topic + "selection.versions.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "version_ids": version_ids + } + + Args: + version_ids (Iterable[str]): Selected version ids. + """ + + pass + + @abstractmethod + def set_selected_representations(self, repre_ids): + """Set selected representations. + + Representation selection changed in UI. Method triggers event with + topic "selection.representations.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "version_ids": version_ids, + "representation_ids": representation_ids + } + + Args: + repre_ids (Iterable[str]): Selected representation ids. + """ + + pass + + # Load action items + @abstractmethod + def get_versions_action_items(self, project_name, version_ids): + """Action items for versions selection. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + list[ActionItem]: List of action items. + """ + + pass + + @abstractmethod + def get_representations_action_items( + self, project_name, representation_ids + ): + """Action items for representations selection. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: List of action items. + """ + + pass + + @abstractmethod + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + """Trigger action item. + + Triggers event "load.started" with data: + { + "identifier": identifier, + "id": , + } + + And triggers "load.finished" with data: + { + "identifier": identifier, + "id": , + "error_info": [...], + } + + Args: + identifier (str): Action identifier. + options (dict[str, Any]): Action option values from UI. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + representation_ids (Iterable[str]): Representation ids. + """ + + pass + + @abstractmethod + def change_products_group(self, project_name, product_ids, group_name): + """Change group of products. + + Triggers event "products.group.changed" with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name, + } + + Args: + project_name (str): Project name. + product_ids (Iterable[str]): Product ids. + group_name (str): New group name. + """ + + pass + + @abstractmethod + def fill_root_in_source(self, source): + """Fill root in source path. + + Args: + source (Union[str, None]): Source of a published version. Usually + rootless workfile path. + """ + + pass + + # NOTE: Methods 'is_loaded_products_supported' and + # 'is_standard_projects_filter_enabled' are both based on being in host + # or not. Maybe we could implement only single method 'is_in_host'? + @abstractmethod + def is_loaded_products_supported(self): + """Is capable to get information about loaded products. + + Returns: + bool: True if it is supported. + """ + + pass + + @abstractmethod + def is_standard_projects_filter_enabled(self): + """Is standard projects filter enabled. + + This is used for filtering out when loader tool is used in a host. In + that case only current project and library projects should be shown. + + Returns: + bool: Frontend should filter out non-library projects, except + current context project. + """ + + pass + + # Site sync functions + @abstractmethod + def is_site_sync_enabled(self, project_name=None): + """Is site sync enabled. + + Site sync addon can be enabled but can be disabled per project. + + When asked for enabled state without project name, it should return + True if site sync addon is available and enabled. + + Args: + project_name (Optional[str]): Project name. + + Returns: + bool: True if site sync is enabled. + """ + + pass + + @abstractmethod + def get_active_site_icon_def(self, project_name): + """Active site icon definition. + + Args: + project_name (Union[str, None]): Project name. + + Returns: + Union[dict[str, Any], None]: Icon definition or None if site sync + is not enabled for the project. + """ + + pass + + @abstractmethod + def get_remote_site_icon_def(self, project_name): + """Remote site icon definition. + + Args: + project_name (Union[str, None]): Project name. + + Returns: + Union[dict[str, Any], None]: Icon definition or None if site sync + is not enabled for the project. + """ + + pass + + @abstractmethod + def get_version_sync_availability(self, project_name, version_ids): + """Version sync availability. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + dict[str, tuple[int, int]]: Sync availability by version id. + """ + + pass + + @abstractmethod + def get_representations_sync_status( + self, project_name, representation_ids + ): + """Representations sync status. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + dict[str, tuple[int, int]]: Sync status by representation id. + """ + + pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py new file mode 100644 index 0000000000..060cef6661 --- /dev/null +++ b/openpype/tools/ayon_loader/control.py @@ -0,0 +1,410 @@ +import logging +import uuid + +import ayon_api + +from openpype.lib.events import QueuedEventSystem +from openpype.pipeline import Anatomy, get_current_context +from openpype.host import ILoadHost +from openpype.tools.ayon_utils.models import ( + ProjectsModel, + HierarchyModel, + NestedCacheItem, + CacheItem, + ThumbnailsModel, +) + +from .abstract import BackendLoaderController, FrontendLoaderController +from .models import ( + SelectionModel, + ProductsModel, + LoaderActionsModel, + SiteSyncModel +) + + +class ExpectedSelection: + def __init__(self, controller): + self._project_name = None + self._folder_id = None + + self._project_selected = True + self._folder_selected = True + + self._controller = controller + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) + + def set_expected_selection(self, project_name, folder_id): + self._project_name = project_name + self._folder_id = folder_id + + self._project_selected = False + self._folder_selected = False + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + return { + "project": { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + }, + "folder": { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + }, + } + + def is_expected_project_selected(self, project_name): + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + +class LoaderController(BackendLoaderController, FrontendLoaderController): + """ + + Args: + host (Optional[AbstractHost]): Host object. Defaults to None. + """ + + def __init__(self, host=None): + self._log = None + self._host = host + + self._event_system = self._create_event_system() + + self._project_anatomy_cache = NestedCacheItem( + levels=1, lifetime=60) + self._loaded_products_cache = CacheItem( + default_factory=set, lifetime=60) + + self._selection_model = SelectionModel(self) + self._expected_selection = ExpectedSelection(self) + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._products_model = ProductsModel(self) + self._loader_actions_model = LoaderActionsModel(self) + self._thumbnails_model = ThumbnailsModel() + self._site_sync_model = SiteSyncModel(self) + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + # Events system + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def reset(self): + self._emit_event("controller.reset.started") + + project_name = self.get_selected_project_name() + folder_ids = self.get_selected_folder_ids() + + self._project_anatomy_cache.reset() + self._loaded_products_cache.reset() + + self._products_model.reset() + self._hierarchy_model.reset() + self._loader_actions_model.reset() + self._projects_model.reset() + self._thumbnails_model.reset() + self._site_sync_model.reset() + + self._projects_model.refresh() + + if not project_name and not folder_ids: + context = self.get_current_context() + project_name = context["project_name"] + folder_id = context["folder_id"] + self.set_expected_selection(project_name, folder_id) + + self._emit_event("controller.reset.finished") + + # Expected selection helpers + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def set_expected_selection(self, project_name, folder_id): + self._expected_selection.set_expected_selection( + project_name, folder_id + ) + + def expected_project_selected(self, project_name): + self._expected_selection.expected_project_selected(project_name) + + def expected_folder_selected(self, folder_id): + self._expected_selection.expected_folder_selected(folder_id) + + # Entity model wrappers + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_product_items(self, project_name, folder_ids, sender=None): + return self._products_model.get_product_items( + project_name, folder_ids, sender) + + def get_product_item(self, project_name, product_id): + return self._products_model.get_product_item( + project_name, product_id + ) + + def get_product_type_items(self, project_name): + return self._products_model.get_product_type_items(project_name) + + def get_representation_items( + self, project_name, version_ids, sender=None + ): + return self._products_model.get_repre_items( + project_name, version_ids, sender + ) + + def get_versions_representation_count( + self, project_name, version_ids, sender=None + ): + return self._products_model.get_versions_repre_count( + project_name, version_ids, sender + ) + + def get_folder_thumbnail_ids(self, project_name, folder_ids): + return self._thumbnails_model.get_folder_thumbnail_ids( + project_name, folder_ids + ) + + def get_version_thumbnail_ids(self, project_name, version_ids): + return self._thumbnails_model.get_version_thumbnail_ids( + project_name, version_ids + ) + + def get_thumbnail_path(self, project_name, thumbnail_id): + return self._thumbnails_model.get_thumbnail_path( + project_name, thumbnail_id + ) + + def change_products_group(self, project_name, product_ids, group_name): + self._products_model.change_products_group( + project_name, product_ids, group_name + ) + + def get_versions_action_items(self, project_name, version_ids): + return self._loader_actions_model.get_versions_action_items( + project_name, version_ids) + + def get_representations_action_items( + self, project_name, representation_ids): + action_items = ( + self._loader_actions_model.get_representations_action_items( + project_name, representation_ids) + ) + + action_items.extend(self._site_sync_model.get_site_sync_action_items( + project_name, representation_ids) + ) + + return action_items + + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + if self._site_sync_model.is_site_sync_action(identifier): + self._site_sync_model.trigger_action_item( + identifier, + project_name, + representation_ids + ) + return + + self._loader_actions_model.trigger_action_item( + identifier, + options, + project_name, + version_ids, + representation_ids + ) + + # Selection model wrappers + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + # Selection model wrappers + def get_selected_folder_ids(self): + return self._selection_model.get_selected_folder_ids() + + def set_selected_folders(self, folder_ids): + self._selection_model.set_selected_folders(folder_ids) + + def get_selected_version_ids(self): + return self._selection_model.get_selected_version_ids() + + def set_selected_versions(self, version_ids): + self._selection_model.set_selected_versions(version_ids) + + def get_selected_representation_ids(self): + return self._selection_model.get_selected_representation_ids() + + def set_selected_representations(self, repre_ids): + self._selection_model.set_selected_representations(repre_ids) + + def fill_root_in_source(self, source): + project_name = self.get_selected_project_name() + anatomy = self._get_project_anatomy(project_name) + if anatomy is None: + return source + + try: + return anatomy.fill_root(source) + except Exception: + return source + + def get_current_context(self): + if self._host is None: + return { + "project_name": None, + "folder_id": None, + "task_name": None, + } + if hasattr(self._host, "get_current_context"): + context = self._host.get_current_context() + else: + context = get_current_context() + folder_id = None + project_name = context.get("project_name") + asset_name = context.get("asset_name") + if project_name and asset_name: + folder = ayon_api.get_folder_by_path( + project_name, asset_name, fields=["id"] + ) + if folder: + folder_id = folder["id"] + return { + "project_name": project_name, + "folder_id": folder_id, + "task_name": context.get("task_name"), + } + + def get_loaded_product_ids(self): + if self._host is None: + return set() + + context = self.get_current_context() + project_name = context["project_name"] + if not project_name: + return set() + + if not self._loaded_products_cache.is_valid: + if isinstance(self._host, ILoadHost): + containers = self._host.get_containers() + else: + containers = self._host.ls() + repre_ids = set() + for container in containers: + repre_id = container.get("representation") + # Ignore invalid representation ids. + # - invalid representation ids may be available if e.g. is + # opened scene from OpenPype whe 'ObjectId' was used instead + # of 'uuid'. + # NOTE: Server call would crash if there is any invalid id. + # That would cause crash we won't get any information. + try: + uuid.UUID(repre_id) + repre_ids.add(repre_id) + except ValueError: + pass + + product_ids = self._products_model.get_product_ids_by_repre_ids( + project_name, repre_ids + ) + self._loaded_products_cache.update_data(product_ids) + return self._loaded_products_cache.get_data() + + def is_site_sync_enabled(self, project_name=None): + return self._site_sync_model.is_site_sync_enabled(project_name) + + def get_active_site_icon_def(self, project_name): + return self._site_sync_model.get_active_site_icon_def(project_name) + + def get_remote_site_icon_def(self, project_name): + return self._site_sync_model.get_remote_site_icon_def(project_name) + + def get_version_sync_availability(self, project_name, version_ids): + return self._site_sync_model.get_version_sync_availability( + project_name, version_ids + ) + + def get_representations_sync_status( + self, project_name, representation_ids + ): + return self._site_sync_model.get_representations_sync_status( + project_name, representation_ids + ) + + def is_loaded_products_supported(self): + return self._host is not None + + def is_standard_projects_filter_enabled(self): + return self._host is not None + + def _get_project_anatomy(self, project_name): + if not project_name: + return None + cache = self._project_anatomy_cache[project_name] + if not cache.is_valid: + cache.update_data(Anatomy(project_name)) + return cache.get_data() + + def _create_event_system(self): + return QueuedEventSystem() + + def _emit_event(self, topic, data=None): + self._event_system.emit(topic, data or {}, "controller") diff --git a/openpype/tools/ayon_loader/models/__init__.py b/openpype/tools/ayon_loader/models/__init__.py new file mode 100644 index 0000000000..8e640659a0 --- /dev/null +++ b/openpype/tools/ayon_loader/models/__init__.py @@ -0,0 +1,12 @@ +from .selection import SelectionModel +from .products import ProductsModel +from .actions import LoaderActionsModel +from .site_sync import SiteSyncModel + + +__all__ = ( + "SelectionModel", + "ProductsModel", + "LoaderActionsModel", + "SiteSyncModel", +) diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py new file mode 100644 index 0000000000..177335a933 --- /dev/null +++ b/openpype/tools/ayon_loader/models/actions.py @@ -0,0 +1,871 @@ +import sys +import traceback +import inspect +import copy +import collections +import uuid + +from openpype.client import ( + get_project, + get_assets, + get_subsets, + get_versions, + get_representations, +) +from openpype.pipeline.load import ( + discover_loader_plugins, + SubsetLoaderPlugin, + filter_repre_contexts_by_loader, + get_loader_identifier, + load_with_repre_context, + load_with_subset_context, + load_with_subset_contexts, + LoadError, + IncompatibleLoaderError, +) +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ActionItem + +ACTIONS_MODEL_SENDER = "actions.model" +NOT_SET = object() + + +class LoaderActionsModel: + """Model for loader actions. + + This is probably only part of models that requires to use codebase from + 'openpype.client' because of backwards compatibility with loaders logic + which are expecting mongo documents. + + TODOs: + Deprecate 'qargparse' usage in loaders and implement conversion + of 'ActionItem' to data (and 'from_data'). + Use controller to get entities (documents) -> possible only when + loaders are able to handle AYON vs. OpenPype logic. + Add missing site sync logic, and if possible remove it from loaders. + Implement loader actions to replace load plugins. + Ask loader actions to return action items instead of guessing them. + """ + + # Cache loader plugins for some time + # NOTE Set to '0' for development + loaders_cache_lifetime = 30 + + def __init__(self, controller): + self._controller = controller + self._current_context_project = NOT_SET + self._loaders_by_identifier = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + self._product_loaders = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + self._repre_loaders = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) + + def reset(self): + """Reset the model with all cached items.""" + + self._current_context_project = NOT_SET + self._loaders_by_identifier.reset() + self._product_loaders.reset() + self._repre_loaders.reset() + + def get_versions_action_items(self, project_name, version_ids): + """Get action items for given version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + list[ActionItem]: List of action items. + """ + + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_versions( + project_name, + version_ids + ) + return self._get_action_items_for_contexts( + project_name, + version_context_by_id, + repre_context_by_id + ) + + def get_representations_action_items( + self, project_name, representation_ids + ): + """Get action items for given representation ids. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: List of action items. + """ + + ( + product_context_by_id, + repre_context_by_id + ) = self._contexts_for_representations( + project_name, + representation_ids + ) + return self._get_action_items_for_contexts( + project_name, + product_context_by_id, + repre_context_by_id + ) + + def trigger_action_item( + self, + identifier, + options, + project_name, + version_ids, + representation_ids + ): + """Trigger action by identifier. + + Triggers the action by identifier for given contexts. + + Triggers events "load.started" and "load.finished". Finished event + also contains "error_info" key with error information if any + happened. + + Args: + identifier (str): Loader identifier. + options (dict[str, Any]): Loader option values. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + representation_ids (Iterable[str]): Representation ids. + """ + + event_data = { + "identifier": identifier, + "id": uuid.uuid4().hex, + } + self._controller.emit_event( + "load.started", + event_data, + ACTIONS_MODEL_SENDER, + ) + loader = self._get_loader_by_identifier(project_name, identifier) + if representation_ids is not None: + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + representation_ids, + ) + elif version_ids is not None: + error_info = self._trigger_version_loader( + loader, + options, + project_name, + version_ids, + ) + else: + raise NotImplementedError( + "Invalid arguments to trigger action item") + + event_data["error_info"] = error_info + self._controller.emit_event( + "load.finished", + event_data, + ACTIONS_MODEL_SENDER, + ) + + def _get_current_context_project(self): + """Get current context project name. + + The value is based on controller (host) and cached. + + Returns: + Union[str, None]: Current context project. + """ + + if self._current_context_project is NOT_SET: + context = self._controller.get_current_context() + self._current_context_project = context["project_name"] + return self._current_context_project + + def _get_action_label(self, loader, representation=None): + """Pull label info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + representation (Optional[dict[str, Any]]): Representation data. + + Returns: + str: Action label. + """ + + label = getattr(loader, "label", None) + if label is None: + label = loader.__name__ + if representation: + # Add the representation as suffix + label = "{} ({})".format(label, representation["name"]) + return label + + def _get_action_icon(self, loader): + """Pull icon info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + + Returns: + Union[dict[str, Any], None]: Icon definition based on + loader plugin. + """ + + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None and not isinstance(icon, dict): + icon = { + "type": "awesome-font", + "name": icon, + "color": getattr(loader, "color", None) or "white" + } + return icon + + def _get_action_tooltip(self, loader): + """Pull tooltip info from loader class. + + Args: + loader (LoaderPlugin): Plugin class. + + Returns: + str: Action tooltip. + """ + + # Add tooltip and statustip from Loader docstring + return inspect.getdoc(loader) + + def _filter_loaders_by_tool_name(self, project_name, loaders): + """Filter loaders by tool name. + + Tool names are based on OpenPype tools loader tool and library + loader tool. The new tool merged both into one tool and the difference + is based only on current project name. + + Args: + project_name (str): Project name. + loaders (list[LoaderPlugin]): List of loader plugins. + + Returns: + list[LoaderPlugin]: Filtered list of loader plugins. + """ + + # Keep filtering by tool name + # - if current context project name is same as project name we do + # expect the tool is used as OpenPype loader tool, otherwise + # as library loader tool. + if project_name == self._get_current_context_project(): + tool_name = "loader" + else: + tool_name = "library_loader" + filtered_loaders = [] + for loader in loaders: + tool_names = getattr(loader, "tool_names", None) + if ( + tool_names is None + or "*" in tool_names + or tool_name in tool_names + ): + filtered_loaders.append(loader) + return filtered_loaders + + def _create_loader_action_item( + self, + loader, + contexts, + project_name, + folder_ids=None, + product_ids=None, + version_ids=None, + representation_ids=None, + repre_name=None, + ): + label = self._get_action_label(loader) + if repre_name: + label = "{} ({})".format(label, repre_name) + return ActionItem( + get_loader_identifier(loader), + label=label, + icon=self._get_action_icon(loader), + tooltip=self._get_action_tooltip(loader), + options=loader.get_options(contexts), + order=loader.order, + project_name=project_name, + folder_ids=folder_ids, + product_ids=product_ids, + version_ids=version_ids, + representation_ids=representation_ids, + ) + + def _get_loaders(self, project_name): + """Loaders with loaded settings for a project. + + Questions: + Project name is required because of settings. Should we actually + pass in current project name instead of project name where + we want to show loaders for? + + Returns: + tuple[list[SubsetLoaderPlugin], list[LoaderPlugin]]: Discovered + loader plugins. + """ + + loaders_by_identifier_c = self._loaders_by_identifier[project_name] + product_loaders_c = self._product_loaders[project_name] + repre_loaders_c = self._repre_loaders[project_name] + if loaders_by_identifier_c.is_valid: + return product_loaders_c.get_data(), repre_loaders_c.get_data() + + # Get all representation->loader combinations available for the + # index under the cursor, so we can list the user the options. + available_loaders = self._filter_loaders_by_tool_name( + project_name, discover_loader_plugins(project_name) + ) + + repre_loaders = [] + product_loaders = [] + loaders_by_identifier = {} + for loader_cls in available_loaders: + if not loader_cls.enabled: + continue + + identifier = get_loader_identifier(loader_cls) + loaders_by_identifier[identifier] = loader_cls + if issubclass(loader_cls, SubsetLoaderPlugin): + product_loaders.append(loader_cls) + else: + repre_loaders.append(loader_cls) + + loaders_by_identifier_c.update_data(loaders_by_identifier) + product_loaders_c.update_data(product_loaders) + repre_loaders_c.update_data(repre_loaders) + return product_loaders, repre_loaders + + def _get_loader_by_identifier(self, project_name, identifier): + if not self._loaders_by_identifier[project_name].is_valid: + self._get_loaders(project_name) + loaders_by_identifier_c = self._loaders_by_identifier[project_name] + loaders_by_identifier = loaders_by_identifier_c.get_data() + return loaders_by_identifier.get(identifier) + + def _actions_sorter(self, action_item): + """Sort the Loaders by their order and then their name. + + Returns: + tuple[int, str]: Sort keys. + """ + + return action_item.order, action_item.label + + def _get_version_docs(self, project_name, version_ids): + """Get version documents for given version ids. + + This function also handles hero versions and copies data from + source version to it. + + Todos: + Remove this function when this is completely rewritten to + use AYON calls. + """ + + version_docs = list(get_versions( + project_name, version_ids=version_ids, hero=True + )) + hero_versions_by_src_id = collections.defaultdict(list) + src_hero_version = set() + for version_doc in version_docs: + if version_doc["type"] != "hero": + continue + version_id = "" + src_hero_version.add(version_id) + hero_versions_by_src_id[version_id].append(version_doc) + + src_versions = [] + if src_hero_version: + src_versions = get_versions(project_name, version_ids=version_ids) + for src_version in src_versions: + src_version_id = src_version["_id"] + for hero_version in hero_versions_by_src_id[src_version_id]: + hero_version["data"] = copy.deepcopy(src_version["data"]) + + return version_docs + + def _contexts_for_versions(self, project_name, version_ids): + """Get contexts for given version ids. + + Prepare version contexts for 'SubsetLoaderPlugin' and representation + contexts for 'LoaderPlugin' for all children representations of + given versions. + + This method is very similar to '_contexts_for_representations' but the + queries of documents are called in a different order. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and + representation contexts. + """ + + # TODO fix hero version + version_context_by_id = {} + repre_context_by_id = {} + if not project_name and not version_ids: + return version_context_by_id, repre_context_by_id + + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = {} + version_docs_by_product_id = collections.defaultdict(list) + for version_doc in version_docs: + version_id = version_doc["_id"] + product_id = version_doc["parent"] + version_docs_by_id[version_id] = version_doc + version_docs_by_product_id[product_id].append(version_doc) + + _product_ids = set(version_docs_by_product_id.keys()) + _product_docs = get_subsets(project_name, subset_ids=_product_ids) + product_docs_by_id = {p["_id"]: p for p in _product_docs} + + _folder_ids = {p["parent"] for p in product_docs_by_id.values()} + _folder_docs = get_assets(project_name, asset_ids=_folder_ids) + folder_docs_by_id = {f["_id"]: f for f in _folder_docs} + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + for version_doc in version_docs: + version_id = version_doc["_id"] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + version_context_by_id[version_id] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + } + + repre_docs = get_representations( + project_name, version_ids=version_ids) + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + + repre_context_by_id[repre_doc["_id"]] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + } + + return version_context_by_id, repre_context_by_id + + def _contexts_for_representations(self, project_name, repre_ids): + """Get contexts for given representation ids. + + Prepare version contexts for 'SubsetLoaderPlugin' and representation + contexts for 'LoaderPlugin' for all children representations of + given versions. + + This method is very similar to '_contexts_for_versions' but the + queries of documents are called in a different order. + + Args: + project_name (str): Project name. + repre_ids (Iterable[str]): Representation ids. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and + representation contexts. + """ + + product_context_by_id = {} + repre_context_by_id = {} + if not project_name and not repre_ids: + return product_context_by_id, repre_context_by_id + + repre_docs = list(get_representations( + project_name, representation_ids=repre_ids + )) + version_ids = {r["parent"] for r in repre_docs} + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = { + v["_id"]: v for v in version_docs + } + + product_ids = {v["parent"] for v in version_docs_by_id.values()} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = { + p["_id"]: p for p in product_docs + } + + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = { + f["_id"]: f for f in folder_docs + } + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + for product_id, product_doc in product_docs_by_id.items(): + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_context_by_id[product_id] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + } + + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + + repre_context_by_id[repre_doc["_id"]] = { + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + } + return product_context_by_id, repre_context_by_id + + def _get_action_items_for_contexts( + self, + project_name, + version_context_by_id, + repre_context_by_id + ): + """Prepare action items based on contexts. + + Actions are prepared based on discovered loader plugins and contexts. + The context must be valid for the loader plugin. + + Args: + project_name (str): Project name. + version_context_by_id (dict[str, dict[str, Any]]): Version + contexts by version id. + repre_context_by_id (dict[str, dict[str, Any]]): Representation + """ + + action_items = [] + if not version_context_by_id and not repre_context_by_id: + return action_items + + product_loaders, repre_loaders = self._get_loaders(project_name) + + repre_contexts_by_name = collections.defaultdict(list) + for repre_context in repre_context_by_id.values(): + repre_name = repre_context["representation"]["name"] + repre_contexts_by_name[repre_name].append(repre_context) + + for loader in repre_loaders: + # # do not allow download whole repre, select specific repre + # if tools_lib.is_sync_loader(loader): + # continue + + for repre_name, repre_contexts in repre_contexts_by_name.items(): + filtered_repre_contexts = filter_repre_contexts_by_loader( + repre_contexts, loader) + if not filtered_repre_contexts: + continue + + repre_ids = set() + repre_version_ids = set() + repre_product_ids = set() + repre_folder_ids = set() + for repre_context in filtered_repre_contexts: + repre_ids.add(repre_context["representation"]["_id"]) + repre_product_ids.add(repre_context["subset"]["_id"]) + repre_version_ids.add(repre_context["version"]["_id"]) + repre_folder_ids.add(repre_context["asset"]["_id"]) + + item = self._create_loader_action_item( + loader, + repre_contexts, + project_name=project_name, + folder_ids=repre_folder_ids, + product_ids=repre_product_ids, + version_ids=repre_version_ids, + representation_ids=repre_ids, + repre_name=repre_name, + ) + action_items.append(item) + + # Subset Loaders. + version_ids = set(version_context_by_id.keys()) + product_folder_ids = set() + product_ids = set() + for product_context in version_context_by_id.values(): + product_ids.add(product_context["subset"]["_id"]) + product_folder_ids.add(product_context["asset"]["_id"]) + + version_contexts = list(version_context_by_id.values()) + for loader in product_loaders: + item = self._create_loader_action_item( + loader, + version_contexts, + project_name=project_name, + folder_ids=product_folder_ids, + product_ids=product_ids, + version_ids=version_ids, + ) + action_items.append(item) + + action_items.sort(key=self._actions_sorter) + return action_items + + def _trigger_version_loader( + self, + loader, + options, + project_name, + version_ids, + ): + """Trigger version loader. + + This triggers 'load' method of 'SubsetLoaderPlugin' for given version + ids. + + Note: + Even when the plugin is 'SubsetLoaderPlugin' it actually expects + versions and should be named 'VersionLoaderPlugin'. Because it + is planned to refactor load system and introduce + 'LoaderAction' plugins it is not relevant to change it + anymore. + + Args: + loader (SubsetLoaderPlugin): Loader plugin to use. + options (dict): Option values for loader. + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + """ + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + + version_docs = self._get_version_docs(project_name, version_ids) + product_ids = {v["parent"] for v in version_docs} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {f["_id"]: f for f in product_docs} + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} + product_contexts = [] + for version_doc in version_docs: + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + product_contexts.append({ + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + }) + + return self._load_products_by_loader( + loader, product_contexts, options + ) + + def _trigger_representation_loader( + self, + loader, + options, + project_name, + representation_ids, + ): + """Trigger representation loader. + + This triggers 'load' method of 'LoaderPlugin' for given representation + ids. For that are prepared contexts for each representation, with + all parent documents. + + Args: + loader (LoaderPlugin): Loader plugin to use. + options (dict): Option values for loader. + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + """ + + project_doc = get_project(project_name) + project_doc["code"] = project_doc["data"]["code"] + repre_docs = list(get_representations( + project_name, representation_ids=representation_ids + )) + version_ids = {r["parent"] for r in repre_docs} + version_docs = self._get_version_docs(project_name, version_ids) + version_docs_by_id = {v["_id"]: v for v in version_docs} + product_ids = {v["parent"] for v in version_docs_by_id.values()} + product_docs = get_subsets(project_name, subset_ids=product_ids) + product_docs_by_id = {p["_id"]: p for p in product_docs} + folder_ids = {p["parent"] for p in product_docs_by_id.values()} + folder_docs = get_assets(project_name, asset_ids=folder_ids) + folder_docs_by_id = {f["_id"]: f for f in folder_docs} + repre_contexts = [] + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_doc = version_docs_by_id[version_id] + product_id = version_doc["parent"] + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + folder_doc = folder_docs_by_id[folder_id] + repre_contexts.append({ + "project": project_doc, + "asset": folder_doc, + "subset": product_doc, + "version": version_doc, + "representation": repre_doc, + }) + + return self._load_representations_by_loader( + loader, repre_contexts, options + ) + + def _load_representations_by_loader(self, loader, repre_contexts, options): + """Loops through list of repre_contexts and loads them with one loader + + Args: + loader (LoaderPlugin): Loader plugin to use. + repre_contexts (list[dict]): Full info about selected + representations, containing repre, version, subset, asset and + project documents. + options (dict): Data from options. + """ + + error_info = [] + for repre_context in repre_contexts: + version_doc = repre_context["version"] + if version_doc["type"] == "hero_version": + version_name = "Hero" + else: + version_name = version_doc.get("name") + try: + load_with_repre_context( + loader, + repre_context, + options=options + ) + + except IncompatibleLoaderError as exc: + print(exc) + error_info.append(( + "Incompatible Loader", + None, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + version_name + )) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + + error_info.append(( + str(exc), + formatted_traceback, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + version_name + )) + return error_info + + def _load_products_by_loader(self, loader, version_contexts, options): + """Triggers load with SubsetLoader type of loaders. + + Warning: + Plugin is named 'SubsetLoader' but version is passed to context + too. + + Args: + loader (SubsetLoder): Loader used to load. + version_contexts (list[dict[str, Any]]): For context for each + version. + options (dict[str, Any]): Options for loader that user could fill. + """ + + error_info = [] + if loader.is_multiple_contexts_compatible: + subset_names = [] + for context in version_contexts: + subset_name = context.get("subset", {}).get("name") or "N/A" + subset_names.append(subset_name) + try: + load_with_subset_contexts( + loader, + version_contexts, + options=options + ) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info.append(( + str(exc), + formatted_traceback, + None, + ", ".join(subset_names), + None + )) + else: + for version_context in version_contexts: + subset_name = ( + version_context.get("subset", {}).get("name") or "N/A" + ) + try: + load_with_subset_context( + loader, + version_context, + options=options + ) + + except Exception as exc: + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join( + traceback.format_exception( + exc_type, exc_value, exc_traceback + ) + ) + + error_info.append(( + str(exc), + formatted_traceback, + None, + subset_name, + None + )) + + return error_info diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py new file mode 100644 index 0000000000..daa36aefdc --- /dev/null +++ b/openpype/tools/ayon_loader/models/products.py @@ -0,0 +1,735 @@ +import collections +import contextlib + +import arrow +import ayon_api +from ayon_api.operations import OperationsSession + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ( + ProductTypeItem, + ProductItem, + VersionItem, + RepreItem, +) + +PRODUCTS_MODEL_SENDER = "products.model" + + +def version_item_from_entity(version): + version_attribs = version["attrib"] + frame_start = version_attribs.get("frameStart") + frame_end = version_attribs.get("frameEnd") + handle_start = version_attribs.get("handleStart") + handle_end = version_attribs.get("handleEnd") + step = version_attribs.get("step") + comment = version_attribs.get("comment") + source = version_attribs.get("source") + + frame_range = None + duration = None + handles = None + if frame_start is not None and frame_end is not None: + # Remove superfluous zeros from numbers (3.0 -> 3) to improve + # readability for most frame ranges + frame_start = int(frame_start) + frame_end = int(frame_end) + frame_range = "{}-{}".format(frame_start, frame_end) + duration = frame_end - frame_start + 1 + + if handle_start is not None and handle_end is not None: + handles = "{}-{}".format(int(handle_start), int(handle_end)) + + # NOTE There is also 'updatedAt', should be used that instead? + # TODO skip conversion - converting to '%Y%m%dT%H%M%SZ' is because + # 'PrettyTimeDelegate' expects it + created_at = arrow.get(version["createdAt"]) + published_time = created_at.strftime("%Y%m%dT%H%M%SZ") + author = version["author"] + version_num = version["version"] + is_hero = version_num < 0 + + return VersionItem( + version_id=version["id"], + version=version_num, + is_hero=is_hero, + product_id=version["productId"], + thumbnail_id=version["thumbnailId"], + published_time=published_time, + author=author, + frame_range=frame_range, + duration=duration, + handles=handles, + step=step, + comment=comment, + source=source, + ) + + +def product_item_from_entity( + product_entity, + version_entities, + product_type_items_by_name, + folder_label, + product_in_scene, +): + product_attribs = product_entity["attrib"] + group = product_attribs.get("productGroup") + product_type = product_entity["productType"] + product_type_item = product_type_items_by_name.get(product_type) + # NOTE This is needed for cases when products were not created on server + # using api functions. In that case product type item may not be + # available and we need to create a default. + if product_type_item is None: + product_type_item = create_default_product_type_item(product_type) + # Cache the item for future use + product_type_items_by_name[product_type] = product_type_item + + product_type_icon = product_type_item.icon + + product_icon = { + "type": "awesome-font", + "name": "fa.file-o", + "color": get_default_entity_icon_color(), + } + version_items = { + version_entity["id"]: version_item_from_entity(version_entity) + for version_entity in version_entities + } + + return ProductItem( + product_id=product_entity["id"], + product_type=product_type, + product_name=product_entity["name"], + product_icon=product_icon, + product_type_icon=product_type_icon, + product_in_scene=product_in_scene, + group_name=group, + folder_id=product_entity["folderId"], + folder_label=folder_label, + version_items=version_items, + ) + + +def product_type_item_from_data(product_type_data): + # TODO implement icon implementation + # icon = product_type_data["icon"] + # color = product_type_data["color"] + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + # TODO implement checked logic + return ProductTypeItem(product_type_data["name"], icon, True) + + +def create_default_product_type_item(product_type): + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductTypeItem(product_type, icon, True) + + +class ProductsModel: + """Model for products, version and representation. + + All of the entities are product based. This model prepares data for UI + and caches it for faster access. + + Note: + Data are not used for actions model because that would require to + break OpenPype compatibility of 'LoaderPlugin's. + """ + + lifetime = 60 # In seconds (minute by default) + + def __init__(self, controller): + self._controller = controller + + # Mapping helpers + # NOTE - mapping must be cleaned up with cache cleanup + self._product_item_by_id = collections.defaultdict(dict) + self._version_item_by_id = collections.defaultdict(dict) + self._product_folder_ids_mapping = collections.defaultdict(dict) + + # Cache helpers + self._product_type_items_cache = NestedCacheItem( + levels=1, default_factory=list, lifetime=self.lifetime) + self._product_items_cache = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + self._repre_items_cache = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + + def reset(self): + """Reset model with all cached data.""" + + self._product_item_by_id.clear() + self._version_item_by_id.clear() + self._product_folder_ids_mapping.clear() + + self._product_type_items_cache.reset() + self._product_items_cache.reset() + self._repre_items_cache.reset() + + def get_product_type_items(self, project_name): + """Product type items for project. + + Args: + project_name (str): Project name. + + Returns: + list[ProductTypeItem]: Product type items. + """ + + cache = self._product_type_items_cache[project_name] + if not cache.is_valid: + product_types = ayon_api.get_project_product_types(project_name) + cache.update_data([ + product_type_item_from_data(product_type) + for product_type in product_types + ]) + return cache.get_data() + + def get_product_items(self, project_name, folder_ids, sender): + """Product items with versions for project and folder ids. + + Product items also contain version items. They're directly connected + to product items in the UI and the separation is not needed. + + Args: + project_name (Union[str, None]): Project name. + folder_ids (Iterable[str]): Folder ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + list[ProductItem]: Product items. + """ + + if not project_name or not folder_ids: + return [] + + project_cache = self._product_items_cache[project_name] + output = [] + folder_ids_to_update = set() + for folder_id in folder_ids: + cache = project_cache[folder_id] + if cache.is_valid: + output.extend(cache.get_data().values()) + else: + folder_ids_to_update.add(folder_id) + + self._refresh_product_items( + project_name, folder_ids_to_update, sender) + + for folder_id in folder_ids_to_update: + cache = project_cache[folder_id] + output.extend(cache.get_data().values()) + return output + + def get_product_item(self, project_name, product_id): + """Get product item based on passed product id. + + This method is using cached items, but if cache is not valid it also + can query the item. + + Args: + project_name (Union[str, None]): Where to look for product. + product_id (Union[str, None]): Product id to receive. + + Returns: + Union[ProductItem, None]: Product item or 'None' if not found. + """ + + if not any((project_name, product_id)): + return None + + product_items_by_id = self._product_item_by_id[project_name] + product_item = product_items_by_id.get(product_id) + if product_item is not None: + return product_item + for product_item in self._query_product_items_by_ids( + project_name, product_ids=[product_id] + ).values(): + return product_item + + def get_product_ids_by_repre_ids(self, project_name, repre_ids): + """Get product ids based on passed representation ids. + + Args: + project_name (str): Where to look for representations. + repre_ids (Iterable[str]): Representation ids. + + Returns: + set[str]: Product ids for passed representation ids. + """ + + # TODO look out how to use single server call + if not repre_ids: + return set() + repres = ayon_api.get_representations( + project_name, repre_ids, fields=["versionId"] + ) + version_ids = {repre["versionId"] for repre in repres} + if not version_ids: + return set() + versions = ayon_api.get_versions( + project_name, version_ids=version_ids, fields=["productId"] + ) + return {v["productId"] for v in versions} + + def get_repre_items(self, project_name, version_ids, sender): + """Get representation items for passed version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + list[RepreItem]: Representation items. + """ + + output = [] + if not any((project_name, version_ids)): + return output + + invalid_version_ids = set() + project_cache = self._repre_items_cache[project_name] + for version_id in version_ids: + version_cache = project_cache[version_id] + if version_cache.is_valid: + output.extend(version_cache.get_data().values()) + else: + invalid_version_ids.add(version_id) + + if invalid_version_ids: + self.refresh_representation_items( + project_name, invalid_version_ids, sender + ) + + for version_id in invalid_version_ids: + version_cache = project_cache[version_id] + output.extend(version_cache.get_data().values()) + + return output + + def get_versions_repre_count(self, project_name, version_ids, sender): + """Get representation count for passed version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + dict[str, int]: Number of representations by version id. + """ + + output = {} + if not any((project_name, version_ids)): + return output + + invalid_version_ids = set() + project_cache = self._repre_items_cache[project_name] + for version_id in version_ids: + version_cache = project_cache[version_id] + if version_cache.is_valid: + output[version_id] = len(version_cache.get_data()) + else: + invalid_version_ids.add(version_id) + + if invalid_version_ids: + self.refresh_representation_items( + project_name, invalid_version_ids, sender + ) + + for version_id in invalid_version_ids: + version_cache = project_cache[version_id] + output[version_id] = len(version_cache.get_data()) + + return output + + def change_products_group(self, project_name, product_ids, group_name): + """Change group name for passed product ids. + + Group name is stored in 'attrib' of product entity and is used in UI + to group items. + + Method triggers "products.group.changed" event with data: + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name + } + + Args: + project_name (str): Project name. + product_ids (Iterable[str]): Product ids to change group name for. + group_name (str): Group name to set. + """ + + if not product_ids: + return + + product_items = self._get_product_items_by_id( + project_name, product_ids + ) + if not product_items: + return + + session = OperationsSession() + folder_ids = set() + for product_item in product_items.values(): + session.update_entity( + project_name, + "product", + product_item.product_id, + {"attrib": {"productGroup": group_name}} + ) + folder_ids.add(product_item.folder_id) + product_item.group_name = group_name + + session.commit() + self._controller.emit_event( + "products.group.changed", + { + "project_name": project_name, + "folder_ids": folder_ids, + "product_ids": product_ids, + "group_name": group_name, + }, + PRODUCTS_MODEL_SENDER + ) + + def _get_product_items_by_id(self, project_name, product_ids): + product_item_by_id = self._product_item_by_id[project_name] + missing_product_ids = set() + output = {} + for product_id in product_ids: + product_item = product_item_by_id.get(product_id) + if product_item is not None: + output[product_id] = product_item + else: + missing_product_ids.add(product_id) + + output.update( + self._query_product_items_by_ids( + project_name, missing_product_ids + ) + ) + return output + + def _get_version_items_by_id(self, project_name, version_ids): + version_item_by_id = self._version_item_by_id[project_name] + missing_version_ids = set() + output = {} + for version_id in version_ids: + version_item = version_item_by_id.get(version_id) + if version_item is not None: + output[version_id] = version_item + else: + missing_version_ids.add(version_id) + + output.update( + self._query_version_items_by_ids( + project_name, missing_version_ids + ) + ) + return output + + def _create_product_items( + self, + project_name, + products, + versions, + folder_items=None, + product_type_items=None, + ): + if folder_items is None: + folder_items = self._controller.get_folder_items(project_name) + + if product_type_items is None: + product_type_items = self.get_product_type_items(project_name) + + loaded_product_ids = self._controller.get_loaded_product_ids() + + versions_by_product_id = collections.defaultdict(list) + for version in versions: + versions_by_product_id[version["productId"]].append(version) + product_type_items_by_name = { + product_type_item.name: product_type_item + for product_type_item in product_type_items + } + output = {} + for product in products: + product_id = product["id"] + folder_id = product["folderId"] + folder_item = folder_items.get(folder_id) + if not folder_item: + continue + versions = versions_by_product_id[product_id] + if not versions: + continue + product_item = product_item_from_entity( + product, + versions, + product_type_items_by_name, + folder_item.label, + product_id in loaded_product_ids, + ) + output[product_id] = product_item + return output + + def _query_product_items_by_ids( + self, + project_name, + folder_ids=None, + product_ids=None, + folder_items=None + ): + """Query product items. + + This method does get from, or store to, cache attributes. + + One of 'product_ids' or 'folder_ids' must be passed to the method. + + Args: + project_name (str): Project name. + folder_ids (Optional[Iterable[str]]): Folder ids under which are + products. + product_ids (Optional[Iterable[str]]): Product ids to use. + folder_items (Optional[Dict[str, FolderItem]]): Prepared folder + items from controller. + + Returns: + dict[str, ProductItem]: Product items by product id. + """ + + if not folder_ids and not product_ids: + return {} + + kwargs = {} + if folder_ids is not None: + kwargs["folder_ids"] = folder_ids + + if product_ids is not None: + kwargs["product_ids"] = product_ids + + products = list(ayon_api.get_products(project_name, **kwargs)) + product_ids = {product["id"] for product in products} + + versions = ayon_api.get_versions( + project_name, product_ids=product_ids + ) + + return self._create_product_items( + project_name, products, versions, folder_items=folder_items + ) + + def _query_version_items_by_ids(self, project_name, version_ids): + versions = list(ayon_api.get_versions( + project_name, version_ids=version_ids + )) + product_ids = {version["productId"] for version in versions} + products = list(ayon_api.get_products( + project_name, product_ids=product_ids + )) + product_items = self._create_product_items( + project_name, products, versions + ) + version_items = {} + for product_item in product_items.values(): + version_items.update(product_item.version_items) + return version_items + + def _clear_product_version_items(self, project_name, folder_ids): + """Clear product and version items from memory. + + When products are re-queried for a folders, the old product and version + items in '_product_item_by_id' and '_version_item_by_id' should + be cleaned up from memory. And mapping in stored in + '_product_folder_ids_mapping' is not relevant either. + + Args: + project_name (str): Name of project. + folder_ids (Iterable[str]): Folder ids which are being refreshed. + """ + + project_mapping = self._product_folder_ids_mapping[project_name] + if not project_mapping: + return + + product_item_by_id = self._product_item_by_id[project_name] + version_item_by_id = self._version_item_by_id[project_name] + for folder_id in folder_ids: + product_ids = project_mapping.pop(folder_id, None) + if not product_ids: + continue + + for product_id in product_ids: + product_item = product_item_by_id.pop(product_id, None) + if product_item is None: + continue + for version_item in product_item.version_items.values(): + version_item_by_id.pop(version_item.version_id, None) + + def _refresh_product_items(self, project_name, folder_ids, sender): + """Refresh product items and store them in cache. + + Args: + project_name (str): Name of project. + folder_ids (Iterable[str]): Folder ids which are being refreshed. + sender (Union[str, None]): Who triggered the refresh. + """ + + if not project_name or not folder_ids: + return + + self._clear_product_version_items(project_name, folder_ids) + + project_mapping = self._product_folder_ids_mapping[project_name] + product_item_by_id = self._product_item_by_id[project_name] + version_item_by_id = self._version_item_by_id[project_name] + + for folder_id in folder_ids: + project_mapping[folder_id] = set() + + with self._product_refresh_event_manager( + project_name, folder_ids, sender + ): + folder_items = self._controller.get_folder_items(project_name) + items_by_folder_id = { + folder_id: {} + for folder_id in folder_ids + } + product_items_by_id = self._query_product_items_by_ids( + project_name, + folder_ids=folder_ids, + folder_items=folder_items + ) + for product_id, product_item in product_items_by_id.items(): + folder_id = product_item.folder_id + items_by_folder_id[product_item.folder_id][product_id] = ( + product_item + ) + + project_mapping[folder_id].add(product_id) + product_item_by_id[product_id] = product_item + for version_id, version_item in ( + product_item.version_items.items() + ): + version_item_by_id[version_id] = version_item + + project_cache = self._product_items_cache[project_name] + for folder_id, product_items in items_by_folder_id.items(): + project_cache[folder_id].update_data(product_items) + + @contextlib.contextmanager + def _product_refresh_event_manager( + self, project_name, folder_ids, sender + ): + self._controller.emit_event( + "products.refresh.started", + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "products.refresh.finished", + { + "project_name": project_name, + "folder_ids": folder_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + + def refresh_representation_items( + self, project_name, version_ids, sender + ): + if not any((project_name, version_ids)): + return + self._controller.emit_event( + "model.representations.refresh.started", + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender, + }, + PRODUCTS_MODEL_SENDER + ) + failed = False + try: + self._refresh_representation_items(project_name, version_ids) + except Exception: + # TODO add more information about failed refresh + failed = True + + self._controller.emit_event( + "model.representations.refresh.finished", + { + "project_name": project_name, + "version_ids": version_ids, + "sender": sender, + "failed": failed, + }, + PRODUCTS_MODEL_SENDER + ) + + def _refresh_representation_items(self, project_name, version_ids): + representations = list(ayon_api.get_representations( + project_name, + version_ids=version_ids, + fields=["id", "name", "versionId"] + )) + + version_items_by_id = self._get_version_items_by_id( + project_name, version_ids + ) + product_ids = { + version_item.product_id + for version_item in version_items_by_id.values() + } + product_items_by_id = self._get_product_items_by_id( + project_name, product_ids + ) + repre_icon = { + "type": "awesome-font", + "name": "fa.file-o", + "color": get_default_entity_icon_color(), + } + repre_items_by_version_id = collections.defaultdict(dict) + for representation in representations: + version_id = representation["versionId"] + version_item = version_items_by_id.get(version_id) + if version_item is None: + continue + product_item = product_items_by_id.get(version_item.product_id) + if product_item is None: + continue + repre_id = representation["id"] + repre_item = RepreItem( + repre_id, + representation["name"], + repre_icon, + product_item.product_name, + product_item.folder_label, + ) + repre_items_by_version_id[version_id][repre_id] = repre_item + + project_cache = self._repre_items_cache[project_name] + for version_id, repre_items in repre_items_by_version_id.items(): + version_cache = project_cache[version_id] + version_cache.update_data(repre_items) diff --git a/openpype/tools/ayon_loader/models/selection.py b/openpype/tools/ayon_loader/models/selection.py new file mode 100644 index 0000000000..326ff835f6 --- /dev/null +++ b/openpype/tools/ayon_loader/models/selection.py @@ -0,0 +1,85 @@ +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folders.changed" + - "selection.versions.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_ids = set() + self._version_ids = set() + self._representation_ids = set() + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if self._project_name == project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": self._project_name}, + self.event_source + ) + + def get_selected_folder_ids(self): + return self._folder_ids + + def set_selected_folders(self, folder_ids): + if folder_ids == self._folder_ids: + return + + self._folder_ids = folder_ids + self._controller.emit_event( + "selection.folders.changed", + { + "project_name": self._project_name, + "folder_ids": folder_ids, + }, + self.event_source + ) + + def get_selected_version_ids(self): + return self._version_ids + + def set_selected_versions(self, version_ids): + if version_ids == self._version_ids: + return + + self._version_ids = version_ids + self._controller.emit_event( + "selection.versions.changed", + { + "project_name": self._project_name, + "folder_ids": self._folder_ids, + "version_ids": self._version_ids, + }, + self.event_source + ) + + def get_selected_representation_ids(self): + return self._representation_ids + + def set_selected_representations(self, repre_ids): + if repre_ids == self._representation_ids: + return + + self._representation_ids = repre_ids + self._controller.emit_event( + "selection.representations.changed", + { + "project_name": self._project_name, + "folder_ids": self._folder_ids, + "version_ids": self._version_ids, + "representation_ids": self._representation_ids, + } + ) diff --git a/openpype/tools/ayon_loader/models/site_sync.py b/openpype/tools/ayon_loader/models/site_sync.py new file mode 100644 index 0000000000..90852b6954 --- /dev/null +++ b/openpype/tools/ayon_loader/models/site_sync.py @@ -0,0 +1,509 @@ +import collections + +from openpype.lib import Logger +from openpype.client.entities import get_representations +from openpype.client import get_linked_representation_id +from openpype.modules import ModulesManager +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ActionItem + +DOWNLOAD_IDENTIFIER = "sitesync.download" +UPLOAD_IDENTIFIER = "sitesync.upload" +REMOVE_IDENTIFIER = "sitesync.remove" + +log = Logger.get_logger(__name__) + + +def _default_version_availability(): + return 0, 0 + + +def _default_repre_status(): + return 0.0, 0.0 + + +class SiteSyncModel: + """Model handling site sync logic. + + Model cares about handling of site sync functionality. All public + functions should be possible to call even if site sync is not available. + """ + + lifetime = 60 # In seconds (minute by default) + status_lifetime = 20 + + def __init__(self, controller): + self._controller = controller + + self._site_icons = None + self._site_sync_enabled_cache = NestedCacheItem( + levels=1, lifetime=self.lifetime + ) + self._active_site_cache = NestedCacheItem( + levels=1, lifetime=self.lifetime + ) + self._remote_site_cache = NestedCacheItem( + levels=1, lifetime=self.lifetime + ) + self._version_availability_cache = NestedCacheItem( + levels=2, + default_factory=_default_version_availability, + lifetime=self.status_lifetime + ) + self._repre_status_cache = NestedCacheItem( + levels=2, + default_factory=_default_repre_status, + lifetime=self.status_lifetime + ) + + manager = ModulesManager() + self._site_sync_addon = manager.get("sync_server") + + def reset(self): + self._site_icons = None + self._site_sync_enabled_cache.reset() + self._active_site_cache.reset() + self._remote_site_cache.reset() + self._version_availability_cache.reset() + self._repre_status_cache.reset() + + def is_site_sync_enabled(self, project_name=None): + """Site sync is enabled for a project. + + Returns false if site sync addon is not available or enabled + or project has disabled it. + + Args: + project_name (Union[str, None]): Project name. If project name + is 'None', True is returned if site sync addon + is available and enabled. + + Returns: + bool: Site sync is enabled. + """ + + if not self._is_site_sync_addon_enabled(): + return False + cache = self._site_sync_enabled_cache[project_name] + if not cache.is_valid: + enabled = True + if project_name: + enabled = self._site_sync_addon.is_project_enabled( + project_name, single=True + ) + cache.update_data(enabled) + return cache.get_data() + + def get_active_site(self, project_name): + """Active site name for a project. + + Args: + project_name (str): Project name. + + Returns: + Union[str, None]: Remote site name. + """ + + cache = self._active_site_cache[project_name] + if not cache.is_valid: + site_name = None + if project_name and self._is_site_sync_addon_enabled(): + site_name = self._site_sync_addon.get_active_site(project_name) + cache.update_data(site_name) + return cache.get_data() + + def get_remote_site(self, project_name): + """Remote site name for a project. + + Args: + project_name (str): Project name. + + Returns: + Union[str, None]: Remote site name. + """ + + cache = self._remote_site_cache[project_name] + if not cache.is_valid: + site_name = None + if project_name and self._is_site_sync_addon_enabled(): + site_name = self._site_sync_addon.get_remote_site(project_name) + cache.update_data(site_name) + return cache.get_data() + + def get_active_site_icon_def(self, project_name): + """Active site icon definition. + + Args: + project_name (Union[str, None]): Name of project. + + Returns: + Union[dict[str, Any], None]: Site icon definition. + """ + + if not 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) + + def get_remote_site_icon_def(self, project_name): + """Remote site icon definition. + + Args: + project_name (Union[str, None]): Name of project. + + Returns: + Union[dict[str, Any], None]: Site icon definition. + """ + + 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_provider_icon(provider) + + def get_version_sync_availability(self, project_name, version_ids): + """Returns how many representations are available on sites. + + Returned value `{version_id: (4, 6)}` denotes that locally are + available 4 and remotely 6 representation. + NOTE: Available means they were synced to site. + + Returns: + dict[str, tuple[int, int]] + """ + + if not self.is_site_sync_enabled(project_name): + return { + version_id: _default_version_availability() + for version_id in version_ids + } + + output = {} + project_cache = self._version_availability_cache[project_name] + invalid_ids = set() + for version_id in version_ids: + repre_cache = project_cache[version_id] + if repre_cache.is_valid: + output[version_id] = repre_cache.get_data() + else: + invalid_ids.add(version_id) + + if invalid_ids: + self._refresh_version_availability( + project_name, invalid_ids + ) + for version_id in invalid_ids: + version_cache = project_cache[version_id] + output[version_id] = version_cache.get_data() + return output + + def get_representations_sync_status( + self, project_name, representation_ids + ): + """ + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + dict[str, tuple[float, float]] + """ + + if not self.is_site_sync_enabled(project_name): + return { + repre_id: _default_repre_status() + for repre_id in representation_ids + } + + output = {} + project_cache = self._repre_status_cache[project_name] + invalid_ids = set() + for repre_id in representation_ids: + repre_cache = project_cache[repre_id] + if repre_cache.is_valid: + output[repre_id] = repre_cache.get_data() + else: + invalid_ids.add(repre_id) + + if invalid_ids: + self._refresh_representations_sync_status( + project_name, invalid_ids + ) + for repre_id in invalid_ids: + repre_cache = project_cache[repre_id] + output[repre_id] = repre_cache.get_data() + return output + + def get_site_sync_action_items(self, project_name, representation_ids): + """ + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: Actions that can be shown in loader. + """ + + if not self.is_site_sync_enabled(project_name): + return [] + + repres_status = self.get_representations_sync_status( + project_name, representation_ids + ) + + repre_ids_per_identifier = collections.defaultdict(set) + for repre_id in representation_ids: + repre_status = repres_status[repre_id] + local_status, remote_status = repre_status + + if local_status: + repre_ids_per_identifier[UPLOAD_IDENTIFIER].add(repre_id) + repre_ids_per_identifier[REMOVE_IDENTIFIER].add(repre_id) + + if remote_status: + repre_ids_per_identifier[DOWNLOAD_IDENTIFIER].add(repre_id) + + action_items = [] + for identifier, repre_ids in repre_ids_per_identifier.items(): + if identifier == DOWNLOAD_IDENTIFIER: + action_items.append(self._create_download_action_item( + project_name, repre_ids + )) + elif identifier == UPLOAD_IDENTIFIER: + action_items.append(self._create_upload_action_item( + project_name, repre_ids + )) + elif identifier == REMOVE_IDENTIFIER: + action_items.append(self._create_delete_action_item( + project_name, repre_ids + )) + + return action_items + + def is_site_sync_action(self, identifier): + """Should be `identifier` handled by SiteSync. + + Args: + identifier (str): Action identifier. + + Returns: + bool: Should action be handled by SiteSync. + """ + + return identifier in { + UPLOAD_IDENTIFIER, + DOWNLOAD_IDENTIFIER, + REMOVE_IDENTIFIER, + } + + def trigger_action_item( + self, + identifier, + project_name, + representation_ids + ): + """Resets status for site_name or remove local files. + + Args: + identifier (str): Action identifier. + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + """ + + active_site = self.get_active_site(project_name) + remote_site = self.get_remote_site(project_name) + + repre_docs = list(get_representations( + project_name, representation_ids=representation_ids + )) + families_per_repre_id = { + item["_id"]: item["context"]["family"] + for item in repre_docs + } + + for repre_id in representation_ids: + family = families_per_repre_id[repre_id] + if identifier == DOWNLOAD_IDENTIFIER: + self._add_site( + project_name, repre_id, active_site, family + ) + + elif identifier == UPLOAD_IDENTIFIER: + self._add_site( + project_name, repre_id, remote_site, family + ) + + elif identifier == REMOVE_IDENTIFIER: + self._site_sync_addon.remove_site( + project_name, + repre_id, + active_site, + remove_local_files=True + ) + + def _is_site_sync_addon_enabled(self): + """ + Returns: + bool: Site sync addon is enabled. + """ + + if self._site_sync_addon is None: + return False + return self._site_sync_addon.enabled + + def _get_provider_for_site(self, project_name, site_name): + """Provider for a site. + + Args: + project_name (str): Project name. + site_name (str): Site name. + + Returns: + Union[str, None]: Provider name. + """ + + if not self._is_site_sync_addon_enabled(): + return None + return self._site_sync_addon.get_provider_for_site( + project_name, site_name + ) + + def _get_provider_icon(self, provider): + """site provider icons. + + Returns: + Union[dict[str, Any], None]: Icon of site provider. + """ + + if not provider: + return None + + if self._site_icons is None: + self._site_icons = self._site_sync_addon.get_site_icons() + return self._site_icons.get(provider) + + def _refresh_version_availability(self, project_name, version_ids): + if not project_name or not version_ids: + return + project_cache = self._version_availability_cache[project_name] + + avail_by_id = self._site_sync_addon.get_version_availability( + project_name, + version_ids, + self.get_active_site(project_name), + self.get_remote_site(project_name), + ) + for version_id in version_ids: + status = avail_by_id.get(version_id) + if status is None: + status = _default_version_availability() + project_cache[version_id].update_data(status) + + def _refresh_representations_sync_status( + self, project_name, representation_ids + ): + if not project_name or not representation_ids: + return + project_cache = self._repre_status_cache[project_name] + status_by_repre_id = ( + self._site_sync_addon.get_representations_sync_state( + project_name, + representation_ids, + self.get_active_site(project_name), + self.get_remote_site(project_name), + ) + ) + for repre_id in representation_ids: + status = status_by_repre_id.get(repre_id) + if status is None: + status = _default_repre_status() + project_cache[repre_id].update_data(status) + + def _create_download_action_item(self, project_name, representation_ids): + return self._create_action_item( + project_name, + representation_ids, + DOWNLOAD_IDENTIFIER, + "Download", + "Mark representation for download locally", + "fa.download" + ) + + def _create_upload_action_item(self, project_name, representation_ids): + return self._create_action_item( + project_name, + representation_ids, + UPLOAD_IDENTIFIER, + "Upload", + "Mark representation for upload remotely", + "fa.upload" + ) + + def _create_delete_action_item(self, project_name, representation_ids): + return self._create_action_item( + project_name, + representation_ids, + REMOVE_IDENTIFIER, + "Remove from local", + "Remove local synchronization", + "fa.trash" + ) + + def _create_action_item( + self, + project_name, + representation_ids, + identifier, + label, + tooltip, + icon_name + ): + return ActionItem( + identifier, + label, + icon={ + "type": "awesome-font", + "name": icon_name, + "color": "#999999" + }, + tooltip=tooltip, + options={}, + order=1, + project_name=project_name, + folder_ids=[], + product_ids=[], + version_ids=[], + representation_ids=representation_ids, + ) + + def _add_site(self, project_name, repre_id, site_name, family): + self._site_sync_addon.add_site( + project_name, repre_id, site_name, force=True + ) + + # TODO this should happen in site sync addon + if family != "workfile": + return + + links = get_linked_representation_id( + project_name, + repre_id=repre_id, + link_type="reference" + ) + for link_repre_id in links: + try: + print("Adding {} to linked representation: {}".format( + site_name, link_repre_id)) + self._site_sync_addon.add_site( + project_name, + link_repre_id, + site_name, + force=False + ) + except Exception: + # do not add/reset working site for references + log.debug("Site present", exc_info=True) diff --git a/openpype/tools/ayon_loader/ui/__init__.py b/openpype/tools/ayon_loader/ui/__init__.py new file mode 100644 index 0000000000..41e4418641 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import LoaderWindow + + +__all__ = ( + "LoaderWindow", +) diff --git a/openpype/tools/ayon_loader/ui/actions_utils.py b/openpype/tools/ayon_loader/ui/actions_utils.py new file mode 100644 index 0000000000..a269b643dc --- /dev/null +++ b/openpype/tools/ayon_loader/ui/actions_utils.py @@ -0,0 +1,118 @@ +import uuid + +from qtpy import QtWidgets, QtGui +import qtawesome + +from openpype.lib.attribute_definitions import AbstractAttrDef +from openpype.tools.attribute_defs import AttributeDefinitionsDialog +from openpype.tools.utils.widgets import ( + OptionalMenu, + OptionalAction, + OptionDialog, +) +from openpype.tools.ayon_utils.widgets import get_qt_icon + + +def show_actions_menu(action_items, global_point, one_item_selected, parent): + selected_action_item = None + selected_options = None + + if not action_items: + menu = QtWidgets.QMenu(parent) + action = _get_no_loader_action(menu, one_item_selected) + menu.addAction(action) + menu.exec_(global_point) + return selected_action_item, selected_options + + menu = OptionalMenu(parent) + + action_items_by_id = {} + for action_item in action_items: + item_id = uuid.uuid4().hex + action_items_by_id[item_id] = action_item + item_options = action_item.options + icon = get_qt_icon(action_item.icon) + use_option = bool(item_options) + action = OptionalAction( + action_item.label, + icon, + use_option, + menu + ) + if use_option: + # Add option box tip + action.set_option_tip(item_options) + + tip = action_item.tooltip + if tip: + action.setToolTip(tip) + action.setStatusTip(tip) + + action.setData(item_id) + + menu.addAction(action) + + action = menu.exec_(global_point) + if action is not None: + item_id = action.data() + selected_action_item = action_items_by_id.get(item_id) + + if selected_action_item is not None: + selected_options = _get_options(action, selected_action_item, parent) + + return selected_action_item, selected_options + + +def _get_options(action, action_item, parent): + """Provides dialog to select value from loader provided options. + + Loader can provide static or dynamically created options based on + AttributeDefinitions, and for backwards compatibility qargparse. + + Args: + action (OptionalAction) - Action object in menu. + action_item (ActionItem) - Action item with context information. + parent (QtCore.QObject) - Parent object for dialog. + + Returns: + Union[dict[str, Any], None]: Selected value from attributes or + 'None' if dialog was cancelled. + """ + + # Pop option dialog + options = action_item.options + if not getattr(action, "optioned", False) or not options: + return {} + + if isinstance(options[0], AbstractAttrDef): + qargparse_options = False + dialog = AttributeDefinitionsDialog(options, parent) + else: + qargparse_options = True + dialog = OptionDialog(parent) + dialog.create(options) + + dialog.setWindowTitle(action.label + " Options") + + if not dialog.exec_(): + return None + + # Get option + if qargparse_options: + return dialog.parse() + return dialog.get_values() + + +def _get_no_loader_action(menu, one_item_selected): + """Creates dummy no loader option in 'menu'""" + + if one_item_selected: + submsg = "this version." + else: + submsg = "your selection." + msg = "No compatible loaders for {}".format(submsg) + icon = qtawesome.icon( + "fa.exclamation", + color=QtGui.QColor(255, 51, 0) + ) + return QtWidgets.QAction(icon, ("*" + msg), menu) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py new file mode 100644 index 0000000000..eaaf7ca617 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -0,0 +1,407 @@ +import qtpy +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) +from openpype.style import get_objected_colors + +from openpype.tools.ayon_utils.widgets import ( + FoldersQtModel, + FOLDERS_MODEL_SENDER_NAME, +) +from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE + +if qtpy.API == "pyside": + from PySide.QtGui import QStyleOptionViewItemV4 +elif qtpy.API == "pyqt4": + from PyQt4.QtGui import QStyleOptionViewItemV4 + +UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 50 + + +class UnderlinesFolderDelegate(QtWidgets.QItemDelegate): + """Item delegate drawing bars under folder label. + + This is used in loader tool. Multiselection of folders + may group products by name under colored groups. Selected color groups are + then propagated back to selected folders as underlines. + """ + bar_height = 3 + + def __init__(self, *args, **kwargs): + super(UnderlinesFolderDelegate, self).__init__(*args, **kwargs) + colors = get_objected_colors("loader", "asset-view") + self._selected_color = colors["selected"].get_qcolor() + self._hover_color = colors["hover"].get_qcolor() + self._selected_hover_color = colors["selected-hover"].get_qcolor() + + def sizeHint(self, option, index): + """Add bar height to size hint.""" + result = super(UnderlinesFolderDelegate, self).sizeHint(option, index) + height = result.height() + result.setHeight(height + self.bar_height) + + return result + + def paint(self, painter, option, index): + """Replicate painting of an item and draw color bars if needed.""" + # Qt4 compat + if qtpy.API in ("pyside", "pyqt4"): + option = QStyleOptionViewItemV4(option) + + painter.save() + + item_rect = QtCore.QRect(option.rect) + item_rect.setHeight(option.rect.height() - self.bar_height) + + subset_colors = index.data(UNDERLINE_COLORS_ROLE) or [] + + subset_colors_width = 0 + if subset_colors: + subset_colors_width = option.rect.width() / len(subset_colors) + + subset_rects = [] + counter = 0 + for subset_c in subset_colors: + new_color = None + new_rect = None + if subset_c: + new_color = QtGui.QColor(subset_c) + + new_rect = QtCore.QRect( + option.rect.left() + (counter * subset_colors_width), + option.rect.top() + ( + option.rect.height() - self.bar_height + ), + subset_colors_width, + self.bar_height + ) + subset_rects.append((new_color, new_rect)) + counter += 1 + + # Background + if option.state & QtWidgets.QStyle.State_Selected: + if len(subset_colors) == 0: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._selected_hover_color + else: + bg_color = self._selected_color + else: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._hover_color + else: + bg_color = QtGui.QColor() + bg_color.setAlpha(0) + + # When not needed to do a rounded corners (easier and without + # painter restore): + painter.fillRect( + option.rect, + QtGui.QBrush(bg_color) + ) + + if option.state & QtWidgets.QStyle.State_Selected: + for color, subset_rect in subset_rects: + if not color or not subset_rect: + continue + painter.fillRect(subset_rect, QtGui.QBrush(color)) + + # Icon + icon_index = index.model().index( + index.row(), index.column(), index.parent() + ) + # - Default icon_rect if not icon + icon_rect = QtCore.QRect( + item_rect.left(), + item_rect.top(), + # To make sure it's same size all the time + option.rect.height() - self.bar_height, + option.rect.height() - self.bar_height + ) + icon = index.model().data(icon_index, QtCore.Qt.DecorationRole) + + if icon: + mode = QtGui.QIcon.Normal + if not (option.state & QtWidgets.QStyle.State_Enabled): + mode = QtGui.QIcon.Disabled + elif option.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + + if isinstance(icon, QtGui.QPixmap): + icon = QtGui.QIcon(icon) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QColor): + pixmap = QtGui.QPixmap(option.decorationSize) + pixmap.fill(icon) + icon = QtGui.QIcon(pixmap) + + elif isinstance(icon, QtGui.QImage): + icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon)) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QIcon): + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + actual_size = option.icon.actualSize( + option.decorationSize, mode, state + ) + option.decorationSize = QtCore.QSize( + min(option.decorationSize.width(), actual_size.width()), + min(option.decorationSize.height(), actual_size.height()) + ) + + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + icon.paint( + painter, icon_rect, + QtCore.Qt.AlignLeft, mode, state + ) + + # Text + text_rect = QtCore.QRect( + icon_rect.left() + icon_rect.width() + 2, + item_rect.top(), + item_rect.width(), + item_rect.height() + ) + + painter.drawText( + text_rect, QtCore.Qt.AlignVCenter, + index.data(QtCore.Qt.DisplayRole) + ) + + painter.restore() + + +class LoaderFoldersModel(FoldersQtModel): + def __init__(self, *args, **kwargs): + super(LoaderFoldersModel, self).__init__(*args, **kwargs) + + self._colored_items = set() + + def _fill_item_data(self, item, folder_item): + """ + + Args: + item (QtGui.QStandardItem): Item to fill data. + folder_item (FolderItem): Folder item. + """ + + super(LoaderFoldersModel, self)._fill_item_data(item, folder_item) + + def set_merged_products_selection(self, items): + changes = { + folder_id: None + for folder_id in self._colored_items + } + + all_folder_ids = set() + for item in items: + folder_ids = item["folder_ids"] + all_folder_ids.update(folder_ids) + + for folder_id in all_folder_ids: + changes[folder_id] = [] + + for item in items: + item_color = item["color"] + item_folder_ids = item["folder_ids"] + for folder_id in all_folder_ids: + folder_color = ( + item_color + if folder_id in item_folder_ids + else None + ) + changes[folder_id].append(folder_color) + + for folder_id, color_value in changes.items(): + item = self._items_by_id.get(folder_id) + if item is not None: + item.setData(color_value, UNDERLINE_COLORS_ROLE) + + self._colored_items = all_folder_ids + + +class LoaderFoldersWidget(QtWidgets.QWidget): + """Folders widget. + + Widget that handles folders view, model and selection. + + Expected selection handling is disabled by default. If enabled, the + widget will handle the expected in predefined way. Widget is listening + to event 'expected_selection_changed' with expected event data below, + the same data must be available when called method + 'get_expected_selection_data' on controller. + + { + "folder": { + "current": bool, # Folder is what should be set now + "folder_id": Union[str, None], # Folder id that should be selected + }, + ... + } + + Selection is confirmed by calling method 'expected_folder_selected' on + controller. + + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller, parent): + super(LoaderFoldersWidget, self).__init__(parent) + + folders_view = DeselectableTreeView(self) + folders_view.setHeaderHidden(True) + folders_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + + folders_model = LoaderFoldersModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + folders_label_delegate = UnderlinesFolderDelegate(folders_view) + + folders_view.setModel(folders_proxy_model) + folders_view.setItemDelegate(folders_label_delegate) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(folders_view, 1) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change, + ) + controller.register_event_callback( + "folders.refresh.finished", + self._on_folders_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = folders_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + folders_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._folders_view = folders_view + self._folders_model = folders_model + self._folders_proxy_model = folders_proxy_model + self._folders_label_delegate = folders_label_delegate + + self._expected_selection = None + + def set_name_filter(self, name): + """Set filter of folder name. + + Args: + name (str): The string filter. + """ + + self._folders_proxy_model.setFilterFixedString(name) + + def set_merged_products_selection(self, items): + """ + + Args: + items (list[dict[str, Any]]): List of merged items with folder + ids. + """ + + self._folders_model.set_merged_products_selection(items) + + def refresh(self): + self._folders_model.refresh() + + def _on_project_selection_change(self, event): + project_name = event["project_name"] + self._set_project_name(project_name) + + def _set_project_name(self, project_name): + self._folders_model.set_project_name(project_name) + + def _clear(self): + self._folders_model.clear() + + def _on_folders_refresh_finished(self, event): + if event["sender"] != FOLDERS_MODEL_SENDER_NAME: + self._set_project_name(event["project_name"]) + + def _on_controller_refresh(self): + self._update_expected_selection() + + def _on_model_refresh(self): + if self._expected_selection: + self._set_expected_selection() + self._folders_proxy_model.sort(0) + self.refreshed.emit() + + def _get_selected_item_ids(self): + selection_model = self._folders_view.selectionModel() + item_ids = [] + for index in selection_model.selectedIndexes(): + item_id = index.data(FOLDER_ID_ROLE) + if item_id is not None: + item_ids.append(item_id) + return item_ids + + def _on_selection_change(self): + item_ids = self._get_selected_item_ids() + self._controller.set_selected_folders(item_ids) + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _update_expected_selection(self, expected_data=None): + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + folder_data = expected_data.get("folder") + if not folder_data or not folder_data["current"]: + return + + folder_id = folder_data["id"] + self._expected_selection = folder_id + if not self._folders_model.is_refreshing: + self._set_expected_selection() + + def _set_expected_selection(self): + folder_id = self._expected_selection + selected_ids = self._get_selected_item_ids() + self._expected_selection = None + skip_selection = ( + folder_id is None + or ( + folder_id in selected_ids + and len(selected_ids) == 1 + ) + ) + if not skip_selection: + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + proxy_index = self._folders_proxy_model.mapFromSource(index) + self._folders_view.setCurrentIndex(proxy_index) + self._controller.expected_folder_selected(folder_id) diff --git a/openpype/tools/ayon_loader/ui/info_widget.py b/openpype/tools/ayon_loader/ui/info_widget.py new file mode 100644 index 0000000000..b7d1b0811f --- /dev/null +++ b/openpype/tools/ayon_loader/ui/info_widget.py @@ -0,0 +1,141 @@ +import datetime + +from qtpy import QtWidgets + +from openpype.tools.utils.lib import format_version + + +class VersionTextEdit(QtWidgets.QTextEdit): + """QTextEdit that displays version specific information. + + This also overrides the context menu to add actions like copying + source path to clipboard or copying the raw data of the version + to clipboard. + + """ + def __init__(self, controller, parent): + super(VersionTextEdit, self).__init__(parent=parent) + + self._version_item = None + self._product_item = None + + self._controller = controller + + # Reset + self.set_current_item() + + def set_current_item(self, product_item=None, version_item=None): + """ + + Args: + product_item (Union[ProductItem, None]): Product item. + version_item (Union[VersionItem, None]): Version item to display. + """ + + self._product_item = product_item + self._version_item = version_item + + if version_item is None: + # Reset state to empty + self.setText("") + return + + version_label = format_version(abs(version_item.version)) + if version_item.version < 0: + version_label = "Hero version {}".format(version_label) + + # Define readable creation timestamp + created = version_item.published_time + created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ") + created = datetime.datetime.strftime(created, "%b %d %Y %H:%M") + + comment = version_item.comment or "No comment" + source = version_item.source or "No source" + + self.setHtml( + ( + "

{product_name}

" + "

{version_label}

" + "Comment
" + "{comment}

" + + "Created
" + "{created}

" + + "Source
" + "{source}" + ).format( + product_name=product_item.product_name, + version_label=version_label, + comment=comment, + created=created, + source=source, + ) + ) + + def contextMenuEvent(self, event): + """Context menu with additional actions""" + menu = self.createStandardContextMenu() + + # Add additional actions when any text, so we can assume + # the version is set. + source = None + if self._version_item is not None: + source = self._version_item.source + + if source: + menu.addSeparator() + action = QtWidgets.QAction( + "Copy source path to clipboard", menu + ) + action.triggered.connect(self._on_copy_source) + menu.addAction(action) + + menu.exec_(event.globalPos()) + + def _on_copy_source(self): + """Copy formatted source path to clipboard.""" + + source = self._version_item.source + if not source: + return + + filled_source = self._controller.fill_root_in_source(source) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(filled_source) + + +class InfoWidget(QtWidgets.QWidget): + """A Widget that display information about a specific version""" + def __init__(self, controller, parent): + super(InfoWidget, self).__init__(parent=parent) + + label_widget = QtWidgets.QLabel("Version Info", self) + info_text_widget = VersionTextEdit(controller, self) + info_text_widget.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label_widget, 0) + layout.addWidget(info_text_widget, 1) + + self._controller = controller + + self._info_text_widget = info_text_widget + self._label_widget = label_widget + + def set_selected_version_info(self, project_name, items): + if not items or not project_name: + self._info_text_widget.set_current_item() + return + first_item = next(iter(items)) + product_item = self._controller.get_product_item( + project_name, + first_item["product_id"], + ) + version_id = first_item["version_id"] + version_item = None + if product_item is not None: + version_item = product_item.version_items.get(version_id) + + self._info_text_widget.set_current_item(product_item, version_item) diff --git a/openpype/tools/ayon_loader/ui/product_group_dialog.py b/openpype/tools/ayon_loader/ui/product_group_dialog.py new file mode 100644 index 0000000000..5737ce58a4 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/product_group_dialog.py @@ -0,0 +1,45 @@ +from qtpy import QtWidgets + +from openpype.tools.utils import PlaceholderLineEdit + + +class ProductGroupDialog(QtWidgets.QDialog): + def __init__(self, controller, parent): + super(ProductGroupDialog, self).__init__(parent) + self.setWindowTitle("Grouping products") + self.setMinimumWidth(250) + self.setModal(True) + + main_label = QtWidgets.QLabel("Group Name", self) + + group_name_input = PlaceholderLineEdit(self) + group_name_input.setPlaceholderText("Remain blank to ungroup..") + + group_btn = QtWidgets.QPushButton("Apply", self) + group_btn.setAutoDefault(True) + group_btn.setDefault(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(main_label, 0) + layout.addWidget(group_name_input, 0) + layout.addWidget(group_btn, 0) + + group_btn.clicked.connect(self._on_apply_click) + + self._project_name = None + self._product_ids = set() + + self._controller = controller + self._group_btn = group_btn + self._group_name_input = group_name_input + + def set_product_ids(self, project_name, product_ids): + self._project_name = project_name + self._product_ids = product_ids + + def _on_apply_click(self): + group_name = self._group_name_input.text().strip() or None + self._controller.change_products_group( + self._project_name, self._product_ids, group_name + ) + self.close() diff --git a/openpype/tools/ayon_loader/ui/product_types_widget.py b/openpype/tools/ayon_loader/ui/product_types_widget.py new file mode 100644 index 0000000000..a84a7ff846 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/product_types_widget.py @@ -0,0 +1,220 @@ +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.ayon_utils.widgets import get_qt_icon + +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 + + +class ProductTypesQtModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + filter_changed = QtCore.Signal() + + def __init__(self, controller): + super(ProductTypesQtModel, self).__init__() + self._controller = controller + + self._refreshing = False + self._bulk_change = False + self._items_by_name = {} + + def is_refreshing(self): + return self._refreshing + + def get_filter_info(self): + """Product types filtering info. + + Returns: + dict[str, bool]: Filtering value by product type name. False value + means to hide product type. + """ + + return { + name: item.checkState() == QtCore.Qt.Checked + for name, item in self._items_by_name.items() + } + + def refresh(self, project_name): + self._refreshing = True + product_type_items = self._controller.get_product_type_items( + project_name) + + items_to_remove = set(self._items_by_name.keys()) + new_items = [] + for product_type_item in product_type_items: + name = product_type_item.name + items_to_remove.discard(name) + item = self._items_by_name.get(product_type_item.name) + if item is None: + item = QtGui.QStandardItem(name) + item.setData(name, PRODUCT_TYPE_ROLE) + item.setEditable(False) + item.setCheckable(True) + new_items.append(item) + self._items_by_name[name] = item + + item.setCheckState( + QtCore.Qt.Checked + if product_type_item.checked + else QtCore.Qt.Unchecked + ) + icon = get_qt_icon(product_type_item.icon) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + if new_items: + root_item.appendRows(new_items) + + for name in items_to_remove: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + self._refreshing = False + self.refreshed.emit() + + def setData(self, index, value, role=None): + checkstate_changed = False + if role is None: + role = QtCore.Qt.EditRole + elif role == QtCore.Qt.CheckStateRole: + checkstate_changed = True + output = super(ProductTypesQtModel, self).setData(index, value, role) + if checkstate_changed and not self._bulk_change: + self.filter_changed.emit() + return output + + def change_state_for_all(self, checked): + if self._items_by_name: + self.change_states(checked, self._items_by_name.keys()) + + def change_states(self, checked, product_types): + product_types = set(product_types) + if not product_types: + return + + if checked is None: + state = None + elif checked: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + + self._bulk_change = True + + changed = False + for product_type in product_types: + item = self._items_by_name.get(product_type) + if item is None: + continue + new_state = state + item_checkstate = item.checkState() + if new_state is None: + if item_checkstate == QtCore.Qt.Checked: + new_state = QtCore.Qt.Unchecked + else: + new_state = QtCore.Qt.Checked + elif item_checkstate == new_state: + continue + changed = True + item.setCheckState(new_state) + + self._bulk_change = False + + if changed: + self.filter_changed.emit() + + +class ProductTypesView(QtWidgets.QListView): + filter_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(ProductTypesView, self).__init__(parent) + + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.setAlternatingRowColors(True) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + product_types_model = ProductTypesQtModel(controller) + product_types_proxy_model = QtCore.QSortFilterProxyModel() + product_types_proxy_model.setSourceModel(product_types_model) + + self.setModel(product_types_proxy_model) + + product_types_model.refreshed.connect(self._on_refresh_finished) + product_types_model.filter_changed.connect(self._on_filter_change) + self.customContextMenuRequested.connect(self._on_context_menu) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + + self._controller = controller + + self._product_types_model = product_types_model + self._product_types_proxy_model = product_types_proxy_model + + def get_filter_info(self): + return self._product_types_model.get_filter_info() + + def _on_project_change(self, event): + project_name = event["project_name"] + self._product_types_model.refresh(project_name) + + def _on_refresh_finished(self): + self.filter_changed.emit() + + def _on_filter_change(self): + if not self._product_types_model.is_refreshing(): + self.filter_changed.emit() + + def _change_selection_state(self, checkstate): + selection_model = self.selectionModel() + product_types = { + index.data(PRODUCT_TYPE_ROLE) + for index in selection_model.selectedIndexes() + } + product_types.discard(None) + self._product_types_model.change_states(checkstate, product_types) + + def _on_enable_all(self): + self._product_types_model.change_state_for_all(True) + + def _on_disable_all(self): + self._product_types_model.change_state_for_all(False) + + def _on_context_menu(self, pos): + menu = QtWidgets.QMenu(self) + + # Add enable all action + action_check_all = QtWidgets.QAction(menu) + action_check_all.setText("Enable All") + action_check_all.triggered.connect(self._on_enable_all) + # Add disable all action + action_uncheck_all = QtWidgets.QAction(menu) + action_uncheck_all.setText("Disable All") + action_uncheck_all.triggered.connect(self._on_disable_all) + + menu.addAction(action_check_all) + menu.addAction(action_uncheck_all) + + # Get mouse position + global_pos = self.viewport().mapToGlobal(pos) + menu.exec_(global_pos) + + def event(self, event): + if event.type() == QtCore.QEvent.KeyPress: + if event.key() == QtCore.Qt.Key_Space: + self._change_selection_state(None) + return True + + if event.key() == QtCore.Qt.Key_Backspace: + self._change_selection_state(False) + return True + + if event.key() == QtCore.Qt.Key_Return: + self._change_selection_state(True) + return True + + return super(ProductTypesView, self).event(event) diff --git a/openpype/tools/ayon_loader/ui/products_delegates.py b/openpype/tools/ayon_loader/ui/products_delegates.py new file mode 100644 index 0000000000..979fa57fd2 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_delegates.py @@ -0,0 +1,271 @@ +import numbers +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.utils.lib import format_version + +from .products_model import ( + PRODUCT_ID_ROLE, + VERSION_NAME_EDIT_ROLE, + VERSION_ID_ROLE, + PRODUCT_IN_SCENE_ROLE, + ACTIVE_SITE_ICON_ROLE, + REMOTE_SITE_ICON_ROLE, + REPRESENTATIONS_COUNT_ROLE, + SYNC_ACTIVE_SITE_AVAILABILITY, + SYNC_REMOTE_SITE_AVAILABILITY, +) + + +class VersionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal(str) + + def __init__(self, product_id, parent): + super(VersionComboBox, self).__init__(parent) + self._product_id = product_id + self._items_by_id = {} + + self._current_id = None + + self.currentIndexChanged.connect(self._on_index_change) + + def update_versions(self, version_items, current_version_id): + model = self.model() + root_item = model.invisibleRootItem() + version_items = list(reversed(version_items)) + version_ids = [ + version_item.version_id + for version_item in version_items + ] + if current_version_id not in version_ids and version_ids: + current_version_id = version_ids[0] + self._current_id = current_version_id + + to_remove = set(self._items_by_id.keys()) - set(version_ids) + for item_id in to_remove: + item = self._items_by_id.pop(item_id) + root_item.removeRow(item.row()) + + for idx, version_item in enumerate(version_items): + version_id = version_item.version_id + + item = self._items_by_id.get(version_id) + if item is None: + label = format_version( + abs(version_item.version), version_item.is_hero + ) + item = QtGui.QStandardItem(label) + item.setData(version_id, QtCore.Qt.UserRole) + self._items_by_id[version_id] = item + + if item.row() != idx: + root_item.insertRow(idx, item) + + index = version_ids.index(current_version_id) + if self.currentIndex() != index: + self.setCurrentIndex(index) + + def _on_index_change(self): + idx = self.currentIndex() + value = self.itemData(idx) + if value == self._current_id: + return + self._current_id = value + self.value_changed.emit(self._product_id) + + +class VersionDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that display version integer formatted as version string.""" + + version_changed = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(VersionDelegate, self).__init__(*args, **kwargs) + self._editor_by_product_id = {} + + def displayText(self, value, locale): + if not isinstance(value, numbers.Integral): + return "N/A" + return format_version(abs(value), value < 0) + + def paint(self, painter, option, index): + fg_color = index.data(QtCore.Qt.ForegroundRole) + if fg_color: + if isinstance(fg_color, QtGui.QBrush): + fg_color = fg_color.color() + elif isinstance(fg_color, QtGui.QColor): + pass + else: + fg_color = None + + if not fg_color: + return super(VersionDelegate, self).paint(painter, option, index) + + if option.widget: + style = option.widget.style() + else: + style = QtWidgets.QApplication.style() + + style.drawControl( + style.CE_ItemViewItem, option, painter, option.widget + ) + + painter.save() + + text = self.displayText( + index.data(QtCore.Qt.DisplayRole), option.locale + ) + pen = painter.pen() + pen.setColor(fg_color) + painter.setPen(pen) + + text_rect = style.subElementRect(style.SE_ItemViewItemText, option) + text_margin = style.proxy().pixelMetric( + style.PM_FocusFrameHMargin, option, option.widget + ) + 1 + + painter.drawText( + text_rect.adjusted(text_margin, 0, - text_margin, 0), + option.displayAlignment, + text + ) + + painter.restore() + + def createEditor(self, parent, option, index): + product_id = index.data(PRODUCT_ID_ROLE) + if not product_id: + return + + editor = VersionComboBox(product_id, parent) + self._editor_by_product_id[product_id] = editor + editor.value_changed.connect(self._on_editor_change) + + return editor + + def _on_editor_change(self, product_id): + editor = self._editor_by_product_id[product_id] + + # Update model data + self.commitData.emit(editor) + # Display model data + self.version_changed.emit() + + def setEditorData(self, editor, index): + editor.clear() + + # Current value of the index + versions = index.data(VERSION_NAME_EDIT_ROLE) or [] + version_id = index.data(VERSION_ID_ROLE) + editor.update_versions(versions, version_id) + + def setModelData(self, editor, model, index): + """Apply the integer version back in the model""" + + version_id = editor.itemData(editor.currentIndex()) + model.setData(index, version_id, VERSION_NAME_EDIT_ROLE) + + +class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for Loaded in Scene state columns. + + Shows "Yes" or "No" for 1 or 0 values, or "N/A" for other values. + Colorizes green or dark grey based on values. + """ + + def __init__(self, *args, **kwargs): + super(LoadedInSceneDelegate, self).__init__(*args, **kwargs) + self._colors = { + 1: QtGui.QColor(80, 170, 80), + 0: QtGui.QColor(90, 90, 90), + } + self._default_color = QtGui.QColor(90, 90, 90) + + def displayText(self, value, locale): + if value == 0: + return "No" + elif value == 1: + return "Yes" + return "N/A" + + def initStyleOption(self, option, index): + super(LoadedInSceneDelegate, self).initStyleOption(option, index) + + # Colorize based on value + value = index.data(PRODUCT_IN_SCENE_ROLE) + color = self._colors.get(value, self._default_color) + option.palette.setBrush(QtGui.QPalette.Text, color) + + +class SiteSyncDelegate(QtWidgets.QStyledItemDelegate): + """Paints icons and downloaded representation ration for both sites.""" + + def paint(self, painter, option, index): + super(SiteSyncDelegate, self).paint(painter, option, index) + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + active_icon = index.data(ACTIVE_SITE_ICON_ROLE) + remote_icon = index.data(REMOTE_SITE_ICON_ROLE) + + availability_active = "{}/{}".format( + index.data(SYNC_ACTIVE_SITE_AVAILABILITY), + index.data(REPRESENTATIONS_COUNT_ROLE) + ) + availability_remote = "{}/{}".format( + index.data(SYNC_REMOTE_SITE_AVAILABILITY), + index.data(REPRESENTATIONS_COUNT_ROLE) + ) + + if availability_active is None or availability_remote is None: + return + + items_to_draw = [ + (value, icon) + for value, icon in ( + (availability_active, active_icon), + (availability_remote, remote_icon), + ) + if icon + ] + if not items_to_draw: + return + + icon_size = QtCore.QSize(24, 24) + padding = 10 + pos_x = option.rect.x() + + item_width = int(option.rect.width() / len(items_to_draw)) + if item_width < 1: + item_width = 0 + + for value, icon in items_to_draw: + item_rect = QtCore.QRect( + pos_x, + option.rect.y(), + item_width, + option.rect.height() + ) + # Prepare pos_x for next item + pos_x = item_rect.x() + item_rect.width() + + pixmap = icon.pixmap(icon.actualSize(icon_size)) + point = QtCore.QPoint( + item_rect.x() + padding, + item_rect.y() + ((item_rect.height() - pixmap.height()) * 0.5) + ) + painter.drawPixmap(point, pixmap) + + icon_offset = icon_size.width() + (padding * 2) + text_rect = QtCore.QRect(item_rect) + text_rect.setLeft(text_rect.left() + icon_offset) + if text_rect.width() < 1: + continue + + painter.drawText( + text_rect, + option.displayAlignment, + value + ) + + def displayText(self, value, locale): + pass diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py new file mode 100644 index 0000000000..84f5bc9a5f --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -0,0 +1,635 @@ +import collections + +import qtawesome +from qtpy import QtGui, QtCore + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.widgets import get_qt_icon + +PRODUCTS_MODEL_SENDER_NAME = "qt_products_model" + +GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1 +MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4 +PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6 +PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 10 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 15 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 16 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 17 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 19 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 22 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23 +REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 24 +SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 25 +SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 26 + + +class ProductsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + version_changed = QtCore.Signal() + column_labels = [ + "Product name", + "Product type", + "Folder", + "Version", + "Time", + "Author", + "Frames", + "Duration", + "Handles", + "Step", + "In scene", + "Availability", + ] + merged_items_colors = [ + ("#{0:02x}{1:02x}{2:02x}".format(*c), QtGui.QColor(*c)) + for c in [ + (55, 161, 222), # Light Blue + (231, 176, 0), # Yellow + (154, 13, 255), # Purple + (130, 184, 30), # Light Green + (211, 79, 63), # Light Red + (179, 181, 182), # Grey + (194, 57, 179), # Pink + (0, 120, 215), # Dark Blue + (0, 204, 106), # Dark Green + (247, 99, 12), # Orange + ] + ] + + version_col = column_labels.index("Version") + published_time_col = column_labels.index("Time") + folders_label_col = column_labels.index("Folder") + in_scene_col = column_labels.index("In scene") + site_sync_avail_col = column_labels.index("Availability") + + def __init__(self, controller): + super(ProductsModel, self).__init__() + self.setColumnCount(len(self.column_labels)) + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + self._controller = controller + + # Variables to store 'QStandardItem' + self._items_by_id = {} + self._group_items_by_name = {} + self._merged_items_by_id = {} + + # product item objects (they have version information) + self._product_items_by_id = {} + self._grouping_enabled = True + self._reset_merge_color = False + self._color_iterator = self._color_iter() + self._group_icon = None + + self._last_project_name = None + self._last_folder_ids = [] + + def get_product_item_indexes(self): + return [ + item.index() + for item in self._items_by_id.values() + ] + + def get_product_item_by_id(self, product_id): + """ + + Args: + product_id (str): Product id. + + Returns: + Union[ProductItem, None]: Product item with version information. + """ + + return self._product_items_by_id.get(product_id) + + def set_enable_grouping(self, enable_grouping): + if enable_grouping is self._grouping_enabled: + return + self._grouping_enabled = enable_grouping + # Ignore change if groups are not available + self.refresh(self._last_project_name, self._last_folder_ids) + + def flags(self, index): + # Make the version column editable + if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): + return ( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEditable + ) + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(ProductsModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + if not index.isValid(): + return None + + col = index.column() + if col == 0: + return super(ProductsModel, self).data(index, role) + + if role == QtCore.Qt.DecorationRole: + if col == 1: + role = PRODUCT_TYPE_ICON_ROLE + else: + return None + + if ( + role == VERSION_NAME_EDIT_ROLE + or (role == QtCore.Qt.EditRole and col == self.version_col) + ): + index = self.index(index.row(), 0, index.parent()) + product_id = index.data(PRODUCT_ID_ROLE) + product_item = self._product_items_by_id.get(product_id) + if product_item is None: + return None + return list(product_item.version_items.values()) + + if role == QtCore.Qt.EditRole: + return None + + if role == QtCore.Qt.DisplayRole: + if not index.data(PRODUCT_ID_ROLE): + return None + if col == self.version_col: + role = VERSION_NAME_ROLE + elif col == 1: + role = PRODUCT_TYPE_ROLE + elif col == 2: + role = FOLDER_LABEL_ROLE + elif col == 4: + role = VERSION_PUBLISH_TIME_ROLE + elif col == 5: + role = VERSION_AUTHOR_ROLE + elif col == 6: + role = VERSION_FRAME_RANGE_ROLE + elif col == 7: + role = VERSION_DURATION_ROLE + elif col == 8: + role = VERSION_HANDLES_ROLE + elif col == 9: + role = VERSION_STEP_ROLE + elif col == 10: + role = PRODUCT_IN_SCENE_ROLE + elif col == 11: + role = VERSION_AVAILABLE_ROLE + else: + return None + + index = self.index(index.row(), 0, index.parent()) + + return super(ProductsModel, self).data(index, role) + + def setData(self, index, value, role=None): + if not index.isValid(): + return False + + if role is None: + role = QtCore.Qt.EditRole + + col = index.column() + if col == self.version_col and role == QtCore.Qt.EditRole: + role = VERSION_NAME_EDIT_ROLE + + if role == VERSION_NAME_EDIT_ROLE: + if col != 0: + index = self.index(index.row(), 0, index.parent()) + product_id = index.data(PRODUCT_ID_ROLE) + product_item = self._product_items_by_id[product_id] + final_version_item = None + for v_id, version_item in product_item.version_items.items(): + if v_id == value: + final_version_item = version_item + break + + if final_version_item is None: + return False + if index.data(VERSION_ID_ROLE) == final_version_item.version_id: + return True + item = self.itemFromIndex(index) + self._set_version_data_to_product_item(item, final_version_item) + self.version_changed.emit() + return True + return super(ProductsModel, self).setData(index, value, role) + + def _get_next_color(self): + return next(self._color_iterator) + + def _color_iter(self): + while True: + for color in self.merged_items_colors: + if self._reset_merge_color: + self._reset_merge_color = False + break + yield color + + def _clear(self): + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + self._items_by_id = {} + self._group_items_by_name = {} + self._merged_items_by_id = {} + self._product_items_by_id = {} + self._reset_merge_color = True + + def _get_group_icon(self): + if self._group_icon is None: + self._group_icon = qtawesome.icon( + "fa.object-group", + color=get_default_entity_icon_color() + ) + return self._group_icon + + def _get_group_model_item(self, group_name): + model_item = self._group_items_by_name.get(group_name) + if model_item is None: + model_item = QtGui.QStandardItem(group_name) + model_item.setData( + self._get_group_icon(), QtCore.Qt.DecorationRole + ) + model_item.setData(0, GROUP_TYPE_ROLE) + model_item.setEditable(False) + model_item.setColumnCount(self.columnCount()) + self._group_items_by_name[group_name] = model_item + return model_item + + def _get_merged_model_item(self, path, count, hex_color): + model_item = self._merged_items_by_id.get(path) + if model_item is None: + model_item = QtGui.QStandardItem() + model_item.setData(1, GROUP_TYPE_ROLE) + model_item.setData(hex_color, MERGED_COLOR_ROLE) + model_item.setEditable(False) + model_item.setColumnCount(self.columnCount()) + self._merged_items_by_id[path] = model_item + label = "{} ({})".format(path, count) + model_item.setData(label, QtCore.Qt.DisplayRole) + return model_item + + def _set_version_data_to_product_item(self, model_item, version_item): + """ + + Args: + model_item (QtGui.QStandardItem): Item which should have values + from version item. + version_item (VersionItem): Item from entities model with + information about version. + """ + + model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.version, VERSION_NAME_ROLE) + model_item.setData(version_item.version_id, VERSION_ID_ROLE) + model_item.setData(version_item.is_hero, VERSION_HERO_ROLE) + model_item.setData( + version_item.published_time, VERSION_PUBLISH_TIME_ROLE + ) + model_item.setData(version_item.author, VERSION_AUTHOR_ROLE) + model_item.setData(version_item.frame_range, VERSION_FRAME_RANGE_ROLE) + model_item.setData(version_item.duration, VERSION_DURATION_ROLE) + model_item.setData(version_item.handles, VERSION_HANDLES_ROLE) + model_item.setData(version_item.step, VERSION_STEP_ROLE) + model_item.setData( + version_item.thumbnail_id, VERSION_THUMBNAIL_ID_ROLE) + + # TODO call site sync methods for all versions at once + project_name = self._last_project_name + version_id = version_item.version_id + repre_count = self._controller.get_versions_representation_count( + project_name, [version_id] + )[version_id] + active, remote = self._controller.get_version_sync_availability( + project_name, [version_id] + )[version_id] + + model_item.setData(repre_count, REPRESENTATIONS_COUNT_ROLE) + model_item.setData(active, SYNC_ACTIVE_SITE_AVAILABILITY) + model_item.setData(remote, SYNC_REMOTE_SITE_AVAILABILITY) + + def _get_product_model_item( + self, + product_item, + active_site_icon, + remote_site_icon + ): + model_item = self._items_by_id.get(product_item.product_id) + versions = list(product_item.version_items.values()) + versions.sort() + last_version = versions[-1] + if model_item is None: + product_id = product_item.product_id + model_item = QtGui.QStandardItem(product_item.product_name) + model_item.setEditable(False) + icon = get_qt_icon(product_item.product_icon) + product_type_icon = get_qt_icon(product_item.product_type_icon) + model_item.setColumnCount(self.columnCount()) + model_item.setData(icon, QtCore.Qt.DecorationRole) + model_item.setData(product_id, PRODUCT_ID_ROLE) + model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE) + model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) + model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) + model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) + + self._product_items_by_id[product_id] = product_item + self._items_by_id[product_id] = model_item + + model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) + in_scene = 1 if product_item.product_in_scene else 0 + model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) + + model_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE) + model_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE) + + self._set_version_data_to_product_item(model_item, last_version) + return model_item + + def get_last_project_name(self): + return self._last_project_name + + def refresh(self, project_name, folder_ids): + self._clear() + + self._last_project_name = project_name + self._last_folder_ids = folder_ids + + active_site_icon_def = self._controller.get_active_site_icon_def( + project_name + ) + remote_site_icon_def = self._controller.get_remote_site_icon_def( + project_name + ) + active_site_icon = get_qt_icon(active_site_icon_def) + remote_site_icon = get_qt_icon(remote_site_icon_def) + + product_items = self._controller.get_product_items( + project_name, + folder_ids, + sender=PRODUCTS_MODEL_SENDER_NAME + ) + product_items_by_id = { + product_item.product_id: product_item + for product_item in product_items + } + + # Prepare product groups + product_name_matches_by_group = collections.defaultdict(dict) + for product_item in product_items_by_id.values(): + group_name = None + if self._grouping_enabled: + group_name = product_item.group_name + + product_name = product_item.product_name + group = product_name_matches_by_group[group_name] + if product_name not in group: + group[product_name] = [product_item] + continue + group[product_name].append(product_item) + + group_names = set(product_name_matches_by_group.keys()) + + root_item = self.invisibleRootItem() + new_root_items = [] + merged_paths = set() + for group_name in group_names: + key_parts = [] + if group_name: + key_parts.append(group_name) + + groups = product_name_matches_by_group[group_name] + merged_product_items = {} + top_items = [] + group_product_types = set() + for product_name, product_items in groups.items(): + group_product_types |= {p.product_type for p in product_items} + if len(product_items) == 1: + top_items.append(product_items[0]) + else: + path = "/".join(key_parts + [product_name]) + merged_paths.add(path) + merged_product_items[path] = ( + product_name, + product_items, + ) + + parent_item = None + if group_name: + parent_item = self._get_group_model_item(group_name) + parent_item.setData( + "|".join(group_product_types), PRODUCT_TYPE_ROLE) + + new_items = [] + if parent_item is not None and parent_item.row() < 0: + new_root_items.append(parent_item) + + for product_item in top_items: + item = self._get_product_model_item( + product_item, + active_site_icon, + remote_site_icon, + ) + new_items.append(item) + + for path_info in merged_product_items.values(): + product_name, product_items = path_info + (merged_color_hex, merged_color_qt) = self._get_next_color() + merged_color = qtawesome.icon( + "fa.circle", color=merged_color_qt) + merged_item = self._get_merged_model_item( + product_name, len(product_items), merged_color_hex) + merged_item.setData(merged_color, QtCore.Qt.DecorationRole) + new_items.append(merged_item) + + merged_product_types = set() + new_merged_items = [] + for product_item in product_items: + item = self._get_product_model_item( + product_item, + active_site_icon, + remote_site_icon, + ) + new_merged_items.append(item) + merged_product_types.add(product_item.product_type) + + merged_item.setData( + "|".join(merged_product_types), PRODUCT_TYPE_ROLE) + if new_merged_items: + merged_item.appendRows(new_merged_items) + + if not new_items: + continue + + if parent_item is None: + new_root_items.extend(new_items) + else: + parent_item.appendRows(new_items) + + if new_root_items: + root_item.appendRows(new_root_items) + + self.refreshed.emit() + # --------------------------------- + # This implementation does not call '_clear' at the start + # but is more complex and probably slower + # --------------------------------- + # def _remove_items(self, items): + # if not items: + # return + # root_item = self.invisibleRootItem() + # for item in items: + # row = item.row() + # if row < 0: + # continue + # parent = item.parent() + # if parent is None: + # parent = root_item + # parent.removeRow(row) + # + # def _remove_group_items(self, group_names): + # group_items = [ + # self._group_items_by_name.pop(group_name) + # for group_name in group_names + # ] + # self._remove_items(group_items) + # + # def _remove_merged_items(self, paths): + # merged_items = [ + # self._merged_items_by_id.pop(path) + # for path in paths + # ] + # self._remove_items(merged_items) + # + # def _remove_product_items(self, product_ids): + # product_items = [] + # for product_id in product_ids: + # self._product_items_by_id.pop(product_id) + # product_items.append(self._items_by_id.pop(product_id)) + # self._remove_items(product_items) + # + # def _add_to_new_items(self, item, parent_item, new_items, root_item): + # if item.row() < 0: + # new_items.append(item) + # else: + # item_parent = item.parent() + # if item_parent is not parent_item: + # if item_parent is None: + # item_parent = root_item + # item_parent.takeRow(item.row()) + # new_items.append(item) + + # def refresh(self, project_name, folder_ids): + # product_items = self._controller.get_product_items( + # project_name, + # folder_ids, + # sender=PRODUCTS_MODEL_SENDER_NAME + # ) + # product_items_by_id = { + # product_item.product_id: product_item + # for product_item in product_items + # } + # # Remove product items that are not available + # product_ids_to_remove = ( + # set(self._items_by_id.keys()) - set(product_items_by_id.keys()) + # ) + # self._remove_product_items(product_ids_to_remove) + # + # # Prepare product groups + # product_name_matches_by_group = collections.defaultdict(dict) + # for product_item in product_items_by_id.values(): + # group_name = None + # if self._grouping_enabled: + # group_name = product_item.group_name + # + # product_name = product_item.product_name + # group = product_name_matches_by_group[group_name] + # if product_name not in group: + # group[product_name] = [product_item] + # continue + # group[product_name].append(product_item) + # + # group_names = set(product_name_matches_by_group.keys()) + # + # root_item = self.invisibleRootItem() + # new_root_items = [] + # merged_paths = set() + # for group_name in group_names: + # key_parts = [] + # if group_name: + # key_parts.append(group_name) + # + # groups = product_name_matches_by_group[group_name] + # merged_product_items = {} + # top_items = [] + # for product_name, product_items in groups.items(): + # if len(product_items) == 1: + # top_items.append(product_items[0]) + # else: + # path = "/".join(key_parts + [product_name]) + # merged_paths.add(path) + # merged_product_items[path] = product_items + # + # parent_item = None + # if group_name: + # parent_item = self._get_group_model_item(group_name) + # + # new_items = [] + # if parent_item is not None and parent_item.row() < 0: + # new_root_items.append(parent_item) + # + # for product_item in top_items: + # item = self._get_product_model_item(product_item) + # self._add_to_new_items( + # item, parent_item, new_items, root_item + # ) + # + # for path, product_items in merged_product_items.items(): + # merged_item = self._get_merged_model_item(path) + # self._add_to_new_items( + # merged_item, parent_item, new_items, root_item + # ) + # + # new_merged_items = [] + # for product_item in product_items: + # item = self._get_product_model_item(product_item) + # self._add_to_new_items( + # item, merged_item, new_merged_items, root_item + # ) + # + # if new_merged_items: + # merged_item.appendRows(new_merged_items) + # + # if not new_items: + # continue + # + # if parent_item is not None: + # parent_item.appendRows(new_items) + # continue + # + # new_root_items.extend(new_items) + # + # root_item.appendRows(new_root_items) + # + # merged_item_ids_to_remove = ( + # set(self._merged_items_by_id.keys()) - merged_paths + # ) + # group_names_to_remove = ( + # set(self._group_items_by_name.keys()) - set(group_names) + # ) + # self._remove_merged_items(merged_item_ids_to_remove) + # self._remove_group_items(group_names_to_remove) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py new file mode 100644 index 0000000000..99faefe693 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -0,0 +1,424 @@ +import collections + +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) +from openpype.tools.utils.delegates import PrettyTimeDelegate + +from .products_model import ( + ProductsModel, + PRODUCTS_MODEL_SENDER_NAME, + PRODUCT_TYPE_ROLE, + GROUP_TYPE_ROLE, + MERGED_COLOR_ROLE, + FOLDER_ID_ROLE, + PRODUCT_ID_ROLE, + VERSION_ID_ROLE, + VERSION_THUMBNAIL_ID_ROLE, +) +from .products_delegates import ( + VersionDelegate, + LoadedInSceneDelegate, + SiteSyncDelegate +) +from .actions_utils import show_actions_menu + + +class ProductsProxyModel(RecursiveSortFilterProxyModel): + def __init__(self, parent=None): + super(ProductsProxyModel, self).__init__(parent) + + self._product_type_filters = {} + self._ascending_sort = True + + def set_product_type_filters(self, product_type_filters): + self._product_type_filters = product_type_filters + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + source_model = self.sourceModel() + index = source_model.index(source_row, 0, source_parent) + product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE) + product_types = [] + if product_types_s: + product_types = product_types_s.split("|") + + for product_type in product_types: + if not self._product_type_filters.get(product_type, True): + return False + return super(ProductsProxyModel, self).filterAcceptsRow( + source_row, source_parent) + + def lessThan(self, left, right): + l_model = left.model() + r_model = right.model() + left_group_type = l_model.data(left, GROUP_TYPE_ROLE) + right_group_type = r_model.data(right, GROUP_TYPE_ROLE) + # Groups are always on top, merged product types are below + # and items without group at the bottom + # QUESTION Do we need to do it this way? + if left_group_type != right_group_type: + if left_group_type is None: + output = False + elif right_group_type is None: + output = True + else: + output = left_group_type < right_group_type + if not self._ascending_sort: + output = not output + return output + return super(ProductsProxyModel, self).lessThan(left, right) + + def sort(self, column, order=None): + if order is None: + order = QtCore.Qt.AscendingOrder + self._ascending_sort = order == QtCore.Qt.AscendingOrder + super(ProductsProxyModel, self).sort(column, order) + + +class ProductsWidget(QtWidgets.QWidget): + refreshed = QtCore.Signal() + merged_products_selection_changed = QtCore.Signal() + selection_changed = QtCore.Signal() + version_changed = QtCore.Signal() + default_widths = ( + 200, # Product name + 90, # Product type + 130, # Folder label + 60, # Version + 125, # Time + 75, # Author + 75, # Frames + 60, # Duration + 55, # Handles + 10, # Step + 25, # Loaded in scene + 65, # Site sync info + ) + + def __init__(self, controller, parent): + super(ProductsWidget, self).__init__(parent) + + self._controller = controller + + products_view = DeselectableTreeView(self) + # TODO - define custom object name in style + products_view.setObjectName("SubsetView") + products_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + products_view.setAllColumnsShowFocus(True) + # TODO - add context menu + products_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + products_view.setSortingEnabled(True) + # Sort by product type + products_view.sortByColumn(1, QtCore.Qt.AscendingOrder) + products_view.setAlternatingRowColors(True) + + products_model = ProductsModel(controller) + products_proxy_model = ProductsProxyModel() + products_proxy_model.setSourceModel(products_model) + + products_view.setModel(products_proxy_model) + + for idx, width in enumerate(self.default_widths): + products_view.setColumnWidth(idx, width) + + version_delegate = VersionDelegate() + products_view.setItemDelegateForColumn( + products_model.version_col, version_delegate) + + time_delegate = PrettyTimeDelegate() + products_view.setItemDelegateForColumn( + products_model.published_time_col, time_delegate) + + in_scene_delegate = LoadedInSceneDelegate() + products_view.setItemDelegateForColumn( + products_model.in_scene_col, in_scene_delegate) + + site_sync_delegate = SiteSyncDelegate() + products_view.setItemDelegateForColumn( + products_model.site_sync_avail_col, site_sync_delegate) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(products_view, 1) + + products_proxy_model.rowsInserted.connect(self._on_rows_inserted) + products_proxy_model.rowsMoved.connect(self._on_rows_moved) + products_model.refreshed.connect(self._on_refresh) + products_view.customContextMenuRequested.connect( + self._on_context_menu) + products_view.selectionModel().selectionChanged.connect( + self._on_selection_change) + products_model.version_changed.connect(self._on_version_change) + + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_change, + ) + controller.register_event_callback( + "products.refresh.finished", + self._on_products_refresh_finished + ) + controller.register_event_callback( + "products.group.changed", + self._on_group_changed + ) + + self._products_view = products_view + self._products_model = products_model + self._products_proxy_model = products_proxy_model + + self._version_delegate = version_delegate + self._time_delegate = time_delegate + self._in_scene_delegate = in_scene_delegate + self._site_sync_delegate = site_sync_delegate + + self._selected_project_name = None + self._selected_folder_ids = set() + + self._selected_merged_products = [] + self._selected_versions_info = [] + + # Set initial state of widget + # - Hide folders column + self._update_folders_label_visible() + # - Hide in scene column if is not supported (this won't change) + products_view.setColumnHidden( + products_model.in_scene_col, + not controller.is_loaded_products_supported() + ) + self._set_site_sync_visibility( + self._controller.is_site_sync_enabled() + ) + + def set_name_filter(self, name): + """Set filter of product name. + + Args: + name (str): The string filter. + """ + + self._products_proxy_model.setFilterFixedString(name) + + def set_product_type_filter(self, product_type_filters): + """ + + Args: + product_type_filters (dict[str, bool]): The filter of product + types. + """ + + self._products_proxy_model.set_product_type_filters( + product_type_filters + ) + + def set_enable_grouping(self, enable_grouping): + self._products_model.set_enable_grouping(enable_grouping) + + def get_selected_merged_products(self): + return self._selected_merged_products + + def get_selected_version_info(self): + return self._selected_versions_info + + def refresh(self): + self._refresh_model() + + def _set_site_sync_visibility(self, site_sync_enabled): + self._products_view.setColumnHidden( + self._products_model.site_sync_avail_col, + not site_sync_enabled + ) + + def _fill_version_editor(self): + model = self._products_proxy_model + index_queue = collections.deque() + for row in range(model.rowCount()): + index_queue.append((row, None)) + + version_col = self._products_model.version_col + while index_queue: + (row, parent_index) = index_queue.popleft() + args = [row, 0] + if parent_index is not None: + args.append(parent_index) + index = model.index(*args) + rows = model.rowCount(index) + for row in range(rows): + index_queue.append((row, index)) + + product_id = model.data(index, PRODUCT_ID_ROLE) + if product_id is not None: + args[1] = version_col + v_index = model.index(*args) + self._products_view.openPersistentEditor(v_index) + + def _on_refresh(self): + self._fill_version_editor() + self.refreshed.emit() + + def _on_rows_inserted(self): + self._fill_version_editor() + + def _on_rows_moved(self): + self._fill_version_editor() + + def _refresh_model(self): + self._products_model.refresh( + self._selected_project_name, + self._selected_folder_ids + ) + + def _on_context_menu(self, point): + selection_model = self._products_view.selectionModel() + model = self._products_view.model() + project_name = self._products_model.get_last_project_name() + + version_ids = set() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + while indexes_queue: + index = indexes_queue.popleft() + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + version_id = model.data(index, VERSION_ID_ROLE) + if version_id is not None: + version_ids.add(version_id) + + action_items = self._controller.get_versions_action_items( + project_name, version_ids) + + # Prepare global point where to show the menu + global_point = self._products_view.mapToGlobal(point) + + result = show_actions_menu( + action_items, + global_point, + len(version_ids) == 1, + self + ) + action_item, options = result + if action_item is None or options is None: + return + + self._controller.trigger_action_item( + action_item.identifier, + options, + action_item.project_name, + version_ids=action_item.version_ids, + representation_ids=action_item.representation_ids, + ) + + def _on_selection_change(self): + selected_merged_products = [] + selection_model = self._products_view.selectionModel() + model = self._products_view.model() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + + # Helper for 'version_items' to avoid duplicated items + all_product_ids = set() + selected_version_ids = set() + # Version items contains information about selected version items + selected_versions_info = [] + while indexes_queue: + index = indexes_queue.popleft() + if index.column() != 0: + continue + + group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type is None: + product_id = model.data(index, PRODUCT_ID_ROLE) + # Skip duplicates - when group and item are selected the item + # would be in the loop multiple times + if product_id in all_product_ids: + continue + + all_product_ids.add(product_id) + + version_id = model.data(index, VERSION_ID_ROLE) + selected_version_ids.add(version_id) + + thumbnail_id = model.data(index, VERSION_THUMBNAIL_ID_ROLE) + selected_versions_info.append({ + "folder_id": model.data(index, FOLDER_ID_ROLE), + "product_id": product_id, + "version_id": version_id, + "thumbnail_id": thumbnail_id, + }) + continue + + if group_type == 0: + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + continue + + if group_type != 1: + continue + + item_folder_ids = set() + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + + folder_id = model.data(child_index, FOLDER_ID_ROLE) + item_folder_ids.add(folder_id) + + if not item_folder_ids: + continue + + hex_color = model.data(index, MERGED_COLOR_ROLE) + item_data = { + "color": hex_color, + "folder_ids": item_folder_ids + } + selected_merged_products.append(item_data) + + prev_selected_merged_products = self._selected_merged_products + self._selected_merged_products = selected_merged_products + self._selected_versions_info = selected_versions_info + + if selected_merged_products != prev_selected_merged_products: + self.merged_products_selection_changed.emit() + self.selection_changed.emit() + self._controller.set_selected_versions(selected_version_ids) + + def _on_version_change(self): + self._on_selection_change() + + def _on_folders_selection_change(self, event): + project_name = event["project_name"] + site_sync_enabled = self._controller.is_site_sync_enabled( + project_name + ) + self._set_site_sync_visibility(site_sync_enabled) + self._selected_project_name = project_name + self._selected_folder_ids = event["folder_ids"] + self._refresh_model() + self._update_folders_label_visible() + + def _update_folders_label_visible(self): + folders_label_hidden = len(self._selected_folder_ids) <= 1 + self._products_view.setColumnHidden( + self._products_model.folders_label_col, + folders_label_hidden + ) + + def _on_products_refresh_finished(self, event): + if event["sender"] != PRODUCTS_MODEL_SENDER_NAME: + self._refresh_model() + + def _on_group_changed(self, event): + if event["project_name"] != self._selected_project_name: + return + folder_ids = event["folder_ids"] + if not set(folder_ids).intersection(set(self._selected_folder_ids)): + return + self.refresh() diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py new file mode 100644 index 0000000000..efc1bb89a4 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/repres_widget.py @@ -0,0 +1,407 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore +import qtawesome + +from openpype.style import get_default_entity_icon_color +from openpype.tools.ayon_utils.widgets import get_qt_icon +from openpype.tools.utils import DeselectableTreeView + +from .actions_utils import show_actions_menu + +REPRESENTAION_NAME_ROLE = QtCore.Qt.UserRole + 1 +REPRESENTATION_ID_ROLE = QtCore.Qt.UserRole + 2 +PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 4 +GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 5 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 6 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 7 +SYNC_ACTIVE_SITE_PROGRESS = QtCore.Qt.UserRole + 8 +SYNC_REMOTE_SITE_PROGRESS = QtCore.Qt.UserRole + 9 + + +class RepresentationsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + colums_info = [ + ("Name", 120), + ("Product name", 125), + ("Folder", 125), + ("Active site", 85), + ("Remote site", 85) + ] + column_labels = [label for label, _ in colums_info] + column_widths = [width for _, width in colums_info] + folder_column = column_labels.index("Product name") + active_site_column = column_labels.index("Active site") + remote_site_column = column_labels.index("Remote site") + + def __init__(self, controller): + super(RepresentationsModel, self).__init__() + + self.setColumnCount(len(self.column_labels)) + + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "selection.versions.changed", + self._on_version_change + ) + self._selected_project_name = None + self._selected_version_ids = None + + self._group_icon = None + + self._items_by_id = {} + self._groups_items_by_name = {} + + self._controller = controller + + def refresh(self): + repre_items = self._controller.get_representation_items( + self._selected_project_name, self._selected_version_ids + ) + self._fill_items(repre_items, self._selected_project_name) + self.refreshed.emit() + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + if role == QtCore.Qt.DecorationRole: + if col == 3: + role = ACTIVE_SITE_ICON_ROLE + elif col == 4: + role = REMOTE_SITE_ICON_ROLE + else: + return None + + if role == QtCore.Qt.DisplayRole: + if col == 1: + role = PRODUCT_NAME_ROLE + elif col == 2: + role = FOLDER_LABEL_ROLE + elif col == 3: + role = SYNC_ACTIVE_SITE_PROGRESS + elif col == 4: + role = SYNC_REMOTE_SITE_PROGRESS + + index = self.index(index.row(), 0, index.parent()) + return super(RepresentationsModel, self).data(index, role) + + def setData(self, index, value, role=None): + if role is None: + role = QtCore.Qt.EditRole + return super(RepresentationsModel, self).setData(index, value, role) + + def _clear_items(self): + self._items_by_id = {} + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + def _get_repre_item( + self, + repre_item, + active_site_icon, + remote_site_icon, + repres_sync_status + ): + repre_id = repre_item.representation_id + repre_name = repre_item.representation_name + repre_icon = repre_item.representation_icon + item = self._items_by_id.get(repre_id) + is_new_item = False + if item is None: + is_new_item = True + item = QtGui.QStandardItem() + self._items_by_id[repre_id] = item + item.setColumnCount(self.columnCount()) + item.setEditable(False) + + sync_status = repres_sync_status[repre_id] + active_progress, remote_progress = sync_status + + active_site_progress = "{}%".format(int(active_progress * 100)) + remote_site_progress = "{}%".format(int(remote_progress * 100)) + + icon = get_qt_icon(repre_icon) + item.setData(repre_name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(repre_name, REPRESENTAION_NAME_ROLE) + item.setData(repre_id, REPRESENTATION_ID_ROLE) + item.setData(repre_item.product_name, PRODUCT_NAME_ROLE) + item.setData(repre_item.folder_label, FOLDER_LABEL_ROLE) + item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE) + item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE) + item.setData(active_site_progress, SYNC_ACTIVE_SITE_PROGRESS) + item.setData(remote_site_progress, SYNC_REMOTE_SITE_PROGRESS) + return is_new_item, item + + def _get_group_icon(self): + if self._group_icon is None: + self._group_icon = qtawesome.icon( + "fa.folder", + color=get_default_entity_icon_color() + ) + return self._group_icon + + def _get_group_item(self, repre_name): + item = self._groups_items_by_name.get(repre_name) + if item is not None: + return False, item + + # TODO add color + item = QtGui.QStandardItem() + item.setColumnCount(self.columnCount()) + item.setData(repre_name, QtCore.Qt.DisplayRole) + item.setData(self._get_group_icon(), QtCore.Qt.DecorationRole) + item.setData(0, GROUP_TYPE_ROLE) + item.setEditable(False) + self._groups_items_by_name[repre_name] = item + return True, item + + def _fill_items(self, repre_items, project_name): + active_site_icon_def = self._controller.get_active_site_icon_def( + project_name + ) + remote_site_icon_def = self._controller.get_remote_site_icon_def( + project_name + ) + active_site_icon = get_qt_icon(active_site_icon_def) + remote_site_icon = get_qt_icon(remote_site_icon_def) + + items_to_remove = set(self._items_by_id.keys()) + repre_items_by_name = collections.defaultdict(list) + repre_ids = set() + for repre_item in repre_items: + repre_ids.add(repre_item.representation_id) + items_to_remove.discard(repre_item.representation_id) + repre_name = repre_item.representation_name + repre_items_by_name[repre_name].append(repre_item) + + repres_sync_status = self._controller.get_representations_sync_status( + project_name, repre_ids + ) + + root_item = self.invisibleRootItem() + for repre_id in items_to_remove: + item = self._items_by_id.pop(repre_id) + parent_item = item.parent() + if parent_item is None: + parent_item = root_item + parent_item.removeRow(item.row()) + + group_names = set() + new_root_items = [] + for repre_name, repre_name_items in repre_items_by_name.items(): + group_item = None + parent_is_group = False + if len(repre_name_items) > 1: + group_names.add(repre_name) + is_new_group, group_item = self._get_group_item(repre_name) + if is_new_group: + new_root_items.append(group_item) + parent_is_group = True + + new_group_items = [] + for repre_item in repre_name_items: + is_new_item, item = self._get_repre_item( + repre_item, + active_site_icon, + remote_site_icon, + repres_sync_status + ) + item_parent = item.parent() + if item_parent is None: + item_parent = root_item + + if not is_new_item: + if parent_is_group: + if item_parent is group_item: + continue + elif item_parent is root_item: + continue + item_parent.takeRow(item.row()) + is_new_item = True + + if is_new_item: + new_group_items.append(item) + + if not new_group_items: + continue + + if group_item is not None: + group_item.appendRows(new_group_items) + else: + new_root_items.extend(new_group_items) + + if new_root_items: + root_item.appendRows(new_root_items) + + for group_name in set(self._groups_items_by_name) - group_names: + item = self._groups_items_by_name.pop(group_name) + parent_item = item.parent() + if parent_item is None: + parent_item = root_item + parent_item.removeRow(item.row()) + + def _on_project_change(self, event): + self._selected_project_name = event["project_name"] + + def _on_version_change(self, event): + self._selected_version_ids = event["version_ids"] + self.refresh() + + +class RepresentationsWidget(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(RepresentationsWidget, self).__init__(parent) + + repre_view = DeselectableTreeView(self) + repre_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + repre_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + repre_view.setSortingEnabled(True) + repre_view.setAlternatingRowColors(True) + + repre_model = RepresentationsModel(controller) + repre_proxy_model = QtCore.QSortFilterProxyModel() + repre_proxy_model.setSourceModel(repre_model) + repre_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + repre_view.setModel(repre_proxy_model) + + for idx, width in enumerate(repre_model.column_widths): + repre_view.setColumnWidth(idx, width) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(repre_view, 1) + + repre_view.customContextMenuRequested.connect( + self._on_context_menu) + repre_view.selectionModel().selectionChanged.connect( + self._on_selection_change) + repre_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "selection.folders.changed", + self._on_folder_change + ) + + self._controller = controller + self._selected_project_name = None + self._selected_multiple_folders = None + + self._repre_view = repre_view + self._repre_model = repre_model + self._repre_proxy_model = repre_proxy_model + + self._set_site_sync_visibility( + self._controller.is_site_sync_enabled() + ) + self._set_multiple_folders_selected(False) + + def refresh(self): + self._repre_model.refresh() + + def _on_folder_change(self, event): + self._set_multiple_folders_selected(len(event["folder_ids"]) > 1) + + def _on_project_change(self, event): + self._selected_project_name = event["project_name"] + site_sync_enabled = self._controller.is_site_sync_enabled( + self._selected_project_name + ) + self._set_site_sync_visibility(site_sync_enabled) + + def _set_site_sync_visibility(self, site_sync_enabled): + self._repre_view.setColumnHidden( + self._repre_model.active_site_column, + not site_sync_enabled + ) + self._repre_view.setColumnHidden( + self._repre_model.remote_site_column, + not site_sync_enabled + ) + + def _set_multiple_folders_selected(self, selected_multiple_folders): + if selected_multiple_folders == self._selected_multiple_folders: + return + self._selected_multiple_folders = selected_multiple_folders + self._repre_view.setColumnHidden( + self._repre_model.folder_column, + not self._selected_multiple_folders + ) + + def _on_model_refresh(self): + self._repre_proxy_model.sort(0) + + def _get_selected_repre_indexes(self): + selection_model = self._repre_view.selectionModel() + model = self._repre_view.model() + indexes_queue = collections.deque() + indexes_queue.extend(selection_model.selectedIndexes()) + + selected_indexes = [] + while indexes_queue: + index = indexes_queue.popleft() + if index.column() != 0: + continue + + group_type = model.data(index, GROUP_TYPE_ROLE) + if group_type is None: + selected_indexes.append(index) + + elif group_type == 0: + for row in range(model.rowCount(index)): + child_index = model.index(row, 0, index) + indexes_queue.append(child_index) + + return selected_indexes + + def _get_selected_repre_ids(self): + repre_ids = { + index.data(REPRESENTATION_ID_ROLE) + for index in self._get_selected_repre_indexes() + } + repre_ids.discard(None) + return repre_ids + + def _on_selection_change(self): + selected_repre_ids = self._get_selected_repre_ids() + self._controller.set_selected_representations(selected_repre_ids) + + def _on_context_menu(self, point): + repre_ids = self._get_selected_repre_ids() + action_items = self._controller.get_representations_action_items( + self._selected_project_name, repre_ids + ) + global_point = self._repre_view.mapToGlobal(point) + result = show_actions_menu( + action_items, + global_point, + len(repre_ids) == 1, + self + ) + action_item, options = result + if action_item is None or options is None: + return + + self._controller.trigger_action_item( + action_item.identifier, + options, + action_item.project_name, + version_ids=action_item.version_ids, + representation_ids=action_item.representation_ids, + ) diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py new file mode 100644 index 0000000000..a6d40d52e7 --- /dev/null +++ b/openpype/tools/ayon_loader/ui/window.py @@ -0,0 +1,511 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.resources import get_openpype_icon_filepath +from openpype.style import load_stylesheet +from openpype.tools.utils import ( + PlaceholderLineEdit, + ErrorMessageBox, + ThumbnailPainterWidget, + RefreshButton, + GoToCurrentButton, +) +from openpype.tools.utils.lib import center_window +from openpype.tools.ayon_utils.widgets import ProjectsCombobox +from openpype.tools.ayon_loader.control import LoaderController + +from .folders_widget import LoaderFoldersWidget +from .products_widget import ProductsWidget +from .product_types_widget import ProductTypesView +from .product_group_dialog import ProductGroupDialog +from .info_widget import InfoWidget +from .repres_widget import RepresentationsWidget + + +class LoadErrorMessageBox(ErrorMessageBox): + def __init__(self, messages, parent=None): + self._messages = messages + super(LoadErrorMessageBox, self).__init__("Loading failed", parent) + + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Failed to load items" + ) + return label_widget + + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, product, version in self._messages: + report_message = ( + "During load error happened on Product: \"{product}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + product=product, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data + + def _create_content(self, content_layout): + item_name_template = ( + "Product: {}
" + "Version: {}
" + "Representation: {}
" + ) + exc_msg_template = "{}" + + for exc_msg, tb_text, repre, product, version in self._messages: + line = self._create_line() + content_layout.addWidget(line) + + item_name = item_name_template.format(product, version, repre) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + item_name_widget.setWordWrap(True) + content_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + message_label_widget.setWordWrap(True) + content_layout.addWidget(message_label_widget) + + if tb_text: + line = self._create_line() + tb_widget = self._create_traceback_widget(tb_text, self) + content_layout.addWidget(line) + content_layout.addWidget(tb_widget) + + +class RefreshHandler: + def __init__(self): + self._project_refreshed = False + self._folders_refreshed = False + self._products_refreshed = False + + @property + def project_refreshed(self): + return self._products_refreshed + + @property + def folders_refreshed(self): + return self._folders_refreshed + + @property + def products_refreshed(self): + return self._products_refreshed + + def reset(self): + self._project_refreshed = False + self._folders_refreshed = False + self._products_refreshed = False + + def set_project_refreshed(self): + self._project_refreshed = True + + def set_folders_refreshed(self): + self._folders_refreshed = True + + def set_products_refreshed(self): + self._products_refreshed = True + + +class LoaderWindow(QtWidgets.QWidget): + def __init__(self, controller=None, parent=None): + super(LoaderWindow, self).__init__(parent) + + icon = QtGui.QIcon(get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("AYON Loader") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window) + + if controller is None: + controller = LoaderController() + + main_splitter = QtWidgets.QSplitter(self) + + context_splitter = QtWidgets.QSplitter(main_splitter) + context_splitter.setOrientation(QtCore.Qt.Vertical) + + # Context selection widget + context_widget = QtWidgets.QWidget(context_splitter) + + context_top_widget = QtWidgets.QWidget(context_widget) + projects_combobox = ProjectsCombobox( + controller, + context_top_widget, + handle_expected_selection=True + ) + projects_combobox.set_select_item_visible(True) + projects_combobox.set_libraries_separator_visible(True) + projects_combobox.set_standard_filter_enabled( + controller.is_standard_projects_filter_enabled() + ) + + go_to_current_btn = GoToCurrentButton(context_top_widget) + refresh_btn = RefreshButton(context_top_widget) + + context_top_layout = QtWidgets.QHBoxLayout(context_top_widget) + context_top_layout.setContentsMargins(0, 0, 0, 0,) + context_top_layout.addWidget(projects_combobox, 1) + context_top_layout.addWidget(go_to_current_btn, 0) + context_top_layout.addWidget(refresh_btn, 0) + + folders_filter_input = PlaceholderLineEdit(context_widget) + folders_filter_input.setPlaceholderText("Folder name filter...") + + folders_widget = LoaderFoldersWidget(controller, context_widget) + + product_types_widget = ProductTypesView(controller, context_splitter) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.addWidget(context_top_widget, 0) + context_layout.addWidget(folders_filter_input, 0) + context_layout.addWidget(folders_widget, 1) + + context_splitter.addWidget(context_widget) + context_splitter.addWidget(product_types_widget) + context_splitter.setStretchFactor(0, 65) + context_splitter.setStretchFactor(1, 35) + + # Product + version selection item + products_wrap_widget = QtWidgets.QWidget(main_splitter) + + products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) + + products_filter_input = PlaceholderLineEdit(products_inputs_widget) + products_filter_input.setPlaceholderText("Product name filter...") + product_group_checkbox = QtWidgets.QCheckBox( + "Enable grouping", products_inputs_widget) + product_group_checkbox.setChecked(True) + + products_widget = ProductsWidget(controller, products_wrap_widget) + + products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) + products_inputs_layout.setContentsMargins(0, 0, 0, 0) + products_inputs_layout.addWidget(products_filter_input, 1) + products_inputs_layout.addWidget(product_group_checkbox, 0) + + products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) + products_wrap_layout.setContentsMargins(0, 0, 0, 0) + products_wrap_layout.addWidget(products_inputs_widget, 0) + products_wrap_layout.addWidget(products_widget, 1) + + right_panel_splitter = QtWidgets.QSplitter(main_splitter) + right_panel_splitter.setOrientation(QtCore.Qt.Vertical) + + thumbnails_widget = ThumbnailPainterWidget(right_panel_splitter) + thumbnails_widget.set_use_checkboard(False) + + info_widget = InfoWidget(controller, right_panel_splitter) + + repre_widget = RepresentationsWidget(controller, right_panel_splitter) + + right_panel_splitter.addWidget(thumbnails_widget) + right_panel_splitter.addWidget(info_widget) + right_panel_splitter.addWidget(repre_widget) + + right_panel_splitter.setStretchFactor(0, 1) + right_panel_splitter.setStretchFactor(1, 1) + right_panel_splitter.setStretchFactor(2, 2) + + main_splitter.addWidget(context_splitter) + main_splitter.addWidget(products_wrap_widget) + main_splitter.addWidget(right_panel_splitter) + + main_splitter.setStretchFactor(0, 4) + main_splitter.setStretchFactor(1, 6) + main_splitter.setStretchFactor(2, 1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.addWidget(main_splitter) + + show_timer = QtCore.QTimer() + show_timer.setInterval(1) + + show_timer.timeout.connect(self._on_show_timer) + + projects_combobox.refreshed.connect(self._on_projects_refresh) + folders_widget.refreshed.connect(self._on_folders_refresh) + products_widget.refreshed.connect(self._on_products_refresh) + folders_filter_input.textChanged.connect( + self._on_folder_filter_change + ) + product_types_widget.filter_changed.connect( + self._on_product_type_filter_change + ) + products_filter_input.textChanged.connect( + self._on_product_filter_change + ) + product_group_checkbox.stateChanged.connect( + self._on_product_group_change + ) + products_widget.merged_products_selection_changed.connect( + self._on_merged_products_selection_change + ) + products_widget.selection_changed.connect( + self._on_products_selection_change + ) + go_to_current_btn.clicked.connect( + self._on_go_to_current_context_click + ) + refresh_btn.clicked.connect( + self._on_refresh_click + ) + controller.register_event_callback( + "load.finished", + self._on_load_finished, + ) + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_changed, + ) + controller.register_event_callback( + "selection.folders.changed", + self._on_folders_selection_changed, + ) + controller.register_event_callback( + "selection.versions.changed", + self._on_versions_selection_changed, + ) + controller.register_event_callback( + "controller.reset.started", + self._on_controller_reset_start, + ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset_finish, + ) + + self._group_dialog = ProductGroupDialog(controller, self) + + self._main_splitter = main_splitter + + self._go_to_current_btn = go_to_current_btn + self._refresh_btn = refresh_btn + self._projects_combobox = projects_combobox + + self._folders_filter_input = folders_filter_input + self._folders_widget = folders_widget + + self._product_types_widget = product_types_widget + + self._products_filter_input = products_filter_input + self._product_group_checkbox = product_group_checkbox + self._products_widget = products_widget + + self._right_panel_splitter = right_panel_splitter + self._thumbnails_widget = thumbnails_widget + self._info_widget = info_widget + self._repre_widget = repre_widget + + self._controller = controller + self._refresh_handler = RefreshHandler() + self._first_show = True + self._reset_on_show = True + self._show_counter = 0 + self._show_timer = show_timer + self._selected_project_name = None + self._selected_folder_ids = set() + self._selected_version_ids = set() + + self._products_widget.set_enable_grouping( + self._product_group_checkbox.isChecked() + ) + + def refresh(self): + self._controller.reset() + + def showEvent(self, event): + super(LoaderWindow, self).showEvent(event) + + if self._first_show: + self._on_first_show() + + self._show_timer.start() + + def keyPressEvent(self, event): + modifiers = event.modifiers() + ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + + # Grouping products on pressing Ctrl + G + if ( + ctrl_pressed + and event.key() == QtCore.Qt.Key_G + and not event.isAutoRepeat() + ): + self._show_group_dialog() + event.setAccepted(True) + return + + super(LoaderWindow, self).keyPressEvent(event) + + def _on_first_show(self): + self._first_show = False + # width, height = 1800, 900 + width, height = 1500, 750 + + self.resize(width, height) + + mid_width = int(width / 1.8) + sides_width = int((width - mid_width) * 0.5) + self._main_splitter.setSizes( + [sides_width, mid_width, sides_width] + ) + + thumbnail_height = int(height / 3.6) + info_height = int((height - thumbnail_height) * 0.5) + self._right_panel_splitter.setSizes( + [thumbnail_height, info_height, info_height] + ) + self.setStyleSheet(load_stylesheet()) + center_window(self) + + def _on_show_timer(self): + if self._show_counter < 2: + self._show_counter += 1 + return + + self._show_counter = 0 + self._show_timer.stop() + + if self._reset_on_show: + self._reset_on_show = False + self._controller.reset() + + def _show_group_dialog(self): + project_name = self._projects_combobox.get_selected_project_name() + if not project_name: + return + + product_ids = { + i["product_id"] + for i in self._products_widget.get_selected_version_info() + } + if not product_ids: + return + + self._group_dialog.set_product_ids(project_name, product_ids) + self._group_dialog.show() + + def _on_folder_filter_change(self, text): + self._folders_widget.set_name_filter(text) + + def _on_product_group_change(self): + self._products_widget.set_enable_grouping( + self._product_group_checkbox.isChecked() + ) + + def _on_product_filter_change(self, text): + self._products_widget.set_name_filter(text) + + def _on_product_type_filter_change(self): + self._products_widget.set_product_type_filter( + self._product_types_widget.get_filter_info() + ) + + def _on_merged_products_selection_change(self): + items = self._products_widget.get_selected_merged_products() + self._folders_widget.set_merged_products_selection(items) + + def _on_products_selection_change(self): + items = self._products_widget.get_selected_version_info() + self._info_widget.set_selected_version_info( + self._projects_combobox.get_selected_project_name(), + items + ) + + def _on_go_to_current_context_click(self): + context = self._controller.get_current_context() + self._controller.set_expected_selection( + context["project_name"], + context["folder_id"], + ) + + def _on_refresh_click(self): + self._controller.reset() + + def _on_controller_reset_start(self): + self._refresh_handler.reset() + + def _on_controller_reset_finish(self): + context = self._controller.get_current_context() + project_name = context["project_name"] + self._go_to_current_btn.setVisible(bool(project_name)) + self._projects_combobox.set_current_context_project(project_name) + if not self._refresh_handler.project_refreshed: + self._projects_combobox.refresh() + + def _on_load_finished(self, event): + error_info = event["error_info"] + if not error_info: + return + + box = LoadErrorMessageBox(error_info, self) + box.show() + + def _on_project_selection_changed(self, event): + self._selected_project_name = event["project_name"] + + def _on_folders_selection_changed(self, event): + self._selected_folder_ids = set(event["folder_ids"]) + self._update_thumbnails() + + def _on_versions_selection_changed(self, event): + self._selected_version_ids = set(event["version_ids"]) + self._update_thumbnails() + + def _update_thumbnails(self): + project_name = self._selected_project_name + thumbnail_ids = set() + if self._selected_version_ids: + thumbnail_id_by_entity_id = ( + self._controller.get_version_thumbnail_ids( + project_name, + self._selected_version_ids + ) + ) + thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + elif self._selected_folder_ids: + thumbnail_id_by_entity_id = ( + self._controller.get_folder_thumbnail_ids( + project_name, + self._selected_folder_ids + ) + ) + thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + + thumbnail_ids.discard(None) + + if not thumbnail_ids: + self._thumbnails_widget.set_current_thumbnails(None) + return + + thumbnail_paths = set() + for thumbnail_id in thumbnail_ids: + thumbnail_path = self._controller.get_thumbnail_path( + project_name, thumbnail_id) + thumbnail_paths.add(thumbnail_path) + thumbnail_paths.discard(None) + self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths) + + def _on_projects_refresh(self): + self._refresh_handler.set_project_refreshed() + if not self._refresh_handler.folders_refreshed: + self._folders_widget.refresh() + + def _on_folders_refresh(self): + self._refresh_handler.set_folders_refreshed() + if not self._refresh_handler.products_refreshed: + self._products_widget.refresh() + + def _on_products_refresh(self): + self._refresh_handler.set_products_refreshed() diff --git a/openpype/tools/ayon_push_to_project/__init__.py b/openpype/tools/ayon_push_to_project/__init__.py new file mode 100644 index 0000000000..83df110c96 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/__init__.py @@ -0,0 +1,6 @@ +from .control import PushToContextController + + +__all__ = ( + "PushToContextController", +) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py new file mode 100644 index 0000000000..0a19136701 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/control.py @@ -0,0 +1,344 @@ +import threading + +from openpype.client import ( + get_asset_by_id, + get_subset_by_id, + get_version_by_id, + get_representations, +) +from openpype.settings import get_project_settings +from openpype.lib import prepare_template_data +from openpype.lib.events import QueuedEventSystem +from openpype.pipeline.create import get_subset_name_template +from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel + +from .models import ( + PushToProjectSelectionModel, + UserPublishValuesModel, + IntegrateModel, +) + + +class PushToContextController: + def __init__(self, project_name=None, version_id=None): + self._event_system = self._create_event_system() + + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._integrate_model = IntegrateModel(self) + + self._selection_model = PushToProjectSelectionModel(self) + self._user_values = UserPublishValuesModel(self) + + self._src_project_name = None + self._src_version_id = None + self._src_asset_doc = None + self._src_subset_doc = None + self._src_version_doc = None + self._src_label = None + + self._submission_enabled = False + self._process_thread = None + self._process_item_id = None + + self.set_source(project_name, version_id) + + # Events system + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def set_source(self, project_name, version_id): + """Set source project and version. + + Args: + project_name (Union[str, None]): Source project name. + version_id (Union[str, None]): Source version id. + """ + + if ( + project_name == self._src_project_name + and version_id == self._src_version_id + ): + return + + self._src_project_name = project_name + self._src_version_id = version_id + self._src_label = None + asset_doc = None + subset_doc = None + version_doc = None + if project_name and version_id: + version_doc = get_version_by_id(project_name, version_id) + + if version_doc: + subset_doc = get_subset_by_id(project_name, version_doc["parent"]) + + if subset_doc: + asset_doc = get_asset_by_id(project_name, subset_doc["parent"]) + + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + if asset_doc: + self._user_values.set_new_folder_name(asset_doc["name"]) + variant = self._get_src_variant() + if variant: + self._user_values.set_variant(variant) + + comment = version_doc["data"].get("comment") + if comment: + self._user_values.set_comment(comment) + + self._emit_event( + "source.changed", + { + "project_name": project_name, + "version_id": version_id + } + ) + + def get_source_label(self): + """Get source label. + + Returns: + str: Label describing source project and version as path. + """ + + if self._src_label is None: + self._src_label = self._prepare_source_label() + return self._src_label + + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) + + def get_user_values(self): + return self._user_values.get_data() + + def set_user_value_folder_name(self, folder_name): + self._user_values.set_new_folder_name(folder_name) + self._invalidate() + + def set_user_value_variant(self, variant): + self._user_values.set_variant(variant) + self._invalidate() + + def set_user_value_comment(self, comment): + self._user_values.set_comment(comment) + self._invalidate() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + self._invalidate() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + self._invalidate() + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + def get_process_item_status(self, item_id): + return self._integrate_model.get_item_status(item_id) + + # Processing methods + def submit(self, wait=True): + if not self._submission_enabled: + return + + if self._process_thread is not None: + return + + item_id = self._integrate_model.create_process_item( + self._src_project_name, + self._src_version_id, + self._selection_model.get_selected_project_name(), + self._selection_model.get_selected_folder_id(), + self._selection_model.get_selected_task_name(), + self._user_values.variant, + comment=self._user_values.comment, + new_folder_name=self._user_values.new_folder_name, + dst_version=1 + ) + + self._process_item_id = item_id + self._emit_event("submit.started") + if wait: + self._submit_callback() + self._process_item_id = None + return item_id + + thread = threading.Thread(target=self._submit_callback) + self._process_thread = thread + thread.start() + return item_id + + def wait_for_process_thread(self): + if self._process_thread is None: + return + self._process_thread.join() + self._process_thread = None + + def _prepare_source_label(self): + if not self._src_project_name or not self._src_version_id: + return "Source is not defined" + + asset_doc = self._src_asset_doc + if not asset_doc: + return "Source is invalid" + + folder_path_parts = list(asset_doc["data"]["parents"]) + folder_path_parts.append(asset_doc["name"]) + folder_path = "/".join(folder_path_parts) + subset_doc = self._src_subset_doc + version_doc = self._src_version_doc + return "Source: {}/{}/{}/v{:0>3}".format( + self._src_project_name, + folder_path, + subset_doc["name"], + version_doc["name"] + ) + + def _get_task_info_from_repre_docs(self, asset_doc, repre_docs): + asset_tasks = asset_doc["data"].get("tasks") or {} + found_comb = [] + for repre_doc in repre_docs: + context = repre_doc["context"] + task_info = context.get("task") + if task_info is None: + continue + + task_name = None + task_type = None + if isinstance(task_info, str): + task_name = task_info + asset_task_info = asset_tasks.get(task_info) or {} + task_type = asset_task_info.get("type") + + elif isinstance(task_info, dict): + task_name = task_info.get("name") + task_type = task_info.get("type") + + if task_name and task_type: + return task_name, task_type + + if task_name: + found_comb.append((task_name, task_type)) + + for task_name, task_type in found_comb: + return task_name, task_type + return None, None + + def _get_src_variant(self): + project_name = self._src_project_name + version_doc = self._src_version_doc + asset_doc = self._src_asset_doc + repre_docs = get_representations( + project_name, version_ids=[version_doc["_id"]] + ) + task_name, task_type = self._get_task_info_from_repre_docs( + asset_doc, repre_docs + ) + + project_settings = get_project_settings(project_name) + subset_doc = self._src_subset_doc + family = subset_doc["data"].get("family") + if not family: + family = subset_doc["data"]["families"][0] + template = get_subset_name_template( + self._src_project_name, + family, + task_name, + task_type, + None, + project_settings=project_settings + ) + template_low = template.lower() + variant_placeholder = "{variant}" + if ( + variant_placeholder not in template_low + or (not task_name and "{task" in template_low) + ): + return "" + + idx = template_low.index(variant_placeholder) + template_s = template[:idx] + template_e = template[idx + len(variant_placeholder):] + fill_data = prepare_template_data({ + "family": family, + "task": task_name + }) + try: + subset_s = template_s.format(**fill_data) + subset_e = template_e.format(**fill_data) + except Exception as exc: + print("Failed format", exc) + return "" + + subset_name = self._src_subset_doc["name"] + if ( + (subset_s and not subset_name.startswith(subset_s)) + or (subset_e and not subset_name.endswith(subset_e)) + ): + return "" + + if subset_s: + subset_name = subset_name[len(subset_s):] + if subset_e: + subset_name = subset_name[:len(subset_e)] + return subset_name + + def _check_submit_validations(self): + if not self._user_values.is_valid: + return False + + if not self._selection_model.get_selected_project_name(): + return False + + if ( + not self._user_values.new_folder_name + and not self._selection_model.get_selected_folder_id() + ): + return False + return True + + def _invalidate(self): + submission_enabled = self._check_submit_validations() + if submission_enabled == self._submission_enabled: + return + self._submission_enabled = submission_enabled + self._emit_event( + "submission.enabled.changed", + {"enabled": submission_enabled} + ) + + def _submit_callback(self): + process_item_id = self._process_item_id + if process_item_id is None: + return + self._integrate_model.integrate_item(process_item_id) + self._emit_event("submit.finished", {}) + if process_item_id == self._process_item_id: + self._process_item_id = None + + def _emit_event(self, topic, data=None): + if data is None: + data = {} + self.emit_event(topic, data, "controller") + + def _create_event_system(self): + return QueuedEventSystem() diff --git a/openpype/tools/ayon_push_to_project/main.py b/openpype/tools/ayon_push_to_project/main.py new file mode 100644 index 0000000000..e36940e488 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/main.py @@ -0,0 +1,32 @@ +import click + +from openpype.tools.utils import get_openpype_qt_app +from openpype.tools.ayon_push_to_project.ui import PushToContextSelectWindow + + +def main_show(project_name, version_id): + app = get_openpype_qt_app() + + window = PushToContextSelectWindow() + window.show() + window.set_source(project_name, version_id) + + app.exec_() + + +@click.command() +@click.option("--project", help="Source project name") +@click.option("--version", help="Source version id") +def main(project, version): + """Run PushToProject tool to integrate version in different project. + + Args: + project (str): Source project name. + version (str): Version id. + """ + + main_show(project, version) + + +if __name__ == "__main__": + main() diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py new file mode 100644 index 0000000000..99355b4296 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -0,0 +1,10 @@ +from .selection import PushToProjectSelectionModel +from .user_values import UserPublishValuesModel +from .integrate import IntegrateModel + + +__all__ = ( + "PushToProjectSelectionModel", + "UserPublishValuesModel", + "IntegrateModel", +) diff --git a/openpype/tools/ayon_push_to_project/models/integrate.py b/openpype/tools/ayon_push_to_project/models/integrate.py new file mode 100644 index 0000000000..976d8cb4f0 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/integrate.py @@ -0,0 +1,1214 @@ +import os +import re +import copy +import socket +import itertools +import datetime +import sys +import traceback +import uuid + +from bson.objectid import ObjectId + +from openpype.client import ( + get_project, + get_assets, + get_asset_by_id, + get_subset_by_id, + get_subset_by_name, + get_version_by_id, + get_last_version_by_subset_id, + get_version_by_name, + get_representations, +) +from openpype.client.operations import ( + OperationsSession, + new_asset_document, + new_subset_document, + new_version_doc, + new_representation_doc, + prepare_version_update_data, + prepare_representation_update_data, +) +from openpype.modules import ModulesManager +from openpype.lib import ( + StringTemplate, + get_openpype_username, + get_formatted_current_time, + source_hash, +) + +from openpype.lib.file_transaction import FileTransaction +from openpype.settings import get_project_settings +from openpype.pipeline import Anatomy +from openpype.pipeline.version_start import get_versioning_start +from openpype.pipeline.template_data import get_template_data +from openpype.pipeline.publish import get_publish_template_name +from openpype.pipeline.create import get_subset_name + +UNKNOWN = object() + + +class PushToProjectError(Exception): + pass + + +class FileItem(object): + def __init__(self, path): + self.path = path + + @property + def is_valid_file(self): + return os.path.exists(self.path) and os.path.isfile(self.path) + + +class SourceFile(FileItem): + def __init__(self, path, frame=None, udim=None): + super(SourceFile, self).__init__(path) + self.frame = frame + self.udim = udim + + def __repr__(self): + subparts = [self.__class__.__name__] + if self.frame is not None: + subparts.append("frame: {}".format(self.frame)) + if self.udim is not None: + subparts.append("UDIM: {}".format(self.udim)) + + return "<{}> '{}'".format(" - ".join(subparts), self.path) + + +class ResourceFile(FileItem): + def __init__(self, path, relative_path): + super(ResourceFile, self).__init__(path) + self.relative_path = relative_path + + def __repr__(self): + return "<{}> '{}'".format(self.__class__.__name__, self.relative_path) + + @property + def is_valid_file(self): + if not self.relative_path: + return False + return super(ResourceFile, self).is_valid_file + + +class ProjectPushItem: + def __init__( + self, + src_project_name, + src_version_id, + dst_project_name, + dst_folder_id, + dst_task_name, + variant, + comment, + new_folder_name, + dst_version, + item_id=None, + ): + if not item_id: + item_id = uuid.uuid4().hex + self.src_project_name = src_project_name + self.src_version_id = src_version_id + self.dst_project_name = dst_project_name + self.dst_folder_id = dst_folder_id + self.dst_task_name = dst_task_name + self.dst_version = dst_version + self.variant = variant + self.new_folder_name = new_folder_name + self.comment = comment or "" + self.item_id = item_id + self._repr_value = None + + @property + def _repr(self): + if not self._repr_value: + self._repr_value = "|".join([ + self.src_project_name, + self.src_version_id, + self.dst_project_name, + str(self.dst_folder_id), + str(self.new_folder_name), + str(self.dst_task_name), + str(self.dst_version) + ]) + return self._repr_value + + def __repr__(self): + return "<{} - {}>".format(self.__class__.__name__, self._repr) + + def to_data(self): + return { + "src_project_name": self.src_project_name, + "src_version_id": self.src_version_id, + "dst_project_name": self.dst_project_name, + "dst_folder_id": self.dst_folder_id, + "dst_task_name": self.dst_task_name, + "dst_version": self.dst_version, + "variant": self.variant, + "comment": self.comment, + "new_folder_name": self.new_folder_name, + "item_id": self.item_id, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class StatusMessage: + def __init__(self, message, level): + self.message = message + self.level = level + + def __str__(self): + return "{}: {}".format(self.level.upper(), self.message) + + def __repr__(self): + return "<{} - {}> {}".format( + self.__class__.__name__, self.level.upper, self.message + ) + + +class ProjectPushItemStatus: + def __init__( + self, + started=False, + failed=False, + finished=False, + fail_reason=None, + full_traceback=None + ): + self.started = started + self.failed = failed + self.finished = finished + self.fail_reason = fail_reason + self.full_traceback = full_traceback + + def set_failed(self, fail_reason, exc_info=None): + """Set status as failed. + + Attribute 'fail_reason' can change automatically based on passed value. + Reason is unset if 'failed' is 'False' and is set do default reason if + is set to 'True' and reason is not set. + + Args: + fail_reason (str): Reason why failed. + exc_info(tuple): Exception info. + """ + + failed = True + if not fail_reason and not exc_info: + failed = False + + full_traceback = None + if exc_info is not None: + full_traceback = "".join(traceback.format_exception(*exc_info)) + if not fail_reason: + fail_reason = "Failed without specified reason" + + self.failed = failed + self.fail_reason = fail_reason or None + self.full_traceback = full_traceback + + def to_data(self): + return { + "started": self.started, + "failed": self.failed, + "finished": self.finished, + "fail_reason": self.fail_reason, + "full_traceback": self.full_traceback, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class ProjectPushRepreItem: + """Representation item. + + Representation item based on representation document and project roots. + + Representation document may have reference to: + - source files: Files defined with publish template + - resource files: Files that should be in publish directory + but filenames are not template based. + + Args: + repre_doc (Dict[str, Ant]): Representation document. + roots (Dict[str, str]): Project roots (based on project anatomy). + """ + + def __init__(self, repre_doc, roots): + self._repre_doc = repre_doc + self._roots = roots + self._src_files = None + self._resource_files = None + self._frame = UNKNOWN + + @property + def repre_doc(self): + return self._repre_doc + + @property + def src_files(self): + if self._src_files is None: + self.get_source_files() + return self._src_files + + @property + def resource_files(self): + if self._resource_files is None: + self.get_source_files() + return self._resource_files + + @staticmethod + def _clean_path(path): + new_value = path.replace("\\", "/") + while "//" in new_value: + new_value = new_value.replace("//", "/") + return new_value + + @staticmethod + def _get_relative_path(path, src_dirpath): + dirpath, basename = os.path.split(path) + if not dirpath.lower().startswith(src_dirpath.lower()): + return None + + relative_dir = dirpath[len(src_dirpath):].lstrip("/") + if relative_dir: + relative_path = "/".join([relative_dir, basename]) + else: + relative_path = basename + return relative_path + + @property + def frame(self): + """First frame of representation files. + + This value will be in representation document context if is sequence. + + Returns: + Union[int, None]: First frame in representation files based on + source files or None if frame is not part of filename. + """ + + if self._frame is UNKNOWN: + frame = None + for src_file in self.src_files: + src_frame = src_file.frame + if ( + src_frame is not None + and (frame is None or src_frame < frame) + ): + frame = src_frame + self._frame = frame + return self._frame + + @staticmethod + def validate_source_files(src_files, resource_files): + if not src_files: + raise AssertionError(( + "Couldn't figure out source files from representation." + " Found resource files {}" + ).format(", ".join(str(i) for i in resource_files))) + + invalid_items = [ + item + for item in itertools.chain(src_files, resource_files) + if not item.is_valid_file + ] + if invalid_items: + raise AssertionError(( + "Source files that were not found on disk: {}" + ).format(", ".join(str(i) for i in invalid_items))) + + def get_source_files(self): + if self._src_files is not None: + return self._src_files, self._resource_files + + repre_context = self._repre_doc["context"] + if "frame" in repre_context or "udim" in repre_context: + src_files, resource_files = self._get_source_files_with_frames() + else: + src_files, resource_files = self._get_source_files() + + self.validate_source_files(src_files, resource_files) + + self._src_files = src_files + self._resource_files = resource_files + return self._src_files, self._resource_files + + def _get_source_files_with_frames(self): + frame_placeholder = "__frame__" + udim_placeholder = "__udim__" + src_files = [] + resource_files = [] + template = self._repre_doc["data"]["template"] + # Remove padding from 'udim' and 'frame' formatting keys + # - "{frame:0>4}" -> "{frame}" + for key in ("udim", "frame"): + sub_part = "{" + key + "[^}]*}" + replacement = "{{{}}}".format(key) + template = re.sub(sub_part, replacement, template) + + repre_context = self._repre_doc["context"] + fill_repre_context = copy.deepcopy(repre_context) + if "frame" in fill_repre_context: + fill_repre_context["frame"] = frame_placeholder + + if "udim" in fill_repre_context: + fill_repre_context["udim"] = udim_placeholder + + fill_roots = fill_repre_context["root"] + for root_name in tuple(fill_roots.keys()): + fill_roots[root_name] = "{{root[{}]}}".format(root_name) + repre_path = StringTemplate.format_template( + template, fill_repre_context) + repre_path = self._clean_path(repre_path) + src_dirpath, src_basename = os.path.split(repre_path) + src_basename = ( + re.escape(src_basename) + .replace(frame_placeholder, "(?P[0-9]+)") + .replace(udim_placeholder, "(?P[0-9]+)") + ) + src_basename_regex = re.compile("^{}$".format(src_basename)) + for file_info in self._repre_doc["files"]: + filepath_template = self._clean_path(file_info["path"]) + filepath = self._clean_path( + filepath_template.format(root=self._roots) + ) + dirpath, basename = os.path.split(filepath_template) + if ( + dirpath.lower() != src_dirpath.lower() + or not src_basename_regex.match(basename) + ): + relative_path = self._get_relative_path(filepath, src_dirpath) + resource_files.append(ResourceFile(filepath, relative_path)) + continue + + filepath = os.path.join(src_dirpath, basename) + frame = None + udim = None + for item in src_basename_regex.finditer(basename): + group_name = item.lastgroup + value = item.group(group_name) + if group_name == "frame": + frame = int(value) + elif group_name == "udim": + udim = value + + src_files.append(SourceFile(filepath, frame, udim)) + + return src_files, resource_files + + def _get_source_files(self): + src_files = [] + resource_files = [] + template = self._repre_doc["data"]["template"] + repre_context = self._repre_doc["context"] + fill_repre_context = copy.deepcopy(repre_context) + fill_roots = fill_repre_context["root"] + for root_name in tuple(fill_roots.keys()): + fill_roots[root_name] = "{{root[{}]}}".format(root_name) + repre_path = StringTemplate.format_template(template, + fill_repre_context) + repre_path = self._clean_path(repre_path) + src_dirpath = os.path.dirname(repre_path) + for file_info in self._repre_doc["files"]: + filepath_template = self._clean_path(file_info["path"]) + filepath = self._clean_path( + filepath_template.format(root=self._roots)) + + if filepath_template.lower() == repre_path.lower(): + src_files.append( + SourceFile(repre_path.format(root=self._roots)) + ) + else: + relative_path = self._get_relative_path( + filepath_template, src_dirpath + ) + resource_files.append( + ResourceFile(filepath, relative_path) + ) + return src_files, resource_files + + +class ProjectPushItemProcess: + """ + Args: + model (IntegrateModel): Model which is processing item. + item (ProjectPushItem): Item which is being processed. + """ + + # TODO where to get host?!!! + host_name = "republisher" + + def __init__(self, model, item): + self._model = model + self._item = item + + self._src_asset_doc = None + self._src_subset_doc = None + self._src_version_doc = None + self._src_repre_items = None + + self._project_doc = None + self._anatomy = None + self._asset_doc = None + self._created_asset_doc = None + self._task_info = None + self._subset_doc = None + self._version_doc = None + + self._family = None + self._subset_name = None + + self._project_settings = None + self._template_name = None + + self._status = ProjectPushItemStatus() + self._operations = OperationsSession() + self._file_transaction = FileTransaction() + + self._messages = [] + + @property + def item_id(self): + return self._item.item_id + + @property + def started(self): + return self._status.started + + def get_status_data(self): + return self._status.to_data() + + def integrate(self): + self._status.started = True + try: + self._log_info("Process started") + self._fill_source_variables() + self._log_info("Source entities were found") + self._fill_destination_project() + self._log_info("Destination project was found") + self._fill_or_create_destination_asset() + self._log_info("Destination asset was determined") + self._determine_family() + self._determine_publish_template_name() + self._determine_subset_name() + self._make_sure_subset_exists() + self._make_sure_version_exists() + self._log_info("Prerequirements were prepared") + self._integrate_representations() + self._log_info("Integration finished") + + except PushToProjectError as exc: + if not self._status.failed: + self._status.set_failed(str(exc)) + + except Exception as exc: + _exc, _value, _tb = sys.exc_info() + self._status.set_failed( + "Unhandled error happened: {}".format(str(exc)), + (_exc, _value, _tb) + ) + + finally: + self._status.finished = True + self._emit_event( + "push.finished.changed", + { + "finished": True, + "item_id": self.item_id, + } + ) + + def _emit_event(self, topic, data): + self._model.emit_event(topic, data) + + # Loggin helpers + # TODO better logging + def _add_message(self, message, level): + message_obj = StatusMessage(message, level) + self._messages.append(message_obj) + self._emit_event( + "push.message.added", + { + "message": message, + "level": level, + "item_id": self.item_id, + } + ) + print(message_obj) + return message_obj + + def _log_debug(self, message): + return self._add_message(message, "debug") + + def _log_info(self, message): + return self._add_message(message, "info") + + def _log_warning(self, message): + return self._add_message(message, "warning") + + def _log_error(self, message): + return self._add_message(message, "error") + + def _log_critical(self, message): + return self._add_message(message, "critical") + + def _fill_source_variables(self): + src_project_name = self._item.src_project_name + src_version_id = self._item.src_version_id + + project_doc = get_project(src_project_name) + if not project_doc: + self._status.set_failed( + f"Source project \"{src_project_name}\" was not found" + ) + + self._emit_event( + "push.failed.changed", + {"item_id": self.item_id} + ) + raise PushToProjectError(self._status.fail_reason) + + self._log_debug(f"Project '{src_project_name}' found") + + version_doc = get_version_by_id(src_project_name, src_version_id) + if not version_doc: + self._status.set_failed(( + f"Source version with id \"{src_version_id}\"" + f" was not found in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + subset_id = version_doc["parent"] + subset_doc = get_subset_by_id(src_project_name, subset_id) + if not subset_doc: + self._status.set_failed(( + f"Could find subset with id \"{subset_id}\"" + f" in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + asset_id = subset_doc["parent"] + asset_doc = get_asset_by_id(src_project_name, asset_id) + if not asset_doc: + self._status.set_failed(( + f"Could find asset with id \"{asset_id}\"" + f" in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + anatomy = Anatomy(src_project_name) + + repre_docs = get_representations( + src_project_name, + version_ids=[src_version_id] + ) + repre_items = [ + ProjectPushRepreItem(repre_doc, anatomy.roots) + for repre_doc in repre_docs + ] + self._log_debug(( + f"Found {len(repre_items)} representations on" + f" version {src_version_id} in project '{src_project_name}'" + )) + if not repre_items: + self._status.set_failed( + "Source version does not have representations" + f" (Version id: {src_version_id})" + ) + raise PushToProjectError(self._status.fail_reason) + + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + self._src_repre_items = repre_items + + def _fill_destination_project(self): + # --- Destination entities --- + dst_project_name = self._item.dst_project_name + # Validate project existence + dst_project_doc = get_project(dst_project_name) + if not dst_project_doc: + self._status.set_failed( + f"Destination project '{dst_project_name}' was not found" + ) + raise PushToProjectError(self._status.fail_reason) + + self._log_debug( + f"Destination project '{dst_project_name}' found" + ) + self._project_doc = dst_project_doc + self._anatomy = Anatomy(dst_project_name) + self._project_settings = get_project_settings( + self._item.dst_project_name + ) + + def _create_asset( + self, + src_asset_doc, + project_doc, + parent_asset_doc, + asset_name + ): + parent_id = None + parents = [] + tools = [] + if parent_asset_doc: + parent_id = parent_asset_doc["_id"] + parents = list(parent_asset_doc["data"]["parents"]) + parents.append(parent_asset_doc["name"]) + _tools = parent_asset_doc["data"].get("tools_env") + if _tools: + tools = list(_tools) + + asset_name_low = asset_name.lower() + other_asset_docs = get_assets( + project_doc["name"], fields=["_id", "name", "data.visualParent"] + ) + for other_asset_doc in other_asset_docs: + other_name = other_asset_doc["name"] + other_parent_id = other_asset_doc["data"].get("visualParent") + if other_name.lower() != asset_name_low: + continue + + if other_parent_id != parent_id: + self._status.set_failed(( + f"Asset with name \"{other_name}\" already" + " exists in different hierarchy." + )) + raise PushToProjectError(self._status.fail_reason) + + self._log_debug(( + f"Found already existing asset with name \"{other_name}\"" + f" which match requested name \"{asset_name}\"" + )) + return get_asset_by_id(project_doc["name"], other_asset_doc["_id"]) + + data_keys = ( + "clipIn", + "clipOut", + "frameStart", + "frameEnd", + "handleStart", + "handleEnd", + "resolutionWidth", + "resolutionHeight", + "fps", + "pixelAspect", + ) + asset_data = { + "visualParent": parent_id, + "parents": parents, + "tasks": {}, + "tools_env": tools + } + src_asset_data = src_asset_doc["data"] + for key in data_keys: + if key in src_asset_data: + asset_data[key] = src_asset_data[key] + + asset_doc = new_asset_document( + asset_name, + project_doc["_id"], + parent_id, + parents, + data=asset_data + ) + self._operations.create_entity( + project_doc["name"], + asset_doc["type"], + asset_doc + ) + self._log_info( + f"Creating new asset with name \"{asset_name}\"" + ) + self._created_asset_doc = asset_doc + return asset_doc + + def _fill_or_create_destination_asset(self): + dst_project_name = self._item.dst_project_name + dst_folder_id = self._item.dst_folder_id + dst_task_name = self._item.dst_task_name + new_folder_name = self._item.new_folder_name + if not dst_folder_id and not new_folder_name: + self._status.set_failed( + "Push item does not have defined destination asset" + ) + raise PushToProjectError(self._status.fail_reason) + + # Get asset document + parent_asset_doc = None + if dst_folder_id: + parent_asset_doc = get_asset_by_id( + self._item.dst_project_name, self._item.dst_folder_id + ) + if not parent_asset_doc: + self._status.set_failed( + f"Could find asset with id \"{dst_folder_id}\"" + f" in project \"{dst_project_name}\"" + ) + raise PushToProjectError(self._status.fail_reason) + + if not new_folder_name: + asset_doc = parent_asset_doc + else: + asset_doc = self._create_asset( + self._src_asset_doc, + self._project_doc, + parent_asset_doc, + new_folder_name + ) + self._asset_doc = asset_doc + if not dst_task_name: + self._task_info = {} + return + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(dst_task_name) + if not task_info: + self._status.set_failed( + f"Could find task with name \"{dst_task_name}\"" + f" on asset \"{asset_path}\"" + f" in project \"{dst_project_name}\"" + ) + raise PushToProjectError(self._status.fail_reason) + + # Create copy of task info to avoid changing data in asset document + task_info = copy.deepcopy(task_info) + task_info["name"] = dst_task_name + # Fill rest of task information based on task type + task_type = task_info["type"] + task_type_info = self._project_doc["config"]["tasks"].get( + task_type, {}) + task_info.update(task_type_info) + self._task_info = task_info + + def _determine_family(self): + subset_doc = self._src_subset_doc + family = subset_doc["data"].get("family") + families = subset_doc["data"].get("families") + if not family and families: + family = families[0] + + if not family: + self._status.set_failed( + "Couldn't figure out family from source subset" + ) + raise PushToProjectError(self._status.fail_reason) + + self._log_debug( + f"Publishing family is '{family}' (Based on source subset)" + ) + self._family = family + + def _determine_publish_template_name(self): + template_name = get_publish_template_name( + self._item.dst_project_name, + self.host_name, + self._family, + self._task_info.get("name"), + self._task_info.get("type"), + project_settings=self._project_settings + ) + self._log_debug( + f"Using template '{template_name}' for integration" + ) + self._template_name = template_name + + def _determine_subset_name(self): + family = self._family + asset_doc = self._asset_doc + task_info = self._task_info + subset_name = get_subset_name( + family, + self._item.variant, + task_info.get("name"), + asset_doc, + project_name=self._item.dst_project_name, + host_name=self.host_name, + project_settings=self._project_settings + ) + self._log_info( + f"Push will be integrating to subset with name '{subset_name}'" + ) + self._subset_name = subset_name + + def _make_sure_subset_exists(self): + project_name = self._item.dst_project_name + asset_id = self._asset_doc["_id"] + subset_name = self._subset_name + family = self._family + subset_doc = get_subset_by_name(project_name, subset_name, asset_id) + if subset_doc: + self._subset_doc = subset_doc + return subset_doc + + data = { + "families": [family] + } + subset_doc = new_subset_document( + subset_name, family, asset_id, data + ) + self._operations.create_entity(project_name, "subset", subset_doc) + self._subset_doc = subset_doc + + def _make_sure_version_exists(self): + """Make sure version document exits in database.""" + + project_name = self._item.dst_project_name + version = self._item.dst_version + src_version_doc = self._src_version_doc + subset_doc = self._subset_doc + subset_id = subset_doc["_id"] + src_data = src_version_doc["data"] + families = subset_doc["data"].get("families") + if not families: + families = [subset_doc["data"]["family"]] + + version_data = { + "families": list(families), + "fps": src_data.get("fps"), + "source": src_data.get("source"), + "machine": socket.gethostname(), + "comment": self._item.comment or "", + "author": get_openpype_username(), + "time": get_formatted_current_time(), + } + if version is None: + last_version_doc = get_last_version_by_subset_id( + project_name, subset_id + ) + if last_version_doc: + version = int(last_version_doc["name"]) + 1 + else: + version = get_versioning_start( + project_name, + self.host_name, + task_name=self._task_info["name"], + task_type=self._task_info["type"], + family=families[0], + subset=subset_doc["name"] + ) + + existing_version_doc = get_version_by_name( + project_name, version, subset_id + ) + # Update existing version + if existing_version_doc: + version_doc = new_version_doc( + version, subset_id, version_data, existing_version_doc["_id"] + ) + update_data = prepare_version_update_data( + existing_version_doc, version_doc + ) + if update_data: + self._operations.update_entity( + project_name, + "version", + existing_version_doc["_id"], + update_data + ) + self._version_doc = version_doc + + return + + version_doc = new_version_doc( + version, subset_id, version_data + ) + self._operations.create_entity(project_name, "version", version_doc) + + self._version_doc = version_doc + + def _integrate_representations(self): + try: + self._real_integrate_representations() + except Exception: + self._operations.clear() + self._file_transaction.rollback() + raise + + def _real_integrate_representations(self): + version_doc = self._version_doc + version_id = version_doc["_id"] + existing_repres = get_representations( + self._item.dst_project_name, + version_ids=[version_id] + ) + existing_repres_by_low_name = { + repre_doc["name"].lower(): repre_doc + for repre_doc in existing_repres + } + template_name = self._template_name + anatomy = self._anatomy + formatting_data = get_template_data( + self._project_doc, + self._asset_doc, + self._task_info.get("name"), + self.host_name + ) + formatting_data.update({ + "subset": self._subset_name, + "family": self._family, + "version": version_doc["name"] + }) + + path_template = anatomy.templates[template_name]["path"].replace( + "\\", "/" + ) + file_template = StringTemplate( + anatomy.templates[template_name]["file"] + ) + self._log_info("Preparing files to transfer") + processed_repre_items = self._prepare_file_transactions( + anatomy, template_name, formatting_data, file_template + ) + self._file_transaction.process() + self._log_info("Preparing database changes") + self._prepare_database_operations( + version_id, + processed_repre_items, + path_template, + existing_repres_by_low_name + ) + self._log_info("Finalization") + self._operations.commit() + self._file_transaction.finalize() + + def _prepare_file_transactions( + self, anatomy, template_name, formatting_data, file_template + ): + processed_repre_items = [] + for repre_item in self._src_repre_items: + repre_doc = repre_item.repre_doc + repre_name = repre_doc["name"] + repre_format_data = copy.deepcopy(formatting_data) + repre_format_data["representation"] = repre_name + for src_file in repre_item.src_files: + ext = os.path.splitext(src_file.path)[-1] + repre_format_data["ext"] = ext[1:] + break + + # Re-use 'output' from source representation + repre_output_name = repre_doc["context"].get("output") + if repre_output_name is not None: + repre_format_data["output"] = repre_output_name + + template_obj = anatomy.templates_obj[template_name]["folder"] + folder_path = template_obj.format_strict(formatting_data) + repre_context = folder_path.used_values + folder_path_rootless = folder_path.rootless + repre_filepaths = [] + published_path = None + for src_file in repre_item.src_files: + file_data = copy.deepcopy(repre_format_data) + frame = src_file.frame + if frame is not None: + file_data["frame"] = frame + + udim = src_file.udim + if udim is not None: + file_data["udim"] = udim + + filename = file_template.format_strict(file_data) + dst_filepath = os.path.normpath( + os.path.join(folder_path, filename) + ) + dst_rootless_path = os.path.normpath( + os.path.join(folder_path_rootless, filename) + ) + if published_path is None or frame == repre_item.frame: + published_path = dst_filepath + repre_context.update(filename.used_values) + + repre_filepaths.append((dst_filepath, dst_rootless_path)) + self._file_transaction.add(src_file.path, dst_filepath) + + for resource_file in repre_item.resource_files: + dst_filepath = os.path.normpath( + os.path.join(folder_path, resource_file.relative_path) + ) + dst_rootless_path = os.path.normpath( + os.path.join( + folder_path_rootless, resource_file.relative_path + ) + ) + repre_filepaths.append((dst_filepath, dst_rootless_path)) + self._file_transaction.add(resource_file.path, dst_filepath) + processed_repre_items.append( + (repre_item, repre_filepaths, repre_context, published_path) + ) + return processed_repre_items + + def _prepare_database_operations( + self, + version_id, + processed_repre_items, + path_template, + existing_repres_by_low_name + ): + modules_manager = ModulesManager() + sync_server_module = modules_manager.get("sync_server") + if sync_server_module is None or not sync_server_module.enabled: + sites = [{ + "name": "studio", + "created_dt": datetime.datetime.now() + }] + else: + sites = sync_server_module.compute_resource_sync_sites( + project_name=self._item.dst_project_name + ) + + added_repre_names = set() + for item in processed_repre_items: + (repre_item, repre_filepaths, repre_context, published_path) = item + repre_name = repre_item.repre_doc["name"] + added_repre_names.add(repre_name.lower()) + new_repre_data = { + "path": published_path, + "template": path_template + } + new_repre_files = [] + for (path, rootless_path) in repre_filepaths: + new_repre_files.append({ + "_id": ObjectId(), + "path": rootless_path, + "size": os.path.getsize(path), + "hash": source_hash(path), + "sites": sites + }) + + existing_repre = existing_repres_by_low_name.get( + repre_name.lower() + ) + entity_id = None + if existing_repre: + entity_id = existing_repre["_id"] + new_repre_doc = new_representation_doc( + repre_name, + version_id, + repre_context, + data=new_repre_data, + entity_id=entity_id + ) + new_repre_doc["files"] = new_repre_files + if not existing_repre: + self._operations.create_entity( + self._item.dst_project_name, + new_repre_doc["type"], + new_repre_doc + ) + else: + update_data = prepare_representation_update_data( + existing_repre, new_repre_doc + ) + if update_data: + self._operations.update_entity( + self._item.dst_project_name, + new_repre_doc["type"], + new_repre_doc["_id"], + update_data + ) + + existing_repre_names = set(existing_repres_by_low_name.keys()) + for repre_name in (existing_repre_names - added_repre_names): + repre_doc = existing_repres_by_low_name[repre_name] + self._operations.update_entity( + self._item.dst_project_name, + repre_doc["type"], + repre_doc["_id"], + {"type": "archived_representation"} + ) + + +class IntegrateModel: + def __init__(self, controller): + self._controller = controller + self._process_items = {} + + def reset(self): + self._process_items = {} + + def emit_event(self, topic, data=None, source=None): + self._controller.emit_event(topic, data, source) + + def create_process_item( + self, + src_project_name, + src_version_id, + dst_project_name, + dst_folder_id, + dst_task_name, + variant, + comment, + new_folder_name, + dst_version, + ): + """Create new item for integration. + + Args: + src_project_name (str): Source project name. + src_version_id (str): Source version id. + dst_project_name (str): Destination project name. + dst_folder_id (str): Destination folder id. + dst_task_name (str): Destination task name. + variant (str): Variant name. + comment (Union[str, None]): Comment. + new_folder_name (Union[str, None]): New folder name. + dst_version (int): Destination version number. + + Returns: + str: Item id. The id can be used to trigger integration or get + status information. + """ + + item = ProjectPushItem( + src_project_name, + src_version_id, + dst_project_name, + dst_folder_id, + dst_task_name, + variant, + comment=comment, + new_folder_name=new_folder_name, + dst_version=dst_version + ) + process_item = ProjectPushItemProcess(self, item) + self._process_items[item.item_id] = process_item + return item.item_id + + def integrate_item(self, item_id): + """Start integration of item. + + Args: + item_id (str): Item id which should be integrated. + """ + + item = self._process_items.get(item_id) + if item is None or item.started: + return + item.integrate() + + def get_item_status(self, item_id): + """Status of an item. + + Args: + item_id (str): Item id for which status should be returned. + + Returns: + dict[str, Any]: Status data. + """ + + item = self._process_items.get(item_id) + if item is not None: + return item.get_status_data() + return None diff --git a/openpype/tools/ayon_push_to_project/models/selection.py b/openpype/tools/ayon_push_to_project/models/selection.py new file mode 100644 index 0000000000..19f1c6d37d --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/selection.py @@ -0,0 +1,72 @@ +class PushToProjectSelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "push-to-project.selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_name = None + self._task_id = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if project_name == self._project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) diff --git a/openpype/tools/ayon_push_to_project/models/user_values.py b/openpype/tools/ayon_push_to_project/models/user_values.py new file mode 100644 index 0000000000..2a4faeb136 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/user_values.py @@ -0,0 +1,110 @@ +import re + +from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS + + +class UserPublishValuesModel: + """Helper object to validate values required for push to different project. + + Args: + controller (PushToContextController): Event system to catch + and emit events. + """ + + folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$") + variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) + + def __init__(self, controller): + self._controller = controller + self._new_folder_name = None + self._variant = None + self._comment = None + self._is_variant_valid = False + self._is_new_folder_name_valid = False + + self.set_new_folder_name("") + self.set_variant("") + self.set_comment("") + + @property + def new_folder_name(self): + return self._new_folder_name + + @property + def variant(self): + return self._variant + + @property + def comment(self): + return self._comment + + @property + def is_variant_valid(self): + return self._is_variant_valid + + @property + def is_new_folder_name_valid(self): + return self._is_new_folder_name_valid + + @property + def is_valid(self): + return self.is_variant_valid and self.is_new_folder_name_valid + + def get_data(self): + return { + "new_folder_name": self._new_folder_name, + "variant": self._variant, + "comment": self._comment, + "is_variant_valid": self._is_variant_valid, + "is_new_folder_name_valid": self._is_new_folder_name_valid, + "is_valid": self.is_valid + } + + def set_variant(self, variant): + if variant == self._variant: + return + + self._variant = variant + is_valid = False + if variant: + is_valid = self.variant_regex.match(variant) is not None + self._is_variant_valid = is_valid + + self._controller.emit_event( + "variant.changed", + { + "variant": variant, + "is_valid": self._is_variant_valid, + }, + "user_values" + ) + + def set_new_folder_name(self, folder_name): + if self._new_folder_name == folder_name: + return + + self._new_folder_name = folder_name + is_valid = True + if folder_name: + is_valid = ( + self.folder_name_regex.match(folder_name) is not None + ) + self._is_new_folder_name_valid = is_valid + self._controller.emit_event( + "new_folder_name.changed", + { + "new_folder_name": self._new_folder_name, + "is_valid": self._is_new_folder_name_valid, + }, + "user_values" + ) + + def set_comment(self, comment): + if comment == self._comment: + return + self._comment = comment + self._controller.emit_event( + "comment.changed", + {"comment": comment}, + "user_values" + ) diff --git a/openpype/tools/ayon_push_to_project/ui/__init__.py b/openpype/tools/ayon_push_to_project/ui/__init__.py new file mode 100644 index 0000000000..1e86475530 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import PushToContextSelectWindow + + +__all__ = ( + "PushToContextSelectWindow", +) diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py new file mode 100644 index 0000000000..535c01c643 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -0,0 +1,432 @@ +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import load_stylesheet, get_app_icon_path +from openpype.tools.utils import ( + PlaceholderLineEdit, + SeparatorWidget, + set_style_property, +) +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) +from openpype.tools.ayon_push_to_project.control import ( + PushToContextController, +) + + +class PushToContextSelectWindow(QtWidgets.QWidget): + def __init__(self, controller=None): + super(PushToContextSelectWindow, self).__init__() + if controller is None: + controller = PushToContextController() + self._controller = controller + + self.setWindowTitle("Push to project (select context)") + self.setWindowIcon(QtGui.QIcon(get_app_icon_path())) + + main_context_widget = QtWidgets.QWidget(self) + + header_widget = QtWidgets.QWidget(main_context_widget) + + header_label = QtWidgets.QLabel( + controller.get_source_label(), + header_widget + ) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(header_label) + + main_splitter = QtWidgets.QSplitter( + QtCore.Qt.Horizontal, main_context_widget + ) + + context_widget = QtWidgets.QWidget(main_splitter) + + projects_combobox = ProjectsCombobox(controller, context_widget) + projects_combobox.set_select_item_visible(True) + projects_combobox.set_standard_filter_enabled(True) + + context_splitter = QtWidgets.QSplitter( + QtCore.Qt.Vertical, context_widget + ) + + folders_widget = FoldersWidget(controller, context_splitter) + folders_widget.set_deselectable(True) + tasks_widget = TasksWidget(controller, context_splitter) + + context_splitter.addWidget(folders_widget) + context_splitter.addWidget(tasks_widget) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.addWidget(projects_combobox, 0) + context_layout.addWidget(context_splitter, 1) + + # --- Inputs widget --- + inputs_widget = QtWidgets.QWidget(main_splitter) + + folder_name_input = PlaceholderLineEdit(inputs_widget) + folder_name_input.setPlaceholderText("< Name of new folder >") + folder_name_input.setObjectName("ValidatedLineEdit") + + variant_input = PlaceholderLineEdit(inputs_widget) + variant_input.setPlaceholderText("< Variant >") + variant_input.setObjectName("ValidatedLineEdit") + + comment_input = PlaceholderLineEdit(inputs_widget) + comment_input.setPlaceholderText("< Publish comment >") + + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.addRow("New folder name", folder_name_input) + inputs_layout.addRow("Variant", variant_input) + inputs_layout.addRow("Comment", comment_input) + + main_splitter.addWidget(context_widget) + main_splitter.addWidget(inputs_widget) + + # --- Buttons widget --- + btns_widget = QtWidgets.QWidget(self) + cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + publish_btn = QtWidgets.QPushButton("Publish", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(cancel_btn, 0) + btns_layout.addWidget(publish_btn, 0) + + sep_1 = SeparatorWidget(parent=main_context_widget) + sep_2 = SeparatorWidget(parent=main_context_widget) + main_context_layout = QtWidgets.QVBoxLayout(main_context_widget) + main_context_layout.addWidget(header_widget, 0) + main_context_layout.addWidget(sep_1, 0) + main_context_layout.addWidget(main_splitter, 1) + main_context_layout.addWidget(sep_2, 0) + main_context_layout.addWidget(btns_widget, 0) + + # NOTE This was added in hurry + # - should be reorganized and changed styles + overlay_widget = QtWidgets.QFrame(self) + overlay_widget.setObjectName("OverlayFrame") + + overlay_label = QtWidgets.QLabel(overlay_widget) + overlay_label.setAlignment(QtCore.Qt.AlignCenter) + + overlay_btns_widget = QtWidgets.QWidget(overlay_widget) + overlay_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + # Add try again button (requires changes in controller) + overlay_try_btn = QtWidgets.QPushButton( + "Try again", overlay_btns_widget + ) + overlay_close_btn = QtWidgets.QPushButton( + "Close", overlay_btns_widget + ) + + overlay_btns_layout = QtWidgets.QHBoxLayout(overlay_btns_widget) + overlay_btns_layout.addStretch(1) + overlay_btns_layout.addWidget(overlay_try_btn, 0) + overlay_btns_layout.addWidget(overlay_close_btn, 0) + overlay_btns_layout.addStretch(1) + + overlay_layout = QtWidgets.QVBoxLayout(overlay_widget) + overlay_layout.addWidget(overlay_label, 0) + overlay_layout.addWidget(overlay_btns_widget, 0) + overlay_layout.setAlignment(QtCore.Qt.AlignCenter) + + main_layout = QtWidgets.QStackedLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(main_context_widget) + main_layout.addWidget(overlay_widget) + main_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + main_layout.setCurrentWidget(main_context_widget) + + show_timer = QtCore.QTimer() + show_timer.setInterval(0) + + main_thread_timer = QtCore.QTimer() + main_thread_timer.setInterval(10) + + user_input_changed_timer = QtCore.QTimer() + user_input_changed_timer.setInterval(200) + user_input_changed_timer.setSingleShot(True) + + main_thread_timer.timeout.connect(self._on_main_thread_timer) + show_timer.timeout.connect(self._on_show_timer) + user_input_changed_timer.timeout.connect(self._on_user_input_timer) + folder_name_input.textChanged.connect(self._on_new_asset_change) + variant_input.textChanged.connect(self._on_variant_change) + comment_input.textChanged.connect(self._on_comment_change) + + publish_btn.clicked.connect(self._on_select_click) + cancel_btn.clicked.connect(self._on_close_click) + overlay_close_btn.clicked.connect(self._on_close_click) + overlay_try_btn.clicked.connect(self._on_try_again_click) + + controller.register_event_callback( + "new_folder_name.changed", + self._on_controller_new_asset_change + ) + controller.register_event_callback( + "variant.changed", self._on_controller_variant_change + ) + controller.register_event_callback( + "comment.changed", self._on_controller_comment_change + ) + controller.register_event_callback( + "submission.enabled.changed", self._on_submission_change + ) + controller.register_event_callback( + "source.changed", self._on_controller_source_change + ) + controller.register_event_callback( + "submit.started", self._on_controller_submit_start + ) + controller.register_event_callback( + "submit.finished", self._on_controller_submit_end + ) + controller.register_event_callback( + "push.message.added", self._on_push_message + ) + + self._main_layout = main_layout + + self._main_context_widget = main_context_widget + + self._header_label = header_label + self._main_splitter = main_splitter + + self._projects_combobox = projects_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + + self._variant_input = variant_input + self._folder_name_input = folder_name_input + self._comment_input = comment_input + + self._publish_btn = publish_btn + + self._overlay_widget = overlay_widget + self._overlay_close_btn = overlay_close_btn + self._overlay_try_btn = overlay_try_btn + self._overlay_label = overlay_label + + self._user_input_changed_timer = user_input_changed_timer + # Store current value on input text change + # The value is unset when is passed to controller + # The goal is to have controll over changes happened during user change + # in UI and controller auto-changes + self._variant_input_text = None + self._new_folder_name_input_text = None + self._comment_input_text = None + + self._first_show = True + self._show_timer = show_timer + self._show_counter = 0 + + self._main_thread_timer = main_thread_timer + self._main_thread_timer_can_stop = True + self._last_submit_message = None + self._process_item_id = None + + self._variant_is_valid = None + self._folder_is_valid = None + + publish_btn.setEnabled(False) + overlay_close_btn.setVisible(False) + overlay_try_btn.setVisible(False) + + # Support of public api function of controller + def set_source(self, project_name, version_id): + """Set source project and version. + + Call the method on controller. + + Args: + project_name (Union[str, None]): Name of project. + version_id (Union[str, None]): Version id. + """ + + self._controller.set_source(project_name, version_id) + + def showEvent(self, event): + super(PushToContextSelectWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() + + def refresh(self): + user_values = self._controller.get_user_values() + new_folder_name = user_values["new_folder_name"] + variant = user_values["variant"] + self._folder_name_input.setText(new_folder_name or "") + self._variant_input.setText(variant or "") + self._invalidate_variant(user_values["is_variant_valid"]) + self._invalidate_new_folder_name( + new_folder_name, user_values["is_new_folder_name_valid"] + ) + + self._projects_combobox.refresh() + + def _on_first_show(self): + width = 740 + height = 640 + inputs_width = 360 + self.setStyleSheet(load_stylesheet()) + self.resize(width, height) + self._main_splitter.setSizes([width - inputs_width, inputs_width]) + self._show_timer.start() + + def _on_show_timer(self): + if self._show_counter < 3: + self._show_counter += 1 + return + self._show_timer.stop() + + self._show_counter = 0 + + self.refresh() + + def _on_new_asset_change(self, text): + self._new_folder_name_input_text = text + self._user_input_changed_timer.start() + + def _on_variant_change(self, text): + self._variant_input_text = text + self._user_input_changed_timer.start() + + def _on_comment_change(self, text): + self._comment_input_text = text + self._user_input_changed_timer.start() + + def _on_user_input_timer(self): + folder_name = self._new_folder_name_input_text + if folder_name is not None: + self._new_folder_name_input_text = None + self._controller.set_user_value_folder_name(folder_name) + + variant = self._variant_input_text + if variant is not None: + self._variant_input_text = None + self._controller.set_user_value_variant(variant) + + comment = self._comment_input_text + if comment is not None: + self._comment_input_text = None + self._controller.set_user_value_comment(comment) + + def _on_controller_new_asset_change(self, event): + folder_name = event["new_folder_name"] + if ( + self._new_folder_name_input_text is None + and folder_name != self._folder_name_input.text() + ): + self._folder_name_input.setText(folder_name) + + self._invalidate_new_folder_name(folder_name, event["is_valid"]) + + def _on_controller_variant_change(self, event): + is_valid = event["is_valid"] + variant = event["variant"] + if ( + self._variant_input_text is None + and variant != self._variant_input.text() + ): + self._variant_input.setText(variant) + + self._invalidate_variant(is_valid) + + def _on_controller_comment_change(self, event): + comment = event["comment"] + if ( + self._comment_input_text is None + and comment != self._comment_input.text() + ): + self._comment_input.setText(comment) + + def _on_controller_source_change(self): + self._header_label.setText(self._controller.get_source_label()) + + def _invalidate_new_folder_name(self, folder_name, is_valid): + self._tasks_widget.setVisible(not folder_name) + if self._folder_is_valid is is_valid: + return + self._folder_is_valid = is_valid + state = "" + if folder_name: + if is_valid is True: + state = "valid" + elif is_valid is False: + state = "invalid" + set_style_property( + self._folder_name_input, "state", state + ) + + def _invalidate_variant(self, is_valid): + if self._variant_is_valid is is_valid: + return + self._variant_is_valid = is_valid + state = "valid" if is_valid else "invalid" + set_style_property(self._variant_input, "state", state) + + def _on_submission_change(self, event): + self._publish_btn.setEnabled(event["enabled"]) + + def _on_close_click(self): + self.close() + + def _on_select_click(self): + self._process_item_id = self._controller.submit(wait=False) + + def _on_try_again_click(self): + self._process_item_id = None + self._last_submit_message = None + + self._overlay_close_btn.setVisible(False) + self._overlay_try_btn.setVisible(False) + self._main_layout.setCurrentWidget(self._main_context_widget) + + def _on_main_thread_timer(self): + if self._last_submit_message: + self._overlay_label.setText(self._last_submit_message) + self._last_submit_message = None + + process_status = self._controller.get_process_item_status( + self._process_item_id + ) + push_failed = process_status["failed"] + fail_traceback = process_status["full_traceback"] + if self._main_thread_timer_can_stop: + self._main_thread_timer.stop() + self._overlay_close_btn.setVisible(True) + if push_failed and not fail_traceback: + self._overlay_try_btn.setVisible(True) + + if push_failed: + message = "Push Failed:\n{}".format(process_status["fail_reason"]) + if fail_traceback: + message += "\n{}".format(fail_traceback) + self._overlay_label.setText(message) + set_style_property(self._overlay_close_btn, "state", "error") + + if self._main_thread_timer_can_stop: + # Join thread in controller + self._controller.wait_for_process_thread() + # Reset process item to None + self._process_item_id = None + + def _on_controller_submit_start(self): + self._main_thread_timer_can_stop = False + self._main_thread_timer.start() + self._main_layout.setCurrentWidget(self._overlay_widget) + self._overlay_label.setText("Submittion started") + + def _on_controller_submit_end(self): + self._main_thread_timer_can_stop = True + + def _on_push_message(self, event): + self._last_submit_message = event["message"] diff --git a/openpype/tools/ayon_sceneinventory/__init__.py b/openpype/tools/ayon_sceneinventory/__init__.py new file mode 100644 index 0000000000..5412e2fea2 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/__init__.py @@ -0,0 +1,6 @@ +from .control import SceneInventoryController + + +__all__ = ( + "SceneInventoryController", +) diff --git a/openpype/tools/ayon_sceneinventory/control.py b/openpype/tools/ayon_sceneinventory/control.py new file mode 100644 index 0000000000..6111d7e43b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/control.py @@ -0,0 +1,127 @@ +import ayon_api + +from openpype.lib.events import QueuedEventSystem +from openpype.host import ILoadHost +from openpype.pipeline import ( + registered_host, + get_current_context, +) +from openpype.tools.ayon_utils.models import HierarchyModel + +from .models import SiteSyncModel + + +class SceneInventoryController: + """This is a temporary controller for AYON. + + Goal of this temporary controller is to provide a way to get current + context instead of using 'AvalonMongoDB' object (or 'legacy_io'). + + Also provides (hopefully) cleaner api for site sync. + """ + + def __init__(self, host=None): + if host is None: + host = registered_host() + self._host = host + self._current_context = None + self._current_project = None + self._current_folder_id = None + self._current_folder_set = False + + self._site_sync_model = SiteSyncModel(self) + # Switch dialog requirements + self._hierarchy_model = HierarchyModel(self) + self._event_system = self._create_event_system() + + def emit_event(self, topic, data=None, source=None): + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def reset(self): + self._current_context = None + self._current_project = None + self._current_folder_id = None + self._current_folder_set = False + + self._site_sync_model.reset() + self._hierarchy_model.reset() + + def get_current_context(self): + if self._current_context is None: + if hasattr(self._host, "get_current_context"): + self._current_context = self._host.get_current_context() + else: + self._current_context = get_current_context() + return self._current_context + + def get_current_project_name(self): + if self._current_project is None: + self._current_project = self.get_current_context()["project_name"] + return self._current_project + + def get_current_folder_id(self): + if self._current_folder_set: + return self._current_folder_id + + context = self.get_current_context() + project_name = context["project_name"] + folder_name = context.get("asset_name") + folder_id = None + if folder_name: + folder = ayon_api.get_folder_by_path(project_name, folder_name) + if folder: + folder_id = folder["id"] + + self._current_folder_id = folder_id + self._current_folder_set = True + return self._current_folder_id + + def get_containers(self): + host = self._host + if isinstance(host, ILoadHost): + return host.get_containers() + elif hasattr(host, "ls"): + return host.ls() + return [] + + # Site Sync methods + def is_sync_server_enabled(self): + return self._site_sync_model.is_sync_server_enabled() + + def get_sites_information(self): + return self._site_sync_model.get_sites_information() + + def get_site_provider_icons(self): + return self._site_sync_model.get_site_provider_icons() + + def get_representations_site_progress(self, representation_ids): + return self._site_sync_model.get_representations_site_progress( + representation_ids + ) + + def resync_representations(self, representation_ids, site_type): + return self._site_sync_model.resync_representations( + representation_ids, site_type + ) + + # Switch dialog methods + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_folder_label(self, folder_id): + if not folder_id: + return None + project_name = self.get_current_project_name() + folder_item = self._hierarchy_model.get_folder_item( + project_name, folder_id) + if folder_item is None: + return None + return folder_item.label + + def _create_event_system(self): + return QueuedEventSystem() diff --git a/openpype/tools/ayon_sceneinventory/model.py b/openpype/tools/ayon_sceneinventory/model.py new file mode 100644 index 0000000000..16924b0a7e --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/model.py @@ -0,0 +1,622 @@ +import collections +import re +import logging +import uuid +import copy + +from collections import defaultdict + +from qtpy import QtCore, QtGui +import qtawesome + +from openpype.client import ( + get_assets, + get_subsets, + get_versions, + get_last_version_by_subset_id, + get_representations, +) +from openpype.pipeline import ( + get_current_project_name, + schema, + HeroVersionType, +) +from openpype.style import get_default_entity_icon_color +from openpype.tools.utils.models import TreeModel, Item + + +def walk_hierarchy(node): + """Recursively yield group node.""" + for child in node.children(): + if child.get("isGroupNode"): + yield child + + for _child in walk_hierarchy(child): + yield _child + + +class InventoryModel(TreeModel): + """The model for the inventory""" + + Columns = [ + "Name", + "version", + "count", + "family", + "group", + "loader", + "objectName", + "active_site", + "remote_site", + ] + active_site_col = Columns.index("active_site") + remote_site_col = Columns.index("remote_site") + + OUTDATED_COLOR = QtGui.QColor(235, 30, 30) + CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30) + GRAYOUT_COLOR = QtGui.QColor(160, 160, 160) + + UniqueRole = QtCore.Qt.UserRole + 2 # unique label role + + def __init__(self, controller, parent=None): + super(InventoryModel, self).__init__(parent) + self.log = logging.getLogger(self.__class__.__name__) + + self._controller = controller + + self._hierarchy_view = False + + self._default_icon_color = get_default_entity_icon_color() + + site_icons = self._controller.get_site_provider_icons() + + self._site_icons = { + provider: QtGui.QIcon(icon_path) + for provider, icon_path in site_icons.items() + } + + def outdated(self, item): + value = item.get("version") + if isinstance(value, HeroVersionType): + return False + + if item.get("version") == item.get("highest_version"): + return False + return True + + def data(self, index, role): + if not index.isValid(): + return + + item = index.internalPointer() + + if role == QtCore.Qt.FontRole: + # Make top-level entries bold + if item.get("isGroupNode") or item.get("isNotSet"): # group-item + font = QtGui.QFont() + font.setBold(True) + return font + + if role == QtCore.Qt.ForegroundRole: + # Set the text color to the OUTDATED_COLOR when the + # collected version is not the same as the highest version + key = self.Columns[index.column()] + if key == "version": # version + if item.get("isGroupNode"): # group-item + if self.outdated(item): + return self.OUTDATED_COLOR + + if self._hierarchy_view: + # If current group is not outdated, check if any + # outdated children. + for _node in walk_hierarchy(item): + if self.outdated(_node): + return self.CHILD_OUTDATED_COLOR + else: + + if self._hierarchy_view: + # Although this is not a group item, we still need + # to distinguish which one contain outdated child. + for _node in walk_hierarchy(item): + if self.outdated(_node): + return self.CHILD_OUTDATED_COLOR.darker(150) + + return self.GRAYOUT_COLOR + + if key == "Name" and not item.get("isGroupNode"): + return self.GRAYOUT_COLOR + + # Add icons + if role == QtCore.Qt.DecorationRole: + if index.column() == 0: + # Override color + color = item.get("color", self._default_icon_color) + if item.get("isGroupNode"): # group-item + return qtawesome.icon("fa.folder", color=color) + if item.get("isNotSet"): + return qtawesome.icon("fa.exclamation-circle", color=color) + + return qtawesome.icon("fa.file-o", color=color) + + if index.column() == 3: + # Family icon + return item.get("familyIcon", None) + + column_name = self.Columns[index.column()] + + if column_name == "group" and item.get("group"): + return qtawesome.icon("fa.object-group", + color=get_default_entity_icon_color()) + + if item.get("isGroupNode"): + if column_name == "active_site": + provider = item.get("active_site_provider") + return self._site_icons.get(provider) + + if column_name == "remote_site": + provider = item.get("remote_site_provider") + return self._site_icons.get(provider) + + if role == QtCore.Qt.DisplayRole and item.get("isGroupNode"): + column_name = self.Columns[index.column()] + progress = None + if column_name == "active_site": + progress = item.get("active_site_progress", 0) + elif column_name == "remote_site": + progress = item.get("remote_site_progress", 0) + if progress is not None: + return "{}%".format(max(progress, 0) * 100) + + if role == self.UniqueRole: + return item["representation"] + item.get("objectName", "") + + return super(InventoryModel, self).data(index, role) + + def set_hierarchy_view(self, state): + """Set whether to display subsets in hierarchy view.""" + state = bool(state) + + if state != self._hierarchy_view: + self._hierarchy_view = state + + def refresh(self, selected=None, containers=None): + """Refresh the model""" + + # for debugging or testing, injecting items from outside + if containers is None: + containers = self._controller.get_containers() + + self.clear() + if not selected or not self._hierarchy_view: + self._add_containers(containers) + return + + # Filter by cherry-picked items + self._add_containers(( + container + for container in containers + if container["objectName"] in selected + )) + + def _add_containers(self, containers, parent=None): + """Add the items to the model. + + The items should be formatted similar to `api.ls()` returns, an item + is then represented as: + {"filename_v001.ma": [full/filename/of/loaded/filename_v001.ma, + full/filename/of/loaded/filename_v001.ma], + "nodetype" : "reference", + "node": "referenceNode1"} + + Note: When performing an additional call to `add_items` it will *not* + group the new items with previously existing item groups of the + same type. + + Args: + containers (generator): Container items. + parent (Item, optional): Set this item as parent for the added + items when provided. Defaults to the root of the model. + + Returns: + node.Item: root node which has children added based on the data + """ + + project_name = get_current_project_name() + + self.beginResetModel() + + # Group by representation + grouped = defaultdict(lambda: {"containers": list()}) + for container in containers: + repre_id = container["representation"] + grouped[repre_id]["containers"].append(container) + + ( + repres_by_id, + versions_by_id, + products_by_id, + folders_by_id, + ) = self._query_entities(project_name, set(grouped.keys())) + # Add to model + not_found = defaultdict(list) + not_found_ids = [] + for repre_id, group_dict in sorted(grouped.items()): + group_containers = group_dict["containers"] + representation = repres_by_id.get(repre_id) + if not representation: + not_found["representation"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + version = versions_by_id.get(representation["parent"]) + if not version: + not_found["version"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + product = products_by_id.get(version["parent"]) + if not product: + not_found["product"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + folder = folders_by_id.get(product["parent"]) + if not folder: + not_found["folder"].extend(group_containers) + not_found_ids.append(repre_id) + continue + + group_dict.update({ + "representation": representation, + "version": version, + "subset": product, + "asset": folder + }) + + for _repre_id in not_found_ids: + grouped.pop(_repre_id) + + for where, group_containers in not_found.items(): + # create the group header + group_node = Item() + name = "< NOT FOUND - {} >".format(where) + group_node["Name"] = name + group_node["representation"] = name + group_node["count"] = len(group_containers) + group_node["isGroupNode"] = False + group_node["isNotSet"] = True + + self.add_child(group_node, parent=parent) + + for container in group_containers: + item_node = Item() + item_node.update(container) + item_node["Name"] = container.get("objectName", "NO NAME") + item_node["isNotFound"] = True + self.add_child(item_node, parent=group_node) + + # TODO Use product icons + family_icon = qtawesome.icon( + "fa.folder", color="#0091B2" + ) + # Prepare site sync specific data + progress_by_id = self._controller.get_representations_site_progress( + set(grouped.keys()) + ) + sites_info = self._controller.get_sites_information() + + for repre_id, group_dict in sorted(grouped.items()): + group_containers = group_dict["containers"] + representation = group_dict["representation"] + version = group_dict["version"] + subset = group_dict["subset"] + asset = group_dict["asset"] + + # Get the primary family + maj_version, _ = schema.get_schema_version(subset["schema"]) + if maj_version < 3: + src_doc = version + else: + src_doc = subset + + prim_family = src_doc["data"].get("family") + if not prim_family: + families = src_doc["data"].get("families") + if families: + prim_family = families[0] + + # Store the highest available version so the model can know + # whether current version is currently up-to-date. + highest_version = get_last_version_by_subset_id( + project_name, version["parent"] + ) + + # create the group header + group_node = Item() + group_node["Name"] = "{}_{}: ({})".format( + asset["name"], subset["name"], representation["name"] + ) + group_node["representation"] = repre_id + group_node["version"] = version["name"] + group_node["highest_version"] = highest_version["name"] + group_node["family"] = prim_family or "" + group_node["familyIcon"] = family_icon + group_node["count"] = len(group_containers) + group_node["isGroupNode"] = True + group_node["group"] = subset["data"].get("subsetGroup") + + # Site sync specific data + progress = progress_by_id[repre_id] + group_node.update(sites_info) + group_node["active_site_progress"] = progress["active_site"] + group_node["remote_site_progress"] = progress["remote_site"] + + self.add_child(group_node, parent=parent) + + for container in group_containers: + item_node = Item() + item_node.update(container) + + # store the current version on the item + item_node["version"] = version["name"] + + # Remapping namespace to item name. + # Noted that the name key is capital "N", by doing this, we + # can view namespace in GUI without changing container data. + item_node["Name"] = container["namespace"] + + self.add_child(item_node, parent=group_node) + + self.endResetModel() + + return self._root_item + + def _query_entities(self, project_name, repre_ids): + """Query entities for representations from containers. + + Returns: + tuple[dict, dict, dict, dict]: Representation, version, product + and folder documents by id. + """ + + repres_by_id = {} + versions_by_id = {} + products_by_id = {} + folders_by_id = {} + output = ( + repres_by_id, + versions_by_id, + products_by_id, + folders_by_id, + ) + + filtered_repre_ids = set() + for repre_id in repre_ids: + # Filter out invalid representation ids + # NOTE: This is added because scenes from OpenPype did contain + # ObjectId from mongo. + try: + uuid.UUID(repre_id) + filtered_repre_ids.add(repre_id) + except ValueError: + continue + if not filtered_repre_ids: + return output + + repre_docs = get_representations(project_name, repre_ids) + repres_by_id.update({ + repre_doc["_id"]: repre_doc + for repre_doc in repre_docs + }) + version_ids = { + repre_doc["parent"] for repre_doc in repres_by_id.values() + } + if not version_ids: + return output + + version_docs = get_versions(project_name, version_ids, hero=True) + versions_by_id.update({ + version_doc["_id"]: version_doc + for version_doc in version_docs + }) + hero_versions_by_subversion_id = collections.defaultdict(list) + for version_doc in versions_by_id.values(): + if version_doc["type"] != "hero_version": + continue + subversion = version_doc["version_id"] + hero_versions_by_subversion_id[subversion].append(version_doc) + + if hero_versions_by_subversion_id: + subversion_ids = set( + hero_versions_by_subversion_id.keys() + ) + subversion_docs = get_versions(project_name, subversion_ids) + for subversion_doc in subversion_docs: + subversion_id = subversion_doc["_id"] + subversion_ids.discard(subversion_id) + h_version_docs = hero_versions_by_subversion_id[subversion_id] + for version_doc in h_version_docs: + version_doc["name"] = HeroVersionType( + subversion_doc["name"] + ) + version_doc["data"] = copy.deepcopy( + subversion_doc["data"] + ) + + for subversion_id in subversion_ids: + h_version_docs = hero_versions_by_subversion_id[subversion_id] + for version_doc in h_version_docs: + versions_by_id.pop(version_doc["_id"]) + + product_ids = { + version_doc["parent"] + for version_doc in versions_by_id.values() + } + if not product_ids: + return output + product_docs = get_subsets(project_name, product_ids) + products_by_id.update({ + product_doc["_id"]: product_doc + for product_doc in product_docs + }) + folder_ids = { + product_doc["parent"] + for product_doc in products_by_id.values() + } + if not folder_ids: + return output + + folder_docs = get_assets(project_name, folder_ids) + folders_by_id.update({ + folder_doc["_id"]: folder_doc + for folder_doc in folder_docs + }) + return output + + +class FilterProxyModel(QtCore.QSortFilterProxyModel): + """Filter model to where key column's value is in the filtered tags""" + + def __init__(self, *args, **kwargs): + super(FilterProxyModel, self).__init__(*args, **kwargs) + self._filter_outdated = False + self._hierarchy_view = False + + def filterAcceptsRow(self, row, parent): + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + + # Always allow bottom entries (individual containers), since their + # parent group hidden if it wouldn't have been validated. + rows = model.rowCount(source_index) + if not rows: + return True + + # Filter by regex + if hasattr(self, "filterRegExp"): + regex = self.filterRegExp() + else: + regex = self.filterRegularExpression() + pattern = regex.pattern() + if pattern: + pattern = re.escape(pattern) + + if not self._matches(row, parent, pattern): + return False + + if self._filter_outdated: + # When filtering to outdated we filter the up to date entries + # thus we "allow" them when they are outdated + if not self._is_outdated(row, parent): + return False + + return True + + def set_filter_outdated(self, state): + """Set whether to show the outdated entries only.""" + state = bool(state) + + if state != self._filter_outdated: + self._filter_outdated = bool(state) + self.invalidateFilter() + + def set_hierarchy_view(self, state): + state = bool(state) + + if state != self._hierarchy_view: + self._hierarchy_view = state + + def _is_outdated(self, row, parent): + """Return whether row is outdated. + + A row is considered outdated if it has "version" and "highest_version" + data and in the internal data structure, and they are not of an + equal value. + + """ + def outdated(node): + version = node.get("version", None) + highest = node.get("highest_version", None) + + # Always allow indices that have no version data at all + if version is None and highest is None: + return True + + # If either a version or highest is present but not the other + # consider the item invalid. + if not self._hierarchy_view: + # Skip this check if in hierarchy view, or the child item + # node will be hidden even it's actually outdated. + if version is None or highest is None: + return False + return version != highest + + index = self.sourceModel().index(row, self.filterKeyColumn(), parent) + + # The scene contents are grouped by "representation", e.g. the same + # "representation" loaded twice is grouped under the same header. + # Since the version check filters these parent groups we skip that + # check for the individual children. + has_parent = index.parent().isValid() + if has_parent and not self._hierarchy_view: + return True + + # Filter to those that have the different version numbers + node = index.internalPointer() + if outdated(node): + return True + + if self._hierarchy_view: + for _node in walk_hierarchy(node): + if outdated(_node): + return True + + return False + + def _matches(self, row, parent, pattern): + """Return whether row matches regex pattern. + + Args: + row (int): row number in model + parent (QtCore.QModelIndex): parent index + pattern (regex.pattern): pattern to check for in key + + Returns: + bool + + """ + model = self.sourceModel() + column = self.filterKeyColumn() + role = self.filterRole() + + def matches(row, parent, pattern): + index = model.index(row, column, parent) + key = model.data(index, role) + if re.search(pattern, key, re.IGNORECASE): + return True + + if matches(row, parent, pattern): + return True + + # Also allow if any of the children matches + source_index = model.index(row, column, parent) + rows = model.rowCount(source_index) + + if any( + matches(idx, source_index, pattern) + for idx in range(rows) + ): + return True + + if not self._hierarchy_view: + return False + + for idx in range(rows): + child_index = model.index(idx, column, source_index) + child_rows = model.rowCount(child_index) + return any( + self._matches(child_idx, child_index, pattern) + for child_idx in range(child_rows) + ) + + return True diff --git a/openpype/tools/ayon_sceneinventory/models/__init__.py b/openpype/tools/ayon_sceneinventory/models/__init__.py new file mode 100644 index 0000000000..c861d3c1a0 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/models/__init__.py @@ -0,0 +1,6 @@ +from .site_sync import SiteSyncModel + + +__all__ = ( + "SiteSyncModel", +) diff --git a/openpype/tools/ayon_sceneinventory/models/site_sync.py b/openpype/tools/ayon_sceneinventory/models/site_sync.py new file mode 100644 index 0000000000..1297137cb0 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/models/site_sync.py @@ -0,0 +1,176 @@ +from openpype.client import get_representations +from openpype.modules import ModulesManager + +NOT_SET = object() + + +class SiteSyncModel: + def __init__(self, controller): + self._controller = controller + + self._sync_server_module = NOT_SET + self._sync_server_enabled = None + self._active_site = NOT_SET + self._remote_site = NOT_SET + self._active_site_provider = NOT_SET + self._remote_site_provider = NOT_SET + + def reset(self): + self._sync_server_module = NOT_SET + self._sync_server_enabled = None + self._active_site = NOT_SET + self._remote_site = NOT_SET + self._active_site_provider = NOT_SET + self._remote_site_provider = NOT_SET + + def is_sync_server_enabled(self): + """Site sync is enabled. + + Returns: + bool: Is enabled or not. + """ + + self._cache_sync_server_module() + return self._sync_server_enabled + + def get_site_provider_icons(self): + """Icon paths per provider. + + Returns: + dict[str, str]: Path by provider name. + """ + + if not self.is_sync_server_enabled(): + return {} + site_sync = self._get_sync_server_module() + return site_sync.get_site_icons() + + def get_sites_information(self): + return { + "active_site": self._get_active_site(), + "active_site_provider": self._get_active_site_provider(), + "remote_site": self._get_remote_site(), + "remote_site_provider": self._get_remote_site_provider() + } + + def get_representations_site_progress(self, representation_ids): + """Get progress of representations sync.""" + + representation_ids = set(representation_ids) + output = { + repre_id: { + "active_site": 0, + "remote_site": 0, + } + for repre_id in representation_ids + } + if not self.is_sync_server_enabled(): + return output + + project_name = self._controller.get_current_project_name() + site_sync = self._get_sync_server_module() + repre_docs = get_representations(project_name, representation_ids) + active_site = self._get_active_site() + remote_site = self._get_remote_site() + + for repre_doc in repre_docs: + repre_output = output[repre_doc["_id"]] + result = site_sync.get_progress_for_repre( + repre_doc, active_site, remote_site + ) + repre_output["active_site"] = result[active_site] + repre_output["remote_site"] = result[remote_site] + + return output + + def resync_representations(self, representation_ids, site_type): + """ + + Args: + representation_ids (Iterable[str]): Representation ids. + site_type (Literal[active_site, remote_site]): Site type. + """ + + project_name = self._controller.get_current_project_name() + site_sync = self._get_sync_server_module() + active_site = self._get_active_site() + remote_site = self._get_remote_site() + progress = self.get_representations_site_progress( + representation_ids + ) + for repre_id in representation_ids: + repre_progress = progress.get(repre_id) + if not repre_progress: + continue + + if site_type == "active_site": + # check opposite from added site, must be 1 or unable to sync + check_progress = repre_progress["remote_site"] + site = active_site + else: + check_progress = repre_progress["active_site"] + site = remote_site + + if check_progress == 1: + site_sync.add_site( + project_name, repre_id, site, force=True + ) + + def _get_sync_server_module(self): + self._cache_sync_server_module() + return self._sync_server_module + + def _cache_sync_server_module(self): + if self._sync_server_module is not NOT_SET: + return self._sync_server_module + manager = ModulesManager() + site_sync = manager.modules_by_name.get("sync_server") + sync_enabled = site_sync is not None and site_sync.enabled + self._sync_server_module = site_sync + self._sync_server_enabled = sync_enabled + + def _get_active_site(self): + if self._active_site is NOT_SET: + self._cache_sites() + return self._active_site + + def _get_remote_site(self): + if self._remote_site is NOT_SET: + self._cache_sites() + return self._remote_site + + def _get_active_site_provider(self): + if self._active_site_provider is NOT_SET: + self._cache_sites() + return self._active_site_provider + + def _get_remote_site_provider(self): + if self._remote_site_provider is NOT_SET: + self._cache_sites() + 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: + 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( + project_name, active_site + ) + if remote_site != "studio": + remote_site_provider = site_sync.get_active_provider( + project_name, remote_site + ) + + self._active_site = active_site + self._remote_site = remote_site + self._active_site_provider = active_site_provider + self._remote_site_provider = remote_site_provider diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py b/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py new file mode 100644 index 0000000000..4c07832829 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/__init__.py @@ -0,0 +1,6 @@ +from .dialog import SwitchAssetDialog + + +__all__ = ( + "SwitchAssetDialog", +) diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py b/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py new file mode 100644 index 0000000000..2ebed7f89b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py @@ -0,0 +1,1333 @@ +import collections +import logging + +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.client import ( + get_assets, + get_subset_by_name, + get_subsets, + get_versions, + get_hero_versions, + get_last_versions, + get_representations, +) +from openpype.pipeline.load import ( + discover_loader_plugins, + switch_container, + get_repres_contexts, + loaders_from_repre_context, + LoaderSwitchNotImplementedError, + IncompatibleLoaderError, + LoaderNotFoundError +) + +from .widgets import ( + ButtonWithMenu, + SearchComboBox +) +from .folders_input import FoldersField + +log = logging.getLogger("SwitchAssetDialog") + + +class ValidationState: + def __init__(self): + self.folder_ok = True + self.product_ok = True + self.repre_ok = True + + @property + def all_ok(self): + return ( + self.folder_ok + and self.product_ok + and self.repre_ok + ) + + +class SwitchAssetDialog(QtWidgets.QDialog): + """Widget to support asset switching""" + + MIN_WIDTH = 550 + + switched = QtCore.Signal() + + def __init__(self, controller, parent=None, items=None): + super(SwitchAssetDialog, self).__init__(parent) + + self.setWindowTitle("Switch selected items ...") + + # Force and keep focus dialog + self.setModal(True) + + folders_field = FoldersField(controller, self) + products_combox = SearchComboBox(self) + repres_combobox = SearchComboBox(self) + + products_combox.set_placeholder("") + repres_combobox.set_placeholder("") + + folder_label = QtWidgets.QLabel(self) + product_label = QtWidgets.QLabel(self) + repre_label = QtWidgets.QLabel(self) + + current_folder_btn = QtWidgets.QPushButton("Use current folder", self) + + accept_icon = qtawesome.icon("fa.check", color="white") + accept_btn = ButtonWithMenu(self) + accept_btn.setIcon(accept_icon) + + main_layout = QtWidgets.QGridLayout(self) + # Folder column + main_layout.addWidget(current_folder_btn, 0, 0) + main_layout.addWidget(folders_field, 1, 0) + main_layout.addWidget(folder_label, 2, 0) + # Product column + main_layout.addWidget(products_combox, 1, 1) + main_layout.addWidget(product_label, 2, 1) + # Representation column + main_layout.addWidget(repres_combobox, 1, 2) + main_layout.addWidget(repre_label, 2, 2) + # Btn column + main_layout.addWidget(accept_btn, 1, 3) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 1) + main_layout.setColumnStretch(2, 1) + main_layout.setColumnStretch(3, 0) + + show_timer = QtCore.QTimer() + show_timer.setInterval(0) + show_timer.setSingleShot(False) + + show_timer.timeout.connect(self._on_show_timer) + folders_field.value_changed.connect( + self._combobox_value_changed + ) + products_combox.currentIndexChanged.connect( + self._combobox_value_changed + ) + repres_combobox.currentIndexChanged.connect( + self._combobox_value_changed + ) + accept_btn.clicked.connect(self._on_accept) + current_folder_btn.clicked.connect(self._on_current_folder) + + self._show_timer = show_timer + self._show_counter = 0 + + self._current_folder_btn = current_folder_btn + + self._folders_field = folders_field + self._products_combox = products_combox + self._representations_box = repres_combobox + + self._folder_label = folder_label + self._product_label = product_label + self._repre_label = repre_label + + self._accept_btn = accept_btn + + self.setMinimumWidth(self.MIN_WIDTH) + + # Set default focus to accept button so you don't directly type in + # first asset field, this also allows to see the placeholder value. + accept_btn.setFocus() + + self._folder_docs_by_id = {} + self._product_docs_by_id = {} + self._version_docs_by_id = {} + self._repre_docs_by_id = {} + + self._missing_folder_ids = set() + self._missing_product_ids = set() + self._missing_version_ids = set() + self._missing_repre_ids = set() + self._missing_docs = False + + self._inactive_folder_ids = set() + self._inactive_product_ids = set() + self._inactive_repre_ids = set() + + self._init_folder_id = None + self._init_product_name = None + self._init_repre_name = None + + self._fill_check = False + + self._project_name = controller.get_current_project_name() + self._folder_id = controller.get_current_folder_id() + + self._current_folder_btn.setEnabled(self._folder_id is not None) + + self._controller = controller + + self._items = items + self._prepare_content_data() + + def showEvent(self, event): + super(SwitchAssetDialog, self).showEvent(event) + self._show_timer.start() + + def refresh(self, init_refresh=False): + """Build the need comboboxes with content""" + if not self._fill_check and not init_refresh: + return + + self._fill_check = False + + validation_state = ValidationState() + self._folders_field.refresh() + # Set other comboboxes to empty if any document is missing or + # any folder of loaded representations is archived. + self._is_folder_ok(validation_state) + if validation_state.folder_ok: + product_values = self._get_product_box_values() + self._fill_combobox(product_values, "product") + self._is_product_ok(validation_state) + + if validation_state.folder_ok and validation_state.product_ok: + repre_values = sorted(self._representations_box_values()) + self._fill_combobox(repre_values, "repre") + self._is_repre_ok(validation_state) + + # Fill comboboxes with values + self.set_labels() + + self.apply_validations(validation_state) + + self._build_loaders_menu() + + if init_refresh: + # pre select context if possible + self._folders_field.set_selected_item(self._init_folder_id) + self._products_combox.set_valid_value(self._init_product_name) + self._representations_box.set_valid_value(self._init_repre_name) + + self._fill_check = True + + def set_labels(self): + folder_label = self._folders_field.get_selected_folder_label() + product_label = self._products_combox.get_valid_value() + repre_label = self._representations_box.get_valid_value() + + default = "*No changes" + self._folder_label.setText(folder_label or default) + self._product_label.setText(product_label or default) + self._repre_label.setText(repre_label or default) + + def apply_validations(self, validation_state): + error_msg = "*Please select" + error_sheet = "border: 1px solid red;" + + product_sheet = None + repre_sheet = None + accept_state = "" + if validation_state.folder_ok is False: + self._folder_label.setText(error_msg) + elif validation_state.product_ok is False: + product_sheet = error_sheet + self._product_label.setText(error_msg) + elif validation_state.repre_ok is False: + repre_sheet = error_sheet + self._repre_label.setText(error_msg) + + if validation_state.all_ok: + accept_state = "1" + + self._folders_field.set_valid(validation_state.folder_ok) + self._products_combox.setStyleSheet(product_sheet or "") + self._representations_box.setStyleSheet(repre_sheet or "") + + self._accept_btn.setEnabled(validation_state.all_ok) + self._set_style_property(self._accept_btn, "state", accept_state) + + def find_last_versions(self, product_ids): + project_name = self._project_name + return get_last_versions( + project_name, + subset_ids=product_ids, + fields=["_id", "parent", "type"] + ) + + def _on_show_timer(self): + if self._show_counter == 2: + self._show_timer.stop() + self.refresh(True) + else: + self._show_counter += 1 + + def _prepare_content_data(self): + repre_ids = { + item["representation"] + for item in self._items + } + + project_name = self._project_name + repres = list(get_representations( + project_name, + representation_ids=repre_ids, + archived=True, + )) + repres_by_id = {str(repre["_id"]): repre for repre in repres} + + content_repre_docs_by_id = {} + inactive_repre_ids = set() + missing_repre_ids = set() + version_ids = set() + for repre_id in repre_ids: + repre_doc = repres_by_id.get(repre_id) + if repre_doc is None: + missing_repre_ids.add(repre_id) + elif repres_by_id[repre_id]["type"] == "archived_representation": + inactive_repre_ids.add(repre_id) + version_ids.add(repre_doc["parent"]) + else: + content_repre_docs_by_id[repre_id] = repre_doc + version_ids.add(repre_doc["parent"]) + + version_docs = get_versions( + project_name, + version_ids=version_ids, + hero=True + ) + content_version_docs_by_id = {} + for version_doc in version_docs: + version_id = version_doc["_id"] + content_version_docs_by_id[version_id] = version_doc + + missing_version_ids = set() + product_ids = set() + for version_id in version_ids: + version_doc = content_version_docs_by_id.get(version_id) + if version_doc is None: + missing_version_ids.add(version_id) + else: + product_ids.add(version_doc["parent"]) + + product_docs = get_subsets( + project_name, subset_ids=product_ids, archived=True + ) + product_docs_by_id = {sub["_id"]: sub for sub in product_docs} + + folder_ids = set() + inactive_product_ids = set() + missing_product_ids = set() + content_product_docs_by_id = {} + for product_id in product_ids: + product_doc = product_docs_by_id.get(product_id) + if product_doc is None: + missing_product_ids.add(product_id) + elif product_doc["type"] == "archived_subset": + folder_ids.add(product_doc["parent"]) + inactive_product_ids.add(product_id) + else: + folder_ids.add(product_doc["parent"]) + content_product_docs_by_id[product_id] = product_doc + + folder_docs = get_assets( + project_name, asset_ids=folder_ids, archived=True + ) + folder_docs_by_id = { + folder_doc["_id"]: folder_doc + for folder_doc in folder_docs + } + + missing_folder_ids = set() + inactive_folder_ids = set() + content_folder_docs_by_id = {} + for folder_id in folder_ids: + folder_doc = folder_docs_by_id.get(folder_id) + if folder_doc is None: + missing_folder_ids.add(folder_id) + elif folder_doc["type"] == "archived_asset": + inactive_folder_ids.add(folder_id) + else: + content_folder_docs_by_id[folder_id] = folder_doc + + # stash context values, works only for single representation + init_folder_id = None + init_product_name = None + init_repre_name = None + if len(repres) == 1: + init_repre_doc = repres[0] + init_version_doc = content_version_docs_by_id.get( + init_repre_doc["parent"]) + init_product_doc = None + init_folder_doc = None + if init_version_doc: + init_product_doc = content_product_docs_by_id.get( + init_version_doc["parent"] + ) + if init_product_doc: + init_folder_doc = content_folder_docs_by_id.get( + init_product_doc["parent"] + ) + if init_folder_doc: + init_repre_name = init_repre_doc["name"] + init_product_name = init_product_doc["name"] + init_folder_id = init_folder_doc["_id"] + + self._init_folder_id = init_folder_id + self._init_product_name = init_product_name + self._init_repre_name = init_repre_name + + self._folder_docs_by_id = content_folder_docs_by_id + self._product_docs_by_id = content_product_docs_by_id + self._version_docs_by_id = content_version_docs_by_id + self._repre_docs_by_id = content_repre_docs_by_id + + self._missing_folder_ids = missing_folder_ids + self._missing_product_ids = missing_product_ids + self._missing_version_ids = missing_version_ids + self._missing_repre_ids = missing_repre_ids + self._missing_docs = ( + bool(missing_folder_ids) + or bool(missing_version_ids) + or bool(missing_product_ids) + or bool(missing_repre_ids) + ) + + self._inactive_folder_ids = inactive_folder_ids + self._inactive_product_ids = inactive_product_ids + self._inactive_repre_ids = inactive_repre_ids + + def _combobox_value_changed(self, *args, **kwargs): + self.refresh() + + def _build_loaders_menu(self): + repre_ids = self._get_current_output_repre_ids() + loaders = self._get_loaders(repre_ids) + # Get and destroy the action group + self._accept_btn.clear_actions() + + if not loaders: + return + + # Build new action group + group = QtWidgets.QActionGroup(self._accept_btn) + + for loader in loaders: + # Label + label = getattr(loader, "label", None) + if label is None: + label = loader.__name__ + + action = group.addAction(label) + # action = QtWidgets.QAction(label) + action.setData(loader) + + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None: + try: + key = "fa.{0}".format(icon) + color = getattr(loader, "color", "white") + action.setIcon(qtawesome.icon(key, color=color)) + + except Exception as exc: + print("Unable to set icon for loader {}: {}".format( + loader, str(exc) + )) + + self._accept_btn.add_action(action) + + group.triggered.connect(self._on_action_clicked) + + def _on_action_clicked(self, action): + loader_plugin = action.data() + self._trigger_switch(loader_plugin) + + def _get_loaders(self, repre_ids): + repre_contexts = None + if repre_ids: + repre_contexts = get_repres_contexts(repre_ids) + + if not repre_contexts: + return list() + + available_loaders = [] + for loader_plugin in discover_loader_plugins(): + # Skip loaders without switch method + if not hasattr(loader_plugin, "switch"): + continue + + # Skip utility loaders + if ( + hasattr(loader_plugin, "is_utility") + and loader_plugin.is_utility + ): + continue + available_loaders.append(loader_plugin) + + loaders = None + for repre_context in repre_contexts.values(): + _loaders = set(loaders_from_repre_context( + available_loaders, repre_context + )) + if loaders is None: + loaders = _loaders + else: + loaders = _loaders.intersection(loaders) + + if not loaders: + break + + if loaders is None: + loaders = [] + else: + loaders = list(loaders) + + return loaders + + def _fill_combobox(self, values, combobox_type): + if combobox_type == "product": + combobox_widget = self._products_combox + elif combobox_type == "repre": + combobox_widget = self._representations_box + else: + return + selected_value = combobox_widget.get_valid_value() + + # Fill combobox + if values is not None: + combobox_widget.populate(list(sorted(values))) + if selected_value and selected_value in values: + index = None + for idx in range(combobox_widget.count()): + if selected_value == str(combobox_widget.itemText(idx)): + index = idx + break + if index is not None: + combobox_widget.setCurrentIndex(index) + + def _set_style_property(self, widget, name, value): + cur_value = widget.property(name) + if cur_value == value: + return + widget.setProperty(name, value) + widget.style().polish(widget) + + def _get_current_output_repre_ids(self): + # NOTE hero versions are not used because it is expected that + # hero version has same representations as latests + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.currentText() + selected_repre = self._representations_box.currentText() + + # Nothing is selected + # [ ] [ ] [ ] + if ( + not selected_folder_id + and not selected_product_name + and not selected_repre + ): + return list(self._repre_docs_by_id.keys()) + + # Everything is selected + # [x] [x] [x] + if selected_folder_id and selected_product_name and selected_repre: + return self._get_current_output_repre_ids_xxx( + selected_folder_id, selected_product_name, selected_repre + ) + + # [x] [x] [ ] + # If folder and product is selected + if selected_folder_id and selected_product_name: + return self._get_current_output_repre_ids_xxo( + selected_folder_id, selected_product_name + ) + + # [x] [ ] [x] + # If folder and repre is selected + if selected_folder_id and selected_repre: + return self._get_current_output_repre_ids_xox( + selected_folder_id, selected_repre + ) + + # [x] [ ] [ ] + # If folder and product is selected + if selected_folder_id: + return self._get_current_output_repre_ids_xoo(selected_folder_id) + + # [ ] [x] [x] + if selected_product_name and selected_repre: + return self._get_current_output_repre_ids_oxx( + selected_product_name, selected_repre + ) + + # [ ] [x] [ ] + if selected_product_name: + return self._get_current_output_repre_ids_oxo( + selected_product_name + ) + + # [ ] [ ] [x] + return self._get_current_output_repre_ids_oox(selected_repre) + + def _get_current_output_repre_ids_xxx( + self, folder_id, selected_product_name, selected_repre + ): + project_name = self._project_name + product_doc = get_subset_by_name( + project_name, + selected_product_name, + folder_id, + fields=["_id"] + ) + + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + version_doc = last_versions_by_product_id.get(product_id) + if not version_doc: + return [] + + repre_docs = get_representations( + project_name, + version_ids=[version_doc["_id"]], + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xxo(self, folder_id, product_name): + project_name = self._project_name + product_doc = get_subset_by_name( + project_name, + product_name, + folder_id, + fields=["_id"] + ) + if not product_doc: + return [] + + repre_names = set() + for repre_doc in self._repre_docs_by_id.values(): + repre_names.add(repre_doc["name"]) + + # TODO where to take version ids? + version_ids = [] + repre_docs = get_representations( + project_name, + representation_names=repre_names, + version_ids=version_ids, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xox(self, folder_id, selected_repre): + product_names = { + product_doc["name"] + for product_doc in self._product_docs_by_id.values() + } + + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=[folder_id], + subset_names=product_names, + fields=["_id", "name"] + ) + product_name_by_id = { + product_doc["_id"]: product_doc["name"] + for product_doc in product_docs + } + product_ids = list(product_name_by_id.keys()) + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_id_by_product_name = {} + for product_id, last_version in last_versions_by_product_id.items(): + product_name = product_name_by_id[product_id] + last_version_id_by_product_name[product_name] = ( + last_version["_id"] + ) + + repre_docs = get_representations( + project_name, + version_ids=last_version_id_by_product_name.values(), + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_xoo(self, folder_id): + project_name = self._project_name + repres_by_product_name = collections.defaultdict(set) + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + product_name = product_doc["name"] + repres_by_product_name[product_name].add(repre_doc["name"]) + + product_docs = list(get_subsets( + project_name, + asset_ids=[folder_id], + subset_names=repres_by_product_name.keys(), + fields=["_id", "name"] + )) + product_name_by_id = { + product_doc["_id"]: product_doc["name"] + for product_doc in product_docs + } + product_ids = list(product_name_by_id.keys()) + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_id_by_product_name = {} + for product_id, last_version in last_versions_by_product_id.items(): + product_name = product_name_by_id[product_id] + last_version_id_by_product_name[product_name] = ( + last_version["_id"] + ) + + repre_names_by_version_id = {} + for product_name, repre_names in repres_by_product_name.items(): + version_id = last_version_id_by_product_name.get(product_name) + # This should not happen but why to crash? + if version_id is not None: + repre_names_by_version_id[version_id] = list(repre_names) + + repre_docs = get_representations( + project_name, + names_by_version_ids=repre_names_by_version_id, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oxx( + self, product_name, selected_repre + ): + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[product_name], + fields=["_id"] + ) + product_ids = [product_doc["_id"] for product_doc in product_docs] + last_versions_by_product_id = self.find_last_versions(product_ids) + last_version_ids = [ + last_version["_id"] + for last_version in last_versions_by_product_id.values() + ] + repre_docs = get_representations( + project_name, + version_ids=last_version_ids, + representation_names=[selected_repre], + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oxo(self, product_name): + project_name = self._project_name + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[product_name], + fields=["_id", "parent"] + ) + product_docs_by_id = { + product_doc["_id"]: product_doc + for product_doc in product_docs + } + if not product_docs: + return list() + + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_names_by_folder_id = collections.defaultdict(set) + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + folder_doc = self._folder_docs_by_id[product_doc["parent"]] + folder_id = folder_doc["_id"] + repre_names_by_folder_id[folder_id].add(repre_doc["name"]) + + repre_names_by_version_id = {} + for last_version_id, product_id in product_id_by_version_id.items(): + product_doc = product_docs_by_id[product_id] + folder_id = product_doc["parent"] + repre_names = repre_names_by_folder_id.get(folder_id) + if not repre_names: + continue + repre_names_by_version_id[last_version_id] = repre_names + + repre_docs = get_representations( + project_name, + names_by_version_ids=repre_names_by_version_id, + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_current_output_repre_ids_oox(self, selected_repre): + project_name = self._project_name + repre_docs = get_representations( + project_name, + representation_names=[selected_repre], + version_ids=self._version_docs_by_id.keys(), + fields=["_id"] + ) + return [repre_doc["_id"] for repre_doc in repre_docs] + + def _get_product_box_values(self): + project_name = self._project_name + selected_folder_id = self._folders_field.get_selected_folder_id() + if selected_folder_id: + folder_ids = [selected_folder_id] + else: + folder_ids = list(self._folder_docs_by_id.keys()) + + product_docs = get_subsets( + project_name, + asset_ids=folder_ids, + fields=["parent", "name"] + ) + + product_names_by_parent_id = collections.defaultdict(set) + for product_doc in product_docs: + product_names_by_parent_id[product_doc["parent"]].add( + product_doc["name"] + ) + + possible_product_names = None + for product_names in product_names_by_parent_id.values(): + if possible_product_names is None: + possible_product_names = product_names + else: + possible_product_names = possible_product_names.intersection( + product_names) + + if not possible_product_names: + break + + if not possible_product_names: + return [] + return list(possible_product_names) + + def _representations_box_values(self): + # NOTE hero versions are not used because it is expected that + # hero version has same representations as latests + project_name = self._project_name + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.currentText() + + # If nothing is selected + # [ ] [ ] [?] + if not selected_folder_id and not selected_product_name: + # Find all representations of selection's products + possible_repres = get_representations( + project_name, + version_ids=self._version_docs_by_id.keys(), + fields=["parent", "name"] + ) + + possible_repres_by_parent = collections.defaultdict(set) + for repre in possible_repres: + possible_repres_by_parent[repre["parent"]].add(repre["name"]) + + output_repres = None + for repre_names in possible_repres_by_parent.values(): + if output_repres is None: + output_repres = repre_names + else: + output_repres = (output_repres & repre_names) + + if not output_repres: + break + + return list(output_repres or list()) + + # [x] [x] [?] + if selected_folder_id and selected_product_name: + product_doc = get_subset_by_name( + project_name, + selected_product_name, + selected_folder_id, + fields=["_id"] + ) + + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + version_doc = last_versions_by_product_id.get(product_id) + repre_docs = get_representations( + project_name, + version_ids=[version_doc["_id"]], + fields=["name"] + ) + return [ + repre_doc["name"] + for repre_doc in repre_docs + ] + + # [x] [ ] [?] + # If only folder is selected + if selected_folder_id: + # Filter products by names from content + product_names = { + product_doc["name"] + for product_doc in self._product_docs_by_id.values() + } + + product_docs = get_subsets( + project_name, + asset_ids=[selected_folder_id], + subset_names=product_names, + fields=["_id"] + ) + product_ids = { + product_doc["_id"] + for product_doc in product_docs + } + if not product_ids: + return list() + + last_versions_by_product_id = self.find_last_versions(product_ids) + product_id_by_version_id = {} + for product_id, last_version in ( + last_versions_by_product_id.items() + ): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_docs = list(get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + )) + if not repre_docs: + return list() + + repre_names_by_parent = collections.defaultdict(set) + for repre_doc in repre_docs: + repre_names_by_parent[repre_doc["parent"]].add( + repre_doc["name"] + ) + + available_repres = None + for repre_names in repre_names_by_parent.values(): + if available_repres is None: + available_repres = repre_names + continue + + available_repres = available_repres.intersection(repre_names) + + return list(available_repres) + + # [ ] [x] [?] + product_docs = list(get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[selected_product_name], + fields=["_id", "parent"] + )) + if not product_docs: + return list() + + product_docs_by_id = { + product_doc["_id"]: product_doc + for product_doc in product_docs + } + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + if not product_id_by_version_id: + return list() + + repre_docs = list( + get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + ) + if not repre_docs: + return list() + + repre_names_by_folder_id = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + folder_id = product_docs_by_id[product_id]["parent"] + repre_names_by_folder_id[folder_id].add(repre_doc["name"]) + + available_repres = None + for repre_names in repre_names_by_folder_id.values(): + if available_repres is None: + available_repres = repre_names + continue + + available_repres = available_repres.intersection(repre_names) + + return list(available_repres) + + def _is_folder_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + if ( + selected_folder_id is None + and (self._missing_docs or self._inactive_folder_ids) + ): + validation_state.folder_ok = False + + def _is_product_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + + # [?] [x] [?] + # If product is selected then must be ok + if selected_product_name is not None: + return + + # [ ] [ ] [?] + if selected_folder_id is None: + # If there were archived products and folder is not selected + if self._inactive_product_ids: + validation_state.product_ok = False + return + + # [x] [ ] [?] + project_name = self._project_name + product_docs = get_subsets( + project_name, asset_ids=[selected_folder_id], fields=["name"] + ) + + product_names = set( + product_doc["name"] + for product_doc in product_docs + ) + + for product_doc in self._product_docs_by_id.values(): + if product_doc["name"] not in product_names: + validation_state.product_ok = False + break + + def _is_repre_ok(self, validation_state): + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + selected_repre = self._representations_box.get_valid_value() + + # [?] [?] [x] + # If product is selected then must be ok + if selected_repre is not None: + return + + # [ ] [ ] [ ] + if selected_folder_id is None and selected_product_name is None: + if ( + self._inactive_repre_ids + or self._missing_version_ids + or self._missing_repre_ids + ): + validation_state.repre_ok = False + return + + # [x] [x] [ ] + project_name = self._project_name + if ( + selected_folder_id is not None + and selected_product_name is not None + ): + product_doc = get_subset_by_name( + project_name, + selected_product_name, + selected_folder_id, + fields=["_id"] + ) + product_id = product_doc["_id"] + last_versions_by_product_id = self.find_last_versions([product_id]) + last_version = last_versions_by_product_id.get(product_id) + if not last_version: + validation_state.repre_ok = False + return + + repre_docs = get_representations( + project_name, + version_ids=[last_version["_id"]], + fields=["name"] + ) + + repre_names = set( + repre_doc["name"] + for repre_doc in repre_docs + ) + for repre_doc in self._repre_docs_by_id.values(): + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + return + + # [x] [ ] [ ] + if selected_folder_id is not None: + product_docs = list(get_subsets( + project_name, + asset_ids=[selected_folder_id], + fields=["_id", "name"] + )) + + product_name_by_id = {} + product_ids = set() + for product_doc in product_docs: + product_id = product_doc["_id"] + product_ids.add(product_id) + product_name_by_id[product_id] = product_doc["name"] + + last_versions_by_product_id = self.find_last_versions(product_ids) + + product_id_by_version_id = {} + for product_id, last_version in ( + last_versions_by_product_id.items() + ): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + repre_docs = get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + repres_by_product_name = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + product_name = product_name_by_id[product_id] + repres_by_product_name[product_name].add(repre_doc["name"]) + + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + repre_names = repres_by_product_name[product_doc["name"]] + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + return + + # [ ] [x] [ ] + # Product documents + product_docs = get_subsets( + project_name, + asset_ids=self._folder_docs_by_id.keys(), + subset_names=[selected_product_name], + fields=["_id", "name", "parent"] + ) + product_docs_by_id = {} + for product_doc in product_docs: + product_docs_by_id[product_doc["_id"]] = product_doc + + last_versions_by_product_id = self.find_last_versions( + product_docs_by_id.keys() + ) + product_id_by_version_id = {} + for product_id, last_version in last_versions_by_product_id.items(): + version_id = last_version["_id"] + product_id_by_version_id[version_id] = product_id + + repre_docs = get_representations( + project_name, + version_ids=product_id_by_version_id.keys(), + fields=["name", "parent"] + ) + repres_by_folder_id = collections.defaultdict(set) + for repre_doc in repre_docs: + product_id = product_id_by_version_id[repre_doc["parent"]] + folder_id = product_docs_by_id[product_id]["parent"] + repres_by_folder_id[folder_id].add(repre_doc["name"]) + + for repre_doc in self._repre_docs_by_id.values(): + version_doc = self._version_docs_by_id[repre_doc["parent"]] + product_doc = self._product_docs_by_id[version_doc["parent"]] + folder_id = product_doc["parent"] + repre_names = repres_by_folder_id[folder_id] + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + + def _on_current_folder(self): + # Set initial folder as current. + folder_id = self._controller.get_current_folder_id() + if not folder_id: + return + + selected_folder_id = self._folders_field.get_selected_folder_id() + if folder_id == selected_folder_id: + return + + self._folders_field.set_selected_item(folder_id) + self._combobox_value_changed() + + def _on_accept(self): + self._trigger_switch() + + def _trigger_switch(self, loader=None): + # Use None when not a valid value or when placeholder value + selected_folder_id = self._folders_field.get_selected_folder_id() + selected_product_name = self._products_combox.get_valid_value() + selected_representation = self._representations_box.get_valid_value() + + project_name = self._project_name + if selected_folder_id: + folder_ids = {selected_folder_id} + else: + folder_ids = set(self._folder_docs_by_id.keys()) + + product_names = None + if selected_product_name: + product_names = [selected_product_name] + + product_docs = list(get_subsets( + project_name, + subset_names=product_names, + asset_ids=folder_ids + )) + product_ids = set() + product_docs_by_parent_and_name = collections.defaultdict(dict) + for product_doc in product_docs: + product_ids.add(product_doc["_id"]) + folder_id = product_doc["parent"] + name = product_doc["name"] + product_docs_by_parent_and_name[folder_id][name] = product_doc + + # versions + _version_docs = get_versions(project_name, subset_ids=product_ids) + version_docs = list(reversed( + sorted(_version_docs, key=lambda item: item["name"]) + )) + + hero_version_docs = list(get_hero_versions( + project_name, subset_ids=product_ids + )) + + version_ids = set() + version_docs_by_parent_id = {} + for version_doc in version_docs: + parent_id = version_doc["parent"] + if parent_id not in version_docs_by_parent_id: + version_ids.add(version_doc["_id"]) + version_docs_by_parent_id[parent_id] = version_doc + + hero_version_docs_by_parent_id = {} + for hero_version_doc in hero_version_docs: + version_ids.add(hero_version_doc["_id"]) + parent_id = hero_version_doc["parent"] + hero_version_docs_by_parent_id[parent_id] = hero_version_doc + + repre_docs = get_representations( + project_name, version_ids=version_ids + ) + repre_docs_by_parent_id_by_name = collections.defaultdict(dict) + for repre_doc in repre_docs: + parent_id = repre_doc["parent"] + name = repre_doc["name"] + repre_docs_by_parent_id_by_name[parent_id][name] = repre_doc + + for container in self._items: + self._switch_container( + container, + loader, + selected_folder_id, + selected_product_name, + selected_representation, + product_docs_by_parent_and_name, + version_docs_by_parent_id, + hero_version_docs_by_parent_id, + repre_docs_by_parent_id_by_name, + ) + + self.switched.emit() + + self.close() + + def _switch_container( + self, + container, + loader, + selected_folder_id, + product_name, + selected_representation, + product_docs_by_parent_and_name, + version_docs_by_parent_id, + hero_version_docs_by_parent_id, + repre_docs_by_parent_id_by_name, + ): + container_repre_id = container["representation"] + container_repre = self._repre_docs_by_id[container_repre_id] + container_repre_name = container_repre["name"] + container_version_id = container_repre["parent"] + + container_version = self._version_docs_by_id[container_version_id] + + container_product_id = container_version["parent"] + container_product = self._product_docs_by_id[container_product_id] + + if selected_folder_id: + folder_id = selected_folder_id + else: + folder_id = container_product["parent"] + + products_by_name = product_docs_by_parent_and_name[folder_id] + if product_name: + product_doc = products_by_name[product_name] + else: + product_doc = products_by_name[container_product["name"]] + + repre_doc = None + product_id = product_doc["_id"] + if container_version["type"] == "hero_version": + hero_version = hero_version_docs_by_parent_id.get( + product_id + ) + if hero_version: + _repres = repre_docs_by_parent_id_by_name.get( + hero_version["_id"] + ) + if selected_representation: + repre_doc = _repres.get(selected_representation) + else: + repre_doc = _repres.get(container_repre_name) + + if not repre_doc: + version_doc = version_docs_by_parent_id[product_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] + else: + repre_doc = repres_by_name[container_repre_name] + + error = None + try: + switch_container(container, repre_doc, loader) + except ( + LoaderSwitchNotImplementedError, + IncompatibleLoaderError, + LoaderNotFoundError, + ) as exc: + error = str(exc) + except Exception: + error = ( + "Switch asset failed. " + "Search console log for more details." + ) + if error is not None: + log.warning(( + "Couldn't switch asset." + "See traceback for more information." + ), exc_info=True) + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Switch asset failed") + dialog.setText(error) + dialog.exec_() diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py b/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py new file mode 100644 index 0000000000..699c62371a --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/folders_input.py @@ -0,0 +1,307 @@ +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.tools.utils import ( + PlaceholderLineEdit, + BaseClickableFrame, + set_style_property, +) +from openpype.tools.ayon_utils.widgets import FoldersWidget + +NOT_SET = object() + + +class ClickableLineEdit(QtWidgets.QLineEdit): + """QLineEdit capturing left mouse click. + + Triggers `clicked` signal on mouse click. + """ + clicked = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(ClickableLineEdit, self).__init__(*args, **kwargs) + self.setReadOnly(True) + self._mouse_pressed = False + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + event.accept() + + def mouseMoveEvent(self, event): + event.accept() + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + event.accept() + + def mouseDoubleClickEvent(self, event): + event.accept() + + +class ControllerWrap: + def __init__(self, controller): + self._controller = controller + self._selected_folder_id = None + + def emit_event(self, *args, **kwargs): + self._controller.emit_event(*args, **kwargs) + + def register_event_callback(self, *args, **kwargs): + self._controller.register_event_callback(*args, **kwargs) + + def get_current_project_name(self): + return self._controller.get_current_project_name() + + def get_folder_items(self, *args, **kwargs): + return self._controller.get_folder_items(*args, **kwargs) + + def set_selected_folder(self, folder_id): + self._selected_folder_id = folder_id + + def get_selected_folder_id(self): + return self._selected_folder_id + + +class FoldersDialog(QtWidgets.QDialog): + """Dialog to select asset for a context of instance.""" + + def __init__(self, controller, parent): + super(FoldersDialog, self).__init__(parent) + self.setWindowTitle("Select folder") + + filter_input = PlaceholderLineEdit(self) + filter_input.setPlaceholderText("Filter folders..") + + controller_wrap = ControllerWrap(controller) + folders_widget = FoldersWidget(controller_wrap, self) + folders_widget.set_deselectable(True) + + ok_btn = QtWidgets.QPushButton("OK", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(filter_input, 0) + layout.addWidget(folders_widget, 1) + layout.addLayout(btns_layout, 0) + + folders_widget.double_clicked.connect(self._on_ok_clicked) + folders_widget.refreshed.connect(self._on_folders_refresh) + filter_input.textChanged.connect(self._on_filter_change) + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self._filter_input = filter_input + self._ok_btn = ok_btn + self._cancel_btn = cancel_btn + + self._folders_widget = folders_widget + self._controller_wrap = controller_wrap + + # Set selected folder only when user confirms the dialog + self._selected_folder_id = None + self._selected_folder_label = None + + self._folder_id_to_select = NOT_SET + + self._first_show = True + self._default_height = 500 + + def showEvent(self, event): + """Refresh asset model on show.""" + super(FoldersDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() + + def refresh(self): + project_name = self._controller_wrap.get_current_project_name() + self._folders_widget.set_project_name(project_name) + + def _on_first_show(self): + center = self.rect().center() + size = self.size() + size.setHeight(self._default_height) + + self.resize(size) + new_pos = self.mapToGlobal(center) + new_pos.setX(new_pos.x() - int(self.width() / 2)) + new_pos.setY(new_pos.y() - int(self.height() / 2)) + self.move(new_pos) + + def _on_folders_refresh(self): + if self._folder_id_to_select is NOT_SET: + return + self._folders_widget.set_selected_folder(self._folder_id_to_select) + self._folder_id_to_select = NOT_SET + + def _on_filter_change(self, text): + """Trigger change of filter of folders.""" + + self._folders_widget.set_name_filter(text) + + def _on_cancel_clicked(self): + self.done(0) + + def _on_ok_clicked(self): + self._selected_folder_id = ( + self._folders_widget.get_selected_folder_id() + ) + self._selected_folder_label = ( + self._folders_widget.get_selected_folder_label() + ) + self.done(1) + + def set_selected_folder(self, folder_id): + """Change preselected folder before showing the dialog. + + This also resets model and clean filter. + """ + + if ( + self._folders_widget.is_refreshing + or self._folders_widget.get_project_name() is None + ): + self._folder_id_to_select = folder_id + else: + self._folders_widget.set_selected_folder(folder_id) + + def get_selected_folder_id(self): + """Get selected folder id. + + Returns: + Union[str, None]: Selected folder id or None if nothing + is selected. + """ + return self._selected_folder_id + + def get_selected_folder_label(self): + return self._selected_folder_label + + +class FoldersField(BaseClickableFrame): + """Field where asset name of selected instance/s is showed. + + Click on the field will trigger `FoldersDialog`. + """ + value_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(FoldersField, self).__init__(parent) + self.setObjectName("AssetNameInputWidget") + + # Don't use 'self' for parent! + # - this widget has specific styles + dialog = FoldersDialog(controller, parent) + + name_input = ClickableLineEdit(self) + name_input.setObjectName("AssetNameInput") + + icon = qtawesome.icon("fa.window-maximize", color="white") + icon_btn = QtWidgets.QPushButton(self) + icon_btn.setIcon(icon) + icon_btn.setObjectName("AssetNameInputButton") + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(name_input, 1) + layout.addWidget(icon_btn, 0) + + # Make sure all widgets are vertically extended to highest widget + for widget in ( + name_input, + icon_btn + ): + w_size_policy = widget.sizePolicy() + w_size_policy.setVerticalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + widget.setSizePolicy(w_size_policy) + + size_policy = self.sizePolicy() + size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Maximum) + self.setSizePolicy(size_policy) + + name_input.clicked.connect(self._mouse_release_callback) + icon_btn.clicked.connect(self._mouse_release_callback) + dialog.finished.connect(self._on_dialog_finish) + + self._controller = controller + self._dialog = dialog + self._name_input = name_input + self._icon_btn = icon_btn + + self._selected_folder_id = None + self._selected_folder_label = None + self._selected_items = [] + self._is_valid = True + + def refresh(self): + self._dialog.refresh() + + def is_valid(self): + """Is asset valid.""" + return self._is_valid + + def get_selected_folder_id(self): + """Selected asset names.""" + return self._selected_folder_id + + def get_selected_folder_label(self): + return self._selected_folder_label + + def set_text(self, text): + """Set text in text field. + + Does not change selected items (assets). + """ + self._name_input.setText(text) + + def set_valid(self, is_valid): + state = "" + if not is_valid: + state = "invalid" + self._set_state_property(state) + + def set_selected_item(self, folder_id=None, folder_label=None): + """Set folder for selection. + + Args: + folder_id (Optional[str]): Folder id to select. + folder_label (Optional[str]): Folder label. + """ + + self._selected_folder_id = folder_id + if not folder_id: + folder_label = None + elif folder_id and not folder_label: + folder_label = self._controller.get_folder_label(folder_id) + self._selected_folder_label = folder_label + self.set_text(folder_label if folder_label else "") + + def _on_dialog_finish(self, result): + if not result: + return + + folder_id = self._dialog.get_selected_folder_id() + folder_label = self._dialog.get_selected_folder_label() + self.set_selected_item(folder_id, folder_label) + + self.value_changed.emit() + + def _mouse_release_callback(self): + self._dialog.set_selected_folder(self._selected_folder_id) + self._dialog.open() + + def _set_state_property(self, state): + set_style_property(self, "state", state) + set_style_property(self._name_input, "state", state) + set_style_property(self._icon_btn, "state", state) diff --git a/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py b/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py new file mode 100644 index 0000000000..50a49e0ce1 --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py @@ -0,0 +1,94 @@ +from qtpy import QtWidgets, QtCore + +from openpype import style + + +class ButtonWithMenu(QtWidgets.QToolButton): + def __init__(self, parent=None): + super(ButtonWithMenu, self).__init__(parent) + + self.setObjectName("ButtonWithMenu") + + self.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + menu = QtWidgets.QMenu(self) + + self.setMenu(menu) + + self._menu = menu + self._actions = [] + + def menu(self): + return self._menu + + def clear_actions(self): + if self._menu is not None: + self._menu.clear() + self._actions = [] + + def add_action(self, action): + self._actions.append(action) + self._menu.addAction(action) + + def _on_action_trigger(self): + action = self.sender() + if action not in self._actions: + return + action.trigger() + + +class SearchComboBox(QtWidgets.QComboBox): + """Searchable ComboBox with empty placeholder value as first value""" + + def __init__(self, parent): + super(SearchComboBox, self).__init__(parent) + + self.setEditable(True) + self.setInsertPolicy(QtWidgets.QComboBox.NoInsert) + + combobox_delegate = QtWidgets.QStyledItemDelegate(self) + self.setItemDelegate(combobox_delegate) + + completer = self.completer() + completer.setCompletionMode( + QtWidgets.QCompleter.PopupCompletion + ) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + + completer_view = completer.popup() + completer_view.setObjectName("CompleterView") + completer_delegate = QtWidgets.QStyledItemDelegate(completer_view) + completer_view.setItemDelegate(completer_delegate) + completer_view.setStyleSheet(style.load_stylesheet()) + + self._combobox_delegate = combobox_delegate + + self._completer_delegate = completer_delegate + self._completer = completer + + def set_placeholder(self, placeholder): + self.lineEdit().setPlaceholderText(placeholder) + + def populate(self, items): + self.clear() + self.addItems([""]) # ensure first item is placeholder + self.addItems(items) + + def get_valid_value(self): + """Return the current text if it's a valid value else None + + Note: The empty placeholder value is valid and returns as "" + + """ + + text = self.currentText() + lookup = set(self.itemText(i) for i in range(self.count())) + if text not in lookup: + return None + + return text or None + + def set_valid_value(self, value): + """Try to locate 'value' and pre-select it in dropdown.""" + index = self.findText(value) + if index > -1: + self.setCurrentIndex(index) diff --git a/openpype/tools/ayon_sceneinventory/view.py b/openpype/tools/ayon_sceneinventory/view.py new file mode 100644 index 0000000000..039b498b1b --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/view.py @@ -0,0 +1,825 @@ +import uuid +import collections +import logging +import itertools +from functools import partial + +from qtpy import QtWidgets, QtCore +import qtawesome + +from openpype.client import ( + get_version_by_id, + get_versions, + get_hero_versions, + get_representation_by_id, + get_representations, +) +from openpype import style +from openpype.pipeline import ( + HeroVersionType, + update_container, + remove_container, + discover_inventory_actions, +) +from openpype.tools.utils.lib import ( + iter_model_rows, + format_version +) + +from .switch_dialog import SwitchAssetDialog +from .model import InventoryModel + + +DEFAULT_COLOR = "#fb9c15" + +log = logging.getLogger("SceneInventory") + + +class SceneInventoryView(QtWidgets.QTreeView): + data_changed = QtCore.Signal() + hierarchy_view_changed = QtCore.Signal(bool) + + def __init__(self, controller, parent): + super(SceneInventoryView, self).__init__(parent=parent) + + # view settings + self.setIndentation(12) + self.setAlternatingRowColors(True) + self.setSortingEnabled(True) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + self.customContextMenuRequested.connect(self._show_right_mouse_menu) + + self._hierarchy_view = False + self._selected = None + + self._controller = controller + + def _set_hierarchy_view(self, enabled): + if enabled == self._hierarchy_view: + return + self._hierarchy_view = enabled + self.hierarchy_view_changed.emit(enabled) + + def _enter_hierarchy(self, items): + self._selected = set(i["objectName"] for i in items) + self._set_hierarchy_view(True) + self.data_changed.emit() + self.expandToDepth(1) + self.setStyleSheet(""" + QTreeView { + border-color: #fb9c15; + } + """) + + def _leave_hierarchy(self): + self._set_hierarchy_view(False) + self.data_changed.emit() + self.setStyleSheet("QTreeView {}") + + def _build_item_menu_for_selection(self, items, menu): + # Exclude items that are "NOT FOUND" since setting versions, updating + # and removal won't work for those items. + items = [item for item in items if not item.get("isNotFound")] + if not items: + return + + # An item might not have a representation, for example when an item + # is listed as "NOT FOUND" + repre_ids = set() + for item in items: + repre_id = item["representation"] + try: + uuid.UUID(repre_id) + repre_ids.add(repre_id) + except ValueError: + pass + + project_name = self._controller.get_current_project_name() + repre_docs = get_representations( + project_name, representation_ids=repre_ids, fields=["parent"] + ) + + version_ids = { + repre_doc["parent"] + for repre_doc in repre_docs + } + + loaded_versions = get_versions( + project_name, version_ids=version_ids, hero=True + ) + + loaded_hero_versions = [] + versions_by_parent_id = collections.defaultdict(list) + subset_ids = set() + for version in loaded_versions: + if version["type"] == "hero_version": + loaded_hero_versions.append(version) + else: + parent_id = version["parent"] + versions_by_parent_id[parent_id].append(version) + subset_ids.add(parent_id) + + all_versions = get_versions( + project_name, subset_ids=subset_ids, hero=True + ) + hero_versions = [] + versions = [] + for version in all_versions: + if version["type"] == "hero_version": + hero_versions.append(version) + else: + versions.append(version) + + has_loaded_hero_versions = len(loaded_hero_versions) > 0 + has_available_hero_version = len(hero_versions) > 0 + has_outdated = False + + for version in versions: + parent_id = version["parent"] + current_versions = versions_by_parent_id[parent_id] + for current_version in current_versions: + if current_version["name"] < version["name"]: + has_outdated = True + break + + if has_outdated: + break + + switch_to_versioned = None + if has_loaded_hero_versions: + def _on_switch_to_versioned(items): + repre_ids = { + item["representation"] + for item in items + } + + repre_docs = get_representations( + project_name, + representation_ids=repre_ids, + fields=["parent"] + ) + + version_ids = set() + version_id_by_repre_id = {} + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + repre_id = str(repre_doc["_id"]) + version_id_by_repre_id[repre_id] = version_id + version_ids.add(version_id) + + hero_versions = get_hero_versions( + project_name, + version_ids=version_ids, + fields=["version_id"] + ) + + hero_src_version_ids = set() + for hero_version in hero_versions: + version_id = hero_version["version_id"] + hero_src_version_ids.add(version_id) + hero_version_id = hero_version["_id"] + for _repre_id, current_version_id in ( + version_id_by_repre_id.items() + ): + if current_version_id == hero_version_id: + version_id_by_repre_id[_repre_id] = version_id + + version_docs = get_versions( + project_name, + version_ids=hero_src_version_ids, + fields=["name"] + ) + version_name_by_id = {} + for version_doc in version_docs: + version_name_by_id[version_doc["_id"]] = \ + version_doc["name"] + + # Specify version per item to update to + update_items = [] + update_versions = [] + for item in items: + repre_id = item["representation"] + version_id = version_id_by_repre_id.get(repre_id) + version_name = version_name_by_id.get(version_id) + if version_name is not None: + update_items.append(item) + update_versions.append(version_name) + self._update_containers(update_items, update_versions) + + update_icon = qtawesome.icon( + "fa.asterisk", + color=DEFAULT_COLOR + ) + switch_to_versioned = QtWidgets.QAction( + update_icon, + "Switch to versioned", + menu + ) + switch_to_versioned.triggered.connect( + lambda: _on_switch_to_versioned(items) + ) + + update_to_latest_action = None + if has_outdated or has_loaded_hero_versions: + update_icon = qtawesome.icon( + "fa.angle-double-up", + color=DEFAULT_COLOR + ) + update_to_latest_action = QtWidgets.QAction( + update_icon, + "Update to latest", + menu + ) + update_to_latest_action.triggered.connect( + lambda: self._update_containers(items, version=-1) + ) + + change_to_hero = None + if has_available_hero_version: + # TODO change icon + change_icon = qtawesome.icon( + "fa.asterisk", + color="#00b359" + ) + change_to_hero = QtWidgets.QAction( + change_icon, + "Change to hero", + menu + ) + change_to_hero.triggered.connect( + lambda: self._update_containers(items, + version=HeroVersionType(-1)) + ) + + # set version + set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR) + set_version_action = QtWidgets.QAction( + set_version_icon, + "Set version", + menu + ) + set_version_action.triggered.connect( + lambda: self._show_version_dialog(items)) + + # switch folder + switch_folder_icon = qtawesome.icon("fa.sitemap", color=DEFAULT_COLOR) + switch_folder_action = QtWidgets.QAction( + switch_folder_icon, + "Switch Folder", + menu + ) + switch_folder_action.triggered.connect( + lambda: self._show_switch_dialog(items)) + + # remove + remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR) + remove_action = QtWidgets.QAction(remove_icon, "Remove items", menu) + remove_action.triggered.connect( + lambda: self._show_remove_warning_dialog(items)) + + # add the actions + if switch_to_versioned: + menu.addAction(switch_to_versioned) + + if update_to_latest_action: + menu.addAction(update_to_latest_action) + + if change_to_hero: + menu.addAction(change_to_hero) + + menu.addAction(set_version_action) + menu.addAction(switch_folder_action) + + menu.addSeparator() + + menu.addAction(remove_action) + + self._handle_sync_server(menu, repre_ids) + + def _handle_sync_server(self, menu, repre_ids): + """Adds actions for download/upload when SyncServer is enabled + + Args: + menu (OptionMenu) + repre_ids (list) of object_ids + + Returns: + (OptionMenu) + """ + + if not self._controller.is_sync_server_enabled(): + return + + menu.addSeparator() + + download_icon = qtawesome.icon("fa.download", color=DEFAULT_COLOR) + download_active_action = QtWidgets.QAction( + download_icon, + "Download", + menu + ) + download_active_action.triggered.connect( + lambda: self._add_sites(repre_ids, "active_site")) + + upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR) + upload_remote_action = QtWidgets.QAction( + upload_icon, + "Upload", + menu + ) + upload_remote_action.triggered.connect( + lambda: self._add_sites(repre_ids, "remote_site")) + + menu.addAction(download_active_action) + menu.addAction(upload_remote_action) + + def _add_sites(self, repre_ids, site_type): + """(Re)sync all 'repre_ids' to specific site. + + It checks if opposite site has fully available content to limit + accidents. (ReSync active when no remote >> losing active content) + + Args: + repre_ids (list) + site_type (Literal[active_site, remote_site]): Site type. + """ + + self._controller.resync_representations(repre_ids, site_type) + + self.data_changed.emit() + + def _build_item_menu(self, items=None): + """Create menu for the selected items""" + + if not items: + items = [] + + menu = QtWidgets.QMenu(self) + + # add the actions + self._build_item_menu_for_selection(items, menu) + + # These two actions should be able to work without selection + # expand all items + expandall_action = QtWidgets.QAction(menu, text="Expand all items") + expandall_action.triggered.connect(self.expandAll) + + # collapse all items + collapse_action = QtWidgets.QAction(menu, text="Collapse all items") + collapse_action.triggered.connect(self.collapseAll) + + menu.addAction(expandall_action) + menu.addAction(collapse_action) + + custom_actions = self._get_custom_actions(containers=items) + if custom_actions: + submenu = QtWidgets.QMenu("Actions", self) + for action in custom_actions: + color = action.color or DEFAULT_COLOR + icon = qtawesome.icon("fa.%s" % action.icon, color=color) + action_item = QtWidgets.QAction(icon, action.label, submenu) + action_item.triggered.connect( + partial(self._process_custom_action, action, items)) + + submenu.addAction(action_item) + + menu.addMenu(submenu) + + # go back to flat view + back_to_flat_action = None + if self._hierarchy_view: + back_to_flat_icon = qtawesome.icon("fa.list", color=DEFAULT_COLOR) + back_to_flat_action = QtWidgets.QAction( + back_to_flat_icon, + "Back to Full-View", + menu + ) + back_to_flat_action.triggered.connect(self._leave_hierarchy) + + # send items to hierarchy view + enter_hierarchy_icon = qtawesome.icon("fa.indent", color="#d8d8d8") + enter_hierarchy_action = QtWidgets.QAction( + enter_hierarchy_icon, + "Cherry-Pick (Hierarchy)", + menu + ) + enter_hierarchy_action.triggered.connect( + lambda: self._enter_hierarchy(items)) + + if items: + menu.addAction(enter_hierarchy_action) + + if back_to_flat_action is not None: + menu.addAction(back_to_flat_action) + + return menu + + def _get_custom_actions(self, containers): + """Get the registered Inventory Actions + + Args: + containers(list): collection of containers + + Returns: + list: collection of filter and initialized actions + """ + + def sorter(Plugin): + """Sort based on order attribute of the plugin""" + return Plugin.order + + # Fedd an empty dict if no selection, this will ensure the compat + # lookup always work, so plugin can interact with Scene Inventory + # reversely. + containers = containers or [dict()] + + # Check which action will be available in the menu + Plugins = discover_inventory_actions() + compatible = [p() for p in Plugins if + any(p.is_compatible(c) for c in containers)] + + return sorted(compatible, key=sorter) + + def _process_custom_action(self, action, containers): + """Run action and if results are returned positive update the view + + If the result is list or dict, will select view items by the result. + + Args: + action (InventoryAction): Inventory Action instance + containers (list): Data of currently selected items + + Returns: + None + """ + + result = action.process(containers) + if result: + self.data_changed.emit() + + if isinstance(result, (list, set)): + self._select_items_by_action(result) + + if isinstance(result, dict): + self._select_items_by_action( + result["objectNames"], result["options"] + ) + + def _select_items_by_action(self, object_names, options=None): + """Select view items by the result of action + + Args: + object_names (list or set): A list/set of container object name + options (dict): GUI operation options. + + Returns: + None + + """ + options = options or dict() + + if options.get("clear", True): + self.clearSelection() + + object_names = set(object_names) + if ( + self._hierarchy_view + and not self._selected.issuperset(object_names) + ): + # If any container not in current cherry-picked view, update + # view before selecting them. + self._selected.update(object_names) + self.data_changed.emit() + + model = self.model() + selection_model = self.selectionModel() + + select_mode = { + "select": QtCore.QItemSelectionModel.Select, + "deselect": QtCore.QItemSelectionModel.Deselect, + "toggle": QtCore.QItemSelectionModel.Toggle, + }[options.get("mode", "select")] + + for index in iter_model_rows(model, 0): + item = index.data(InventoryModel.ItemRole) + if item.get("isGroupNode"): + continue + + name = item.get("objectName") + if name in object_names: + self.scrollTo(index) # Ensure item is visible + flags = select_mode | QtCore.QItemSelectionModel.Rows + selection_model.select(index, flags) + + object_names.remove(name) + + if len(object_names) == 0: + break + + def _show_right_mouse_menu(self, pos): + """Display the menu when at the position of the item clicked""" + + globalpos = self.viewport().mapToGlobal(pos) + + if not self.selectionModel().hasSelection(): + print("No selection") + # Build menu without selection, feed an empty list + menu = self._build_item_menu() + menu.exec_(globalpos) + return + + active = self.currentIndex() # index under mouse + active = active.sibling(active.row(), 0) # get first column + + # move index under mouse + indices = self.get_indices() + if active in indices: + indices.remove(active) + + indices.append(active) + + # Extend to the sub-items + all_indices = self._extend_to_children(indices) + items = [dict(i.data(InventoryModel.ItemRole)) for i in all_indices + if i.parent().isValid()] + + if self._hierarchy_view: + # Ensure no group item + items = [n for n in items if not n.get("isGroupNode")] + + menu = self._build_item_menu(items) + menu.exec_(globalpos) + + def get_indices(self): + """Get the selected rows""" + selection_model = self.selectionModel() + return selection_model.selectedRows() + + def _extend_to_children(self, indices): + """Extend the indices to the children indices. + + Top-level indices are extended to its children indices. Sub-items + are kept as is. + + Args: + indices (list): The indices to extend. + + Returns: + list: The children indices + + """ + def get_children(i): + model = i.model() + rows = model.rowCount(parent=i) + for row in range(rows): + child = model.index(row, 0, parent=i) + yield child + + subitems = set() + for i in indices: + valid_parent = i.parent().isValid() + if valid_parent and i not in subitems: + subitems.add(i) + + if self._hierarchy_view: + # Assume this is a group item + for child in get_children(i): + subitems.add(child) + else: + # is top level item + for child in get_children(i): + subitems.add(child) + + return list(subitems) + + def _show_version_dialog(self, items): + """Create a dialog with the available versions for the selected file + + Args: + items (list): list of items to run the "set_version" for + + Returns: + None + """ + + active = items[-1] + + project_name = self._controller.get_current_project_name() + # Get available versions for active representation + repre_doc = get_representation_by_id( + project_name, + active["representation"], + fields=["parent"] + ) + + repre_version_doc = get_version_by_id( + project_name, + repre_doc["parent"], + fields=["parent"] + ) + + version_docs = list(get_versions( + project_name, + subset_ids=[repre_version_doc["parent"]], + hero=True + )) + hero_version = None + standard_versions = [] + for version_doc in version_docs: + if version_doc["type"] == "hero_version": + hero_version = version_doc + else: + standard_versions.append(version_doc) + versions = list(reversed( + sorted(standard_versions, key=lambda item: item["name"]) + )) + if hero_version: + _version_id = hero_version["version_id"] + for _version in versions: + if _version["_id"] != _version_id: + continue + + hero_version["name"] = HeroVersionType( + _version["name"] + ) + hero_version["data"] = _version["data"] + break + + # Get index among the listed versions + current_item = None + current_version = active["version"] + if isinstance(current_version, HeroVersionType): + current_item = hero_version + else: + for version in versions: + if version["name"] == current_version: + current_item = version + break + + all_versions = [] + if hero_version: + all_versions.append(hero_version) + all_versions.extend(versions) + + if current_item: + index = all_versions.index(current_item) + else: + index = 0 + + versions_by_label = dict() + labels = [] + for version in all_versions: + is_hero = version["type"] == "hero_version" + label = format_version(version["name"], is_hero) + labels.append(label) + versions_by_label[label] = version["name"] + + label, state = QtWidgets.QInputDialog.getItem( + self, + "Set version..", + "Set version number to", + labels, + current=index, + editable=False + ) + if not state: + return + + if label: + version = versions_by_label[label] + self._update_containers(items, version) + + def _show_switch_dialog(self, items): + """Display Switch dialog""" + dialog = SwitchAssetDialog(self._controller, self, items) + dialog.switched.connect(self.data_changed.emit) + dialog.show() + + def _show_remove_warning_dialog(self, items): + """Prompt a dialog to inform the user the action will remove items""" + + accept = QtWidgets.QMessageBox.Ok + buttons = accept | QtWidgets.QMessageBox.Cancel + + state = QtWidgets.QMessageBox.question( + self, + "Are you sure?", + "Are you sure you want to remove {} item(s)".format(len(items)), + buttons=buttons, + defaultButton=accept + ) + + if state != accept: + return + + for item in items: + remove_container(item) + self.data_changed.emit() + + def _show_version_error_dialog(self, version, items): + """Shows QMessageBox when version switch doesn't work + + Args: + version: str or int or None + """ + if version == -1: + version_str = "latest" + elif isinstance(version, HeroVersionType): + version_str = "hero" + elif isinstance(version, int): + version_str = "v{:03d}".format(version) + else: + version_str = version + + dialog = QtWidgets.QMessageBox(self) + dialog.setIcon(QtWidgets.QMessageBox.Warning) + dialog.setStyleSheet(style.load_stylesheet()) + dialog.setWindowTitle("Update failed") + + switch_btn = dialog.addButton( + "Switch Folder", + QtWidgets.QMessageBox.ActionRole + ) + switch_btn.clicked.connect(lambda: self._show_switch_dialog(items)) + + dialog.addButton(QtWidgets.QMessageBox.Cancel) + + msg = ( + "Version update to '{}' failed as representation doesn't exist." + "\n\nPlease update to version with a valid representation" + " OR \n use 'Switch Folder' button to change folder." + ).format(version_str) + dialog.setText(msg) + dialog.exec_() + + def update_all(self): + """Update all items that are currently 'outdated' in the view""" + # Get the source model through the proxy model + model = self.model().sourceModel() + + # Get all items from outdated groups + outdated_items = [] + for index in iter_model_rows(model, + column=0, + include_root=False): + item = index.data(model.ItemRole) + + if not item.get("isGroupNode"): + continue + + # Only the group nodes contain the "highest_version" data and as + # such we find only the groups and take its children. + if not model.outdated(item): + continue + + # Collect all children which we want to update + children = item.children() + outdated_items.extend(children) + + if not outdated_items: + log.info("Nothing to update.") + return + + # Trigger update to latest + self._update_containers(outdated_items, version=-1) + + def _update_containers(self, items, version): + """Helper to update items to given version (or version per item) + + If at least one item is specified this will always try to refresh + the inventory even if errors occurred on any of the items. + + Arguments: + items (list): Items to update + version (int or list): Version to set to. + This can be a list specifying a version for each item. + Like `update_container` version -1 sets the latest version + and HeroTypeVersion instances set the hero version. + + """ + + if isinstance(version, (list, tuple)): + # We allow a unique version to be specified per item. In that case + # the length must match with the items + assert len(items) == len(version), ( + "Number of items mismatches number of versions: " + "{} items - {} versions".format(len(items), len(version)) + ) + versions = version + else: + # Repeat the same version infinitely + versions = itertools.repeat(version) + + # Trigger update to latest + try: + for item, item_version in zip(items, versions): + try: + update_container(item, item_version) + except AssertionError: + self._show_version_error_dialog(item_version, [item]) + log.warning("Update failed", exc_info=True) + finally: + # Always update the scene inventory view, even if errors occurred + self.data_changed.emit() diff --git a/openpype/tools/ayon_sceneinventory/window.py b/openpype/tools/ayon_sceneinventory/window.py new file mode 100644 index 0000000000..427bf4c50d --- /dev/null +++ b/openpype/tools/ayon_sceneinventory/window.py @@ -0,0 +1,200 @@ +from qtpy import QtWidgets, QtCore, QtGui +import qtawesome + +from openpype import style, resources +from openpype.tools.utils.delegates import VersionDelegate +from openpype.tools.utils.lib import ( + preserve_expanded_rows, + preserve_selection, +) +from openpype.tools.ayon_sceneinventory import SceneInventoryController + +from .model import ( + InventoryModel, + FilterProxyModel +) +from .view import SceneInventoryView + + +class ControllerVersionDelegate(VersionDelegate): + """Version delegate that uses controller to get project. + + Original VersionDelegate is using 'AvalonMongoDB' object instead. Don't + worry about the variable name, object is stored to '_dbcon' attribute. + """ + + def get_project_name(self): + self._dbcon.get_current_project_name() + + +class SceneInventoryWindow(QtWidgets.QDialog): + """Scene Inventory window""" + + def __init__(self, controller=None, parent=None): + super(SceneInventoryWindow, self).__init__(parent) + + if controller is None: + controller = SceneInventoryController() + + project_name = controller.get_current_project_name() + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("Scene Inventory - {}".format(project_name)) + self.setObjectName("SceneInventory") + + self.resize(1100, 480) + + # region control + + filter_label = QtWidgets.QLabel("Search", self) + text_filter = QtWidgets.QLineEdit(self) + + outdated_only_checkbox = QtWidgets.QCheckBox( + "Filter to outdated", self + ) + outdated_only_checkbox.setToolTip("Show outdated files only") + outdated_only_checkbox.setChecked(False) + + icon = qtawesome.icon("fa.arrow-up", color="white") + update_all_button = QtWidgets.QPushButton(self) + update_all_button.setToolTip("Update all outdated to latest version") + update_all_button.setIcon(icon) + + icon = qtawesome.icon("fa.refresh", color="white") + refresh_button = QtWidgets.QPushButton(self) + refresh_button.setToolTip("Refresh") + refresh_button.setIcon(icon) + + control_layout = QtWidgets.QHBoxLayout() + control_layout.addWidget(filter_label) + control_layout.addWidget(text_filter) + control_layout.addWidget(outdated_only_checkbox) + control_layout.addWidget(update_all_button) + control_layout.addWidget(refresh_button) + + model = InventoryModel(controller) + proxy = FilterProxyModel() + proxy.setSourceModel(model) + proxy.setDynamicSortFilter(True) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + view = SceneInventoryView(controller, self) + view.setModel(proxy) + + sync_enabled = controller.is_sync_server_enabled() + view.setColumnHidden(model.active_site_col, not sync_enabled) + view.setColumnHidden(model.remote_site_col, not sync_enabled) + + # set some nice default widths for the view + view.setColumnWidth(0, 250) # name + view.setColumnWidth(1, 55) # version + view.setColumnWidth(2, 55) # count + view.setColumnWidth(3, 150) # family + view.setColumnWidth(4, 120) # group + view.setColumnWidth(5, 150) # loader + + # apply delegates + version_delegate = ControllerVersionDelegate(controller, self) + column = model.Columns.index("version") + view.setItemDelegateForColumn(column, version_delegate) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(control_layout) + layout.addWidget(view) + + show_timer = QtCore.QTimer() + show_timer.setInterval(0) + show_timer.setSingleShot(False) + + # signals + show_timer.timeout.connect(self._on_show_timer) + text_filter.textChanged.connect(self._on_text_filter_change) + outdated_only_checkbox.stateChanged.connect( + self._on_outdated_state_change + ) + view.hierarchy_view_changed.connect( + self._on_hierarchy_view_change + ) + view.data_changed.connect(self._on_refresh_request) + refresh_button.clicked.connect(self._on_refresh_request) + update_all_button.clicked.connect(self._on_update_all) + + self._show_timer = show_timer + self._show_counter = 0 + self._controller = controller + self._update_all_button = update_all_button + self._outdated_only_checkbox = outdated_only_checkbox + self._view = view + self._model = model + self._proxy = proxy + self._version_delegate = version_delegate + + self._first_show = True + self._first_refresh = True + + def showEvent(self, event): + super(SceneInventoryWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + + self._show_counter = 0 + self._show_timer.start() + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidentally perform Maya commands + whilst trying to name an instance. + + """ + + def _on_refresh_request(self): + """Signal callback to trigger 'refresh' without any arguments.""" + + self.refresh() + + def refresh(self, containers=None): + self._first_refresh = False + self._controller.reset() + with preserve_expanded_rows( + tree_view=self._view, + role=self._model.UniqueRole + ): + with preserve_selection( + tree_view=self._view, + role=self._model.UniqueRole, + current_index=False + ): + kwargs = {"containers": containers} + # TODO do not touch view's inner attribute + if self._view._hierarchy_view: + kwargs["selected"] = self._view._selected + self._model.refresh(**kwargs) + + def _on_show_timer(self): + if self._show_counter < 3: + self._show_counter += 1 + return + self._show_timer.stop() + self.refresh() + + def _on_hierarchy_view_change(self, enabled): + self._proxy.set_hierarchy_view(enabled) + self._model.set_hierarchy_view(enabled) + + def _on_text_filter_change(self, text_filter): + if hasattr(self._proxy, "setFilterRegExp"): + self._proxy.setFilterRegExp(text_filter) + else: + self._proxy.setFilterRegularExpression(text_filter) + + def _on_outdated_state_change(self): + self._proxy.set_filter_outdated( + self._outdated_only_checkbox.isChecked() + ) + + def _on_update_all(self): + self._view.update_all() diff --git a/openpype/tools/ayon_utils/models/__init__.py b/openpype/tools/ayon_utils/models/__init__.py new file mode 100644 index 0000000000..8895515b1a --- /dev/null +++ b/openpype/tools/ayon_utils/models/__init__.py @@ -0,0 +1,35 @@ +"""Backend models that can be used in controllers.""" + +from .cache import CacheItem, NestedCacheItem +from .projects import ( + ProjectItem, + ProjectsModel, + PROJECTS_MODEL_SENDER, +) +from .hierarchy import ( + FolderItem, + TaskItem, + HierarchyModel, + HIERARCHY_MODEL_SENDER, +) +from .thumbnails import ThumbnailsModel +from .selection import HierarchyExpectedSelection + + +__all__ = ( + "CacheItem", + "NestedCacheItem", + + "ProjectItem", + "ProjectsModel", + "PROJECTS_MODEL_SENDER", + + "FolderItem", + "TaskItem", + "HierarchyModel", + "HIERARCHY_MODEL_SENDER", + + "ThumbnailsModel", + + "HierarchyExpectedSelection", +) diff --git a/openpype/tools/ayon_utils/models/cache.py b/openpype/tools/ayon_utils/models/cache.py new file mode 100644 index 0000000000..221a14160c --- /dev/null +++ b/openpype/tools/ayon_utils/models/cache.py @@ -0,0 +1,239 @@ +import time +import collections + +InitInfo = collections.namedtuple( + "InitInfo", + ["default_factory", "lifetime"] +) + + +def _default_factory_func(): + return None + + +class CacheItem: + """Simple cache item with lifetime and default value. + + Args: + default_factory (Optional[callable]): Function that returns default + value used on init and on reset. + lifetime (Optional[int]): Lifetime of the cache data in seconds. + """ + + def __init__(self, default_factory=None, lifetime=None): + if lifetime is None: + lifetime = 120 + self._lifetime = lifetime + self._last_update = None + if default_factory is None: + default_factory = _default_factory_func + self._default_factory = default_factory + self._data = default_factory() + + @property + def is_valid(self): + """Is cache valid to use. + + Return: + bool: True if cache is valid, False otherwise. + """ + + if self._last_update is None: + return False + + return (time.time() - self._last_update) < self._lifetime + + def set_lifetime(self, lifetime): + """Change lifetime of cache item. + + Args: + lifetime (int): Lifetime of the cache data in seconds. + """ + + self._lifetime = lifetime + + def set_invalid(self): + """Set cache as invalid.""" + + self._last_update = None + + def reset(self): + """Set cache as invalid and reset data.""" + + self._last_update = None + self._data = self._default_factory() + + def get_data(self): + """Receive cached data. + + Returns: + Any: Any data that are cached. + """ + + return self._data + + def update_data(self, data): + self._data = data + self._last_update = time.time() + + +class NestedCacheItem: + """Helper for cached items stored in nested structure. + + Example: + >>> cache = NestedCacheItem(levels=2, default_factory=lambda: 0) + >>> cache["a"]["b"].is_valid + False + >>> cache["a"]["b"].get_data() + 0 + >>> cache["a"]["b"] = 1 + >>> cache["a"]["b"].is_valid + True + >>> cache["a"]["b"].get_data() + 1 + >>> cache.reset() + >>> cache["a"]["b"].is_valid + False + + Args: + levels (int): Number of nested levels where read cache is stored. + default_factory (Optional[callable]): Function that returns default + value used on init and on reset. + lifetime (Optional[int]): Lifetime of the cache data in seconds. + _init_info (Optional[InitInfo]): Private argument. Init info for + nested cache where created from parent item. + """ + + def __init__( + self, levels=1, default_factory=None, lifetime=None, _init_info=None + ): + if levels < 1: + raise ValueError("Nested levels must be greater than 0") + self._data_by_key = {} + if _init_info is None: + _init_info = InitInfo(default_factory, lifetime) + self._init_info = _init_info + self._levels = levels + + def __getitem__(self, key): + """Get cached data. + + Args: + key (str): Key of the cache item. + + Returns: + Union[NestedCacheItem, CacheItem]: Cache item. + """ + + cache = self._data_by_key.get(key) + if cache is None: + if self._levels > 1: + cache = NestedCacheItem( + levels=self._levels - 1, + _init_info=self._init_info + ) + else: + cache = CacheItem( + self._init_info.default_factory, + self._init_info.lifetime + ) + self._data_by_key[key] = cache + return cache + + def __setitem__(self, key, value): + """Update cached data. + + Args: + key (str): Key of the cache item. + value (Any): Any data that are cached. + """ + + if self._levels > 1: + raise AttributeError(( + "{} does not support '__setitem__'. Lower nested level by {}" + ).format(self.__class__.__name__, self._levels - 1)) + cache = self[key] + cache.update_data(value) + + def get(self, key): + """Get cached data. + + Args: + key (str): Key of the cache item. + + Returns: + Union[NestedCacheItem, CacheItem]: Cache item. + """ + + return self[key] + + def cached_count(self): + """Amount of cached items. + + Returns: + int: Amount of cached items. + """ + + return len(self._data_by_key) + + def clear_key(self, key): + """Clear cached item by key. + + Args: + key (str): Key of the cache item. + """ + + self._data_by_key.pop(key, None) + + def clear_invalid(self): + """Clear all invalid cache items. + + Note: + To clear all cache items use 'reset'. + """ + + changed = {} + children_are_nested = self._levels > 1 + for key, cache in tuple(self._data_by_key.items()): + if children_are_nested: + output = cache.clear_invalid() + if output: + changed[key] = output + if not cache.cached_count(): + self._data_by_key.pop(key) + elif not cache.is_valid: + changed[key] = cache.get_data() + self._data_by_key.pop(key) + return changed + + def reset(self): + """Reset cache. + + Note: + To clear only invalid cache items use 'clear_invalid'. + """ + + self._data_by_key = {} + + def set_lifetime(self, lifetime): + """Change lifetime of all children cache items. + + Args: + lifetime (int): Lifetime of the cache data in seconds. + """ + + self._init_info.lifetime = lifetime + for cache in self._data_by_key.values(): + cache.set_lifetime(lifetime) + + @property + def is_valid(self): + """Raise reasonable error when called on wront level. + + Raises: + AttributeError: If called on nested cache item. + """ + + raise AttributeError(( + "{} does not support 'is_valid'. Lower nested level by '{}'" + ).format(self.__class__.__name__, self._levels)) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py new file mode 100644 index 0000000000..fc6b8e1eb7 --- /dev/null +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -0,0 +1,500 @@ +import collections +import contextlib +from abc import ABCMeta, abstractmethod + +import ayon_api +import six + +from openpype.style import get_default_entity_icon_color + +from .cache import NestedCacheItem + +HIERARCHY_MODEL_SENDER = "hierarchy.model" + + +@six.add_metaclass(ABCMeta) +class AbstractHierarchyController: + @abstractmethod + def emit_event(self, topic, data, source): + pass + + +class FolderItem: + """Item representing folder entity on a server. + + Folder can be a child of another folder or a project. + + Args: + entity_id (str): Folder id. + parent_id (Union[str, None]): Parent folder id. If 'None' then project + is parent. + name (str): Name of folder. + path (str): Folder path. + folder_type (str): Type of folder. + label (Union[str, None]): Folder label. + icon (Union[dict[str, Any], None]): Icon definition. + """ + + def __init__( + self, entity_id, parent_id, name, path, folder_type, label, icon + ): + self.entity_id = entity_id + self.parent_id = parent_id + self.name = name + self.path = path + self.folder_type = folder_type + self.label = label or name + if not icon: + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": get_default_entity_icon_color() + } + self.icon = icon + + def to_data(self): + """Converts folder item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "entity_id": self.entity_id, + "parent_id": self.parent_id, + "name": self.name, + "path": self.path, + "folder_type": self.folder_type, + "label": self.label, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-creates folder item from data. + + Args: + data (dict[str, Any]): Folder item data. + + Returns: + FolderItem: Folder item. + """ + + return cls(**data) + + +class TaskItem: + """Task item representing task entity on a server. + + Task is child of a folder. + + Task item has label that is used for display in UI. The label is by + default using task name and type. + + Args: + task_id (str): Task id. + name (str): Name of task. + task_type (str): Type of task. + parent_id (str): Parent folder id. + icon (Union[dict[str, Any], None]): Icon definitions. + """ + + def __init__( + self, task_id, name, task_type, parent_id, icon + ): + self.task_id = task_id + self.name = name + self.task_type = task_type + self.parent_id = parent_id + if icon is None: + icon = { + "type": "awesome-font", + "name": "fa.male", + "color": get_default_entity_icon_color() + } + self.icon = icon + + self._label = None + + @property + def id(self): + """Alias for task_id. + + Returns: + str: Task id. + """ + + return self.task_id + + @property + def label(self): + """Label of task item for UI. + + Returns: + str: Label of task item. + """ + + if self._label is None: + self._label = "{} ({})".format(self.name, self.task_type) + return self._label + + def to_data(self): + """Converts task item to data. + + Returns: + dict[str, Any]: Task item data. + """ + + return { + "task_id": self.task_id, + "name": self.name, + "parent_id": self.parent_id, + "task_type": self.task_type, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-create task item from data. + + Args: + data (dict[str, Any]): Task item data. + + Returns: + TaskItem: Task item. + """ + + return cls(**data) + + +def _get_task_items_from_tasks(tasks): + """ + + Returns: + TaskItem: Task item. + """ + + output = [] + for task in tasks: + folder_id = task["folderId"] + output.append(TaskItem( + task["id"], + task["name"], + task["type"], + folder_id, + None + )) + return output + + +def _get_folder_item_from_hierarchy_item(item): + name = item["name"] + path_parts = list(item["parents"]) + path_parts.append(name) + + return FolderItem( + item["id"], + item["parentId"], + name, + "/".join(path_parts), + item["folderType"], + item["label"], + None, + ) + + +def _get_folder_item_from_entity(entity): + name = entity["name"] + return FolderItem( + entity["id"], + entity["parentId"], + name, + entity["path"], + entity["folderType"], + entity["label"] or name, + None, + ) + + +class HierarchyModel(object): + """Model for project hierarchy items. + + Hierarchy items are folders and tasks. Folders can have as parent another + folder or project. Tasks can have as parent only folder. + """ + lifetime = 60 # A minute + + def __init__(self, controller): + self._folders_items = NestedCacheItem( + levels=1, default_factory=dict, lifetime=self.lifetime) + self._folders_by_id = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + + self._task_items = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + self._tasks_by_id = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + + self._folders_refreshing = set() + self._tasks_refreshing = set() + self._controller = controller + + def reset(self): + self._folders_items.reset() + self._folders_by_id.reset() + + self._task_items.reset() + self._tasks_by_id.reset() + + def refresh_project(self, project_name): + """Force to refresh folder items for a project. + + Args: + project_name (str): Name of project to refresh. + """ + + self._refresh_folders_cache(project_name) + + def get_folder_items(self, project_name, sender): + """Get folder items by project name. + + The folders are cached per project name. If the cache is not valid + then the folders are queried from server. + + Args: + project_name (str): Name of project where to look for folders. + sender (Union[str, None]): Who requested the folder ids. + + Returns: + dict[str, FolderItem]: Folder items by id. + """ + + if not self._folders_items[project_name].is_valid: + self._refresh_folders_cache(project_name, sender) + return self._folders_items[project_name].get_data() + + def get_folder_items_by_id(self, project_name, folder_ids): + """Get folder items by ids. + + This function will query folders if they are not in cache. But the + queried items are not added to cache back. + + Args: + project_name (str): Name of project where to look for folders. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Union[FolderItem, None]]: Folder items by id. + """ + + folder_ids = set(folder_ids) + if self._folders_items[project_name].is_valid: + cache_data = self._folders_items[project_name].get_data() + return { + folder_id: cache_data.get(folder_id) + for folder_id in folder_ids + } + folders = ayon_api.get_folders( + project_name, + folder_ids=folder_ids, + fields=["id", "name", "label", "parentId", "path", "folderType"] + ) + # Make sure all folder ids are in output + output = {folder_id: None for folder_id in folder_ids} + output.update({ + folder["id"]: _get_folder_item_from_entity(folder) + for folder in folders + }) + return output + + def get_folder_item(self, project_name, folder_id): + """Get folder items by id. + + This function will query folder if they is not in cache. But the + queried items are not added to cache back. + + Args: + project_name (str): Name of project where to look for folders. + folder_id (str): Folder id. + + Returns: + Union[FolderItem, None]: Folder item. + """ + items = self.get_folder_items_by_id( + project_name, [folder_id] + ) + return items.get(folder_id) + + def get_task_items(self, project_name, folder_id, sender): + if not project_name or not folder_id: + return [] + + task_cache = self._task_items[project_name][folder_id] + if not task_cache.is_valid: + self._refresh_tasks_cache(project_name, folder_id, sender) + return task_cache.get_data() + + def get_folder_entities(self, project_name, folder_ids): + """Get folder entities by ids. + + Args: + project_name (str): Project name. + folder_ids (Iterable[str]): Folder ids. + + Returns: + dict[str, Any]: Folder entities by id. + """ + + output = {} + folder_ids = set(folder_ids) + if not project_name or not folder_ids: + return output + + folder_ids_to_query = set() + for folder_id in folder_ids: + cache = self._folders_by_id[project_name][folder_id] + if cache.is_valid: + output[folder_id] = cache.get_data() + elif folder_id: + folder_ids_to_query.add(folder_id) + else: + output[folder_id] = None + self._query_folder_entities(project_name, folder_ids_to_query) + for folder_id in folder_ids_to_query: + cache = self._folders_by_id[project_name][folder_id] + output[folder_id] = cache.get_data() + return output + + def get_folder_entity(self, project_name, folder_id): + output = self.get_folder_entities(project_name, {folder_id}) + return output[folder_id] + + def get_task_entities(self, project_name, task_ids): + output = {} + task_ids = set(task_ids) + if not project_name or not task_ids: + return output + + task_ids_to_query = set() + for task_id in task_ids: + cache = self._tasks_by_id[project_name][task_id] + if cache.is_valid: + output[task_id] = cache.get_data() + elif task_id: + task_ids_to_query.add(task_id) + else: + output[task_id] = None + self._query_task_entities(project_name, task_ids_to_query) + for task_id in task_ids_to_query: + cache = self._tasks_by_id[project_name][task_id] + output[task_id] = cache.get_data() + return output + + def get_task_entity(self, project_name, task_id): + output = self.get_task_entities(project_name, {task_id}) + return output[task_id] + + @contextlib.contextmanager + def _folder_refresh_event_manager(self, project_name, sender): + self._folders_refreshing.add(project_name) + self._controller.emit_event( + "folders.refresh.started", + {"project_name": project_name, "sender": sender}, + HIERARCHY_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "folders.refresh.finished", + {"project_name": project_name, "sender": sender}, + HIERARCHY_MODEL_SENDER + ) + self._folders_refreshing.remove(project_name) + + @contextlib.contextmanager + def _task_refresh_event_manager( + self, project_name, folder_id, sender + ): + self._tasks_refreshing.add(folder_id) + self._controller.emit_event( + "tasks.refresh.started", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + HIERARCHY_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "tasks.refresh.finished", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + HIERARCHY_MODEL_SENDER + ) + self._tasks_refreshing.discard(folder_id) + + def _refresh_folders_cache(self, project_name, sender=None): + if project_name in self._folders_refreshing: + return + + with self._folder_refresh_event_manager(project_name, sender): + folder_items = self._query_folders(project_name) + self._folders_items[project_name].update_data(folder_items) + + def _query_folders(self, project_name): + hierarchy = ayon_api.get_folders_hierarchy(project_name) + + folder_items = {} + hierachy_queue = collections.deque(hierarchy["hierarchy"]) + while hierachy_queue: + item = hierachy_queue.popleft() + folder_item = _get_folder_item_from_hierarchy_item(item) + folder_items[folder_item.entity_id] = folder_item + hierachy_queue.extend(item["children"] or []) + return folder_items + + def _query_folder_entities(self, project_name, folder_ids): + if not project_name or not folder_ids: + return + project_cache = self._folders_by_id[project_name] + folders = ayon_api.get_folders(project_name, folder_ids=folder_ids) + for folder in folders: + folder_id = folder["id"] + project_cache[folder_id].update_data(folder) + + def _query_task_entities(self, project_name, task_ids): + if not project_name or not task_ids: + return + + project_cache = self._tasks_by_id[project_name] + tasks = ayon_api.get_tasks(project_name, task_ids=task_ids) + for task in tasks: + task_id = task["id"] + project_cache[task_id].update_data(task) + + def _refresh_tasks_cache(self, project_name, folder_id, sender=None): + if folder_id in self._tasks_refreshing: + return + + with self._task_refresh_event_manager( + project_name, folder_id, sender + ): + task_items = self._query_tasks(project_name, folder_id) + self._task_items[project_name][folder_id] = task_items + + def _query_tasks(self, project_name, folder_id): + tasks = list(ayon_api.get_tasks( + project_name, + folder_ids=[folder_id], + fields={"id", "name", "label", "folderId", "type"} + )) + return _get_task_items_from_tasks(tasks) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py new file mode 100644 index 0000000000..36d53edc24 --- /dev/null +++ b/openpype/tools/ayon_utils/models/projects.py @@ -0,0 +1,158 @@ +import contextlib +from abc import ABCMeta, abstractmethod + +import ayon_api +import six + +from openpype.style import get_default_entity_icon_color + +from .cache import CacheItem + +PROJECTS_MODEL_SENDER = "projects.model" + + +@six.add_metaclass(ABCMeta) +class AbstractHierarchyController: + @abstractmethod + def emit_event(self, topic, data, source): + pass + + +class ProjectItem: + """Item representing folder entity on a server. + + Folder can be a child of another folder or a project. + + Args: + name (str): Project name. + active (Union[str, None]): Parent folder id. If 'None' then project + is parent. + """ + + def __init__(self, name, active, is_library, icon=None): + self.name = name + self.active = active + self.is_library = is_library + if icon is None: + icon = { + "type": "awesome-font", + "name": "fa.book" if is_library else "fa.map", + "color": get_default_entity_icon_color(), + } + self.icon = icon + + def to_data(self): + """Converts folder item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "name": self.name, + "active": self.active, + "is_library": self.is_library, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-creates folder item from data. + + Args: + data (dict[str, Any]): Folder item data. + + Returns: + FolderItem: Folder item. + """ + + return cls(**data) + + +def _get_project_items_from_entitiy(projects): + """ + + Args: + projects (list[dict[str, Any]]): List of projects. + + Returns: + ProjectItem: Project item. + """ + + return [ + ProjectItem(project["name"], project["active"], project["library"]) + for project in projects + ] + + +class ProjectsModel(object): + def __init__(self, controller): + self._projects_cache = CacheItem(default_factory=list) + self._project_items_by_name = {} + self._projects_by_name = {} + + self._is_refreshing = False + self._controller = controller + + def reset(self): + self._projects_cache.reset() + self._project_items_by_name = {} + self._projects_by_name = {} + + def refresh(self): + self._refresh_projects_cache() + + def get_project_items(self, sender): + """ + + Args: + sender (str): Name of sender who asked for items. + + Returns: + Union[list[ProjectItem], None]: List of project items, or None + if model is refreshing. + """ + + if not self._projects_cache.is_valid: + return self._refresh_projects_cache(sender) + return self._projects_cache.get_data() + + def get_project_entity(self, project_name): + if project_name not in self._projects_by_name: + entity = None + if project_name: + entity = ayon_api.get_project(project_name) + self._projects_by_name[project_name] = entity + return self._projects_by_name[project_name] + + @contextlib.contextmanager + def _project_refresh_event_manager(self, sender): + self._is_refreshing = True + self._controller.emit_event( + "projects.refresh.started", + {"sender": sender}, + PROJECTS_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "projects.refresh.finished", + {"sender": sender}, + PROJECTS_MODEL_SENDER + ) + self._is_refreshing = False + + def _refresh_projects_cache(self, sender=None): + if self._is_refreshing: + return None + + with self._project_refresh_event_manager(sender): + project_items = self._query_projects() + self._projects_cache.update_data(project_items) + return self._projects_cache.get_data() + + def _query_projects(self): + projects = ayon_api.get_projects(fields=["name", "active", "library"]) + return _get_project_items_from_entitiy(projects) diff --git a/openpype/tools/ayon_utils/models/selection.py b/openpype/tools/ayon_utils/models/selection.py new file mode 100644 index 0000000000..0ff239882b --- /dev/null +++ b/openpype/tools/ayon_utils/models/selection.py @@ -0,0 +1,179 @@ +class _ExampleController: + def emit_event(self, topic, data, **kwargs): + pass + + +class HierarchyExpectedSelection: + """Base skeleton of expected selection model. + + Expected selection model holds information about which entities should be + selected. The order of selection is very important as change of project + will affect what folders are available in folders UI and so on. Because + of that should expected selection model know what is current entity + to select. + + If any of 'handle_project', 'handle_folder' or 'handle_task' is set to + 'False' expected selection data won't contain information about the + entity type at all. Also if project is not handled then it is not + necessary to call 'expected_project_selected'. Same goes for folder and + task. + + Model is triggering event with 'expected_selection_changed' topic and + data > data structure is matching 'get_expected_selection_data' method. + + Questions: + Require '_ExampleController' as abstraction? + + Args: + controller (Any): Controller object. ('_ExampleController') + handle_project (bool): Project can be considered as can have expected + selection. + handle_folder (bool): Folder can be considered as can have expected + selection. + handle_task (bool): Task can be considered as can have expected + selection. + """ + + def __init__( + self, + controller, + handle_project=True, + handle_folder=True, + handle_task=True + ): + self._project_name = None + self._folder_id = None + self._task_name = None + + self._project_selected = True + self._folder_selected = True + self._task_selected = True + + self._controller = controller + + self._handle_project = handle_project + self._handle_folder = handle_folder + self._handle_task = handle_task + + def set_expected_selection( + self, + project_name=None, + folder_id=None, + task_name=None + ): + """Sets expected selection. + + Args: + project_name (Optional[str]): Project name. + folder_id (Optional[str]): Folder id. + task_name (Optional[str]): Task name. + """ + + self._project_name = project_name + self._folder_id = folder_id + self._task_name = task_name + + self._project_selected = not self._handle_project + self._folder_selected = not self._handle_folder + self._task_selected = not self._handle_task + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + task_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + elif not self._task_selected: + task_current = True + data = {} + if self._handle_project: + data["project"] = { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + } + if self._handle_folder: + data["folder"] = { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + } + if self._handle_task: + data["task"] = { + "name": self._task_name, + "current": task_current, + "selected": self._task_selected, + } + + return data + + def is_expected_project_selected(self, project_name): + if not self._handle_project: + return True + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + if not self._handle_folder: + return True + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + """UI selected requested project. + + Other entity types can be requested for selection. + + Args: + project_name (str): Name of project. + """ + + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + """UI selected requested folder. + + Other entity types can be requested for selection. + + Args: + folder_id (str): Folder id. + """ + + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + def expected_task_selected(self, folder_id, task_name): + """UI selected requested task. + + Other entity types can be requested for selection. + + Because task name is not unique across project a folder id is also + required to confirm the right task has been selected. + + Args: + folder_id (str): Folder id. + task_name (str): Task name. + """ + + if self._folder_id != folder_id: + return False + + if task_name != self._task_name: + return False + self._task_selected = True + self._emit_change() + return True + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) diff --git a/openpype/tools/ayon_utils/models/thumbnails.py b/openpype/tools/ayon_utils/models/thumbnails.py new file mode 100644 index 0000000000..40892338df --- /dev/null +++ b/openpype/tools/ayon_utils/models/thumbnails.py @@ -0,0 +1,118 @@ +import collections + +import ayon_api + +from openpype.client.server.thumbnails import AYONThumbnailCache + +from .cache import NestedCacheItem + + +class ThumbnailsModel: + entity_cache_lifetime = 240 # In seconds + + def __init__(self): + self._thumbnail_cache = AYONThumbnailCache() + self._paths_cache = collections.defaultdict(dict) + self._folders_cache = NestedCacheItem( + levels=2, lifetime=self.entity_cache_lifetime) + self._versions_cache = NestedCacheItem( + levels=2, lifetime=self.entity_cache_lifetime) + + def reset(self): + self._paths_cache = collections.defaultdict(dict) + self._folders_cache.reset() + self._versions_cache.reset() + + def get_thumbnail_path(self, project_name, thumbnail_id): + return self._get_thumbnail_path(project_name, thumbnail_id) + + def get_folder_thumbnail_ids(self, project_name, folder_ids): + project_cache = self._folders_cache[project_name] + output = {} + missing_cache = set() + for folder_id in folder_ids: + cache = project_cache[folder_id] + if cache.is_valid: + output[folder_id] = cache.get_data() + else: + missing_cache.add(folder_id) + self._query_folder_thumbnail_ids(project_name, missing_cache) + for folder_id in missing_cache: + cache = project_cache[folder_id] + output[folder_id] = cache.get_data() + return output + + def get_version_thumbnail_ids(self, project_name, version_ids): + project_cache = self._versions_cache[project_name] + output = {} + missing_cache = set() + for version_id in version_ids: + cache = project_cache[version_id] + if cache.is_valid: + output[version_id] = cache.get_data() + else: + missing_cache.add(version_id) + self._query_version_thumbnail_ids(project_name, missing_cache) + for version_id in missing_cache: + cache = project_cache[version_id] + output[version_id] = cache.get_data() + return output + + def _get_thumbnail_path(self, project_name, thumbnail_id): + if not thumbnail_id: + return None + + project_cache = self._paths_cache[project_name] + if thumbnail_id in project_cache: + return project_cache[thumbnail_id] + + filepath = self._thumbnail_cache.get_thumbnail_filepath( + project_name, thumbnail_id + ) + if filepath is not None: + project_cache[thumbnail_id] = filepath + return filepath + + # 'ayon_api' had a bug, public function + # 'get_thumbnail_by_id' did not return output of + # 'ServerAPI' method. + con = ayon_api.get_server_api_connection() + result = con.get_thumbnail_by_id(project_name, thumbnail_id) + if result is None: + pass + + elif result.is_valid: + filepath = self._thumbnail_cache.store_thumbnail( + project_name, + thumbnail_id, + result.content, + result.content_type + ) + project_cache[thumbnail_id] = filepath + return filepath + + def _query_folder_thumbnail_ids(self, project_name, folder_ids): + if not project_name or not folder_ids: + return + + folders = ayon_api.get_folders( + project_name, + folder_ids=folder_ids, + fields=["id", "thumbnailId"] + ) + project_cache = self._folders_cache[project_name] + for folder in folders: + project_cache[folder["id"]] = folder["thumbnailId"] + + def _query_version_thumbnail_ids(self, project_name, version_ids): + if not project_name or not version_ids: + return + + versions = ayon_api.get_versions( + project_name, + version_ids=version_ids, + fields=["id", "thumbnailId"] + ) + project_cache = self._versions_cache[project_name] + for version in versions: + project_cache[version["id"]] = version["thumbnailId"] diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py new file mode 100644 index 0000000000..f58de17c4a --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -0,0 +1,41 @@ +from .projects_widget import ( + # ProjectsWidget, + ProjectsCombobox, + ProjectsQtModel, + ProjectSortFilterProxy, +) + +from .folders_widget import ( + FoldersWidget, + FoldersQtModel, + FOLDERS_MODEL_SENDER_NAME, +) + +from .tasks_widget import ( + TasksWidget, + TasksQtModel, + TASKS_MODEL_SENDER_NAME, +) +from .utils import ( + get_qt_icon, + RefreshThread, +) + + +__all__ = ( + # "ProjectsWidget", + "ProjectsCombobox", + "ProjectsQtModel", + "ProjectSortFilterProxy", + + "FoldersWidget", + "FoldersQtModel", + "FOLDERS_MODEL_SENDER_NAME", + + "TasksWidget", + "TasksQtModel", + "TASKS_MODEL_SENDER_NAME", + + "get_qt_icon", + "RefreshThread", +) diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py new file mode 100644 index 0000000000..44323a192c --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -0,0 +1,516 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + TreeView, +) + +from .utils import RefreshThread, get_qt_icon + +FOLDERS_MODEL_SENDER_NAME = "qt_folders_model" +FOLDER_ID_ROLE = QtCore.Qt.UserRole + 1 +FOLDER_NAME_ROLE = QtCore.Qt.UserRole + 2 +FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 3 +FOLDER_TYPE_ROLE = QtCore.Qt.UserRole + 4 + + +class FoldersQtModel(QtGui.QStandardItemModel): + """Folders model which cares about refresh of folders. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(FoldersQtModel, self).__init__() + + self._controller = controller + self._items_by_id = {} + self._parent_id_by_id = {} + + self._refresh_threads = {} + self._current_refresh_thread = None + self._last_project_name = None + + self._has_content = False + self._is_refreshing = False + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: True if model is refreshing. + """ + return self._is_refreshing + + @property + def has_content(self): + """Has at least one folder. + + Returns: + bool: True if model has at least one folder. + """ + + return self._has_content + + def refresh(self): + """Refresh folders for last selected project. + + Force to update folders model from controller. This may or may not + trigger query from server, that's based on controller's cache. + """ + + self.set_project_name(self._last_project_name) + + def _clear_items(self): + self._items_by_id = {} + self._parent_id_by_id = {} + self._has_content = False + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + def get_index_by_id(self, item_id): + """Get index by folder id. + + Returns: + QtCore.QModelIndex: Index of the folder. Can be invalid if folder + is not available. + """ + item = self._items_by_id.get(item_id) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def get_project_name(self): + """Project name which model currently use. + + Returns: + Union[str, None]: Currently used project name. + """ + + return self._last_project_name + + def set_project_name(self, project_name): + """Refresh folders items. + + Refresh start thread because it can cause that controller can + start query from database if folders are not cached. + """ + + if not project_name: + self._last_project_name = project_name + self._fill_items({}) + self._current_refresh_thread = None + return + + self._is_refreshing = True + + if self._last_project_name != project_name: + self._clear_items() + self._last_project_name = project_name + + thread = self._refresh_threads.get(project_name) + if thread is not None: + self._current_refresh_thread = thread + return + + thread = RefreshThread( + project_name, + self._controller.get_folder_items, + project_name, + FOLDERS_MODEL_SENDER_NAME + ) + self._current_refresh_thread = thread + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Folders are stored by id. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_items(thread.get_result()) + self._current_refresh_thread = None + + def _fill_item_data(self, item, folder_item): + """ + + Args: + item (QtGui.QStandardItem): Item to fill data. + folder_item (FolderItem): Folder item. + """ + + icon = get_qt_icon(folder_item.icon) + item.setData(folder_item.entity_id, FOLDER_ID_ROLE) + item.setData(folder_item.name, FOLDER_NAME_ROLE) + item.setData(folder_item.path, FOLDER_PATH_ROLE) + item.setData(folder_item.folder_type, FOLDER_TYPE_ROLE) + item.setData(folder_item.label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + + def _fill_items(self, folder_items_by_id): + if not folder_items_by_id: + if folder_items_by_id is not None: + self._clear_items() + self._is_refreshing = False + self.refreshed.emit() + return + + self._has_content = True + + folder_ids = set(folder_items_by_id) + ids_to_remove = set(self._items_by_id) - folder_ids + + folder_items_by_parent = collections.defaultdict(dict) + for folder_item in folder_items_by_id.values(): + ( + folder_items_by_parent + [folder_item.parent_id] + [folder_item.entity_id] + ) = folder_item + + hierarchy_queue = collections.deque() + hierarchy_queue.append((self.invisibleRootItem(), None)) + + # Keep pointers to removed items until the refresh finishes + # - some children of the items could be moved and reused elsewhere + removed_items = [] + while hierarchy_queue: + item = hierarchy_queue.popleft() + parent_item, parent_id = item + folder_items = folder_items_by_parent[parent_id] + + items_by_id = {} + folder_ids_to_add = set(folder_items) + for row_idx in reversed(range(parent_item.rowCount())): + child_item = parent_item.child(row_idx) + child_id = child_item.data(FOLDER_ID_ROLE) + if child_id in ids_to_remove: + removed_items.append(parent_item.takeRow(row_idx)) + else: + items_by_id[child_id] = child_item + + new_items = [] + for item_id in folder_ids_to_add: + folder_item = folder_items[item_id] + item = items_by_id.get(item_id) + if item is None: + is_new = True + item = QtGui.QStandardItem() + item.setEditable(False) + else: + is_new = self._parent_id_by_id[item_id] != parent_id + + self._fill_item_data(item, folder_item) + if is_new: + new_items.append(item) + self._items_by_id[item_id] = item + self._parent_id_by_id[item_id] = parent_id + + hierarchy_queue.append((item, item_id)) + + if new_items: + parent_item.appendRows(new_items) + + for item_id in ids_to_remove: + self._items_by_id.pop(item_id) + self._parent_id_by_id.pop(item_id) + + self._is_refreshing = False + self.refreshed.emit() + + +class FoldersWidget(QtWidgets.QWidget): + """Folders widget. + + Widget that handles folders view, model and selection. + + Expected selection handling is disabled by default. If enabled, the + widget will handle the expected in predefined way. Widget is listening + to event 'expected_selection_changed' with expected event data below, + the same data must be available when called method + 'get_expected_selection_data' on controller. + + { + "folder": { + "current": bool, # Folder is what should be set now + "folder_id": Union[str, None], # Folder id that should be selected + }, + ... + } + + Selection is confirmed by calling method 'expected_folder_selected' on + controller. + + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + handle_expected_selection (bool): If True, the widget will handle + the expected selection. Defaults to False. + """ + + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + selection_changed = QtCore.Signal() + refreshed = QtCore.Signal() + + def __init__(self, controller, parent, handle_expected_selection=False): + super(FoldersWidget, self).__init__(parent) + + folders_view = TreeView(self) + folders_view.setHeaderHidden(True) + + folders_model = FoldersQtModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + folders_view.setModel(folders_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(folders_view, 1) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change, + ) + controller.register_event_callback( + "folders.refresh.finished", + self._on_folders_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = folders_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + folders_view.double_clicked.connect(self.double_clicked) + folders_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._folders_view = folders_view + self._folders_model = folders_model + self._folders_proxy_model = folders_proxy_model + + self._handle_expected_selection = handle_expected_selection + self._expected_selection = None + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: True if model is refreshing. + """ + + return self._folders_model.is_refreshing + + @property + def has_content(self): + """Has at least one folder. + + Returns: + bool: True if model has at least one folder. + """ + + return self._folders_model.has_content + + def set_name_filter(self, name): + """Set filter of folder name. + + Args: + name (str): The string filter. + """ + + self._folders_proxy_model.setFilterFixedString(name) + + def refresh(self): + """Refresh folders model. + + Force to update folders model from controller. + """ + + self._folders_model.refresh() + + def get_project_name(self): + """Project name in which folders widget currently is. + + Returns: + Union[str, None]: Currently used project name. + """ + + return self._folders_model.get_project_name() + + def set_project_name(self, project_name): + """Set project name. + + Do not use this method when controller is handling selection of + project using 'selection.project.changed' event. + + Args: + project_name (str): Project name. + """ + + self._folders_model.set_project_name(project_name) + + def get_selected_folder_id(self): + """Get selected folder id. + + Returns: + Union[str, None]: Folder id which is selected. + """ + + return self._get_selected_item_id() + + def get_selected_folder_label(self): + """Selected folder label. + + Returns: + Union[str, None]: Selected folder label. + """ + + item_id = self._get_selected_item_id() + return self.get_folder_label(item_id) + + def get_folder_label(self, folder_id): + """Folder label for a given folder id. + + Returns: + Union[str, None]: Folder label. + """ + + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + return index.data(QtCore.Qt.DisplayRole) + return None + + def set_selected_folder(self, folder_id): + """Change selection. + + Args: + folder_id (Union[str, None]): Folder id or None to deselect. + """ + + if folder_id is None: + self._folders_view.clearSelection() + return True + + if folder_id == self._get_selected_item_id(): + return True + index = self._folders_model.get_index_by_id(folder_id) + if not index.isValid(): + return False + + proxy_index = self._folders_proxy_model.mapFromSource(index) + if not proxy_index.isValid(): + return False + + selection_model = self._folders_view.selectionModel() + selection_model.setCurrentIndex( + proxy_index, QtCore.QItemSelectionModel.SelectCurrent + ) + return True + + def set_deselectable(self, enabled): + """Set deselectable mode. + + Items in view can be deselected. + + Args: + enabled (bool): Enable deselectable mode. + """ + + self._folders_view.set_deselectable(enabled) + + def _get_selected_index(self): + return self._folders_model.get_index_by_id( + self.get_selected_folder_id() + ) + + def _on_project_selection_change(self, event): + project_name = event["project_name"] + self.set_project_name(project_name) + + def _on_folders_refresh_finished(self, event): + if event["sender"] != FOLDERS_MODEL_SENDER_NAME: + self.set_project_name(event["project_name"]) + + def _on_controller_refresh(self): + self._update_expected_selection() + + def _on_model_refresh(self): + if self._expected_selection: + self._set_expected_selection() + self._folders_proxy_model.sort(0) + self.refreshed.emit() + + def _get_selected_item_id(self): + selection_model = self._folders_view.selectionModel() + for index in selection_model.selectedIndexes(): + item_id = index.data(FOLDER_ID_ROLE) + if item_id is not None: + return item_id + return None + + def _on_selection_change(self): + item_id = self._get_selected_item_id() + self._controller.set_selected_folder(item_id) + self.selection_changed.emit() + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _update_expected_selection(self, expected_data=None): + if not self._handle_expected_selection: + return + + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + folder_data = expected_data.get("folder") + if not folder_data or not folder_data["current"]: + return + + folder_id = folder_data["id"] + self._expected_selection = folder_id + if not self._folders_model.is_refreshing: + self._set_expected_selection() + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return + + folder_id = self._expected_selection + self._expected_selection = None + if folder_id is not None: + self.set_selected_folder(folder_id) + self._controller.expected_folder_selected(folder_id) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py new file mode 100644 index 0000000000..728433f929 --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -0,0 +1,600 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER +from .utils import RefreshThread, get_qt_icon + +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 +PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 +PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 +PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 +LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 + + +class ProjectsQtModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(ProjectsQtModel, self).__init__() + self._controller = controller + + self._project_items = {} + self._has_libraries = False + + self._empty_item = None + self._empty_item_added = False + + self._select_item = None + self._select_item_added = False + self._select_item_visible = None + + self._libraries_sep_item = None + self._libraries_sep_item_added = False + self._libraries_sep_item_visible = False + + self._current_context_project = None + + self._selected_project = None + + self._refresh_thread = None + + @property + def is_refreshing(self): + return self._refresh_thread is not None + + def refresh(self): + self._refresh() + + def has_content(self): + return len(self._project_items) > 0 + + def set_select_item_visible(self, visible): + if self._select_item_visible is visible: + return + self._select_item_visible = visible + + if self._selected_project is None: + self._add_select_item() + + def set_libraries_separator_visible(self, visible): + if self._libraries_sep_item_visible is visible: + return + self._libraries_sep_item_visible = visible + + def set_selected_project(self, project_name): + if not self._select_item_visible: + return + + self._selected_project = project_name + if project_name is None: + self._add_select_item() + else: + self._remove_select_item() + + def set_current_context_project(self, project_name): + if project_name == self._current_context_project: + return + self._unset_current_context_project(self._current_context_project) + self._current_context_project = project_name + self._set_current_context_project(project_name) + + def _set_current_context_project(self, project_name): + item = self._project_items.get(project_name) + if item is None: + return + item.setData(True, PROJECT_IS_CURRENT_ROLE) + + def _unset_current_context_project(self, project_name): + item = self._project_items.get(project_name) + if item is None: + return + item.setData(False, PROJECT_IS_CURRENT_ROLE) + + def _add_empty_item(self): + if self._empty_item_added: + return + self._empty_item_added = True + item = self._get_empty_item() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_empty_item(self): + if not self._empty_item_added: + return + self._empty_item_added = False + root_item = self.invisibleRootItem() + item = self._get_empty_item() + root_item.takeRow(item.row()) + + def _get_empty_item(self): + if self._empty_item is None: + item = QtGui.QStandardItem("< No projects >") + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_item = item + return self._empty_item + + def _get_library_sep_item(self): + if self._libraries_sep_item is not None: + return self._libraries_sep_item + + item = QtGui.QStandardItem() + item.setData("Libraries", QtCore.Qt.DisplayRole) + item.setData(True, LIBRARY_PROJECT_SEPARATOR_ROLE) + item.setFlags(QtCore.Qt.NoItemFlags) + self._libraries_sep_item = item + return item + + def _add_library_sep_item(self): + if ( + not self._libraries_sep_item_visible + or self._libraries_sep_item_added + ): + return + self._libraries_sep_item_added = True + item = self._get_library_sep_item() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_library_sep_item(self): + if ( + not self._libraries_sep_item_added + ): + return + self._libraries_sep_item_added = False + item = self._get_library_sep_item() + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + + def _add_select_item(self): + if self._select_item_added: + return + self._select_item_added = True + item = self._get_select_item() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_select_item(self): + if not self._select_item_added: + return + self._select_item_added = False + root_item = self.invisibleRootItem() + item = self._get_select_item() + root_item.takeRow(item.row()) + + def _get_select_item(self): + if self._select_item is None: + item = QtGui.QStandardItem("< Select project >") + item.setEditable(False) + self._select_item = item + return self._select_item + + def _refresh(self): + if self._refresh_thread is not None: + return + + refresh_thread = RefreshThread( + "projects", self._query_project_items + ) + refresh_thread.refresh_finished.connect(self._refresh_finished) + + self._refresh_thread = refresh_thread + refresh_thread.start() + + def _query_project_items(self): + return self._controller.get_project_items( + sender=PROJECTS_MODEL_SENDER + ) + + def _refresh_finished(self): + # TODO check if failed + result = self._refresh_thread.get_result() + if result is not None: + self._fill_items(result) + + self._refresh_thread = None + if result is None: + self._refresh() + else: + self.refreshed.emit() + + def _fill_items(self, project_items): + new_project_names = { + project_item.name + for project_item in project_items + } + + # Handle "Select item" visibility + if self._select_item_visible: + # Add select project. if previously selected project is not in + # project items + if self._selected_project not in new_project_names: + self._add_select_item() + else: + self._remove_select_item() + + root_item = self.invisibleRootItem() + + items_to_remove = set(self._project_items.keys()) - new_project_names + for project_name in items_to_remove: + item = self._project_items.pop(project_name) + root_item.takeRow(item.row()) + + has_library_project = False + new_items = [] + for project_item in project_items: + project_name = project_item.name + item = self._project_items.get(project_name) + if project_item.is_library: + has_library_project = True + if item is None: + item = QtGui.QStandardItem() + item.setEditable(False) + new_items.append(item) + icon = get_qt_icon(project_item.icon) + item.setData(project_name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(project_name, PROJECT_NAME_ROLE) + item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) + item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) + is_current = project_name == self._current_context_project + item.setData(is_current, PROJECT_IS_CURRENT_ROLE) + self._project_items[project_name] = item + + self._set_current_context_project(self._current_context_project) + + self._has_libraries = has_library_project + + if new_items: + root_item.appendRows(new_items) + + if self.has_content(): + # Make sure "No projects" item is removed + self._remove_empty_item() + if has_library_project: + self._add_library_sep_item() + else: + self._remove_library_sep_item() + else: + # Keep only "No projects" item + self._add_empty_item() + self._remove_select_item() + self._remove_library_sep_item() + + +class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) + self._filter_inactive = True + self._filter_standard = False + self._filter_library = False + self._sort_by_type = True + # Disable case sensitivity + self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + def _type_sort(self, l_index, r_index): + if not self._sort_by_type: + return None + + l_is_library = l_index.data(PROJECT_IS_LIBRARY_ROLE) + r_is_library = r_index.data(PROJECT_IS_LIBRARY_ROLE) + # Both hare project items + if l_is_library is not None and r_is_library is not None: + if l_is_library is r_is_library: + return None + if l_is_library: + return False + return True + + if l_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE): + if r_is_library is None: + return False + return r_is_library + + if r_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE): + if l_is_library is None: + return True + return l_is_library + return None + + def lessThan(self, left_index, right_index): + # Current project always on top + # - make sure this is always first, before any other sorting + # e.g. type sort would move the item lower + if left_index.data(PROJECT_IS_CURRENT_ROLE): + return True + if right_index.data(PROJECT_IS_CURRENT_ROLE): + return False + + # Library separator should be before library projects + result = self._type_sort(left_index, right_index) + if result is not None: + return result + + if left_index.data(PROJECT_NAME_ROLE) is None: + return True + + if right_index.data(PROJECT_NAME_ROLE) is None: + return False + + left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) + right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) + if right_is_active == left_is_active: + return super(ProjectSortFilterProxy, self).lessThan( + left_index, right_index + ) + + if left_is_active: + return True + return False + + def filterAcceptsRow(self, source_row, source_parent): + index = self.sourceModel().index(source_row, 0, source_parent) + project_name = index.data(PROJECT_NAME_ROLE) + if project_name is None: + return True + + string_pattern = self.filterRegularExpression().pattern() + if string_pattern: + return string_pattern.lower() in project_name.lower() + + # Current project keep always visible + default = super(ProjectSortFilterProxy, self).filterAcceptsRow( + source_row, source_parent + ) + if not default: + return default + + # Make sure current project is visible + if index.data(PROJECT_IS_CURRENT_ROLE): + return True + + if ( + self._filter_inactive + and not index.data(PROJECT_IS_ACTIVE_ROLE) + ): + return False + + if ( + self._filter_standard + and not index.data(PROJECT_IS_LIBRARY_ROLE) + ): + return False + + if ( + self._filter_library + and index.data(PROJECT_IS_LIBRARY_ROLE) + ): + return False + return True + + def _custom_index_filter(self, index): + return bool(index.data(PROJECT_IS_ACTIVE_ROLE)) + + def is_active_filter_enabled(self): + return self._filter_inactive + + def set_active_filter_enabled(self, enabled): + if self._filter_inactive == enabled: + return + self._filter_inactive = enabled + self.invalidateFilter() + + def set_library_filter_enabled(self, enabled): + if self._filter_library == enabled: + return + self._filter_library = enabled + self.invalidateFilter() + + def set_standard_filter_enabled(self, enabled): + if self._filter_standard == enabled: + return + self._filter_standard = enabled + self.invalidateFilter() + + def set_sort_by_type(self, enabled): + if self._sort_by_type is enabled: + return + self._sort_by_type = enabled + self.invalidate() + + +class ProjectsCombobox(QtWidgets.QWidget): + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() + + def __init__(self, controller, parent, handle_expected_selection=False): + super(ProjectsCombobox, self).__init__(parent) + + projects_combobox = QtWidgets.QComboBox(self) + combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) + projects_combobox.setItemDelegate(combobox_delegate) + projects_model = ProjectsQtModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + projects_combobox.setModel(projects_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(projects_combobox, 1) + + projects_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + projects_combobox.currentIndexChanged.connect( + self._on_current_index_changed + ) + + self._controller = controller + self._listen_selection_change = True + self._select_item_visible = False + + self._handle_expected_selection = handle_expected_selection + self._expected_selection = None + + self._projects_combobox = projects_combobox + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + self._combobox_delegate = combobox_delegate + + def refresh(self): + self._projects_model.refresh() + + def set_selection(self, project_name): + """Set selection to a given project. + + Selection change is ignored if project is not found. + + Args: + project_name (str): Name of project. + + Returns: + bool: True if selection was changed, False otherwise. NOTE: + Selection may not be changed if project is not found, or if + project is already selected. + """ + + idx = self._projects_combobox.findData( + project_name, PROJECT_NAME_ROLE) + if idx < 0: + return False + if idx != self._projects_combobox.currentIndex(): + self._projects_combobox.setCurrentIndex(idx) + return True + return False + + def set_listen_to_selection_change(self, listen): + """Disable listening to changes of the selection. + + Because combobox is triggering selection change when it's model + is refreshed, it's necessary to disable listening to selection for + some cases, e.g. when is on a different page of UI and should be just + refreshed. + + Args: + listen (bool): Enable or disable listening to selection changes. + """ + + self._listen_selection_change = listen + + def get_selected_project_name(self): + """Name of selected project. + + Returns: + Union[str, None]: Name of selected project, or None if no project + """ + + idx = self._projects_combobox.currentIndex() + if idx < 0: + return None + return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) + + def set_current_context_project(self, project_name): + self._projects_model.set_current_context_project(project_name) + self._projects_proxy_model.invalidateFilter() + + def set_select_item_visible(self, visible): + self._select_item_visible = visible + self._projects_model.set_select_item_visible(visible) + self._update_select_item_visiblity() + + def set_libraries_separator_visible(self, visible): + self._projects_model.set_libraries_separator_visible(visible) + + def is_active_filter_enabled(self): + return self._projects_proxy_model.is_active_filter_enabled() + + def set_active_filter_enabled(self, enabled): + return self._projects_proxy_model.set_active_filter_enabled(enabled) + + def set_standard_filter_enabled(self, enabled): + return self._projects_proxy_model.set_standard_filter_enabled(enabled) + + def set_library_filter_enabled(self, enabled): + return self._projects_proxy_model.set_library_filter_enabled(enabled) + + def _update_select_item_visiblity(self, **kwargs): + if not self._select_item_visible: + return + if "project_name" not in kwargs: + project_name = self.get_selected_project_name() + else: + project_name = kwargs.get("project_name") + + # Hide the item if a project is selected + self._projects_model.set_selected_project(project_name) + + def _on_current_index_changed(self, idx): + if not self._listen_selection_change: + return + project_name = self._projects_combobox.itemData( + idx, PROJECT_NAME_ROLE) + self._update_select_item_visiblity(project_name=project_name) + self._controller.set_selected_project(project_name) + self.selection_changed.emit() + + def _on_model_refresh(self): + self._projects_proxy_model.sort(0) + self._projects_proxy_model.invalidateFilter() + if self._expected_selection: + self._set_expected_selection() + self._update_select_item_visiblity() + self.refreshed.emit() + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() + + def _on_controller_refresh(self): + self._update_expected_selection() + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return + project_name = self._expected_selection + if project_name is not None: + if project_name != self.get_selected_project_name(): + self.set_selection(project_name) + else: + # Fake project change + self._on_current_index_changed( + self._projects_combobox.currentIndex() + ) + + self._controller.expected_project_selected(project_name) + + def _update_expected_selection(self, expected_data=None): + if not self._handle_expected_selection: + return + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + project_data = expected_data.get("project") + if ( + not project_data + or not project_data["current"] + or project_data["selected"] + ): + return + self._expected_selection = project_data["name"] + if not self._projects_model.is_refreshing: + self._set_expected_selection() + + +class ProjectsWidget(QtWidgets.QWidget): + # TODO implement + pass diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py new file mode 100644 index 0000000000..f27711acdd --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -0,0 +1,459 @@ +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import get_disabled_entity_icon_color +from openpype.tools.utils import DeselectableTreeView + +from .utils import RefreshThread, get_qt_icon + +TASKS_MODEL_SENDER_NAME = "qt_tasks_model" +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +PARENT_ID_ROLE = QtCore.Qt.UserRole + 2 +ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4 + + +class TasksQtModel(QtGui.QStandardItemModel): + """Tasks model which cares about refresh of tasks by folder id. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(TasksQtModel, self).__init__() + + self._controller = controller + + self._items_by_name = {} + self._has_content = False + self._is_refreshing = False + + self._invalid_selection_item_used = False + self._invalid_selection_item = None + self._empty_tasks_item_used = False + self._empty_tasks_item = None + + self._last_project_name = None + self._last_folder_id = None + + self._refresh_threads = {} + self._current_refresh_thread = None + + # Initial state + self._add_invalid_selection_item() + + def _clear_items(self): + self._items_by_name = {} + self._has_content = False + self._remove_invalid_items() + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + def refresh(self): + """Refresh tasks for last project and folder.""" + + self._refresh(self._last_project_name, self._last_folder_id) + + def set_context(self, project_name, folder_id): + """Set context for which should be tasks showed. + + Args: + project_name (Union[str]): Name of project. + folder_id (Union[str, None]): Folder id. + """ + + self._refresh(project_name, folder_id) + + def get_index_by_name(self, task_name): + """Find item by name and return its index. + + Returns: + QtCore.QModelIndex: Index of item. Is invalid if task is not + found by name. + """ + + item = self._items_by_name.get(task_name) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def get_last_project_name(self): + """Get last refreshed project name. + + Returns: + Union[str, None]: Project name. + """ + + return self._last_project_name + + def get_last_folder_id(self): + """Get last refreshed folder id. + + Returns: + Union[str, None]: Folder id. + """ + + return self._last_folder_id + + def set_selected_project(self, project_name): + self._selected_project_name = project_name + + def _get_invalid_selection_item(self): + if self._invalid_selection_item is None: + item = QtGui.QStandardItem("Select a folder") + item.setFlags(QtCore.Qt.NoItemFlags) + icon = get_qt_icon({ + "type": "awesome-font", + "name": "fa.times", + "color": get_disabled_entity_icon_color(), + }) + item.setData(icon, QtCore.Qt.DecorationRole) + self._invalid_selection_item = item + return self._invalid_selection_item + + def _get_empty_task_item(self): + if self._empty_tasks_item is None: + item = QtGui.QStandardItem("No task") + icon = get_qt_icon({ + "type": "awesome-font", + "name": "fa.exclamation-circle", + "color": get_disabled_entity_icon_color(), + }) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_tasks_item = item + return self._empty_tasks_item + + def _add_invalid_item(self, item): + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_invalid_item(self, item): + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + + def _remove_invalid_items(self): + self._remove_invalid_selection_item() + self._remove_empty_task_item() + + def _add_invalid_selection_item(self): + if not self._invalid_selection_item_used: + self._add_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = True + + def _remove_invalid_selection_item(self): + if self._invalid_selection_item: + self._remove_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = False + + def _add_empty_task_item(self): + if not self._empty_tasks_item_used: + self._add_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = True + + def _remove_empty_task_item(self): + if self._empty_tasks_item_used: + self._remove_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = False + + def _refresh(self, project_name, folder_id): + self._is_refreshing = True + self._last_project_name = project_name + self._last_folder_id = folder_id + if not folder_id: + self._add_invalid_selection_item() + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + return + + thread = self._refresh_threads.get(folder_id) + if thread is not None: + self._current_refresh_thread = thread + return + thread = RefreshThread( + folder_id, + self._controller.get_task_items, + project_name, + folder_id + ) + self._current_refresh_thread = thread + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _fill_data_from_thread(self, thread): + task_items = thread.get_result() + # Task items are refreshed + if task_items is None: + return + + # No tasks are available on folder + if not task_items: + self._add_empty_task_item() + return + self._remove_invalid_items() + + new_items = [] + new_names = set() + for task_item in task_items: + name = task_item.name + new_names.add(name) + item = self._items_by_name.get(name) + if item is None: + item = QtGui.QStandardItem() + item.setEditable(False) + new_items.append(item) + self._items_by_name[name] = item + + # TODO cache locally + icon = get_qt_icon(task_item.icon) + item.setData(task_item.label, QtCore.Qt.DisplayRole) + item.setData(name, ITEM_NAME_ROLE) + item.setData(task_item.id, ITEM_ID_ROLE) + item.setData(task_item.parent_id, PARENT_ID_ROLE) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + + for name in set(self._items_by_name) - new_names: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + if new_items: + root_item.appendRows(new_items) + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_data_from_thread(thread) + + root_item = self.invisibleRootItem() + self._has_content = root_item.rowCount() > 0 + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: Model is refreshing + """ + + return self._is_refreshing + + @property + def has_content(self): + """Model has content. + + Returns: + bools: Have at least one task. + """ + + return self._has_content + + def headerData(self, section, orientation, role): + # Show nice labels in the header + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section == 0: + return "Tasks" + + return super(TasksQtModel, self).headerData( + section, orientation, role + ) + + +class TasksWidget(QtWidgets.QWidget): + """Tasks widget. + + Widget that handles tasks view, model and selection. + + Args: + controller (AbstractWorkfilesFrontend): Workfiles controller. + parent (QtWidgets.QWidget): Parent widget. + handle_expected_selection (Optional[bool]): Handle expected selection. + """ + + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() + + def __init__(self, controller, parent, handle_expected_selection=False): + super(TasksWidget, self).__init__(parent) + + tasks_view = DeselectableTreeView(self) + tasks_view.setIndentation(0) + + tasks_model = TasksQtModel(controller) + tasks_proxy_model = QtCore.QSortFilterProxyModel() + tasks_proxy_model.setSourceModel(tasks_model) + tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + tasks_view.setModel(tasks_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(tasks_view, 1) + + controller.register_event_callback( + "tasks.refresh.finished", + self._on_tasks_refresh_finished + ) + controller.register_event_callback( + "selection.folder.changed", + self._folder_selection_changed + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = tasks_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + tasks_model.refreshed.connect(self._on_tasks_model_refresh) + + self._controller = controller + self._tasks_view = tasks_view + self._tasks_model = tasks_model + self._tasks_proxy_model = tasks_proxy_model + + self._selected_folder_id = None + + self._handle_expected_selection = handle_expected_selection + self._expected_selection_data = None + + def refresh(self): + """Refresh folders for last selected project. + + Force to update folders model from controller. This may or may not + trigger query from server, that's based on controller's cache. + """ + + self._tasks_model.refresh() + + def _on_tasks_refresh_finished(self, event): + """Tasks were refreshed in controller. + + Ignore if refresh was triggered by tasks model, or refreshed folder is + not the same as currently selected folder. + + Args: + event (Event): Event object. + """ + + # Refresh only if current folder id is the same + if ( + event["sender"] == TASKS_MODEL_SENDER_NAME + or event["folder_id"] != self._selected_folder_id + ): + return + self._tasks_model.set_context( + event["project_name"], self._selected_folder_id + ) + + def _folder_selection_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._tasks_model.set_context( + event["project_name"], self._selected_folder_id + ) + + def _on_tasks_model_refresh(self): + if not self._set_expected_selection(): + self._on_selection_change() + self._tasks_proxy_model.sort(0) + self.refreshed.emit() + + def _get_selected_item_ids(self): + selection_model = self._tasks_view.selectionModel() + for index in selection_model.selectedIndexes(): + task_id = index.data(ITEM_ID_ROLE) + task_name = index.data(ITEM_NAME_ROLE) + parent_id = index.data(PARENT_ID_ROLE) + if task_name is not None: + return parent_id, task_id, task_name + return self._selected_folder_id, None, None + + def _on_selection_change(self): + # Don't trigger task change during refresh + # - a task was deselected if that happens + # - can cause crash triggered during tasks refreshing + if self._tasks_model.is_refreshing: + return + + parent_id, task_id, task_name = self._get_selected_item_ids() + self._controller.set_selected_task(task_id, task_name) + self.selection_changed.emit() + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return False + + if self._expected_selection_data is None: + return False + folder_id = self._expected_selection_data["folder_id"] + task_name = self._expected_selection_data["task_name"] + self._expected_selection_data = None + model_folder_id = self._tasks_model.get_last_folder_id() + if folder_id != model_folder_id: + return False + if task_name is not None: + index = self._tasks_model.get_index_by_name(task_name) + if index.isValid(): + proxy_index = self._tasks_proxy_model.mapFromSource(index) + self._tasks_view.setCurrentIndex(proxy_index) + self._controller.expected_task_selected(folder_id, task_name) + return True + + def _update_expected_selection(self, expected_data=None): + if not self._handle_expected_selection: + return + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + folder_data = expected_data.get("folder") + task_data = expected_data.get("task") + if ( + not folder_data + or not task_data + or not task_data["current"] + ): + return + folder_id = folder_data["id"] + self._expected_selection_data = { + "task_name": task_data["name"], + "folder_id": folder_id, + } + model_folder_id = self._tasks_model.get_last_folder_id() + if folder_id != model_folder_id or self._tasks_model.is_refreshing: + return + self._set_expected_selection() diff --git a/openpype/tools/ayon_utils/widgets/utils.py b/openpype/tools/ayon_utils/widgets/utils.py new file mode 100644 index 0000000000..5165b3a262 --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/utils.py @@ -0,0 +1,109 @@ +import os +from functools import partial + +from qtpy import QtCore, QtGui + +from openpype.tools.utils.lib import get_qta_icon_by_name_and_color + + +class RefreshThread(QtCore.QThread): + refresh_finished = QtCore.Signal(str) + + def __init__(self, thread_id, func, *args, **kwargs): + super(RefreshThread, self).__init__() + self._id = thread_id + self._callback = partial(func, *args, **kwargs) + self._exception = None + self._result = None + self.finished.connect(self._on_finish_callback) + + @property + def id(self): + return self._id + + @property + def failed(self): + return self._exception is not None + + def run(self): + try: + self._result = self._callback() + except Exception as exc: + self._exception = exc + + def get_result(self): + return self._result + + def _on_finish_callback(self): + """Trigger custom signal with thread id. + + Listening for 'finished' signal we make sure that execution of thread + finished and QThread object can be safely deleted. + """ + + self.refresh_finished.emit(self.id) + + +class _IconsCache: + """Cache for icons.""" + + _cache = {} + _default = None + + @classmethod + def _get_cache_key(cls, icon_def): + parts = [] + icon_type = icon_def["type"] + if icon_type == "path": + parts = [icon_type, icon_def["path"]] + + elif icon_type == "awesome-font": + parts = [icon_type, icon_def["name"], icon_def["color"]] + return "|".join(parts) + + @classmethod + def get_icon(cls, icon_def): + if not icon_def: + return None + icon_type = icon_def["type"] + cache_key = cls._get_cache_key(icon_def) + cache = cls._cache.get(cache_key) + if cache is not None: + return cache + + icon = None + if icon_type == "path": + path = icon_def["path"] + if os.path.exists(path): + icon = QtGui.QIcon(path) + + elif icon_type == "awesome-font": + icon_name = icon_def["name"] + icon_color = icon_def["color"] + icon = get_qta_icon_by_name_and_color(icon_name, icon_color) + if icon is None: + icon = get_qta_icon_by_name_and_color( + "fa.{}".format(icon_name), icon_color) + if icon is None: + icon = cls.get_default() + cls._cache[cache_key] = icon + return icon + + @classmethod + def get_default(cls): + pix = QtGui.QPixmap(1, 1) + pix.fill(QtCore.Qt.transparent) + return QtGui.QIcon(pix) + + +def get_qt_icon(icon_def): + """Returns icon from cache or creates new one. + + Args: + icon_def (dict[str, Any]): Icon definition. + + Returns: + QtGui.QIcon: Icon. + """ + + return _IconsCache.get_icon(icon_def) diff --git a/openpype/tools/ayon_workfiles/__init__.py b/openpype/tools/ayon_workfiles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py new file mode 100644 index 0000000000..260f701d4b --- /dev/null +++ b/openpype/tools/ayon_workfiles/abstract.py @@ -0,0 +1,1010 @@ +import os +from abc import ABCMeta, abstractmethod + +import six +from openpype.style import get_default_entity_icon_color + + +class WorkfileInfo: + """Information about workarea file with possible additional from database. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Filepath. + filesize (int): File size. + creation_time (int): Creation time (timestamp). + modification_time (int): Modification time (timestamp). + note (str): Note. + """ + + def __init__( + self, + folder_id, + task_id, + filepath, + filesize, + creation_time, + modification_time, + note, + ): + self.folder_id = folder_id + self.task_id = task_id + self.filepath = filepath + self.filesize = filesize + self.creation_time = creation_time + self.modification_time = modification_time + self.note = note + + def to_data(self): + """Converts WorkfileInfo item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "folder_id": self.folder_id, + "task_id": self.task_id, + "filepath": self.filepath, + "filesize": self.filesize, + "creation_time": self.creation_time, + "modification_time": self.modification_time, + "note": self.note, + } + + @classmethod + def from_data(cls, data): + """Re-creates WorkfileInfo item from data. + + Args: + data (dict[str, Any]): Workfile info item data. + + Returns: + WorkfileInfo: Workfile info item. + """ + + return cls(**data) + + +class FolderItem: + """Item representing folder entity on a server. + + Folder can be a child of another folder or a project. + + Args: + entity_id (str): Folder id. + parent_id (Union[str, None]): Parent folder id. If 'None' then project + is parent. + name (str): Name of folder. + label (str): Folder label. + icon_name (str): Name of icon from font awesome. + icon_color (str): Hex color string that will be used for icon. + """ + + def __init__( + self, entity_id, parent_id, name, label, icon_name, icon_color + ): + self.entity_id = entity_id + self.parent_id = parent_id + self.name = name + self.icon_name = icon_name or "fa.folder" + self.icon_color = icon_color or get_default_entity_icon_color() + self.label = label or name + + def to_data(self): + """Converts folder item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "entity_id": self.entity_id, + "parent_id": self.parent_id, + "name": self.name, + "label": self.label, + "icon_name": self.icon_name, + "icon_color": self.icon_color, + } + + @classmethod + def from_data(cls, data): + """Re-creates folder item from data. + + Args: + data (dict[str, Any]): Folder item data. + + Returns: + FolderItem: Folder item. + """ + + return cls(**data) + + +class TaskItem: + """Task item representing task entity on a server. + + Task is child of a folder. + + Task item has label that is used for display in UI. The label is by + default using task name and type. + + Args: + task_id (str): Task id. + name (str): Name of task. + task_type (str): Type of task. + parent_id (str): Parent folder id. + icon_name (str): Name of icon from font awesome. + icon_color (str): Hex color string that will be used for icon. + """ + + def __init__( + self, task_id, name, task_type, parent_id, icon_name, icon_color + ): + self.task_id = task_id + self.name = name + self.task_type = task_type + self.parent_id = parent_id + self.icon_name = icon_name or "fa.male" + self.icon_color = icon_color or get_default_entity_icon_color() + self._label = None + + @property + def id(self): + """Alias for task_id. + + Returns: + str: Task id. + """ + + return self.task_id + + @property + def label(self): + """Label of task item for UI. + + Returns: + str: Label of task item. + """ + + if self._label is None: + self._label = "{} ({})".format(self.name, self.task_type) + return self._label + + def to_data(self): + """Converts task item to data. + + Returns: + dict[str, Any]: Task item data. + """ + + return { + "task_id": self.task_id, + "name": self.name, + "parent_id": self.parent_id, + "task_type": self.task_type, + "icon_name": self.icon_name, + "icon_color": self.icon_color, + } + + @classmethod + def from_data(cls, data): + """Re-create task item from data. + + Args: + data (dict[str, Any]): Task item data. + + Returns: + TaskItem: Task item. + """ + + return cls(**data) + + +class FileItem: + """File item that represents a file. + + Can be used for both Workarea and Published workfile. Workarea file + will always exist on disk which is not the case for Published workfile. + + Args: + dirpath (str): Directory path of file. + filename (str): Filename. + modified (float): Modified timestamp. + representation_id (Optional[str]): Representation id of published + workfile. + filepath (Optional[str]): Prepared filepath. + exists (Optional[bool]): If file exists on disk. + """ + + def __init__( + self, + dirpath, + filename, + modified, + representation_id=None, + filepath=None, + exists=None + ): + self.filename = filename + self.dirpath = dirpath + self.modified = modified + self.representation_id = representation_id + self._filepath = filepath + self._exists = exists + + @property + def filepath(self): + """Filepath of file. + + Returns: + str: Full path to a file. + """ + + if self._filepath is None: + self._filepath = os.path.join(self.dirpath, self.filename) + return self._filepath + + @property + def exists(self): + """File is available. + + Returns: + bool: If file exists on disk. + """ + + if self._exists is None: + self._exists = os.path.exists(self.filepath) + return self._exists + + def to_data(self): + """Converts file item to data. + + Returns: + dict[str, Any]: File item data. + """ + + return { + "filename": self.filename, + "dirpath": self.dirpath, + "modified": self.modified, + "representation_id": self.representation_id, + "filepath": self.filepath, + "exists": self.exists, + } + + @classmethod + def from_data(cls, data): + """Re-creates file item from data. + + Args: + data (dict[str, Any]): File item data. + + Returns: + FileItem: File item. + """ + + required_keys = { + "filename", + "dirpath", + "modified", + "representation_id" + } + missing_keys = required_keys - set(data.keys()) + if missing_keys: + raise KeyError("Missing keys: {}".format(missing_keys)) + + return cls(**{ + key: data[key] + for key in required_keys + }) + + +class WorkareaFilepathResult: + """Result of workarea file formatting. + + Args: + root (str): Root path of workarea. + filename (str): Filename. + exists (bool): True if file exists. + filepath (str): Filepath. If not provided it will be constructed + from root and filename. + """ + + def __init__(self, root, filename, exists, filepath=None): + if not filepath and root and filename: + filepath = os.path.join(root, filename) + self.root = root + self.filename = filename + self.exists = exists + self.filepath = filepath + + +@six.add_metaclass(ABCMeta) +class AbstractWorkfilesCommon(object): + @abstractmethod + def is_host_valid(self): + """Host is valid for workfiles tool work. + + Returns: + bool: True if host is valid. + """ + + pass + + @abstractmethod + def get_workfile_extensions(self): + """Get possible workfile extensions. + + Defined by host implementation. + + Returns: + Iterable[str]: List of extensions. + """ + + pass + + @abstractmethod + def is_save_enabled(self): + """Is workfile save enabled. + + Returns: + bool: True if save is enabled. + """ + + pass + + @abstractmethod + def set_save_enabled(self, enabled): + """Enable or disabled workfile save. + + Args: + enabled (bool): Enable save workfile when True. + """ + + pass + + +class AbstractWorkfilesBackend(AbstractWorkfilesCommon): + # Current context + @abstractmethod + def get_host_name(self): + """Name of host. + + Returns: + str: Name of host. + """ + pass + + @abstractmethod + def get_current_project_name(self): + """Project name from current context of host. + + Returns: + str: Name of project. + """ + + pass + + @abstractmethod + def get_current_folder_id(self): + """Folder id from current context of host. + + Returns: + Union[str, None]: Folder id or None if host does not have + any context. + """ + + pass + + @abstractmethod + def get_current_task_name(self): + """Task name from current context of host. + + Returns: + Union[str, None]: Task name or None if host does not have + any context. + """ + + pass + + @abstractmethod + def get_current_workfile(self): + """Current workfile from current context of host. + + Returns: + Union[str, None]: Path to workfile or None if host does + not have opened specific file. + """ + + pass + + @property + @abstractmethod + def project_anatomy(self): + """Project anatomy for current project. + + Returns: + Anatomy: Project anatomy. + """ + + pass + + @property + @abstractmethod + def project_settings(self): + """Project settings for current project. + + Returns: + dict[str, Any]: Project settings. + """ + + pass + + @abstractmethod + def get_project_entity(self, project_name): + """Get project entity by name. + + Args: + project_name (str): Project name. + + Returns: + dict[str, Any]: Project entity data. + """ + + pass + + @abstractmethod + def get_folder_entity(self, project_name, folder_id): + """Get folder entity by id. + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + + Returns: + dict[str, Any]: Folder entity data. + """ + + pass + + @abstractmethod + def get_task_entity(self, project_name, task_id): + """Get task entity by id. + + Args: + project_name (str): Project name. + task_id (str): Task id. + + Returns: + dict[str, Any]: Task entity data. + """ + + pass + + def emit_event(self, topic, data=None, source=None): + """Emit event. + + Args: + topic (str): Event topic used for callbacks filtering. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + + pass + + +class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): + """UI controller abstraction that is used for workfiles tool frontend. + + Abstraction to provide data for UI and to handle UI events. + + Provide access to abstract backend data, like folders and tasks. Cares + about handling of selection, keep information about current UI selection + and have ability to tell what selection should UI show. + + Selection is separated into 2 parts, first is what UI elements tell + about selection, and second is what UI should show as selected. + """ + + @abstractmethod + def register_event_callback(self, topic, callback): + """Register event callback. + + Listen for events with given topic. + + Args: + topic (str): Name of topic. + callback (Callable): Callback that will be called when event + is triggered. + """ + + pass + + # Host information + @abstractmethod + def get_workfile_extensions(self): + """Each host can define extensions that can be used for workfile. + + Returns: + List[str]: File extensions that can be used as workfile for + current host. + """ + + pass + + # Selection information + @abstractmethod + def get_selected_folder_id(self): + """Currently selected folder id. + + Returns: + Union[str, None]: Folder id or None if no folder is selected. + """ + + pass + + @abstractmethod + def set_selected_folder(self, folder_id): + """Change selected folder. + + This deselects currently selected task. + + Args: + folder_id (Union[str, None]): Folder id or None if no folder + is selected. + """ + + pass + + @abstractmethod + def get_selected_task_id(self): + """Currently selected task id. + + Returns: + Union[str, None]: Task id or None if no folder is selected. + """ + + pass + + @abstractmethod + def get_selected_task_name(self): + """Currently selected task name. + + Returns: + Union[str, None]: Task name or None if no folder is selected. + """ + + pass + + @abstractmethod + def set_selected_task(self, task_id, task_name): + """Change selected task. + + Args: + task_id (Union[str, None]): Task id or None if no task + is selected. + task_name (Union[str, None]): Task name or None if no task + is selected. + """ + + pass + + @abstractmethod + def get_selected_workfile_path(self): + """Currently selected workarea workile. + + Returns: + Union[str, None]: Selected workfile path. + """ + + pass + + @abstractmethod + def set_selected_workfile_path(self, path): + """Change selected workfile path. + + Args: + path (Union[str, None]): Selected workfile path. + """ + + pass + + @abstractmethod + def get_selected_representation_id(self): + """Currently selected workfile representation id. + + Returns: + Union[str, None]: Representation id or None if no representation + is selected. + """ + + pass + + @abstractmethod + def set_selected_representation_id(self, representation_id): + """Change selected representation. + + Args: + representation_id (Union[str, None]): Selected workfile + representation id. + """ + + pass + + def get_selected_context(self): + """Obtain selected context. + + Returns: + dict[str, Union[str, None]]: Selected context. + """ + + return { + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + "workfile_path": self.get_selected_workfile_path(), + "representation_id": self.get_selected_representation_id(), + } + + # Expected selection + # - expected selection is used to restore selection after refresh + # or when current context should be used + @abstractmethod + def set_expected_selection( + self, + folder_id, + task_name, + workfile_name=None, + representation_id=None + ): + """Define what should be selected in UI. + + Expected selection provide a way to define/change selection of + sequential UI elements. For example, if folder and task should be + selected a task element should wait until folder element has selected + folder. + + Triggers 'expected_selection.changed' event. + + Args: + folder_id (str): Folder id. + task_name (str): Task name. + workfile_name (Optional[str]): Workfile name. Used for workarea + files UI element. + representation_id (Optional[str]): Representation id. Used for + published filed UI element. + """ + + pass + + @abstractmethod + def get_expected_selection_data(self): + """Data of expected selection. + + TODOs: + Return defined object instead of dict. + + Returns: + dict[str, Any]: Expected selection data. + """ + + pass + + @abstractmethod + def expected_folder_selected(self, folder_id): + """Expected folder was selected in UI. + + Args: + folder_id (str): Folder id which was selected. + """ + + pass + + @abstractmethod + def expected_task_selected(self, folder_id, task_name): + """Expected task was selected in UI. + + Args: + folder_id (str): Folder id under which task is. + task_name (str): Task name which was selected. + """ + + pass + + @abstractmethod + def expected_representation_selected( + self, folder_id, task_name, representation_id + ): + """Expected representation was selected in UI. + + Args: + folder_id (str): Folder id under which representation is. + task_name (str): Task name under which representation is. + representation_id (str): Representation id which was selected. + """ + + pass + + @abstractmethod + def expected_workfile_selected(self, folder_id, task_name, workfile_name): + """Expected workfile was selected in UI. + + Args: + folder_id (str): Folder id under which workfile is. + task_name (str): Task name under which workfile is. + workfile_name (str): Workfile filename which was selected. + """ + + pass + + @abstractmethod + def go_to_current_context(self): + """Set expected selection to current context.""" + + pass + + # Model functions + @abstractmethod + def get_folder_items(self, project_name, sender): + """Folder items to visualize project hierarchy. + + This function may trigger events 'folders.refresh.started' and + 'folders.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of folder items in UI elements. + + Args: + project_name (str): Project name for which are folders requested. + sender (str): Who requested folder items. + + Returns: + list[FolderItem]: Minimum possible information needed + for visualisation of folder hierarchy. + """ + + pass + + @abstractmethod + def get_task_items(self, project_name, folder_id, sender): + """Task items. + + This function may trigger events 'tasks.refresh.started' and + 'tasks.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of task items in UI elements. + + Args: + project_name (str): Project name for which are tasks requested. + folder_id (str): Folder ID for which are tasks requested. + sender (str): Who requested folder items. + + Returns: + list[TaskItem]: Minimum possible information needed + for visualisation of tasks. + """ + + pass + + @abstractmethod + def has_unsaved_changes(self): + """Has host unsaved change in currently running session. + + Returns: + bool: Has unsaved changes. + """ + + pass + + @abstractmethod + def get_workarea_dir_by_context(self, folder_id, task_id): + """Get workarea directory by context. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + str: Workarea directory. + """ + + pass + + @abstractmethod + def get_workarea_file_items(self, folder_id, task_id): + """Get workarea file items. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + list[FileItem]: List of workarea file items. + """ + + pass + + @abstractmethod + def get_workarea_save_as_data(self, folder_id, task_id): + """Prepare data for Save As operation. + + Todos: + Return defined object instead of dict. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + dict[str, Any]: Data for Save As operation. + """ + + pass + + @abstractmethod + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + """Calculate workfile path for passed context. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + extension (str): File extension. + use_last_version (bool): Use last version. + version (int): Version used if 'use_last_version' if 'False'. + comment (str): User's comment (subversion). + + Returns: + WorkareaFilepathResult: Result of the operation. + """ + + pass + + @abstractmethod + def get_published_file_items(self, folder_id, task_id): + """Get published file items. + + Args: + folder_id (str): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[FileItem]: List of published file items. + """ + + pass + + @abstractmethod + def get_workfile_info(self, folder_id, task_id, filepath): + """Workfile info from database. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Workfile path. + + Returns: + Union[WorkfileInfo, None]: Workfile info or None if was passed + invalid context. + """ + + pass + + @abstractmethod + def save_workfile_info(self, folder_id, task_id, filepath, note): + """Save workfile info to database. + + At this moment the only information which can be saved about + workfile is 'note'. + + When 'note' is 'None' it is only validated if workfile info exists, + and if not then creates one with empty note. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Workfile path. + note (Union[str, None]): Note. + """ + + pass + + # General commands + @abstractmethod + def reset(self): + """Reset everything, models, ui etc. + + Triggers 'controller.reset.started' event at the beginning and + 'controller.reset.finished' at the end. + """ + + pass + + # Controller actions + @abstractmethod + def open_workfile(self, folder_id, task_id, filepath): + """Open a workfile for context. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Workfile path. + """ + + pass + + @abstractmethod + def save_current_workfile(self): + """Save state of current workfile.""" + + pass + + @abstractmethod + def save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + """Save current state of workfile to workarea. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + workdir (str): Workarea directory. + filename (str): Workarea filename. + template_key (str): Template key used to get the workdir + and filename. + """ + + pass + + @abstractmethod + def copy_workfile_representation( + self, + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + """Action to copy published workfile representation to workarea. + + Triggers 'copy_representation.started' event on start and + 'copy_representation.finished' event with '{"failed": bool}'. + + Args: + representation_id (str): Representation id. + representation_filepath (str): Path to representation file. + folder_id (str): Folder id. + task_id (str): Task id. + workdir (str): Workarea directory. + filename (str): Workarea filename. + template_key (str): Template key. + """ + + pass + + @abstractmethod + def duplicate_workfile(self, src_filepath, workdir, filename): + """Duplicate workfile. + + Workfiles is not opened when done. + + Args: + src_filepath (str): Source workfile path. + workdir (str): Destination workdir. + filename (str): Destination filename. + """ + + pass diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py new file mode 100644 index 0000000000..9d19571267 --- /dev/null +++ b/openpype/tools/ayon_workfiles/control.py @@ -0,0 +1,744 @@ +import os +import shutil + +import ayon_api + +from openpype.client import get_asset_by_id +from openpype.host import IWorkfileHost +from openpype.lib import Logger, emit_event +from openpype.lib.events import QueuedEventSystem +from openpype.settings import get_project_settings +from openpype.pipeline import Anatomy, registered_host +from openpype.pipeline.context_tools import ( + change_current_context, + get_current_host_name, + get_global_context, +) +from openpype.pipeline.workfile import create_workdir_extra_folders + +from openpype.tools.ayon_utils.models import ( + HierarchyModel, + HierarchyExpectedSelection, + ProjectsModel, +) + +from .abstract import ( + AbstractWorkfilesFrontend, + AbstractWorkfilesBackend, +) +from .models import SelectionModel, WorkfilesModel + + +class WorkfilesToolExpectedSelection(HierarchyExpectedSelection): + def __init__(self, controller): + super(WorkfilesToolExpectedSelection, self).__init__( + controller, + handle_project=False, + handle_folder=True, + handle_task=True, + ) + + self._workfile_name = None + self._representation_id = None + + self._workfile_selected = True + self._representation_selected = True + + def set_expected_selection( + self, + project_name=None, + folder_id=None, + task_name=None, + workfile_name=None, + representation_id=None, + ): + self._workfile_name = workfile_name + self._representation_id = representation_id + + self._workfile_selected = False + self._representation_selected = False + + super(WorkfilesToolExpectedSelection, self).set_expected_selection( + project_name, + folder_id, + task_name, + ) + + def get_expected_selection_data(self): + data = super( + WorkfilesToolExpectedSelection, self + ).get_expected_selection_data() + + _is_current = ( + self._project_selected + and self._folder_selected + and self._task_selected + ) + workfile_is_current = False + repre_is_current = False + if _is_current: + workfile_is_current = not self._workfile_selected + repre_is_current = not self._representation_selected + + data["workfile"] = { + "name": self._workfile_name, + "current": workfile_is_current, + "selected": self._workfile_selected, + } + data["representation"] = { + "id": self._representation_id, + "current": repre_is_current, + "selected": self._workfile_selected, + } + return data + + def is_expected_workfile_selected(self, workfile_name): + return ( + workfile_name == self._workfile_name + and self._workfile_selected + ) + + def is_expected_representation_selected(self, representation_id): + return ( + representation_id == self._representation_id + and self._representation_selected + ) + + def expected_workfile_selected(self, folder_id, task_name, workfile_name): + if folder_id != self._folder_id: + return False + + if task_name != self._task_name: + return False + + if workfile_name != self._workfile_name: + return False + self._workfile_selected = True + self._emit_change() + return True + + def expected_representation_selected( + self, folder_id, task_name, representation_id + ): + if folder_id != self._folder_id: + return False + + if task_name != self._task_name: + return False + + if representation_id != self._representation_id: + return False + self._representation_selected = True + self._emit_change() + return True + + +class BaseWorkfileController( + AbstractWorkfilesFrontend, AbstractWorkfilesBackend +): + def __init__(self, host=None): + if host is None: + host = registered_host() + + host_is_valid = False + if host is not None: + missing_methods = ( + IWorkfileHost.get_missing_workfile_methods(host) + ) + host_is_valid = len(missing_methods) == 0 + + self._host = host + self._host_is_valid = host_is_valid + + self._project_anatomy = None + self._project_settings = None + self._event_system = None + self._log = None + + self._current_project_name = None + self._current_folder_name = None + self._current_folder_id = None + self._current_task_name = None + self._save_is_enabled = True + + # Expected selected folder and task + self._expected_selection = self._create_expected_selection_obj() + self._selection_model = self._create_selection_model() + self._projects_model = self._create_projects_model() + self._hierarchy_model = self._create_hierarchy_model() + self._workfiles_model = self._create_workfiles_model() + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger("WorkfilesUI") + return self._log + + def is_host_valid(self): + return self._host_is_valid + + def _create_expected_selection_obj(self): + return WorkfilesToolExpectedSelection(self) + + def _create_projects_model(self): + return ProjectsModel(self) + + def _create_selection_model(self): + return SelectionModel(self) + + def _create_hierarchy_model(self): + return HierarchyModel(self) + + def _create_workfiles_model(self): + return WorkfilesModel(self) + + @property + def event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + # ---------------------------------------------------- + # Implementation of methods required for backend logic + # ---------------------------------------------------- + @property + def project_settings(self): + if self._project_settings is None: + self._project_settings = get_project_settings( + self.get_current_project_name()) + return self._project_settings + + @property + def project_anatomy(self): + if self._project_anatomy is None: + self._project_anatomy = Anatomy(self.get_current_project_name()) + return self._project_anatomy + + def get_project_entity(self, project_name): + return self._projects_model.get_project_entity( + project_name) + + def get_folder_entity(self, project_name, folder_id): + return self._hierarchy_model.get_folder_entity( + project_name, folder_id) + + def get_task_entity(self, project_name, task_id): + return self._hierarchy_model.get_task_entity( + project_name, task_id) + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self.event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self.event_system.add_callback(topic, callback) + + def is_save_enabled(self): + """Is workfile save enabled. + + Returns: + bool: True if save is enabled. + """ + + return self._save_is_enabled + + def set_save_enabled(self, enabled): + """Enable or disabled workfile save. + + Args: + enabled (bool): Enable save workfile when True. + """ + + if self._save_is_enabled == enabled: + return + + self._save_is_enabled = enabled + self._emit_event( + "workfile_save_enable.changed", + {"enabled": enabled} + ) + + # Host information + def get_workfile_extensions(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.get_workfile_extensions() + return host.file_extensions() + + def has_unsaved_changes(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.workfile_has_unsaved_changes() + return host.has_unsaved_changes() + + # Current context + def get_host_name(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.name + return get_current_host_name() + + def _get_host_current_context(self): + if hasattr(self._host, "get_current_context"): + return self._host.get_current_context() + return get_global_context() + + def get_current_project_name(self): + return self._current_project_name + + def get_current_folder_id(self): + return self._current_folder_id + + def get_current_task_name(self): + return self._current_task_name + + def get_current_workfile(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.get_current_workfile() + return host.current_file() + + # Selection information + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def set_selected_task(self, task_id, task_name): + return self._selection_model.set_selected_task(task_id, task_name) + + def get_selected_workfile_path(self): + return self._selection_model.get_selected_workfile_path() + + def set_selected_workfile_path(self, path): + self._selection_model.set_selected_workfile_path(path) + + def get_selected_representation_id(self): + return self._selection_model.get_selected_representation_id() + + def set_selected_representation_id(self, representation_id): + self._selection_model.set_selected_representation_id( + representation_id) + + def set_expected_selection( + self, + folder_id, + task_name, + workfile_name=None, + representation_id=None + ): + self._expected_selection.set_expected_selection( + self.get_current_project_name(), + folder_id, + task_name, + workfile_name, + representation_id + ) + self._trigger_expected_selection_changed() + + def expected_folder_selected(self, folder_id): + if self._expected_selection.expected_folder_selected(folder_id): + self._trigger_expected_selection_changed() + + def expected_task_selected(self, folder_id, task_name): + if self._expected_selection.expected_task_selected( + folder_id, task_name + ): + self._trigger_expected_selection_changed() + + def expected_workfile_selected(self, folder_id, task_name, workfile_name): + if self._expected_selection.expected_workfile_selected( + folder_id, task_name, workfile_name + ): + self._trigger_expected_selection_changed() + + def expected_representation_selected( + self, folder_id, task_name, representation_id + ): + if self._expected_selection.expected_representation_selected( + folder_id, task_name, representation_id + ): + self._trigger_expected_selection_changed() + + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def go_to_current_context(self): + self.set_expected_selection( + self._current_folder_id, self._current_task_name + ) + + # Model functions + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) + + def get_workarea_dir_by_context(self, folder_id, task_id): + return self._workfiles_model.get_workarea_dir_by_context( + folder_id, task_id) + + def get_workarea_file_items(self, folder_id, task_id): + return self._workfiles_model.get_workarea_file_items( + folder_id, task_id) + + def get_workarea_save_as_data(self, folder_id, task_id): + return self._workfiles_model.get_workarea_save_as_data( + folder_id, task_id) + + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + return self._workfiles_model.fill_workarea_filepath( + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ) + + def get_published_file_items(self, folder_id, task_id): + task_name = None + if task_id: + task = self.get_task_entity( + self.get_current_project_name(), task_id + ) + task_name = task.get("name") + + return self._workfiles_model.get_published_file_items( + folder_id, task_name) + + def get_workfile_info(self, folder_id, task_id, filepath): + return self._workfiles_model.get_workfile_info( + folder_id, task_id, filepath + ) + + def save_workfile_info(self, folder_id, task_id, filepath, note): + self._workfiles_model.save_workfile_info( + folder_id, task_id, filepath, note + ) + + def reset(self): + if not self._host_is_valid: + self._emit_event("controller.reset.started") + self._emit_event("controller.reset.finished") + return + expected_folder_id = self.get_selected_folder_id() + expected_task_name = self.get_selected_task_name() + expected_work_path = self.get_selected_workfile_path() + expected_repre_id = self.get_selected_representation_id() + expected_work_name = None + if expected_work_path: + expected_work_name = os.path.basename(expected_work_path) + + self._emit_event("controller.reset.started") + + context = self._get_host_current_context() + + project_name = context["project_name"] + folder_name = context["asset_name"] + task_name = context["task_name"] + current_file = self.get_current_workfile() + folder_id = None + if folder_name: + folder = ayon_api.get_folder_by_path(project_name, folder_name) + if folder: + folder_id = folder["id"] + + self._project_settings = None + self._project_anatomy = None + + self._current_project_name = project_name + self._current_folder_name = folder_name + self._current_folder_id = folder_id + self._current_task_name = task_name + + self._projects_model.reset() + self._hierarchy_model.reset() + + if not expected_folder_id: + expected_folder_id = folder_id + expected_task_name = task_name + if current_file: + expected_work_name = os.path.basename(current_file) + + self._emit_event("controller.reset.finished") + + self._expected_selection.set_expected_selection( + project_name, + expected_folder_id, + expected_task_name, + expected_work_name, + expected_repre_id, + ) + + # Controller actions + def open_workfile(self, folder_id, task_id, filepath): + self._emit_event("open_workfile.started") + + failed = False + try: + self._open_workfile(folder_id, task_id, filepath) + + except Exception: + failed = True + self.log.warning("Open of workfile failed", exc_info=True) + + self._emit_event( + "open_workfile.finished", + {"failed": failed}, + ) + + def save_current_workfile(self): + current_file = self.get_current_workfile() + self._host_save_workfile(current_file) + + def save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + self._emit_event("save_as.started") + + failed = False + try: + self._save_as_workfile( + folder_id, + task_id, + workdir, + filename, + template_key, + ) + except Exception: + failed = True + self.log.warning("Save as failed", exc_info=True) + + self._emit_event( + "save_as.finished", + {"failed": failed}, + ) + + def copy_workfile_representation( + self, + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + self._emit_event("copy_representation.started") + + failed = False + try: + self._save_as_workfile( + folder_id, + task_id, + workdir, + filename, + template_key, + ) + except Exception: + failed = True + self.log.warning( + "Copy of workfile representation failed", exc_info=True + ) + + self._emit_event( + "copy_representation.finished", + {"failed": failed}, + ) + + def duplicate_workfile(self, src_filepath, workdir, filename): + self._emit_event("workfile_duplicate.started") + + failed = False + try: + dst_filepath = os.path.join(workdir, filename) + shutil.copy(src_filepath, dst_filepath) + except Exception: + failed = True + self.log.warning("Duplication of workfile failed", exc_info=True) + + self._emit_event( + "workfile_duplicate.finished", + {"failed": failed}, + ) + + # Helper host methods that resolve 'IWorkfileHost' interface + def _host_open_workfile(self, filepath): + host = self._host + if isinstance(host, IWorkfileHost): + host.open_workfile(filepath) + else: + host.open_file(filepath) + + def _host_save_workfile(self, filepath): + host = self._host + if isinstance(host, IWorkfileHost): + host.save_workfile(filepath) + else: + host.save_file(filepath) + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") + + # Expected selection + # - expected selection is used to restore selection after refresh + # or when current context should be used + def _trigger_expected_selection_changed(self): + self._emit_event( + "expected_selection_changed", + self._expected_selection.get_expected_selection_data(), + ) + + def _get_event_context_data( + self, project_name, folder_id, task_id, folder=None, task=None + ): + if folder is None: + folder = self.get_folder_entity(project_name, folder_id) + if task is None: + task = self.get_task_entity(project_name, task_id) + # NOTE keys should be OpenPype compatible + return { + "project_name": project_name, + "folder_id": folder_id, + "asset_id": folder_id, + "asset_name": folder["name"], + "task_id": task_id, + "task_name": task["name"], + "host_name": self.get_host_name(), + } + + def _open_workfile(self, folder_id, task_id, filepath): + project_name = self.get_current_project_name() + event_data = self._get_event_context_data( + project_name, folder_id, task_id + ) + event_data["filepath"] = filepath + + emit_event("workfile.open.before", event_data, source="workfiles.tool") + + # Change context + task_name = event_data["task_name"] + if ( + folder_id != self.get_current_folder_id() + or task_name != self.get_current_task_name() + ): + # Use OpenPype asset-like object + asset_doc = get_asset_by_id( + event_data["project_name"], + event_data["folder_id"], + ) + change_current_context( + asset_doc, + event_data["task_name"] + ) + + self._host_open_workfile(filepath) + + emit_event("workfile.open.after", event_data, source="workfiles.tool") + + def _save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + src_filepath=None, + ): + # Trigger before save event + project_name = self.get_current_project_name() + folder = self.get_folder_entity(project_name, folder_id) + task = self.get_task_entity(project_name, task_id) + task_name = task["name"] + + # QUESTION should the data be different for 'before' and 'after'? + event_data = self._get_event_context_data( + project_name, folder_id, task_id, folder, task + ) + event_data.update({ + "filename": filename, + "workdir_path": workdir, + }) + + emit_event("workfile.save.before", event_data, source="workfiles.tool") + + # Create workfiles root folder + if not os.path.exists(workdir): + self.log.debug("Initializing work directory: %s", workdir) + os.makedirs(workdir) + + # Change context + if ( + folder_id != self.get_current_folder_id() + or task_name != self.get_current_task_name() + ): + # Use OpenPype asset-like object + asset_doc = get_asset_by_id(project_name, folder["id"]) + change_current_context( + asset_doc, + task["name"], + template_key=template_key + ) + + # Save workfile + dst_filepath = os.path.join(workdir, filename) + if src_filepath: + shutil.copyfile(src_filepath, dst_filepath) + self._host_open_workfile(dst_filepath) + else: + self._host_save_workfile(dst_filepath) + + # Make sure workfile info exists + self.save_workfile_info(folder_id, task_id, dst_filepath, None) + + # Create extra folders + create_workdir_extra_folders( + workdir, + self.get_host_name(), + task["taskType"], + task_name, + project_name + ) + + # Trigger after save events + emit_event("workfile.save.after", event_data, source="workfiles.tool") + self.reset() diff --git a/openpype/tools/ayon_workfiles/models/__init__.py b/openpype/tools/ayon_workfiles/models/__init__.py new file mode 100644 index 0000000000..734cb08cb6 --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/__init__.py @@ -0,0 +1,8 @@ +from .selection import SelectionModel +from .workfiles import WorkfilesModel + + +__all__ = ( + "SelectionModel", + "WorkfilesModel", +) diff --git a/openpype/tools/ayon_workfiles/models/selection.py b/openpype/tools/ayon_workfiles/models/selection.py new file mode 100644 index 0000000000..2f0896842d --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/selection.py @@ -0,0 +1,96 @@ +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.folder.changed" + - "selection.task.changed" + - "selection.workarea.changed" + - "selection.representation.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._folder_id = None + self._task_name = None + self._task_id = None + self._workfile_path = None + self._representation_id = None + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._controller.get_current_project_name(), + "folder_id": folder_id + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._controller.get_current_project_name(), + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id + }, + self.event_source + ) + + def get_selected_workfile_path(self): + return self._workfile_path + + def set_selected_workfile_path(self, path): + if path == self._workfile_path: + return + + self._workfile_path = path + self._controller.emit_event( + "selection.workarea.changed", + { + "project_name": self._controller.get_current_project_name(), + "path": path, + "folder_id": self._folder_id, + "task_name": self._task_name, + "task_id": self._task_id, + }, + self.event_source + ) + + def get_selected_representation_id(self): + return self._representation_id + + def set_selected_representation_id(self, representation_id): + if representation_id == self._representation_id: + return + self._representation_id = representation_id + self._controller.emit_event( + "selection.representation.changed", + { + "project_name": self._controller.get_current_project_name(), + "representation_id": representation_id, + }, + self.event_source + ) diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py new file mode 100644 index 0000000000..907b9b5383 --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/workfiles.py @@ -0,0 +1,733 @@ +import os +import re +import copy + +import arrow +import ayon_api +from ayon_api.operations import OperationsSession + +from openpype.client import get_project +from openpype.client.operations import ( + prepare_workfile_info_update_data, +) +from openpype.pipeline.template_data import ( + get_template_data, +) +from openpype.pipeline.workfile import ( + get_workdir_with_workdir_data, + get_workfile_template_key, + get_last_workfile_with_version, +) +from openpype.pipeline.version_start import get_versioning_start +from openpype.tools.ayon_workfiles.abstract import ( + WorkareaFilepathResult, + FileItem, + WorkfileInfo, +) + + +def get_folder_template_data(folder): + if not folder: + return {} + parts = folder["path"].split("/") + parts.pop(-1) + hierarchy = "/".join(parts) + return { + "asset": folder["name"], + "folder": { + "name": folder["name"], + "type": folder["folderType"], + "path": folder["path"], + }, + "hierarchy": hierarchy, + } + + +def get_task_template_data(project_entity, task): + if not task: + return {} + short_name = None + task_type_name = task["taskType"] + for task_type_info in project_entity["taskTypes"]: + if task_type_info["name"] == task_type_name: + short_name = task_type_info["shortName"] + break + + return { + "task": { + "name": task["name"], + "type": task_type_name, + "short": short_name, + } + } + + +class CommentMatcher(object): + """Use anatomy and work file data to parse comments from filenames""" + def __init__(self, extensions, file_template, data): + self.fname_regex = None + + if "{comment}" not in file_template: + # Don't look for comment if template doesn't allow it + return + + # Create a regex group for extensions + any_extension = "(?:{})".format( + "|".join(re.escape(ext.lstrip(".")) for ext in extensions) + ) + + # Use placeholders that will never be in the filename + temp_data = copy.deepcopy(data) + temp_data["comment"] = "<>" + temp_data["version"] = "<>" + temp_data["ext"] = "<>" + + fname_pattern = file_template.format_strict(temp_data) + fname_pattern = re.escape(fname_pattern) + + # Replace comment and version with something we can match with regex + replacements = { + "<>": "(.+)", + "<>": "[0-9]+", + "<>": any_extension, + } + for src, dest in replacements.items(): + fname_pattern = fname_pattern.replace(re.escape(src), dest) + + # Match from beginning to end of string to be safe + fname_pattern = "^{}$".format(fname_pattern) + + self.fname_regex = re.compile(fname_pattern) + + def parse_comment(self, filepath): + """Parse the {comment} part from a filename""" + if not self.fname_regex: + return + + fname = os.path.basename(filepath) + match = self.fname_regex.match(fname) + if match: + return match.group(1) + + +class WorkareaModel: + """Workfiles model looking for workfiles in workare folder. + + Workarea folder is usually task and host specific, defined by + anatomy templates. Is looking for files with extensions defined + by host integration. + """ + + def __init__(self, controller): + self._controller = controller + extensions = None + if controller.is_host_valid(): + extensions = controller.get_workfile_extensions() + self._extensions = extensions + self._base_data = None + self._fill_data_by_folder_id = {} + self._task_data_by_folder_id = {} + self._workdir_by_context = {} + + @property + def project_name(self): + return self._controller.get_current_project_name() + + def reset(self): + self._base_data = None + self._fill_data_by_folder_id = {} + self._task_data_by_folder_id = {} + + def _get_base_data(self): + if self._base_data is None: + base_data = get_template_data(get_project(self.project_name)) + base_data["app"] = self._controller.get_host_name() + self._base_data = base_data + return copy.deepcopy(self._base_data) + + def _get_folder_data(self, folder_id): + fill_data = self._fill_data_by_folder_id.get(folder_id) + if fill_data is None: + folder = self._controller.get_folder_entity( + self.project_name, folder_id + ) + fill_data = get_folder_template_data(folder) + self._fill_data_by_folder_id[folder_id] = fill_data + return copy.deepcopy(fill_data) + + def _get_task_data(self, project_entity, folder_id, task_id): + task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) + if task_id not in task_data: + task = self._controller.get_task_entity( + self.project_name, task_id + ) + if task: + task_data[task_id] = get_task_template_data( + project_entity, task) + return copy.deepcopy(task_data[task_id]) + + def _prepare_fill_data(self, folder_id, task_id): + if not folder_id or not task_id: + return {} + + base_data = self._get_base_data() + project_name = base_data["project"]["name"] + folder_data = self._get_folder_data(folder_id) + project_entity = self._controller.get_project_entity(project_name) + task_data = self._get_task_data(project_entity, folder_id, task_id) + + base_data.update(folder_data) + base_data.update(task_data) + + return base_data + + def get_workarea_dir_by_context(self, folder_id, task_id): + if not folder_id or not task_id: + return None + folder_mapping = self._workdir_by_context.setdefault(folder_id, {}) + workdir = folder_mapping.get(task_id) + if workdir is not None: + return workdir + + workdir_data = self._prepare_fill_data(folder_id, task_id) + + workdir = get_workdir_with_workdir_data( + workdir_data, + self.project_name, + anatomy=self._controller.project_anatomy, + ) + folder_mapping[task_id] = workdir + return workdir + + def get_file_items(self, folder_id, task_id): + items = [] + if not folder_id or not task_id: + return items + + workdir = self.get_workarea_dir_by_context(folder_id, task_id) + if not os.path.exists(workdir): + return items + + for filename in os.listdir(workdir): + filepath = os.path.join(workdir, filename) + if not os.path.isfile(filepath): + continue + + ext = os.path.splitext(filename)[1].lower() + if ext not in self._extensions: + continue + + modified = os.path.getmtime(filepath) + items.append( + FileItem(workdir, filename, modified) + ) + return items + + def _get_template_key(self, fill_data): + task_type = fill_data.get("task", {}).get("type") + # TODO cache + return get_workfile_template_key( + task_type, + self._controller.get_host_name(), + project_name=self.project_name + ) + + def _get_last_workfile_version( + self, workdir, file_template, fill_data, extensions + ): + version = get_last_workfile_with_version( + workdir, str(file_template), fill_data, extensions + )[1] + + if version is None: + task_info = fill_data.get("task", {}) + version = get_versioning_start( + self.project_name, + self._controller.get_host_name(), + task_name=task_info.get("name"), + task_type=task_info.get("type"), + family="workfile", + project_settings=self._controller.project_settings, + ) + else: + version += 1 + return version + + def _get_comments_from_root( + self, + file_template, + extensions, + fill_data, + root, + current_filename, + ): + current_comment = None + comment_hints = set() + filenames = [] + if root and os.path.exists(root): + for filename in os.listdir(root): + path = os.path.join(root, filename) + if not os.path.isfile(path): + continue + + ext = os.path.splitext(filename)[-1].lower() + if ext in extensions: + filenames.append(filename) + + if not filenames: + return comment_hints, current_comment + + matcher = CommentMatcher(extensions, file_template, fill_data) + + for filename in filenames: + comment = matcher.parse_comment(filename) + if comment: + comment_hints.add(comment) + if filename == current_filename: + current_comment = comment + + return list(comment_hints), current_comment + + def _get_workdir(self, anatomy, template_key, fill_data): + template_info = anatomy.templates_obj[template_key] + directory_template = template_info["folder"] + return directory_template.format_strict(fill_data).normalized() + + def get_workarea_save_as_data(self, folder_id, task_id): + folder = None + task = None + if folder_id: + folder = self._controller.get_folder_entity( + self.project_name, folder_id + ) + if task_id: + task = self._controller.get_task_entity( + self.project_name, task_id + ) + + if not folder or not task: + return { + "template_key": None, + "template_has_version": None, + "template_has_comment": None, + "ext": None, + "workdir": None, + "comment": None, + "comment_hints": None, + "last_version": None, + "extensions": None, + } + + anatomy = self._controller.project_anatomy + fill_data = self._prepare_fill_data(folder_id, task_id) + template_key = self._get_template_key(fill_data) + + current_workfile = self._controller.get_current_workfile() + current_filename = None + current_ext = None + if current_workfile: + current_filename = os.path.basename(current_workfile) + current_ext = os.path.splitext(current_filename)[1].lower() + + extensions = self._extensions + if not current_ext and extensions: + current_ext = tuple(extensions)[0] + + workdir = self._get_workdir(anatomy, template_key, fill_data) + + template_info = anatomy.templates_obj[template_key] + file_template = template_info["file"] + + comment_hints, comment = self._get_comments_from_root( + file_template, + extensions, + fill_data, + workdir, + current_filename, + ) + last_version = self._get_last_workfile_version( + workdir, file_template, fill_data, extensions) + str_file_template = str(file_template) + template_has_version = "{version" in str_file_template + template_has_comment = "{comment" in str_file_template + + return { + "template_key": template_key, + "template_has_version": template_has_version, + "template_has_comment": template_has_comment, + "ext": current_ext, + "workdir": workdir, + "comment": comment, + "comment_hints": comment_hints, + "last_version": last_version, + "extensions": extensions, + } + + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + anatomy = self._controller.project_anatomy + fill_data = self._prepare_fill_data(folder_id, task_id) + template_key = self._get_template_key(fill_data) + + workdir = self._get_workdir(anatomy, template_key, fill_data) + + template_info = anatomy.templates_obj[template_key] + file_template = template_info["file"] + + if use_last_version: + version = self._get_last_workfile_version( + workdir, file_template, fill_data, self._extensions + ) + fill_data["version"] = version + fill_data["ext"] = extension.lstrip(".") + + if comment: + fill_data["comment"] = comment + + filename = file_template.format(fill_data) + if not filename.solved: + filename = None + + exists = False + if filename: + filepath = os.path.join(workdir, filename) + exists = os.path.exists(filepath) + + return WorkareaFilepathResult( + workdir, + filename, + exists + ) + + +class WorkfileEntitiesModel: + """Workfile entities model. + + Args: + control (AbstractWorkfileController): Controller object. + """ + + def __init__(self, controller): + self._controller = controller + self._cache = {} + self._items = {} + + def _get_workfile_info_identifier( + self, folder_id, task_id, rootless_path + ): + return "_".join([folder_id, task_id, rootless_path]) + + def _get_rootless_path(self, filepath): + anatomy = self._controller.project_anatomy + + workdir, filename = os.path.split(filepath) + success, rootless_dir = anatomy.find_root_template_from_path(workdir) + return "/".join([ + os.path.normpath(rootless_dir).replace("\\", "/"), + filename + ]) + + def _prepare_workfile_info_item( + self, folder_id, task_id, workfile_info, filepath + ): + note = "" + if workfile_info: + note = workfile_info["attrib"].get("description") or "" + + filestat = os.stat(filepath) + return WorkfileInfo( + folder_id, + task_id, + filepath, + filesize=filestat.st_size, + creation_time=filestat.st_ctime, + modification_time=filestat.st_mtime, + note=note + ) + + def _get_workfile_info(self, folder_id, task_id, identifier): + workfile_info = self._cache.get(identifier) + if workfile_info is not None: + return workfile_info + + for workfile_info in ayon_api.get_workfiles_info( + self._controller.get_current_project_name(), + task_ids=[task_id], + fields=["id", "path", "attrib"], + ): + workfile_identifier = self._get_workfile_info_identifier( + folder_id, task_id, workfile_info["path"] + ) + self._cache[workfile_identifier] = workfile_info + return self._cache.get(identifier) + + def get_workfile_info( + self, folder_id, task_id, filepath, rootless_path=None + ): + if not folder_id or not task_id or not filepath: + return None + + if rootless_path is None: + rootless_path = self._get_rootless_path(filepath) + + identifier = self._get_workfile_info_identifier( + folder_id, task_id, rootless_path) + item = self._items.get(identifier) + if item is None: + workfile_info = self._get_workfile_info( + folder_id, task_id, identifier + ) + item = self._prepare_workfile_info_item( + folder_id, task_id, workfile_info, filepath + ) + self._items[identifier] = item + return item + + def save_workfile_info(self, folder_id, task_id, filepath, note): + rootless_path = self._get_rootless_path(filepath) + identifier = self._get_workfile_info_identifier( + folder_id, task_id, rootless_path + ) + workfile_info = self._get_workfile_info( + folder_id, task_id, identifier + ) + if not workfile_info: + self._cache[identifier] = self._create_workfile_info_entity( + task_id, rootless_path, note or "") + self._items.pop(identifier, None) + return + + if note is None: + return + + new_workfile_info = copy.deepcopy(workfile_info) + attrib = new_workfile_info.setdefault("attrib", {}) + attrib["description"] = note + update_data = prepare_workfile_info_update_data( + workfile_info, new_workfile_info + ) + self._cache[identifier] = new_workfile_info + self._items.pop(identifier, None) + if not update_data: + return + + project_name = self._controller.get_current_project_name() + + session = OperationsSession() + session.update_entity( + project_name, "workfile", workfile_info["id"], update_data + ) + session.commit() + + def _create_workfile_info_entity(self, task_id, rootless_path, note): + extension = os.path.splitext(rootless_path)[1] + + project_name = self._controller.get_current_project_name() + + workfile_info = { + "path": rootless_path, + "taskId": task_id, + "attrib": { + "extension": extension, + "description": note + } + } + + session = OperationsSession() + session.create_entity(project_name, "workfile", workfile_info) + session.commit() + return workfile_info + + +class PublishWorkfilesModel: + """Model for handling of published workfiles. + + Todos: + Cache workfiles products and representations for some time. + Note Representations won't change. Only what can change are + versions. + """ + + def __init__(self, controller): + self._controller = controller + self._cached_extensions = None + self._cached_repre_extensions = None + + @property + def _extensions(self): + if self._cached_extensions is None: + exts = self._controller.get_workfile_extensions() or [] + self._cached_extensions = exts + return self._cached_extensions + + @property + def _repre_extensions(self): + if self._cached_repre_extensions is None: + self._cached_repre_extensions = { + ext.lstrip(".") for ext in self._extensions + } + return self._cached_repre_extensions + + def _file_item_from_representation( + self, repre_entity, project_anatomy, task_name=None + ): + if task_name is not None: + task_info = repre_entity["context"].get("task") + if not task_info or task_info["name"] != task_name: + return None + + # Filter by extension + extensions = self._repre_extensions + workfile_path = None + for repre_file in repre_entity["files"]: + ext = ( + os.path.splitext(repre_file["name"])[1] + .lower() + .lstrip(".") + ) + if ext in extensions: + workfile_path = repre_file["path"] + break + + if not workfile_path: + return None + + try: + workfile_path = workfile_path.format( + root=project_anatomy.roots) + except Exception as exc: + print("Failed to format workfile path: {}".format(exc)) + + dirpath, filename = os.path.split(workfile_path) + created_at = arrow.get(repre_entity["createdAt"]) + return FileItem( + dirpath, + filename, + created_at.float_timestamp, + repre_entity["id"] + ) + + def get_file_items(self, folder_id, task_name): + # TODO refactor to use less server API calls + project_name = self._controller.get_current_project_name() + # Get subset docs of asset + product_entities = ayon_api.get_products( + project_name, + folder_ids=[folder_id], + product_types=["workfile"], + fields=["id", "name"] + ) + + output = [] + product_ids = {product["id"] for product in product_entities} + if not product_ids: + return output + + # Get version docs of subsets with their families + version_entities = ayon_api.get_versions( + project_name, + product_ids=product_ids, + fields=["id", "productId"] + ) + version_ids = {version["id"] for version in version_entities} + if not version_ids: + return output + + # Query representations of filtered versions and add filter for + # extension + repre_entities = ayon_api.get_representations( + project_name, + version_ids=version_ids + ) + project_anatomy = self._controller.project_anatomy + + # Filter queried representations by task name if task is set + file_items = [] + for repre_entity in repre_entities: + file_item = self._file_item_from_representation( + repre_entity, project_anatomy, task_name + ) + if file_item is not None: + file_items.append(file_item) + + return file_items + + +class WorkfilesModel: + """Workfiles model.""" + + def __init__(self, controller): + self._controller = controller + + self._entities_model = WorkfileEntitiesModel(controller) + self._workarea_model = WorkareaModel(controller) + self._published_model = PublishWorkfilesModel(controller) + + def get_workfile_info(self, folder_id, task_id, filepath): + return self._entities_model.get_workfile_info( + folder_id, task_id, filepath + ) + + def save_workfile_info(self, folder_id, task_id, filepath, note): + self._entities_model.save_workfile_info( + folder_id, task_id, filepath, note + ) + + def get_workarea_dir_by_context(self, folder_id, task_id): + """Workarea dir for passed context. + + The directory path is based on project anatomy templates. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + Union[str, None]: Workarea dir path or None for invalid context. + """ + + return self._workarea_model.get_workarea_dir_by_context( + folder_id, task_id) + + def get_workarea_file_items(self, folder_id, task_id): + """Workfile items for passed context from workarea. + + Args: + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[FileItem]: List of file items matching workarea of passed + context. + """ + + return self._workarea_model.get_file_items(folder_id, task_id) + + def get_workarea_save_as_data(self, folder_id, task_id): + return self._workarea_model.get_workarea_save_as_data( + folder_id, task_id) + + def fill_workarea_filepath(self, *args, **kwargs): + return self._workarea_model.fill_workarea_filepath( + *args, **kwargs + ) + + def get_published_file_items(self, folder_id, task_name): + """Published workfiles for passed context. + + Args: + folder_id (str): Folder id. + task_name (str): Task name. + + Returns: + list[FileItem]: List of files for published workfiles. + """ + + return self._published_model.get_file_items(folder_id, task_name) diff --git a/openpype/tools/ayon_workfiles/widgets/__init__.py b/openpype/tools/ayon_workfiles/widgets/__init__.py new file mode 100644 index 0000000000..f0c5ba1c40 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/__init__.py @@ -0,0 +1,6 @@ +from .window import WorkfilesToolWindow + + +__all__ = ( + "WorkfilesToolWindow", +) diff --git a/openpype/tools/ayon_workfiles/widgets/constants.py b/openpype/tools/ayon_workfiles/widgets/constants.py new file mode 100644 index 0000000000..fc74fd9bc4 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/constants.py @@ -0,0 +1,7 @@ +from qtpy import QtCore + + +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +PARENT_ID_ROLE = QtCore.Qt.UserRole + 2 +ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4 diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget.py b/openpype/tools/ayon_workfiles/widgets/files_widget.py new file mode 100644 index 0000000000..16f0b6fce3 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget.py @@ -0,0 +1,407 @@ +import os + +import qtpy +from qtpy import QtWidgets, QtCore + +from .save_as_dialog import SaveAsDialog +from .files_widget_workarea import WorkAreaFilesWidget +from .files_widget_published import PublishedFilesWidget + + +class FilesWidget(QtWidgets.QWidget): + """A widget displaying files that allows to save and open files. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + def __init__(self, controller, parent): + super(FilesWidget, self).__init__(parent) + + files_widget = QtWidgets.QStackedWidget(self) + workarea_widget = WorkAreaFilesWidget(controller, files_widget) + published_widget = PublishedFilesWidget(controller, files_widget) + files_widget.addWidget(workarea_widget) + files_widget.addWidget(published_widget) + + btns_widget = QtWidgets.QWidget(self) + + workarea_btns_widget = QtWidgets.QWidget(btns_widget) + workarea_btn_open = QtWidgets.QPushButton( + "Open", workarea_btns_widget) + workarea_btn_browse = QtWidgets.QPushButton( + "Browse", workarea_btns_widget) + workarea_btn_save = QtWidgets.QPushButton( + "Save As", workarea_btns_widget) + + workarea_btns_layout = QtWidgets.QHBoxLayout(workarea_btns_widget) + workarea_btns_layout.setContentsMargins(0, 0, 0, 0) + workarea_btns_layout.addWidget(workarea_btn_open, 1) + workarea_btns_layout.addWidget(workarea_btn_browse, 1) + workarea_btns_layout.addWidget(workarea_btn_save, 1) + + published_btns_widget = QtWidgets.QWidget(btns_widget) + published_btn_copy_n_open = QtWidgets.QPushButton( + "Copy && Open", published_btns_widget + ) + published_btn_change_context = QtWidgets.QPushButton( + "Choose different context", published_btns_widget + ) + published_btn_cancel = QtWidgets.QPushButton( + "Cancel", published_btns_widget + ) + + published_btns_layout = QtWidgets.QHBoxLayout(published_btns_widget) + published_btns_layout.setContentsMargins(0, 0, 0, 0) + published_btns_layout.addWidget(published_btn_copy_n_open, 1) + published_btns_layout.addWidget(published_btn_change_context, 1) + published_btns_layout.addWidget(published_btn_cancel, 1) + + btns_layout = QtWidgets.QVBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(workarea_btns_widget, 1) + btns_layout.addWidget(published_btns_widget, 1) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(files_widget, 1) + main_layout.addWidget(btns_widget, 0) + + controller.register_event_callback( + "selection.workarea.changed", + self._on_workarea_path_changed + ) + controller.register_event_callback( + "selection.representation.changed", + self._on_published_repre_changed + ) + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "copy_representation.finished", + self._on_copy_representation_finished, + ) + controller.register_event_callback( + "workfile_save_enable.changed", + self._on_workfile_save_enabled_change, + ) + + workarea_widget.open_current_requested.connect( + self._on_current_open_requests) + workarea_widget.duplicate_requested.connect( + self._on_duplicate_request) + workarea_btn_open.clicked.connect(self._on_workarea_open_clicked) + workarea_btn_browse.clicked.connect(self._on_workarea_browse_clicked) + workarea_btn_save.clicked.connect(self._on_workarea_save_clicked) + + published_widget.save_as_requested.connect(self._on_save_as_request) + published_btn_copy_n_open.clicked.connect( + self._on_published_save_clicked) + published_btn_change_context.clicked.connect( + self._on_published_change_context_clicked) + published_btn_cancel.clicked.connect( + self._on_published_cancel_clicked) + + self._selected_folder_id = None + self._selected_task_id = None + self._selected_task_name = None + + self._pre_select_folder_id = None + self._pre_select_task_name = None + + self._select_context_mode = False + self._valid_selected_context = False + self._valid_representation_id = False + self._tmp_text_filter = None + self._is_save_enabled = True + + self._controller = controller + self._files_widget = files_widget + self._workarea_widget = workarea_widget + self._published_widget = published_widget + self._workarea_btns_widget = workarea_btns_widget + self._published_btns_widget = published_btns_widget + + self._workarea_btn_open = workarea_btn_open + self._workarea_btn_browse = workarea_btn_browse + self._workarea_btn_save = workarea_btn_save + + self._published_widget = published_widget + self._published_btn_copy_n_open = published_btn_copy_n_open + self._published_btn_change_context = published_btn_change_context + self._published_btn_cancel = published_btn_cancel + + # Initial setup + workarea_btn_open.setEnabled(False) + published_btn_copy_n_open.setEnabled(False) + published_btn_change_context.setEnabled(False) + published_btn_cancel.setVisible(False) + + def set_published_mode(self, published_mode): + # Make sure context selection is disabled + self._set_select_contex_mode(False) + # Change current widget + self._files_widget.setCurrentWidget(( + self._published_widget + if published_mode + else self._workarea_widget + )) + # Pass the mode to the widgets, so they can start/stop handle events + self._workarea_widget.set_published_mode(published_mode) + self._published_widget.set_published_mode(published_mode) + + # Change available buttons + self._workarea_btns_widget.setVisible(not published_mode) + self._published_btns_widget.setVisible(published_mode) + + def set_text_filter(self, text_filter): + if self._select_context_mode: + self._tmp_text_filter = text_filter + return + self._workarea_widget.set_text_filter(text_filter) + self._published_widget.set_text_filter(text_filter) + + def _exec_save_as_dialog(self): + """Show SaveAs dialog using currently selected context. + + Returns: + Union[dict[str, Any], None]: Result of the dialog. + """ + + dialog = SaveAsDialog(self._controller, self) + dialog.update_context() + dialog.exec_() + return dialog.get_result() + + # ------------------------------------------------------------- + # Workarea workfiles + # ------------------------------------------------------------- + def _open_workfile(self, folder_id, task_name, filepath): + if self._controller.has_unsaved_changes(): + result = self._save_changes_prompt() + if result is None: + return + + if result: + self._controller.save_current_workfile() + self._controller.open_workfile(folder_id, task_name, filepath) + + def _on_workarea_open_clicked(self): + path = self._workarea_widget.get_selected_path() + if not path: + return + folder_id = self._selected_folder_id + task_id = self._selected_task_id + self._open_workfile(folder_id, task_id, path) + + def _on_current_open_requests(self): + self._on_workarea_open_clicked() + + def _on_duplicate_request(self): + filepath = self._workarea_widget.get_selected_path() + if filepath is None: + return + + result = self._exec_save_as_dialog() + if result is None: + return + self._controller.duplicate_workfile( + filepath, + result["workdir"], + result["filename"] + ) + + def _on_workarea_browse_clicked(self): + extnsions = self._controller.get_workfile_extensions() + ext_filter = "Work File (*{0})".format( + " *".join(extnsions) + ) + dir_key = "directory" + if qtpy.API in ("pyside", "pyside2", "pyside6"): + dir_key = "dir" + + selected_context = self._controller.get_selected_context() + workfile_root = self._controller.get_workarea_dir_by_context( + selected_context["folder_id"], selected_context["task_id"] + ) + # Find existing directory of workfile root + # - Qt will use 'cwd' instead, if path does not exist, which may lead + # to igniter directory + while workfile_root: + if os.path.exists(workfile_root): + break + workfile_root = os.path.dirname(workfile_root) + + kwargs = { + "caption": "Work Files", + "filter": ext_filter, + dir_key: workfile_root + } + + filepath = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] + if not filepath: + return + + folder_id = self._selected_folder_id + task_id = self._selected_task_id + self._open_workfile(folder_id, task_id, filepath) + + def _on_workarea_save_clicked(self): + result = self._exec_save_as_dialog() + if result is None: + return + self._controller.save_as_workfile( + result["folder_id"], + result["task_id"], + result["workdir"], + result["filename"], + result["template_key"], + ) + + def _on_workarea_path_changed(self, event): + valid_path = event["path"] is not None + self._workarea_btn_open.setEnabled(valid_path) + + # ------------------------------------------------------------- + # Published workfiles + # ------------------------------------------------------------- + def _update_published_btns_state(self): + enabled = ( + self._valid_representation_id + and self._valid_selected_context + and self._is_save_enabled + ) + self._published_btn_copy_n_open.setEnabled(enabled) + self._published_btn_change_context.setEnabled(enabled) + + def _update_workarea_btns_state(self): + enabled = self._is_save_enabled + self._workarea_btn_save.setEnabled(enabled) + + def _on_published_repre_changed(self, event): + self._valid_representation_id = event["representation_id"] is not None + self._update_published_btns_state() + + def _on_task_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + self._selected_task_name = event["task_name"] + self._valid_selected_context = ( + self._selected_folder_id is not None + and self._selected_task_id is not None + ) + self._update_published_btns_state() + + def _on_published_save_clicked(self): + result = self._exec_save_as_dialog() + if result is None: + return + + repre_info = self._published_widget.get_selected_repre_info() + self._controller.copy_workfile_representation( + repre_info["representation_id"], + repre_info["filepath"], + result["folder_id"], + result["task_id"], + result["workdir"], + result["filename"], + result["template_key"], + ) + + def _on_save_as_request(self): + self._on_published_save_clicked() + + def _set_select_contex_mode(self, enabled): + if self._select_context_mode is enabled: + return + + if enabled: + self._pre_select_folder_id = self._selected_folder_id + self._pre_select_task_name = self._selected_task_name + else: + self._pre_select_folder_id = None + self._pre_select_task_name = None + self._select_context_mode = enabled + self._published_btn_cancel.setVisible(enabled) + self._published_btn_change_context.setVisible(not enabled) + self._published_widget.set_select_context_mode(enabled) + + if not enabled and self._tmp_text_filter is not None: + self.set_text_filter(self._tmp_text_filter) + self._tmp_text_filter = None + + def _on_published_change_context_clicked(self): + self._set_select_contex_mode(True) + + def _should_set_pre_select_context(self): + if self._pre_select_folder_id is None: + return False + if self._pre_select_folder_id != self._selected_folder_id: + return True + if self._pre_select_task_name is None: + return False + return self._pre_select_task_name != self._selected_task_name + + def _on_published_cancel_clicked(self): + folder_id = self._pre_select_folder_id + task_name = self._pre_select_task_name + representation_id = self._published_widget.get_selected_repre_id() + should_change_selection = self._should_set_pre_select_context() + self._set_select_contex_mode(False) + if should_change_selection: + self._controller.set_expected_selection( + folder_id, task_name, representation_id=representation_id + ) + + def _on_copy_representation_finished(self, event): + """Callback for when copy representation is finished. + + Make sure that select context mode is disabled when representation + copy is finished. + + Args: + event (Event): Event object. + """ + + if not event["failed"]: + self._set_select_contex_mode(False) + + def _on_workfile_save_enabled_change(self, event): + enabled = event["enabled"] + self._is_save_enabled = enabled + self._update_published_btns_state() + self._update_workarea_btns_state() + + def _save_changes_prompt(self): + """Ask user if wants to save changes to current file. + + Returns: + Union[bool, None]: True if user wants to save changes, False if + user does not want to save changes, None if user cancels + operation. + """ + messagebox = QtWidgets.QMessageBox(parent=self) + messagebox.setWindowFlags( + messagebox.windowFlags() | QtCore.Qt.FramelessWindowHint + ) + messagebox.setIcon(QtWidgets.QMessageBox.Warning) + messagebox.setWindowTitle("Unsaved Changes!") + messagebox.setText( + "There are unsaved changes to the current file." + "\nDo you want to save the changes?" + ) + messagebox.setStandardButtons( + QtWidgets.QMessageBox.Yes + | QtWidgets.QMessageBox.No + | QtWidgets.QMessageBox.Cancel + ) + + result = messagebox.exec_() + if result == QtWidgets.QMessageBox.Yes: + return True + if result == QtWidgets.QMessageBox.No: + return False + return None diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py new file mode 100644 index 0000000000..704f7b2f39 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py @@ -0,0 +1,382 @@ +import qtawesome +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import ( + get_default_entity_icon_color, + get_disabled_entity_icon_color, +) +from openpype.tools.utils import TreeView +from openpype.tools.utils.delegates import PrettyTimeDelegate + +from .utils import BaseOverlayFrame + + +REPRE_ID_ROLE = QtCore.Qt.UserRole + 1 +FILEPATH_ROLE = QtCore.Qt.UserRole + 2 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 + + +class PublishedFilesModel(QtGui.QStandardItemModel): + """A model for displaying files. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller): + super(PublishedFilesModel, self).__init__() + + self.setColumnCount(2) + + self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") + self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified") + + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_changed + ) + + self._file_icon = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) + self._controller = controller + self._items_by_id = {} + self._missing_context_item = None + self._missing_context_used = False + self._empty_root_item = None + self._empty_item_used = False + + self._published_mode = False + self._context_select_mode = False + + self._last_folder_id = None + self._last_task_id = None + + self._add_empty_item() + + def set_published_mode(self, published_mode): + if self._published_mode == published_mode: + return + self._published_mode = published_mode + if published_mode: + self._fill_items() + elif self._context_select_mode: + self.set_select_context_mode(False) + + def set_select_context_mode(self, select_mode): + if self._context_select_mode is select_mode: + return + self._context_select_mode = select_mode + if not select_mode and self._published_mode: + self._fill_items() + + def get_index_by_representation_id(self, representation_id): + item = self._items_by_id.get(representation_id) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def refresh(self): + if self._published_mode: + self._fill_items() + + def _clear_items(self): + self._remove_missing_context_item() + self._remove_empty_item() + if self._items_by_id: + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + self._items_by_id = {} + + def _get_missing_context_item(self): + if self._missing_context_item is None: + message = "Select folder" + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._missing_context_item = item + return self._missing_context_item + + def _add_missing_context_item(self): + if self._missing_context_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_missing_context_item()) + self._missing_context_used = True + + def _remove_missing_context_item(self): + if not self._missing_context_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._missing_context_item.row()) + self._missing_context_used = False + + def _get_empty_root_item(self): + if self._empty_root_item is None: + message = "Didn't find any published workfiles." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._empty_root_item = item + return self._empty_root_item + + def _add_empty_item(self): + if self._empty_item_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_empty_root_item()) + self._empty_item_used = True + + def _remove_empty_item(self): + if not self._empty_item_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._empty_root_item.row()) + self._empty_item_used = False + + def _on_folder_changed(self, event): + self._last_folder_id = event["folder_id"] + if self._context_select_mode: + return + + if self._published_mode: + self._fill_items() + + def _on_task_changed(self, event): + self._last_folder_id = event["folder_id"] + self._last_task_id = event["task_id"] + if self._context_select_mode: + return + + if self._published_mode: + self._fill_items() + + def _fill_items(self): + folder_id = self._last_folder_id + task_id = self._last_task_id + if not folder_id: + self._add_missing_context_item() + return + + file_items = self._controller.get_published_file_items( + folder_id, task_id + ) + root_item = self.invisibleRootItem() + if not file_items: + self._add_empty_item() + return + self._remove_empty_item() + self._remove_missing_context_item() + + items_to_remove = set(self._items_by_id.keys()) + new_items = [] + for file_item in file_items: + repre_id = file_item.representation_id + if repre_id in self._items_by_id: + items_to_remove.discard(repre_id) + item = self._items_by_id[repre_id] + else: + item = QtGui.QStandardItem() + new_items.append(item) + item.setColumnCount(self.columnCount()) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + item.setData(file_item.filename, QtCore.Qt.DisplayRole) + item.setData(repre_id, REPRE_ID_ROLE) + + if file_item.exists: + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + else: + flags = QtCore.Qt.NoItemFlags + + item.setFlags(flags) + item.setData(file_item.filepath, FILEPATH_ROLE) + item.setData(file_item.modified, DATE_MODIFIED_ROLE) + + self._items_by_id[repre_id] = item + + if new_items: + root_item.appendRows(new_items) + + for repre_id in items_to_remove: + item = self._items_by_id.pop(repre_id) + root_item.removeRow(item.row()) + + if root_item.rowCount() == 0: + self._add_empty_item() + + def flags(self, index): + # Use flags of first column for all columns + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(PublishedFilesModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + # Handle roles for first column + if index.column() == 1: + if role == QtCore.Qt.DecorationRole: + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = DATE_MODIFIED_ROLE + index = self.index(index.row(), 0, index.parent()) + + return super(PublishedFilesModel, self).data(index, role) + + +class SelectContextOverlay(BaseOverlayFrame): + """Overlay for files view when user should select context. + + Todos: + The look of this overlay should be improved, it is "not nice" now. + """ + + def __init__(self, parent): + super(SelectContextOverlay, self).__init__(parent) + + label_widget = QtWidgets.QLabel( + "Please choose context on the left
<", + self + ) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + label_widget.setObjectName("OverlayFrameLabel") + + layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter) + + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + +class PublishedFilesWidget(QtWidgets.QWidget): + """Published workfiles widget. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + selection_changed = QtCore.Signal() + save_as_requested = QtCore.Signal() + + def __init__(self, controller, parent): + super(PublishedFilesWidget, self).__init__(parent) + + view = TreeView(self) + view.setSortingEnabled(True) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # Smaller indentation + view.setIndentation(0) + + model = PublishedFilesModel(controller) + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(model) + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setDynamicSortFilter(True) + + view.setModel(proxy_model) + + time_delegate = PrettyTimeDelegate() + view.setItemDelegateForColumn(1, time_delegate) + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + view.setColumnWidth(0, 330) + + select_overlay = SelectContextOverlay(view) + select_overlay.setVisible(False) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(view, 1) + + selection_model = view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + view.double_clicked.connect(self._on_mouse_double_click) + + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + self._view = view + self._select_overlay = select_overlay + self._model = model + self._proxy_model = proxy_model + self._time_delegate = time_delegate + self._controller = controller + + def set_published_mode(self, published_mode): + self._model.set_published_mode(published_mode) + + def set_select_context_mode(self, select_mode): + self._model.set_select_context_mode(select_mode) + self._select_overlay.setVisible(select_mode) + + def set_text_filter(self, text_filter): + self._proxy_model.setFilterFixedString(text_filter) + + def get_selected_repre_info(self): + selection_model = self._view.selectionModel() + representation_id = None + filepath = None + for index in selection_model.selectedIndexes(): + representation_id = index.data(REPRE_ID_ROLE) + filepath = index.data(FILEPATH_ROLE) + + return { + "representation_id": representation_id, + "filepath": filepath, + } + + def get_selected_repre_id(self): + return self.get_selected_repre_info()["representation_id"] + + def _on_selection_change(self): + repre_id = self.get_selected_repre_id() + self._controller.set_selected_representation_id(repre_id) + + def _on_mouse_double_click(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.save_as_requested.emit() + + def _on_expected_selection_change(self, event): + repre_info = event["representation"] + if not repre_info["current"]: + return + + self._model.refresh() + + representation_id = repre_info["id"] + selected_repre_id = self.get_selected_repre_id() + if ( + representation_id is not None + and representation_id != selected_repre_id + ): + index = self._model.get_index_by_representation_id( + representation_id) + if index.isValid(): + proxy_index = self._proxy_model.mapFromSource(index) + self._view.setCurrentIndex(proxy_index) + + self._controller.expected_representation_selected( + event["folder"]["id"], event["task"]["name"], representation_id + ) diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py new file mode 100644 index 0000000000..8eefd3cf81 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py @@ -0,0 +1,396 @@ +import qtawesome +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import ( + get_default_entity_icon_color, + get_disabled_entity_icon_color, +) +from openpype.tools.utils import TreeView +from openpype.tools.utils.delegates import PrettyTimeDelegate + +FILENAME_ROLE = QtCore.Qt.UserRole + 1 +FILEPATH_ROLE = QtCore.Qt.UserRole + 2 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 + + +class WorkAreaFilesModel(QtGui.QStandardItemModel): + """A model for workare workfiles. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller): + super(WorkAreaFilesModel, self).__init__() + + self.setColumnCount(2) + + self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") + self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified") + + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_changed + ) + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "workfile_duplicate.finished", + self._on_duplicate_finished + ) + controller.register_event_callback( + "save_as.finished", + self._on_save_as_finished + ) + + self._file_icon = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) + self._controller = controller + self._items_by_filename = {} + self._missing_context_item = None + self._missing_context_used = False + self._empty_root_item = None + self._empty_item_used = False + self._published_mode = False + self._selected_folder_id = None + self._selected_task_id = None + + self._add_missing_context_item() + + def get_index_by_filename(self, filename): + item = self._items_by_filename.get(filename) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def refresh(self): + if not self._published_mode: + self._fill_items() + + def _get_missing_context_item(self): + if self._missing_context_item is None: + message = "Select folder and task" + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._missing_context_item = item + return self._missing_context_item + + def _clear_items(self): + self._remove_missing_context_item() + self._remove_empty_item() + if self._items_by_filename: + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + self._items_by_filename = {} + + def _add_missing_context_item(self): + if self._missing_context_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_missing_context_item()) + self._missing_context_used = True + + def _remove_missing_context_item(self): + if not self._missing_context_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._missing_context_item.row()) + self._missing_context_used = False + + def _get_empty_root_item(self): + if self._empty_root_item is None: + message = "Work Area is empty.." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.exclamation-circle", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._empty_root_item = item + return self._empty_root_item + + def _add_empty_item(self): + if self._empty_item_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_empty_root_item()) + self._empty_item_used = True + + def _remove_empty_item(self): + if not self._empty_item_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._empty_root_item.row()) + self._empty_item_used = False + + def _on_folder_changed(self, event): + self._selected_folder_id = event["folder_id"] + if not self._published_mode: + self._fill_items() + + def _on_task_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + if not self._published_mode: + self._fill_items() + + def _on_duplicate_finished(self, event): + if event["failed"]: + return + + if not self._published_mode: + self._fill_items() + + def _on_save_as_finished(self, event): + if event["failed"]: + return + + if not self._published_mode: + self._fill_items() + + def _fill_items(self): + folder_id = self._selected_folder_id + task_id = self._selected_task_id + if not folder_id or not task_id: + self._add_missing_context_item() + return + + file_items = self._controller.get_workarea_file_items( + folder_id, task_id + ) + root_item = self.invisibleRootItem() + if not file_items: + self._add_empty_item() + return + self._remove_empty_item() + self._remove_missing_context_item() + + items_to_remove = set(self._items_by_filename.keys()) + new_items = [] + for file_item in file_items: + filename = file_item.filename + if filename in self._items_by_filename: + items_to_remove.discard(filename) + item = self._items_by_filename[filename] + else: + item = QtGui.QStandardItem() + new_items.append(item) + item.setColumnCount(self.columnCount()) + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + item.setData(file_item.filename, QtCore.Qt.DisplayRole) + item.setData(file_item.filename, FILENAME_ROLE) + + item.setData(file_item.filepath, FILEPATH_ROLE) + item.setData(file_item.modified, DATE_MODIFIED_ROLE) + + self._items_by_filename[file_item.filename] = item + + if new_items: + root_item.appendRows(new_items) + + for filename in items_to_remove: + item = self._items_by_filename.pop(filename) + root_item.removeRow(item.row()) + + if root_item.rowCount() == 0: + self._add_empty_item() + + def flags(self, index): + # Use flags of first column for all columns + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(WorkAreaFilesModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + # Handle roles for first column + if index.column() == 1: + if role == QtCore.Qt.DecorationRole: + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = DATE_MODIFIED_ROLE + index = self.index(index.row(), 0, index.parent()) + + return super(WorkAreaFilesModel, self).data(index, role) + + def set_published_mode(self, published_mode): + if self._published_mode == published_mode: + return + self._published_mode = published_mode + if not published_mode: + self._fill_items() + + +class WorkAreaFilesWidget(QtWidgets.QWidget): + """Workarea files widget. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + selection_changed = QtCore.Signal() + open_current_requested = QtCore.Signal() + duplicate_requested = QtCore.Signal() + + def __init__(self, controller, parent): + super(WorkAreaFilesWidget, self).__init__(parent) + + view = TreeView(self) + view.setSortingEnabled(True) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # Smaller indentation + view.setIndentation(0) + + model = WorkAreaFilesModel(controller) + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(model) + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setDynamicSortFilter(True) + + view.setModel(proxy_model) + + time_delegate = PrettyTimeDelegate() + view.setItemDelegateForColumn(1, time_delegate) + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + view.setColumnWidth(0, 330) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(view, 1) + + selection_model = view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + view.double_clicked.connect(self._on_mouse_double_click) + view.customContextMenuRequested.connect(self._on_context_menu) + + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + self._view = view + self._model = model + self._proxy_model = proxy_model + self._time_delegate = time_delegate + self._controller = controller + + self._published_mode = False + + def set_published_mode(self, published_mode): + """Set the published mode. + + Widget should ignore most of events when in published mode is enabled. + + Args: + published_mode (bool): The published mode. + """ + + self._model.set_published_mode(published_mode) + self._published_mode = published_mode + + def set_text_filter(self, text_filter): + """Set the text filter. + + Args: + text_filter (str): The text filter. + """ + + self._proxy_model.setFilterFixedString(text_filter) + + def _get_selected_info(self): + selection_model = self._view.selectionModel() + filepath = None + filename = None + for index in selection_model.selectedIndexes(): + filepath = index.data(FILEPATH_ROLE) + filename = index.data(FILENAME_ROLE) + return { + "filepath": filepath, + "filename": filename, + } + + def get_selected_path(self): + """Selected filepath. + + Returns: + Union[str, None]: The selected filepath or None if nothing is + selected. + """ + return self._get_selected_info()["filepath"] + + def _on_selection_change(self): + filepath = self.get_selected_path() + self._controller.set_selected_workfile_path(filepath) + + def _on_mouse_double_click(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.open_current_requested.emit() + + def _on_context_menu(self, point): + index = self._view.indexAt(point) + if not index.isValid(): + return + + if not index.flags() & QtCore.Qt.ItemIsEnabled: + return + + menu = QtWidgets.QMenu(self) + + # Duplicate + action = QtWidgets.QAction("Duplicate", menu) + tip = "Duplicate selected file." + action.setToolTip(tip) + action.setStatusTip(tip) + action.triggered.connect(self._on_duplicate_pressed) + menu.addAction(action) + + # Show the context action menu + global_point = self._view.mapToGlobal(point) + _ = menu.exec_(global_point) + + def _on_duplicate_pressed(self): + self.duplicate_requested.emit() + + def _on_expected_selection_change(self, event): + workfile_info = event["workfile"] + if not workfile_info["current"]: + return + + self._model.refresh() + + workfile_name = workfile_info["name"] + if ( + workfile_name is not None + and workfile_name != self._get_selected_info()["filename"] + ): + index = self._model.get_index_by_filename(workfile_name) + if index.isValid(): + proxy_index = self._proxy_model.mapFromSource(index) + self._view.setCurrentIndex(proxy_index) + + self._controller.expected_workfile_selected( + event["folder"]["id"], event["task"]["name"], workfile_name + ) diff --git a/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py b/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py new file mode 100644 index 0000000000..cdce73f030 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py @@ -0,0 +1,351 @@ +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import PlaceholderLineEdit + + +class SubversionLineEdit(QtWidgets.QWidget): + """QLineEdit with QPushButton for drop down selection of list of strings""" + + text_changed = QtCore.Signal(str) + + def __init__(self, *args, **kwargs): + super(SubversionLineEdit, self).__init__(*args, **kwargs) + + input_field = PlaceholderLineEdit(self) + menu_btn = QtWidgets.QPushButton(self) + menu_btn.setFixedWidth(18) + + menu = QtWidgets.QMenu(self) + menu_btn.setMenu(menu) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + layout.addWidget(input_field, 1) + layout.addWidget(menu_btn, 0) + + input_field.textChanged.connect(self.text_changed) + + self.setFocusProxy(input_field) + + self._input_field = input_field + self._menu_btn = menu_btn + self._menu = menu + + def set_placeholder(self, placeholder): + self._input_field.setPlaceholderText(placeholder) + + def set_text(self, text): + self._input_field.setText(text) + + def set_values(self, values): + self._update(values) + + def _on_button_clicked(self): + self._menu.exec_() + + def _on_action_clicked(self, action): + self._input_field.setText(action.text()) + + def _update(self, values): + """Create optional predefined subset names + + Args: + default_names(list): all predefined names + + Returns: + None + """ + + menu = self._menu + button = self._menu_btn + + state = any(values) + button.setEnabled(state) + if state is False: + return + + # Include an empty string + values = [""] + sorted(values) + + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + # Build new action group + group = QtWidgets.QActionGroup(button) + for name in values: + action = group.addAction(name) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + + +class SaveAsDialog(QtWidgets.QDialog): + """Save as dialog to define a unique filename inside workdir. + + The filename is calculated in controller where UI sends values from + dialog inputs. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller, parent): + super(SaveAsDialog, self).__init__(parent=parent) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) + + self._controller = controller + + self._folder_id = None + self._task_id = None + self._last_version = None + self._template_key = None + self._comment_value = None + self._version_value = None + self._ext_value = None + self._filename = None + self._workdir = None + + self._result = None + + # Btns widget + btns_widget = QtWidgets.QWidget(self) + + btn_ok = QtWidgets.QPushButton("Ok", btns_widget) + btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.addWidget(btn_ok) + btns_layout.addWidget(btn_cancel) + + # Inputs widget + inputs_widget = QtWidgets.QWidget(self) + + # Version widget + version_widget = QtWidgets.QWidget(inputs_widget) + + # Version number input + version_input = QtWidgets.QSpinBox(version_widget) + version_input.setMinimum(1) + version_input.setMaximum(9999) + + # Last version checkbox + last_version_check = QtWidgets.QCheckBox( + "Next Available Version", version_widget + ) + last_version_check.setChecked(True) + + version_layout = QtWidgets.QHBoxLayout(version_widget) + version_layout.setContentsMargins(0, 0, 0, 0) + version_layout.addWidget(version_input) + version_layout.addWidget(last_version_check) + + # Preview widget + preview_widget = QtWidgets.QLabel("Preview filename", inputs_widget) + preview_widget.setWordWrap(True) + + # Subversion input + subversion_input = SubversionLineEdit(inputs_widget) + subversion_input.set_placeholder("Will be part of filename.") + + # Extensions combobox + extension_combobox = QtWidgets.QComboBox(inputs_widget) + # Add styled delegate to use stylesheets + extension_delegate = QtWidgets.QStyledItemDelegate() + extension_combobox.setItemDelegate(extension_delegate) + + version_label = QtWidgets.QLabel("Version:", inputs_widget) + subversion_label = QtWidgets.QLabel("Subversion:", inputs_widget) + extension_label = QtWidgets.QLabel("Extension:", inputs_widget) + preview_label = QtWidgets.QLabel("Preview:", inputs_widget) + + # Build inputs + inputs_layout = QtWidgets.QGridLayout(inputs_widget) + inputs_layout.addWidget(version_label, 0, 0) + inputs_layout.addWidget(version_widget, 0, 1) + inputs_layout.addWidget(subversion_label, 1, 0) + inputs_layout.addWidget(subversion_input, 1, 1) + inputs_layout.addWidget(extension_label, 2, 0) + inputs_layout.addWidget(extension_combobox, 2, 1) + inputs_layout.addWidget(preview_label, 3, 0) + inputs_layout.addWidget(preview_widget, 3, 1) + + # Build layout + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget) + main_layout.addWidget(btns_widget) + + # Signal callback registration + version_input.valueChanged.connect(self._on_version_spinbox_change) + last_version_check.stateChanged.connect( + self._on_version_checkbox_change + ) + + subversion_input.text_changed.connect(self._on_comment_change) + extension_combobox.currentIndexChanged.connect( + self._on_extension_change) + + btn_ok.pressed.connect(self._on_ok_pressed) + btn_cancel.pressed.connect(self._on_cancel_pressed) + + # Store objects + self._inputs_layout = inputs_layout + + self._btn_ok = btn_ok + self._btn_cancel = btn_cancel + + self._version_widget = version_widget + + self._version_input = version_input + self._last_version_check = last_version_check + + self._extension_delegate = extension_delegate + self._extension_combobox = extension_combobox + self._subversion_input = subversion_input + self._preview_widget = preview_widget + + self._version_label = version_label + self._subversion_label = subversion_label + self._extension_label = extension_label + self._preview_label = preview_label + + # Post init setup + + # Allow "Enter" key to accept the save. + btn_ok.setDefault(True) + + # Disable version input if last version is checked + version_input.setEnabled(not last_version_check.isChecked()) + + # Force default focus to comment, some hosts didn't automatically + # apply focus to this line edit (e.g. Houdini) + subversion_input.setFocus() + + def get_result(self): + return self._result + + def update_context(self): + # Add version only if template contains version key + # - since the version can be padded with "{version:0>4}" we only search + # for "{version". + selected_context = self._controller.get_selected_context() + folder_id = selected_context["folder_id"] + task_id = selected_context["task_id"] + data = self._controller.get_workarea_save_as_data(folder_id, task_id) + last_version = data["last_version"] + comment = data["comment"] + comment_hints = data["comment_hints"] + + template_has_version = data["template_has_version"] + template_has_comment = data["template_has_comment"] + + self._folder_id = folder_id + self._task_id = task_id + self._workdir = data["workdir"] + self._comment_value = data["comment"] + self._ext_value = data["ext"] + self._template_key = data["template_key"] + self._last_version = data["last_version"] + + self._extension_combobox.clear() + self._extension_combobox.addItems(data["extensions"]) + + self._version_input.setValue(last_version) + + vw_idx = self._inputs_layout.indexOf(self._version_widget) + self._version_label.setVisible(template_has_version) + self._version_widget.setVisible(template_has_version) + if template_has_version: + if vw_idx == -1: + self._inputs_layout.addWidget(self._version_label, 0, 0) + self._inputs_layout.addWidget(self._version_widget, 0, 1) + elif vw_idx != -1: + self._inputs_layout.takeAt(vw_idx) + self._inputs_layout.takeAt( + self._inputs_layout.indexOf(self._version_label) + ) + + cw_idx = self._inputs_layout.indexOf(self._subversion_input) + self._subversion_label.setVisible(template_has_comment) + self._subversion_input.setVisible(template_has_comment) + if template_has_comment: + if cw_idx == -1: + self._inputs_layout.addWidget(self._subversion_label, 1, 0) + self._inputs_layout.addWidget(self._subversion_input, 1, 1) + elif cw_idx != -1: + self._inputs_layout.takeAt(cw_idx) + self._inputs_layout.takeAt( + self._inputs_layout.indexOf(self._subversion_label) + ) + + if template_has_comment: + self._subversion_input.set_text(comment or "") + self._subversion_input.set_values(comment_hints) + self._update_filename() + + def _on_version_spinbox_change(self, value): + if value == self._version_value: + return + self._version_value = value + if not self._last_version_check.isChecked(): + self._update_filename() + + def _on_version_checkbox_change(self): + use_last_version = self._last_version_check.isChecked() + self._version_input.setEnabled(not use_last_version) + if use_last_version: + self._version_input.blockSignals(True) + self._version_input.setValue(self._last_version) + self._version_input.blockSignals(False) + self._update_filename() + + def _on_comment_change(self, text): + if self._comment_value == text: + return + self._comment_value = text + self._update_filename() + + def _on_extension_change(self): + ext = self._extension_combobox.currentText() + if ext == self._ext_value: + return + self._ext_value = ext + self._update_filename() + + def _on_ok_pressed(self): + self._result = { + "filename": self._filename, + "workdir": self._workdir, + "folder_id": self._folder_id, + "task_id": self._task_id, + "template_key": self._template_key, + } + self.close() + + def _on_cancel_pressed(self): + self.close() + + def _update_filename(self): + result = self._controller.fill_workarea_filepath( + self._folder_id, + self._task_id, + self._ext_value, + self._last_version_check.isChecked(), + self._version_value, + self._comment_value, + ) + self._filename = result.filename + self._btn_ok.setEnabled(not result.exists) + + if result.exists: + self._preview_widget.setText(( + "Cannot create \"{}\" because file exists!" + "" + ).format(result.filename)) + else: + self._preview_widget.setText( + "{}".format(result.filename) + ) diff --git a/openpype/tools/ayon_workfiles/widgets/side_panel.py b/openpype/tools/ayon_workfiles/widgets/side_panel.py new file mode 100644 index 0000000000..5085f4701e --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/side_panel.py @@ -0,0 +1,163 @@ +import datetime + +from qtpy import QtWidgets, QtCore + + +def file_size_to_string(file_size): + size = 0 + size_ending_mapping = { + "KB": 1024 ** 1, + "MB": 1024 ** 2, + "GB": 1024 ** 3 + } + ending = "B" + for _ending, _size in size_ending_mapping.items(): + if file_size < _size: + break + size = file_size / _size + ending = _ending + return "{:.2f} {}".format(size, ending) + + +class SidePanelWidget(QtWidgets.QWidget): + """Details about selected workfile. + + Todos: + At this moment only shows created and modified date of file + or its size. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + published_workfile_message = ( + "INFO: Opened published workfiles will be stored in" + " temp directory on your machine. Current temp size: {}." + ) + + def __init__(self, controller, parent): + super(SidePanelWidget, self).__init__(parent) + + details_label = QtWidgets.QLabel("Details", self) + details_input = QtWidgets.QPlainTextEdit(self) + details_input.setReadOnly(True) + + artist_note_widget = QtWidgets.QWidget(self) + note_label = QtWidgets.QLabel("Artist note", artist_note_widget) + note_input = QtWidgets.QPlainTextEdit(artist_note_widget) + btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget) + + artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget) + artist_note_layout.setContentsMargins(0, 0, 0, 0) + artist_note_layout.addWidget(note_label, 0) + artist_note_layout.addWidget(note_input, 1) + artist_note_layout.addWidget( + btn_note_save, 0, alignment=QtCore.Qt.AlignRight + ) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(details_label, 0) + main_layout.addWidget(details_input, 1) + main_layout.addWidget(artist_note_widget, 1) + + note_input.textChanged.connect(self._on_note_change) + btn_note_save.clicked.connect(self._on_save_click) + + controller.register_event_callback( + "selection.workarea.changed", self._on_selection_change + ) + + self._details_input = details_input + self._artist_note_widget = artist_note_widget + self._note_input = note_input + self._btn_note_save = btn_note_save + + self._folder_id = None + self._task_id = None + self._filepath = None + self._orig_note = "" + self._controller = controller + + self._set_context(None, None, None) + + def set_published_mode(self, published_mode): + """Change published mode. + + Args: + published_mode (bool): Published mode enabled. + """ + + self._artist_note_widget.setVisible(not published_mode) + + def _on_selection_change(self, event): + folder_id = event["folder_id"] + task_id = event["task_id"] + filepath = event["path"] + + self._set_context(folder_id, task_id, filepath) + + def _on_note_change(self): + text = self._note_input.toPlainText() + self._btn_note_save.setEnabled(self._orig_note != text) + + def _on_save_click(self): + note = self._note_input.toPlainText() + self._controller.save_workfile_info( + self._folder_id, + self._task_id, + self._filepath, + note + ) + self._orig_note = note + self._btn_note_save.setEnabled(False) + + def _set_context(self, folder_id, task_id, filepath): + workfile_info = None + # Check if folder, task and file are selected + if bool(folder_id) and bool(task_id) and bool(filepath): + workfile_info = self._controller.get_workfile_info( + folder_id, task_id, filepath + ) + enabled = workfile_info is not None + + self._details_input.setEnabled(enabled) + self._note_input.setEnabled(enabled) + self._btn_note_save.setEnabled(enabled) + + self._folder_id = folder_id + self._task_id = task_id + self._filepath = filepath + + # Disable inputs and remove texts if any required arguments are + # missing + if not enabled: + self._orig_note = "" + self._details_input.setPlainText("") + self._note_input.setPlainText("") + return + + note = workfile_info.note + size_value = file_size_to_string(workfile_info.filesize) + + # Append html string + datetime_format = "%b %d %Y %H:%M:%S" + creation_time = datetime.datetime.fromtimestamp( + workfile_info.creation_time) + modification_time = datetime.datetime.fromtimestamp( + workfile_info.modification_time) + lines = ( + "Size:", + size_value, + "Created:", + creation_time.strftime(datetime_format), + "Modified:", + modification_time.strftime(datetime_format) + ) + self._orig_note = note + self._note_input.setPlainText(note) + + # Set as empty string + self._details_input.setPlainText("") + self._details_input.appendHtml("
".join(lines)) diff --git a/openpype/tools/ayon_workfiles/widgets/utils.py b/openpype/tools/ayon_workfiles/widgets/utils.py new file mode 100644 index 0000000000..9171638546 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/utils.py @@ -0,0 +1,28 @@ +from qtpy import QtWidgets, QtCore + + +class BaseOverlayFrame(QtWidgets.QFrame): + """Base frame for overlay widgets. + + Has implemented automated resize and event filtering. + """ + + def __init__(self, parent): + super(BaseOverlayFrame, self).__init__(parent) + self.setObjectName("OverlayFrame") + + self._parent = parent + + def setVisible(self, visible): + super(BaseOverlayFrame, self).setVisible(visible) + if visible: + self._parent.installEventFilter(self) + self.resize(self._parent.size()) + else: + self._parent.removeEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Resize: + self.resize(obj.size()) + + return super(BaseOverlayFrame, self).eventFilter(obj, event) diff --git a/openpype/tools/ayon_workfiles/widgets/window.py b/openpype/tools/ayon_workfiles/widgets/window.py new file mode 100644 index 0000000000..eb2f2bc1c7 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/window.py @@ -0,0 +1,383 @@ +from qtpy import QtCore, QtWidgets, QtGui + +from openpype import style, resources +from openpype.tools.utils import ( + PlaceholderLineEdit, + MessageOverlayObject, +) + +from openpype.tools.ayon_utils.widgets import FoldersWidget, TasksWidget +from openpype.tools.ayon_workfiles.control import BaseWorkfileController +from openpype.tools.utils import GoToCurrentButton, RefreshButton + +from .side_panel import SidePanelWidget +from .files_widget import FilesWidget +from .utils import BaseOverlayFrame + + +class InvalidHostOverlay(BaseOverlayFrame): + def __init__(self, parent): + super(InvalidHostOverlay, self).__init__(parent) + + label_widget = QtWidgets.QLabel( + ( + "Workfiles tool is not supported in this host/DCCs." + "

This may be caused by a bug." + " Please contact your TD for more information." + ), + self + ) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + label_widget.setObjectName("OverlayFrameLabel") + + layout = QtWidgets.QVBoxLayout(self) + layout.addStretch(2) + layout.addWidget(label_widget, 0, QtCore.Qt.AlignCenter) + layout.addStretch(3) + + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + +class WorkfilesToolWindow(QtWidgets.QWidget): + """WorkFiles Window. + + Main windows of workfiles tool. + + Args: + controller (AbstractWorkfilesFrontend): Frontend controller. + parent (Optional[QtWidgets.QWidget]): Parent widget. + """ + + title = "Work Files" + + def __init__(self, controller=None, parent=None): + super(WorkfilesToolWindow, self).__init__(parent=parent) + + if controller is None: + controller = BaseWorkfileController() + + self.setWindowTitle(self.title) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + flags = self.windowFlags() | QtCore.Qt.Window + self.setWindowFlags(flags) + + self._default_window_flags = flags + + self._folders_widget = None + self._folder_filter_input = None + + self._files_widget = None + + self._first_show = True + self._controller_refreshed = False + self._context_to_set = None + # Host validation should happen only once + self._host_is_valid = None + + self._controller = controller + + # Create pages widget and set it as central widget + pages_widget = QtWidgets.QStackedWidget(self) + + home_page_widget = QtWidgets.QWidget(pages_widget) + home_body_widget = QtWidgets.QWidget(home_page_widget) + + col_1_widget = self._create_col_1_widget(controller, parent) + tasks_widget = TasksWidget( + controller, home_body_widget, handle_expected_selection=True + ) + col_3_widget = self._create_col_3_widget(controller, home_body_widget) + side_panel = SidePanelWidget(controller, home_body_widget) + + pages_widget.addWidget(home_page_widget) + + # Build home + home_page_layout = QtWidgets.QVBoxLayout(home_page_widget) + home_page_layout.addWidget(home_body_widget) + + # Build home - body + body_layout = QtWidgets.QVBoxLayout(home_body_widget) + split_widget = QtWidgets.QSplitter(home_body_widget) + split_widget.addWidget(col_1_widget) + split_widget.addWidget(tasks_widget) + split_widget.addWidget(col_3_widget) + split_widget.addWidget(side_panel) + split_widget.setSizes([255, 160, 455, 175]) + + body_layout.addWidget(split_widget) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.addWidget(pages_widget, 1) + + overlay_messages_widget = MessageOverlayObject(self) + overlay_invalid_host = InvalidHostOverlay(self) + overlay_invalid_host.setVisible(False) + + first_show_timer = QtCore.QTimer() + first_show_timer.setSingleShot(True) + first_show_timer.setInterval(50) + + first_show_timer.timeout.connect(self._on_first_show) + + controller.register_event_callback( + "save_as.finished", + self._on_save_as_finished, + ) + controller.register_event_callback( + "copy_representation.finished", + self._on_copy_representation_finished, + ) + controller.register_event_callback( + "workfile_duplicate.finished", + self._on_duplicate_finished + ) + controller.register_event_callback( + "open_workfile.finished", + self._on_open_finished + ) + controller.register_event_callback( + "controller.reset.started", + self._on_controller_refresh_started, + ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_refresh_finished, + ) + + self._overlay_messages_widget = overlay_messages_widget + self._overlay_invalid_host = overlay_invalid_host + self._home_page_widget = home_page_widget + self._pages_widget = pages_widget + self._home_body_widget = home_body_widget + self._split_widget = split_widget + + self._tasks_widget = tasks_widget + self._side_panel = side_panel + + self._first_show_timer = first_show_timer + + self._post_init() + + def _post_init(self): + self._on_published_checkbox_changed() + + # Force focus on the open button by default, required for Houdini. + self._files_widget.setFocus() + + self.resize(1200, 600) + + def _create_col_1_widget(self, controller, parent): + col_widget = QtWidgets.QWidget(parent) + header_widget = QtWidgets.QWidget(col_widget) + + folder_filter_input = PlaceholderLineEdit(header_widget) + folder_filter_input.setPlaceholderText("Filter folders..") + + go_to_current_btn = GoToCurrentButton(header_widget) + refresh_btn = RefreshButton(header_widget) + + folder_widget = FoldersWidget( + controller, col_widget, handle_expected_selection=True + ) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(folder_filter_input, 1) + header_layout.addWidget(go_to_current_btn, 0) + header_layout.addWidget(refresh_btn, 0) + + col_layout = QtWidgets.QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.addWidget(header_widget, 0) + col_layout.addWidget(folder_widget, 1) + + folder_filter_input.textChanged.connect(self._on_folder_filter_change) + go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) + refresh_btn.clicked.connect(self._on_refresh_clicked) + + self._folder_filter_input = folder_filter_input + self._folders_widget = folder_widget + + return col_widget + + def _create_col_3_widget(self, controller, parent): + col_widget = QtWidgets.QWidget(parent) + + header_widget = QtWidgets.QWidget(col_widget) + + files_filter_input = PlaceholderLineEdit(header_widget) + files_filter_input.setPlaceholderText("Filter files..") + + published_checkbox = QtWidgets.QCheckBox("Published", header_widget) + published_checkbox.setToolTip("Show published workfiles") + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(files_filter_input, 1) + header_layout.addWidget(published_checkbox, 0) + + files_widget = FilesWidget(controller, col_widget) + + col_layout = QtWidgets.QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.addWidget(header_widget, 0) + col_layout.addWidget(files_widget, 1) + + files_filter_input.textChanged.connect( + self._on_file_text_filter_change) + published_checkbox.stateChanged.connect( + self._on_published_checkbox_changed + ) + + self._files_filter_input = files_filter_input + self._published_checkbox = published_checkbox + + self._files_widget = files_widget + + return col_widget + + def set_window_on_top(self, on_top): + """Set window on top of other windows. + + Args: + on_top (bool): Show on top of other windows. + """ + + flags = self._default_window_flags + if on_top: + flags |= QtCore.Qt.WindowStaysOnTopHint + if self.windowFlags() != flags: + self.setWindowFlags(flags) + + def ensure_visible(self, use_context=True, save=True, on_top=False): + """Ensure the window is visible. + + This method expects arguments for compatibility with previous variant + of Workfiles tool. + + Args: + use_context (Optional[bool]): DEPRECATED: This argument is + ignored. + save (Optional[bool]): Allow to save workfiles. + on_top (Optional[bool]): Show on top of other windows. + """ + + save = True if save is None else save + on_top = False if on_top is None else on_top + + is_visible = self.isVisible() + self._controller.set_save_enabled(save) + self.set_window_on_top(on_top) + + self.show() + self.raise_() + self.activateWindow() + if is_visible: + self.refresh() + + def refresh(self): + """Trigger refresh of workfiles tool controller.""" + + self._controller.reset() + + def showEvent(self, event): + super(WorkfilesToolWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self._first_show_timer.start() + self.setStyleSheet(style.load_stylesheet()) + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidentally perform Maya commands + whilst trying to name an instance. + """ + + pass + + def _on_first_show(self): + if not self._controller_refreshed: + self.refresh() + + def _on_file_text_filter_change(self, text): + self._files_widget.set_text_filter(text) + + def _on_published_checkbox_changed(self): + """Publish mode changed. + + Tell children widgets about it so they can handle the mode. + """ + + published_mode = self._published_checkbox.isChecked() + self._files_widget.set_published_mode(published_mode) + self._side_panel.set_published_mode(published_mode) + + def _on_folder_filter_change(self, text): + self._folders_widget.set_name_filter(text) + + def _on_go_to_current_clicked(self): + self._controller.go_to_current_context() + + def _on_refresh_clicked(self): + self.refresh() + + def _on_controller_refresh_started(self): + self._controller_refreshed = True + + def _on_controller_refresh_finished(self): + if self._host_is_valid is None: + self._host_is_valid = self._controller.is_host_valid() + self._overlay_invalid_host.setVisible(not self._host_is_valid) + + if not self._host_is_valid: + return + + self._folders_widget.set_project_name( + self._controller.get_current_project_name() + ) + + def _on_save_as_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to save workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Workfile saved" + ) + + def _on_copy_representation_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to copy published workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Publish workfile saved" + ) + + def _on_duplicate_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to duplicate workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Workfile duplicated" + ) + + def _on_open_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to open workfile", + "error", + ) + else: + self.close() diff --git a/openpype/tools/context_dialog/__init__.py b/openpype/tools/context_dialog/__init__.py index 9b10baf903..15b90463da 100644 --- a/openpype/tools/context_dialog/__init__.py +++ b/openpype/tools/context_dialog/__init__.py @@ -1,10 +1,12 @@ -from .window import ( - ContextDialog, - main -) +from openpype import AYON_SERVER_ENABLED + +if AYON_SERVER_ENABLED: + from ._ayon_window import ContextDialog, main +else: + from ._openpype_window import ContextDialog, main __all__ = ( "ContextDialog", - "main" + "main", ) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py new file mode 100644 index 0000000000..f347978392 --- /dev/null +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -0,0 +1,799 @@ +import os +import json + +import ayon_api +from qtpy import QtWidgets, QtCore, QtGui + +from openpype import style +from openpype.lib.events import QueuedEventSystem +from openpype.tools.ayon_utils.models import ( + ProjectsModel, + HierarchyModel, +) +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) +from openpype.tools.utils.lib import ( + center_window, + get_openpype_qt_app, +) + + +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_id = None + self._task_name = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) + + +class ExpectedSelection: + def __init__(self, controller): + self._project_name = None + self._folder_id = None + + self._project_selected = True + self._folder_selected = True + + self._controller = controller + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) + + def set_expected_selection(self, project_name, folder_id): + self._project_name = project_name + self._folder_id = folder_id + + self._project_selected = False + self._folder_selected = False + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + return { + "project": { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + }, + "folder": { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + }, + } + + def is_expected_project_selected(self, project_name): + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + +class ContextDialogController: + def __init__(self): + self._event_system = None + + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._selection_model = SelectionModel(self) + self._expected_selection = ExpectedSelection(self) + + self._confirmed = False + self._is_strict = False + self._output_path = None + + self._initial_project_name = None + self._initial_folder_id = None + self._initial_folder_label = None + self._initial_project_found = True + self._initial_folder_found = True + self._initial_tasks_found = True + + def reset(self): + self._emit_event("controller.reset.started") + + self._confirmed = False + self._output_path = None + + self._initial_project_name = None + self._initial_folder_id = None + self._initial_folder_label = None + self._initial_project_found = True + self._initial_folder_found = True + self._initial_tasks_found = True + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._emit_event("controller.reset.finished") + + def refresh(self): + self._emit_event("controller.refresh.started") + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._emit_event("controller.refresh.finished") + + # Event handling + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._get_event_system().emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._get_event_system().add_callback(topic, callback) + + def set_output_json_path(self, output_path): + self._output_path = output_path + + def is_strict(self): + return self._is_strict + + def set_strict(self, enabled): + if self._is_strict is enabled: + return + self._is_strict = enabled + self._emit_event("strict.changed", {"strict": enabled}) + + # Data model functions + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) + + # Expected selection helpers + def set_expected_selection(self, project_name, folder_id): + return self._expected_selection.set_expected_selection( + project_name, folder_id + ) + + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def expected_project_selected(self, project_name): + self._expected_selection.expected_project_selected(project_name) + + def expected_folder_selected(self, folder_id): + self._expected_selection.expected_folder_selected(folder_id) + + # Selection handling + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + def is_initial_context_valid(self): + return self._initial_folder_found and self._initial_project_found + + def set_initial_context(self, project_name=None, asset_name=None): + result = self._prepare_initial_context(project_name, asset_name) + + self._initial_project_name = project_name + self._initial_folder_id = result["folder_id"] + self._initial_folder_label = result["folder_label"] + self._initial_project_found = result["project_found"] + self._initial_folder_found = result["folder_found"] + self._initial_tasks_found = result["tasks_found"] + self._emit_event( + "initial.context.changed", + self.get_initial_context() + ) + + def get_initial_context(self): + return { + "project_name": self._initial_project_name, + "folder_id": self._initial_folder_id, + "folder_label": self._initial_folder_label, + "project_found": self._initial_project_found, + "folder_found": self._initial_folder_found, + "tasks_found": self._initial_tasks_found, + "valid": ( + self._initial_project_found + and self._initial_folder_found + and self._initial_tasks_found + ) + } + + # Result of this tool + def get_selected_context(self): + project_name = None + folder_id = None + task_id = None + task_name = None + folder_path = None + folder_name = None + if self._confirmed: + project_name = self.get_selected_project_name() + folder_id = self.get_selected_folder_id() + task_id = self.get_selected_task_id() + task_name = self.get_selected_task_name() + + folder_item = None + if folder_id: + folder_item = self._hierarchy_model.get_folder_item( + project_name, folder_id) + + if folder_item: + folder_path = folder_item.path + folder_name = folder_item.name + return { + "project": project_name, + "project_name": project_name, + "asset": folder_name, + "folder_id": folder_id, + "folder_path": folder_path, + "task": task_name, + "task_name": task_name, + "task_id": task_id, + "initial_context_valid": self.is_initial_context_valid(), + } + + def confirm_selection(self): + self._confirmed = True + + def store_output(self): + if not self._output_path: + return + + dirpath = os.path.dirname(self._output_path) + os.makedirs(dirpath, exist_ok=True) + with open(self._output_path, "w") as stream: + json.dump(self.get_selected_context(), stream, indent=4) + + def _prepare_initial_context(self, project_name, asset_name): + project_found = True + output = { + "project_found": project_found, + "folder_id": None, + "folder_label": None, + "folder_found": True, + "tasks_found": True, + } + if project_name is None: + asset_name = None + else: + project = ayon_api.get_project(project_name) + project_found = project is not None + output["project_found"] = project_found + if not project_found or not asset_name: + return output + + output["folder_label"] = asset_name + + folder_id = None + folder_found = False + # First try to find by path + folder = ayon_api.get_folder_by_path(project_name, asset_name) + # Try to find by name if folder was not found by path + # - prevent to query by name if 'asset_name' contains '/' + if not folder and "/" not in asset_name: + folder = next( + ayon_api.get_folders( + project_name, folder_names=[asset_name], fields=["id"]), + None + ) + + if folder: + folder_id = folder["id"] + folder_found = True + + output["folder_id"] = folder_id + output["folder_found"] = folder_found + if not folder_found: + return output + + tasks = list(ayon_api.get_tasks( + project_name, folder_ids=[folder_id], fields=["id"] + )) + output["tasks_found"] = bool(tasks) + return output + + def _get_event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") + + +class InvalidContextOverlay(QtWidgets.QFrame): + confirmed = QtCore.Signal() + + def __init__(self, parent): + super(InvalidContextOverlay, self).__init__(parent) + self.setObjectName("OverlayFrame") + + mid_widget = QtWidgets.QWidget(self) + label_widget = QtWidgets.QLabel( + "Requested context was not found...", + mid_widget + ) + + confirm_btn = QtWidgets.QPushButton("Close", mid_widget) + + mid_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + mid_layout = QtWidgets.QVBoxLayout(mid_widget) + mid_layout.setContentsMargins(0, 0, 0, 0) + mid_layout.addWidget(label_widget, 0) + mid_layout.addSpacing(30) + mid_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(mid_widget, 1, 1) + main_layout.setRowStretch(0, 1) + main_layout.setRowStretch(1, 0) + main_layout.setRowStretch(2, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 0) + main_layout.setColumnStretch(2, 1) + + confirm_btn.clicked.connect(self.confirmed) + + self._label_widget = label_widget + self._confirm_btn = confirm_btn + + def set_context( + self, + project_name, + folder_label, + project_found, + folder_found, + tasks_found, + ): + lines = [] + if not project_found: + lines.extend([ + "Requested project '{}' was not found...".format( + project_name), + ]) + + elif not folder_found: + lines.extend([ + "Requested folder was not found...", + "", + "Project: {}".format(project_name), + "Folder: {}".format(folder_label), + ]) + elif not tasks_found: + lines.extend([ + "Requested folder does not have any tasks...", + "", + "Project: {}".format(project_name), + "Folder: {}".format(folder_label), + ]) + else: + lines.append("Requested context was not found...") + self._label_widget.setText("
".join(lines)) + + +class ContextDialog(QtWidgets.QDialog): + """Dialog to select a context. + + Context has 3 parts: + - Project + - Asset + - Task + + It is possible to predefine project and asset. In that case their widgets + will have passed preselected values and will be disabled. + """ + def __init__(self, controller=None, parent=None): + super(ContextDialog, self).__init__(parent) + + self.setWindowTitle("Select Context") + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + + if controller is None: + controller = ContextDialogController() + + # Enable minimize and maximize for app + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # Left side widget contains project combobox and asset widget + left_side_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = ProjectsCombobox( + controller, + parent=left_side_widget, + handle_expected_selection=True + ) + project_combobox.set_select_item_visible(True) + + # Assets widget + folders_widget = FoldersWidget( + controller, + parent=left_side_widget, + handle_expected_selection=True + ) + + left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) + left_side_layout.setContentsMargins(0, 0, 0, 0) + left_side_layout.addWidget(project_combobox, 0) + left_side_layout.addWidget(folders_widget, 1) + + # Right side of window contains only tasks + tasks_widget = TasksWidget(controller, parent=main_splitter) + + # Add widgets to main splitter + main_splitter.addWidget(left_side_widget) + main_splitter.addWidget(tasks_widget) + + # Set stretch of both sides + main_splitter.setStretchFactor(0, 7) + main_splitter.setStretchFactor(1, 3) + + # Add confimation button to bottom right + ok_btn = QtWidgets.QPushButton("OK", self) + + buttons_layout = QtWidgets.QHBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(main_splitter, 1) + main_layout.addLayout(buttons_layout, 0) + + overlay_widget = InvalidContextOverlay(self) + overlay_widget.setVisible(False) + + ok_btn.clicked.connect(self._on_ok_click) + project_combobox.refreshed.connect(self._on_projects_refresh) + overlay_widget.confirmed.connect(self._on_overlay_confirm) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_selection_change + ) + controller.register_event_callback( + "selection.task.changed", + self._on_task_selection_change + ) + controller.register_event_callback( + "initial.context.changed", + self._on_init_context_change + ) + controller.register_event_callback( + "strict.changed", + self._on_strict_changed + ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + + # Set stylehseet and resize window on first show + self._first_show = True + self._visible = False + + self._controller = controller + + self._project_combobox = project_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + + self._ok_btn = ok_btn + + self._overlay_widget = overlay_widget + + self._apply_strict_changes(self.is_strict()) + + def is_strict(self): + return self._controller.is_strict() + + def showEvent(self, event): + """Override show event to do some callbacks.""" + super(ContextDialog, self).showEvent(event) + self._visible = True + + if self._first_show: + self._first_show = False + # Set stylesheet and resize + self.setStyleSheet(style.load_stylesheet()) + self.resize(600, 700) + center_window(self) + self._controller.refresh() + + initial_context = self._controller.get_initial_context() + self._set_init_context(initial_context) + self._overlay_widget.resize(self.size()) + + def resizeEvent(self, event): + super(ContextDialog, self).resizeEvent(event) + self._overlay_widget.resize(self.size()) + + def closeEvent(self, event): + """Ignore close event if is in strict state and context is not done.""" + if self.is_strict() and not self._ok_btn.isEnabled(): + # Allow to close window when initial context is not valid + if self._controller.is_initial_context_valid(): + event.ignore() + return + + if self.is_strict(): + self._confirm_selection() + self._visible = False + super(ContextDialog, self).closeEvent(event) + + def set_strict(self, enabled): + """Change strictness of dialog.""" + + self._controller.set_strict(enabled) + + def refresh(self): + """Refresh all widget one by one. + + When asset refresh is triggered we have to wait when is done so + this method continues with `_on_asset_widget_refresh_finished`. + """ + + self._controller.reset() + + def get_context(self): + """Result of dialog.""" + return self._controller.get_selected_context() + + def set_context(self, project_name=None, asset_name=None): + """Set context which will be used and locked in dialog.""" + + self._controller.set_initial_context(project_name, asset_name) + + def _on_projects_refresh(self): + initial_context = self._controller.get_initial_context() + self._controller.set_expected_selection( + initial_context["project_name"], + initial_context["folder_id"] + ) + + def _on_overlay_confirm(self): + self.close() + + def _on_ok_click(self): + # Store values to output + self._confirm_selection() + # Close dialog + self.accept() + + def _confirm_selection(self): + self._controller.confirm_selection() + + def _on_project_selection_change(self, event): + self._on_selection_change( + event["project_name"], + ) + + def _on_folder_selection_change(self, event): + self._on_selection_change( + event["project_name"], + event["folder_id"], + ) + + def _on_task_selection_change(self, event): + self._on_selection_change( + event["project_name"], + event["folder_id"], + event["task_name"], + ) + + def _on_selection_change( + self, project_name, folder_id=None, task_name=None + ): + self._validate_strict(project_name, folder_id, task_name) + + def _on_init_context_change(self, event): + self._set_init_context(event.data) + if self._visible: + self._controller.set_expected_selection( + event["project_name"], event["folder_id"] + ) + + def _set_init_context(self, init_context): + project_name = init_context["project_name"] + if not init_context["valid"]: + self._overlay_widget.setVisible(True) + self._overlay_widget.set_context( + project_name, + init_context["folder_label"], + init_context["project_found"], + init_context["folder_found"], + init_context["tasks_found"] + ) + return + + self._overlay_widget.setVisible(False) + if project_name: + self._project_combobox.setEnabled(False) + if init_context["folder_id"]: + self._folders_widget.setEnabled(False) + else: + self._project_combobox.setEnabled(True) + self._folders_widget.setEnabled(True) + + def _on_strict_changed(self, event): + self._apply_strict_changes(event["strict"]) + + def _on_controller_reset(self): + self._apply_strict_changes(self.is_strict()) + self._project_combobox.refresh() + + def _on_controller_refresh(self): + self._project_combobox.refresh() + + def _apply_strict_changes(self, is_strict): + if not is_strict: + if not self._ok_btn.isEnabled(): + self._ok_btn.setEnabled(True) + return + context = self._controller.get_selected_context() + self._validate_strict( + context["project_name"], + context["folder_id"], + context["task_name"] + ) + + def _validate_strict(self, project_name, folder_id, task_name): + if not self.is_strict(): + return + + enabled = True + if not project_name or not folder_id or not task_name: + enabled = False + self._ok_btn.setEnabled(enabled) + + +def main( + path_to_store, + project_name=None, + asset_name=None, + strict=True +): + # Run Qt application + app = get_openpype_qt_app() + controller = ContextDialogController() + controller.set_strict(strict) + controller.set_initial_context(project_name, asset_name) + controller.set_output_json_path(path_to_store) + window = ContextDialog(controller=controller) + window.show() + app.exec_() + controller.store_output() diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/_openpype_window.py similarity index 99% rename from openpype/tools/context_dialog/window.py rename to openpype/tools/context_dialog/_openpype_window.py index 4fe41c9949..d370772a7f 100644 --- a/openpype/tools/context_dialog/window.py +++ b/openpype/tools/context_dialog/_openpype_window.py @@ -22,7 +22,7 @@ class ContextDialog(QtWidgets.QDialog): Context has 3 parts: - Project - - Aseet + - Asset - Task It is possible to predefine project and asset. In that case their widgets @@ -268,7 +268,7 @@ class ContextDialog(QtWidgets.QDialog): if self._set_context_asset: self._dbcon.Session["AVALON_ASSET"] = self._set_context_asset self._assets_widget.setEnabled(False) - self._assets_widget.select_assets(self._set_context_asset) + self._assets_widget.select_asset_by_name(self._set_context_asset) self._set_asset_to_tasks_widget() else: self._assets_widget.setEnabled(True) diff --git a/openpype/tools/creator/window.py b/openpype/tools/creator/window.py index 47f27a262a..117519e1d7 100644 --- a/openpype/tools/creator/window.py +++ b/openpype/tools/creator/window.py @@ -214,7 +214,7 @@ class CreatorWindow(QtWidgets.QDialog): asset_name = self._asset_name_input.text() # Early exit if no asset name - if not asset_name.strip(): + if not asset_name: self._build_menu() self.echo("Asset name is required ..") self._set_valid_state(False) diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py index 00b6ae07a4..3a25d72ff4 100644 --- a/openpype/tools/experimental_tools/dialog.py +++ b/openpype/tools/experimental_tools/dialog.py @@ -1,5 +1,6 @@ from qtpy import QtWidgets, QtCore, QtGui +from openpype import AYON_SERVER_ENABLED from openpype.style import ( load_stylesheet, app_icon_path @@ -26,7 +27,8 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(ExperimentalToolsDialog, self).__init__(parent) - self.setWindowTitle("OpenPype Experimental tools") + app_label = "AYON" if AYON_SERVER_ENABLED else "OpenPype" + self.setWindowTitle("{} Experimental tools".format(app_label)) icon = QtGui.QIcon(app_icon_path()) self.setWindowIcon(icon) self.setStyleSheet(load_stylesheet()) @@ -68,8 +70,8 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): tool_btns_label = QtWidgets.QLabel( ( "You can enable these features in" - "
OpenPype tray -> Settings -> Experimental tools" - ), + "
{} tray -> Settings -> Experimental tools" + ).format(app_label), tool_btns_widget ) tool_btns_label.setAlignment(QtCore.Qt.AlignCenter) @@ -113,6 +115,7 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): self._window_is_active = False def refresh(self): + app_label = "AYON" if AYON_SERVER_ENABLED else "OpenPype" self._experimental_tools.refresh_availability() buttons_to_remove = set(self._buttons_by_tool_identifier.keys()) @@ -139,8 +142,8 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): elif is_new or button.isEnabled(): button.setToolTip(( "You can enable this tool in local settings." - "\n\nOpenPype Tray > Settings > Experimental Tools" - )) + "\n\n{} Tray > Settings > Experimental Tools" + ).format(app_label)) if tool.enabled != button.isEnabled(): button.setEnabled(tool.enabled) diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index 61660ee9b7..285b5d04ca 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -1,8 +1,5 @@ -import os - from qtpy import QtWidgets, QtGui -from openpype import PLUGINS_DIR from openpype import style from openpype import resources from openpype.lib import ( @@ -10,46 +7,7 @@ from openpype.lib import ( ApplictionExecutableNotFound, ApplicationLaunchFailed ) -from openpype.pipeline import ( - LauncherAction, - register_launcher_action_path, -) - - -def register_actions_from_paths(paths): - if not paths: - return - - for path in paths: - if not path: - continue - - if path.startswith("."): - print(( - "BUG: Relative paths are not allowed for security reasons. {}" - ).format(path)) - continue - - if not os.path.exists(path): - print("Path was not found: {}".format(path)) - continue - - register_launcher_action_path(path) - - -def register_config_actions(): - """Register actions from the configuration for Launcher""" - - actions_dir = os.path.join(PLUGINS_DIR, "actions") - if os.path.exists(actions_dir): - register_actions_from_paths([actions_dir]) - - -def register_environment_actions(): - """Register actions from AVALON_ACTIONS for Launcher.""" - - paths_str = os.environ.get("AVALON_ACTIONS") or "" - register_actions_from_paths(paths_str.split(os.pathsep)) +from openpype.pipeline import LauncherAction # TODO move to 'openpype.pipeline.actions' diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index d4e0ae0453..b02d83e4f6 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -12,10 +12,12 @@ from abc import ABCMeta, abstractmethod import six import pyblish.api +from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_assets, get_asset_by_id, get_subsets, + get_asset_name_identifier, ) from openpype.lib.events import EventSystem from openpype.lib.attribute_definitions import ( @@ -73,6 +75,8 @@ class AssetDocsCache: "data.visualParent": True, "data.tasks": True } + if AYON_SERVER_ENABLED: + projection["data.parents"] = True def __init__(self, controller): self._controller = controller @@ -105,7 +109,7 @@ class AssetDocsCache: elif "tasks" not in asset_doc["data"]: asset_doc["data"]["tasks"] = {} - asset_name = asset_doc["name"] + asset_name = get_asset_name_identifier(asset_doc) asset_tasks = asset_doc["data"]["tasks"] task_names_by_asset_name[asset_name] = list(asset_tasks.keys()) asset_docs_by_name[asset_name] = asset_doc @@ -176,11 +180,10 @@ class PublishReportMaker: self._create_discover_result = None self._convert_discover_result = None self._publish_discover_result = None - self._plugin_data = [] - self._plugin_data_with_plugin = [] - self._stored_plugins = [] - self._current_plugin_data = [] + self._plugin_data_by_id = {} + self._current_plugin = None + self._current_plugin_data = {} self._all_instances_by_id = {} self._current_context = None @@ -192,8 +195,9 @@ class PublishReportMaker: create_context.convertor_discover_result ) self._publish_discover_result = create_context.publish_discover_result - self._plugin_data = [] - self._plugin_data_with_plugin = [] + + self._plugin_data_by_id = {} + self._current_plugin = None self._current_plugin_data = {} self._all_instances_by_id = {} self._current_context = context @@ -210,18 +214,11 @@ class PublishReportMaker: if self._current_plugin_data: self._current_plugin_data["passed"] = True + self._current_plugin = plugin self._current_plugin_data = self._add_plugin_data_item(plugin) - def _get_plugin_data_item(self, plugin): - store_item = None - for item in self._plugin_data_with_plugin: - if item["plugin"] is plugin: - store_item = item["data"] - break - return store_item - def _add_plugin_data_item(self, plugin): - if plugin in self._stored_plugins: + if plugin.id in self._plugin_data_by_id: # A plugin would be processed more than once. What can cause it: # - there is a bug in controller # - plugin class is imported into multiple files @@ -229,15 +226,9 @@ class PublishReportMaker: raise ValueError( "Plugin '{}' is already stored".format(str(plugin))) - self._stored_plugins.append(plugin) - plugin_data_item = self._create_plugin_data_item(plugin) + self._plugin_data_by_id[plugin.id] = plugin_data_item - self._plugin_data_with_plugin.append({ - "plugin": plugin, - "data": plugin_data_item - }) - self._plugin_data.append(plugin_data_item) return plugin_data_item def _create_plugin_data_item(self, plugin): @@ -278,7 +269,7 @@ class PublishReportMaker: """Add result of single action.""" plugin = result["plugin"] - store_item = self._get_plugin_data_item(plugin) + store_item = self._plugin_data_by_id.get(plugin.id) if store_item is None: store_item = self._add_plugin_data_item(plugin) @@ -300,14 +291,24 @@ class PublishReportMaker: instance, instance in self._current_context ) - plugins_data = copy.deepcopy(self._plugin_data) - if plugins_data and not plugins_data[-1]["passed"]: - plugins_data[-1]["passed"] = True + plugins_data_by_id = copy.deepcopy( + self._plugin_data_by_id + ) + + # Ensure the current plug-in is marked as `passed` in the result + # so that it shows on reports for paused publishes + if self._current_plugin is not None: + current_plugin_data = plugins_data_by_id.get( + self._current_plugin.id + ) + if current_plugin_data and not current_plugin_data["passed"]: + current_plugin_data["passed"] = True if publish_plugins: for plugin in publish_plugins: - if plugin not in self._stored_plugins: - plugins_data.append(self._create_plugin_data_item(plugin)) + if plugin.id not in plugins_data_by_id: + plugins_data_by_id[plugin.id] = \ + self._create_plugin_data_item(plugin) reports = [] if self._create_discover_result is not None: @@ -328,7 +329,7 @@ class PublishReportMaker: ) return { - "plugins_data": plugins_data, + "plugins_data": list(plugins_data_by_id.values()), "instances": instances_details, "context": self._extract_context_data(self._current_context), "crashed_file_paths": crashed_file_paths, @@ -1456,7 +1457,7 @@ class BasePublisherController(AbstractPublisherController): """ if self._log is None: - self._log = logging.getLogget(self.__class__.__name__) + self._log = logging.getLogger(self.__class__.__name__) return self._log @property @@ -1884,10 +1885,19 @@ class PublisherController(BasePublisherController): self._emit_event("plugins.refresh.finished") def _collect_creator_items(self): - return { - identifier: CreatorItem.from_creator(creator) - for identifier, creator in self._create_context.creators.items() - } + # TODO add crashed initialization of create plugins to report + output = {} + for identifier, creator in self._create_context.creators.items(): + try: + output[identifier] = CreatorItem.from_creator(creator) + except Exception: + self.log.error( + "Failed to create creator item for '%s'", + identifier, + exc_info=True + ) + + return output def _reset_instances(self): """Reset create instances.""" @@ -2507,7 +2517,7 @@ class PublisherController(BasePublisherController): else: msg = ( "Something went wrong. Send report" - " to your supervisor or OpenPype." + " to your supervisor or Ynput team." ) self.publish_error_msg = msg self.publish_has_crashed = True diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py index 127a65dd9b..dc4ad70934 100644 --- a/openpype/tools/publisher/publish_report_viewer/window.py +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -329,7 +329,9 @@ class LoadedFilesView(QtWidgets.QTreeView): def __init__(self, *args, **kwargs): super(LoadedFilesView, self).__init__(*args, **kwargs) self.setEditTriggers( - self.EditKeyPressed | self.SelectedClicked | self.DoubleClicked + QtWidgets.QAbstractItemView.EditKeyPressed + | QtWidgets.QAbstractItemView.SelectedClicked + | QtWidgets.QAbstractItemView.DoubleClicked ) self.setIndentation(0) self.setAlternatingRowColors(True) @@ -366,7 +368,7 @@ class LoadedFilesView(QtWidgets.QTreeView): def _on_rows_inserted(self): header = self.header() - header.resizeSections(header.ResizeToContents) + header.resizeSections(QtWidgets.QHeaderView.ResizeToContents) self._update_remove_btn() def resizeEvent(self, event): @@ -377,7 +379,7 @@ class LoadedFilesView(QtWidgets.QTreeView): super(LoadedFilesView, self).showEvent(event) self._model.refresh() header = self.header() - header.resizeSections(header.ResizeToContents) + header.resizeSections(QtWidgets.QHeaderView.ResizeToContents) self._update_remove_btn() def _on_selection_change(self): diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index c536f93c9b..32be514dd7 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -11,7 +11,8 @@ from openpype.tools.utils import ( from openpype.tools.utils.assets_widget import ( SingleSelectAssetsWidget, ASSET_ID_ROLE, - ASSET_NAME_ROLE + ASSET_NAME_ROLE, + ASSET_PATH_ROLE, ) @@ -31,6 +32,15 @@ class CreateWidgetAssetsWidget(SingleSelectAssetsWidget): self._last_filter_height = None + def get_selected_asset_name(self): + if AYON_SERVER_ENABLED: + selection_model = self._view.selectionModel() + indexes = selection_model.selectedRows() + for index in indexes: + return index.data(ASSET_PATH_ROLE) + return None + return super(CreateWidgetAssetsWidget, self).get_selected_asset_name() + def _check_header_height(self): """Catch header height changes. @@ -100,21 +110,24 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): self._controller = controller self._items_by_name = {} + self._items_by_path = {} self._items_by_asset_id = {} def reset(self): self.clear() self._items_by_name = {} + self._items_by_path = {} self._items_by_asset_id = {} assets_by_parent_id = self._controller.get_asset_hierarchy() items_by_name = {} + items_by_path = {} items_by_asset_id = {} _queue = collections.deque() - _queue.append((self.invisibleRootItem(), None)) + _queue.append((self.invisibleRootItem(), None, None)) while _queue: - parent_item, parent_id = _queue.popleft() + parent_item, parent_id, parent_path = _queue.popleft() children = assets_by_parent_id.get(parent_id) if not children: continue @@ -127,6 +140,11 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): for name in sorted(children_by_name.keys()): child = children_by_name[name] child_id = child["_id"] + if parent_path: + child_path = "{}/{}".format(parent_path, name) + else: + child_path = "/{}".format(name) + has_children = bool(assets_by_parent_id.get(child_id)) icon = get_asset_icon(child, has_children) @@ -138,15 +156,18 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): item.setData(icon, QtCore.Qt.DecorationRole) item.setData(child_id, ASSET_ID_ROLE) item.setData(name, ASSET_NAME_ROLE) + item.setData(child_path, ASSET_PATH_ROLE) items_by_name[name] = item + items_by_path[child_path] = item items_by_asset_id[child_id] = item items.append(item) - _queue.append((item, child_id)) + _queue.append((item, child_id, child_path)) parent_item.appendRows(items) self._items_by_name = items_by_name + self._items_by_path = items_by_path self._items_by_asset_id = items_by_asset_id def get_index_by_asset_id(self, asset_id): @@ -156,12 +177,20 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): return QtCore.QModelIndex() def get_index_by_asset_name(self, asset_name): - item = self._items_by_name.get(asset_name) + item = None + if AYON_SERVER_ENABLED: + item = self._items_by_path.get(asset_name) + + if item is None: + item = self._items_by_name.get(asset_name) + if item is None: return QtCore.QModelIndex() return item.index() def name_is_valid(self, item_name): + if AYON_SERVER_ENABLED and item_name in self._items_by_path: + return True return item_name in self._items_by_name @@ -296,7 +325,10 @@ class AssetsDialog(QtWidgets.QDialog): index = self._asset_view.currentIndex() asset_name = None if index.isValid(): - asset_name = index.data(ASSET_NAME_ROLE) + if AYON_SERVER_ENABLED: + asset_name = index.data(ASSET_PATH_ROLE) + else: + asset_name = index.data(ASSET_NAME_ROLE) self._selected_asset = asset_name self.done(1) diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index eae8e0420a..5cdd429cd4 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -797,6 +797,7 @@ class InstanceCardView(AbstractInstanceView): widget.set_active(value) else: self._select_item_clear(instance_id, group_name, instance_widget) + self.selection_changed.emit() self.active_changed.emit() def _on_widget_selection(self, instance_id, group_name, selection_type): diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py index 1940d16eb8..73dcae51a5 100644 --- a/openpype/tools/publisher/widgets/create_widget.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -6,6 +6,7 @@ from openpype import AYON_SERVER_ENABLED from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, PRE_CREATE_THUMBNAIL_KEY, + DEFAULT_VARIANT_VALUE, TaskNotSetError, ) @@ -626,7 +627,7 @@ class CreateWidget(QtWidgets.QWidget): default_variants = creator_item.default_variants if not default_variants: - default_variants = ["Main"] + default_variants = [DEFAULT_VARIANT_VALUE] default_variant = creator_item.default_variant if not default_variant: @@ -642,7 +643,7 @@ class CreateWidget(QtWidgets.QWidget): elif variant: self.variant_hints_menu.addAction(variant) - variant_text = default_variant or "Main" + variant_text = default_variant or DEFAULT_VARIANT_VALUE # Make sure subset name is updated to new plugin if variant_text == self.variant_input.text(): self._on_variant_change() @@ -815,8 +816,13 @@ class CreateWidget(QtWidgets.QWidget): # Where to define these data? # - what data show be stored? + if AYON_SERVER_ENABLED: + asset_key = "folderPath" + else: + asset_key = "asset" + instance_data = { - "asset": asset_name, + asset_key: asset_name, "task": task_name, "variant": variant, "family": family diff --git a/openpype/tools/publisher/widgets/images/browse.png b/openpype/tools/publisher/widgets/images/browse.png new file mode 100644 index 0000000000..b115bb6766 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/browse.png differ diff --git a/openpype/tools/publisher/widgets/images/options.png b/openpype/tools/publisher/widgets/images/options.png new file mode 100644 index 0000000000..b394dbd4ce Binary files /dev/null and b/openpype/tools/publisher/widgets/images/options.png differ diff --git a/openpype/tools/publisher/widgets/images/paste.png b/openpype/tools/publisher/widgets/images/paste.png new file mode 100644 index 0000000000..14a6050da1 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/paste.png differ diff --git a/openpype/tools/publisher/widgets/images/take_screenshot.png b/openpype/tools/publisher/widgets/images/take_screenshot.png new file mode 100644 index 0000000000..242a36a026 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/take_screenshot.png differ diff --git a/openpype/tools/publisher/widgets/overview_widget.py b/openpype/tools/publisher/widgets/overview_widget.py index 470645b9ee..10151250f6 100644 --- a/openpype/tools/publisher/widgets/overview_widget.py +++ b/openpype/tools/publisher/widgets/overview_widget.py @@ -1,5 +1,7 @@ from qtpy import QtWidgets, QtCore +from openpype import AYON_SERVER_ENABLED + from .border_label_widget import BorderedLabelWidget from .card_view_widgets import InstanceCardView @@ -35,7 +37,10 @@ class OverviewWidget(QtWidgets.QFrame): # --- Created Subsets/Instances --- # Common widget for creation and overview subset_views_widget = BorderedLabelWidget( - "Subsets to publish", subset_content_widget + "{} to publish".format( + "Products" if AYON_SERVER_ENABLED else "Subsets" + ), + subset_content_widget ) subset_view_cards = InstanceCardView(controller, subset_views_widget) @@ -168,7 +173,7 @@ class OverviewWidget(QtWidgets.QFrame): def make_sure_animation_is_finished(self): if self._change_anim.state() == QtCore.QAbstractAnimation.Running: self._change_anim.stop() - self._on_change_anim_finished() + self._on_change_anim_finished() def set_state(self, new_state, animate): if new_state == self._current_state: diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py new file mode 100644 index 0000000000..3504b419b4 --- /dev/null +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -0,0 +1,303 @@ +import os +import tempfile + +from qtpy import QtCore, QtGui, QtWidgets + + +class ScreenMarquee(QtWidgets.QDialog): + """Dialog to interactively define screen area. + + This allows to select a screen area through a marquee selection. + + You can use any of its classmethods for easily saving an image, + capturing to QClipboard or returning a QPixmap, respectively + `capture_to_file`, `capture_to_clipboard` and `capture_to_pixmap`. + """ + + def __init__(self, parent=None): + super(ScreenMarquee, self).__init__(parent=parent) + + self.setWindowFlags( + 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) + + app = QtWidgets.QApplication.instance() + if hasattr(app, "screenAdded"): + app.screenAdded.connect(self._on_screen_added) + app.screenRemoved.connect(self._fit_screen_geometry) + elif hasattr(app, "desktop"): + desktop = app.desktop() + desktop.screenCountChanged.connect(self._fit_screen_geometry) + + for screen in QtWidgets.QApplication.screens(): + screen.geometryChanged.connect(self._fit_screen_geometry) + + self._opacity = 50 + self._click_pos = None + self._capture_rect = None + + def get_captured_pixmap(self): + if self._capture_rect is None: + return QtGui.QPixmap() + + return self.get_desktop_pixmap(self._capture_rect) + + def paintEvent(self, event): + """Paint event""" + + # Convert click and current mouse positions to local space. + mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) + click_pos = None + if self._click_pos is not None: + click_pos = self.mapFromGlobal(self._click_pos) + + painter = QtGui.QPainter(self) + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + # Draw background. Aside from aesthetics, this makes the full + # tool region accept mouse events. + painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) + painter.setPen(QtCore.Qt.NoPen) + rect = event.rect() + fill_path = QtGui.QPainterPath() + fill_path.addRect(rect) + + # Clear the capture area + if click_pos is not None: + sub_path = QtGui.QPainterPath() + capture_rect = QtCore.QRect(click_pos, mouse_pos) + sub_path.addRect(capture_rect) + fill_path = fill_path.subtracted(sub_path) + + painter.drawPath(fill_path) + + pen_color = QtGui.QColor(255, 255, 255, self._opacity) + pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) + painter.setPen(pen) + + # Draw cropping markers at click position + if click_pos is not None: + painter.drawLine( + rect.left(), click_pos.y(), + rect.right(), click_pos.y() + ) + painter.drawLine( + click_pos.x(), rect.top(), + click_pos.x(), rect.bottom() + ) + + # Draw cropping markers at current mouse position + painter.drawLine( + rect.left(), mouse_pos.y(), + rect.right(), mouse_pos.y() + ) + painter.drawLine( + mouse_pos.x(), rect.top(), + mouse_pos.x(), rect.bottom() + ) + painter.end() + + def mousePressEvent(self, event): + """Mouse click event""" + + if event.button() == QtCore.Qt.LeftButton: + # Begin click drag operation + self._click_pos = event.globalPos() + + def mouseReleaseEvent(self, event): + """Mouse release event""" + if ( + self._click_pos is not None + and event.button() == QtCore.Qt.LeftButton + ): + # End click drag operation and commit the current capture rect + self._capture_rect = QtCore.QRect( + self._click_pos, event.globalPos() + ).normalized() + self._click_pos = None + self.close() + + def mouseMoveEvent(self, event): + """Mouse move event""" + self.repaint() + + def keyPressEvent(self, event): + """Mouse press event""" + if event.key() == QtCore.Qt.Key_Escape: + self._click_pos = None + self._capture_rect = None + event.accept() + self.close() + return + return super(ScreenMarquee, self).keyPressEvent(event) + + def showEvent(self, event): + self._fit_screen_geometry() + + def _fit_screen_geometry(self): + # Compute the union of all screen geometries, and resize to fit. + workspace_rect = QtCore.QRect() + for screen in QtWidgets.QApplication.screens(): + workspace_rect = workspace_rect.united(screen.geometry()) + self.setGeometry(workspace_rect) + + def _on_screen_added(self): + for screen in QtGui.QGuiApplication.screens(): + screen.geometryChanged.connect(self._fit_screen_geometry) + + @classmethod + def get_desktop_pixmap(cls, rect): + """Performs a screen capture on the specified rectangle. + + Args: + rect (QtCore.QRect): The rectangle to capture. + + Returns: + QtGui.QPixmap: Captured pixmap image + """ + + if rect.width() < 1 or rect.height() < 1: + return QtGui.QPixmap() + + screen_pixes = [] + for screen in QtWidgets.QApplication.screens(): + screen_geo = screen.geometry() + if not screen_geo.intersects(rect): + continue + + screen_pix_rect = screen_geo.intersected(rect) + screen_pix = screen.grabWindow( + 0, + screen_pix_rect.x() - screen_geo.x(), + screen_pix_rect.y() - screen_geo.y(), + screen_pix_rect.width(), screen_pix_rect.height() + ) + paste_point = QtCore.QPoint( + screen_pix_rect.x() - rect.x(), + screen_pix_rect.y() - rect.y() + ) + screen_pixes.append((screen_pix, paste_point)) + + output_pix = QtGui.QPixmap(rect.width(), rect.height()) + output_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(output_pix) + for item in screen_pixes: + (screen_pix, offset) = item + pix_painter.drawPixmap(offset, screen_pix) + + pix_painter.end() + + return output_pix + + @classmethod + def capture_to_pixmap(cls): + """Take screenshot with marquee into pixmap. + + Note: + The pixmap can be invalid (use 'isNull' to check). + + Returns: + QtGui.QPixmap: Captured pixmap image. + """ + + tool = cls() + tool.exec_() + return tool.get_captured_pixmap() + + @classmethod + def capture_to_file(cls, filepath=None): + """Take screenshot with marquee into file. + + Args: + filepath (Optional[str]): Path where screenshot will be saved. + + Returns: + Union[str, None]: Path to the saved screenshot, or None if user + cancelled the operation. + """ + + pixmap = cls.capture_to_pixmap() + if pixmap.isNull(): + return None + + if filepath is None: + with tempfile.NamedTemporaryFile( + prefix="screenshot_", suffix=".png", delete=False + ) as tmpfile: + filepath = tmpfile.name + + else: + output_dir = os.path.dirname(filepath) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + pixmap.save(filepath) + return filepath + + @classmethod + def capture_to_clipboard(cls): + """Take screenshot with marquee into clipboard. + + Notes: + Screenshot is not in clipboard if user cancelled the operation. + + Returns: + bool: Screenshot was added to clipboard. + """ + + clipboard = QtWidgets.QApplication.clipboard() + pixmap = cls.capture_to_pixmap() + if pixmap.isNull(): + return False + image = pixmap.toImage() + clipboard.setImage(image, QtGui.QClipboard.Clipboard) + return True + + +def capture_to_pixmap(): + """Take screenshot with marquee into pixmap. + + Note: + The pixmap can be invalid (use 'isNull' to check). + + Returns: + QtGui.QPixmap: Captured pixmap image. + """ + + return ScreenMarquee.capture_to_pixmap() + + +def capture_to_file(filepath=None): + """Take screenshot with marquee into file. + + Args: + filepath (Optional[str]): Path where screenshot will be saved. + + Returns: + Union[str, None]: Path to the saved screenshot, or None if user + cancelled the operation. + """ + + return ScreenMarquee.capture_to_file(filepath) + + +def capture_to_clipboard(): + """Take screenshot with marquee into clipboard. + + Notes: + Screenshot is not in clipboard if user cancelled the operation. + + Returns: + bool: Screenshot was added to clipboard. + """ + + return ScreenMarquee.capture_to_clipboard() diff --git a/openpype/tools/publisher/widgets/thumbnail_widget.py b/openpype/tools/publisher/widgets/thumbnail_widget.py index 80d156185b..60970710d8 100644 --- a/openpype/tools/publisher/widgets/thumbnail_widget.py +++ b/openpype/tools/publisher/widgets/thumbnail_widget.py @@ -22,6 +22,7 @@ from openpype.tools.utils import ( from openpype.tools.publisher.control import CardMessageTypes from .icons import get_image +from .screenshot_widget import capture_to_file class ThumbnailPainterWidget(QtWidgets.QWidget): @@ -306,20 +307,43 @@ class ThumbnailWidget(QtWidgets.QWidget): thumbnail_painter = ThumbnailPainterWidget(self) + icon_color = get_objected_colors("bg-view-selection").get_qcolor() + icon_color.setAlpha(255) + buttons_widget = QtWidgets.QWidget(self) buttons_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - icon_color = get_objected_colors("bg-view-selection").get_qcolor() - icon_color.setAlpha(255) clear_image = get_image("clear_thumbnail") clear_pix = paint_image_with_color(clear_image, icon_color) - clear_button = PixmapButton(clear_pix, buttons_widget) clear_button.setObjectName("ThumbnailPixmapHoverButton") + clear_button.setToolTip("Clear thumbnail") + + take_screenshot_image = get_image("take_screenshot") + take_screenshot_pix = paint_image_with_color( + take_screenshot_image, icon_color) + take_screenshot_btn = PixmapButton( + take_screenshot_pix, buttons_widget) + take_screenshot_btn.setObjectName("ThumbnailPixmapHoverButton") + take_screenshot_btn.setToolTip("Take screenshot") + + paste_image = get_image("paste") + paste_pix = paint_image_with_color(paste_image, icon_color) + paste_btn = PixmapButton(paste_pix, buttons_widget) + paste_btn.setObjectName("ThumbnailPixmapHoverButton") + paste_btn.setToolTip("Paste from clipboard") + + browse_image = get_image("browse") + browse_pix = paint_image_with_color(browse_image, icon_color) + browse_btn = PixmapButton(browse_pix, buttons_widget) + browse_btn.setObjectName("ThumbnailPixmapHoverButton") + browse_btn.setToolTip("Browse...") buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) - buttons_layout.setContentsMargins(3, 3, 3, 3) - buttons_layout.addStretch(1) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addWidget(take_screenshot_btn, 0) + buttons_layout.addWidget(paste_btn, 0) + buttons_layout.addWidget(browse_btn, 0) buttons_layout.addWidget(clear_button, 0) layout = QtWidgets.QHBoxLayout(self) @@ -327,6 +351,9 @@ class ThumbnailWidget(QtWidgets.QWidget): layout.addWidget(thumbnail_painter) clear_button.clicked.connect(self._on_clear_clicked) + take_screenshot_btn.clicked.connect(self._on_take_screenshot) + paste_btn.clicked.connect(self._on_paste_from_clipboard) + browse_btn.clicked.connect(self._on_browse_clicked) self._controller = controller self._output_dir = controller.get_thumbnail_temp_dir_path() @@ -338,9 +365,16 @@ class ThumbnailWidget(QtWidgets.QWidget): self._adapted_to_size = True self._last_width = None self._last_height = None + self._hide_on_finish = False self._buttons_widget = buttons_widget self._thumbnail_painter = thumbnail_painter + self._clear_button = clear_button + self._take_screenshot_btn = take_screenshot_btn + self._paste_btn = paste_btn + self._browse_btn = browse_btn + + clear_button.setEnabled(False) @property def width_ratio(self): @@ -430,13 +464,75 @@ class ThumbnailWidget(QtWidgets.QWidget): self._thumbnail_painter.clear_cache() + def _set_current_thumbails(self, thumbnail_paths): + self._thumbnail_painter.set_current_thumbnails(thumbnail_paths) + self._update_buttons_position() + def set_current_thumbnails(self, thumbnail_paths=None): self._thumbnail_painter.set_current_thumbnails(thumbnail_paths) self._update_buttons_position() + self._clear_button.setEnabled(self._thumbnail_painter.has_pixes) def _on_clear_clicked(self): self.set_current_thumbnails() self.thumbnail_cleared.emit() + self._clear_button.setEnabled(False) + + def _on_take_screenshot(self): + window = self.window() + state = window.windowState() + window.setWindowState(QtCore.Qt.WindowMinimized) + output_path = os.path.join( + self._output_dir, uuid.uuid4().hex + ".png") + if capture_to_file(output_path): + self.thumbnail_created.emit(output_path) + # restore original window state + window.setWindowState(state) + + def _on_paste_from_clipboard(self): + """Set thumbnail from a pixmap image in the system clipboard""" + + clipboard = QtWidgets.QApplication.clipboard() + pixmap = clipboard.pixmap() + if pixmap.isNull(): + return + + # Save as temporary file + output_path = os.path.join( + self._output_dir, uuid.uuid4().hex + ".png") + + output_dir = os.path.dirname(output_path) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + if pixmap.save(output_path): + self.thumbnail_created.emit(output_path) + + def _on_browse_clicked(self): + ext_filter = "Source (*{0})".format( + " *".join(self._review_extensions) + ) + filepath, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Choose thumbnail", os.path.expanduser("~"), ext_filter + ) + if not filepath: + return + valid_path = False + ext = os.path.splitext(filepath)[-1].lower() + if ext in self._review_extensions: + valid_path = True + + output = None + if valid_path: + output = export_thumbnail(filepath, self._output_dir) + + if output: + self.thumbnail_created.emit(output) + else: + self._controller.emit_card_message( + "Couldn't convert the source for thumbnail", + CardMessageTypes.error + ) def _adapt_to_size(self): if not self._adapted_to_size: @@ -452,13 +548,25 @@ class ThumbnailWidget(QtWidgets.QWidget): self._thumbnail_painter.clear_cache() def _update_buttons_position(self): - self._buttons_widget.setVisible(self._thumbnail_painter.has_pixes) size = self.size() + my_width = size.width() my_height = size.height() - height = self._buttons_widget.sizeHint().height() + buttons_sh = self._buttons_widget.sizeHint() + buttons_height = buttons_sh.height() + buttons_width = buttons_sh.width() + pos_x = my_width - (buttons_width + 3) + pos_y = my_height - (buttons_height + 3) + if pos_x < 0: + pos_x = 0 + buttons_width = my_width + if pos_y < 0: + pos_y = 0 + buttons_height = my_height self._buttons_widget.setGeometry( - 0, my_height - height, - size.width(), height + pos_x, + pos_y, + buttons_width, + buttons_height ) def resizeEvent(self, event): diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 1bbe73381f..ecccc4e0c8 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -210,7 +210,9 @@ class CreateBtn(PublishIconBtn): def __init__(self, parent=None): icon_path = get_icon_path("create") super(CreateBtn, self).__init__(icon_path, "Create", parent) - self.setToolTip("Create new subset/s") + self.setToolTip("Create new {}/s".format( + "product" if AYON_SERVER_ENABLED else "subset" + )) self.setLayoutDirection(QtCore.Qt.RightToLeft) @@ -538,6 +540,7 @@ class AssetsField(BaseClickableFrame): Does not change selected items (assets). """ self._name_input.setText(text) + self._name_input.end(False) def set_selected_items(self, asset_names=None): """Set asset names for selection of instances. @@ -579,6 +582,10 @@ class AssetsField(BaseClickableFrame): """Change to asset names set with last `set_selected_items` call.""" self.set_selected_items(self._origin_value) + def confirm_value(self): + self._origin_value = copy.deepcopy(self._selected_items) + self._has_value_changed = False + class TasksComboboxProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): @@ -651,7 +658,11 @@ class TasksCombobox(QtWidgets.QComboBox): self._proxy_model.set_filter_empty(invalid) if invalid: self._set_is_valid(False) - self.set_text("< One or more subsets require Task selected >") + self.set_text( + "< One or more {} require Task selected >".format( + "products" if AYON_SERVER_ENABLED else "subsets" + ) + ) else: self.set_text(None) @@ -785,6 +796,15 @@ class TasksCombobox(QtWidgets.QComboBox): self._set_is_valid(is_valid) + def confirm_value(self, asset_names): + new_task_name = self._selected_items[0] + self._origin_value = [ + (asset_name, new_task_name) + for asset_name in asset_names + ] + self._origin_selection = copy.deepcopy(self._selected_items) + self._has_value_changed = False + def set_selected_items(self, asset_task_combinations=None): """Set items for selected instances. @@ -919,6 +939,10 @@ class VariantInputWidget(PlaceholderLineEdit): """Change text of multiselection.""" self._multiselection_text = text + def confirm_value(self): + self._origin_value = copy.deepcopy(self._current_value) + self._has_value_changed = False + def _set_is_valid(self, valid): if valid == self._is_valid: return @@ -1110,6 +1134,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): btns_layout = QtWidgets.QHBoxLayout() btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addStretch(1) + btns_layout.setSpacing(5) btns_layout.addWidget(submit_btn) btns_layout.addWidget(cancel_btn) @@ -1160,9 +1185,13 @@ class GlobalAttrsWidget(QtWidgets.QWidget): subset_names = set() invalid_tasks = False + asset_names = [] for instance in self._current_instances: new_variant_value = instance.get("variant") - new_asset_name = instance.get("asset") + if AYON_SERVER_ENABLED: + new_asset_name = instance.get("folderPath") + else: + new_asset_name = instance.get("asset") new_task_name = instance.get("task") if variant_value is not None: new_variant_value = variant_value @@ -1173,6 +1202,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if task_name is not None: new_task_name = task_name + asset_names.append(new_asset_name) try: new_subset_name = self._controller.get_subset_name( instance.creator_identifier, @@ -1193,7 +1223,11 @@ class GlobalAttrsWidget(QtWidgets.QWidget): instance["variant"] = variant_value if asset_name is not None: - instance["asset"] = asset_name + if AYON_SERVER_ENABLED: + instance["folderPath"] = asset_name + else: + instance["asset"] = asset_name + instance.set_asset_invalid(False) if task_name is not None: @@ -1210,6 +1244,15 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self._set_btns_enabled(False) self._set_btns_visible(invalid_tasks) + if variant_value is not None: + self.variant_input.confirm_value() + + if asset_name is not None: + self.asset_value_widget.confirm_value() + + if task_name is not None: + self.task_value_widget.confirm_value(asset_names) + self.instance_context_changed.emit() def _on_cancel(self): @@ -1282,7 +1325,10 @@ class GlobalAttrsWidget(QtWidgets.QWidget): variants.add(instance.get("variant") or self.unknown_value) families.add(instance.get("family") or self.unknown_value) - asset_name = instance.get("asset") or self.unknown_value + if AYON_SERVER_ENABLED: + asset_name = instance.get("folderPath") or self.unknown_value + else: + asset_name = instance.get("asset") or self.unknown_value task_name = instance.get("task") or "" asset_names.add(asset_name) asset_task_combinations.append((asset_name, task_name)) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 39e78c01bb..b3138c3f45 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -9,12 +9,14 @@ from openpype import ( resources, style ) +from openpype import AYON_SERVER_ENABLED from openpype.tools.utils import ( ErrorMessageBox, PlaceholderLineEdit, MessageOverlayObject, PixmapLabel, ) +from openpype.tools.utils.lib import center_window from .constants import ResetKeySequence from .publish_report_viewer import PublishReportViewerWidget @@ -52,7 +54,9 @@ class PublisherWindow(QtWidgets.QDialog): self.setObjectName("PublishWindow") - self.setWindowTitle("OpenPype publisher") + self.setWindowTitle("{} publisher".format( + "AYON" if AYON_SERVER_ENABLED else "OpenPype" + )) icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) @@ -388,6 +392,45 @@ class PublisherWindow(QtWidgets.QDialog): def controller(self): return self._controller + def show_and_publish(self, comment=None): + """Show the window and start publishing. + + The method will reset controller and start the publishing afterwards. + + Todos: + Move validations from '_on_publish_clicked' and change of + 'comment' value in controller to controller so it can be + simplified. + + Args: + comment (Optional[str]): Comment to be set to publish. + If is set to 'None' a comment is not changed at all. + """ + + self._reset_on_show = False + self._reset_on_first_show = False + + if comment is not None: + self.set_comment(comment) + self.make_sure_is_visible() + # Reset controller + self._controller.reset() + # Fake publish click to trigger save validation and propagate + # comment to controller + self._on_publish_clicked() + + def set_comment(self, comment): + """Change comment text. + + Todos: + Be able to set the comment via controller. + + Args: + comment (str): Comment text. + """ + + self._comment_input.setText(comment) + def make_sure_is_visible(self): if self._window_is_visible: self.setWindowState(QtCore.Qt.WindowActive) @@ -490,6 +533,7 @@ class PublisherWindow(QtWidgets.QDialog): def _on_first_show(self): self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) + center_window(self) self._reset_on_show = self._reset_on_first_show def _on_show_timer(self): diff --git a/openpype/tools/push_to_project/control_integrate.py b/openpype/tools/push_to_project/control_integrate.py index a822339ccf..9f083d8eb7 100644 --- a/openpype/tools/push_to_project/control_integrate.py +++ b/openpype/tools/push_to_project/control_integrate.py @@ -1051,6 +1051,11 @@ class ProjectPushItemProcess: repre_format_data["ext"] = ext[1:] break + # Re-use 'output' from source representation + repre_output_name = repre_doc["context"].get("output") + if repre_output_name is not None: + repre_format_data["output"] = repre_output_name + template_obj = anatomy.templates_obj[template_name]["folder"] folder_path = template_obj.format_strict(formatting_data) repre_context = folder_path.used_values diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index 64c439712c..4fd82f04a4 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -85,7 +85,7 @@ class InventoryModel(TreeModel): self.remote_provider = remote_provider self._site_icons = { provider: QtGui.QIcon(icon_path) - for provider, icon_path in self.get_site_icons().items() + for provider, icon_path in sync_server.get_site_icons().items() } if "active_site" not in self.Columns: self.Columns.append("active_site") diff --git a/openpype/tools/settings/local_settings/projects_widget.py b/openpype/tools/settings/local_settings/projects_widget.py index 68e144f87b..f2b6535115 100644 --- a/openpype/tools/settings/local_settings/projects_widget.py +++ b/openpype/tools/settings/local_settings/projects_widget.py @@ -286,7 +286,7 @@ class SitesWidget(QtWidgets.QWidget): continue site_inputs = [] - site_config = site_configs[site_name] + site_config = site_configs.get(site_name, {}) for root_name, path_entity in site_config.get("root", {}).items(): if not path_entity: continue diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 117eca7d6b..2fd13cbbd8 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -4,6 +4,7 @@ from qtpy import QtWidgets, QtCore, QtGui from openpype.widgets.sliders import NiceSlider from openpype.tools.settings import CHILD_OFFSET +from openpype.tools.utils import MultiSelectionComboBox from openpype.settings.entities.exceptions import BaseInvalidValue from .widgets import ( @@ -15,7 +16,6 @@ from .widgets import ( SettingsNiceCheckbox, SettingsLineEdit ) -from .multiselection_combobox import MultiSelectionComboBox from .wrapper_widgets import ( WrapperWidget, CollapsibleWrapper, diff --git a/openpype/tools/tray/pype_info_widget.py b/openpype/tools/tray/pype_info_widget.py index dc222b79b5..985a23f64c 100644 --- a/openpype/tools/tray/pype_info_widget.py +++ b/openpype/tools/tray/pype_info_widget.py @@ -219,7 +219,9 @@ class PypeInfoWidget(QtWidgets.QWidget): icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) - self.setWindowTitle("OpenPype info") + self.setWindowTitle( + "{} info".format("AYON" if AYON_SERVER_ENABLED else "OpenPype") + ) scroll_area = QtWidgets.QScrollArea(self) info_widget = PypeInfoSubWidget(scroll_area) @@ -441,16 +443,19 @@ class PypeInfoSubWidget(QtWidgets.QWidget): info_values = { "executable": executable_args[-1], "server_url": os.environ["AYON_SERVER_URL"], + "bundle_name": os.environ["AYON_BUNDLE_NAME"], "username": username } key_label_mapping = { "executable": "AYON Executable:", "server_url": "AYON Server:", + "bundle_name": "AYON Bundle:", "username": "AYON Username:" } # Prepare keys order keys_order = [ "server_url", + "bundle_name", "username", "executable", ] diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index f35bfaee70..50d50f467a 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -15,8 +15,15 @@ from .widgets import ( IconButton, PixmapButton, SeparatorWidget, + VerticalExpandButton, + SquareButton, + RefreshButton, + GoToCurrentButton, +) +from .views import ( + DeselectableTreeView, + TreeView, ) -from .views import DeselectableTreeView from .error_dialog import ErrorMessageBox from .lib import ( WrappedCallbackItem, @@ -38,6 +45,8 @@ from .models import ( from .overlay_messages import ( MessageOverlayObject, ) +from .multiselection_combobox import MultiSelectionComboBox +from .thumbnail_paint_widget import ThumbnailPainterWidget __all__ = ( @@ -59,7 +68,13 @@ __all__ = ( "PixmapButton", "SeparatorWidget", + "VerticalExpandButton", + "SquareButton", + "RefreshButton", + "GoToCurrentButton", + "DeselectableTreeView", + "TreeView", "ErrorMessageBox", @@ -78,4 +93,8 @@ __all__ = ( "RecursiveSortFilterProxyModel", "MessageOverlayObject", + + "MultiSelectionComboBox", + + "ThumbnailPainterWidget", ) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index a45d762c73..b83f4dfcaf 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -36,6 +36,7 @@ ASSET_ID_ROLE = QtCore.Qt.UserRole + 1 ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 ASSET_LABEL_ROLE = QtCore.Qt.UserRole + 3 ASSET_UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4 +ASSET_PATH_ROLE = QtCore.Qt.UserRole + 5 class AssetsView(TreeViewSpinner, DeselectableTreeView): diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index c71c87f9b0..c51323e556 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -24,9 +24,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): lock = False def __init__(self, dbcon, *args, **kwargs): - self.dbcon = dbcon + self._dbcon = dbcon super(VersionDelegate, self).__init__(*args, **kwargs) + def get_project_name(self): + return self._dbcon.active_project() + def displayText(self, value, locale): if isinstance(value, HeroVersionType): return lib.format_version(value, True) @@ -120,7 +123,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): "Version is not integer" ) - project_name = self.dbcon.active_project() + project_name = self.get_project_name() # Add all available versions to the editor parent_id = item["version_document"]["parent"] version_docs = [ diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index bc4b7867c2..cc20774349 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -6,6 +6,8 @@ use singleton approach with global functions (using helper anyway). import os import pyblish.api + +from openpype import AYON_SERVER_ENABLED from openpype.host import IWorkfileHost, ILoadHost from openpype.lib import Logger from openpype.pipeline import ( @@ -46,17 +48,29 @@ class HostToolsHelper: self._log = Logger.get_logger(self.__class__.__name__) return self._log + def _init_ayon_workfiles_tool(self, parent): + from openpype.tools.ayon_workfiles.widgets import WorkfilesToolWindow + + workfiles_window = WorkfilesToolWindow(parent=parent) + self._workfiles_tool = workfiles_window + + def _init_openpype_workfiles_tool(self, parent): + from openpype.tools.workfiles.app import Window + + # Host validation + host = registered_host() + IWorkfileHost.validate_workfile_methods(host) + + workfiles_window = Window(parent=parent) + self._workfiles_tool = workfiles_window + def get_workfiles_tool(self, parent): """Create, cache and return workfiles tool window.""" if self._workfiles_tool is None: - from openpype.tools.workfiles.app import Window - - # Host validation - host = registered_host() - IWorkfileHost.validate_workfile_methods(host) - - workfiles_window = Window(parent=parent) - self._workfiles_tool = workfiles_window + if AYON_SERVER_ENABLED: + self._init_ayon_workfiles_tool(parent) + else: + self._init_openpype_workfiles_tool(parent) return self._workfiles_tool @@ -72,12 +86,22 @@ class HostToolsHelper: def get_loader_tool(self, parent): """Create, cache and return loader tool window.""" if self._loader_tool is None: - from openpype.tools.loader import LoaderWindow - host = registered_host() ILoadHost.validate_load_methods(host) + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_loader.ui import LoaderWindow + from openpype.tools.ayon_loader import LoaderController - loader_window = LoaderWindow(parent=parent or self._parent) + controller = LoaderController(host=host) + loader_window = LoaderWindow( + controller=controller, + parent=parent or self._parent + ) + + else: + from openpype.tools.loader import LoaderWindow + + loader_window = LoaderWindow(parent=parent or self._parent) self._loader_tool = loader_window return self._loader_tool @@ -95,7 +119,7 @@ class HostToolsHelper: if use_context is None: use_context = False - if use_context: + if not AYON_SERVER_ENABLED and use_context: context = {"asset": get_current_asset_name()} loader_tool.set_context(context, refresh=True) else: @@ -147,14 +171,23 @@ class HostToolsHelper: def get_scene_inventory_tool(self, parent): """Create, cache and return scene inventory tool window.""" if self._scene_inventory_tool is None: - from openpype.tools.sceneinventory import SceneInventoryWindow - host = registered_host() ILoadHost.validate_load_methods(host) - scene_inventory_window = SceneInventoryWindow( - parent=parent or self._parent - ) + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_sceneinventory.window import ( + SceneInventoryWindow) + + scene_inventory_window = SceneInventoryWindow( + parent=parent or self._parent + ) + + else: + from openpype.tools.sceneinventory import SceneInventoryWindow + + scene_inventory_window = SceneInventoryWindow( + parent=parent or self._parent + ) self._scene_inventory_tool = scene_inventory_window return self._scene_inventory_tool @@ -173,6 +206,9 @@ class HostToolsHelper: def get_library_loader_tool(self, parent): """Create, cache and return library loader tool window.""" + if AYON_SERVER_ENABLED: + return self.get_loader_tool(parent) + if self._library_loader_tool is None: from openpype.tools.libraryloader import LibraryLoaderWindow @@ -185,6 +221,9 @@ class HostToolsHelper: def show_library_loader(self, parent=None): """Loader tool for loading representations from library project.""" + if AYON_SERVER_ENABLED: + return self.show_loader(parent) + with qt_app_context(): library_loader_tool = self.get_library_loader_tool(parent) library_loader_tool.show() @@ -257,7 +296,8 @@ class HostToolsHelper: ILoadHost.validate_load_methods(host) publisher_window = PublisherWindow( - controller=controller, parent=parent or self._parent + controller=controller, + parent=parent or self._parent ) self._publisher_tool = publisher_window diff --git a/openpype/tools/utils/images/__init__.py b/openpype/tools/utils/images/__init__.py new file mode 100644 index 0000000000..3f437fcc8c --- /dev/null +++ b/openpype/tools/utils/images/__init__.py @@ -0,0 +1,56 @@ +import os +from qtpy import QtGui + +IMAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) + + +def get_image_path(filename): + """Get image path from './images'. + + Returns: + Union[str, None]: Path to image file or None if not found. + """ + + path = os.path.join(IMAGES_DIR, filename) + if os.path.exists(path): + return path + return None + + +def get_image(filename): + """Load image from './images' as QImage. + + Returns: + Union[QtGui.QImage, None]: QImage or None if not found. + """ + + path = get_image_path(filename) + if path: + return QtGui.QImage(path) + return None + + +def get_pixmap(filename): + """Load image from './images' as QPixmap. + + Returns: + Union[QtGui.QPixmap, None]: QPixmap or None if not found. + """ + + path = get_image_path(filename) + if path: + return QtGui.QPixmap(path) + return None + + +def get_icon(filename): + """Load image from './images' as QIcon. + + Returns: + Union[QtGui.QIcon, None]: QIcon or None if not found. + """ + + pix = get_pixmap(filename) + if pix: + return QtGui.QIcon(pix) + return None diff --git a/openpype/tools/utils/images/thumbnail.png b/openpype/tools/utils/images/thumbnail.png new file mode 100644 index 0000000000..adea862e5b Binary files /dev/null and b/openpype/tools/utils/images/thumbnail.png differ diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 2df46c1eae..723e71e7aa 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -170,8 +170,12 @@ def get_openpype_qt_app(): if attr is not None: QtWidgets.QApplication.setAttribute(attr) - if hasattr( - QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy" + policy = os.getenv("QT_SCALE_FACTOR_ROUNDING_POLICY") + if ( + hasattr( + QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy" + ) + and not policy ): QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough diff --git a/openpype/tools/settings/settings/multiselection_combobox.py b/openpype/tools/utils/multiselection_combobox.py similarity index 84% rename from openpype/tools/settings/settings/multiselection_combobox.py rename to openpype/tools/utils/multiselection_combobox.py index d64fc83745..34361fca17 100644 --- a/openpype/tools/settings/settings/multiselection_combobox.py +++ b/openpype/tools/utils/multiselection_combobox.py @@ -1,9 +1,10 @@ from qtpy import QtCore, QtGui, QtWidgets -from openpype.tools.utils.lib import ( + +from .lib import ( checkstate_int_to_enum, checkstate_enum_to_int, ) -from openpype.tools.utils.constants import ( +from .constants import ( CHECKED_INT, UNCHECKED_INT, ITEM_IS_USER_TRISTATE, @@ -60,12 +61,25 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) self._initial_mouse_pos = None self._separator = separator - self.placeholder_text = placeholder - self.delegate = ComboItemDelegate(self) - self.setItemDelegate(self.delegate) + self._placeholder_text = placeholder + delegate = ComboItemDelegate(self) + self.setItemDelegate(delegate) - self.lines = {} - self.item_height = None + self._lines = {} + self._item_height = None + self._custom_text = None + self._delegate = delegate + + def get_placeholder_text(self): + return self._placeholder_text + + def set_placeholder_text(self, text): + self._placeholder_text = text + self._update_size_hint() + + def set_custom_text(self, text): + self._custom_text = text + self._update_size_hint() def focusInEvent(self, event): self.focused_in.emit() @@ -158,7 +172,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): if new_state is not None: model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) self.view().update(current_index) - self.update_size_hint() + self._update_size_hint() self.value_changed.emit() return True @@ -182,25 +196,33 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self.initStyleOption(option) painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) - # draw the icon and text items = self.checked_items_text() - if not items: - option.currentText = self.placeholder_text + # draw the icon and text + draw_text = True + combotext = None + if self._custom_text is not None: + combotext = self._custom_text + elif not items: + combotext = self._placeholder_text + else: + draw_text = False + if draw_text: + option.currentText = combotext option.palette.setCurrentColorGroup(QtGui.QPalette.Disabled) painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) return font_metricts = self.fontMetrics() - if self.item_height is None: + if self._item_height is None: self.updateGeometry() self.update() return - for line, items in self.lines.items(): + for line, items in self._lines.items(): top_y = ( option.rect.top() - + (line * self.item_height) + + (line * self._item_height) + self.top_bottom_margins ) left_x = option.rect.left() + self.left_offset @@ -210,7 +232,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): label_rect.moveTop(top_y) label_rect.moveLeft(left_x) - label_rect.setHeight(self.item_height) + label_rect.setHeight(self._item_height) label_rect.setWidth( label_rect.width() + self.left_right_padding ) @@ -239,14 +261,18 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): def resizeEvent(self, *args, **kwargs): super(MultiSelectionComboBox, self).resizeEvent(*args, **kwargs) - self.update_size_hint() + self._update_size_hint() - def update_size_hint(self): - self.lines = {} + def _update_size_hint(self): + if self._custom_text is not None: + self.update() + return + self._lines = {} items = self.checked_items_text() if not items: self.update() + self.repaint() return option = QtWidgets.QStyleOptionComboBox() @@ -259,7 +285,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): total_width = option.rect.width() - btn_rect.width() line = 0 - self.lines = {line: []} + self._lines = {line: []} font_metricts = self.fontMetrics() default_left_x = 0 + self.left_offset @@ -270,18 +296,18 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): right_x = left_x + width if right_x > total_width: left_x = int(default_left_x) - if self.lines.get(line): + if self._lines.get(line): line += 1 - self.lines[line] = [item] + self._lines[line] = [item] left_x += width else: - self.lines[line] = [item] + self._lines[line] = [item] line += 1 else: - if line in self.lines: - self.lines[line].append(item) + if line in self._lines: + self._lines[line].append(item) else: - self.lines[line] = [item] + self._lines[line] = [item] left_x = left_x + width + self.item_spacing self.update() @@ -289,18 +315,20 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): def sizeHint(self): value = super(MultiSelectionComboBox, self).sizeHint() - lines = len(self.lines) - if lines == 0: - lines = 1 + lines = 1 + if self._custom_text is None: + lines = len(self._lines) + if lines == 0: + lines = 1 - if self.item_height is None: - self.item_height = ( + if self._item_height is None: + self._item_height = ( self.fontMetrics().height() + (2 * self.top_bottom_padding) + (2 * self.top_bottom_margins) ) value.setHeight( - (lines * self.item_height) + (lines * self._item_height) + (2 * self.top_bottom_margins) ) return value @@ -316,7 +344,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): else: check_state = UNCHECKED_INT self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) - self.update_size_hint() + self._update_size_hint() def value(self): items = list() diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 8c0505223e..b554ed50d3 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -75,7 +75,7 @@ class TasksModel(QtGui.QStandardItemModel): def set_asset_id(self, asset_id): asset_doc = None - if self._context_is_valid(): + if asset_id and self._context_is_valid(): project_name = self._get_current_project() asset_doc = get_asset_by_id( project_name, asset_id, fields=["data.tasks"] diff --git a/openpype/tools/utils/thumbnail_paint_widget.py b/openpype/tools/utils/thumbnail_paint_widget.py new file mode 100644 index 0000000000..130942aaf0 --- /dev/null +++ b/openpype/tools/utils/thumbnail_paint_widget.py @@ -0,0 +1,366 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors + +from .lib import paint_image_with_color +from .images import get_image + + +class ThumbnailPainterWidget(QtWidgets.QWidget): + """Widget for painting of thumbnails. + + The widget use is to paint thumbnail or multiple thumbnails in a defined + area. Is not meant to show them in a grid but in overlay. + + It is expected that there is a logic that will provide thumbnails to + paint and set them using 'set_current_thumbnails' or + 'set_current_thumbnail_paths'. + """ + + width_ratio = 3.0 + height_ratio = 2.0 + border_width = 1 + max_thumbnails = 3 + offset_sep = 4 + checker_boxes_count = 20 + + def __init__(self, parent): + super(ThumbnailPainterWidget, self).__init__(parent) + + border_color = get_objected_colors("bg-buttons").get_qcolor() + thumbnail_bg_color = get_objected_colors("bg-view").get_qcolor() + + default_image = get_image("thumbnail.png") + default_pix = paint_image_with_color(default_image, border_color) + + self._border_color = border_color + self._thumbnail_bg_color = thumbnail_bg_color + self._default_pix = default_pix + + self._cached_pix = None + self._current_pixes = None + self._has_pixes = False + + self._bg_color = QtCore.Qt.transparent + self._use_checker = True + self._checker_color_1 = QtGui.QColor(89, 89, 89) + self._checker_color_2 = QtGui.QColor(188, 187, 187) + + def set_background_color(self, color): + self._bg_color = color + self.repaint() + + def set_use_checkboard(self, use_checker): + if self._use_checker is use_checker: + return + self._use_checker = use_checker + self.repaint() + + def set_checker_colors(self, color_1, color_2): + self._checker_color_1 = color_1 + self._checker_color_2 = color_2 + self.repaint() + + def set_border_color(self, color): + """Change border color. + + Args: + color (QtGui.QColor): Color to set. + """ + + self._border_color = color + self._default_pix = None + self.clear_cache() + + def set_thumbnail_bg_color(self, color): + """Change background color. + + Args: + color (QtGui.QColor): Color to set. + """ + + self._thumbnail_bg_color = color + self.clear_cache() + + @property + def has_pixes(self): + """Has set thumbnails. + + Returns: + bool: True if widget has thumbnails to paint. + """ + + return self._has_pixes + + def clear_cache(self): + """Clear cache of resized thumbnails and repaint widget.""" + + self._cached_pix = None + self.repaint() + + def set_current_thumbnails(self, pixmaps=None): + """Set current thumbnails. + + Args: + pixmaps (Optional[List[QtGui.QPixmap]]): List of pixmaps. + """ + + self._current_pixes = pixmaps or None + self._has_pixes = self._current_pixes is not None + self.clear_cache() + + def set_current_thumbnail_paths(self, thumbnail_paths=None): + """Set current thumbnails. + + Set current thumbnails using paths to a files. + + Args: + thumbnail_paths (Optional[List[str]]): List of paths to thumbnail + sources. + """ + + pixes = [] + if thumbnail_paths: + for thumbnail_path in thumbnail_paths: + pixes.append(QtGui.QPixmap(thumbnail_path)) + + self.set_current_thumbnails(pixes) + + def paintEvent(self, event): + if self._cached_pix is None: + self._cache_pix() + + painter = QtGui.QPainter() + painter.begin(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.drawPixmap(0, 0, self._cached_pix) + painter.end() + + def resizeEvent(self, event): + self._cached_pix = None + super(ThumbnailPainterWidget, self).resizeEvent(event) + + def _get_default_pix(self): + if self._default_pix is None: + default_image = get_image("thumbnail") + default_pix = paint_image_with_color( + default_image, self._border_color) + self._default_pix = default_pix + return self._default_pix + + def _paint_tile(self, width, height): + if not self._use_checker: + tile_pix = QtGui.QPixmap(width, width) + tile_pix.fill(self._bg_color) + return tile_pix + + checker_size = int(float(width) / self.checker_boxes_count) + if checker_size < 1: + checker_size = 1 + + checker_pix = QtGui.QPixmap(checker_size * 2, checker_size * 2) + checker_pix.fill(QtCore.Qt.transparent) + checker_painter = QtGui.QPainter() + checker_painter.begin(checker_pix) + checker_painter.setPen(QtCore.Qt.NoPen) + checker_painter.setBrush(self._checker_color_1) + checker_painter.drawRect( + 0, 0, checker_pix.width(), checker_pix.height() + ) + checker_painter.setBrush(self._checker_color_2) + checker_painter.drawRect( + 0, 0, checker_size, checker_size + ) + checker_painter.drawRect( + checker_size, checker_size, checker_size, checker_size + ) + checker_painter.end() + return checker_pix + + def _paint_default_pix(self, pix_width, pix_height): + full_border_width = 2 * self.border_width + width = pix_width - full_border_width + height = pix_height - full_border_width + if width > 100: + width = int(width * 0.6) + height = int(height * 0.6) + + scaled_pix = self._get_default_pix().scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + pix_painter.setRenderHints(render_hints) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + return new_pix + + def _draw_thumbnails(self, thumbnails, pix_width, pix_height): + full_border_width = 2 * self.border_width + + checker_pix = self._paint_tile(pix_width, pix_height) + + backgrounded_images = [] + for src_pix in thumbnails: + scaled_pix = src_pix.scaled( + pix_width - full_border_width, + pix_height - full_border_width, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + pix_painter.setRenderHints(render_hints) + + tiled_rect = QtCore.QRectF( + pos_x, pos_y, scaled_pix.width(), scaled_pix.height() + ) + pix_painter.drawTiledPixmap( + tiled_rect, + checker_pix, + QtCore.QPointF(0.0, 0.0) + ) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + backgrounded_images.append(new_pix) + return backgrounded_images + + def _paint_dash_line(self, painter, rect): + pen = QtGui.QPen() + pen.setWidth(1) + pen.setBrush(QtCore.Qt.darkGray) + pen.setStyle(QtCore.Qt.DashLine) + + new_rect = rect.adjusted(1, 1, -1, -1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + # painter.drawRect(rect) + painter.drawRect(new_rect) + + def _cache_pix(self): + rect = self.rect() + rect_width = rect.width() + rect_height = rect.height() + + pix_x_offset = 0 + pix_y_offset = 0 + expected_height = int( + (rect_width / self.width_ratio) * self.height_ratio + ) + if expected_height > rect_height: + expected_height = rect_height + expected_width = int( + (rect_height / self.height_ratio) * self.width_ratio + ) + pix_x_offset = (rect_width - expected_width) / 2 + else: + expected_width = rect_width + pix_y_offset = (rect_height - expected_height) / 2 + + if self._current_pixes is None: + used_default_pix = True + pixes_to_draw = None + pixes_len = 1 + else: + used_default_pix = False + pixes_to_draw = self._current_pixes + if len(pixes_to_draw) > self.max_thumbnails: + pixes_to_draw = pixes_to_draw[:-self.max_thumbnails] + pixes_len = len(pixes_to_draw) + + width_offset, height_offset = self._get_pix_offset_size( + expected_width, expected_height, pixes_len + ) + pix_width = expected_width - width_offset + pix_height = expected_height - height_offset + + if used_default_pix: + thumbnail_images = [self._paint_default_pix(pix_width, pix_height)] + else: + thumbnail_images = self._draw_thumbnails( + pixes_to_draw, pix_width, pix_height + ) + + if pixes_len == 1: + width_offset_part = 0 + height_offset_part = 0 + else: + width_offset_part = int(float(width_offset) / (pixes_len - 1)) + height_offset_part = int(float(height_offset) / (pixes_len - 1)) + full_width_offset = width_offset + pix_x_offset + + final_pix = QtGui.QPixmap(rect_width, rect_height) + final_pix.fill(QtCore.Qt.transparent) + + bg_pen = QtGui.QPen() + bg_pen.setWidth(self.border_width) + bg_pen.setColor(self._border_color) + + final_painter = QtGui.QPainter() + final_painter.begin(final_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + final_painter.setRenderHints(render_hints) + + final_painter.setBrush(QtGui.QBrush(self._thumbnail_bg_color)) + final_painter.setPen(bg_pen) + final_painter.drawRect(rect) + + for idx, pix in enumerate(thumbnail_images): + x_offset = full_width_offset - (width_offset_part * idx) + y_offset = (height_offset_part * idx) + pix_y_offset + final_painter.drawPixmap(x_offset, y_offset, pix) + + # Draw drop enabled dashes + if used_default_pix: + self._paint_dash_line(final_painter, rect) + + final_painter.end() + + self._cached_pix = final_pix + + def _get_pix_offset_size(self, width, height, image_count): + if image_count == 1: + return 0, 0 + + part_width = width / self.offset_sep + part_height = height / self.offset_sep + return part_width, part_height diff --git a/openpype/tools/utils/views.py b/openpype/tools/utils/views.py index 01919d6745..596a47ede9 100644 --- a/openpype/tools/utils/views.py +++ b/openpype/tools/utils/views.py @@ -1,4 +1,6 @@ from openpype.resources import get_image_path +from openpype.tools.flickcharm import FlickCharm + from qtpy import QtWidgets, QtCore, QtGui, QtSvg @@ -57,3 +59,63 @@ class TreeViewSpinner(QtWidgets.QTreeView): self.paint_empty(event) else: super(TreeViewSpinner, self).paintEvent(event) + + +class TreeView(QtWidgets.QTreeView): + """Ultimate TreeView with flick charm and double click signals. + + Tree view have deselectable mode, which allows to deselect items by + clicking on item area without any items. + + Todos: + Add refresh animation. + """ + + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + + def __init__(self, *args, **kwargs): + super(TreeView, self).__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super(TreeView, self).mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event) + + return super(TreeView, self).mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 5a8104611b..9223afecaa 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -6,10 +6,13 @@ import qtawesome from openpype.style import ( get_objected_colors, - get_style_image_path + get_style_image_path, + get_default_tools_icon_color, ) from openpype.lib.attribute_definitions import AbstractAttrDef +from .lib import get_qta_icon_by_name_and_color + log = logging.getLogger(__name__) @@ -410,6 +413,18 @@ class PixmapButtonPainter(QtWidgets.QWidget): self._pixmap = pixmap self._cached_pixmap = None + self._disabled = False + + def resizeEvent(self, event): + super(PixmapButtonPainter, self).resizeEvent(event) + self._cached_pixmap = None + self.repaint() + + def set_enabled(self, enabled): + if self._disabled != enabled: + return + self._disabled = not enabled + self.repaint() def set_pixmap(self, pixmap): self._pixmap = pixmap @@ -444,6 +459,8 @@ class PixmapButtonPainter(QtWidgets.QWidget): if self._cached_pixmap is None: self._cache_pixmap() + if self._disabled: + painter.setOpacity(0.5) painter.drawPixmap(0, 0, self._cached_pixmap) painter.end() @@ -464,6 +481,10 @@ class PixmapButton(ClickableFrame): layout.setContentsMargins(*args) self._update_painter_geo() + def setEnabled(self, enabled): + self._button_painter.set_enabled(enabled) + super(PixmapButton, self).setEnabled(enabled) + def set_pixmap(self, pixmap): self._button_painter.set_pixmap(pixmap) @@ -759,3 +780,77 @@ class SeparatorWidget(QtWidgets.QFrame): self._orientation = orientation self._set_size(self._size) + + +def get_refresh_icon(): + return get_qta_icon_by_name_and_color( + "fa.refresh", get_default_tools_icon_color() + ) + + +def get_go_to_current_icon(): + return get_qta_icon_by_name_and_color( + "fa.arrow-down", get_default_tools_icon_color() + ) + + +class VerticalExpandButton(QtWidgets.QPushButton): + """Button which is expanding vertically. + + By default, button is a little bit smaller than other widgets like + QLineEdit. This button is expanding vertically to match size of + other widgets, next to it. + """ + + def __init__(self, parent=None): + super(VerticalExpandButton, self).__init__(parent) + + sp = self.sizePolicy() + sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + self.setSizePolicy(sp) + + +class SquareButton(QtWidgets.QPushButton): + """Make button square shape. + + Change width to match height on resize. + """ + + def __init__(self, *args, **kwargs): + super(SquareButton, self).__init__(*args, **kwargs) + + sp = self.sizePolicy() + sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Minimum) + self.setSizePolicy(sp) + self._ideal_width = None + + def showEvent(self, event): + super(SquareButton, self).showEvent(event) + self._ideal_width = self.height() + self.updateGeometry() + + def resizeEvent(self, event): + super(SquareButton, self).resizeEvent(event) + self._ideal_width = self.height() + self.updateGeometry() + + def sizeHint(self): + sh = super(SquareButton, self).sizeHint() + ideal_width = self._ideal_width + if ideal_width is None: + ideal_width = sh.height() + sh.setWidth(ideal_width) + return sh + + +class RefreshButton(VerticalExpandButton): + def __init__(self, parent=None): + super(RefreshButton, self).__init__(parent) + self.setIcon(get_refresh_icon()) + + +class GoToCurrentButton(VerticalExpandButton): + def __init__(self, parent=None): + super(GoToCurrentButton, self).__init__(parent) + self.setIcon(get_go_to_current_icon()) diff --git a/openpype/vendor/python/common/ayon_api/__init__.py b/openpype/vendor/python/common/ayon_api/__init__.py index 0540d7692d..dc3d361f46 100644 --- a/openpype/vendor/python/common/ayon_api/__init__.py +++ b/openpype/vendor/python/common/ayon_api/__init__.py @@ -30,6 +30,8 @@ from ._api import ( set_client_version, get_default_settings_variant, set_default_settings_variant, + get_sender, + set_sender, get_base_url, get_rest_url, @@ -46,6 +48,11 @@ from ._api import ( patch, delete, + get_timeout, + set_timeout, + get_max_retries, + set_max_retries, + get_event, get_events, dispatch_event, @@ -92,6 +99,7 @@ from ._api import ( get_users, get_attributes_for_type, + get_attributes_fields_for_type, get_default_fields_for_type, get_project_anatomy_preset, @@ -110,6 +118,11 @@ from ._api import ( get_addons_project_settings, get_addons_settings, + get_secrets, + get_secret, + save_secret, + delete_secret, + get_project_names, get_projects, get_project, @@ -124,6 +137,8 @@ from ._api import ( get_folders_hierarchy, get_tasks, + get_task_by_id, + get_task_by_name, get_folder_ids_with_products, get_product_by_id, @@ -154,6 +169,7 @@ from ._api import ( get_workfile_info, get_workfile_info_by_id, + get_thumbnail_by_id, get_thumbnail, get_folder_thumbnail, get_version_thumbnail, @@ -216,6 +232,8 @@ __all__ = ( "set_client_version", "get_default_settings_variant", "set_default_settings_variant", + "get_sender", + "set_sender", "get_base_url", "get_rest_url", @@ -232,6 +250,11 @@ __all__ = ( "patch", "delete", + "get_timeout", + "set_timeout", + "get_max_retries", + "set_max_retries", + "get_event", "get_events", "dispatch_event", @@ -278,6 +301,7 @@ __all__ = ( "get_users", "get_attributes_for_type", + "get_attributes_fields_for_type", "get_default_fields_for_type", "get_project_anatomy_preset", @@ -295,6 +319,11 @@ __all__ = ( "get_addons_project_settings", "get_addons_settings", + "get_secrets", + "get_secret", + "save_secret", + "delete_secret", + "get_project_names", "get_projects", "get_project", @@ -308,6 +337,8 @@ __all__ = ( "get_folders", "get_tasks", + "get_task_by_id", + "get_task_by_name", "get_folder_ids_with_products", "get_product_by_id", @@ -338,6 +369,7 @@ __all__ = ( "get_workfile_info", "get_workfile_info_by_id", + "get_thumbnail_by_id", "get_thumbnail", "get_folder_thumbnail", "get_version_thumbnail", diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py index 26a4b1530a..9d4fc697ae 100644 --- a/openpype/vendor/python/common/ayon_api/_api.py +++ b/openpype/vendor/python/common/ayon_api/_api.py @@ -374,7 +374,7 @@ def get_default_settings_variant(): """ con = get_server_api_connection() - return con.get_client_version() + return con.get_default_settings_variant() def set_default_settings_variant(variant): @@ -392,6 +392,28 @@ def set_default_settings_variant(variant): return con.set_default_settings_variant(variant) +def get_sender(): + """Sender used to send requests. + + Returns: + Union[str, None]: Sender name or None. + """ + + con = get_server_api_connection() + return con.get_sender() + + +def set_sender(sender): + """Change sender used for requests. + + Args: + sender (Union[str, None]): Sender name or None. + """ + + con = get_server_api_connection() + return con.set_sender(sender) + + def get_base_url(): con = get_server_api_connection() return con.get_base_url() @@ -452,6 +474,26 @@ def delete(*args, **kwargs): return con.delete(*args, **kwargs) +def get_timeout(*args, **kwargs): + con = get_server_api_connection() + return con.get_timeout(*args, **kwargs) + + +def set_timeout(*args, **kwargs): + con = get_server_api_connection() + return con.set_timeout(*args, **kwargs) + + +def get_max_retries(*args, **kwargs): + con = get_server_api_connection() + return con.get_max_retries(*args, **kwargs) + + +def set_max_retries(*args, **kwargs): + con = get_server_api_connection() + return con.set_max_retries(*args, **kwargs) + + def get_event(*args, **kwargs): con = get_server_api_connection() return con.get_event(*args, **kwargs) @@ -560,12 +602,12 @@ def delete_installer(*args, **kwargs): def download_installer(*args, **kwargs): con = get_server_api_connection() - con.download_installer(*args, **kwargs) + return con.download_installer(*args, **kwargs) def upload_installer(*args, **kwargs): con = get_server_api_connection() - con.upload_installer(*args, **kwargs) + return con.upload_installer(*args, **kwargs) # Dependency packages @@ -704,6 +746,26 @@ def get_addons_settings(*args, **kwargs): return con.get_addons_settings(*args, **kwargs) +def get_secrets(*args, **kwargs): + con = get_server_api_connection() + return con.get_secrets(*args, **kwargs) + + +def get_secret(*args, **kwargs): + con = get_server_api_connection() + return con.get_secret(*args, **kwargs) + + +def save_secret(*args, **kwargs): + con = get_server_api_connection() + return con.save_secret(*args, **kwargs) + + +def delete_secret(*args, **kwargs): + con = get_server_api_connection() + return con.delete_secret(*args, **kwargs) + + def get_project_names(*args, **kwargs): con = get_server_api_connection() return con.get_project_names(*args, **kwargs) @@ -734,6 +796,16 @@ def get_tasks(*args, **kwargs): return con.get_tasks(*args, **kwargs) +def get_task_by_id(*args, **kwargs): + con = get_server_api_connection() + return con.get_task_by_id(*args, **kwargs) + + +def get_task_by_name(*args, **kwargs): + con = get_server_api_connection() + return con.get_task_by_name(*args, **kwargs) + + def get_folder_by_id(*args, **kwargs): con = get_server_api_connection() return con.get_folder_by_id(*args, **kwargs) @@ -904,9 +976,16 @@ def delete_project(project_name): return con.delete_project(project_name) +def get_thumbnail_by_id(project_name, thumbnail_id): + con = get_server_api_connection() + return con.get_thumbnail_by_id(project_name, thumbnail_id) + + def get_thumbnail(project_name, entity_type, entity_id, thumbnail_id=None): con = get_server_api_connection() - con.get_thumbnail(project_name, entity_type, entity_id, thumbnail_id) + return con.get_thumbnail( + project_name, entity_type, entity_id, thumbnail_id + ) def get_folder_thumbnail(project_name, folder_id, thumbnail_id=None): @@ -934,6 +1013,11 @@ def update_thumbnail(project_name, thumbnail_id, src_filepath): return con.update_thumbnail(project_name, thumbnail_id, src_filepath) +def get_attributes_fields_for_type(entity_type): + con = get_server_api_connection() + return con.get_attributes_fields_for_type(entity_type) + + def get_default_fields_for_type(entity_type): con = get_server_api_connection() return con.get_default_fields_for_type(entity_type) diff --git a/openpype/vendor/python/common/ayon_api/constants.py b/openpype/vendor/python/common/ayon_api/constants.py index e2b05a5cae..c31940d93a 100644 --- a/openpype/vendor/python/common/ayon_api/constants.py +++ b/openpype/vendor/python/common/ayon_api/constants.py @@ -1,9 +1,35 @@ # Environments where server url and api key are stored for global connection SERVER_URL_ENV_KEY = "AYON_SERVER_URL" SERVER_API_ENV_KEY = "AYON_API_KEY" +SERVER_TIMEOUT_ENV_KEY = "AYON_SERVER_TIMEOUT" +SERVER_RETRIES_ENV_KEY = "AYON_SERVER_RETRIES" +# Default variant used for settings +DEFAULT_VARIANT_ENV_KEY = "AYON_DEFAULT_SETTINGS_VARIANT" +# Default site id used for connection +SITE_ID_ENV_KEY = "AYON_SITE_ID" + # Backwards compatibility SERVER_TOKEN_ENV_KEY = SERVER_API_ENV_KEY +# --- User --- +DEFAULT_USER_FIELDS = { + "accessGroups", + "defaultAccessGroups", + "name", + "isService", + "isManager", + "isGuest", + "isAdmin", + "createdAt", + "active", + "hasPassword", + "updatedAt", + "apiKeyPreview", + "attrib.avatarUrl", + "attrib.email", + "attrib.fullName", +} + # --- Product types --- DEFAULT_PRODUCT_TYPE_FIELDS = { "name", @@ -18,6 +44,7 @@ DEFAULT_PROJECT_FIELDS = { "code", "config", "createdAt", + "data", } # --- Folders --- @@ -30,6 +57,7 @@ DEFAULT_FOLDER_FIELDS = { "parentId", "active", "thumbnailId", + "data", } # --- Tasks --- @@ -41,6 +69,7 @@ DEFAULT_TASK_FIELDS = { "folderId", "active", "assignees", + "data", } # --- Products --- @@ -50,6 +79,7 @@ DEFAULT_PRODUCT_FIELDS = { "folderId", "active", "productType", + "data", } # --- Versions --- @@ -64,6 +94,7 @@ DEFAULT_VERSION_FIELDS = { "thumbnailId", "createdAt", "updatedAt", + "data", } # --- Representations --- @@ -74,6 +105,7 @@ DEFAULT_REPRESENTATION_FIELDS = { "createdAt", "active", "versionId", + "data", } REPRESENTATION_FILES_FIELDS = { @@ -97,6 +129,7 @@ DEFAULT_WORKFILE_INFO_FIELDS = { "thumbnailId", "updatedAt", "updatedBy", + "data", } DEFAULT_EVENT_FIELDS = { diff --git a/openpype/vendor/python/common/ayon_api/entity_hub.py b/openpype/vendor/python/common/ayon_api/entity_hub.py index ab1e2584d7..61d740fe57 100644 --- a/openpype/vendor/python/common/ayon_api/entity_hub.py +++ b/openpype/vendor/python/common/ayon_api/entity_hub.py @@ -1,10 +1,11 @@ +import re import copy import collections from abc import ABCMeta, abstractmethod import six from ._api import get_server_api_connection -from .utils import create_entity_id, convert_entity_id +from .utils import create_entity_id, convert_entity_id, slugify_string UNKNOWN_VALUE = object() PROJECT_PARENT_ID = object() @@ -35,11 +36,20 @@ class EntityHub(object): """ def __init__( - self, project_name, connection=None, allow_data_changes=False + self, project_name, connection=None, allow_data_changes=None ): if not connection: connection = get_server_api_connection() + major, minor, patch, _, _ = connection.server_version_tuple + path_start_with_slash = True + if (major, minor) < (0, 6): + path_start_with_slash = False + + if allow_data_changes is None: + allow_data_changes = connection.graphql_allows_data_in_query + self._connection = connection + self._path_start_with_slash = path_start_with_slash self._project_name = project_name self._entities_by_id = {} @@ -64,6 +74,18 @@ class EntityHub(object): return self._allow_data_changes + @property + def path_start_with_slash(self): + """Folder path should start with slash. + + This changed in 0.6.x server version. + + Returns: + bool: Path starts with slash. + """ + + return self._path_start_with_slash + @property def project_name(self): """Project name which is maintained by hub. @@ -545,6 +567,7 @@ class EntityHub(object): library=project["library"], folder_types=project["folderTypes"], task_types=project["taskTypes"], + statuses=project["statuses"], name=project["name"], attribs=project["ownAttrib"], data=project["data"], @@ -775,8 +798,7 @@ class EntityHub(object): "projects/{}".format(self.project_name), **project_changes ) - if response.status_code != 204: - raise ValueError("Failed to update project") + response.raise_for_status() self.project_entity.lock() @@ -1485,6 +1507,722 @@ class BaseEntity(object): self._children_ids = set(children_ids) +class ProjectStatus: + """Project status class. + + Args: + name (str): Name of the status. e.g. 'In progress' + short_name (Optional[str]): Short name of the status. e.g. 'IP' + state (Optional[Literal[not_started, in_progress, done, blocked]]): A + state of the status. + icon (Optional[str]): Icon of the status. e.g. 'play_arrow'. + color (Optional[str]): Color of the status. e.g. '#eeeeee'. + index (Optional[int]): Index of the status. + project_statuses (Optional[_ProjectStatuses]): Project statuses + wrapper. + """ + + valid_states = ("not_started", "in_progress", "done", "blocked") + color_regex = re.compile(r"#([a-f0-9]{6})$") + default_state = "in_progress" + default_color = "#eeeeee" + + def __init__( + self, + name, + short_name=None, + state=None, + icon=None, + color=None, + index=None, + project_statuses=None, + is_new=None, + ): + short_name = short_name or "" + icon = icon or "" + state = state or self.default_state + color = color or self.default_color + self._name = name + self._short_name = short_name + self._icon = icon + self._slugified_name = None + self._state = None + self._color = None + self.set_state(state) + self.set_color(color) + + self._original_name = name + self._original_short_name = short_name + self._original_icon = icon + self._original_state = state + self._original_color = color + self._original_index = index + + self._index = index + self._project_statuses = project_statuses + if is_new is None: + is_new = index is None or project_statuses is None + self._is_new = is_new + + def __str__(self): + short_name = "" + if self.short_name: + short_name = "({})".format(self.short_name) + return "<{} {}{}>".format( + self.__class__.__name__, self.name, short_name + ) + + def __repr__(self): + return str(self) + + def __getitem__(self, key): + if key in { + "name", "short_name", "icon", "state", "color", "slugified_name" + }: + return getattr(self, key) + raise KeyError(key) + + def __setitem__(self, key, value): + if key in {"name", "short_name", "icon", "state", "color"}: + return setattr(self, key, value) + raise KeyError(key) + + def lock(self): + """Lock status. + + Changes were commited and current values are now the original values. + """ + + self._is_new = False + self._original_name = self.name + self._original_short_name = self.short_name + self._original_icon = self.icon + self._original_state = self.state + self._original_color = self.color + self._original_index = self.index + + @staticmethod + def slugify_name(name): + """Slugify status name for name comparison. + + Args: + name (str): Name of the status. + + Returns: + str: Slugified name. + """ + + return slugify_string(name.lower()) + + def get_project_statuses(self): + """Internal logic method. + + Returns: + _ProjectStatuses: Project statuses object. + """ + + return self._project_statuses + + def set_project_statuses(self, project_statuses): + """Internal logic method to change parent object. + + Args: + project_statuses (_ProjectStatuses): Project statuses object. + """ + + self._project_statuses = project_statuses + + def unset_project_statuses(self, project_statuses): + """Internal logic method to unset parent object. + + Args: + project_statuses (_ProjectStatuses): Project statuses object. + """ + + if self._project_statuses is project_statuses: + self._project_statuses = None + self._index = None + + @property + def changed(self): + """Status has changed. + + Returns: + bool: Status has changed. + """ + + return ( + self._is_new + or self._original_name != self._name + or self._original_short_name != self._short_name + or self._original_index != self._index + or self._original_state != self._state + or self._original_icon != self._icon + or self._original_color != self._color + ) + + def delete(self): + """Remove status from project statuses object.""" + + if self._project_statuses is not None: + self._project_statuses.remove(self) + + def get_index(self): + """Get index of status. + + Returns: + Union[int, None]: Index of status or None if status is not under + project. + """ + + return self._index + + def set_index(self, index, **kwargs): + """Change status index. + + Returns: + Union[int, None]: Index of status or None if status is not under + project. + """ + + if kwargs.get("from_parent"): + self._index = index + else: + self._project_statuses.set_status_index(self, index) + + def get_name(self): + """Status name. + + Returns: + str: Status name. + """ + + return self._name + + def set_name(self, name): + """Change status name. + + Args: + name (str): New status name. + """ + + if not isinstance(name, six.string_types): + raise TypeError("Name must be a string.") + if name == self._name: + return + self._name = name + self._slugified_name = None + + def get_short_name(self): + """Status short name 3 letters tops. + + Returns: + str: Status short name. + """ + + return self._short_name + + def set_short_name(self, short_name): + """Change status short name. + + Args: + short_name (str): New status short name. 3 letters tops. + """ + + if not isinstance(short_name, six.string_types): + raise TypeError("Short name must be a string.") + self._short_name = short_name + + def get_icon(self): + """Name of icon to use for status. + + Returns: + str: Name of the icon. + """ + + return self._icon + + def set_icon(self, icon): + """Change status icon name. + + Args: + icon (str): Name of the icon. + """ + + if icon is None: + icon = "" + if not isinstance(icon, six.string_types): + raise TypeError("Icon name must be a string.") + self._icon = icon + + @property + def slugified_name(self): + """Slugified and lowere status name. + + Can be used for comparison of existing statuses. e.g. 'In Progress' + vs. 'in-progress'. + + Returns: + str: Slugified and lower status name. + """ + + if self._slugified_name is None: + self._slugified_name = self.slugify_name(self.name) + return self._slugified_name + + def get_state(self): + """Get state of project status. + + Return: + Literal[not_started, in_progress, done, blocked]: General + state of status. + """ + + return self._state + + def set_state(self, state): + """Set color of project status. + + Args: + state (Literal[not_started, in_progress, done, blocked]): General + state of status. + """ + + if state not in self.valid_states: + raise ValueError("Invalid state '{}'".format(str(state))) + self._state = state + + def get_color(self): + """Get color of project status. + + Returns: + str: Status color. + """ + + return self._color + + def set_color(self, color): + """Set color of project status. + + Args: + color (str): Color in hex format. Example: '#ff0000'. + """ + + if not isinstance(color, six.string_types): + raise TypeError( + "Color must be string got '{}'".format(type(color))) + color = color.lower() + if self.color_regex.fullmatch(color) is None: + raise ValueError("Invalid color value '{}'".format(color)) + self._color = color + + name = property(get_name, set_name) + short_name = property(get_short_name, set_short_name) + project_statuses = property(get_project_statuses, set_project_statuses) + index = property(get_index, set_index) + state = property(get_state, set_state) + color = property(get_color, set_color) + icon = property(get_icon, set_icon) + + def _validate_other_p_statuses(self, other): + """Validate if other status can be used for move. + + To be able to work with other status, and position them in relation, + they must belong to same existing object of '_ProjectStatuses'. + + Args: + other (ProjectStatus): Other status to validate. + """ + + o_project_statuses = other.project_statuses + m_project_statuses = self.project_statuses + if o_project_statuses is None and m_project_statuses is None: + raise ValueError("Both statuses are not assigned to a project.") + + missing_status = None + if o_project_statuses is None: + missing_status = other + elif m_project_statuses is None: + missing_status = self + if missing_status is not None: + raise ValueError( + "Status '{}' is not assigned to a project.".format( + missing_status.name)) + if m_project_statuses is not o_project_statuses: + raise ValueError( + "Statuse are assigned to different projects." + " Cannot execute move." + ) + + def move_before(self, other): + """Move status before other status. + + Args: + other (ProjectStatus): Status to move before. + """ + + self._validate_other_p_statuses(other) + self._project_statuses.set_status_index(self, other.index) + + def move_after(self, other): + """Move status after other status. + + Args: + other (ProjectStatus): Status to move after. + """ + + self._validate_other_p_statuses(other) + self._project_statuses.set_status_index(self, other.index + 1) + + def to_data(self): + """Convert status to data. + + Returns: + dict[str, str]: Status data. + """ + + output = { + "name": self.name, + "shortName": self.short_name, + "state": self.state, + "icon": self.icon, + "color": self.color, + } + if ( + not self._is_new + and self._original_name + and self.name != self._original_name + ): + output["original_name"] = self._original_name + return output + + @classmethod + def from_data(cls, data, index=None, project_statuses=None): + """Create project status from data. + + Args: + data (dict[str, str]): Status data. + index (Optional[int]): Status index. + project_statuses (Optional[ProjectStatuses]): Project statuses + object which wraps the status for a project. + """ + + return cls( + data["name"], + data.get("shortName", data.get("short_name")), + data.get("state"), + data.get("icon"), + data.get("color"), + index=index, + project_statuses=project_statuses + ) + + +class _ProjectStatuses: + """Wrapper for project statuses. + + Supports basic methods to add, change or remove statuses from a project. + + To add new statuses use 'create' or 'add_status' methods. To change + statuses receive them by one of the getter methods and change their + values. + + Todos: + Validate if statuses are duplicated. + """ + + def __init__(self, statuses): + self._statuses = [ + ProjectStatus.from_data(status, idx, self) + for idx, status in enumerate(statuses) + ] + self._orig_status_length = len(self._statuses) + self._set_called = False + + def __len__(self): + return len(self._statuses) + + def __iter__(self): + """Iterate over statuses. + + Yields: + ProjectStatus: Project status. + """ + + for status in self._statuses: + yield status + + def create( + self, + name, + short_name=None, + state=None, + icon=None, + color=None, + ): + """Create project status. + + Args: + name (str): Name of the status. e.g. 'In progress' + short_name (Optional[str]): Short name of the status. e.g. 'IP' + state (Optional[Literal[not_started, in_progress, done, blocked]]): A + state of the status. + icon (Optional[str]): Icon of the status. e.g. 'play_arrow'. + color (Optional[str]): Color of the status. e.g. '#eeeeee'. + + Returns: + ProjectStatus: Created project status. + """ + + status = ProjectStatus( + name, short_name, state, icon, color, is_new=True + ) + self.append(status) + return status + + def lock(self): + """Lock statuses. + + Changes were commited and current values are now the original values. + """ + + self._orig_status_length = len(self._statuses) + self._set_called = False + for status in self._statuses: + status.lock() + + def to_data(self): + """Convert to project statuses data.""" + + return [ + status.to_data() + for status in self._statuses + ] + + def set(self, statuses): + """Explicitly override statuses. + + This method does not handle if statuses changed or not. + + Args: + statuses (list[dict[str, str]]): List of statuses data. + """ + + self._set_called = True + self._statuses = [ + ProjectStatus.from_data(status, idx, self) + for idx, status in enumerate(statuses) + ] + + @property + def changed(self): + """Statuses have changed. + + Returns: + bool: True if statuses changed, False otherwise. + """ + + if self._set_called: + return True + + # Check if status length changed + # - when all statuses are removed it is a changed + if self._orig_status_length != len(self._statuses): + return True + # Go through all statuses and check if any of them changed + for status in self._statuses: + if status.changed: + return True + return False + + def get(self, name, default=None): + """Get status by name. + + Args: + name (str): Status name. + default (Any): Default value of status is not found. + + Returns: + Union[ProjectStatus, Any]: Status or default value. + """ + + return next( + ( + status + for status in self._statuses + if status.name == name + ), + default + ) + + get_status_by_name = get + + def index(self, status, **kwargs): + """Get status index. + + Args: + status (ProjectStatus): Status to get index of. + default (Optional[Any]): Default value if status is not found. + + Returns: + Union[int, Any]: Status index. + + Raises: + ValueError: If status is not found and default value is not + defined. + """ + + output = next( + ( + idx + for idx, st in enumerate(self._statuses) + if st is status + ), + None + ) + if output is not None: + return output + + if "default" in kwargs: + return kwargs["default"] + raise ValueError("Status '{}' not found".format(status.name)) + + def get_status_by_slugified_name(self, name): + """Get status by slugified name. + + Args: + name (str): Status name. Is slugified before search. + + Returns: + Union[ProjectStatus, None]: Status or None if not found. + """ + + slugified_name = ProjectStatus.slugify_name(name) + return next( + ( + status + for status in self._statuses + if status.slugified_name == slugified_name + ), + None + ) + + def remove_by_name(self, name, ignore_missing=False): + """Remove status by name. + + Args: + name (str): Status name. + ignore_missing (Optional[bool]): If True, no error is raised if + status is not found. + + Returns: + ProjectStatus: Removed status. + """ + + matching_status = self.get(name) + if matching_status is None: + if ignore_missing: + return + raise ValueError( + "Status '{}' not found in project".format(name)) + return self.remove(matching_status) + + def remove(self, status, ignore_missing=False): + """Remove status. + + Args: + status (ProjectStatus): Status to remove. + ignore_missing (Optional[bool]): If True, no error is raised if + status is not found. + + Returns: + Union[ProjectStatus, None]: Removed status. + """ + + index = self.index(status, default=None) + if index is None: + if ignore_missing: + return None + raise ValueError("Status '{}' not in project".format(status)) + + return self.pop(index) + + def pop(self, index): + """Remove status by index. + + Args: + index (int): Status index. + + Returns: + ProjectStatus: Removed status. + """ + + status = self._statuses.pop(index) + status.unset_project_statuses(self) + for st in self._statuses[index:]: + st.set_index(st.index - 1, from_parent=True) + return status + + def insert(self, index, status): + """Insert status at index. + + Args: + index (int): Status index. + status (Union[ProjectStatus, dict[str, str]]): Status to insert. + Can be either status object or status data. + + Returns: + ProjectStatus: Inserted status. + """ + + if not isinstance(status, ProjectStatus): + status = ProjectStatus.from_data(status) + + start_index = index + end_index = len(self._statuses) + 1 + matching_index = self.index(status, default=None) + if matching_index is not None: + if matching_index == index: + status.set_index(index, from_parent=True) + return + + self._statuses.pop(matching_index) + if matching_index < index: + start_index = matching_index + end_index = index + 1 + else: + end_index -= 1 + + status.set_project_statuses(self) + self._statuses.insert(index, status) + for idx, st in enumerate(self._statuses[start_index:end_index]): + st.set_index(start_index + idx, from_parent=True) + return status + + def append(self, status): + """Add new status to the end of the list. + + Args: + status (Union[ProjectStatus, dict[str, str]]): Status to insert. + Can be either status object or status data. + + Returns: + ProjectStatus: Inserted status. + """ + + return self.insert(len(self._statuses), status) + + def set_status_index(self, status, index): + """Set status index. + + Args: + status (ProjectStatus): Status to set index. + index (int): New status index. + """ + + return self.insert(index, status) + + class ProjectEntity(BaseEntity): """Entity representing project on AYON server. @@ -1514,7 +2252,14 @@ class ProjectEntity(BaseEntity): default_task_type_icon = "task_alt" def __init__( - self, project_code, library, folder_types, task_types, *args, **kwargs + self, + project_code, + library, + folder_types, + task_types, + statuses, + *args, + **kwargs ): super(ProjectEntity, self).__init__(*args, **kwargs) @@ -1522,11 +2267,13 @@ class ProjectEntity(BaseEntity): self._library_project = library self._folder_types = folder_types self._task_types = task_types + self._statuses_obj = _ProjectStatuses(statuses) self._orig_project_code = project_code self._orig_library_project = library self._orig_folder_types = copy.deepcopy(folder_types) self._orig_task_types = copy.deepcopy(task_types) + self._orig_statuses = copy.deepcopy(statuses) def _prepare_entity_id(self, entity_id): if entity_id != self.project_name: @@ -1573,13 +2320,24 @@ class ProjectEntity(BaseEntity): new_task_types.append(task_type) self._task_types = new_task_types + def get_orig_statuses(self): + return copy.deepcopy(self._orig_statuses) + + def get_statuses(self): + return self._statuses_obj + + def set_statuses(self, statuses): + self._statuses_obj.set(statuses) + folder_types = property(get_folder_types, set_folder_types) task_types = property(get_task_types, set_task_types) + statuses = property(get_statuses, set_statuses) def lock(self): super(ProjectEntity, self).lock() self._orig_folder_types = copy.deepcopy(self._folder_types) self._orig_task_types = copy.deepcopy(self._task_types) + self._statuses_obj.lock() @property def changes(self): @@ -1590,6 +2348,9 @@ class ProjectEntity(BaseEntity): if self._orig_task_types != self._task_types: changes["taskTypes"] = self.get_task_types() + if self._statuses_obj.changed: + changes["statuses"] = self._statuses_obj.to_data() + return changes @classmethod @@ -1679,10 +2440,13 @@ class FolderEntity(BaseEntity): if self._path is None: parent = self.parent - path = self.name if parent.entity_type == "folder": parent_path = parent.path - path = "/".join([parent_path, path]) + path = "/".join([parent_path, self.name]) + elif self._entity_hub.path_start_with_slash: + path = "/{}".format(self.name) + else: + path = self.name self._path = path return self._path @@ -1785,7 +2549,10 @@ class FolderEntity(BaseEntity): if self.thumbnail_id is not UNKNOWN_VALUE: output["thumbnailId"] = self.thumbnail_id - if self._entity_hub.allow_data_changes: + if ( + self._entity_hub.allow_data_changes + and self._data is not UNKNOWN_VALUE + ): output["data"] = self._data return output diff --git a/openpype/vendor/python/common/ayon_api/graphql.py b/openpype/vendor/python/common/ayon_api/graphql.py index 854f207a00..2594b45966 100644 --- a/openpype/vendor/python/common/ayon_api/graphql.py +++ b/openpype/vendor/python/common/ayon_api/graphql.py @@ -202,6 +202,15 @@ class GraphQlQuery: self._variables[key]["value"] = value + def get_variable_keys(self): + """Get all variable keys. + + Returns: + set[str]: Variable keys. + """ + + return set(self._variables.keys()) + def get_variables_values(self): """Calculate variable values used that should be used in query. diff --git a/openpype/vendor/python/common/ayon_api/graphql_queries.py b/openpype/vendor/python/common/ayon_api/graphql_queries.py index 4af8c53e4e..f121a5649f 100644 --- a/openpype/vendor/python/common/ayon_api/graphql_queries.py +++ b/openpype/vendor/python/common/ayon_api/graphql_queries.py @@ -144,6 +144,7 @@ def product_types_query(fields): query_queue.append((k, v, field)) return query + def project_product_types_query(fields): query = GraphQlQuery("ProjectProductTypes") project_query = query.add_field("project") @@ -174,8 +175,15 @@ def folders_graphql_query(fields): folder_ids_var = query.add_variable("folderIds", "[String!]") parent_folder_ids_var = query.add_variable("parentFolderIds", "[String!]") folder_paths_var = query.add_variable("folderPaths", "[String!]") + folder_path_regex_var = query.add_variable("folderPathRegex", "String!") folder_names_var = query.add_variable("folderNames", "[String!]") + folder_types_var = query.add_variable("folderTypes", "[String!]") has_products_var = query.add_variable("folderHasProducts", "Boolean!") + has_tasks_var = query.add_variable("folderHasTasks", "Boolean!") + has_links_var = query.add_variable("folderHasLinks", "HasLinksFilter") + has_children_var = query.add_variable("folderHasChildren", "Boolean!") + statuses_var = query.add_variable("folderStatuses", "[String!]") + tags_var = query.add_variable("folderTags", "[String!]") project_field = query.add_field("project") project_field.set_filter("name", project_name_var) @@ -185,7 +193,14 @@ def folders_graphql_query(fields): folders_field.set_filter("parentIds", parent_folder_ids_var) folders_field.set_filter("names", folder_names_var) folders_field.set_filter("paths", folder_paths_var) + folders_field.set_filter("pathEx", folder_path_regex_var) + folders_field.set_filter("folderTypes", folder_types_var) + folders_field.set_filter("statuses", statuses_var) + folders_field.set_filter("tags", tags_var) folders_field.set_filter("hasProducts", has_products_var) + folders_field.set_filter("hasTasks", has_tasks_var) + folders_field.set_filter("hasLinks", has_links_var) + folders_field.set_filter("hasChildren", has_children_var) nested_fields = fields_to_dict(fields) add_links_fields(folders_field, nested_fields) @@ -213,6 +228,10 @@ def tasks_graphql_query(fields): task_names_var = query.add_variable("taskNames", "[String!]") task_types_var = query.add_variable("taskTypes", "[String!]") folder_ids_var = query.add_variable("folderIds", "[String!]") + assignees_any_var = query.add_variable("taskAssigneesAny", "[String!]") + assignees_all_var = query.add_variable("taskAssigneesAll", "[String!]") + statuses_var = query.add_variable("taskStatuses", "[String!]") + tags_var = query.add_variable("taskTags", "[String!]") project_field = query.add_field("project") project_field.set_filter("name", project_name_var) @@ -223,6 +242,10 @@ def tasks_graphql_query(fields): tasks_field.set_filter("names", task_names_var) tasks_field.set_filter("taskTypes", task_types_var) tasks_field.set_filter("folderIds", folder_ids_var) + tasks_field.set_filter("assigneesAny", assignees_any_var) + tasks_field.set_filter("assignees", assignees_all_var) + tasks_field.set_filter("statuses", statuses_var) + tasks_field.set_filter("tags", tags_var) nested_fields = fields_to_dict(fields) add_links_fields(tasks_field, nested_fields) @@ -247,9 +270,14 @@ def products_graphql_query(fields): query = GraphQlQuery("ProductsQuery") project_name_var = query.add_variable("projectName", "String!") - folder_ids_var = query.add_variable("folderIds", "[String!]") product_ids_var = query.add_variable("productIds", "[String!]") product_names_var = query.add_variable("productNames", "[String!]") + folder_ids_var = query.add_variable("folderIds", "[String!]") + product_types_var = query.add_variable("productTypes", "[String!]") + product_name_regex_var = query.add_variable("productNameRegex", "String!") + product_path_regex_var = query.add_variable("productPathRegex", "String!") + statuses_var = query.add_variable("productStatuses.", "[String!]") + tags_var = query.add_variable("productTags.", "[String!]") project_field = query.add_field("project") project_field.set_filter("name", project_name_var) @@ -258,6 +286,11 @@ def products_graphql_query(fields): products_field.set_filter("ids", product_ids_var) products_field.set_filter("names", product_names_var) products_field.set_filter("folderIds", folder_ids_var) + products_field.set_filter("productTypes", product_types_var) + products_field.set_filter("statuses", statuses_var) + products_field.set_filter("tags", tags_var) + products_field.set_filter("nameEx", product_name_regex_var) + products_field.set_filter("pathEx", product_path_regex_var) nested_fields = fields_to_dict(set(fields)) add_links_fields(products_field, nested_fields) @@ -290,24 +323,28 @@ def versions_graphql_query(fields): hero_or_latest_only_var = query.add_variable( "heroOrLatestOnly", "Boolean" ) + statuses_var = query.add_variable("versionStatuses", "[String!]") + tags_var = query.add_variable("versionTags", "[String!]") project_field = query.add_field("project") project_field.set_filter("name", project_name_var) - products_field = project_field.add_field_with_edges("versions") - products_field.set_filter("ids", version_ids_var) - products_field.set_filter("productIds", product_ids_var) - products_field.set_filter("versions", versions_var) - products_field.set_filter("heroOnly", hero_only_var) - products_field.set_filter("latestOnly", latest_only_var) - products_field.set_filter("heroOrLatestOnly", hero_or_latest_only_var) + versions_field = project_field.add_field_with_edges("versions") + versions_field.set_filter("ids", version_ids_var) + versions_field.set_filter("productIds", product_ids_var) + versions_field.set_filter("versions", versions_var) + versions_field.set_filter("heroOnly", hero_only_var) + versions_field.set_filter("latestOnly", latest_only_var) + versions_field.set_filter("heroOrLatestOnly", hero_or_latest_only_var) + versions_field.set_filter("statuses", statuses_var) + versions_field.set_filter("tags", tags_var) nested_fields = fields_to_dict(set(fields)) - add_links_fields(products_field, nested_fields) + add_links_fields(versions_field, nested_fields) query_queue = collections.deque() for key, value in nested_fields.items(): - query_queue.append((key, value, products_field)) + query_queue.append((key, value, versions_field)) while query_queue: item = query_queue.popleft() @@ -328,6 +365,13 @@ def representations_graphql_query(fields): repre_ids_var = query.add_variable("representationIds", "[String!]") repre_names_var = query.add_variable("representationNames", "[String!]") version_ids_var = query.add_variable("versionIds", "[String!]") + has_links_var = query.add_variable("representationHasLinks", "HasLinksFilter") + statuses_var = query.add_variable( + "representationStatuses", "[String!]" + ) + tags_var = query.add_variable( + "representationTags", "[String!]" + ) project_field = query.add_field("project") project_field.set_filter("name", project_name_var) @@ -336,6 +380,9 @@ def representations_graphql_query(fields): repres_field.set_filter("ids", repre_ids_var) repres_field.set_filter("versionIds", version_ids_var) repres_field.set_filter("names", repre_names_var) + repres_field.set_filter("hasLinks", has_links_var) + repres_field.set_filter("statuses", statuses_var) + repres_field.set_filter("tags", tags_var) nested_fields = fields_to_dict(set(fields)) add_links_fields(repres_field, nested_fields) @@ -403,6 +450,10 @@ def workfiles_info_graphql_query(fields): workfiles_info_ids = query.add_variable("workfileIds", "[String!]") task_ids_var = query.add_variable("taskIds", "[String!]") paths_var = query.add_variable("paths", "[String!]") + path_regex_var = query.add_variable("workfilePathRegex", "String!") + has_links_var = query.add_variable("workfilehasLinks", "HasLinksFilter") + statuses_var = query.add_variable("workfileStatuses", "[String!]") + tags_var = query.add_variable("workfileTags", "[String!]") project_field = query.add_field("project") project_field.set_filter("name", project_name_var) @@ -411,6 +462,10 @@ def workfiles_info_graphql_query(fields): workfiles_field.set_filter("ids", workfiles_info_ids) workfiles_field.set_filter("taskIds", task_ids_var) workfiles_field.set_filter("paths", paths_var) + workfiles_field.set_filter("pathEx", path_regex_var) + workfiles_field.set_filter("hasLinks", has_links_var) + workfiles_field.set_filter("statuses", statuses_var) + workfiles_field.set_filter("tags", tags_var) nested_fields = fields_to_dict(set(fields)) add_links_fields(workfiles_field, nested_fields) @@ -438,6 +493,9 @@ def events_graphql_query(fields): states_var = query.add_variable("eventStates", "[String!]") users_var = query.add_variable("eventUsers", "[String!]") include_logs_var = query.add_variable("includeLogsFilter", "Boolean!") + has_children_var = query.add_variable("hasChildrenFilter", "Boolean!") + newer_than_var = query.add_variable("newerThanFilter", "String!") + older_than_var = query.add_variable("olderThanFilter", "String!") events_field = query.add_field_with_edges("events") events_field.set_filter("topics", topics_var) @@ -445,6 +503,9 @@ def events_graphql_query(fields): events_field.set_filter("states", states_var) events_field.set_filter("users", users_var) events_field.set_filter("includeLogs", include_logs_var) + events_field.set_filter("hasChildren", has_children_var) + events_field.set_filter("newerThan", newer_than_var) + events_field.set_filter("olderThan", older_than_var) nested_fields = fields_to_dict(set(fields)) @@ -462,3 +523,28 @@ def events_graphql_query(fields): for k, v in value.items(): query_queue.append((k, v, field)) return query + + +def users_graphql_query(fields): + query = GraphQlQuery("Users") + names_var = query.add_variable("userNames", "[String!]") + + users_field = query.add_field_with_edges("users") + users_field.set_filter("names", names_var) + + nested_fields = fields_to_dict(set(fields)) + + query_queue = collections.deque() + for key, value in nested_fields.items(): + query_queue.append((key, value, users_field)) + + while query_queue: + item = query_queue.popleft() + key, value, parent = item + field = parent.add_field(key) + if value is FIELD_VALUE: + continue + + for k, v in value.items(): + query_queue.append((k, v, field)) + return query diff --git a/openpype/vendor/python/common/ayon_api/operations.py b/openpype/vendor/python/common/ayon_api/operations.py index 7cf610a566..eb2ca8afe3 100644 --- a/openpype/vendor/python/common/ayon_api/operations.py +++ b/openpype/vendor/python/common/ayon_api/operations.py @@ -1,3 +1,4 @@ +import os import copy import collections import uuid @@ -22,6 +23,8 @@ def new_folder_entity( name, folder_type, parent_id=None, + status=None, + tags=None, attribs=None, data=None, thumbnail_id=None, @@ -32,12 +35,14 @@ def new_folder_entity( Args: name (str): Is considered as unique identifier of folder in project. folder_type (str): Type of folder. - parent_id (Optional[str]]): Id of parent folder. + parent_id (Optional[str]): Parent folder id. + status (Optional[str]): Product status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of folder. data (Optional[Dict[str, Any]]): Custom folder data. Empty dictionary is used if not passed. - thumbnail_id (Optional[str]): Id of thumbnail related to folder. + thumbnail_id (Optional[str]): Thumbnail id related to folder. entity_id (Optional[str]): Predefined id of entity. New id is created if not passed. @@ -54,7 +59,7 @@ def new_folder_entity( if parent_id is not None: parent_id = _create_or_convert_to_id(parent_id) - return { + output = { "id": _create_or_convert_to_id(entity_id), "name": name, # This will be ignored @@ -64,6 +69,11 @@ def new_folder_entity( "attrib": attribs, "thumbnailId": thumbnail_id } + if status: + output["status"] = status + if tags: + output["tags"] = tags + return output def new_product_entity( @@ -71,6 +81,7 @@ def new_product_entity( product_type, folder_id, status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -81,8 +92,9 @@ def new_product_entity( name (str): Is considered as unique identifier of product under folder. product_type (str): Product type. - folder_id (str): Id of parent folder. + folder_id (str): Parent folder id. status (Optional[str]): Product status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of product. data (Optional[Dict[str, Any]]): product entity data. Empty dictionary @@ -110,6 +122,8 @@ def new_product_entity( } if status: output["status"] = status + if tags: + output["tags"] = tags return output @@ -119,6 +133,8 @@ def new_version_entity( task_id=None, thumbnail_id=None, author=None, + status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -128,10 +144,12 @@ def new_version_entity( Args: version (int): Is considered as unique identifier of version under product. - product_id (str): Id of parent product. - task_id (Optional[str]]): Id of task under which product was created. - thumbnail_id (Optional[str]]): Thumbnail related to version. - author (Optional[str]]): Name of version author. + product_id (str): Parent product id. + task_id (Optional[str]): Task id under which product was created. + thumbnail_id (Optional[str]): Thumbnail related to version. + author (Optional[str]): Name of version author. + status (Optional[str]): Version status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of version. data (Optional[Dict[str, Any]]): Version entity custom data. @@ -164,6 +182,10 @@ def new_version_entity( output["thumbnailId"] = thumbnail_id if author: output["author"] = author + if tags: + output["tags"] = tags + if status: + output["status"] = status return output @@ -173,6 +195,8 @@ def new_hero_version_entity( task_id=None, thumbnail_id=None, author=None, + status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -182,10 +206,12 @@ def new_hero_version_entity( Args: version (int): Is considered as unique identifier of version under product. Should be same as standard version if there is any. - product_id (str): Id of parent product. - task_id (Optional[str]): Id of task under which product was created. + product_id (str): Parent product id. + task_id (Optional[str]): Task id under which product was created. thumbnail_id (Optional[str]): Thumbnail related to version. author (Optional[str]): Name of version author. + status (Optional[str]): Version status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of version. data (Optional[Dict[str, Any]]): Version entity data. @@ -215,18 +241,32 @@ def new_hero_version_entity( output["thumbnailId"] = thumbnail_id if author: output["author"] = author + if tags: + output["tags"] = tags + if status: + output["status"] = status return output def new_representation_entity( - name, version_id, attribs=None, data=None, entity_id=None + name, + version_id, + files, + status=None, + tags=None, + attribs=None, + data=None, + entity_id=None ): """Create skeleton data of representation entity. Args: name (str): Representation name considered as unique identifier of representation under version. - version_id (str): Id of parent version. + version_id (str): Parent version id. + files (list[dict[str, str]]): List of files in representation. + status (Optional[str]): Representation status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of representation. data (Optional[Dict[str, Any]]): Representation entity data. @@ -243,27 +283,42 @@ def new_representation_entity( if data is None: data = {} - return { + output = { "id": _create_or_convert_to_id(entity_id), "versionId": _create_or_convert_to_id(version_id), + "files": files, "name": name, "data": data, "attrib": attribs } + if tags: + output["tags"] = tags + if status: + output["status"] = status + return output -def new_workfile_info_doc( - filename, folder_id, task_name, files, data=None, entity_id=None +def new_workfile_info( + filepath, + task_id, + status=None, + tags=None, + attribs=None, + description=None, + data=None, + entity_id=None ): """Create skeleton data of workfile info entity. Workfile entity is at this moment used primarily for artist notes. Args: - filename (str): Filename of workfile. - folder_id (str): Id of folder under which workfile live. - task_name (str): Task under which was workfile created. - files (List[str]): List of rootless filepaths related to workfile. + filepath (str): Rootless workfile filepath. + task_id (str): Task under which was workfile created. + status (Optional[str]): Workfile status. + tags (Optional[List[str]]): Workfile tags. + attribs (Options[dic[str, Any]]): Explicitly set attributes. + description (Optional[str]): Workfile description. data (Optional[Dict[str, Any]]): Additional metadata. entity_id (Optional[str]): Predefined id of entity. New id is created if not passed. @@ -272,17 +327,31 @@ def new_workfile_info_doc( Dict[str, Any]: Skeleton of workfile info entity. """ + if attribs is None: + attribs = {} + + if "extension" not in attribs: + attribs["extension"] = os.path.splitext(filepath)[-1] + + if description: + attribs["description"] = description + if not data: data = {} - return { + output = { "id": _create_or_convert_to_id(entity_id), - "parent": _create_or_convert_to_id(folder_id), - "task_name": task_name, - "filename": filename, + "taskId": task_id, + "path": filepath, "data": data, - "files": files + "attrib": attribs } + if status: + output["status"] = status + + if tags: + output["tags"] = tags + return output @six.add_metaclass(ABCMeta) diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py index c578124cfc..e4e7146279 100644 --- a/openpype/vendor/python/common/ayon_api/server_api.py +++ b/openpype/vendor/python/common/ayon_api/server_api.py @@ -2,21 +2,35 @@ import os import re import io import json +import time import logging import collections import platform import copy import uuid from contextlib import contextmanager + +import six + try: from http import HTTPStatus except ImportError: HTTPStatus = None import requests -from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError +try: + # This should be used if 'requests' have it available + from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError +except ImportError: + # Older versions of 'requests' don't have custom exception for json + # decode error + try: + from simplejson import JSONDecodeError as RequestsJSONDecodeError + except ImportError: + from json import JSONDecodeError as RequestsJSONDecodeError from .constants import ( + SERVER_RETRIES_ENV_KEY, DEFAULT_PRODUCT_TYPE_FIELDS, DEFAULT_PROJECT_FIELDS, DEFAULT_FOLDER_FIELDS, @@ -27,8 +41,8 @@ from .constants import ( REPRESENTATION_FILES_FIELDS, DEFAULT_WORKFILE_INFO_FIELDS, DEFAULT_EVENT_FIELDS, + DEFAULT_USER_FIELDS, ) -from .thumbnails import ThumbnailCache from .graphql import GraphQlQuery, INTROSPECTION_QUERY from .graphql_queries import ( project_graphql_query, @@ -43,6 +57,7 @@ from .graphql_queries import ( representations_parents_qraphql_query, workfiles_info_graphql_query, events_graphql_query, + users_graphql_query, ) from .exceptions import ( FailedOperations, @@ -61,8 +76,13 @@ from .utils import ( failed_json_default, TransferProgress, create_dependency_package_basename, + ThumbnailContent, + get_default_timeout, + get_default_settings_variant, + get_default_site_id, ) +_PLACEHOLDER = object() PatternType = type(re.compile("")) JSONDecodeError = getattr(json, "JSONDecodeError", ValueError) # This should be collected from server schema @@ -116,6 +136,8 @@ class RestApiResponse(object): @property def text(self): + if self._response is None: + return self.detail return self._response.text @property @@ -124,6 +146,8 @@ class RestApiResponse(object): @property def headers(self): + if self._response is None: + return {} return self._response.headers @property @@ -137,6 +161,8 @@ class RestApiResponse(object): @property def content(self): + if self._response is None: + return b"" return self._response.content @property @@ -155,6 +181,11 @@ class RestApiResponse(object): return self.status def raise_for_status(self, message=None): + if self._response is None: + if self._data and self._data.get("detail"): + raise ServerError(self._data["detail"]) + raise ValueError("Response is not available.") + try: self._response.raise_for_status() except requests.exceptions.HTTPError as exc: @@ -319,6 +350,8 @@ class ServerAPI(object): default_settings_variant (Optional[Literal["production", "staging"]]): Settings variant used by default if a method for settings won't get any (by default is 'production'). + sender (Optional[str]): Sender of requests. Used in server logs and + propagated into events. ssl_verify (Union[bool, str, None]): Verify SSL certificate Looks for env variable value 'AYON_CA_FILE' by default. If not available then 'True' is used. @@ -326,18 +359,28 @@ class ServerAPI(object): variable value 'AYON_CERT_FILE' by default. create_session (Optional[bool]): Create session for connection if token is available. Default is True. + timeout (Optional[float]): Timeout for requests. + max_retries (Optional[int]): Number of retries for requests. """ + _default_max_retries = 3 + # 1 MB chunk by default + # TODO find out if these are reasonable default value + default_download_chunk_size = 1024 * 1024 + default_upload_chunk_size = 1024 * 1024 def __init__( self, base_url, token=None, - site_id=None, + site_id=_PLACEHOLDER, client_version=None, default_settings_variant=None, + sender=None, ssl_verify=None, cert=None, create_session=True, + timeout=None, + max_retries=None, ): if not base_url: raise ValueError("Invalid server URL {}".format(str(base_url))) @@ -348,12 +391,23 @@ class ServerAPI(object): self._graphql_url = "{}/graphql".format(base_url) self._log = None self._access_token = token + # Allow to have 'site_id' to 'None' + if site_id is _PLACEHOLDER: + site_id = get_default_site_id() self._site_id = site_id self._client_version = client_version self._default_settings_variant = ( default_settings_variant - or "production" + or get_default_settings_variant() ) + self._sender = sender + + self._timeout = None + self._max_retries = None + + # Set timeout and max retries based on passed values + self.set_timeout(timeout) + self.set_max_retries(max_retries) if ssl_verify is None: # Custom AYON env variable for CA file or 'True' @@ -374,6 +428,8 @@ class ServerAPI(object): self._server_version = None self._server_version_tuple = None + self._graphql_allows_data_in_query = None + self._session = None self._base_functions_mapping = { @@ -390,7 +446,6 @@ class ServerAPI(object): self._entity_type_attributes_cache = {} self._as_user_stack = _AsUserStack() - self._thumbnail_cache = ThumbnailCache(True) # Create session if self._access_token and create_session: @@ -460,6 +515,80 @@ class ServerAPI(object): ssl_verify = property(get_ssl_verify, set_ssl_verify) cert = property(get_cert, set_cert) + @classmethod + def get_default_timeout(cls): + """Default value for requests timeout. + + Utils function 'get_default_timeout' is used by default. + + Returns: + float: Timeout value in seconds. + """ + + return get_default_timeout() + + @classmethod + def get_default_max_retries(cls): + """Default value for requests max retries. + + First looks for environment variable SERVER_RETRIES_ENV_KEY, which + can affect max retries value. If not available then use class + attribute '_default_max_retries'. + + Returns: + int: Max retries value. + """ + + try: + return int(os.environ.get(SERVER_RETRIES_ENV_KEY)) + except (ValueError, TypeError): + pass + + return cls._default_max_retries + + def get_timeout(self): + """Current value for requests timeout. + + Returns: + float: Timeout value in seconds. + """ + + return self._timeout + + def set_timeout(self, timeout): + """Change timeout value for requests. + + Args: + timeout (Union[float, None]): Timeout value in seconds. + """ + + if timeout is None: + timeout = self.get_default_timeout() + self._timeout = float(timeout) + + def get_max_retries(self): + """Current value for requests max retries. + + Returns: + int: Max retries value. + """ + + return self._max_retries + + def set_max_retries(self, max_retries): + """Change max retries value for requests. + + Args: + max_retries (Union[int, None]): Max retries value. + """ + + if max_retries is None: + max_retries = self.get_default_max_retries() + self._max_retries = int(max_retries) + + timeout = property(get_timeout, set_timeout) + max_retries = property(get_max_retries, set_max_retries) + @property def access_token(self): """Access token used for authorization to server. @@ -545,13 +674,10 @@ class ServerAPI(object): as default variant. Args: - variant (Literal['production', 'staging']): Settings variant name. + variant (str): Settings variant name. It is possible to use + 'production', 'staging' or name of dev bundle. """ - if variant not in ("production", "staging"): - raise ValueError(( - "Invalid variant name {}. Expected 'production' or 'staging'" - ).format(variant)) self._default_settings_variant = variant default_settings_variant = property( @@ -559,6 +685,29 @@ class ServerAPI(object): set_default_settings_variant ) + def get_sender(self): + """Sender used to send requests. + + Returns: + Union[str, None]: Sender name or None. + """ + + return self._sender + + def set_sender(self, sender): + """Change sender used for requests. + + Args: + sender (Union[str, None]): Sender name or None. + """ + + if sender == self._sender: + return + self._sender = sender + self._update_session_headers() + + sender = property(get_sender, set_sender) + def get_default_service_username(self): """Default username used for callbacks when used with service API key. @@ -742,6 +891,7 @@ class ServerAPI(object): ("X-as-user", self._as_user_stack.username), ("x-ayon-version", self._client_version), ("x-ayon-site-id", self._site_id), + ("x-sender", self._sender), ): if value is not None: self._session.headers[key] = value @@ -797,14 +947,34 @@ class ServerAPI(object): int(re_match.group("major")), int(re_match.group("minor")), int(re_match.group("patch")), - re_match.group("prerelease"), - re_match.group("buildmetadata") + re_match.group("prerelease") or "", + re_match.group("buildmetadata") or "", ) return self._server_version_tuple server_version = property(get_server_version) server_version_tuple = property(get_server_version_tuple) + @property + def graphql_allows_data_in_query(self): + """GraphlQl query can support 'data' field. + + This applies only to project hierarchy entities 'project', 'folder', + 'task', 'product', 'version' and 'representation'. Others like 'user' + still require to use rest api to access 'data'. + + Returns: + bool: True if server supports 'data' field in GraphQl query. + """ + + if self._graphql_allows_data_in_query is None: + major, minor, patch, _, _ = self.server_version_tuple + graphql_allows_data_in_query = True + if (major, minor, patch) < (0, 5, 5): + graphql_allows_data_in_query = False + self._graphql_allows_data_in_query = graphql_allows_data_in_query + return self._graphql_allows_data_in_query + def _get_user_info(self): if self._access_token is None: return None @@ -826,10 +996,44 @@ class ServerAPI(object): self._access_token_is_service = None return None - def get_users(self): - # TODO how to find out if user have permission? - users = self.get("users") - return users.data + def get_users(self, usernames=None, fields=None): + """Get Users. + + Args: + usernames (Optional[Iterable[str]]): Filter by usernames. + fields (Optional[Iterable[str]]): fields to be queried + for users. + + Returns: + Generator[dict[str, Any]]: Queried users. + """ + + filters = {} + if usernames is not None: + usernames = set(usernames) + if not usernames: + return + filters["userNames"] = list(usernames) + + if not fields: + fields = self.get_default_fields_for_type("user") + + query = users_graphql_query(set(fields)) + for attr, filter_value in filters.items(): + query.set_variable_value(attr, filter_value) + + # Backwards compatibility for server 0.3.x + # - will be removed in future releases + major, minor, _, _, _ = self.server_version_tuple + access_groups_field = "accessGroups" + if major == 0 and minor <= 3: + access_groups_field = "roles" + + for parsed_data in query.continuous_query(self): + for user in parsed_data["users"]: + user[access_groups_field] = json.loads( + user[access_groups_field]) + yield user def get_user(self, username=None): output = None @@ -859,6 +1063,9 @@ class ServerAPI(object): if self._client_version is not None: headers["x-ayon-version"] = self._client_version + if self._sender is not None: + headers["x-sender"] = self._sender + if self._access_token: if self._access_token_is_service: headers["X-Api-Key"] = self._access_token @@ -900,18 +1107,24 @@ class ServerAPI(object): self.validate_server_availability() - response = self.post( - "auth/login", - name=username, - password=password - ) - if response.status_code != 200: - _detail = response.data.get("detail") - details = "" - if _detail: - details = " {}".format(_detail) + self._token_validation_started = True - raise AuthenticationError("Login failed {}".format(details)) + try: + response = self.post( + "auth/login", + name=username, + password=password + ) + if response.status_code != 200: + _detail = response.data.get("detail") + details = "" + if _detail: + details = " {}".format(_detail) + + raise AuthenticationError("Login failed {}".format(details)) + + finally: + self._token_validation_started = False self._access_token = response["token"] @@ -931,6 +1144,10 @@ class ServerAPI(object): logout_from_server(self._base_url, self._access_token) def _do_rest_request(self, function, url, **kwargs): + kwargs.setdefault("timeout", self.timeout) + max_retries = kwargs.get("max_retries", self.max_retries) + if max_retries < 1: + max_retries = 1 if self._session is None: # Validate token if was not yet validated # - ignore validation if we're in middle of @@ -950,38 +1167,64 @@ class ServerAPI(object): elif isinstance(function, RequestType): function = self._session_functions_mapping[function] - try: - response = function(url, **kwargs) + response = None + new_response = None + for retry_idx in reversed(range(max_retries)): + try: + response = function(url, **kwargs) + break - except ConnectionRefusedError: - new_response = RestApiResponse( - None, - {"detail": "Unable to connect the server. Connection refused"} - ) - except requests.exceptions.ConnectionError: - new_response = RestApiResponse( - None, - {"detail": "Unable to connect the server. Connection error"} - ) - else: - content_type = response.headers.get("Content-Type") - if content_type == "application/json": - try: - new_response = RestApiResponse(response) - except JSONDecodeError: - new_response = RestApiResponse( - None, - { - "detail": "The response is not a JSON: {}".format( - response.text) - } + except ConnectionRefusedError: + if retry_idx == 0: + self.log.warning( + "Connection error happened.", exc_info=True ) - elif content_type in ("image/jpeg", "image/png"): - new_response = RestApiResponse(response) + # Server may be restarting + new_response = RestApiResponse( + None, + {"detail": "Unable to connect the server. Connection refused"} + ) - else: + except requests.exceptions.Timeout: + # Connection timed out + new_response = RestApiResponse( + None, + {"detail": "Connection timed out."} + ) + + except requests.exceptions.ConnectionError: + # Log warning only on last attempt + if retry_idx == 0: + self.log.warning( + "Connection error happened.", exc_info=True + ) + + new_response = RestApiResponse( + None, + {"detail": "Unable to connect the server. Connection error"} + ) + + time.sleep(0.1) + + if new_response is not None: + return new_response + + content_type = response.headers.get("Content-Type") + if content_type == "application/json": + try: new_response = RestApiResponse(response) + except JSONDecodeError: + new_response = RestApiResponse( + None, + { + "detail": "The response is not a JSON: {}".format( + response.text) + } + ) + + else: + new_response = RestApiResponse(response) self.log.debug("Response {}".format(str(new_response))) return new_response @@ -1075,6 +1318,9 @@ class ServerAPI(object): states=None, users=None, include_logs=None, + has_children=None, + newer_than=None, + older_than=None, fields=None ): """Get events from server with filtering options. @@ -1090,6 +1336,12 @@ class ServerAPI(object): users (Optional[Iterable[str]]): Filtering by users who created/triggered an event. include_logs (Optional[bool]): Query also log events. + has_children (Optional[bool]): Event is with/without children + events. If 'None' then all events are returned, default. + newer_than (Optional[str]): Return only events newer than given + iso datetime string. + older_than (Optional[str]): Return only events older than given + iso datetime string. fields (Optional[Iterable[str]]): Fields that should be received for each event. @@ -1126,8 +1378,17 @@ class ServerAPI(object): include_logs = False filters["includeLogsFilter"] = include_logs + if has_children is not None: + filters["hasChildrenFilter"] = has_children + + if newer_than is not None: + filters["newerThanFilter"] = newer_than + + if older_than is not None: + filters["olderThanFilter"] = older_than + if not fields: - fields = DEFAULT_EVENT_FIELDS + fields = self.get_default_fields_for_type("event") query = events_graphql_query(set(fields)) for attr, filter_value in filters.items(): @@ -1145,7 +1406,9 @@ class ServerAPI(object): status=None, description=None, summary=None, - payload=None + payload=None, + progress=None, + retries=None ): kwargs = { key: value @@ -1156,9 +1419,27 @@ class ServerAPI(object): ("description", description), ("summary", summary), ("payload", payload), + ("progress", progress), + ("retries", retries), ) if value is not None } + # 'progress' and 'retries' are available since 0.5.x server version + major, minor, _, _, _ = self.server_version_tuple + if (major, minor) < (0, 5): + args = [] + if progress is not None: + args.append("progress") + if retries is not None: + args.append("retries") + fields = ", ".join("'{}'".format(f) for f in args) + ending = "s" if len(args) > 1 else "" + raise ValueError(( + "Your server version '{}' does not support update" + " of {} field{} on event. The fields are supported since" + " server version '0.5'." + ).format(self.get_server_version(), fields, ending)) + response = self.patch( "events/{}".format(event_id), **kwargs @@ -1228,7 +1509,9 @@ class ServerAPI(object): target_topic, sender, description=None, - sequential=None + sequential=None, + events_filter=None, + max_retries=None, ): """Enroll job based on events. @@ -1270,6 +1553,12 @@ class ServerAPI(object): in target event. sequential (Optional[bool]): The source topic must be processed in sequence. + events_filter (Optional[dict[str, Any]]): Filtering conditions + to filter the source event. For more technical specifications + look to server backed 'ayon_server.sqlfilter.Filter'. + TODO: Add example of filters. + max_retries (Optional[int]): How many times can be event retried. + Default value is based on server (3 at the time of this PR). Returns: Union[None, dict[str, Any]]: None if there is no event matching @@ -1281,10 +1570,14 @@ class ServerAPI(object): "targetTopic": target_topic, "sender": sender, } + if max_retries is not None: + kwargs["maxRetries"] = max_retries if sequential is not None: kwargs["sequential"] = sequential if description is not None: kwargs["description"] = description + if events_filter is not None: + kwargs["filter"] = events_filter response = self.post("enroll", **kwargs) if response.status_code == 204: return None @@ -1325,6 +1618,10 @@ class ServerAPI(object): download happens in thread and other thread want to catch changes over time. + Todos: + Use retries and timeout. + Return RestApiResponse. + Args: endpoint (str): Endpoint or URL to file that should be downloaded. filepath (str): Path where file will be downloaded. @@ -1335,8 +1632,7 @@ class ServerAPI(object): """ if not chunk_size: - # 1 MB chunk by default - chunk_size = 1024 * 1024 + chunk_size = self.default_download_chunk_size if endpoint.startswith(self._base_url): url = endpoint @@ -1363,33 +1659,93 @@ class ServerAPI(object): progress.set_transfer_done() return progress - def _upload_file(self, url, filepath, progress, request_type=None): + @staticmethod + def _upload_chunks_iter(file_stream, progress, chunk_size): + """Generator that yields chunks of file. + + Args: + file_stream (io.BinaryIO): Byte stream. + progress (TransferProgress): Object to track upload progress. + chunk_size (int): Size of chunks that are uploaded at once. + + Yields: + bytes: Chunk of file. + """ + + # Get size of file + file_stream.seek(0, io.SEEK_END) + size = file_stream.tell() + file_stream.seek(0) + # Set content size to progress object + progress.set_content_size(size) + + while True: + chunk = file_stream.read(chunk_size) + if not chunk: + break + progress.add_transferred_chunk(len(chunk)) + yield chunk + + def _upload_file( + self, + url, + filepath, + progress, + request_type=None, + chunk_size=None, + **kwargs + ): + """ + + Args: + url (str): Url where file will be uploaded. + filepath (str): Source filepath. + progress (TransferProgress): Object that gives ability to track + progress. + request_type (Optional[RequestType]): Type of request that will + be used. Default is PUT. + chunk_size (Optional[int]): Size of chunks that are uploaded + at once. + **kwargs (Any): Additional arguments that will be passed + to request function. + + Returns: + RestApiResponse: Server response. + """ + if request_type is None: request_type = RequestTypes.put - kwargs = {} + if self._session is None: - kwargs["headers"] = self.get_headers() + headers = kwargs.setdefault("headers", {}) + for key, value in self.get_headers().items(): + if key not in headers: + headers[key] = value post_func = self._base_functions_mapping[request_type] else: post_func = self._session_functions_mapping[request_type] + if not chunk_size: + chunk_size = self.default_upload_chunk_size + with open(filepath, "rb") as stream: - stream.seek(0, io.SEEK_END) - size = stream.tell() - stream.seek(0) - progress.set_content_size(size) - response = post_func(url, data=stream, **kwargs) + response = post_func( + url, + data=self._upload_chunks_iter(stream, progress, chunk_size), + **kwargs + ) + response.raise_for_status() - progress.set_transferred_size(size) return response def upload_file( - self, endpoint, filepath, progress=None, request_type=None + self, endpoint, filepath, progress=None, request_type=None, **kwargs ): """Upload file to server. Todos: - Uploading with more detailed progress. + Use retries and timeout. + Return RestApiResponse. Args: endpoint (str): Endpoint or url where file will be uploaded. @@ -1398,6 +1754,8 @@ class ServerAPI(object): to track upload progress. request_type (Optional[RequestType]): Type of request that will be used to upload file. + **kwargs (Any): Additional arguments that will be passed + to request function. Returns: requests.Response: Response object. @@ -1419,7 +1777,9 @@ class ServerAPI(object): progress.set_started() try: - return self._upload_file(url, filepath, progress, request_type) + return self._upload_file( + url, filepath, progress, request_type, **kwargs + ) except Exception as exc: progress.set_failed(str(exc)) @@ -1612,6 +1972,19 @@ class ServerAPI(object): return copy.deepcopy(attributes) + def get_attributes_fields_for_type(self, entity_type): + """Prepare attribute fields for entity type. + + Returns: + set[str]: Attributes fields for entity type. + """ + + attributes = self.get_attributes_for_type(entity_type) + return { + "attrib.{}".format(attr) + for attr in attributes + } + def get_default_fields_for_type(self, entity_type): """Default fields for entity type. @@ -1624,51 +1997,68 @@ class ServerAPI(object): set[str]: Fields that should be queried from server. """ - attributes = self.get_attributes_for_type(entity_type) + # Event does not have attributes + if entity_type == "event": + return set(DEFAULT_EVENT_FIELDS) + if entity_type == "project": - return DEFAULT_PROJECT_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + entity_type_defaults = set(DEFAULT_PROJECT_FIELDS) + if not self.graphql_allows_data_in_query: + entity_type_defaults.discard("data") - if entity_type == "folder": - return DEFAULT_FOLDER_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "folder": + entity_type_defaults = set(DEFAULT_FOLDER_FIELDS) + if not self.graphql_allows_data_in_query: + entity_type_defaults.discard("data") - if entity_type == "task": - return DEFAULT_TASK_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "task": + entity_type_defaults = set(DEFAULT_TASK_FIELDS) + if not self.graphql_allows_data_in_query: + entity_type_defaults.discard("data") - if entity_type == "product": - return DEFAULT_PRODUCT_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "product": + entity_type_defaults = set(DEFAULT_PRODUCT_FIELDS) + if not self.graphql_allows_data_in_query: + entity_type_defaults.discard("data") - if entity_type == "version": - return DEFAULT_VERSION_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "version": + entity_type_defaults = set(DEFAULT_VERSION_FIELDS) + if not self.graphql_allows_data_in_query: + entity_type_defaults.discard("data") - if entity_type == "representation": - return ( + elif entity_type == "representation": + entity_type_defaults = ( DEFAULT_REPRESENTATION_FIELDS | REPRESENTATION_FILES_FIELDS - | { - "attrib.{}".format(attr) - for attr in attributes - } ) + if not self.graphql_allows_data_in_query: + entity_type_defaults.discard("data") - if entity_type == "productType": - return DEFAULT_PRODUCT_TYPE_FIELDS + elif entity_type == "productType": + entity_type_defaults = set(DEFAULT_PRODUCT_TYPE_FIELDS) - raise ValueError("Unknown entity type \"{}\"".format(entity_type)) + elif entity_type == "workfile": + entity_type_defaults = set(DEFAULT_WORKFILE_INFO_FIELDS) + if not self.graphql_allows_data_in_query: + entity_type_defaults.discard("data") + + elif entity_type == "user": + entity_type_defaults = set(DEFAULT_USER_FIELDS) + # Backwards compatibility for server 0.3.x + # - will be removed in future releases + major, minor, _, _, _ = self.server_version_tuple + if major == 0 and minor <= 3: + entity_type_defaults.discard("accessGroups") + entity_type_defaults.discard("defaultAccessGroups") + entity_type_defaults.add("roles") + entity_type_defaults.add("defaultRoles") + + else: + raise ValueError("Unknown entity type \"{}\"".format(entity_type)) + return ( + entity_type_defaults + | self.get_attributes_fields_for_type(entity_type) + ) def get_addons_info(self, details=True): """Get information about addons available on server. @@ -2011,6 +2401,34 @@ class ServerAPI(object): response.raise_for_status("Failed to create/update dependency") return response.data + def _get_dependency_package_route( + self, filename=None, platform_name=None + ): + major, minor, patch, _, _ = self.server_version_tuple + if (major, minor, patch) <= (0, 2, 0): + # Backwards compatibility for AYON server 0.2.0 and lower + self.log.warning(( + "Using deprecated dependency package route." + " Please update your AYON server to version 0.2.1 or higher." + " Backwards compatibility for this route will be removed" + " in future releases of ayon-python-api." + )) + if platform_name is None: + platform_name = platform.system().lower() + base = "dependencies" + if not filename: + return base + return "{}/{}/{}".format(base, filename, platform_name) + + if (major, minor) <= (0, 3): + endpoint = "desktop/dependency_packages" + else: + endpoint = "desktop/dependencyPackages" + + if filename: + return "{}/{}".format(endpoint, filename) + return endpoint + def get_dependency_packages(self): """Information about dependency packages on server. @@ -2038,28 +2456,11 @@ class ServerAPI(object): server. """ - result = self.get("desktop/dependency_packages") + endpoint = self._get_dependency_package_route() + result = self.get(endpoint) result.raise_for_status() return result.data - def _get_dependency_package_route( - self, filename=None, platform_name=None - ): - major, minor, patch, _, _ = self.server_version_tuple - if major == 0 and (minor > 2 or (minor == 2 and patch >= 1)): - base = "desktop/dependency_packages" - if not filename: - return base - return "{}/{}".format(base, filename) - - # Backwards compatibility for AYON server 0.2.0 and lower - if platform_name is None: - platform_name = platform.system().lower() - base = "dependencies" - if not filename: - return base - return "{}/{}/{}".format(base, filename, platform_name) - def create_dependency_package( self, filename, @@ -2926,6 +3327,79 @@ class ServerAPI(object): only_values=only_values ) + def get_secrets(self): + """Get all secrets. + + Example output: + [ + { + "name": "secret_1", + "value": "secret_value_1", + }, + { + "name": "secret_2", + "value": "secret_value_2", + } + ] + + Returns: + list[dict[str, str]]: List of secret entities. + """ + + response = self.get("secrets") + response.raise_for_status() + return response.data + + def get_secret(self, secret_name): + """Get secret by name. + + Example output: + { + "name": "secret_name", + "value": "secret_value", + } + + Args: + secret_name (str): Name of secret. + + Returns: + dict[str, str]: Secret entity data. + """ + + response = self.get("secrets/{}".format(secret_name)) + response.raise_for_status() + return response.data + + def save_secret(self, secret_name, secret_value): + """Save secret. + + This endpoint can create and update secret. + + Args: + secret_name (str): Name of secret. + secret_value (str): Value of secret. + """ + + response = self.put( + "secrets/{}".format(secret_name), + name=secret_name, + value=secret_value, + ) + response.raise_for_status() + return response.data + + + def delete_secret(self, secret_name): + """Delete secret by name. + + Args: + secret_name (str): Name of secret to delete. + """ + + response = self.delete("secrets/{}".format(secret_name)) + response.raise_for_status() + return response.data + # Entity getters def get_rest_project(self, project_name): """Query project by name. @@ -3070,8 +3544,6 @@ class ServerAPI(object): else: use_rest = False fields = set(fields) - if own_attributes: - fields.add("ownAttrib") for field in fields: if field.startswith("config"): use_rest = True @@ -3084,6 +3556,13 @@ class ServerAPI(object): yield project else: + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("project") + + if own_attributes: + fields.add("ownAttrib") + query = projects_graphql_query(fields) for parsed_data in query.continuous_query(self): for project in parsed_data["projects"]: @@ -3124,8 +3603,12 @@ class ServerAPI(object): fill_own_attribs(project) return project + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("project") + if own_attributes: - field.add("ownAttrib") + fields.add("ownAttrib") query = project_graphql_query(fields) query.set_variable_value("projectName", project_name) @@ -3203,8 +3686,16 @@ class ServerAPI(object): folder_ids=None, folder_paths=None, folder_names=None, + folder_types=None, parent_ids=None, + folder_path_regex=None, + has_products=None, + has_tasks=None, + has_children=None, + statuses=None, + tags=None, active=True, + has_links=None, fields=None, own_attributes=False ): @@ -3224,10 +3715,26 @@ class ServerAPI(object): for filtering. folder_names (Optional[Iterable[str]]): Folder names used for filtering. + folder_types (Optional[Iterable[str]]): Folder types used + for filtering. parent_ids (Optional[Iterable[str]]): Ids of folder parents. Use 'None' if folder is direct child of project. + folder_path_regex (Optional[str]): Folder path regex used + for filtering. + has_products (Optional[bool]): Filter folders with/without + products. Ignored when None, default behavior. + has_tasks (Optional[bool]): Filter folders with/without + tasks. Ignored when None, default behavior. + has_children (Optional[bool]): Filter folders with/without + children. Ignored when None, default behavior. + statuses (Optional[Iterable[str]]): Folder statuses used + for filtering. + tags (Optional[Iterable[str]]): Folder tags used + for filtering. active (Optional[bool]): Filter active/inactive folders. Both are returned if is set to None. + has_links (Optional[Literal[IN, OUT, ANY]]): Filter + representations with IN/OUT/ANY links. fields (Optional[Iterable[str]]): Fields to be queried for folder. All possible folder fields are returned if 'None' is passed. @@ -3262,6 +3769,24 @@ class ServerAPI(object): return filters["folderNames"] = list(folder_names) + if folder_types is not None: + folder_types = set(folder_types) + if not folder_types: + return + filters["folderTypes"] = list(folder_types) + + if statuses is not None: + statuses = set(statuses) + if not statuses: + return + filters["folderStatuses"] = list(statuses) + + if tags is not None: + tags = set(tags) + if not tags: + return + filters["folderTags"] = list(tags) + if parent_ids is not None: parent_ids = set(parent_ids) if not parent_ids: @@ -3282,13 +3807,31 @@ class ServerAPI(object): filters["parentFolderIds"] = list(parent_ids) - if fields: - fields = set(fields) - else: + if folder_path_regex is not None: + filters["folderPathRegex"] = folder_path_regex + + if has_products is not None: + filters["folderHasProducts"] = has_products + + if has_tasks is not None: + filters["folderHasTasks"] = has_tasks + + if has_links is not None: + filters["folderHasLinks"] = has_links.upper() + + if has_children is not None: + filters["folderHasChildren"] = has_children + + if not fields: fields = self.get_default_fields_for_type("folder") + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("folder") use_rest = False - if "data" in fields: + if "data" in fields and not self.graphql_allows_data_in_query: use_rest = True fields = {"id"} @@ -3309,6 +3852,8 @@ class ServerAPI(object): if use_rest: folder = self.get_rest_folder(project_name, folder["id"]) + else: + self._convert_entity_data(folder) if own_attributes: fill_own_attribs(folder) @@ -3461,6 +4006,10 @@ class ServerAPI(object): task_names=None, task_types=None, folder_ids=None, + assignees=None, + assignees_all=None, + statuses=None, + tags=None, active=True, fields=None, own_attributes=False @@ -3474,6 +4023,16 @@ class ServerAPI(object): task_types (Iterable[str]): Task types used for filtering. folder_ids (Iterable[str]): Ids of task parents. Use 'None' if folder is direct child of project. + assignees (Optional[Iterable[str]]): Task assignees used for + filtering. All tasks with any of passed assignees are + returned. + assignees_all (Optional[Iterable[str]]): Task assignees used + for filtering. Task must have all of passed assignees to be + returned. + statuses (Optional[Iterable[str]]): Task statuses used for + filtering. + tags (Optional[Iterable[str]]): Task tags used for + filtering. active (Optional[bool]): Filter active/inactive tasks. Both are returned if is set to None. fields (Optional[Iterable[str]]): Fields to be queried for @@ -3517,13 +4076,40 @@ class ServerAPI(object): return filters["folderIds"] = list(folder_ids) + if assignees is not None: + assignees = set(assignees) + if not assignees: + return + filters["taskAssigneesAny"] = list(assignees) + + if assignees_all is not None: + assignees_all = set(assignees_all) + if not assignees_all: + return + filters["taskAssigneesAll"] = list(assignees_all) + + if statuses is not None: + statuses = set(statuses) + if not statuses: + return + filters["taskStatuses"] = list(statuses) + + if tags is not None: + tags = set(tags) + if not tags: + return + filters["taskTags"] = list(tags) + if not fields: fields = self.get_default_fields_for_type("task") - - fields = set(fields) + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("task") use_rest = False - if "data" in fields: + if "data" in fields and not self.graphql_allows_data_in_query: use_rest = True fields = {"id"} @@ -3544,6 +4130,8 @@ class ServerAPI(object): if use_rest: task = self.get_rest_task(project_name, task["id"]) + else: + self._convert_entity_data(task) if own_attributes: fill_own_attribs(task) @@ -3624,6 +4212,8 @@ class ServerAPI(object): if use_rest: product = self.get_rest_product(project_name, product["id"]) + else: + self._convert_entity_data(product) if own_attributes: fill_own_attribs(product) @@ -3636,7 +4226,12 @@ class ServerAPI(object): product_ids=None, product_names=None, folder_ids=None, + product_types=None, + product_name_regex=None, + product_path_regex=None, names_by_folder_ids=None, + statuses=None, + tags=None, active=True, fields=None, own_attributes=False @@ -3654,8 +4249,17 @@ class ServerAPI(object): filtering. folder_ids (Optional[Iterable[str]]): Ids of task parents. Use 'None' if folder is direct child of project. + product_types (Optional[Iterable[str]]): Product types used for + filtering. + product_name_regex (Optional[str]): Filter products by name regex. + product_path_regex (Optional[str]): Filter products by path regex. + Path starts with folder path and ends with product name. names_by_folder_ids (Optional[dict[str, Iterable[str]]]): Product name filtering by folder id. + statuses (Optional[Iterable[str]]): Product statuses used + for filtering. + tags (Optional[Iterable[str]]): Product tags used + for filtering. active (Optional[bool]): Filter active/inactive products. Both are returned if is set to None. fields (Optional[Iterable[str]]): Fields to be queried for @@ -3671,11 +4275,7 @@ class ServerAPI(object): if not project_name: return - if product_ids is not None: - product_ids = set(product_ids) - if not product_ids: - return - + # Prepare these filters before 'name_by_filter_ids' filter filter_product_names = None if product_names is not None: filter_product_names = set(product_names) @@ -3705,11 +4305,14 @@ class ServerAPI(object): # Convert fields and add minimum required fields if fields: fields = set(fields) | {"id"} + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("product") else: fields = self.get_default_fields_for_type("product") use_rest = False - if "data" in fields: + if "data" in fields and not self.graphql_allows_data_in_query: use_rest = True fields = {"id"} @@ -3728,15 +4331,43 @@ class ServerAPI(object): filters = { "projectName": project_name } + if filter_folder_ids: filters["folderIds"] = list(filter_folder_ids) - if product_ids: - filters["productIds"] = list(product_ids) - if filter_product_names: filters["productNames"] = list(filter_product_names) + if product_ids is not None: + product_ids = set(product_ids) + if not product_ids: + return + filters["productIds"] = list(product_ids) + + if product_types is not None: + product_types = set(product_types) + if not product_types: + return + filters["productTypes"] = list(product_types) + + if statuses is not None: + statuses = set(statuses) + if not statuses: + return + filters["productStatuses"] = list(statuses) + + if tags is not None: + tags = set(tags) + if not tags: + return + filters["productTags"] = list(tags) + + if product_name_regex: + filters["productNameRegex"] = product_name_regex + + if product_path_regex: + filters["productPathRegex"] = product_path_regex + query = products_graphql_query(fields) for attr, filter_value in filters.items(): query.set_variable_value(attr, filter_value) @@ -3928,6 +4559,8 @@ class ServerAPI(object): hero=True, standard=True, latest=None, + statuses=None, + tags=None, active=True, fields=None, own_attributes=False @@ -3947,6 +4580,10 @@ class ServerAPI(object): latest (Optional[bool]): Return only latest version of standard versions. This can be combined only with 'standard' attribute set to True. + statuses (Optional[Iterable[str]]): Representation statuses used + for filtering. + tags (Optional[Iterable[str]]): Representation tags used + for filtering. active (Optional[bool]): Receive active/inactive entities. Both are returned when 'None' is passed. fields (Optional[Iterable[str]]): Fields to be queried @@ -3961,19 +4598,23 @@ class ServerAPI(object): if not fields: fields = self.get_default_fields_for_type("version") - fields = set(fields) - - if active is not None: - fields.add("active") + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("version") # Make sure fields have minimum required fields fields |= {"id", "version"} use_rest = False - if "data" in fields: + if "data" in fields and not self.graphql_allows_data_in_query: use_rest = True fields = {"id"} + if active is not None: + fields.add("active") + if own_attributes: fields.add("ownAttrib") @@ -3999,6 +4640,18 @@ class ServerAPI(object): return filters["versions"] = list(versions) + if statuses is not None: + statuses = set(statuses) + if not statuses: + return + filters["versionStatuses"] = list(statuses) + + if tags is not None: + tags = set(tags) + if not tags: + return + filters["versionTags"] = list(tags) + if not hero and not standard: return @@ -4047,6 +4700,8 @@ class ServerAPI(object): version = self.get_rest_version( project_name, version["id"] ) + else: + self._convert_entity_data(version) if own_attributes: fill_own_attribs(version) @@ -4253,6 +4908,10 @@ class ServerAPI(object): dict[str, dict[str, Any]]: Last versions by product id. """ + if fields: + fields = set(fields) + fields.add("productId") + versions = self.get_versions( project_name, product_ids=product_ids, @@ -4262,7 +4921,7 @@ class ServerAPI(object): own_attributes=own_attributes ) return { - version["parent"]: version + version["productId"]: version for version in versions } @@ -4376,6 +5035,23 @@ class ServerAPI(object): ) return latest_version["id"] == version_id + def _representation_conversion(self, representation): + if "context" in representation: + orig_context = representation["context"] + context = {} + if orig_context and orig_context != "null": + context = json.loads(orig_context) + representation["context"] = context + + repre_files = representation.get("files") + if not repre_files: + return + + for repre_file in repre_files: + repre_file_size = repre_file.get("size") + if repre_file_size is not None: + repre_file["size"] = int(repre_file["size"]) + def get_representations( self, project_name, @@ -4383,7 +5059,10 @@ class ServerAPI(object): representation_names=None, version_ids=None, names_by_version_ids=None, + statuses=None, + tags=None, active=True, + has_links=None, fields=None, own_attributes=False ): @@ -4405,8 +5084,14 @@ class ServerAPI(object): names_by_version_ids (Optional[bool]): Find representations by names and version ids. This filter discard all other filters. + statuses (Optional[Iterable[str]]): Representation statuses used + for filtering. + tags (Optional[Iterable[str]]): Representation tags used + for filtering. active (Optional[bool]): Receive active/inactive entities. Both are returned when 'None' is passed. + has_links (Optional[Literal[IN, OUT, ANY]]): Filter + representations with IN/OUT/ANY links. fields (Optional[Iterable[str]]): Fields to be queried for representation. All possible fields are returned if 'None' is passed. @@ -4419,10 +5104,16 @@ class ServerAPI(object): if not fields: fields = self.get_default_fields_for_type("representation") - fields = set(fields) + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type( + "representation" + ) use_rest = False - if "data" in fields: + if "data" in fields and not self.graphql_allows_data_in_query: use_rest = True fields = {"id"} @@ -4471,6 +5162,21 @@ class ServerAPI(object): if representaion_names_filter: filters["representationNames"] = list(representaion_names_filter) + if statuses is not None: + statuses = set(statuses) + if not statuses: + return + filters["representationStatuses"] = list(statuses) + + if tags is not None: + tags = set(tags) + if not tags: + return + filters["representationTags"] = list(tags) + + if has_links is not None: + filters["representationHasLinks"] = has_links.upper() + query = representations_graphql_query(fields) for attr, filter_value in filters.items(): @@ -4485,13 +5191,10 @@ class ServerAPI(object): repre = self.get_rest_representation( project_name, repre["id"] ) + else: + self._convert_entity_data(repre) - if "context" in repre: - orig_context = repre["context"] - context = {} - if orig_context and orig_context != "null": - context = json.loads(orig_context) - repre["context"] = context + self._representation_conversion(repre) if own_attributes: fill_own_attribs(repre) @@ -4604,6 +5307,9 @@ class ServerAPI(object): version = repre.pop("version") product = version.pop("product") folder = product.pop("folder") + self._convert_entity_data(version) + self._convert_entity_data(product) + self._convert_entity_data(folder) output[repre_id] = RepresentationParents( version, product, folder, project ) @@ -4725,6 +5431,10 @@ class ServerAPI(object): workfile_ids=None, task_ids=None, paths=None, + path_regex=None, + statuses=None, + tags=None, + has_links=None, fields=None, own_attributes=False ): @@ -4735,6 +5445,13 @@ class ServerAPI(object): workfile_ids (Optional[Iterable[str]]): Workfile ids. task_ids (Optional[Iterable[str]]): Task ids. paths (Optional[Iterable[str]]): Rootless workfiles paths. + path_regex (Optional[str]): Regex filter for workfile path. + statuses (Optional[Iterable[str]]): Workfile info statuses used + for filtering. + tags (Optional[Iterable[str]]): Workfile info tags used + for filtering. + has_links (Optional[Literal[IN, OUT, ANY]]): Filter + representations with IN/OUT/ANY links. fields (Optional[Iterable[str]]): Fields to be queried for representation. All possible fields are returned if 'None' is passed. @@ -4758,15 +5475,40 @@ class ServerAPI(object): return filters["paths"] = list(paths) + if path_regex is not None: + filters["workfilePathRegex"] = path_regex + if workfile_ids is not None: workfile_ids = set(workfile_ids) if not workfile_ids: return filters["workfileIds"] = list(workfile_ids) + if statuses is not None: + statuses = set(statuses) + if not statuses: + return + filters["workfileStatuses"] = list(statuses) + + if tags is not None: + tags = set(tags) + if not tags: + return + filters["workfileTags"] = list(tags) + + if has_links is not None: + filters["workfilehasLinks"] = has_links.upper() + if not fields: - fields = DEFAULT_WORKFILE_INFO_FIELDS + fields = self.get_default_fields_for_type("workfile") + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= { + "attrib.{}".format(attr) + for attr in self.get_attributes_for_type("workfile") + } if own_attributes: fields.add("ownAttrib") @@ -4843,18 +5585,61 @@ class ServerAPI(object): return workfile_info return None + def _prepare_thumbnail_content(self, project_name, response): + content = None + content_type = response.content_type + + # It is expected the response contains thumbnail id otherwise the + # content cannot be cached and filepath returned + thumbnail_id = response.headers.get("X-Thumbnail-Id") + if thumbnail_id is not None: + content = response.content + + return ThumbnailContent( + project_name, thumbnail_id, content, content_type + ) + + def get_thumbnail_by_id(self, project_name, thumbnail_id): + """Get thumbnail from server by id. + + Permissions of thumbnails are related to entities so thumbnails must + be queried per entity. So an entity type and entity type is required + to be passed. + + Notes: + It is recommended to use one of prepared entity type specific + methods 'get_folder_thumbnail', 'get_version_thumbnail' or + 'get_workfile_thumbnail'. + We do recommend pass thumbnail id if you have access to it. Each + entity that allows thumbnails has 'thumbnailId' field, so it + can be queried. + + Args: + project_name (str): Project under which the entity is located. + thumbnail_id (Optional[str]): DEPRECATED Use + 'get_thumbnail_by_id'. + + Returns: + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. + """ + + response = self.raw_get( + "projects/{}/thumbnails/{}".format( + project_name, + thumbnail_id + ) + ) + return self._prepare_thumbnail_content(project_name, response) + def get_thumbnail( self, project_name, entity_type, entity_id, thumbnail_id=None ): """Get thumbnail from server. - Permissions of thumbnails are related to entities so thumbnails must be - queried per entity. So an entity type and entity type is required to - be passed. - - If thumbnail id is passed logic can look into locally cached thumbnails - before calling server which can enhance loading time. If thumbnail id - is not passed the thumbnail is always downloaded even if is available. + Permissions of thumbnails are related to entities so thumbnails must + be queried per entity. So an entity type and entity type is required + to be passed. Notes: It is recommended to use one of prepared entity type specific @@ -4868,20 +5653,16 @@ class ServerAPI(object): project_name (str): Project under which the entity is located. entity_type (str): Entity type which passed entity id represents. entity_id (str): Entity id for which thumbnail should be returned. - thumbnail_id (Optional[str]): Prepared thumbnail id from entity. - Used only to check if thumbnail was already cached. + thumbnail_id (Optional[str]): DEPRECATED Use + 'get_thumbnail_by_id'. Returns: - Union[str, None]: Path to downloaded thumbnail or none if entity - does not have any (or if user does not have permissions). + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ - # Look for thumbnail into cache and return the path if was found - filepath = self._thumbnail_cache.get_thumbnail_filepath( - project_name, thumbnail_id - ) - if filepath: - return filepath + if thumbnail_id: + return self.get_thumbnail_by_id(project_name, thumbnail_id) if entity_type in ( "folder", @@ -4890,29 +5671,12 @@ class ServerAPI(object): ): entity_type += "s" - # Receive thumbnail content from server - result = self.raw_get("projects/{}/{}/{}/thumbnail".format( + response = self.raw_get("projects/{}/{}/{}/thumbnail".format( project_name, entity_type, entity_id )) - - if result.content_type is None: - return None - - # It is expected the response contains thumbnail id otherwise the - # content cannot be cached and filepath returned - thumbnail_id = result.headers.get("X-Thumbnail-Id") - if thumbnail_id is None: - return None - - # Cache thumbnail and return path - return self._thumbnail_cache.store_thumbnail( - project_name, - thumbnail_id, - result.content, - result.content_type - ) + return self._prepare_thumbnail_content(project_name, response) def get_folder_thumbnail( self, project_name, folder_id, thumbnail_id=None @@ -5027,16 +5791,14 @@ class ServerAPI(object): return thumbnail_id mime_type = self._get_thumbnail_mime_type(src_filepath) - with open(src_filepath, "rb") as stream: - content = stream.read() - - response = self.raw_post( + response = self.upload_file( "projects/{}/thumbnails".format(project_name), + src_filepath, + request_type=RequestTypes.post, headers={"Content-Type": mime_type}, - data=content ) response.raise_for_status() - return response.data["id"] + return response.json()["id"] def update_thumbnail(self, project_name, thumbnail_id, src_filepath): """Change thumbnail content by id. @@ -5057,13 +5819,11 @@ class ServerAPI(object): raise ValueError("Entered filepath does not exist.") mime_type = self._get_thumbnail_mime_type(src_filepath) - with open(src_filepath, "rb") as stream: - content = stream.read() - - response = self.raw_put( + response = self.upload_file( "projects/{}/thumbnails/{}".format(project_name, thumbnail_id), + src_filepath, + request_type=RequestTypes.put, headers={"Content-Type": mime_type}, - data=content ) response.raise_for_status() @@ -5463,19 +6223,22 @@ class ServerAPI(object): """Helper method to get links from server for entity types. Example output: - [ - { - "id": "59a212c0d2e211eda0e20242ac120002", - "linkType": "reference", - "description": "reference link between folders", - "projectName": "my_project", - "author": "frantadmin", - "entityId": "b1df109676db11ed8e8c6c9466b19aa8", - "entityType": "folder", - "direction": "out" - }, + { + "59a212c0d2e211eda0e20242ac120001": [ + { + "id": "59a212c0d2e211eda0e20242ac120002", + "linkType": "reference", + "description": "reference link between folders", + "projectName": "my_project", + "author": "frantadmin", + "entityId": "b1df109676db11ed8e8c6c9466b19aa8", + "entityType": "folder", + "direction": "out" + }, + ... + ], ... - ] + } Args: project_name (str): Project where links are. @@ -5882,3 +6645,13 @@ class ServerAPI(object): op_result["detail"], )) return op_results + + def _convert_entity_data(self, entity): + if not entity: + return + entity_data = entity.get("data") + if ( + entity_data is not None + and isinstance(entity_data, six.string_types) + ): + entity["data"] = json.loads(entity_data) diff --git a/openpype/vendor/python/common/ayon_api/thumbnails.py b/openpype/vendor/python/common/ayon_api/thumbnails.py deleted file mode 100644 index 50acd94dcb..0000000000 --- a/openpype/vendor/python/common/ayon_api/thumbnails.py +++ /dev/null @@ -1,219 +0,0 @@ -import os -import time -import collections - -import appdirs - -FileInfo = collections.namedtuple( - "FileInfo", - ("path", "size", "modification_time") -) - - -class ThumbnailCache: - """Cache of thumbnails on local storage. - - Thumbnails are cached to appdirs to predefined directory. Each project has - own subfolder with thumbnails -> that's because each project has own - thumbnail id validation and file names are thumbnail ids with matching - extension. Extensions are predefined (.png and .jpeg). - - Cache has cleanup mechanism which is triggered on initialized by default. - - The cleanup has 2 levels: - 1. soft cleanup which remove all files that are older then 'days_alive' - 2. max size cleanup which remove all files until the thumbnails folder - contains less then 'max_filesize' - - this is time consuming so it's not triggered automatically - - Args: - cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails). - """ - - # Lifetime of thumbnails (in seconds) - # - default 3 days - days_alive = 3 * 24 * 60 * 60 - # Max size of thumbnail directory (in bytes) - # - default 2 Gb - max_filesize = 2 * 1024 * 1024 * 1024 - - def __init__(self, cleanup=True): - self._thumbnails_dir = None - if cleanup: - self.cleanup() - - def get_thumbnails_dir(self): - """Root directory where thumbnails are stored. - - Returns: - str: Path to thumbnails root. - """ - - if self._thumbnails_dir is None: - directory = appdirs.user_data_dir("ayon", "ynput") - self._thumbnails_dir = os.path.join(directory, "thumbnails") - return self._thumbnails_dir - - thumbnails_dir = property(get_thumbnails_dir) - - def get_thumbnails_dir_file_info(self): - """Get information about all files in thumbnails directory. - - Returns: - List[FileInfo]: List of file information about all files. - """ - - thumbnails_dir = self.thumbnails_dir - files_info = [] - if not os.path.exists(thumbnails_dir): - return files_info - - for root, _, filenames in os.walk(thumbnails_dir): - for filename in filenames: - path = os.path.join(root, filename) - files_info.append(FileInfo( - path, os.path.getsize(path), os.path.getmtime(path) - )) - return files_info - - def get_thumbnails_dir_size(self, files_info=None): - """Got full size of thumbnail directory. - - Args: - files_info (List[FileInfo]): Prepared file information about - files in thumbnail directory. - - Returns: - int: File size of all files in thumbnail directory. - """ - - if files_info is None: - files_info = self.get_thumbnails_dir_file_info() - - if not files_info: - return 0 - - return sum( - file_info.size - for file_info in files_info - ) - - def cleanup(self, check_max_size=False): - """Cleanup thumbnails directory. - - Args: - check_max_size (bool): Also cleanup files to match max size of - thumbnails directory. - """ - - thumbnails_dir = self.get_thumbnails_dir() - # Skip if thumbnails dir does not exists yet - if not os.path.exists(thumbnails_dir): - return - - self._soft_cleanup(thumbnails_dir) - if check_max_size: - self._max_size_cleanup(thumbnails_dir) - - def _soft_cleanup(self, thumbnails_dir): - current_time = time.time() - for root, _, filenames in os.walk(thumbnails_dir): - for filename in filenames: - path = os.path.join(root, filename) - modification_time = os.path.getmtime(path) - if current_time - modification_time > self.days_alive: - os.remove(path) - - def _max_size_cleanup(self, thumbnails_dir): - files_info = self.get_thumbnails_dir_file_info() - size = self.get_thumbnails_dir_size(files_info) - if size < self.max_filesize: - return - - sorted_file_info = collections.deque( - sorted(files_info, key=lambda item: item.modification_time) - ) - diff = size - self.max_filesize - while diff > 0: - if not sorted_file_info: - break - - file_info = sorted_file_info.popleft() - diff -= file_info.size - os.remove(file_info.path) - - def get_thumbnail_filepath(self, project_name, thumbnail_id): - """Get thumbnail by thumbnail id. - - Args: - project_name (str): Name of project. - thumbnail_id (str): Thumbnail id. - - Returns: - Union[str, None]: Path to thumbnail image or None if thumbnail - is not cached yet. - """ - - if not thumbnail_id: - return None - - for ext in ( - ".png", - ".jpeg", - ): - filepath = os.path.join( - self.thumbnails_dir, project_name, thumbnail_id + ext - ) - if os.path.exists(filepath): - return filepath - return None - - def get_project_dir(self, project_name): - """Path to root directory for specific project. - - Args: - project_name (str): Name of project for which root directory path - should be returned. - - Returns: - str: Path to root of project's thumbnails. - """ - - return os.path.join(self.thumbnails_dir, project_name) - - def make_sure_project_dir_exists(self, project_name): - project_dir = self.get_project_dir(project_name) - if not os.path.exists(project_dir): - os.makedirs(project_dir) - return project_dir - - def store_thumbnail(self, project_name, thumbnail_id, content, mime_type): - """Store thumbnail to cache folder. - - Args: - project_name (str): Project where the thumbnail belong to. - thumbnail_id (str): Id of thumbnail. - content (bytes): Byte content of thumbnail file. - mime_data (str): Type of content. - - Returns: - str: Path to cached thumbnail image file. - """ - - if mime_type == "image/png": - ext = ".png" - elif mime_type == "image/jpeg": - ext = ".jpeg" - else: - raise ValueError( - "Unknown mime type for thumbnail \"{}\"".format(mime_type)) - - project_dir = self.make_sure_project_dir_exists(project_name) - thumbnail_path = os.path.join(project_dir, thumbnail_id + ext) - with open(thumbnail_path, "wb") as stream: - stream.write(content) - - current_time = time.time() - os.utime(thumbnail_path, (current_time, current_time)) - - return thumbnail_path diff --git a/openpype/vendor/python/common/ayon_api/utils.py b/openpype/vendor/python/common/ayon_api/utils.py index 93822a58ac..e9d7f53771 100644 --- a/openpype/vendor/python/common/ayon_api/utils.py +++ b/openpype/vendor/python/common/ayon_api/utils.py @@ -1,3 +1,4 @@ +import os import re import datetime import uuid @@ -15,6 +16,11 @@ except ImportError: import requests import unidecode +from .constants import ( + SERVER_TIMEOUT_ENV_KEY, + DEFAULT_VARIANT_ENV_KEY, + SITE_ID_ENV_KEY, +) from .exceptions import UrlError REMOVED_VALUE = object() @@ -27,6 +33,82 @@ RepresentationParents = collections.namedtuple( ) +def get_default_timeout(): + """Default value for requests timeout. + + First looks for environment variable SERVER_TIMEOUT_ENV_KEY which + can affect timeout value. If not available then use 10.0 s. + + Returns: + float: Timeout value in seconds. + """ + + try: + return float(os.environ.get(SERVER_TIMEOUT_ENV_KEY)) + except (ValueError, TypeError): + pass + return 10.0 + + +def get_default_settings_variant(): + """Default settings variant. + + Returns: + str: Settings variant from environment variable or 'production'. + """ + + return os.environ.get(DEFAULT_VARIANT_ENV_KEY) or "production" + + +def get_default_site_id(): + """Site id used for server connection. + + Returns: + Union[str, None]: Site id from environment variable or None. + """ + + return os.environ.get(SITE_ID_ENV_KEY) + + +class ThumbnailContent: + """Wrapper for thumbnail content. + + Args: + project_name (str): Project name. + thumbnail_id (Union[str, None]): Thumbnail id. + content_type (Union[str, None]): Content type e.g. 'image/png'. + content (Union[bytes, None]): Thumbnail content. + """ + + def __init__(self, project_name, thumbnail_id, content, content_type): + self.project_name = project_name + self.thumbnail_id = thumbnail_id + self.content_type = content_type + self.content = content or b"" + + @property + def id(self): + """Wrapper for thumbnail id. + + Returns: + + """ + + return self.thumbnail_id + + @property + def is_valid(self): + """Content of thumbnail is valid. + + Returns: + bool: Content is valid and can be used. + """ + return ( + self.thumbnail_id is not None + and self.content_type is not None + ) + + def prepare_query_string(key_values): """Prepare data to query string. @@ -192,30 +274,36 @@ def _try_parse_url(url): return None -def _try_connect_to_server(url): +def _try_connect_to_server(url, timeout=None): + if timeout is None: + timeout = get_default_timeout() try: # TODO add validation if the url lead to Ayon server - # - thiw won't validate if the url lead to 'google.com' - requests.get(url) + # - this won't validate if the url lead to 'google.com' + requests.get(url, timeout=timeout) except BaseException: return False return True -def login_to_server(url, username, password): +def login_to_server(url, username, password, timeout=None): """Use login to the server to receive token. Args: url (str): Server url. username (str): User's username. password (str): User's password. + timeout (Optional[float]): Timeout for request. Value from + 'get_default_timeout' is used if not specified. Returns: Union[str, None]: User's token if login was successfull. Otherwise 'None'. """ + if timeout is None: + timeout = get_default_timeout() headers = {"Content-Type": "application/json"} response = requests.post( "{}/api/auth/login".format(url), @@ -223,7 +311,8 @@ def login_to_server(url, username, password): json={ "name": username, "password": password - } + }, + timeout=timeout, ) token = None # 200 - success @@ -234,47 +323,67 @@ def login_to_server(url, username, password): return token -def logout_from_server(url, token): +def logout_from_server(url, token, timeout=None): """Logout from server and throw token away. Args: url (str): Url from which should be logged out. token (str): Token which should be used to log out. + timeout (Optional[float]): Timeout for request. Value from + 'get_default_timeout' is used if not specified. """ + if timeout is None: + timeout = get_default_timeout() headers = { "Content-Type": "application/json", "Authorization": "Bearer {}".format(token) } requests.post( url + "/api/auth/logout", - headers=headers + headers=headers, + timeout=timeout, ) -def is_token_valid(url, token): +def is_token_valid(url, token, timeout=None): """Check if token is valid. + Token can be a user token or service api key. + Args: url (str): Server url. token (str): User's token. + timeout (Optional[float]): Timeout for request. Value from + 'get_default_timeout' is used if not specified. Returns: bool: True if token is valid. """ - headers = { + if timeout is None: + timeout = get_default_timeout() + + base_headers = { "Content-Type": "application/json", - "Authorization": "Bearer {}".format(token) } - response = requests.get( - "{}/api/users/me".format(url), - headers=headers - ) - return response.status_code == 200 + for header_value in ( + {"Authorization": "Bearer {}".format(token)}, + {"X-Api-Key": token}, + ): + headers = base_headers.copy() + headers.update(header_value) + response = requests.get( + "{}/api/users/me".format(url), + headers=headers, + timeout=timeout, + ) + if response.status_code == 200: + return True + return False -def validate_url(url): +def validate_url(url, timeout=None): """Validate url if is valid and server is available. Validation checks if can be parsed as url and contains scheme. @@ -295,6 +404,7 @@ def validate_url(url): Args: url (str): Server url. + timeout (Optional[int]): Timeout in seconds for connection to server. Returns: Url which was used to connect to server. @@ -330,10 +440,10 @@ def validate_url(url): # - this will trigger UrlError if both will crash if not parsed_url.scheme: new_url = "https://" + modified_url - if _try_connect_to_server(new_url): + if _try_connect_to_server(new_url, timeout=timeout): return new_url - if _try_connect_to_server(modified_url): + if _try_connect_to_server(modified_url, timeout=timeout): return modified_url hints = [] diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py index 93024ea5f2..bc1107da1e 100644 --- a/openpype/vendor/python/common/ayon_api/version.py +++ b/openpype/vendor/python/common/ayon_api/version.py @@ -1,2 +1,2 @@ """Package declaring Python API for Ayon server.""" -__version__ = "0.3.3" +__version__ = "1.0.0-rc.1" diff --git a/openpype/version.py b/openpype/version.py index d7c8a71343..48688d5651 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.3" +__version__ = "3.17.7-nightly.6" diff --git a/poetry.lock b/poetry.lock index 5621d39988..d074a0c3d9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "acre" version = "1.0.0" description = "Lightweight cross-platform environment management Python package that makes it trivial to launch applications in their own configurable working environment." -category = "main" optional = false python-versions = ">=2.7" files = [] @@ -20,7 +19,6 @@ resolved_reference = "126f7a188cfe36718f707f42ebbc597e86aa86c3" name = "aiohttp" version = "3.8.4" description = "Async http client/server framework (asyncio)" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -129,7 +127,6 @@ speedups = ["Brotli", "aiodns", "cchardet"] name = "aiohttp-json-rpc" version = "0.13.3" description = "Implementation JSON-RPC 2.0 server and client using aiohttp on top of websockets transport" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -144,7 +141,6 @@ aiohttp = ">=3,<4" name = "aiohttp-middlewares" version = "2.2.0" description = "Collection of useful middlewares for aiohttp applications." -category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -161,7 +157,6 @@ yarl = ">=1.5.1,<2.0.0" name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -176,7 +171,6 @@ frozenlist = ">=1.1.0" name = "alabaster" version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -188,7 +182,6 @@ files = [ name = "ansicon" version = "1.89.0" description = "Python wrapper for loading Jason Hood's ANSICON" -category = "main" optional = false python-versions = "*" files = [ @@ -200,7 +193,6 @@ files = [ name = "appdirs" version = "1.4.4" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [] @@ -216,7 +208,6 @@ resolved_reference = "8734277956c1df3b85385e6b308e954910533884" name = "arrow" version = "0.17.0" description = "Better dates & times for Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -231,7 +222,6 @@ python-dateutil = ">=2.7.0" name = "astroid" version = "2.15.5" description = "An abstract syntax tree for Python with inference support." -category = "dev" optional = false python-versions = ">=3.7.2" files = [ @@ -248,7 +238,6 @@ wrapt = {version = ">=1.11,<2", markers = "python_version < \"3.11\""} name = "async-timeout" version = "4.0.2" description = "Timeout context manager for asyncio programs" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -260,7 +249,6 @@ files = [ name = "atomicwrites" version = "1.4.1" description = "Atomic file writes." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -271,7 +259,6 @@ files = [ name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -290,7 +277,6 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "autopep8" version = "2.0.2" description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -306,7 +292,6 @@ tomli = {version = "*", markers = "python_version < \"3.11\""} name = "babel" version = "2.12.1" description = "Internationalization utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -318,7 +303,6 @@ files = [ name = "bcrypt" version = "4.0.1" description = "Modern password hashing for your software and your servers" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -353,7 +337,6 @@ typecheck = ["mypy"] name = "bidict" version = "0.22.1" description = "The bidirectional mapping library for Python." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -370,7 +353,6 @@ test = ["hypothesis", "pytest", "pytest-benchmark[histogram]", "pytest-cov", "py name = "blessed" version = "1.20.0" description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." -category = "main" optional = false python-versions = ">=2.7" files = [ @@ -387,7 +369,6 @@ wcwidth = ">=0.1.4" name = "cachetools" version = "5.3.1" description = "Extensible memoizing collections and decorators" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -397,21 +378,19 @@ files = [ [[package]] name = "certifi" -version = "2023.5.7" +version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] [[package]] name = "cffi" version = "1.15.1" description = "Foreign Function Interface for Python calling C code." -category = "main" optional = false python-versions = "*" files = [ @@ -488,7 +467,6 @@ pycparser = "*" name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." -category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -500,7 +478,6 @@ files = [ name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -583,24 +560,19 @@ files = [ [[package]] name = "click" -version = "8.1.3" +version = "7.1.2" description = "Composable command line interface toolkit" -category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, ] -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "clique" version = "1.6.1" description = "Manage collections with common numerical component" -category = "main" optional = false python-versions = ">=2.7, <4.0" files = [ @@ -617,7 +589,6 @@ test = ["pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)" name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -629,7 +600,6 @@ files = [ name = "commonmark" version = "0.9.1" description = "Python parser for the CommonMark Markdown spec" -category = "dev" optional = false python-versions = "*" files = [ @@ -644,7 +614,6 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] name = "coolname" version = "2.2.0" description = "Random name and slug generator" -category = "main" optional = false python-versions = "*" files = [ @@ -656,7 +625,6 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -732,7 +700,6 @@ toml = ["tomli"] name = "cryptography" version = "39.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -776,7 +743,6 @@ test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0 name = "cx-freeze" version = "6.12.0" description = "Create standalone executables from Python scripts" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -834,7 +800,6 @@ test = ["nose (==1.3.7)", "pygments (>=2.11.2)", "pytest (>=7.0.1)", "pytest-cov name = "cx-logging" version = "3.1.0" description = "Python and C interfaces for logging" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -865,7 +830,6 @@ files = [ name = "dill" version = "0.3.6" description = "serialize all of python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -880,7 +844,6 @@ graph = ["objgraph (>=1.7.2)"] name = "distlib" version = "0.3.6" description = "Distribution utilities" -category = "dev" optional = false python-versions = "*" files = [ @@ -892,7 +855,6 @@ files = [ name = "dnspython" version = "2.3.0" description = "DNS toolkit" -category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -913,7 +875,6 @@ wmi = ["wmi (>=1.5.1,<2.0.0)"] name = "docutils" version = "0.19" description = "Docutils -- Python Documentation Utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -925,7 +886,6 @@ files = [ name = "dropbox" version = "11.36.0" description = "Official Dropbox API Client" -category = "main" optional = false python-versions = "*" files = [ @@ -943,7 +903,6 @@ stone = ">=2" name = "enlighten" version = "1.11.2" description = "Enlighten Progress Bar" -category = "main" optional = false python-versions = "*" files = [ @@ -959,7 +918,6 @@ prefixed = ">=0.3.2" name = "evdev" version = "1.6.1" description = "Bindings to the Linux input handling subsystem" -category = "main" optional = false python-versions = "*" files = [ @@ -970,7 +928,6 @@ files = [ name = "filelock" version = "3.12.0" description = "A platform independent file lock." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -986,7 +943,6 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "p name = "flake8" version = "6.0.0" description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" optional = false python-versions = ">=3.8.1" files = [ @@ -1003,7 +959,6 @@ pyflakes = ">=3.0.0,<3.1.0" name = "frozenlist" version = "1.3.3" description = "A list-like structure which implements collections.abc.MutableSequence" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1087,7 +1042,6 @@ files = [ name = "ftrack-python-api" version = "2.5.0" description = "Python API for ftrack." -category = "main" optional = false python-versions = ">=2.7.9, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -1110,7 +1064,6 @@ websocket-client = ">=0.40.0,<1" name = "future" version = "0.18.3" description = "Clean single-source support for Python 3 and 2" -category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1121,7 +1074,6 @@ files = [ name = "gazu" version = "0.9.3" description = "Gazu is a client for Zou, the API to store the data of your CG production." -category = "main" optional = false python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*, != 3.5.*, != 3.6.1, != 3.6.2" files = [ @@ -1141,7 +1093,6 @@ test = ["pytest", "pytest-cov", "requests-mock"] name = "gitdb" version = "4.0.10" description = "Git Object Database" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1156,7 +1107,6 @@ smmap = ">=3.0.1,<6" name = "gitpython" version = "3.1.31" description = "GitPython is a Python library used to interact with Git repositories" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1171,7 +1121,6 @@ gitdb = ">=4.0.1,<5" name = "google-api-core" version = "2.11.0" description = "Google API client core library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1194,7 +1143,6 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0dev)"] name = "google-api-python-client" version = "1.12.11" description = "Google API Client Library for Python" -category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" files = [ @@ -1214,7 +1162,6 @@ uritemplate = ">=3.0.0,<4dev" name = "google-auth" version = "2.17.3" description = "Google Authentication Library" -category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" files = [ @@ -1239,7 +1186,6 @@ requests = ["requests (>=2.20.0,<3.0.0dev)"] name = "google-auth-httplib2" version = "0.1.0" description = "Google Authentication Library: httplib2 transport" -category = "main" optional = false python-versions = "*" files = [ @@ -1256,7 +1202,6 @@ six = "*" name = "googleapis-common-protos" version = "1.59.0" description = "Common protobufs used in Google APIs" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1274,7 +1219,6 @@ grpc = ["grpcio (>=1.44.0,<2.0.0dev)"] name = "httplib2" version = "0.22.0" description = "A comprehensive HTTP client library." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1289,7 +1233,6 @@ pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0 name = "identify" version = "2.5.24" description = "File identification library for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1304,7 +1247,6 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -1316,7 +1258,6 @@ files = [ name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1328,7 +1269,6 @@ files = [ name = "importlib-metadata" version = "6.6.0" description = "Read metadata from Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1348,7 +1288,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1360,7 +1299,6 @@ files = [ name = "isort" version = "5.12.0" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -1378,7 +1316,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"] name = "jedi" version = "0.13.3" description = "An autocompletion tool for Python that can be used for text editors." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1396,7 +1333,6 @@ testing = ["colorama", "docopt", "pytest (>=3.1.0)"] name = "jeepney" version = "0.8.0" description = "Low-level, pure Python DBus protocol wrapper." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1412,7 +1348,6 @@ trio = ["async_generator", "trio"] name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1430,7 +1365,6 @@ i18n = ["Babel (>=2.7)"] name = "jinxed" version = "1.2.0" description = "Jinxed Terminal Library" -category = "main" optional = false python-versions = "*" files = [ @@ -1445,7 +1379,6 @@ ansicon = {version = "*", markers = "platform_system == \"Windows\""} name = "jsonschema" version = "2.6.0" description = "An implementation of JSON Schema validation for Python" -category = "main" optional = false python-versions = "*" files = [ @@ -1460,7 +1393,6 @@ format = ["rfc3987", "strict-rfc3339", "webcolors"] name = "keyring" version = "22.4.0" description = "Store and access your passwords safely." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1482,7 +1414,6 @@ testing = ["pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2. name = "lazy-object-proxy" version = "1.9.0" description = "A fast and thorough lazy object proxy." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1528,7 +1459,6 @@ files = [ name = "lief" version = "0.13.1" description = "Library to instrument executable formats" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1561,7 +1491,6 @@ files = [ name = "linkify-it-py" version = "2.0.2" description = "Links recognition library with FULL unicode support." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1582,7 +1511,6 @@ test = ["coverage", "pytest", "pytest-cov"] name = "log4mongo" version = "1.7.0" description = "mongo database handler for python logging" -category = "main" optional = false python-versions = "*" files = [ @@ -1596,7 +1524,6 @@ pymongo = "*" name = "m2r2" version = "0.3.3.post2" description = "Markdown and reStructuredText in a single file." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1612,7 +1539,6 @@ mistune = "0.8.4" name = "markdown-it-py" version = "2.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1637,7 +1563,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.0.1" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1716,7 +1641,6 @@ files = [ name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1728,7 +1652,6 @@ files = [ name = "mdit-py-plugins" version = "0.3.5" description = "Collection of plugins for markdown-it-py" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1748,7 +1671,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1760,7 +1682,6 @@ files = [ name = "mistune" version = "0.8.4" description = "The fastest markdown parser in pure Python" -category = "dev" optional = false python-versions = "*" files = [ @@ -1772,7 +1693,6 @@ files = [ name = "multidict" version = "6.0.4" description = "multidict implementation" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1856,7 +1776,6 @@ files = [ name = "myst-parser" version = "0.18.1" description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1883,7 +1802,6 @@ testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" -category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -1898,7 +1816,6 @@ setuptools = "*" name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1910,7 +1827,6 @@ files = [ name = "paramiko" version = "3.2.0" description = "SSH2 protocol library" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1932,7 +1848,6 @@ invoke = ["invoke (>=2.0)"] name = "parso" version = "0.8.3" description = "A Python Parser" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1948,7 +1863,6 @@ testing = ["docopt", "pytest (<6.0.0)"] name = "patchelf" version = "0.17.2.1" description = "A small utility to modify the dynamic linker and RPATH of ELF executables." -category = "dev" optional = false python-versions = "*" files = [ @@ -1968,7 +1882,6 @@ test = ["importlib-metadata", "pytest"] name = "pathlib2" version = "2.3.7.post1" description = "Object-oriented filesystem paths" -category = "main" optional = false python-versions = "*" files = [ @@ -1983,7 +1896,6 @@ six = "*" name = "pillow" version = "9.5.0" description = "Python Imaging Library (Fork)" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2063,7 +1975,6 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa name = "platformdirs" version = "3.5.1" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2079,7 +1990,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2095,7 +2005,6 @@ testing = ["pytest", "pytest-benchmark"] name = "ply" version = "3.11" description = "Python Lex & Yacc" -category = "main" optional = false python-versions = "*" files = [ @@ -2107,7 +2016,6 @@ files = [ name = "pockets" version = "0.9.1" description = "A collection of helpful Python tools!" -category = "dev" optional = false python-versions = "*" files = [ @@ -2122,7 +2030,6 @@ six = ">=1.5.2" name = "pre-commit" version = "3.3.2" description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -2141,7 +2048,6 @@ virtualenv = ">=20.10.0" name = "prefixed" version = "0.7.0" description = "Prefixed alternative numeric library" -category = "main" optional = false python-versions = "*" files = [ @@ -2153,7 +2059,6 @@ files = [ name = "protobuf" version = "4.23.2" description = "" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2176,7 +2081,6 @@ files = [ name = "py" version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -2188,7 +2092,6 @@ files = [ name = "pyasn1" version = "0.5.0" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ @@ -2200,7 +2103,6 @@ files = [ name = "pyasn1-modules" version = "0.3.0" description = "A collection of ASN.1-based protocols modules" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ @@ -2215,7 +2117,6 @@ pyasn1 = ">=0.4.6,<0.6.0" name = "pyblish-base" version = "1.8.11" description = "Plug-in driven automation framework for content" -category = "main" optional = false python-versions = "*" files = [ @@ -2227,7 +2128,6 @@ files = [ name = "pycodestyle" version = "2.10.0" description = "Python style guide checker" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2239,7 +2139,6 @@ files = [ name = "pycparser" version = "2.21" description = "C parser in Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -2251,7 +2150,6 @@ files = [ name = "pydocstyle" version = "6.3.0" description = "Python docstring style checker" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2269,7 +2167,6 @@ toml = ["tomli (>=1.2.3)"] name = "pyflakes" version = "3.0.1" description = "passive checker of Python programs" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2281,7 +2178,6 @@ files = [ name = "pygments" version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2296,7 +2192,6 @@ plugins = ["importlib-metadata"] name = "pylint" version = "2.17.4" description = "python code static checker" -category = "dev" optional = false python-versions = ">=3.7.2" files = [ @@ -2323,7 +2218,6 @@ testutils = ["gitpython (>3)"] name = "pymongo" version = "3.13.0" description = "Python driver for MongoDB " -category = "main" optional = false python-versions = "*" files = [ @@ -2452,7 +2346,6 @@ zstd = ["zstandard"] name = "pynacl" version = "1.5.0" description = "Python binding to the Networking and Cryptography (NaCl) library" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2479,7 +2372,6 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] name = "pynput" version = "1.7.6" description = "Monitor and control user input devices" -category = "main" optional = false python-versions = "*" files = [ @@ -2498,7 +2390,6 @@ six = "*" name = "pyobjc-core" version = "9.1.1" description = "Python<->ObjC Interoperability Module" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2515,7 +2406,6 @@ files = [ name = "pyobjc-framework-applicationservices" version = "9.1.1" description = "Wrappers for the framework ApplicationServices on macOS" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2537,7 +2427,6 @@ pyobjc-framework-Quartz = ">=9.1.1" name = "pyobjc-framework-cocoa" version = "9.1.1" description = "Wrappers for the Cocoa frameworks on macOS" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2557,7 +2446,6 @@ pyobjc-core = ">=9.1.1" name = "pyobjc-framework-quartz" version = "9.1.1" description = "Wrappers for the Quartz frameworks on macOS" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2578,7 +2466,6 @@ pyobjc-framework-Cocoa = ">=9.1.1" name = "pyparsing" version = "2.4.7" description = "Python parsing module" -category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2590,7 +2477,6 @@ files = [ name = "pysftp" version = "0.2.9" description = "A friendly face on SFTP" -category = "main" optional = false python-versions = "*" files = [ @@ -2604,7 +2490,6 @@ paramiko = ">=1.17" name = "pytest" version = "6.2.5" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2629,7 +2514,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2648,7 +2532,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-print" version = "0.3.1" description = "pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2666,7 +2549,6 @@ test = ["coverage (>=5)"] name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -2681,7 +2563,6 @@ six = ">=1.5" name = "python-engineio" version = "4.4.1" description = "Engine.IO server and client for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2697,7 +2578,6 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] name = "python-socketio" version = "5.8.0" description = "Socket.IO server and client for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2719,7 +2599,6 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] name = "python-xlib" version = "0.33" description = "Python X Library" -category = "main" optional = false python-versions = "*" files = [ @@ -2734,7 +2613,6 @@ six = ">=1.10.0" name = "python3-xlib" version = "0.15" description = "Python3 X Library" -category = "main" optional = false python-versions = "*" files = [ @@ -2745,7 +2623,6 @@ files = [ name = "pywin32" version = "301" description = "Python for Window Extensions" -category = "main" optional = false python-versions = "*" files = [ @@ -2765,7 +2642,6 @@ files = [ name = "pywin32-ctypes" version = "0.2.0" description = "" -category = "main" optional = false python-versions = "*" files = [ @@ -2777,7 +2653,6 @@ files = [ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2827,7 +2702,6 @@ files = [ name = "qt-py" version = "1.3.8" description = "Python 2 & 3 compatibility wrapper around all Qt bindings - PySide, PySide2, PyQt4 and PyQt5." -category = "main" optional = false python-versions = "*" files = [ @@ -2842,7 +2716,6 @@ types-PySide2 = "*" name = "qtawesome" version = "0.7.3" description = "FontAwesome icons in PyQt and PySide applications" -category = "main" optional = false python-versions = "*" files = [ @@ -2858,7 +2731,6 @@ six = "*" name = "qtpy" version = "2.3.1" description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2876,7 +2748,6 @@ test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"] name = "recommonmark" version = "0.7.1" description = "A docutils-compatibility bridge to CommonMark, enabling you to write CommonMark inside of Docutils & Sphinx projects." -category = "dev" optional = false python-versions = "*" files = [ @@ -2893,7 +2764,6 @@ sphinx = ">=1.3.1" name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2915,7 +2785,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "revitron-sphinx-theme" version = "0.7.2" description = "Revitron theme for Sphinx" -category = "dev" optional = false python-versions = "*" files = [] @@ -2937,7 +2806,6 @@ resolved_reference = "c0779c66365d9d258d93575ebaff7db9d3aee282" name = "rsa" version = "4.9" description = "Pure-Python RSA implementation" -category = "main" optional = false python-versions = ">=3.6,<4" files = [ @@ -2952,7 +2820,6 @@ pyasn1 = ">=0.1.3" name = "secretstorage" version = "3.3.3" description = "Python bindings to FreeDesktop.org Secret Service API" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2968,7 +2835,6 @@ jeepney = ">=0.6" name = "semver" version = "2.13.0" description = "Python helper for Semantic Versioning (http://semver.org/)" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -2980,7 +2846,6 @@ files = [ name = "setuptools" version = "65.7.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2997,7 +2862,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "shotgun-api3" version = "3.3.3" description = "Shotgun Python API" -category = "main" optional = false python-versions = "*" files = [] @@ -3013,7 +2877,6 @@ resolved_reference = "b9f066c0edbea6e0733242e18f32f75489064840" name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -3025,7 +2888,6 @@ files = [ name = "slack-sdk" version = "3.21.3" description = "The Slack API Platform SDK for Python" -category = "main" optional = false python-versions = ">=3.6.0" files = [ @@ -3041,7 +2903,6 @@ testing = ["Flask (>=1,<2)", "Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "We name = "smmap" version = "5.0.0" description = "A pure Python implementation of a sliding window memory map manager" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -3053,7 +2914,6 @@ files = [ name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" optional = false python-versions = "*" files = [ @@ -3065,7 +2925,6 @@ files = [ name = "speedcopy" version = "2.1.4" description = "Replacement or alternative for python copyfile() utilizing server side copy on network shares for faster copying." -category = "main" optional = false python-versions = "*" files = [ @@ -3077,7 +2936,6 @@ files = [ name = "sphinx" version = "5.3.0" description = "Python documentation generator" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -3113,7 +2971,6 @@ test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] name = "sphinx-autoapi" version = "2.1.0" description = "Sphinx API documentation generator" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3137,7 +2994,6 @@ go = ["sphinxcontrib-golangdomain"] name = "sphinxcontrib-applehelp" version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -3153,7 +3009,6 @@ test = ["pytest"] name = "sphinxcontrib-devhelp" version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -3169,7 +3024,6 @@ test = ["pytest"] name = "sphinxcontrib-htmlhelp" version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -3185,7 +3039,6 @@ test = ["html5lib", "pytest"] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -3200,7 +3053,6 @@ test = ["flake8", "mypy", "pytest"] name = "sphinxcontrib-napoleon" version = "0.7" description = "Sphinx \"napoleon\" extension." -category = "dev" optional = false python-versions = "*" files = [ @@ -3216,7 +3068,6 @@ six = ">=1.5.2" name = "sphinxcontrib-qthelp" version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -3232,7 +3083,6 @@ test = ["pytest"] name = "sphinxcontrib-serializinghtml" version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -3248,7 +3098,6 @@ test = ["pytest"] name = "stone" version = "3.3.1" description = "Stone is an interface description language (IDL) for APIs." -category = "main" optional = false python-versions = "*" files = [ @@ -3265,7 +3114,6 @@ six = ">=1.12.0" name = "termcolor" version = "1.1.0" description = "ANSII Color formatting for output in terminal." -category = "main" optional = false python-versions = "*" files = [ @@ -3276,7 +3124,6 @@ files = [ name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -3288,7 +3135,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3300,7 +3146,6 @@ files = [ name = "tomlkit" version = "0.11.8" description = "Style preserving TOML library" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3312,7 +3157,6 @@ files = [ name = "types-pyside2" version = "5.15.2.1.5" description = "The most accurate stubs for PySide2" -category = "main" optional = false python-versions = "*" files = [ @@ -3323,7 +3167,6 @@ files = [ name = "typing-extensions" version = "4.6.2" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3335,7 +3178,6 @@ files = [ name = "uc-micro-py" version = "1.0.2" description = "Micro subset of unicode data files for linkify-it-py projects." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3350,7 +3192,6 @@ test = ["coverage", "pytest", "pytest-cov"] name = "unidecode" version = "1.2.0" description = "ASCII transliterations of Unicode text" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -3362,7 +3203,6 @@ files = [ name = "uritemplate" version = "3.0.1" description = "URI templates" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -3372,27 +3212,24 @@ files = [ [[package]] name = "urllib3" -version = "2.0.2" +version = "1.26.16" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ - {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"}, - {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"}, + {file = "urllib3-1.26.16-py2.py3-none-any.whl", hash = "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f"}, + {file = "urllib3-1.26.16.tar.gz", hash = "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" version = "20.23.0" description = "Virtual Python Environment builder" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3413,7 +3250,6 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess name = "wcwidth" version = "0.2.6" description = "Measures the displayed width of unicode strings in a terminal" -category = "main" optional = false python-versions = "*" files = [ @@ -3425,7 +3261,6 @@ files = [ name = "websocket-client" version = "0.59.0" description = "WebSocket client for Python with low level API options" -category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -3440,7 +3275,6 @@ six = "*" name = "wheel" version = "0.40.0" description = "A built-package format for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3455,7 +3289,6 @@ test = ["pytest (>=6.0.0)"] name = "wrapt" version = "1.15.0" description = "Module for decorators, wrappers and monkey patching." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" files = [ @@ -3540,7 +3373,6 @@ files = [ name = "wsrpc-aiohttp" version = "3.2.0" description = "WSRPC is the RPC over WebSocket for aiohttp" -category = "main" optional = false python-versions = ">3.5.*, <4" files = [ @@ -3561,7 +3393,6 @@ ujson = ["ujson"] name = "yarl" version = "1.9.2" description = "Yet another URL library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -3649,7 +3480,6 @@ multidict = ">=4.0" name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -3666,5 +3496,5 @@ docs = [] [metadata] lock-version = "2.0" -python-versions = ">=3.9.1,<3.10" -content-hash = "d2b8da22dcd11e0b03f19b9b79e51f205156c5ce75e41cc0225392e9afd8803b" +python-versions = "~3.9" +content-hash = "bc3e256094db6e33894840bb6a5adda4473d3736b852433ad8d5bd478c7e0c1c" diff --git a/pyproject.toml b/pyproject.toml index 5e7938751e..21ba7d1199 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.3" # OpenPype +version = "3.17.6" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" @@ -36,7 +36,7 @@ appdirs = { git = "https://github.com/ActiveState/appdirs.git", branch = "master blessed = "^1.17" # openpype terminal formatting coolname = "*" clique = "1.6.*" -Click = "^8" +Click = "7.1.2" dnspython = "^2.1.0" ftrack-python-api = "^2.3.3" arrow = "^0.17" @@ -56,6 +56,7 @@ QtPy = "^2.3.0" qtawesome = "0.7.3" speedcopy = "^2.1" six = "^1.15" +urllib3 = "1.26.16" semver = "^2.13.0" # for version resolution wsrpc_aiohttp = "^3.1.1" # websocket server pywin32 = { version = "301", markers = "sys_platform == 'win32'" } diff --git a/schema/application-1.0.json b/schema/application-1.0.json deleted file mode 100644 index 953abee569..0000000000 --- a/schema/application-1.0.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:application-1.0", - "description": "An application definition.", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "label", - "application_dir", - "executable" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "label": { - "description": "Nice name of application.", - "type": "string" - }, - "application_dir": { - "description": "Name of directory used for application resources.", - "type": "string" - }, - "executable": { - "description": "Name of callable executable, this is called to launch the application", - "type": "string" - }, - "description": { - "description": "Description of application.", - "type": "string" - }, - "environment": { - "description": "Key/value pairs for environment variables related to this application. Supports lists for paths, such as PYTHONPATH.", - "type": "object", - "items": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - } - }, - "default_dirs": { - "type": "array", - "items": { - "type": "string" - } - }, - "copy": { - "type": "object", - "patternProperties": { - "^.*$": { - "anyOf": [ - {"type": "string"}, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - } -} diff --git a/schema/asset-1.0.json b/schema/asset-1.0.json deleted file mode 100644 index ab104c002a..0000000000 --- a/schema/asset-1.0.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:asset-1.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "name", - "subsets" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "name": { - "description": "Name of directory", - "type": "string" - }, - "subsets": { - "type": "array", - "items": { - "$ref": "subset.json" - } - } - }, - - "definitions": {} -} diff --git a/schema/asset-2.0.json b/schema/asset-2.0.json deleted file mode 100644 index b894d79792..0000000000 --- a/schema/asset-2.0.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:asset-2.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "silo", - "data" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:asset-2.0"], - "example": "openpype:asset-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["asset"], - "example": "asset" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of asset", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "Bruce" - }, - "silo": { - "description": "Group or container of asset", - "type": "string", - "example": "assets" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": {"key": "value"} - } - }, - - "definitions": {} -} diff --git a/schema/asset-3.0.json b/schema/asset-3.0.json deleted file mode 100644 index 948704d2a1..0000000000 --- a/schema/asset-3.0.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:asset-3.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:asset-3.0"], - "example": "openpype:asset-3.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["asset"], - "example": "asset" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of asset", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "Bruce" - }, - "silo": { - "description": "Group or container of asset", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "assets" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": {"key": "value"} - } - }, - - "definitions": {} -} diff --git a/schema/config-1.0.json b/schema/config-1.0.json deleted file mode 100644 index 49398a57cd..0000000000 --- a/schema/config-1.0.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-1.0", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": false, - "required": [ - "tasks", - "apps" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "template": { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^.*$": { - "type": "string" - } - } - }, - "tasks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": ["name"] - } - }, - "apps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": ["name"] - } - }, - "families": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "label": {"type": "string"}, - "hideFilter": {"type": "boolean"} - }, - "required": ["name"] - } - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "color": {"type": "string"}, - "order": {"type": ["integer", "number"]} - }, - "required": ["name"] - } - }, - "copy": { - "type": "object" - } - } -} diff --git a/schema/config-1.1.json b/schema/config-1.1.json deleted file mode 100644 index 6e15514aaf..0000000000 --- a/schema/config-1.1.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-1.1", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": false, - "required": [ - "tasks", - "apps" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "template": { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^.*$": { - "type": "string" - } - } - }, - "tasks": { - "type": "object", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": [ - "short_name" - ] - } - }, - "apps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": ["name"] - } - }, - "families": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "label": {"type": "string"}, - "hideFilter": {"type": "boolean"} - }, - "required": ["name"] - } - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "color": {"type": "string"}, - "order": {"type": ["integer", "number"]} - }, - "required": ["name"] - } - }, - "copy": { - "type": "object" - } - } -} diff --git a/schema/config-2.0.json b/schema/config-2.0.json deleted file mode 100644 index 54b226711a..0000000000 --- a/schema/config-2.0.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-2.0", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": false, - "required": [ - "tasks", - "apps" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "templates": { - "type": "object" - }, - "roots": { - "type": "object" - }, - "imageio": { - "type": "object" - }, - "tasks": { - "type": "object", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": [ - "short_name" - ] - } - }, - "apps": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "group": {"type": "string"}, - "label": {"type": "string"} - }, - "required": ["name"] - } - }, - "families": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "label": {"type": "string"}, - "hideFilter": {"type": "boolean"} - }, - "required": ["name"] - } - }, - "groups": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "icon": {"type": "string"}, - "color": {"type": "string"}, - "order": {"type": ["integer", "number"]} - }, - "required": ["name"] - } - }, - "copy": { - "type": "object" - } - } -} diff --git a/schema/container-1.0.json b/schema/container-1.0.json deleted file mode 100644 index 012e8499e6..0000000000 --- a/schema/container-1.0.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:container-1.0", - "description": "A loaded asset", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "id", - "objectName", - "name", - "author", - "loader", - "families", - "time", - "subset", - "asset", - "representation", - "version", - "silo", - "path", - "source" - ], - "properties": { - "id": { - "description": "Identifier for finding object in host", - "type": "string", - "enum": ["pyblish.mindbender.container"], - "example": "pyblish.mindbender.container" - }, - "objectName": { - "description": "Name of internal object, such as the objectSet in Maya.", - "type": "string", - "example": "Bruce_:rigDefault_CON" - }, - "name": { - "description": "Full name of application object", - "type": "string", - "example": "modelDefault" - }, - "author": { - "description": "Name of the author of the published version", - "type": "string", - "example": "Marcus Ottosson" - }, - "loader": { - "description": "Name of loader plug-in used to produce this container", - "type": "string", - "example": "ModelLoader" - }, - "families": { - "description": "Families associated with the this subset", - "type": "string", - "example": "mindbender.model" - }, - "time": { - "description": "File-system safe, formatted time", - "type": "string", - "example": "20170329T131545Z" - }, - "subset": { - "description": "Name of source subset", - "type": "string", - "example": "modelDefault" - }, - "asset": { - "description": "Name of source asset", - "type": "string" , - "example": "Bruce" - }, - "representation": { - "description": "Name of source representation", - "type": "string" , - "example": ".ma" - }, - "version": { - "description": "Version number", - "type": "number", - "example": 12 - }, - "silo": { - "description": "Silo of parent asset", - "type": "string", - "example": "assets" - }, - "path": { - "description": "Absolute path on disk", - "type": "string", - "example": "{root}/assets/Bruce/publish/rigDefault/v002" - }, - "source": { - "description": "Absolute path to file from which this version was published", - "type": "string", - "example": "{root}/assets/Bruce/work/rigging/maya/scenes/rig_v001.ma" - } - } -} diff --git a/schema/container-2.0.json b/schema/container-2.0.json deleted file mode 100644 index 1673ee5d1d..0000000000 --- a/schema/container-2.0.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:container-2.0", - "description": "A loaded asset", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "id", - "objectName", - "name", - "namespace", - "loader", - "representation" - ], - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:container-2.0"], - "example": "openpype:container-2.0" - }, - "id": { - "description": "Identifier for finding object in host", - "type": "string", - "enum": ["pyblish.avalon.container"], - "example": "pyblish.avalon.container" - }, - "objectName": { - "description": "Name of internal object, such as the objectSet in Maya.", - "type": "string", - "example": "Bruce_:rigDefault_CON" - }, - "loader": { - "description": "Name of loader plug-in used to produce this container", - "type": "string", - "example": "ModelLoader" - }, - "name": { - "description": "Internal object name of container in application", - "type": "string", - "example": "modelDefault_01" - }, - "namespace": { - "description": "Internal namespace of container in application", - "type": "string", - "example": "Bruce_" - }, - "representation": { - "description": "Unique id of representation in database", - "type": "string", - "example": "59523f355f8c1b5f6c5e8348" - } - } -} diff --git a/schema/hero_version-1.0.json b/schema/hero_version-1.0.json deleted file mode 100644 index b720dc2887..0000000000 --- a/schema/hero_version-1.0.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:hero_version-1.0", - "description": "Hero version of asset", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "version_id", - "schema", - "type", - "parent" - ], - - "properties": { - "_id": { - "description": "Document's id (database will create it's if not entered)", - "example": "ObjectId(592c33475f8c1b064c4d1696)" - }, - "version_id": { - "description": "The version ID from which it was created", - "example": "ObjectId(592c33475f8c1b064c4d1695)" - }, - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:hero_version-1.0"], - "example": "openpype:hero_version-1.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["hero_version"], - "example": "hero_version" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "ObjectId(592c33475f8c1b064c4d1697)" - } - } -} diff --git a/schema/inventory-1.0.json b/schema/inventory-1.0.json deleted file mode 100644 index 2fe78794ab..0000000000 --- a/schema/inventory-1.0.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-1.0", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": true -} diff --git a/schema/inventory-1.1.json b/schema/inventory-1.1.json deleted file mode 100644 index b61a76b32a..0000000000 --- a/schema/inventory-1.1.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:config-1.1", - "description": "A project configuration.", - - "type": "object", - - "additionalProperties": true -} diff --git a/schema/project-2.0.json b/schema/project-2.0.json deleted file mode 100644 index 0ed5a55599..0000000000 --- a/schema/project-2.0.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:project-2.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "data", - "config" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:project-2.0"], - "example": "openpype:project-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["project"], - "example": "project" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "hulk" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": { - "fps": 24, - "width": 1920, - "height": 1080 - } - }, - "config": { - "type": "object", - "description": "Document metadata", - "example": { - "schema": "openpype:config-1.0", - "apps": [ - { - "name": "maya2016", - "label": "Autodesk Maya 2016" - }, - { - "name": "nuke10", - "label": "The Foundry Nuke 10.0" - } - ], - "tasks": [ - {"name": "model"}, - {"name": "render"}, - {"name": "animate"}, - {"name": "rig"}, - {"name": "lookdev"}, - {"name": "layout"} - ], - "template": { - "work": - "{root}/{project}/{silo}/{asset}/work/{task}/{app}", - "publish": - "{root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/{subset}.{representation}" - } - }, - "$ref": "config-1.0.json" - } - }, - - "definitions": {} -} diff --git a/schema/project-2.1.json b/schema/project-2.1.json deleted file mode 100644 index 9413c9f691..0000000000 --- a/schema/project-2.1.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:project-2.1", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "data", - "config" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:project-2.1"], - "example": "openpype:project-2.1" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["project"], - "example": "project" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "hulk" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": { - "fps": 24, - "width": 1920, - "height": 1080 - } - }, - "config": { - "type": "object", - "description": "Document metadata", - "example": { - "schema": "openpype:config-1.1", - "apps": [ - { - "name": "maya2016", - "label": "Autodesk Maya 2016" - }, - { - "name": "nuke10", - "label": "The Foundry Nuke 10.0" - } - ], - "tasks": { - "Model": {"short_name": "mdl"}, - "Render": {"short_name": "rnd"}, - "Animate": {"short_name": "anim"}, - "Rig": {"short_name": "rig"}, - "Lookdev": {"short_name": "look"}, - "Layout": {"short_name": "lay"} - }, - "template": { - "work": - "{root}/{project}/{silo}/{asset}/work/{task}/{app}", - "publish": - "{root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/{subset}.{representation}" - } - }, - "$ref": "config-1.1.json" - } - }, - - "definitions": {} -} diff --git a/schema/project-3.0.json b/schema/project-3.0.json deleted file mode 100644 index be23e10c93..0000000000 --- a/schema/project-3.0.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:project-3.0", - "description": "A unit of data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "name", - "data", - "config" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:project-3.0"], - "example": "openpype:project-3.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["project"], - "example": "project" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "hulk" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": { - "fps": 24, - "width": 1920, - "height": 1080 - } - }, - "config": { - "type": "object", - "description": "Document metadata", - "$ref": "config-2.0.json" - } - }, - - "definitions": {} -} diff --git a/schema/representation-1.0.json b/schema/representation-1.0.json deleted file mode 100644 index 347c585f52..0000000000 --- a/schema/representation-1.0.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:representation-1.0", - "description": "The inverse of an instance", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "format", - "path" - ], - - "properties": { - "schema": {"type": "string"}, - "format": { - "description": "File extension, including '.'", - "type": "string" - }, - "path": { - "description": "Unformatted path to version.", - "type": "string" - } - } -} diff --git a/schema/representation-2.0.json b/schema/representation-2.0.json deleted file mode 100644 index f47c16a10a..0000000000 --- a/schema/representation-2.0.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:representation-2.0", - "description": "The inverse of an instance", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:representation-2.0"], - "example": "openpype:representation-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["representation"], - "example": "representation" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of representation", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "abc" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": { - "label": "Alembic" - } - }, - "dependencies": { - "description": "Other representation that this representation depends on", - "type": "array", - "items": {"type": "string"}, - "example": [ - "592d547a5f8c1b388093c145" - ] - }, - "context": { - "description": "Summary of the context to which this representation belong.", - "type": "object", - "properties": { - "project": {"type": "object"}, - "asset": {"type": "string"}, - "silo": {"type": ["string", "null"]}, - "subset": {"type": "string"}, - "version": {"type": "number"}, - "representation": {"type": "string"} - }, - "example": { - "project": "hulk", - "asset": "Bruce", - "silo": "assets", - "subset": "rigDefault", - "version": 12, - "representation": "ma" - } - } - } -} diff --git a/schema/session-1.0.json b/schema/session-1.0.json deleted file mode 100644 index 5ced0a6f08..0000000000 --- a/schema/session-1.0.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:session-1.0", - "description": "The Avalon environment", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "AVALON_PROJECTS", - "AVALON_PROJECT", - "AVALON_ASSET", - "AVALON_SILO", - "AVALON_CONFIG" - ], - - "properties": { - "AVALON_PROJECTS": { - "description": "Absolute path to root of project directories", - "type": "string", - "example": "/nas/projects" - }, - "AVALON_PROJECT": { - "description": "Name of project", - "type": "string", - "pattern": "^\\w*$", - "example": "Hulk" - }, - "AVALON_ASSET": { - "description": "Name of asset", - "type": "string", - "pattern": "^\\w*$", - "example": "Bruce" - }, - "AVALON_SILO": { - "description": "Name of asset group or container", - "type": "string", - "pattern": "^\\w*$", - "example": "assets" - }, - "AVALON_TASK": { - "description": "Name of task", - "type": "string", - "pattern": "^\\w*$", - "example": "modeling" - }, - "AVALON_CONFIG": { - "description": "Name of Avalon configuration", - "type": "string", - "pattern": "^\\w*$", - "example": "polly" - }, - "AVALON_APP": { - "description": "Name of application", - "type": "string", - "pattern": "^\\w*$", - "example": "maya2016" - }, - "AVALON_MONGO": { - "description": "Address to the asset database", - "type": "string", - "pattern": "^mongodb://[\\w/@:.]*$", - "example": "mongodb://localhost:27017", - "default": "mongodb://localhost:27017" - }, - "AVALON_DB": { - "description": "Name of database", - "type": "string", - "pattern": "^\\w*$", - "example": "avalon", - "default": "avalon" - }, - "AVALON_LABEL": { - "description": "Nice name of Avalon, used in e.g. graphical user interfaces", - "type": "string", - "example": "Mindbender", - "default": "Avalon" - }, - "AVALON_SENTRY": { - "description": "Address to Sentry", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "https://5b872b280de742919b115bdc8da076a5:8d278266fe764361b8fa6024af004a9c@logs.mindbender.com/2", - "default": null - }, - "AVALON_DEADLINE": { - "description": "Address to Deadline", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "http://192.168.99.101", - "default": null - }, - "AVALON_TIMEOUT": { - "description": "Wherever there is a need for a timeout, this is the default value.", - "type": "string", - "pattern": "^[0-9]*$", - "default": "1000", - "example": "1000" - }, - "AVALON_UPLOAD": { - "description": "Boolean of whether to upload published material to central asset repository", - "type": "string", - "default": null, - "example": "True" - }, - "AVALON_USERNAME": { - "description": "Generic username", - "type": "string", - "pattern": "^\\w*$", - "default": "avalon", - "example": "myself" - }, - "AVALON_PASSWORD": { - "description": "Generic password", - "type": "string", - "pattern": "^\\w*$", - "default": "secret", - "example": "abc123" - }, - "AVALON_INSTANCE_ID": { - "description": "Unique identifier for instances in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.instance", - "example": "avalon.instance" - }, - "AVALON_CONTAINER_ID": { - "description": "Unique identifier for a loaded representation in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.container", - "example": "avalon.container" - }, - "AVALON_DEBUG": { - "description": "Enable debugging mode. Some applications may use this for e.g. extended verbosity or mock plug-ins.", - "type": "string", - "default": null, - "example": "True" - } - } -} diff --git a/schema/session-2.0.json b/schema/session-2.0.json deleted file mode 100644 index 0a4d51beb2..0000000000 --- a/schema/session-2.0.json +++ /dev/null @@ -1,134 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:session-2.0", - "description": "The Avalon environment", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "AVALON_PROJECT", - "AVALON_ASSET", - "AVALON_CONFIG" - ], - - "properties": { - "AVALON_PROJECTS": { - "description": "Absolute path to root of project directories", - "type": "string", - "example": "/nas/projects" - }, - "AVALON_PROJECT": { - "description": "Name of project", - "type": "string", - "pattern": "^\\w*$", - "example": "Hulk" - }, - "AVALON_ASSET": { - "description": "Name of asset", - "type": "string", - "pattern": "^\\w*$", - "example": "Bruce" - }, - "AVALON_SILO": { - "description": "Name of asset group or container", - "type": "string", - "pattern": "^\\w*$", - "example": "assets" - }, - "AVALON_TASK": { - "description": "Name of task", - "type": "string", - "pattern": "^\\w*$", - "example": "modeling" - }, - "AVALON_CONFIG": { - "description": "Name of Avalon configuration", - "type": "string", - "pattern": "^\\w*$", - "example": "polly" - }, - "AVALON_APP": { - "description": "Name of application", - "type": "string", - "pattern": "^\\w*$", - "example": "maya2016" - }, - "AVALON_DB": { - "description": "Name of database", - "type": "string", - "pattern": "^\\w*$", - "example": "avalon", - "default": "avalon" - }, - "AVALON_LABEL": { - "description": "Nice name of Avalon, used in e.g. graphical user interfaces", - "type": "string", - "example": "Mindbender", - "default": "Avalon" - }, - "AVALON_SENTRY": { - "description": "Address to Sentry", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "https://5b872b280de742919b115bdc8da076a5:8d278266fe764361b8fa6024af004a9c@logs.mindbender.com/2", - "default": null - }, - "AVALON_DEADLINE": { - "description": "Address to Deadline", - "type": "string", - "pattern": "^http[\\w/@:.]*$", - "example": "http://192.168.99.101", - "default": null - }, - "AVALON_TIMEOUT": { - "description": "Wherever there is a need for a timeout, this is the default value.", - "type": "string", - "pattern": "^[0-9]*$", - "default": "1000", - "example": "1000" - }, - "AVALON_UPLOAD": { - "description": "Boolean of whether to upload published material to central asset repository", - "type": "string", - "default": null, - "example": "True" - }, - "AVALON_USERNAME": { - "description": "Generic username", - "type": "string", - "pattern": "^\\w*$", - "default": "avalon", - "example": "myself" - }, - "AVALON_PASSWORD": { - "description": "Generic password", - "type": "string", - "pattern": "^\\w*$", - "default": "secret", - "example": "abc123" - }, - "AVALON_INSTANCE_ID": { - "description": "Unique identifier for instances in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.instance", - "example": "avalon.instance" - }, - "AVALON_CONTAINER_ID": { - "description": "Unique identifier for a loaded representation in a working file", - "type": "string", - "pattern": "^[\\w.]*$", - "default": "avalon.container", - "example": "avalon.container" - }, - "AVALON_DEBUG": { - "description": "Enable debugging mode. Some applications may use this for e.g. extended verbosity or mock plug-ins.", - "type": "string", - "default": null, - "example": "True" - } - } -} diff --git a/schema/shaders-1.0.json b/schema/shaders-1.0.json deleted file mode 100644 index 7102ba1861..0000000000 --- a/schema/shaders-1.0.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:shaders-1.0", - "description": "Relationships between shaders and Avalon IDs", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "shader" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "shader": { - "description": "Name of directory", - "type": "array", - "items": { - "type": "str", - "description": "Avalon ID and optional face indexes, e.g. 'f9520572-ac1d-11e6-b39e-3085a99791c9.f[5002:5185]'" - } - } - }, - - "definitions": {} -} diff --git a/schema/subset-1.0.json b/schema/subset-1.0.json deleted file mode 100644 index a299a6d341..0000000000 --- a/schema/subset-1.0.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:subset-1.0", - "description": "A container of instances", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "name", - "versions" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string" - }, - "name": { - "description": "Name of directory", - "type": "string" - }, - "versions": { - "type": "array", - "items": { - "$ref": "version.json" - } - } - }, - - "definitions": {} -} diff --git a/schema/subset-2.0.json b/schema/subset-2.0.json deleted file mode 100644 index db256ec7fb..0000000000 --- a/schema/subset-2.0.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:subset-2.0", - "description": "A container of instances", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:subset-2.0"], - "example": "openpype:subset-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["subset"], - "example": "subset" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "shot01" - }, - "data": { - "type": "object", - "description": "Document metadata", - "example": { - "frameStart": 1000, - "frameEnd": 1201 - } - } - } -} diff --git a/schema/subset-3.0.json b/schema/subset-3.0.json deleted file mode 100644 index 1a0db53c04..0000000000 --- a/schema/subset-3.0.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:subset-3.0", - "description": "A container of instances", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:subset-3.0"], - "example": "openpype:subset-3.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["subset"], - "example": "subset" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Name of directory", - "type": "string", - "pattern": "^[a-zA-Z0-9_.]*$", - "example": "shot01" - }, - "data": { - "description": "Document metadata", - "type": "object", - "required": ["families"], - "properties": { - "families": { - "type": "array", - "items": {"type": "string"}, - "description": "One or more families associated with this subset" - } - }, - "example": { - "families" : [ - "avalon.camera" - ], - "frameStart": 1000, - "frameEnd": 1201 - } - } - } -} diff --git a/schema/thumbnail-1.0.json b/schema/thumbnail-1.0.json deleted file mode 100644 index 5bdf78a4b1..0000000000 --- a/schema/thumbnail-1.0.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:thumbnail-1.0", - "description": "Entity with thumbnail data", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:thumbnail-1.0"], - "example": "openpype:thumbnail-1.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["thumbnail"], - "example": "thumbnail" - }, - "data": { - "description": "Thumbnail data", - "type": "object", - "example": { - "binary_data": "Binary({byte data of image})", - "template": "{thumbnail_root}/{project[name]}/{_id}{ext}}", - "template_data": { - "ext": ".jpg" - } - } - } - } -} diff --git a/schema/version-1.0.json b/schema/version-1.0.json deleted file mode 100644 index daa1997721..0000000000 --- a/schema/version-1.0.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:version-1.0", - "description": "An individual version", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "version", - "path", - "time", - "author", - "source", - "representations" - ], - - "properties": { - "schema": {"type": "string"}, - "representations": { - "type": "array", - "items": { - "$ref": "representation.json" - } - }, - "time": { - "description": "ISO formatted, file-system compatible time", - "type": "string" - }, - "author": { - "description": "User logged on to the machine at time of publish", - "type": "string" - }, - "version": { - "description": "Number of this version", - "type": "number" - }, - "path": { - "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001", - "type": "string" - }, - "source": { - "description": "Original file from which this version was made.", - "type": "string" - } - } -} diff --git a/schema/version-2.0.json b/schema/version-2.0.json deleted file mode 100644 index 099e9be70a..0000000000 --- a/schema/version-2.0.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:version-2.0", - "description": "An individual version", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:version-2.0"], - "example": "openpype:version-2.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["version"], - "example": "version" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Number of version", - "type": "number", - "example": 12 - }, - "locations": { - "description": "Where on the planet this version can be found.", - "type": "array", - "items": {"type": "string"}, - "example": ["data.avalon.com"] - }, - "data": { - "description": "Document metadata", - "type": "object", - "required": ["families", "author", "source", "time"], - "properties": { - "time": { - "description": "ISO formatted, file-system compatible time", - "type": "string" - }, - "timeFormat": { - "description": "ISO format of time", - "type": "string" - }, - "author": { - "description": "User logged on to the machine at time of publish", - "type": "string" - }, - "version": { - "description": "Number of this version", - "type": "number" - }, - "path": { - "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001", - "type": "string" - }, - "source": { - "description": "Original file from which this version was made.", - "type": "string" - }, - "families": { - "type": "array", - "items": {"type": "string"}, - "description": "One or more families associated with this version" - } - }, - "example": { - "source" : "{root}/f02_prod/assets/BubbleWitch/work/modeling/marcus/maya/scenes/model_v001.ma", - "author" : "marcus", - "families" : [ - "avalon.model" - ], - "time" : "20170510T090203Z" - } - } - } -} diff --git a/schema/version-3.0.json b/schema/version-3.0.json deleted file mode 100644 index 3e07fc4499..0000000000 --- a/schema/version-3.0.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:version-3.0", - "description": "An individual version", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "parent", - "name", - "data" - ], - - "properties": { - "schema": { - "description": "The schema associated with this document", - "type": "string", - "enum": ["openpype:version-3.0"], - "example": "openpype:version-3.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["version"], - "example": "version" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "name": { - "description": "Number of version", - "type": "number", - "example": 12 - }, - "locations": { - "description": "Where on the planet this version can be found.", - "type": "array", - "items": {"type": "string"}, - "example": ["data.avalon.com"] - }, - "data": { - "description": "Document metadata", - "type": "object", - "required": ["author", "source", "time"], - "properties": { - "time": { - "description": "ISO formatted, file-system compatible time", - "type": "string" - }, - "timeFormat": { - "description": "ISO format of time", - "type": "string" - }, - "author": { - "description": "User logged on to the machine at time of publish", - "type": "string" - }, - "version": { - "description": "Number of this version", - "type": "number" - }, - "path": { - "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001", - "type": "string" - }, - "source": { - "description": "Original file from which this version was made.", - "type": "string" - } - }, - "example": { - "source" : "{root}/f02_prod/assets/BubbleWitch/work/modeling/marcus/maya/scenes/model_v001.ma", - "author" : "marcus", - "time" : "20170510T090203Z" - } - } - } -} diff --git a/schema/workfile-1.0.json b/schema/workfile-1.0.json deleted file mode 100644 index 5f9600ef20..0000000000 --- a/schema/workfile-1.0.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - - "title": "openpype:workfile-1.0", - "description": "Workfile additional information.", - - "type": "object", - - "additionalProperties": true, - - "required": [ - "schema", - "type", - "filename", - "task_name", - "parent" - ], - - "properties": { - "schema": { - "description": "Schema identifier for payload", - "type": "string", - "enum": ["openpype:workfile-1.0"], - "example": "openpype:workfile-1.0" - }, - "type": { - "description": "The type of document", - "type": "string", - "enum": ["workfile"], - "example": "workfile" - }, - "parent": { - "description": "Unique identifier to parent document", - "example": "592c33475f8c1b064c4d1696" - }, - "filename": { - "description": "Workfile's filename", - "type": "string", - "example": "kuba_each_case_Alpaca_01_animation_v001.ma" - }, - "task_name": { - "description": "Task name", - "type": "string", - "example": "animation" - }, - "data": { - "description": "Document metadata", - "type": "object", - "example": {"key": "value"} - } - } -} diff --git a/server_addon/aftereffects/server/settings/creator_plugins.py b/server_addon/aftereffects/server/settings/creator_plugins.py index ee52fadd40..9cb03b0b26 100644 --- a/server_addon/aftereffects/server/settings/creator_plugins.py +++ b/server_addon/aftereffects/server/settings/creator_plugins.py @@ -5,7 +5,7 @@ from ayon_server.settings import BaseSettingsModel class CreateRenderPlugin(BaseSettingsModel): mark_for_review: bool = Field(True, title="Review") - defaults: list[str] = Field( + default_variants: list[str] = Field( default_factory=list, title="Default Variants" ) diff --git a/server_addon/aftereffects/server/settings/main.py b/server_addon/aftereffects/server/settings/main.py index 04d2e51cc9..4edc46d259 100644 --- a/server_addon/aftereffects/server/settings/main.py +++ b/server_addon/aftereffects/server/settings/main.py @@ -40,7 +40,7 @@ DEFAULT_AFTEREFFECTS_SETTING = { "create": { "RenderCreator": { "mark_for_review": True, - "defaults": [ + "default_variants": [ "Main" ] } diff --git a/server_addon/aftereffects/server/version.py b/server_addon/aftereffects/server/version.py index a242f0e757..df0c92f1e2 100644 --- a/server_addon/aftereffects/server/version.py +++ b/server_addon/aftereffects/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index b19308ee7c..825f50276a 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -7,6 +7,26 @@ "host_name": "maya", "environment": "{\n \"MAYA_DISABLE_CLIC_IPM\": \"Yes\",\n \"MAYA_DISABLE_CIP\": \"Yes\",\n \"MAYA_DISABLE_CER\": \"Yes\",\n \"PYMEL_SKIP_MEL_INIT\": \"Yes\",\n \"LC_ALL\": \"C\"\n}\n", "variants": [ + { + "name": "2024", + "label": "2024", + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\maya.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/maya" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{\n \"MAYA_VERSION\": \"2024\"\n}", + "use_python_2": false + }, { "name": "2023", "label": "2023", @@ -45,18 +65,47 @@ "linux": [] }, "environment": "{\n \"MAYA_VERSION\": \"2022\"\n}", + "use_python_2": true + } + ] + }, + "mayapy": { + "enabled": true, + "label": "Maya", + "icon": "{}/app_icons/maya.png", + "host_name": "maya", + "environment": "{\n \"MAYA_DISABLE_CLIC_IPM\": \"Yes\",\n \"MAYA_DISABLE_CIP\": \"Yes\",\n \"MAYA_DISABLE_CER\": \"Yes\",\n \"PYMEL_SKIP_MEL_INIT\": \"Yes\",\n \"LC_ALL\": \"C\"\n}\n", + "variants": [ + { + "name": "2024", + "label": "2024", + "executables": { + "windows": [ + "C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe" + ], + "darwin": [], + "linux": [ + "/usr/autodesk/maya2024/bin/mayapy" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{\n \"MAYA_VERSION\": \"2024\"\n}", "use_python_2": false }, { - "name": "2020", - "label": "2020", + "name": "2023", + "label": "2023", "executables": { "windows": [ - "C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe" + "C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe" ], "darwin": [], "linux": [ - "/usr/autodesk/maya2020/bin/maya" + "/usr/autodesk/maya2023/bin/mayapy" ] }, "arguments": { @@ -64,48 +113,8 @@ "darwin": [], "linux": [] }, - "environment": "{\n \"MAYA_VERSION\": \"2020\"\n}", - "use_python_2": true - }, - { - "name": "2019", - "label": "2019", - "executables": { - "windows": [ - "C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe" - ], - "darwin": [], - "linux": [ - "/usr/autodesk/maya2019/bin/maya" - ] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": "{\n \"MAYA_VERSION\": \"2019\"\n}", - "use_python_2": true - }, - { - "name": "2018", - "label": "2018", - "executables": { - "windows": [ - "C:\\Program Files\\Autodesk\\Maya2018\\bin\\maya.exe" - ], - "darwin": [], - "linux": [ - "/usr/autodesk/maya2018/bin/maya" - ] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, - "environment": "{\n \"MAYA_VERSION\": \"2018\"\n}", - "use_python_2": true + "environment": "{\n \"MAYA_VERSION\": \"2023\"\n}", + "use_python_2": false } ] }, @@ -127,9 +136,7 @@ "linux": [] }, "arguments": { - "windows": [ - "-U MAXScript {OPENPYPE_ROOT}\\openpype\\hosts\\max\\startup\\startup.ms" - ], + "windows": [], "darwin": [], "linux": [] }, @@ -239,6 +246,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -321,6 +329,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -407,6 +416,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -493,6 +503,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -579,6 +590,7 @@ }, { "name": "13-0", + "label": "13.0", "use_python_2": false, "executables": { "windows": [ @@ -1080,6 +1092,32 @@ } ] }, + "substancepainter": { + "enabled": true, + "label": "Substance Painter", + "icon": "{}/app_icons/substancepainter.png", + "host_name": "substancepainter", + "environment": "{}", + "variants": [ + { + "name": "8-2-0", + "label": "8.2", + "executables": { + "windows": [ + "C:\\Program Files\\Adobe\\Adobe Substance 3D Painter\\Adobe Substance 3D Painter.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{}" + } + ] + }, "unreal": { "enabled": true, "label": "Unreal Editor", @@ -1120,6 +1158,32 @@ } ] }, + "wrap": { + "enabled": true, + "label": "Wrap", + "icon": "{}/app_icons/wrap.png", + "host_name": "wrap", + "environment": "{\n \n}", + "variants": [ + { + "name": "2023", + "use_python_2": false, + "executables": { + "windows": [ + "c:\\Program Files\\Faceform\\Wrap 2023.10.2\\Wrap.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{\n \n}" + } + ] + }, "additional_apps": [] } } diff --git a/server_addon/applications/server/settings.py b/server_addon/applications/server/settings.py index fd481b6ce8..224f999564 100644 --- a/server_addon/applications/server/settings.py +++ b/server_addon/applications/server/settings.py @@ -115,9 +115,7 @@ class ToolGroupModel(BaseSettingsModel): name: str = Field("", title="Name") label: str = Field("", title="Label") environment: str = Field("{}", title="Environments", widget="textarea") - variants: list[ToolVariantModel] = Field( - default_factory=ToolVariantModel - ) + variants: list[ToolVariantModel] = Field(default_factory=list) @validator("environment") def validate_json(cls, value): @@ -166,8 +164,12 @@ class ApplicationsSettings(BaseSettingsModel): default_factory=AppGroupWithPython, title="Adobe After Effects") celaction: AppGroup = Field( default_factory=AppGroupWithPython, title="Celaction 2D") + substancepainter: AppGroup = Field( + default_factory=AppGroupWithPython, title="Substance Painter") unreal: AppGroup = Field( default_factory=AppGroupWithPython, title="Unreal Editor") + wrap: AppGroup = Field( + default_factory=AppGroupWithPython, title="Wrap") additional_apps: list[AdditionalAppGroup] = Field( default_factory=list, title="Additional Applications") diff --git a/server_addon/applications/server/version.py b/server_addon/applications/server/version.py index 485f44ac21..ae7362549b 100644 --- a/server_addon/applications/server/version.py +++ b/server_addon/applications/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.3" diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index f6118d39cd..5eff276ef5 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -9,6 +9,10 @@ from .publish_plugins import ( PublishPuginsModel, DEFAULT_BLENDER_PUBLISH_SETTINGS ) +from .render_settings import ( + RenderSettingsModel, + DEFAULT_RENDER_SETTINGS +) class UnitScaleSettingsModel(BaseSettingsModel): @@ -37,6 +41,8 @@ class BlenderSettings(BaseSettingsModel): default_factory=BlenderImageIOModel, title="Color Management (ImageIO)" ) + RenderSettings: RenderSettingsModel = Field( + default_factory=RenderSettingsModel, title="Render Settings") workfile_builder: TemplateWorkfileBaseOptions = Field( default_factory=TemplateWorkfileBaseOptions, title="Workfile Builder" @@ -55,6 +61,7 @@ DEFAULT_VALUES = { }, "set_frames_startup": True, "set_resolution_startup": True, + "RenderSettings": DEFAULT_RENDER_SETTINGS, "publish": DEFAULT_BLENDER_PUBLISH_SETTINGS, "workfile_builder": { "create_first_version": False, diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 65dda78411..7a5bc236d4 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -26,6 +26,16 @@ class ValidatePluginModel(BaseSettingsModel): active: bool = Field(title="Active") +class ValidateFileSavedModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateFileSaved") + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + exclude_families: list[str] = Field( + default_factory=list, + title="Exclude product types" + ) + + class ExtractBlendModel(BaseSettingsModel): enabled: bool = Field(True) optional: bool = Field(title="Optional") @@ -51,11 +61,20 @@ class PublishPuginsModel(BaseSettingsModel): ValidateCameraZeroKeyframe: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Validate Camera Zero Keyframe", - section="Validators" + section="General Validators" + ) + ValidateFileSaved: ValidateFileSavedModel = Field( + default_factory=ValidateFileSavedModel, + title="Validate File Saved", + ) + ValidateInstanceEmpty: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Instance is not Empty" ) ValidateMeshHasUvs: ValidatePluginModel = Field( default_factory=ValidatePluginModel, - title="Validate Mesh Has Uvs" + title="Validate Mesh Has Uvs", + section="Model Validators" ) ValidateMeshNoNegativeScale: ValidatePluginModel = Field( default_factory=ValidatePluginModel, @@ -69,6 +88,15 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ValidatePluginModel, title="Validate No Colons In Name" ) + ValidateRenderCameraIsSet: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Render Camera Is Set", + section="Render Validators" + ) + ValidateDeadlinePublish: ValidatePluginModel = Field( + default_factory=ValidatePluginModel, + title="Validate Render Output for Deadline", + ) ExtractBlend: ExtractBlendModel = Field( default_factory=ExtractBlendModel, title="Extract Blend", @@ -78,7 +106,7 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ValidatePluginModel, title="Extract FBX" ) - ExtractABC: ValidatePluginModel = Field( + ExtractModelABC: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Extract ABC" ) @@ -100,7 +128,7 @@ class PublishPuginsModel(BaseSettingsModel): ) ExtractLayout: ValidatePluginModel = Field( default_factory=ValidatePluginModel, - title="Extract Layout" + title="Extract Layout (JSON)" ) ExtractThumbnail: ExtractPlayblastModel = Field( default_factory=ExtractPlayblastModel, @@ -118,6 +146,22 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateFileSaved": { + "enabled": True, + "optional": False, + "active": True, + "exclude_families": [] + }, + "ValidateRenderCameraIsSet": { + "enabled": True, + "optional": False, + "active": True + }, + "ValidateDeadlinePublish": { + "enabled": True, + "optional": False, + "active": True + }, "ValidateMeshHasUvs": { "enabled": True, "optional": True, @@ -138,6 +182,11 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": False, "active": True }, + "ValidateInstanceEmpty": { + "enabled": True, + "optional": False, + "active": True + }, "ExtractBlend": { "enabled": True, "optional": True, @@ -156,10 +205,10 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": True, "active": False }, - "ExtractABC": { + "ExtractModelABC": { "enabled": True, "optional": True, - "active": False + "active": True }, "ExtractBlendAnimation": { "enabled": True, diff --git a/server_addon/blender/server/settings/render_settings.py b/server_addon/blender/server/settings/render_settings.py new file mode 100644 index 0000000000..f62013982e --- /dev/null +++ b/server_addon/blender/server/settings/render_settings.py @@ -0,0 +1,109 @@ +"""Providing models and values for Blender Render Settings.""" +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +def aov_separators_enum(): + return [ + {"value": "dash", "label": "- (dash)"}, + {"value": "underscore", "label": "_ (underscore)"}, + {"value": "dot", "label": ". (dot)"} + ] + + +def image_format_enum(): + return [ + {"value": "exr", "label": "OpenEXR"}, + {"value": "bmp", "label": "BMP"}, + {"value": "rgb", "label": "Iris"}, + {"value": "png", "label": "PNG"}, + {"value": "jpg", "label": "JPEG"}, + {"value": "jp2", "label": "JPEG 2000"}, + {"value": "tga", "label": "Targa"}, + {"value": "tif", "label": "TIFF"}, + ] + + +def aov_list_enum(): + return [ + {"value": "empty", "label": "< none >"}, + {"value": "combined", "label": "Combined"}, + {"value": "z", "label": "Z"}, + {"value": "mist", "label": "Mist"}, + {"value": "normal", "label": "Normal"}, + {"value": "diffuse_light", "label": "Diffuse Light"}, + {"value": "diffuse_color", "label": "Diffuse Color"}, + {"value": "specular_light", "label": "Specular Light"}, + {"value": "specular_color", "label": "Specular Color"}, + {"value": "volume_light", "label": "Volume Light"}, + {"value": "emission", "label": "Emission"}, + {"value": "environment", "label": "Environment"}, + {"value": "shadow", "label": "Shadow"}, + {"value": "ao", "label": "Ambient Occlusion"}, + {"value": "denoising", "label": "Denoising"}, + {"value": "volume_direct", "label": "Direct Volumetric Scattering"}, + {"value": "volume_indirect", "label": "Indirect Volumetric Scattering"} + ] + + +def custom_passes_types_enum(): + return [ + {"value": "COLOR", "label": "Color"}, + {"value": "VALUE", "label": "Value"}, + ] + + +class CustomPassesModel(BaseSettingsModel): + """Custom Passes""" + _layout = "compact" + + attribute: str = Field("", title="Attribute name") + value: str = Field( + "COLOR", + title="Type", + enum_resolver=custom_passes_types_enum + ) + + +class RenderSettingsModel(BaseSettingsModel): + default_render_image_folder: str = Field( + title="Default Render Image Folder" + ) + aov_separator: str = Field( + "underscore", + title="AOV Separator Character", + enum_resolver=aov_separators_enum + ) + image_format: str = Field( + "exr", + title="Image Format", + enum_resolver=image_format_enum + ) + multilayer_exr: bool = Field( + title="Multilayer (EXR)" + ) + aov_list: list[str] = Field( + default_factory=list, + enum_resolver=aov_list_enum, + title="AOVs to create" + ) + custom_passes: list[CustomPassesModel] = Field( + default_factory=list, + title="Custom Passes", + description=( + "Add custom AOVs. They are added to the view layer and in the " + "Compositing Nodetree,\nbut they need to be added manually to " + "the Shader Nodetree." + ) + ) + + +DEFAULT_RENDER_SETTINGS = { + "default_render_image_folder": "renders/blender", + "aov_separator": "underscore", + "image_format": "exr", + "multilayer_exr": True, + "aov_list": [], + "custom_passes": [] +} diff --git a/server_addon/blender/server/version.py b/server_addon/blender/server/version.py index 485f44ac21..1276d0254f 100644 --- a/server_addon/blender/server/version.py +++ b/server_addon/blender/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.5" diff --git a/server_addon/core/server/settings/main.py b/server_addon/core/server/settings/main.py index d19d732e71..433d0ef2f0 100644 --- a/server_addon/core/server/settings/main.py +++ b/server_addon/core/server/settings/main.py @@ -4,6 +4,7 @@ from ayon_server.settings import ( BaseSettingsModel, MultiplatformPathListModel, ensure_unique_names, + task_types_enum, ) from ayon_server.exceptions import BadRequestException @@ -11,6 +12,27 @@ from .publish_plugins import PublishPuginsModel, DEFAULT_PUBLISH_VALUES from .tools import GlobalToolsModel, DEFAULT_TOOLS_VALUES +class DiskMappingItemModel(BaseSettingsModel): + _layout = "expanded" + source: str = Field("", title="Source") + destination: str = Field("", title="Destination") + + +class DiskMappingModel(BaseSettingsModel): + windows: list[DiskMappingItemModel] = Field( + title="Windows", + default_factory=list, + ) + linux: list[DiskMappingItemModel] = Field( + title="Linux", + default_factory=list, + ) + darwin: list[DiskMappingItemModel] = Field( + title="MacOS", + default_factory=list, + ) + + class ImageIOFileRuleModel(BaseSettingsModel): name: str = Field("", title="Rule name") pattern: str = Field("", title="Regex pattern") @@ -38,13 +60,52 @@ class CoreImageIOConfigModel(BaseSettingsModel): class CoreImageIOBaseModel(BaseSettingsModel): activate_global_color_management: bool = Field( False, - title="Override global OCIO config" + title="Enable Color Management" ) ocio_config: CoreImageIOConfigModel = Field( - default_factory=CoreImageIOConfigModel, title="OCIO config" + default_factory=CoreImageIOConfigModel, + title="OCIO config" ) file_rules: CoreImageIOFileRulesModel = Field( - default_factory=CoreImageIOFileRulesModel, title="File Rules" + default_factory=CoreImageIOFileRulesModel, + title="File Rules" + ) + + +class VersionStartCategoryProfileModel(BaseSettingsModel): + _layout = "expanded" + host_names: list[str] = Field( + default_factory=list, + title="Host names" + ) + task_types: list[str] = Field( + default_factory=list, + title="Task types", + enum_resolver=task_types_enum + ) + task_names: list[str] = Field( + default_factory=list, + title="Task names" + ) + product_types: list[str] = Field( + default_factory=list, + title="Product types" + ) + product_names: list[str] = Field( + default_factory=list, + title="Product names" + ) + version_start: int = Field( + 1, + title="Version Start", + ge=0 + ) + + +class VersionStartCategoryModel(BaseSettingsModel): + profiles: list[VersionStartCategoryProfileModel] = Field( + default_factory=list, + title="Profiles" ) @@ -57,10 +118,18 @@ class CoreSettings(BaseSettingsModel): widget="textarea", scope=["studio"], ) + disk_mapping: DiskMappingModel = Field( + default_factory=DiskMappingModel, + title="Disk mapping", + ) tools: GlobalToolsModel = Field( default_factory=GlobalToolsModel, title="Tools" ) + version_start_category: VersionStartCategoryModel = Field( + default_factory=VersionStartCategoryModel, + title="Version start" + ) imageio: CoreImageIOBaseModel = Field( default_factory=CoreImageIOBaseModel, title="Color Management (ImageIO)" @@ -131,6 +200,9 @@ DEFAULT_VALUES = { "studio_code": "", "environments": "{}", "tools": DEFAULT_TOOLS_VALUES, + "version_start_category": { + "profiles": [] + }, "publish": DEFAULT_PUBLISH_VALUES, "project_folder_structure": json.dumps({ "__project_root__": { diff --git a/server_addon/core/server/settings/publish_plugins.py b/server_addon/core/server/settings/publish_plugins.py index c012312579..93d8db964d 100644 --- a/server_addon/core/server/settings/publish_plugins.py +++ b/server_addon/core/server/settings/publish_plugins.py @@ -21,7 +21,7 @@ class ValidateBaseModel(BaseSettingsModel): class CollectAnatomyInstanceDataModel(BaseSettingsModel): _isGroup = True follow_workfile_version: bool = Field( - True, title="Collect Anatomy Instance Data" + True, title="Follow workfile version" ) @@ -116,6 +116,8 @@ class OIIOToolArgumentsModel(BaseSettingsModel): class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): + _layout = "expanded" + name: str = Field("", title="Name") extension: str = Field("", title="Extension") transcoding_type: str = Field( "colorspace", @@ -164,6 +166,11 @@ class ExtractOIIOTranscodeProfileModel(BaseSettingsModel): title="Output Definitions", ) + @validator("outputs") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + class ExtractOIIOTranscodeModel(BaseSettingsModel): enabled: bool = Field(True) diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index 7befc795e4..0dd9d396ae 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -487,6 +487,17 @@ DEFAULT_TOOLS_VALUES = { "task_types": [], "task_names": [], "template_name": "publish_online" + }, + { + "product_types": [ + "tycache" + ], + "hosts": [ + "max" + ], + "task_types": [], + "task_names": [], + "template_name": "publish_tycache" } ], "hero_template_name_profiles": [ diff --git a/server_addon/core/server/version.py b/server_addon/core/server/version.py index 485f44ac21..ae7362549b 100644 --- a/server_addon/core/server/version.py +++ b/server_addon/core/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.3" diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py index 61dbd5c8d9..fc7a673dcc 100644 --- a/server_addon/create_ayon_addons.py +++ b/server_addon/create_ayon_addons.py @@ -3,6 +3,7 @@ import sys import re import json import shutil +import argparse import zipfile import platform import collections @@ -32,6 +33,20 @@ IGNORE_FILE_PATTERNS: List[Pattern] = [ } ] +IGNORED_HOSTS = [ + "flame", + "harmony", +] + +IGNORED_MODULES = [ + "ftrack", + "shotgrid", + "sync_server", + "example_addons", + "slack", + "kitsu", +] + class ZipFileLongPaths(zipfile.ZipFile): """Allows longer paths in zip files. @@ -184,9 +199,12 @@ def create_openpype_package( addon_output_dir = output_dir / "openpype" / addon_version private_dir = addon_output_dir / "private" + if addon_output_dir.exists(): + shutil.rmtree(str(addon_output_dir)) + # Make sure dir exists - addon_output_dir.mkdir(parents=True) - private_dir.mkdir(parents=True) + addon_output_dir.mkdir(parents=True, exist_ok=True) + private_dir.mkdir(parents=True, exist_ok=True) # Copy version shutil.copy(str(version_path), str(addon_output_dir)) @@ -198,15 +216,6 @@ def create_openpype_package( str(pyproject_path), (private_dir / pyproject_path.name) ) - - ignored_hosts = [] - ignored_modules = [ - "ftrack", - "shotgrid", - "sync_server", - "example_addons", - "slack" - ] # Subdirs that won't be added to output zip file ignored_subpaths = [ ["addons"], @@ -214,11 +223,11 @@ def create_openpype_package( ] ignored_subpaths.extend( ["hosts", host_name] - for host_name in ignored_hosts + for host_name in IGNORED_HOSTS ) ignored_subpaths.extend( ["modules", module_name] - for module_name in ignored_modules + for module_name in IGNORED_MODULES ) # Zip client @@ -268,22 +277,41 @@ def create_addon_package( ) -def main(create_zip=True, keep_source=False): +def main( + output_dir=None, + skip_zip=True, + keep_source=False, + clear_output_dir=False, + addons=None, +): current_dir = Path(os.path.dirname(os.path.abspath(__file__))) root_dir = current_dir.parent - output_dir = current_dir / "packages" - print("Package creation started...") + create_zip = not skip_zip - # Make sure package dir is empty - if output_dir.exists(): + if output_dir: + output_dir = Path(output_dir) + else: + output_dir = current_dir / "packages" + + if output_dir.exists() and clear_output_dir: shutil.rmtree(str(output_dir)) - # Make sure output dir is created - output_dir.mkdir(parents=True) + print("Package creation started...") + print(f"Output directory: {output_dir}") + + # Make sure output dir is created + output_dir.mkdir(parents=True, exist_ok=True) + ignored_addons = set(IGNORED_HOSTS) | set(IGNORED_MODULES) for addon_dir in current_dir.iterdir(): if not addon_dir.is_dir(): continue + if addons and addon_dir.name not in addons: + continue + + if addon_dir.name in ignored_addons: + continue + server_dir = addon_dir / "server" if not server_dir.exists(): continue @@ -303,6 +331,54 @@ def main(create_zip=True, keep_source=False): if __name__ == "__main__": - create_zip = "--skip-zip" not in sys.argv - keep_sources = "--keep-sources" in sys.argv - main(create_zip, keep_sources) + parser = argparse.ArgumentParser() + parser.add_argument( + "--skip-zip", + dest="skip_zip", + action="store_true", + help=( + "Skip zipping server package and create only" + " server folder structure." + ) + ) + parser.add_argument( + "--keep-sources", + dest="keep_sources", + action="store_true", + help=( + "Keep folder structure when server package is created." + ) + ) + parser.add_argument( + "-o", "--output", + dest="output_dir", + default=None, + help=( + "Directory path where package will be created" + " (Will be purged if already exists!)" + ) + ) + parser.add_argument( + "-c", "--clear-output-dir", + dest="clear_output_dir", + action="store_true", + help=( + "Clear output directory before package creation." + ) + ) + parser.add_argument( + "-a", + "--addon", + dest="addons", + action="append", + help="Limit addon creation to given addon name", + ) + + args = parser.parse_args(sys.argv[1:]) + main( + args.output_dir, + args.skip_zip, + args.keep_sources, + args.clear_output_dir, + args.addons, + ) diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index 8d1b667345..0781902fe5 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -124,6 +124,24 @@ class LimitGroupsSubmodel(BaseSettingsModel): ) +def fusion_deadline_plugin_enum(): + """Return a list of value/label dicts for the enumerator. + + Returning a list of dicts is used to allow for a custom label to be + displayed in the UI. + """ + return [ + { + "value": "Fusion", + "label": "Fusion" + }, + { + "value": "FusionCmd", + "label": "FusionCmd" + } + ] + + class FusionSubmitDeadlineModel(BaseSettingsModel): enabled: bool = Field(True, title="Enabled") optional: bool = Field(False, title="Optional") @@ -132,6 +150,9 @@ class FusionSubmitDeadlineModel(BaseSettingsModel): chunk_size: int = Field(10, title="Frame per Task") concurrent_tasks: int = Field(1, title="Number of concurrent tasks") group: str = Field("", title="Group Name") + plugin: str = Field("Fusion", + enum_resolver=fusion_deadline_plugin_enum, + title="Deadline Plugin") class NukeSubmitDeadlineModel(BaseSettingsModel): @@ -208,6 +229,17 @@ class CelactionSubmitDeadlineModel(BaseSettingsModel): ) +class BlenderSubmitDeadlineModel(BaseSettingsModel): + enabled: bool = Field(True) + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + use_published: bool = Field(title="Use Published scene") + priority: int = Field(title="Priority") + chunk_size: int = Field(title="Frame per Task") + group: str = Field("", title="Group Name") + job_delay: str = Field("", title="Delay job (timecode dd:hh:mm:ss)") + + class AOVFilterSubmodel(BaseSettingsModel): _layout = "expanded" name: str = Field(title="Host") @@ -217,6 +249,17 @@ class AOVFilterSubmodel(BaseSettingsModel): ) +class ProcessCacheJobFarmModel(BaseSettingsModel): + """Process submitted job on farm.""" + + enabled: bool = Field(title="Enabled") + deadline_department: str = Field(title="Department") + deadline_pool: str = Field(title="Pool") + deadline_group: str = Field(title="Group") + deadline_chunk_size: int = Field(title="Chunk Size") + deadline_priority: int = Field(title="Priority") + + class ProcessSubmittedJobOnFarmModel(BaseSettingsModel): """Process submitted job on farm.""" @@ -236,7 +279,7 @@ class ProcessSubmittedJobOnFarmModel(BaseSettingsModel): title="Reviewable products filter", ) - @validator("aov_filter", "skip_integration_repre_list") + @validator("aov_filter") def validate_unique_names(cls, value): ensure_unique_names(value) return value @@ -276,8 +319,13 @@ class PublishPluginsModel(BaseSettingsModel): title="After Effects to deadline") CelactionSubmitDeadline: CelactionSubmitDeadlineModel = Field( default_factory=CelactionSubmitDeadlineModel, - title="Celaction Submit Deadline" - ) + title="Celaction Submit Deadline") + BlenderSubmitDeadline: BlenderSubmitDeadlineModel = Field( + default_factory=BlenderSubmitDeadlineModel, + title="Blender Submit Deadline") + ProcessSubmittedCacheJobOnFarm: ProcessCacheJobFarmModel = Field( + default_factory=ProcessCacheJobFarmModel, + title="Process submitted cache Job on farm.") ProcessSubmittedJobOnFarm: ProcessSubmittedJobOnFarmModel = Field( default_factory=ProcessSubmittedJobOnFarmModel, title="Process submitted job on farm.") @@ -384,6 +432,24 @@ DEFAULT_DEADLINE_PLUGINS_SETTINGS = { "deadline_chunk_size": 10, "deadline_job_delay": "00:00:00:00" }, + "BlenderSubmitDeadline": { + "enabled": True, + "optional": False, + "active": True, + "use_published": True, + "priority": 50, + "chunk_size": 10, + "group": "none", + "job_delay": "00:00:00:00" + }, + "ProcessSubmittedCacheJobOnFarm": { + "enabled": True, + "deadline_department": "", + "deadline_pool": "", + "deadline_group": "", + "deadline_chunk_size": 1, + "deadline_priority": 50 + }, "ProcessSubmittedJobOnFarm": { "enabled": True, "deadline_department": "", @@ -400,6 +466,12 @@ DEFAULT_DEADLINE_PLUGINS_SETTINGS = { ".*([Bb]eauty).*" ] }, + { + "name": "blender", + "value": [ + ".*([Bb]eauty).*" + ] + }, { "name": "aftereffects", "value": [ diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py index 485f44ac21..bbab0242f6 100644 --- a/server_addon/deadline/server/version.py +++ b/server_addon/deadline/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.4" diff --git a/server_addon/hiero/server/settings/scriptsmenu.py b/server_addon/hiero/server/settings/scriptsmenu.py index 51cb088298..ea898dd7ff 100644 --- a/server_addon/hiero/server/settings/scriptsmenu.py +++ b/server_addon/hiero/server/settings/scriptsmenu.py @@ -28,14 +28,14 @@ class ScriptsmenuSettings(BaseSettingsModel): DEFAULT_SCRIPTSMENU_SETTINGS = { - "name": "OpenPype Tools", + "name": "Custom Tools", "definition": [ { "type": "action", "sourcetype": "python", - "title": "OpenPype Docs", - "command": "import webbrowser;webbrowser.open(url='https://openpype.io/docs/artist_hosts_hiero')", - "tooltip": "Open the OpenPype Hiero user doc page" + "title": "Ayon Hiero Docs", + "command": "import webbrowser;webbrowser.open(url='https://ayon.ynput.io/docs/addon_hiero_artist')", # noqa + "tooltip": "Open the Ayon Hiero user doc page" } ] } diff --git a/server_addon/hiero/server/version.py b/server_addon/hiero/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/hiero/server/version.py +++ b/server_addon/hiero/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/create.py similarity index 53% rename from server_addon/houdini/server/settings/publish_plugins.py rename to server_addon/houdini/server/settings/create.py index 7d35d7e634..e8db917849 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/create.py @@ -1,5 +1,4 @@ from pydantic import Field - from ayon_server.settings import BaseSettingsModel @@ -21,49 +20,85 @@ class CreateArnoldAssModel(BaseSettingsModel): ext: str = Field(Title="Extension") +class CreateStaticMeshModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + default_variants: list[str] = Field( + default_factory=list, + title="Default Products" + ) + static_mesh_prefix: str = Field("S", title="Static Mesh Prefix") + collision_prefixes: list[str] = Field( + default_factory=list, + title="Collision Prefixes" + ) + + class CreatePluginsModel(BaseSettingsModel): - CreateArnoldAss: CreateArnoldAssModel = Field( - default_factory=CreateArnoldAssModel, - title="Create Alembic Camera") CreateAlembicCamera: CreatorModel = Field( default_factory=CreatorModel, title="Create Alembic Camera") + CreateArnoldAss: CreateArnoldAssModel = Field( + default_factory=CreateArnoldAssModel, + title="Create Arnold Ass") + CreateArnoldRop: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Arnold ROP") CreateCompositeSequence: CreatorModel = Field( default_factory=CreatorModel, - title="Create Composite Sequence") + title="Create Composite (Image Sequence)") + CreateHDA: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Houdini Digital Asset") + CreateKarmaROP: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Karma ROP") + CreateMantraROP: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Mantra ROP") CreatePointCache: CreatorModel = Field( default_factory=CreatorModel, - title="Create Point Cache") + title="Create PointCache (Abc)") + CreateBGEO: CreatorModel = Field( + default_factory=CreatorModel, + title="Create PointCache (Bgeo)") + CreateRedshiftProxy: CreatorModel = Field( + default_factory=CreatorModel, + title="Create Redshift Proxy") CreateRedshiftROP: CreatorModel = Field( default_factory=CreatorModel, - title="Create RedshiftROP") - CreateRemotePublish: CreatorModel = Field( + title="Create Redshift ROP") + CreateReview: CreatorModel = Field( default_factory=CreatorModel, - title="Create Remote Publish") + title="Create Review") + # "-" is not compatible in the new model + CreateStaticMesh: CreateStaticMeshModel = Field( + default_factory=CreateStaticMeshModel, + title="Create Static Mesh") + CreateUSD: CreatorModel = Field( + default_factory=CreatorModel, + title="Create USD (experimental)") + CreateUSDRender: CreatorModel = Field( + default_factory=CreatorModel, + title="Create USD render (experimental)") CreateVDBCache: CreatorModel = Field( default_factory=CreatorModel, title="Create VDB Cache") - CreateUSD: CreatorModel = Field( + CreateVrayROP: CreatorModel = Field( default_factory=CreatorModel, - title="Create USD") - CreateUSDModel: CreatorModel = Field( - default_factory=CreatorModel, - title="Create USD model") - USDCreateShadingWorkspace: CreatorModel = Field( - default_factory=CreatorModel, - title="Create USD shading workspace") - CreateUSDRender: CreatorModel = Field( - default_factory=CreatorModel, - title="Create USD render") + title="Create VRay ROP") DEFAULT_HOUDINI_CREATE_SETTINGS = { + "CreateAlembicCamera": { + "enabled": True, + "default_variants": ["Main"] + }, "CreateArnoldAss": { "enabled": True, "default_variants": ["Main"], "ext": ".ass" }, - "CreateAlembicCamera": { + "CreateArnoldRop": { "enabled": True, "default_variants": ["Main"] }, @@ -71,86 +106,65 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "enabled": True, "default_variants": ["Main"] }, + "CreateHDA": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateKarmaROP": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateMantraROP": { + "enabled": True, + "default_variants": ["Main"] + }, "CreatePointCache": { "enabled": True, "default_variants": ["Main"] }, + "CreateBGEO": { + "enabled": True, + "default_variants": ["Main"] + }, + "CreateRedshiftProxy": { + "enabled": True, + "default_variants": ["Main"] + }, "CreateRedshiftROP": { "enabled": True, "default_variants": ["Main"] }, - "CreateRemotePublish": { + "CreateReview": { "enabled": True, "default_variants": ["Main"] }, - "CreateVDBCache": { + "CreateStaticMesh": { "enabled": True, - "default_variants": ["Main"] + "default_variants": [ + "Main" + ], + "static_mesh_prefix": "S", + "collision_prefixes": [ + "UBX", + "UCP", + "USP", + "UCX" + ] }, "CreateUSD": { "enabled": False, "default_variants": ["Main"] }, - "CreateUSDModel": { - "enabled": False, - "default_variants": ["Main"] - }, - "USDCreateShadingWorkspace": { - "enabled": False, - "default_variants": ["Main"] - }, "CreateUSDRender": { "enabled": False, "default_variants": ["Main"] }, -} - - -# Publish Plugins -class ValidateWorkfilePathsModel(BaseSettingsModel): - enabled: bool = Field(title="Enabled") - optional: bool = Field(title="Optional") - node_types: list[str] = Field( - default_factory=list, - title="Node Types" - ) - prohibited_vars: list[str] = Field( - default_factory=list, - title="Prohibited Variables" - ) - - -class ValidateContainersModel(BaseSettingsModel): - enabled: bool = Field(title="Enabled") - optional: bool = Field(title="Optional") - active: bool = Field(title="Active") - - -class PublishPluginsModel(BaseSettingsModel): - ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( - default_factory=ValidateWorkfilePathsModel, - title="Validate workfile paths settings.") - ValidateContainers: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, - title="Validate Latest Containers.") - - -DEFAULT_HOUDINI_PUBLISH_SETTINGS = { - "ValidateWorkfilePaths": { + "CreateVDBCache": { "enabled": True, - "optional": True, - "node_types": [ - "file", - "alembic" - ], - "prohibited_vars": [ - "$HIP", - "$JOB" - ] + "default_variants": ["Main"] }, - "ValidateContainers": { + "CreateVrayROP": { "enabled": True, - "optional": True, - "active": True - } + "default_variants": ["Main"] + }, } diff --git a/server_addon/houdini/server/settings/general.py b/server_addon/houdini/server/settings/general.py new file mode 100644 index 0000000000..aee44f1648 --- /dev/null +++ b/server_addon/houdini/server/settings/general.py @@ -0,0 +1,50 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel + + +class HoudiniVarModel(BaseSettingsModel): + _layout = "expanded" + var: str = Field("", title="Var") + value: str = Field("", title="Value") + is_directory: bool = Field(False, title="Treat as directory") + + +class UpdateHoudiniVarcontextModel(BaseSettingsModel): + """Sync vars with context changes. + + If a value is treated as a directory on update + it will be ensured the folder exists. + """ + + enabled: bool = Field(title="Enabled") + # TODO this was dynamic dictionary '{var: path}' + houdini_vars: list[HoudiniVarModel] = Field( + default_factory=list, + title="Houdini Vars" + ) + + +class GeneralSettingsModel(BaseSettingsModel): + add_self_publish_button: bool = Field( + False, + title="Add Self Publish Button" + ) + update_houdini_var_context: UpdateHoudiniVarcontextModel = Field( + default_factory=UpdateHoudiniVarcontextModel, + title="Update Houdini Vars on context change" + ) + + +DEFAULT_GENERAL_SETTINGS = { + "add_self_publish_button": False, + "update_houdini_var_context": { + "enabled": True, + "houdini_vars": [ + { + "var": "JOB", + "value": "{root[work]}/{project[name]}/{hierarchy}/{asset}/work/{task[name]}", # noqa + "is_directory": True + } + ] + } +} diff --git a/server_addon/houdini/server/settings/main.py b/server_addon/houdini/server/settings/main.py index fdb6838f5c..9cfec54f22 100644 --- a/server_addon/houdini/server/settings/main.py +++ b/server_addon/houdini/server/settings/main.py @@ -1,78 +1,46 @@ from pydantic import Field -from ayon_server.settings import ( - BaseSettingsModel, - MultiplatformPathModel, - MultiplatformPathListModel, +from ayon_server.settings import BaseSettingsModel +from .general import ( + GeneralSettingsModel, + DEFAULT_GENERAL_SETTINGS ) - from .imageio import HoudiniImageIOModel -from .publish_plugins import ( - PublishPluginsModel, +from .shelves import ShelvesModel +from .create import ( CreatePluginsModel, - DEFAULT_HOUDINI_PUBLISH_SETTINGS, DEFAULT_HOUDINI_CREATE_SETTINGS ) - - -class ShelfToolsModel(BaseSettingsModel): - name: str = Field(title="Name") - help: str = Field(title="Help text") - script: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel, - title="Script Path " - ) - icon: MultiplatformPathModel = Field( - default_factory=MultiplatformPathModel, - title="Icon Path " - ) - - -class ShelfDefinitionModel(BaseSettingsModel): - _layout = "expanded" - shelf_name: str = Field(title="Shelf name") - tools_list: list[ShelfToolsModel] = Field( - default_factory=list, - title="Shelf Tools" - ) - - -class ShelvesModel(BaseSettingsModel): - _layout = "expanded" - shelf_set_name: str = Field(title="Shelfs set name") - - shelf_set_source_path: MultiplatformPathListModel = Field( - default_factory=MultiplatformPathListModel, - title="Shelf Set Path (optional)" - ) - - shelf_definition: list[ShelfDefinitionModel] = Field( - default_factory=list, - title="Shelf Definitions" - ) +from .publish import ( + PublishPluginsModel, + DEFAULT_HOUDINI_PUBLISH_SETTINGS, +) class HoudiniSettings(BaseSettingsModel): + general: GeneralSettingsModel = Field( + default_factory=GeneralSettingsModel, + title="General" + ) imageio: HoudiniImageIOModel = Field( default_factory=HoudiniImageIOModel, title="Color Management (ImageIO)" ) shelves: list[ShelvesModel] = Field( default_factory=list, - title="Houdini Scripts Shelves", + title="Shelves Manager", + ) + create: CreatePluginsModel = Field( + default_factory=CreatePluginsModel, + title="Creator Plugins", ) - publish: PublishPluginsModel = Field( default_factory=PublishPluginsModel, title="Publish Plugins", ) - create: CreatePluginsModel = Field( - default_factory=CreatePluginsModel, - title="Creator Plugins", - ) - DEFAULT_VALUES = { + "general": DEFAULT_GENERAL_SETTINGS, "shelves": [], "create": DEFAULT_HOUDINI_CREATE_SETTINGS, "publish": DEFAULT_HOUDINI_PUBLISH_SETTINGS diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py new file mode 100644 index 0000000000..92a676b0d0 --- /dev/null +++ b/server_addon/houdini/server/settings/publish.py @@ -0,0 +1,103 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel + + +# Publish Plugins +class CollectAssetHandlesModel(BaseSettingsModel): + """Collect Frame Range + Disable this if you want the publisher to + ignore start and end handles specified in the + asset data for publish instances + """ + use_asset_handles: bool = Field( + title="Use asset handles") + + +class ValidateWorkfilePathsModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + node_types: list[str] = Field( + default_factory=list, + title="Node Types" + ) + prohibited_vars: list[str] = Field( + default_factory=list, + title="Prohibited Variables" + ) + + +class BasicValidateModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + + +class PublishPluginsModel(BaseSettingsModel): + CollectAssetHandles: CollectAssetHandlesModel = Field( + default_factory=CollectAssetHandlesModel, + title="Collect Asset Handles.", + section="Collectors" + ) + ValidateContainers: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Latest Containers.", + section="Validators") + ValidateMeshIsStatic: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Mesh is Static.") + ValidateReviewColorspace: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Review Colorspace.") + ValidateSubsetName: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Subset Name.") + ValidateUnrealStaticMeshName: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Unreal Static Mesh Name.") + ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( + default_factory=ValidateWorkfilePathsModel, + title="Validate workfile paths settings.") + + +DEFAULT_HOUDINI_PUBLISH_SETTINGS = { + "CollectAssetHandles": { + "use_asset_handles": True + }, + "ValidateContainers": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateMeshIsStatic": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateReviewColorspace": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateSubsetName": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateUnrealStaticMeshName": { + "enabled": False, + "optional": True, + "active": True + }, + "ValidateWorkfilePaths": { + "enabled": True, + "optional": True, + "node_types": [ + "file", + "alembic" + ], + "prohibited_vars": [ + "$HIP", + "$JOB" + ] + } +} diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py new file mode 100644 index 0000000000..133c18f77c --- /dev/null +++ b/server_addon/houdini/server/settings/shelves.py @@ -0,0 +1,67 @@ +from pydantic import Field +from ayon_server.settings import ( + BaseSettingsModel, + MultiplatformPathModel +) + + +class ShelfToolsModel(BaseSettingsModel): + """Name and Script Path are mandatory.""" + label: str = Field(title="Name") + script: str = Field(title="Script Path") + icon: str = Field("", title="Icon Path") + help: str = Field("", title="Help text") + + +class ShelfDefinitionModel(BaseSettingsModel): + _layout = "expanded" + shelf_name: str = Field(title="Shelf name") + tools_list: list[ShelfToolsModel] = Field( + default_factory=list, + title="Shelf Tools" + ) + + +class AddShelfFileModel(BaseSettingsModel): + shelf_set_source_path: MultiplatformPathModel = Field( + default_factory=MultiplatformPathModel, + title="Shelf Set Path" + ) + + +class AddSetAndDefinitionsModel(BaseSettingsModel): + shelf_set_name: str = Field("", title="Shelf Set Name") + shelf_definition: list[ShelfDefinitionModel] = Field( + default_factory=list, + title="Shelves Definitions" + ) + + +def shelves_enum_options(): + return [ + { + "value": "add_shelf_file", + "label": "Add a .shelf file" + }, + { + "value": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions" + } + ] + + +class ShelvesModel(BaseSettingsModel): + options: str = Field( + title="Options", + description="Switch between shelves manager options", + enum_resolver=shelves_enum_options, + conditionalEnum=True + ) + add_shelf_file: AddShelfFileModel = Field( + title="Add a .shelf file", + default_factory=AddShelfFileModel + ) + add_set_and_definitions: AddSetAndDefinitionsModel = Field( + title="Add Shelf Set Name and Shelves Definitions", + default_factory=AddSetAndDefinitionsModel + ) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 485f44ac21..75cf7831c4 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.2.9" diff --git a/server_addon/max/server/__init__.py b/server_addon/max/server/__init__.py new file mode 100644 index 0000000000..31c694a084 --- /dev/null +++ b/server_addon/max/server/__init__.py @@ -0,0 +1,17 @@ +from typing import Type + +from ayon_server.addons import BaseServerAddon + +from .version import __version__ +from .settings import MaxSettings, DEFAULT_VALUES + + +class MaxAddon(BaseServerAddon): + name = "max" + title = "Max" + version = __version__ + settings_model: Type[MaxSettings] = MaxSettings + + async def get_default_settings(self): + settings_model_cls = self.get_settings_model() + return settings_model_cls(**DEFAULT_VALUES) diff --git a/server_addon/max/server/settings/__init__.py b/server_addon/max/server/settings/__init__.py new file mode 100644 index 0000000000..986b1903a5 --- /dev/null +++ b/server_addon/max/server/settings/__init__.py @@ -0,0 +1,10 @@ +from .main import ( + MaxSettings, + DEFAULT_VALUES, +) + + +__all__ = ( + "MaxSettings", + "DEFAULT_VALUES", +) diff --git a/server_addon/max/server/settings/create_review_settings.py b/server_addon/max/server/settings/create_review_settings.py new file mode 100644 index 0000000000..43dac0730a --- /dev/null +++ b/server_addon/max/server/settings/create_review_settings.py @@ -0,0 +1,93 @@ +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +def image_format_enum(): + """Return enumerator for image output formats.""" + return [ + {"label": "exr", "value": "exr"}, + {"label": "jpg", "value": "jpg"}, + {"label": "png", "value": "png"}, + {"label": "tga", "value": "tga"} + ] + + +def visual_style_enum(): + """Return enumerator for viewport visual style.""" + return [ + {"label": "Realistic", "value": "Realistic"}, + {"label": "Shaded", "value": "Shaded"}, + {"label": "Facets", "value": "Facets"}, + {"label": "ConsistentColors", + "value": "ConsistentColors"}, + {"label": "Wireframe", "value": "Wireframe"}, + {"label": "BoundingBox", "value": "BoundingBox"}, + {"label": "Ink", "value": "Ink"}, + {"label": "ColorInk", "value": "ColorInk"}, + {"label": "Acrylic", "value": "Acrylic"}, + {"label": "Tech", "value": "Tech"}, + {"label": "Graphite", "value": "Graphite"}, + {"label": "ColorPencil", "value": "ColorPencil"}, + {"label": "Pastel", "value": "Pastel"}, + {"label": "Clay", "value": "Clay"}, + {"label": "ModelAssist", "value": "ModelAssist"} + ] + + +def preview_preset_enum(): + """Return enumerator for viewport visual preset.""" + return [ + {"label": "Quality", "value": "Quality"}, + {"label": "Standard", "value": "Standard"}, + {"label": "Performance", "value": "Performance"}, + {"label": "DXMode", "value": "DXMode"}, + {"label": "Customize", "value": "Customize"}, + ] + + +def anti_aliasing_enum(): + """Return enumerator for viewport anti-aliasing.""" + return [ + {"label": "None", "value": "None"}, + {"label": "2X", "value": "2X"}, + {"label": "4X", "value": "4X"}, + {"label": "8X", "value": "8X"} + ] + + +class CreateReviewModel(BaseSettingsModel): + review_width: int = Field(1920, title="Review Width") + review_height: int = Field(1080, title="Review Height") + percentSize: float = Field(100.0, title="Percent of Output") + keep_images: bool = Field(False, title="Keep Image Sequences") + image_format: str = Field( + enum_resolver=image_format_enum, + title="Image Format Options" + ) + visual_style: str = Field( + enum_resolver=visual_style_enum, + title="Preference" + ) + viewport_preset: str = Field( + enum_resolver=preview_preset_enum, + title="Preview Preset" + ) + anti_aliasing: str = Field( + enum_resolver=anti_aliasing_enum, + title="Anti-aliasing Quality" + ) + vp_texture: bool = Field(True, title="Viewport Texture") + + +DEFAULT_CREATE_REVIEW_SETTINGS = { + "review_width": 1920, + "review_height": 1080, + "percentSize": 100.0, + "keep_images": False, + "image_format": "png", + "visual_style": "Realistic", + "viewport_preset": "Quality", + "anti_aliasing": "None", + "vp_texture": True +} diff --git a/server_addon/max/server/settings/imageio.py b/server_addon/max/server/settings/imageio.py new file mode 100644 index 0000000000..5e46104fa7 --- /dev/null +++ b/server_addon/max/server/settings/imageio.py @@ -0,0 +1,48 @@ +from pydantic import Field, validator +from ayon_server.settings import BaseSettingsModel +from ayon_server.settings.validators import ensure_unique_names + + +class ImageIOConfigModel(BaseSettingsModel): + override_global_config: bool = Field( + False, + title="Override global OCIO config" + ) + filepath: list[str] = Field( + default_factory=list, + title="Config path" + ) + + +class ImageIOFileRuleModel(BaseSettingsModel): + name: str = Field("", title="Rule name") + pattern: str = Field("", title="Regex pattern") + colorspace: str = Field("", title="Colorspace name") + ext: str = Field("", title="File extension") + + +class ImageIOFileRulesModel(BaseSettingsModel): + activate_host_rules: bool = Field(False) + rules: list[ImageIOFileRuleModel] = Field( + default_factory=list, + title="Rules" + ) + + @validator("rules") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + + +class ImageIOSettings(BaseSettingsModel): + activate_host_color_management: bool = Field( + True, title="Enable Color Management" + ) + ocio_config: ImageIOConfigModel = Field( + default_factory=ImageIOConfigModel, + title="OCIO config" + ) + file_rules: ImageIOFileRulesModel = Field( + default_factory=ImageIOFileRulesModel, + title="File Rules" + ) diff --git a/server_addon/max/server/settings/main.py b/server_addon/max/server/settings/main.py new file mode 100644 index 0000000000..ea6a11915a --- /dev/null +++ b/server_addon/max/server/settings/main.py @@ -0,0 +1,68 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel +from .imageio import ImageIOSettings +from .render_settings import ( + RenderSettingsModel, DEFAULT_RENDER_SETTINGS +) +from .create_review_settings import ( + CreateReviewModel, DEFAULT_CREATE_REVIEW_SETTINGS +) +from .publishers import ( + PublishersModel, DEFAULT_PUBLISH_SETTINGS +) + + +class PRTAttributesModel(BaseSettingsModel): + _layout = "compact" + name: str = Field(title="Name") + value: str = Field(title="Attribute") + + +class PointCloudSettings(BaseSettingsModel): + attribute: list[PRTAttributesModel] = Field( + default_factory=list, title="Channel Attribute") + + +class MaxSettings(BaseSettingsModel): + imageio: ImageIOSettings = Field( + default_factory=ImageIOSettings, + title="Color Management (ImageIO)" + ) + RenderSettings: RenderSettingsModel = Field( + default_factory=RenderSettingsModel, + title="Render Settings" + ) + CreateReview: CreateReviewModel = Field( + default_factory=CreateReviewModel, + title="Create Review" + ) + PointCloud: PointCloudSettings = Field( + default_factory=PointCloudSettings, + title="Point Cloud" + ) + publish: PublishersModel = Field( + default_factory=PublishersModel, + title="Publish Plugins") + + +DEFAULT_VALUES = { + "RenderSettings": DEFAULT_RENDER_SETTINGS, + "CreateReview": DEFAULT_CREATE_REVIEW_SETTINGS, + "PointCloud": { + "attribute": [ + {"name": "Age", "value": "age"}, + {"name": "Radius", "value": "radius"}, + {"name": "Position", "value": "position"}, + {"name": "Rotation", "value": "rotation"}, + {"name": "Scale", "value": "scale"}, + {"name": "Velocity", "value": "velocity"}, + {"name": "Color", "value": "color"}, + {"name": "TextureCoordinate", "value": "texcoord"}, + {"name": "MaterialID", "value": "matid"}, + {"name": "custFloats", "value": "custFloats"}, + {"name": "custVecs", "value": "custVecs"}, + ] + }, + "publish": DEFAULT_PUBLISH_SETTINGS + +} diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py new file mode 100644 index 0000000000..b48f14a064 --- /dev/null +++ b/server_addon/max/server/settings/publishers.py @@ -0,0 +1,87 @@ +import json +from pydantic import Field, validator + +from ayon_server.settings import BaseSettingsModel +from ayon_server.exceptions import BadRequestException + + +class ValidateAttributesModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateAttributes") + attributes: str = Field( + "{}", title="Attributes", widget="textarea") + + @validator("attributes") + def validate_json(cls, value): + if not value.strip(): + return "{}" + try: + converted_value = json.loads(value) + success = isinstance(converted_value, dict) + except json.JSONDecodeError: + success = False + + if not success: + raise BadRequestException( + "The attibutes can't be parsed as json object" + ) + return value + + +class FamilyMappingItemModel(BaseSettingsModel): + product_types: list[str] = Field( + default_factory=list, + title="Product Types" + ) + plugins: list[str] = Field( + default_factory=list, + title="Plugins" + ) + + +class ValidateLoadedPluginModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + family_plugins_mapping: list[FamilyMappingItemModel] = Field( + default_factory=list, + title="Family Plugins Mapping" + ) + + +class BasicValidateModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + + +class PublishersModel(BaseSettingsModel): + ValidateFrameRange: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Frame Range", + section="Validators" + ) + ValidateAttributes: ValidateAttributesModel = Field( + default_factory=ValidateAttributesModel, + title="Validate Attributes" + ) + + ValidateLoadedPlugin: ValidateLoadedPluginModel = Field( + default_factory=ValidateLoadedPluginModel, + title="Validate Loaded Plugin" + ) + +DEFAULT_PUBLISH_SETTINGS = { + "ValidateFrameRange": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateAttributes": { + "enabled": False, + "attributes": "{}" + }, + "ValidateLoadedPlugin": { + "enabled": False, + "optional": True, + "family_plugins_mapping": [] + } +} diff --git a/server_addon/max/server/settings/render_settings.py b/server_addon/max/server/settings/render_settings.py new file mode 100644 index 0000000000..c00cb5e436 --- /dev/null +++ b/server_addon/max/server/settings/render_settings.py @@ -0,0 +1,49 @@ +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +def aov_separators_enum(): + return [ + {"value": "dash", "label": "- (dash)"}, + {"value": "underscore", "label": "_ (underscore)"}, + {"value": "dot", "label": ". (dot)"} + ] + + +def image_format_enum(): + """Return enumerator for image output formats.""" + return [ + {"label": "bmp", "value": "bmp"}, + {"label": "exr", "value": "exr"}, + {"label": "tif", "value": "tif"}, + {"label": "tiff", "value": "tiff"}, + {"label": "jpg", "value": "jpg"}, + {"label": "png", "value": "png"}, + {"label": "tga", "value": "tga"}, + {"label": "dds", "value": "dds"} + ] + + +class RenderSettingsModel(BaseSettingsModel): + default_render_image_folder: str = Field( + title="Default render image folder" + ) + aov_separator: str = Field( + "underscore", + title="AOV Separator character", + enum_resolver=aov_separators_enum + ) + image_format: str = Field( + enum_resolver=image_format_enum, + title="Output Image Format" + ) + multipass: bool = Field(title="multipass") + + +DEFAULT_RENDER_SETTINGS = { + "default_render_image_folder": "renders/3dsmax", + "aov_separator": "underscore", + "image_format": "exr", + "multipass": True +} diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py new file mode 100644 index 0000000000..b3f4756216 --- /dev/null +++ b/server_addon/max/server/version.py @@ -0,0 +1 @@ +__version__ = "0.1.2" diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 9b97b92e59..34a54832af 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -1,6 +1,7 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel +from ayon_server.settings import task_types_enum class CreateLookModel(BaseSettingsModel): @@ -26,7 +27,7 @@ class CreateUnrealStaticMeshModel(BaseSettingsModel): default_factory=list, title="Default Products" ) - static_mesh_prefixes: str = Field("S", title="Static Mesh Prefix") + static_mesh_prefix: str = Field("S", title="Static Mesh Prefix") collision_prefixes: list[str] = Field( default_factory=list, title="Collision Prefixes" @@ -120,6 +121,16 @@ class CreateVrayProxyModel(BaseSettingsModel): default_factory=list, title="Default Products") +class CreateMultishotLayout(BasicCreatorModel): + shotParent: str = Field(title="Shot Parent Folder") + groupLoadedAssets: bool = Field(title="Group Loaded Assets") + task_type: list[str] = Field( + title="Task types", + enum_resolver=task_types_enum + ) + task_name: str = Field(title="Task name (regex)") + + class CreatorsModel(BaseSettingsModel): CreateLook: CreateLookModel = Field( default_factory=CreateLookModel, @@ -252,7 +263,9 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateUnrealSkeletalMesh": { "enabled": True, - "default_variants": [], + "default_variants": [ + "Main", + ], "joint_hints": "jnt_org" }, "CreateMultiverseLook": { diff --git a/server_addon/maya/server/settings/imageio.py b/server_addon/maya/server/settings/imageio.py index 7512bfe253..946a14c866 100644 --- a/server_addon/maya/server/settings/imageio.py +++ b/server_addon/maya/server/settings/imageio.py @@ -39,8 +39,10 @@ class ImageIOFileRulesModel(BaseSettingsModel): class ColorManagementPreferenceV2Model(BaseSettingsModel): - """Color Management Preference v2 (Maya 2022+).""" - _layout = "expanded" + """Color Management Preference v2 (Maya 2022+). + + Please migrate all to 'imageio/workfile' and enable it. + """ enabled: bool = Field(True, title="Use Color Management Preference v2") @@ -51,7 +53,6 @@ class ColorManagementPreferenceV2Model(BaseSettingsModel): class ColorManagementPreferenceModel(BaseSettingsModel): """Color Management Preference (legacy).""" - _layout = "expanded" renderSpace: str = Field(title="Rendering Space") viewTransform: str = Field(title="Viewer Transform ") @@ -89,11 +90,11 @@ class ImageIOSettings(BaseSettingsModel): # Deprecated colorManagementPreference_v2: ColorManagementPreferenceV2Model = Field( default_factory=ColorManagementPreferenceV2Model, - title="Color Management Preference v2 (Maya 2022+)" + title="DEPRECATED: Color Management Preference v2 (Maya 2022+)" ) colorManagementPreference: ColorManagementPreferenceModel = Field( default_factory=ColorManagementPreferenceModel, - title="Color Management Preference (legacy)" + title="DEPRECATED: Color Management Preference (legacy)" ) diff --git a/server_addon/maya/server/settings/loaders.py b/server_addon/maya/server/settings/loaders.py index 29966bb6dd..ed6b6fd2ac 100644 --- a/server_addon/maya/server/settings/loaders.py +++ b/server_addon/maya/server/settings/loaders.py @@ -120,5 +120,10 @@ DEFAULT_LOADERS_SETTING = { "namespace": "{folder[name]}_{product[name]}_##_", "group_name": "_GRP", "display_handle": True + }, + "import_loader": { + "namespace": "{folder[name]}_{product[name]}_##_", + "group_name": "_GRP", + "display_handle": True } } diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index bd7ccdf4d5..dd8d4a0a37 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -129,6 +129,10 @@ class CollectMayaRenderModel(BaseSettingsModel): ) +class CollectFbxAnimationModel(BaseSettingsModel): + enabled: bool = Field(title="Collect Fbx Animation") + + class CollectFbxCameraModel(BaseSettingsModel): enabled: bool = Field(title="CollectFbxCamera") @@ -364,6 +368,10 @@ class PublishersModel(BaseSettingsModel): title="Collect Render Layers", section="Collectors" ) + CollectFbxAnimation: CollectFbxAnimationModel = Field( + default_factory=CollectFbxAnimationModel, + title="Collect FBX Animation", + ) CollectFbxCamera: CollectFbxCameraModel = Field( default_factory=CollectFbxCameraModel, title="Collect Camera for FBX export", @@ -425,6 +433,10 @@ class PublishersModel(BaseSettingsModel): default_factory=ValidateRenderSettingsModel, title="Validate Render Settings" ) + ValidateResolution: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Resolution Setting" + ) ValidateCurrentRenderLayerIsRenderable: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Current Render Layer Has Renderable Camera" @@ -644,6 +656,10 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Rig Controllers", ) + ValidateAnimatedReferenceRig: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Animated Reference Rig", + ) ValidateAnimationContent: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Animation Content", @@ -660,14 +676,34 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Skeletal Mesh Top Node", ) + ValidateSkeletonRigContents: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Contents" + ) + ValidateSkeletonRigControllers: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Controllers" + ) ValidateSkinclusterDeformerSet: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Skincluster Deformer Relationships", ) + ValidateSkeletonRigOutputIds: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Output Ids" + ) + ValidateSkeletonTopGroupHierarchy: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Top Group Hierarchy", + ) ValidateRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( default_factory=ValidateRigOutSetNodeIdsModel, title="Validate Rig Out Set Node Ids", ) + ValidateSkeletonRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( + default_factory=ValidateRigOutSetNodeIdsModel, + title="Validate Skeleton Rig Out Set Node Ids", + ) # Rig - END ValidateCameraAttributes: BasicValidateModel = Field( default_factory=BasicValidateModel, @@ -748,6 +784,9 @@ DEFAULT_PUBLISH_SETTINGS = { "CollectMayaRender": { "sync_workfile_version": False }, + "CollectFbxAnimation": { + "enabled": True + }, "CollectFbxCamera": { "enabled": False }, @@ -867,6 +906,11 @@ DEFAULT_PUBLISH_SETTINGS = { "redshift_render_attributes": [], "renderman_render_attributes": [] }, + "ValidateResolution": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateCurrentRenderLayerIsRenderable": { "enabled": True, "optional": False, @@ -1143,6 +1187,11 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateAnimatedReferenceRig": { + "enabled": True, + "optional": False, + "active": True + }, "ValidateAnimationContent": { "enabled": True, "optional": False, @@ -1163,6 +1212,16 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": False, "active": True }, + "ValidateSkeletonRigContents": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateSkeletonRigControllers": { + "enabled": False, + "optional": True, + "active": True + }, "ValidateSkinclusterDeformerSet": { "enabled": True, "optional": False, @@ -1173,6 +1232,21 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": False, "allow_history_only": False }, + "ValidateSkeletonRigOutSetNodeIds": { + "enabled": False, + "optional": False, + "allow_history_only": False + }, + "ValidateSkeletonRigOutputIds": { + "enabled": False, + "optional": True, + "active": True + }, + "ValidateSkeletonTopGroupHierarchy": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateCameraAttributes": { "enabled": False, "optional": True, diff --git a/server_addon/maya/server/settings/scriptsmenu.py b/server_addon/maya/server/settings/scriptsmenu.py index 82c1c2e53c..4ac2263f7a 100644 --- a/server_addon/maya/server/settings/scriptsmenu.py +++ b/server_addon/maya/server/settings/scriptsmenu.py @@ -26,7 +26,7 @@ class ScriptsmenuModel(BaseSettingsModel): DEFAULT_SCRIPTSMENU_SETTINGS = { - "name": "OpenPype Tools", + "name": "Custom Tools", "definition": [ { "type": "action", diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index e57ad00718..b87834cc35 100644 --- a/server_addon/maya/server/version.py +++ b/server_addon/maya/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.3" +__version__ = "0.1.7" diff --git a/server_addon/nuke/server/settings/common.py b/server_addon/nuke/server/settings/common.py index 700f01f3dc..2bc3c9be81 100644 --- a/server_addon/nuke/server/settings/common.py +++ b/server_addon/nuke/server/settings/common.py @@ -89,12 +89,6 @@ knob_types_enum = [ class KnobModel(BaseSettingsModel): - """# TODO: new data structure - - v3 was having type, name, value but - ayon is not able to make it the same. Current model is - defining `type` as `text` and instead of `value` the key is `text`. - So if `type` is `boolean` then key is `boolean` (value). - """ _layout = "expanded" type: str = Field( diff --git a/server_addon/nuke/server/settings/create_plugins.py b/server_addon/nuke/server/settings/create_plugins.py index 0bbae4ee77..80aec51ae0 100644 --- a/server_addon/nuke/server/settings/create_plugins.py +++ b/server_addon/nuke/server/settings/create_plugins.py @@ -16,13 +16,10 @@ def instance_attributes_enum(): class PrenodeModel(BaseSettingsModel): - # TODO: missing in host api - # - good for `dependency` name: str = Field( title="Node name" ) - # TODO: `nodeclass` should be renamed to `nuke_node_class` nodeclass: str = Field( "", title="Node class" @@ -32,11 +29,8 @@ class PrenodeModel(BaseSettingsModel): title="Incoming dependency" ) - """# TODO: Changes in host api: - - Need complete rework of knob types in nuke integration. - - We could not support v3 style of settings. - """ knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) @@ -61,11 +55,8 @@ class CreateWriteRenderModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) @@ -90,11 +81,8 @@ class CreateWritePrerenderModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) @@ -119,11 +107,8 @@ class CreateWriteImageModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) diff --git a/server_addon/nuke/server/settings/dirmap.py b/server_addon/nuke/server/settings/dirmap.py index 2da6d7bf60..7e3c443957 100644 --- a/server_addon/nuke/server/settings/dirmap.py +++ b/server_addon/nuke/server/settings/dirmap.py @@ -25,19 +25,6 @@ class DirmapSettings(BaseSettingsModel): ) -"""# TODO: -nuke is having originally implemented -following data inputs: - -"nuke-dirmap": { - "enabled": false, - "paths": { - "source-path": [], - "destination-path": [] - } -} -""" - DEFAULT_DIRMAP_SETTINGS = { "enabled": False, "paths": { diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index b43017ef8b..19ad5ff24a 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -9,22 +9,37 @@ from .common import KnobModel class NodesModel(BaseSettingsModel): - """# TODO: This needs to be somehow labeled in settings panel - or at least it could show gist of configuration - """ _layout = "expanded" plugins: list[str] = Field( + default_factory=list, title="Used in plugins" ) - # TODO: rename `nukeNodeClass` to `nuke_node_class` - nukeNodeClass: str = Field( + nuke_node_class: str = Field( title="Nuke Node Class", ) - """ # TODO: Need complete rework of knob types - in nuke integration. We could not support v3 style of settings. - """ + +class RequiredNodesModel(NodesModel): knobs: list[KnobModel] = Field( + default_factory=list, + title="Knobs", + ) + + @validator("knobs") + def ensure_unique_names(cls, value): + """Ensure name fields within the lists have unique names.""" + ensure_unique_names(value) + return value + + +class OverrideNodesModel(NodesModel): + subsets: list[str] = Field( + default_factory=list, + title="Subsets" + ) + + knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) @@ -36,13 +51,11 @@ class NodesModel(BaseSettingsModel): class NodesSetting(BaseSettingsModel): - # TODO: rename `requiredNodes` to `required_nodes` - requiredNodes: list[NodesModel] = Field( + required_nodes: list[RequiredNodesModel] = Field( title="Plugin required", default_factory=list ) - # TODO: rename `overrideNodes` to `override_nodes` - overrideNodes: list[NodesModel] = Field( + override_nodes: list[OverrideNodesModel] = Field( title="Plugin's node overrides", default_factory=list ) @@ -51,66 +64,40 @@ class NodesSetting(BaseSettingsModel): def ocio_configs_switcher_enum(): return [ {"value": "nuke-default", "label": "nuke-default"}, - {"value": "spi-vfx", "label": "spi-vfx"}, - {"value": "spi-anim", "label": "spi-anim"}, - {"value": "aces_0.1.1", "label": "aces_0.1.1"}, - {"value": "aces_0.7.1", "label": "aces_0.7.1"}, - {"value": "aces_1.0.1", "label": "aces_1.0.1"}, - {"value": "aces_1.0.3", "label": "aces_1.0.3"}, - {"value": "aces_1.1", "label": "aces_1.1"}, - {"value": "aces_1.2", "label": "aces_1.2"}, - {"value": "aces_1.3", "label": "aces_1.3"}, - {"value": "custom", "label": "custom"} + {"value": "spi-vfx", "label": "spi-vfx (11)"}, + {"value": "spi-anim", "label": "spi-anim (11)"}, + {"value": "aces_0.1.1", "label": "aces_0.1.1 (11)"}, + {"value": "aces_0.7.1", "label": "aces_0.7.1 (11)"}, + {"value": "aces_1.0.1", "label": "aces_1.0.1 (11)"}, + {"value": "aces_1.0.3", "label": "aces_1.0.3 (11, 12)"}, + {"value": "aces_1.1", "label": "aces_1.1 (12, 13)"}, + {"value": "aces_1.2", "label": "aces_1.2 (13, 14)"}, + {"value": "studio-config-v1.0.0_aces-v1.3_ocio-v2.1", + "label": "studio-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)"}, + {"value": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1", + "label": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)"}, ] class WorkfileColorspaceSettings(BaseSettingsModel): """Nuke workfile colorspace preset. """ - """# TODO: enhance settings with host api: - we need to add mapping to resolve properly keys. - Nuke is excpecting camel case key names, - but for better code consistency we need to - be using snake_case: - color_management = colorManagement - ocio_config = OCIO_config - working_space_name = workingSpaceLUT - monitor_name = monitorLut - monitor_out_name = monitorOutLut - int_8_name = int8Lut - int_16_name = int16Lut - log_name = logLut - float_name = floatLut - """ - - colorManagement: Literal["Nuke", "OCIO"] = Field( - title="Color Management" + color_management: Literal["Nuke", "OCIO"] = Field( + title="Color Management Workflow" ) - OCIO_config: str = Field( - title="OpenColorIO Config", - description="Switch between OCIO configs", + native_ocio_config: str = Field( + title="Native OpenColorIO Config", + description="Switch between native OCIO configs", enum_resolver=ocio_configs_switcher_enum, conditionalEnum=True ) - workingSpaceLUT: str = Field( + working_space: str = Field( title="Working Space" ) - monitorLut: str = Field( - title="Monitor" - ) - int8Lut: str = Field( - title="8-bit files" - ) - int16Lut: str = Field( - title="16-bit files" - ) - logLut: str = Field( - title="Log files" - ) - floatLut: str = Field( - title="Float files" + thumbnail_space: str = Field( + title="Thumbnail Space" ) @@ -170,7 +157,7 @@ class ImageIOSettings(BaseSettingsModel): _isGroup: bool = True """# TODO: enhance settings with host api: - to restruture settings for simplification. + to restructure settings for simplification. now: nuke/imageio/viewer/viewerProcess future: nuke/imageio/viewer @@ -193,7 +180,7 @@ class ImageIOSettings(BaseSettingsModel): ) """# TODO: enhance settings with host api: - to restruture settings for simplification. + to restructure settings for simplification. now: nuke/imageio/baking/viewerProcess future: nuke/imageio/baking @@ -215,12 +202,10 @@ class ImageIOSettings(BaseSettingsModel): title="Nodes" ) """# TODO: enhance settings with host api: - - old settings are using `regexInputs` key but we - need to rename to `regex_inputs` - - no need for `inputs` middle part. It can stay + - [ ] no need for `inputs` middle part. It can stay directly on `regex_inputs` """ - regexInputs: RegexInputsModel = Field( + regex_inputs: RegexInputsModel = Field( default_factory=RegexInputsModel, title="Assign colorspace to read nodes via rules" ) @@ -228,28 +213,24 @@ class ImageIOSettings(BaseSettingsModel): DEFAULT_IMAGEIO_SETTINGS = { "viewer": { - "viewerProcess": "sRGB" + "viewerProcess": "sRGB (default)" }, "baking": { - "viewerProcess": "rec709" + "viewerProcess": "rec709 (default)" }, "workfile": { - "colorManagement": "Nuke", - "OCIO_config": "nuke-default", - "workingSpaceLUT": "linear", - "monitorLut": "sRGB", - "int8Lut": "sRGB", - "int16Lut": "sRGB", - "logLut": "Cineon", - "floatLut": "linear" + "color_management": "OCIO", + "native_ocio_config": "nuke-default", + "working_space": "scene_linear", + "thumbnail_space": "sRGB (default)", }, "nodes": { - "requiredNodes": [ + "required_nodes": [ { "plugins": [ "CreateWriteRender" ], - "nukeNodeClass": "Write", + "nuke_node_class": "Write", "knobs": [ { "type": "text", @@ -288,7 +269,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "linear" + "text": "scene_linear" }, { "type": "boolean", @@ -301,7 +282,7 @@ DEFAULT_IMAGEIO_SETTINGS = { "plugins": [ "CreateWritePrerender" ], - "nukeNodeClass": "Write", + "nuke_node_class": "Write", "knobs": [ { "type": "text", @@ -340,7 +321,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "linear" + "text": "scene_linear" }, { "type": "boolean", @@ -353,7 +334,7 @@ DEFAULT_IMAGEIO_SETTINGS = { "plugins": [ "CreateWriteImage" ], - "nukeNodeClass": "Write", + "nuke_node_class": "Write", "knobs": [ { "type": "text", @@ -387,7 +368,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "sRGB" + "text": "texture_paint" }, { "type": "boolean", @@ -397,9 +378,9 @@ DEFAULT_IMAGEIO_SETTINGS = { ] } ], - "overrideNodes": [] + "override_nodes": [] }, - "regexInputs": { + "regex_inputs": { "inputs": [ { "regex": "(beauty).*(?=.exr)", diff --git a/server_addon/nuke/server/settings/loader_plugins.py b/server_addon/nuke/server/settings/loader_plugins.py index 6db381bffb..51e2c2149b 100644 --- a/server_addon/nuke/server/settings/loader_plugins.py +++ b/server_addon/nuke/server/settings/loader_plugins.py @@ -6,10 +6,6 @@ class LoadImageModel(BaseSettingsModel): enabled: bool = Field( title="Enabled" ) - """# TODO: v3 api used `_representation` - New api is hiding it so it had to be renamed - to `representations_include` - """ representations_include: list[str] = Field( default_factory=list, title="Include representations" @@ -33,10 +29,6 @@ class LoadClipModel(BaseSettingsModel): enabled: bool = Field( title="Enabled" ) - """# TODO: v3 api used `_representation` - New api is hiding it so it had to be renamed - to `representations_include` - """ representations_include: list[str] = Field( default_factory=list, title="Include representations" diff --git a/server_addon/nuke/server/settings/main.py b/server_addon/nuke/server/settings/main.py index 4687d48ac9..cdaaa3a9e2 100644 --- a/server_addon/nuke/server/settings/main.py +++ b/server_addon/nuke/server/settings/main.py @@ -59,9 +59,7 @@ class NukeSettings(BaseSettingsModel): default_factory=ImageIOSettings, title="Color Management (imageio)", ) - """# TODO: fix host api: - - rename `nuke-dirmap` to `dirmap` was inevitable - """ + dirmap: DirmapSettings = Field( default_factory=DirmapSettings, title="Nuke Directory Mapping", diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 7e898f8c9a..81663fa5aa 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -28,11 +28,9 @@ def nuke_product_types_enum(): class NodeModel(BaseSettingsModel): - # TODO: missing in host api name: str = Field( title="Node name" ) - # TODO: `nodeclass` rename to `nuke_node_class` nodeclass: str = Field( "", title="Node class" @@ -41,11 +39,8 @@ class NodeModel(BaseSettingsModel): "", title="Incoming dependency" ) - """# TODO: Changes in host api: - - Need complete rework of knob types in nuke integration. - - We could not support v3 style of settings. - """ knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) @@ -99,12 +94,9 @@ class ExtractThumbnailModel(BaseSettingsModel): use_rendered: bool = Field(title="Use rendered images") bake_viewer_process: bool = Field(title="Bake view process") bake_viewer_input_process: bool = Field(title="Bake viewer input process") - """# TODO: needs to rewrite from v3 to ayon - - `nodes` in v3 was dict but now `prenodes` is list of dict - - also later `nodes` should be `prenodes` - """ nodes: list[NodeModel] = Field( + default_factory=list, title="Nodes (deprecated)" ) reposition_nodes: list[ThumbnailRepositionNodeModel] = Field( @@ -157,14 +149,16 @@ class ReformatNodesConfigModel(BaseSettingsModel): ) -class BakingStreamModel(BaseSettingsModel): +class IntermediateOutputModel(BaseSettingsModel): name: str = Field(title="Output name") filter: BakingStreamFilterModel = Field( title="Filter", default_factory=BakingStreamFilterModel) read_raw: bool = Field(title="Read raw switch") viewer_process_override: str = Field(title="Viewer process override") - bake_viewer_process: bool = Field(title="Bake view process") - bake_viewer_input_process: bool = Field(title="Bake viewer input process") + bake_viewer_process: bool = Field(title="Bake viewer process") + bake_viewer_input_process: bool = Field( + title="Bake viewer input process node (LUT)" + ) reformat_nodes_config: ReformatNodesConfigModel = Field( default_factory=ReformatNodesConfigModel, title="Reformat Nodes") @@ -174,9 +168,22 @@ class BakingStreamModel(BaseSettingsModel): class ExtractReviewDataMovModel(BaseSettingsModel): + """[deprecated] use Extract Review Data Baking + Streams instead. + """ enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") - outputs: list[BakingStreamModel] = Field( + outputs: list[IntermediateOutputModel] = Field( + default_factory=list, + title="Baking streams" + ) + + +class ExtractReviewIntermediatesModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + viewer_lut_raw: bool = Field(title="Viewer lut raw") + outputs: list[IntermediateOutputModel] = Field( + default_factory=list, title="Baking streams" ) @@ -213,12 +220,6 @@ class ExctractSlateFrameParamModel(BaseSettingsModel): class ExtractSlateFrameModel(BaseSettingsModel): viewer_lut_raw: bool = Field(title="Viewer lut raw") - """# TODO: v3 api different model: - - not possible to replicate v3 model: - {"name": [bool, str]} - - not it is: - {"name": {"enabled": bool, "template": str}} - """ key_value_mapping: ExctractSlateFrameParamModel = Field( title="Key value mapping", default_factory=ExctractSlateFrameParamModel @@ -237,7 +238,7 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=CollectInstanceDataModel, section="Collectors" ) - ValidateCorrectAssetName: OptionalPluginModel = Field( + ValidateCorrectAssetContext: OptionalPluginModel = Field( title="Validate Correct Folder Name", default_factory=OptionalPluginModel, section="Validators" @@ -262,8 +263,8 @@ class PublishPuginsModel(BaseSettingsModel): title="Validate Backdrop", default_factory=OptionalPluginModel ) - ValidateScript: OptionalPluginModel = Field( - title="Validate Script", + ValidateScriptAttributes: OptionalPluginModel = Field( + title="Validate workfile attributes", default_factory=OptionalPluginModel ) ExtractThumbnail: ExtractThumbnailModel = Field( @@ -283,11 +284,14 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Review Data Mov", default_factory=ExtractReviewDataMovModel ) + ExtractReviewIntermediates: ExtractReviewIntermediatesModel = Field( + title="Extract Review Intermediates", + default_factory=ExtractReviewIntermediatesModel + ) ExtractSlateFrame: ExtractSlateFrameModel = Field( title="Extract Slate Frame", default_factory=ExtractSlateFrameModel ) - # TODO: plugin should be renamed - `workfile` not `script` IncrementScriptVersion: IncrementScriptVersionModel = Field( title="Increment Workfile Version", default_factory=IncrementScriptVersionModel, @@ -306,7 +310,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "write" ] }, - "ValidateCorrectAssetName": { + "ValidateCorrectAssetContext": { "enabled": True, "optional": True, "active": True @@ -341,7 +345,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "optional": True, "active": True }, - "ValidateScript": { + "ValidateScriptAttributes": { "enabled": True, "optional": True, "active": True @@ -405,12 +409,12 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "text": "Lanczos6" }, { - "type": "bool", + "type": "boolean", "name": "black_outside", "boolean": True }, { - "type": "bool", + "type": "boolean", "name": "pbb", "boolean": False } @@ -425,6 +429,61 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "enabled": False }, "ExtractReviewDataMov": { + "enabled": False, + "viewer_lut_raw": False, + "outputs": [ + { + "name": "baking", + "filter": { + "task_types": [], + "product_types": [], + "product_names": [] + }, + "read_raw": False, + "viewer_process_override": "", + "bake_viewer_process": True, + "bake_viewer_input_process": True, + "reformat_nodes_config": { + "enabled": False, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": [ + { + "type": "text", + "name": "type", + "text": "to format" + }, + { + "type": "text", + "name": "format", + "text": "HD_1080" + }, + { + "type": "text", + "name": "filter", + "text": "Lanczos6" + }, + { + "type": "boolean", + "name": "black_outside", + "boolean": True + }, + { + "type": "boolean", + "name": "pbb", + "boolean": False + } + ] + } + ] + }, + "extension": "mov", + "add_custom_tags": [] + } + ] + }, + "ExtractReviewIntermediates": { "enabled": True, "viewer_lut_raw": False, "outputs": [ @@ -461,12 +520,12 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = { "text": "Lanczos6" }, { - "type": "bool", + "type": "boolean", "name": "black_outside", "boolean": True }, { - "type": "bool", + "type": "boolean", "name": "pbb", "boolean": False } diff --git a/server_addon/nuke/server/settings/scriptsmenu.py b/server_addon/nuke/server/settings/scriptsmenu.py index 9d1c32ebac..3dd6765920 100644 --- a/server_addon/nuke/server/settings/scriptsmenu.py +++ b/server_addon/nuke/server/settings/scriptsmenu.py @@ -17,7 +17,6 @@ class ScriptsmenuSettings(BaseSettingsModel): """Nuke script menu project settings.""" _isGroup = True - # TODO: in api rename key `name` to `menu_name` name: str = Field(title="Menu Name") definition: list[ScriptsmenuSubmodel] = Field( default_factory=list, @@ -27,27 +26,27 @@ class ScriptsmenuSettings(BaseSettingsModel): DEFAULT_SCRIPTSMENU_SETTINGS = { - "name": "OpenPype Tools", + "name": "Custom Tools", "definition": [ { "type": "action", "sourcetype": "python", - "title": "OpenPype Docs", - "command": "import webbrowser;webbrowser.open(url='https://openpype.io/docs/artist_hosts_nuke_tut')", - "tooltip": "Open the OpenPype Nuke user doc page" + "title": "Ayon Nuke Docs", + "command": "import webbrowser;webbrowser.open(url='https://ayon.ynput.io/docs/addon_nuke_artist')", # noqa + "tooltip": "Open the Ayon Nuke user doc page" }, { "type": "action", "sourcetype": "python", "title": "Set Frame Start (Read Node)", - "command": "from openpype.hosts.nuke.startup.frame_setting_for_read_nodes import main;main();", + "command": "from openpype.hosts.nuke.startup.frame_setting_for_read_nodes import main;main();", # noqa "tooltip": "Set frame start for read node(s)" }, { "type": "action", "sourcetype": "python", "title": "Set non publish output for Write Node", - "command": "from openpype.hosts.nuke.startup.custom_write_node import main;main();", + "command": "from openpype.hosts.nuke.startup.custom_write_node import main;main();", # noqa "tooltip": "Open the OpenPype Nuke user doc page" } ] diff --git a/server_addon/nuke/server/settings/templated_workfile_build.py b/server_addon/nuke/server/settings/templated_workfile_build.py index e0245c8d06..0899be841e 100644 --- a/server_addon/nuke/server/settings/templated_workfile_build.py +++ b/server_addon/nuke/server/settings/templated_workfile_build.py @@ -28,6 +28,7 @@ class TemplatedWorkfileProfileModel(BaseSettingsModel): class TemplatedWorkfileBuildModel(BaseSettingsModel): + """Settings for templated workfile builder.""" profiles: list[TemplatedWorkfileProfileModel] = Field( default_factory=list ) diff --git a/server_addon/nuke/server/settings/workfile_builder.py b/server_addon/nuke/server/settings/workfile_builder.py index ee67c7c16a..3ae3b08788 100644 --- a/server_addon/nuke/server/settings/workfile_builder.py +++ b/server_addon/nuke/server/settings/workfile_builder.py @@ -48,20 +48,32 @@ class BuilderProfileModel(BaseSettingsModel): title="Task names" ) current_context: list[BuilderProfileItemModel] = Field( - title="Current context") + default_factory=list, + title="Current context" + ) linked_assets: list[BuilderProfileItemModel] = Field( - title="Linked assets/shots") + default_factory=list, + title="Linked assets/shots" + ) class WorkfileBuilderModel(BaseSettingsModel): + """[deprecated] use Template Workfile Build Settings instead. + """ create_first_version: bool = Field( title="Create first workfile") custom_templates: list[CustomTemplateModel] = Field( - title="Custom templates") + default_factory=list, + title="Custom templates" + ) builder_on_start: bool = Field( - title="Run Builder at first workfile") + default=False, + title="Run Builder at first workfile" + ) profiles: list[BuilderProfileModel] = Field( - title="Builder profiles") + default_factory=list, + title="Builder profiles" + ) DEFAULT_WORKFILE_BUILDER_SETTINGS = { diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py index b3f4756216..0a8da88258 100644 --- a/server_addon/nuke/server/version.py +++ b/server_addon/nuke/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.6" diff --git a/server_addon/openpype/client/pyproject.toml b/server_addon/openpype/client/pyproject.toml index 6d5ac92ca7..40da8f6716 100644 --- a/server_addon/openpype/client/pyproject.toml +++ b/server_addon/openpype/client/pyproject.toml @@ -8,7 +8,6 @@ aiohttp_json_rpc = "*" # TVPaint server aiohttp-middlewares = "^2.0.0" wsrpc_aiohttp = "^3.1.1" # websocket server clique = "1.6.*" -shotgun_api3 = {git = "https://github.com/shotgunsoftware/python-api.git", rev = "v3.3.3"} gazu = "^0.9.3" google-api-python-client = "^1.12.8" # sync server google support (should be separate?) jsonschema = "^2.6.0" diff --git a/server_addon/photoshop/server/settings/publish_plugins.py b/server_addon/photoshop/server/settings/publish_plugins.py index 6bc72b4072..2863979ca9 100644 --- a/server_addon/photoshop/server/settings/publish_plugins.py +++ b/server_addon/photoshop/server/settings/publish_plugins.py @@ -150,7 +150,7 @@ class PhotoshopPublishPlugins(BaseSettingsModel): ) CollectVersion: CollectVersionPlugin = Field( - title="Create Image", + title="Collect Version", default_factory=CollectVersionPlugin, ) diff --git a/server_addon/photoshop/server/settings/workfile_builder.py b/server_addon/photoshop/server/settings/workfile_builder.py index ec2ee136ad..68db05270d 100644 --- a/server_addon/photoshop/server/settings/workfile_builder.py +++ b/server_addon/photoshop/server/settings/workfile_builder.py @@ -1,31 +1,18 @@ from pydantic import Field -from pathlib import Path -from ayon_server.settings import BaseSettingsModel - - -class PathsTemplate(BaseSettingsModel): - windows: Path = Field( - '', - title="Windows" - ) - darwin: Path = Field( - '', - title="MacOS" - ) - linux: Path = Field( - '', - title="Linux" - ) +from ayon_server.settings import BaseSettingsModel, MultiplatformPathModel class CustomBuilderTemplate(BaseSettingsModel): + _layout = "expanded" task_types: list[str] = Field( default_factory=list, title="Task types", ) - template_path: PathsTemplate = Field( - default_factory=PathsTemplate + + path: MultiplatformPathModel = Field( + default_factory=MultiplatformPathModel, + title="Template path" ) @@ -37,5 +24,6 @@ class WorkfileBuilderPlugin(BaseSettingsModel): ) custom_templates: list[CustomBuilderTemplate] = Field( - default_factory=CustomBuilderTemplate + default_factory=CustomBuilderTemplate, + title="Template profiles" ) diff --git a/server_addon/substancepainter/server/__init__.py b/server_addon/substancepainter/server/__init__.py new file mode 100644 index 0000000000..2bf808d508 --- /dev/null +++ b/server_addon/substancepainter/server/__init__.py @@ -0,0 +1,17 @@ +from typing import Type + +from ayon_server.addons import BaseServerAddon + +from .version import __version__ +from .settings import SubstancePainterSettings, DEFAULT_SPAINTER_SETTINGS + + +class SubstancePainterAddon(BaseServerAddon): + name = "substancepainter" + title = "Substance Painter" + version = __version__ + settings_model: Type[SubstancePainterSettings] = SubstancePainterSettings + + async def get_default_settings(self): + settings_model_cls = self.get_settings_model() + return settings_model_cls(**DEFAULT_SPAINTER_SETTINGS) diff --git a/server_addon/substancepainter/server/settings/__init__.py b/server_addon/substancepainter/server/settings/__init__.py new file mode 100644 index 0000000000..f47f064536 --- /dev/null +++ b/server_addon/substancepainter/server/settings/__init__.py @@ -0,0 +1,10 @@ +from .main import ( + SubstancePainterSettings, + DEFAULT_SPAINTER_SETTINGS, +) + + +__all__ = ( + "SubstancePainterSettings", + "DEFAULT_SPAINTER_SETTINGS", +) diff --git a/server_addon/substancepainter/server/settings/imageio.py b/server_addon/substancepainter/server/settings/imageio.py new file mode 100644 index 0000000000..e301d3d865 --- /dev/null +++ b/server_addon/substancepainter/server/settings/imageio.py @@ -0,0 +1,61 @@ +from pydantic import Field, validator +from ayon_server.settings import BaseSettingsModel +from ayon_server.settings.validators import ensure_unique_names + + +class ImageIOConfigModel(BaseSettingsModel): + override_global_config: bool = Field( + False, + title="Override global OCIO config" + ) + filepath: list[str] = Field( + default_factory=list, + title="Config path" + ) + + +class ImageIOFileRuleModel(BaseSettingsModel): + name: str = Field("", title="Rule name") + pattern: str = Field("", title="Regex pattern") + colorspace: str = Field("", title="Colorspace name") + ext: str = Field("", title="File extension") + + +class ImageIOFileRulesModel(BaseSettingsModel): + activate_host_rules: bool = Field(False) + rules: list[ImageIOFileRuleModel] = Field( + default_factory=list, + title="Rules" + ) + + @validator("rules") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + + +class ImageIOSettings(BaseSettingsModel): + activate_host_color_management: bool = Field( + True, title="Enable Color Management" + ) + ocio_config: ImageIOConfigModel = Field( + default_factory=ImageIOConfigModel, + title="OCIO config" + ) + file_rules: ImageIOFileRulesModel = Field( + default_factory=ImageIOFileRulesModel, + title="File Rules" + ) + + +DEFAULT_IMAGEIO_SETTINGS = { + "activate_host_color_management": True, + "ocio_config": { + "override_global_config": False, + "filepath": [] + }, + "file_rules": { + "activate_host_rules": False, + "rules": [] + } +} diff --git a/server_addon/substancepainter/server/settings/main.py b/server_addon/substancepainter/server/settings/main.py new file mode 100644 index 0000000000..f8397c3c08 --- /dev/null +++ b/server_addon/substancepainter/server/settings/main.py @@ -0,0 +1,26 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel +from .imageio import ImageIOSettings, DEFAULT_IMAGEIO_SETTINGS + + +class ShelvesSettingsModel(BaseSettingsModel): + _layout = "compact" + name: str = Field(title="Name") + value: str = Field(title="Path") + + +class SubstancePainterSettings(BaseSettingsModel): + imageio: ImageIOSettings = Field( + default_factory=ImageIOSettings, + title="Color Management (ImageIO)" + ) + shelves: list[ShelvesSettingsModel] = Field( + default_factory=list, + title="Shelves" + ) + + +DEFAULT_SPAINTER_SETTINGS = { + "imageio": DEFAULT_IMAGEIO_SETTINGS, + "shelves": [] +} diff --git a/server_addon/substancepainter/server/version.py b/server_addon/substancepainter/server/version.py new file mode 100644 index 0000000000..3dc1f76bc6 --- /dev/null +++ b/server_addon/substancepainter/server/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/server_addon/traypublisher/server/settings/editorial_creators.py b/server_addon/traypublisher/server/settings/editorial_creators.py index 4111f22576..ac0ff0afc7 100644 --- a/server_addon/traypublisher/server/settings/editorial_creators.py +++ b/server_addon/traypublisher/server/settings/editorial_creators.py @@ -5,19 +5,17 @@ from ayon_server.settings import BaseSettingsModel, task_types_enum class ClipNameTokenizerItem(BaseSettingsModel): _layout = "expanded" - # TODO was 'dict-modifiable', is list of dicts now, must be fixed in code - name: str = Field("#TODO", title="Tokenizer name") + name: str = Field("", title="Tokenizer name") regex: str = Field("", title="Tokenizer regex") class ShotAddTasksItem(BaseSettingsModel): _layout = "expanded" - # TODO was 'dict-modifiable', is list of dicts now, must be fixed in code name: str = Field('', title="Key") - task_type: list[str] = Field( + task_type: str = Field( title="Task type", - default_factory=list, - enum_resolver=task_types_enum) + enum_resolver=task_types_enum + ) class ShotRenameSubmodel(BaseSettingsModel): @@ -54,7 +52,7 @@ class TokenToParentConvertorItem(BaseSettingsModel): ) -class ShotHierchySubmodel(BaseSettingsModel): +class ShotHierarchySubmodel(BaseSettingsModel): enabled: bool = True parents_path: str = Field( "", @@ -102,9 +100,9 @@ class EditorialSimpleCreatorPlugin(BaseSettingsModel): title="Shot Rename", default_factory=ShotRenameSubmodel ) - shot_hierarchy: ShotHierchySubmodel = Field( + shot_hierarchy: ShotHierarchySubmodel = Field( title="Shot Hierarchy", - default_factory=ShotHierchySubmodel + default_factory=ShotHierarchySubmodel ) shot_add_tasks: list[ShotAddTasksItem] = Field( title="Add tasks to shot", diff --git a/server_addon/traypublisher/server/settings/simple_creators.py b/server_addon/traypublisher/server/settings/simple_creators.py index 94d6602738..8335b9d34e 100644 --- a/server_addon/traypublisher/server/settings/simple_creators.py +++ b/server_addon/traypublisher/server/settings/simple_creators.py @@ -288,5 +288,22 @@ DEFAULT_SIMPLE_CREATORS = [ "allow_multiple_items": True, "allow_version_control": False, "extensions": [] + }, + { + "product_type": "audio", + "identifier": "", + "label": "Audio ", + "icon": "fa5s.file-audio", + "default_variants": [ + "Main" + ], + "description": "Audio product", + "detailed_description": "Audio files for review or final delivery", + "allow_sequences": False, + "allow_multiple_items": False, + "allow_version_control": False, + "extensions": [ + ".wav" + ] } ] diff --git a/server_addon/traypublisher/server/version.py b/server_addon/traypublisher/server/version.py index df0c92f1e2..e57ad00718 100644 --- a/server_addon/traypublisher/server/version.py +++ b/server_addon/traypublisher/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/setup.cfg b/setup.cfg index 10cca3eb3f..ead9b25164 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,4 +28,11 @@ omit = /tests directory = ./coverage [tool:pytest] -norecursedirs = repos/* openpype/modules/ftrack/* \ No newline at end of file +norecursedirs = openpype/modules/ftrack/* + +[isort] +line_length = 79 +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +combine_as_imports = True diff --git a/setup.py b/setup.py index 4b6f286730..6179de1d34 100644 --- a/setup.py +++ b/setup.py @@ -124,7 +124,6 @@ bin_includes = [ include_files = [ "igniter", "openpype", - "schema", "LICENSE", "README.md" ] diff --git a/tests/conftest.py b/tests/conftest.py index 4f7c17244b..028c19bc5f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,11 @@ def pytest_addoption(parser): help="True - keep test_db, test_openpype, outputted test files" ) + parser.addoption( + "--app_group", action="store", default=None, + help="Keep empty to use default application or explicit" + ) + parser.addoption( "--app_variant", action="store", default=None, help="Keep empty to locate latest installed variant or explicit" @@ -29,6 +34,11 @@ def pytest_addoption(parser): help="True - only setup test, do not run any tests" ) + parser.addoption( + "--mongo_url", action="store", default=None, + help="Provide url of the Mongo database." + ) + @pytest.fixture(scope="module") def test_data_folder(request): @@ -40,6 +50,11 @@ def persist(request): return request.config.getoption("--persist") +@pytest.fixture(scope="module") +def app_group(request): + return request.config.getoption("--app_group") + + @pytest.fixture(scope="module") def app_variant(request): return request.config.getoption("--app_variant") @@ -55,6 +70,11 @@ def setup_only(request): return request.config.getoption("--setup_only") +@pytest.fixture(scope="module") +def mongo_url(request): + return request.config.getoption("--mongo_url") + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object @@ -65,3 +85,11 @@ def pytest_runtest_makereport(item, call): # be "setup", "call", "teardown" setattr(item, "rep_" + rep.when, rep) + + # In the event of module scoped fixtures, also mark failure in module. + module = item + while module is not None and not isinstance(module, pytest.Module): + module = module.parent + if module is not None: + if rep.when == 'call' and (rep.failed or rep.skipped): + module.module_test_failure = True diff --git a/tests/integration/README.md b/tests/integration/README.md index eef8141127..7b9b7cd706 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -14,23 +14,52 @@ How to run - run in cmd `{OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests {OPENPYPE_ROOT}/tests/integration` - add `hosts/APP_NAME` after integration part to limit only on specific app (eg. `{OPENPYPE_ROOT}/tests/integration/hosts/maya`) - -OR can use built executables + +OR can use built executables `openpype_console runtests {ABS_PATH}/tests/integration` +Command line arguments +---------------------- + - "--mark" - "Run tests marked by", + - "--pyargs" - "Run tests from package", + - "--test_data_folder" - "Unzipped directory path of test file", + - "--persist" - "Persist test DB and published files after test end", + - "--app_variant" - "Provide specific app variant for test, empty for latest", + - "--app_group" - "Provide specific app group for test, empty for default", + - "--timeout" - "Provide specific timeout value for test case", + - "--setup_only" - "Only create dbs, do not run tests", + - "--mongo_url" - "MongoDB for testing.", + +Run Tray for test +----------------- +In case of failed test you might want to run it manually and visually debug what happened. +For that: +- run tests that is failing +- add environment variables (to command line process or your IDE) + - OPENPYPE_DATABASE_NAME = openpype_tests + - AVALON_DB = avalon_tests +- run tray as usual + - `{OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py run tray --debug` + +You should see only test asset and state of databases for that particular use case. + How to check logs/errors from app -------------------------------- -Keep PERSIST to True in the class and check `test_openpype.logs` collection. +Keep PERSIST to True in the class and check `test_openpype.logs` collection. How to create test for publishing from host ------------------------------------------ - Extend PublishTest in `tests/lib/testing_classes.py` - Use `resources\test_data.zip` skeleton file as a template for testing input data -- Put workfile into `test_data.zip/input/workfile` -- If you require other than base DB dumps provide them to `test_data.zip/input/dumps` +- Create subfolder `test_data` with matching name to your test file containing you test class + - (see `tests/integration/hosts/maya/test_publish_in_maya` and `test_publish_in_maya.py`) +- Put this subfolder name into TEST_FILES [(HASH_ID, FILE_NAME, MD5_OPTIONAL)] + - at first position, all others may be "" +- Put workfile into `test_data/input/workfile` +- If you require other than base DB dumps provide them to `test_data/input/dumps` -- (Check commented code in `db_handler.py` how to dump specific DB. Currently all collections will be dumped.) -- Implement `last_workfile_path` -- `startup_scripts` - must contain pointing host to startup script saved into `test_data.zip/input/startup` +- Implement `last_workfile_path` +- `startup_scripts` - must contain pointing host to startup script saved into `test_data/input/startup` -- Script must contain something like (pseudocode) ``` import openpype @@ -39,7 +68,7 @@ from avalon import api, HOST from openpype.api import Logger log = Logger().get_logger(__name__) - + api.install(HOST) log_lines = [] for result in pyblish.util.publish_iter(): @@ -54,18 +83,20 @@ for result in pyblish.util.publish_iter(): EXIT_APP (command to exit host) ``` (Install and publish methods must be triggered only AFTER host app is fully initialized!) -- If you would like add any command line arguments for your host app add it to `test_data.zip/input/app_args/app_args.json` (as a json list) -- Provide any required environment variables to `test_data.zip/input/env_vars/env_vars.json` (as a json dictionary) -- Zip `test_data.zip`, named it with descriptive name, upload it to Google Drive, right click - `Get link`, copy hash id (file must be accessible to anyone with a link!) -- Put this hash id and zip file name into TEST_FILES [(HASH_ID, FILE_NAME, MD5_OPTIONAL)]. If you want to check MD5 of downloaded -file, provide md5 value of zipped file. +- If you would like add any command line arguments for your host app add it to `test_data/input/app_args/app_args.json` (as a json list) +- Provide any required environment variables to `test_data/input/env_vars/env_vars.json` (as a json dictionary) - Implement any assert checks you need in extended class - Run test class manually (via Pycharm or pytest runner (TODO)) - If you want test to visually compare expected files to published one, set PERSIST to True, run test manually -- Locate temporary `publish` subfolder of temporary folder (found in debugging console log) - -- Copy whole folder content into .zip file into `expected` subfolder + -- Copy whole folder content into .zip file into `expected` subfolder -- By default tests are comparing only structure of `expected` and published format (eg. if you want to save space, replace published files with empty files, but with expected names!) -- Zip and upload again, change PERSIST to False - + - Use `TEST_DATA_FOLDER` variable in your class to reuse existing downloaded and unzipped test data (for faster creation of tests) -- Keep `APP_VARIANT` empty if you want to trigger test on latest version of app, or provide explicit value (as '2022' for Photoshop for example) \ No newline at end of file +- Keep `APP_VARIANT` empty if you want to trigger test on latest version of app, or provide explicit value (as '2022' for Photoshop for example) + +For storing test zip files on Google Drive: +- Zip `test_data.zip`, named it with descriptive name, upload it to Google Drive, right click - `Get link`, copy hash id (file must be accessible to anyone with a link!) +- Put this hash id and zip file name into TEST_FILES [(HASH_ID, FILE_NAME, MD5_OPTIONAL)]. If you want to check MD5 of downloaded +file, provide md5 value of zipped file. diff --git a/tests/integration/hosts/maya/lib.py b/tests/integration/hosts/maya/lib.py index e7480e25fa..ab5bcbbd36 100644 --- a/tests/integration/hosts/maya/lib.py +++ b/tests/integration/hosts/maya/lib.py @@ -16,18 +16,25 @@ class MayaHostFixtures(HostFixtures): Maya expects workfile in proper folder, so copy is done first. """ - src_path = os.path.join(download_test_data, - "input", - "workfile", - "test_project_test_asset_test_task_v001.mb") - dest_folder = os.path.join(output_folder_url, - self.PROJECT, - self.ASSET, - "work", - self.TASK) + src_path = os.path.join( + download_test_data, + "input", + "workfile", + "test_project_test_asset_test_task_v001.ma" + ) + dest_folder = os.path.join( + output_folder_url, + self.PROJECT, + self.ASSET, + "work", + self.TASK + ) + os.makedirs(dest_folder) - dest_path = os.path.join(dest_folder, - "test_project_test_asset_test_task_v001.mb") + + dest_path = os.path.join( + dest_folder, "test_project_test_asset_test_task_v001.ma" + ) shutil.copy(src_path, dest_path) yield dest_path @@ -35,14 +42,19 @@ class MayaHostFixtures(HostFixtures): @pytest.fixture(scope="module") def startup_scripts(self, monkeypatch_session, download_test_data): """Points Maya to userSetup file from input data""" - startup_path = os.path.join(download_test_data, - "input", - "startup") + startup_path = os.path.join( + download_test_data, "input", "startup" + ) original_pythonpath = os.environ.get("PYTHONPATH") - monkeypatch_session.setenv("PYTHONPATH", - "{}{}{}".format(startup_path, - os.pathsep, - original_pythonpath)) + monkeypatch_session.setenv( + "PYTHONPATH", + "{}{}{}".format(startup_path, os.pathsep, original_pythonpath) + ) + + monkeypatch_session.setenv( + "MAYA_CMD_FILE_OUTPUT", + os.path.join(download_test_data, "output.log") + ) @pytest.fixture(scope="module") def skip_compare_folders(self): diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya.py b/tests/integration/hosts/maya/test_deadline_publish_in_maya.py index c5bf526f52..7d2b409db3 100644 --- a/tests/integration/hosts/maya/test_deadline_publish_in_maya.py +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya.py @@ -21,18 +21,17 @@ class TestDeadlinePublishInMaya(MayaDeadlinePublishTestClass): {OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/maya # noqa: E501 """ - PERSIST = True + PERSIST = False TEST_FILES = [ - ("1dDY7CbdFXfRksGVoiuwjhnPoTRCCf5ea", - "test_maya_deadline_publish.zip", "") + ("test_deadline_publish_in_maya", "", "") ] APP_GROUP = "maya" # keep empty to locate latest installed variant or explicit APP_VARIANT = "" - TIMEOUT = 120 # publish timeout + TIMEOUT = 180 # publish timeout def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/README.md b/tests/integration/hosts/maya/test_deadline_publish_in_maya/README.md new file mode 100644 index 0000000000..929bfcd666 --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/README.md @@ -0,0 +1,17 @@ +Test data +--------- +Each class implementing `TestCase` can provide test file(s) by adding them to +TEST_FILES ('GDRIVE_FILE_ID', 'ACTUAL_FILE_NAME', 'MD5HASH') + +GDRIVE_FILE_ID can be pulled from shareable link from Google Drive app. + +Currently it is expected that test file will be zip file with structure: +- expected - expected files (not implemented yet) +- input + - data - test data (workfiles, images etc) + - dumps - folder for BSOn dumps from (`mongodump`) + - env_vars + env_vars.json - dictionary with environment variables {key:value} + + - sql - sql files to load with `mongoimport` (human readable) + - startup - scripts that should run in the host on its startup diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc new file mode 100644 index 0000000000..d9e5425996 Binary files /dev/null and b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc differ diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma new file mode 100644 index 0000000000..9ee588337e --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma @@ -0,0 +1,1208 @@ +//Maya ASCII 2020 scene +//Name: modelMain.ma +//Last modified: Mon, Oct 24, 2022 02:57:47 PM +//Codeset: 1252 +requires maya "2020"; +requires "mtoa" "4.1.1"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2020"; +fileInfo "version" "2020"; +fileInfo "cutIdentifier" "202011110415-b1e20b88e2"; +fileInfo "osv" "Microsoft Windows 10 Technical Preview (Build 19044)\n"; +fileInfo "UUID" "A787A358-4FE7-6E55-0C81-61BFEB0C2726"; +createNode transform -n "model_GRP"; + rename -uid "445FDC20-4A9D-2C5B-C7BD-F98F6E660B5C"; + setAttr ".rlio[0]" 1 yes 0; +createNode transform -n "pSphere1_GEO" -p "model_GRP"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:302a4c6123a4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr -s 439 ".uvst[0].uvsp"; + setAttr ".uvst[0].uvsp[0:249]" -type "float2" 0 0.050000001 0.050000001 0.050000001 + 0.1 0.050000001 0.15000001 0.050000001 0.2 0.050000001 0.25 0.050000001 0.30000001 + 0.050000001 0.35000002 0.050000001 0.40000004 0.050000001 0.45000005 0.050000001 + 0.50000006 0.050000001 0.55000007 0.050000001 0.60000008 0.050000001 0.6500001 0.050000001 + 0.70000011 0.050000001 0.75000012 0.050000001 0.80000013 0.050000001 0.85000014 0.050000001 + 0.90000015 0.050000001 0.95000017 0.050000001 1.000000119209 0.050000001 0 0.1 0.050000001 + 0.1 0.1 0.1 0.15000001 0.1 0.2 0.1 0.25 0.1 0.30000001 0.1 0.35000002 0.1 0.40000004 + 0.1 0.45000005 0.1 0.50000006 0.1 0.55000007 0.1 0.60000008 0.1 0.6500001 0.1 0.70000011 + 0.1 0.75000012 0.1 0.80000013 0.1 0.85000014 0.1 0.90000015 0.1 0.95000017 0.1 1.000000119209 + 0.1 0 0.15000001 0.050000001 0.15000001 0.1 0.15000001 0.15000001 0.15000001 0.2 + 0.15000001 0.25 0.15000001 0.30000001 0.15000001 0.35000002 0.15000001 0.40000004 + 0.15000001 0.45000005 0.15000001 0.50000006 0.15000001 0.55000007 0.15000001 0.60000008 + 0.15000001 0.6500001 0.15000001 0.70000011 0.15000001 0.75000012 0.15000001 0.80000013 + 0.15000001 0.85000014 0.15000001 0.90000015 0.15000001 0.95000017 0.15000001 1.000000119209 + 0.15000001 0 0.2 0.050000001 0.2 0.1 0.2 0.15000001 0.2 0.2 0.2 0.25 0.2 0.30000001 + 0.2 0.35000002 0.2 0.40000004 0.2 0.45000005 0.2 0.50000006 0.2 0.55000007 0.2 0.60000008 + 0.2 0.6500001 0.2 0.70000011 0.2 0.75000012 0.2 0.80000013 0.2 0.85000014 0.2 0.90000015 + 0.2 0.95000017 0.2 1.000000119209 0.2 0 0.25 0.050000001 0.25 0.1 0.25 0.15000001 + 0.25 0.2 0.25 0.25 0.25 0.30000001 0.25 0.35000002 0.25 0.40000004 0.25 0.45000005 + 0.25 0.50000006 0.25 0.55000007 0.25 0.60000008 0.25 0.6500001 0.25 0.70000011 0.25 + 0.75000012 0.25 0.80000013 0.25 0.85000014 0.25 0.90000015 0.25 0.95000017 0.25 1.000000119209 + 0.25 0 0.30000001 0.050000001 0.30000001 0.1 0.30000001 0.15000001 0.30000001 0.2 + 0.30000001 0.25 0.30000001 0.30000001 0.30000001 0.35000002 0.30000001 0.40000004 + 0.30000001 0.45000005 0.30000001 0.50000006 0.30000001 0.55000007 0.30000001 0.60000008 + 0.30000001 0.6500001 0.30000001 0.70000011 0.30000001 0.75000012 0.30000001 0.80000013 + 0.30000001 0.85000014 0.30000001 0.90000015 0.30000001 0.95000017 0.30000001 1.000000119209 + 0.30000001 0 0.35000002 0.050000001 0.35000002 0.1 0.35000002 0.15000001 0.35000002 + 0.2 0.35000002 0.25 0.35000002 0.30000001 0.35000002 0.35000002 0.35000002 0.40000004 + 0.35000002 0.45000005 0.35000002 0.50000006 0.35000002 0.55000007 0.35000002 0.60000008 + 0.35000002 0.6500001 0.35000002 0.70000011 0.35000002 0.75000012 0.35000002 0.80000013 + 0.35000002 0.85000014 0.35000002 0.90000015 0.35000002 0.95000017 0.35000002 1.000000119209 + 0.35000002 0 0.40000004 0.050000001 0.40000004 0.1 0.40000004 0.15000001 0.40000004 + 0.2 0.40000004 0.25 0.40000004 0.30000001 0.40000004 0.35000002 0.40000004 0.40000004 + 0.40000004 0.45000005 0.40000004 0.50000006 0.40000004 0.55000007 0.40000004 0.60000008 + 0.40000004 0.6500001 0.40000004 0.70000011 0.40000004 0.75000012 0.40000004 0.80000013 + 0.40000004 0.85000014 0.40000004 0.90000015 0.40000004 0.95000017 0.40000004 1.000000119209 + 0.40000004 0 0.45000005 0.050000001 0.45000005 0.1 0.45000005 0.15000001 0.45000005 + 0.2 0.45000005 0.25 0.45000005 0.30000001 0.45000005 0.35000002 0.45000005 0.40000004 + 0.45000005 0.45000005 0.45000005 0.50000006 0.45000005 0.55000007 0.45000005 0.60000008 + 0.45000005 0.6500001 0.45000005 0.70000011 0.45000005 0.75000012 0.45000005 0.80000013 + 0.45000005 0.85000014 0.45000005 0.90000015 0.45000005 0.95000017 0.45000005 1.000000119209 + 0.45000005 0 0.50000006 0.050000001 0.50000006 0.1 0.50000006 0.15000001 0.50000006 + 0.2 0.50000006 0.25 0.50000006 0.30000001 0.50000006 0.35000002 0.50000006 0.40000004 + 0.50000006 0.45000005 0.50000006 0.50000006 0.50000006 0.55000007 0.50000006 0.60000008 + 0.50000006 0.6500001 0.50000006 0.70000011 0.50000006 0.75000012 0.50000006 0.80000013 + 0.50000006 0.85000014 0.50000006 0.90000015 0.50000006 0.95000017 0.50000006 1.000000119209 + 0.50000006 0 0.55000007 0.050000001 0.55000007 0.1 0.55000007 0.15000001 0.55000007 + 0.2 0.55000007 0.25 0.55000007 0.30000001 0.55000007 0.35000002 0.55000007 0.40000004 + 0.55000007 0.45000005 0.55000007 0.50000006 0.55000007 0.55000007 0.55000007 0.60000008 + 0.55000007 0.6500001 0.55000007 0.70000011 0.55000007 0.75000012 0.55000007 0.80000013 + 0.55000007 0.85000014 0.55000007 0.90000015 0.55000007 0.95000017 0.55000007 1.000000119209 + 0.55000007 0 0.60000008 0.050000001 0.60000008 0.1 0.60000008 0.15000001 0.60000008 + 0.2 0.60000008 0.25 0.60000008 0.30000001 0.60000008 0.35000002 0.60000008 0.40000004 + 0.60000008 0.45000005 0.60000008 0.50000006 0.60000008 0.55000007 0.60000008 0.60000008 + 0.60000008 0.6500001 0.60000008 0.70000011 0.60000008 0.75000012 0.60000008 0.80000013 + 0.60000008 0.85000014 0.60000008 0.90000015 0.60000008; + setAttr ".uvst[0].uvsp[250:438]" 0.95000017 0.60000008 1.000000119209 0.60000008 + 0 0.6500001 0.050000001 0.6500001 0.1 0.6500001 0.15000001 0.6500001 0.2 0.6500001 + 0.25 0.6500001 0.30000001 0.6500001 0.35000002 0.6500001 0.40000004 0.6500001 0.45000005 + 0.6500001 0.50000006 0.6500001 0.55000007 0.6500001 0.60000008 0.6500001 0.6500001 + 0.6500001 0.70000011 0.6500001 0.75000012 0.6500001 0.80000013 0.6500001 0.85000014 + 0.6500001 0.90000015 0.6500001 0.95000017 0.6500001 1.000000119209 0.6500001 0 0.70000011 + 0.050000001 0.70000011 0.1 0.70000011 0.15000001 0.70000011 0.2 0.70000011 0.25 0.70000011 + 0.30000001 0.70000011 0.35000002 0.70000011 0.40000004 0.70000011 0.45000005 0.70000011 + 0.50000006 0.70000011 0.55000007 0.70000011 0.60000008 0.70000011 0.6500001 0.70000011 + 0.70000011 0.70000011 0.75000012 0.70000011 0.80000013 0.70000011 0.85000014 0.70000011 + 0.90000015 0.70000011 0.95000017 0.70000011 1.000000119209 0.70000011 0 0.75000012 + 0.050000001 0.75000012 0.1 0.75000012 0.15000001 0.75000012 0.2 0.75000012 0.25 0.75000012 + 0.30000001 0.75000012 0.35000002 0.75000012 0.40000004 0.75000012 0.45000005 0.75000012 + 0.50000006 0.75000012 0.55000007 0.75000012 0.60000008 0.75000012 0.6500001 0.75000012 + 0.70000011 0.75000012 0.75000012 0.75000012 0.80000013 0.75000012 0.85000014 0.75000012 + 0.90000015 0.75000012 0.95000017 0.75000012 1.000000119209 0.75000012 0 0.80000013 + 0.050000001 0.80000013 0.1 0.80000013 0.15000001 0.80000013 0.2 0.80000013 0.25 0.80000013 + 0.30000001 0.80000013 0.35000002 0.80000013 0.40000004 0.80000013 0.45000005 0.80000013 + 0.50000006 0.80000013 0.55000007 0.80000013 0.60000008 0.80000013 0.6500001 0.80000013 + 0.70000011 0.80000013 0.75000012 0.80000013 0.80000013 0.80000013 0.85000014 0.80000013 + 0.90000015 0.80000013 0.95000017 0.80000013 1.000000119209 0.80000013 0 0.85000014 + 0.050000001 0.85000014 0.1 0.85000014 0.15000001 0.85000014 0.2 0.85000014 0.25 0.85000014 + 0.30000001 0.85000014 0.35000002 0.85000014 0.40000004 0.85000014 0.45000005 0.85000014 + 0.50000006 0.85000014 0.55000007 0.85000014 0.60000008 0.85000014 0.6500001 0.85000014 + 0.70000011 0.85000014 0.75000012 0.85000014 0.80000013 0.85000014 0.85000014 0.85000014 + 0.90000015 0.85000014 0.95000017 0.85000014 1.000000119209 0.85000014 0 0.90000015 + 0.050000001 0.90000015 0.1 0.90000015 0.15000001 0.90000015 0.2 0.90000015 0.25 0.90000015 + 0.30000001 0.90000015 0.35000002 0.90000015 0.40000004 0.90000015 0.45000005 0.90000015 + 0.50000006 0.90000015 0.55000007 0.90000015 0.60000008 0.90000015 0.6500001 0.90000015 + 0.70000011 0.90000015 0.75000012 0.90000015 0.80000013 0.90000015 0.85000014 0.90000015 + 0.90000015 0.90000015 0.95000017 0.90000015 1.000000119209 0.90000015 0 0.95000017 + 0.050000001 0.95000017 0.1 0.95000017 0.15000001 0.95000017 0.2 0.95000017 0.25 0.95000017 + 0.30000001 0.95000017 0.35000002 0.95000017 0.40000004 0.95000017 0.45000005 0.95000017 + 0.50000006 0.95000017 0.55000007 0.95000017 0.60000008 0.95000017 0.6500001 0.95000017 + 0.70000011 0.95000017 0.75000012 0.95000017 0.80000013 0.95000017 0.85000014 0.95000017 + 0.90000015 0.95000017 0.95000017 0.95000017 1.000000119209 0.95000017 0.025 0 0.075000003 + 0 0.125 0 0.17500001 0 0.22500001 0 0.27500001 0 0.32500002 0 0.375 0 0.42500001 + 0 0.47500002 0 0.52499998 0 0.57499999 0 0.625 0 0.67500001 0 0.72499996 0 0.77499998 + 0 0.82499999 0 0.875 0 0.92500001 0 0.97499996 0 0.025 1 0.075000003 1 0.125 1 0.17500001 + 1 0.22500001 1 0.27500001 1 0.32500002 1 0.375 1 0.42500001 1 0.47500002 1 0.52499998 + 1 0.57499999 1 0.625 1 0.67500001 1 0.72499996 1 0.77499998 1 0.82499999 1 0.875 + 1 0.92500001 1 0.97499996 1; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr -s 382 ".vt"; + setAttr ".vt[0:165]" 0.14877813 -0.98768836 -0.048340943 0.12655823 -0.98768836 -0.091949932 + 0.091949932 -0.98768836 -0.12655823 0.048340935 -0.98768836 -0.14877811 0 -0.98768836 -0.15643455 + -0.048340935 -0.98768836 -0.1487781 -0.091949917 -0.98768836 -0.1265582 -0.12655818 -0.98768836 -0.091949902 + -0.14877807 -0.98768836 -0.048340924 -0.15643452 -0.98768836 0 -0.14877807 -0.98768836 0.048340924 + -0.12655818 -0.98768836 0.091949895 -0.091949895 -0.98768836 0.12655817 -0.048340924 -0.98768836 0.14877805 + -4.6621107e-09 -0.98768836 0.15643449 0.048340909 -0.98768836 0.14877804 0.09194988 -0.98768836 0.12655815 + 0.12655815 -0.98768836 0.091949888 0.14877804 -0.98768836 0.048340913 0.15643448 -0.98768836 0 + 0.29389283 -0.95105654 -0.095491566 0.25000018 -0.95105654 -0.18163574 0.18163574 -0.95105654 -0.25000015 + 0.095491551 -0.95105654 -0.2938928 0 -0.95105654 -0.30901715 -0.095491551 -0.95105654 -0.29389277 + -0.18163571 -0.95105654 -0.25000009 -0.25000009 -0.95105654 -0.18163569 -0.29389271 -0.95105654 -0.095491529 + -0.30901706 -0.95105654 0 -0.29389271 -0.95105654 0.095491529 -0.25000006 -0.95105654 0.18163568 + -0.18163568 -0.95105654 0.25000006 -0.095491529 -0.95105654 0.29389268 -9.2094243e-09 -0.95105654 0.30901703 + 0.095491499 -0.95105654 0.29389265 0.18163563 -0.95105654 0.25000003 0.25 -0.95105654 0.18163565 + 0.29389265 -0.95105654 0.095491506 0.309017 -0.95105654 0 0.43177092 -0.89100653 -0.14029087 + 0.36728629 -0.89100653 -0.2668491 0.2668491 -0.89100653 -0.36728626 0.14029086 -0.89100653 -0.43177086 + 0 -0.89100653 -0.45399073 -0.14029086 -0.89100653 -0.43177083 -0.26684904 -0.89100653 -0.36728618 + -0.36728615 -0.89100653 -0.26684901 -0.43177077 -0.89100653 -0.14029081 -0.45399064 -0.89100653 0 + -0.43177077 -0.89100653 0.14029081 -0.36728612 -0.89100653 0.26684898 -0.26684898 -0.89100653 0.36728612 + -0.14029081 -0.89100653 0.43177071 -1.3529972e-08 -0.89100653 0.45399058 0.14029078 -0.89100653 0.43177068 + 0.26684892 -0.89100653 0.36728609 0.36728606 -0.89100653 0.26684895 0.43177065 -0.89100653 0.1402908 + 0.45399052 -0.89100653 0 0.55901736 -0.809017 -0.18163574 0.47552857 -0.809017 -0.34549171 + 0.34549171 -0.809017 -0.47552854 0.18163572 -0.809017 -0.5590173 0 -0.809017 -0.58778554 + -0.18163572 -0.809017 -0.55901724 -0.34549165 -0.809017 -0.47552842 -0.47552839 -0.809017 -0.34549159 + -0.55901712 -0.809017 -0.18163566 -0.58778536 -0.809017 0 -0.55901712 -0.809017 0.18163566 + -0.47552836 -0.809017 0.34549156 -0.34549156 -0.809017 0.47552833 -0.18163566 -0.809017 0.55901706 + -1.7517365e-08 -0.809017 0.5877853 0.18163562 -0.809017 0.55901706 0.3454915 -0.809017 0.4755283 + 0.47552827 -0.809017 0.34549153 0.559017 -0.809017 0.18163563 0.58778524 -0.809017 0 + 0.67249894 -0.70710677 -0.21850814 0.57206178 -0.70710677 -0.41562718 0.41562718 -0.70710677 -0.57206172 + 0.21850812 -0.70710677 -0.67249888 0 -0.70710677 -0.70710713 -0.21850812 -0.70710677 -0.67249882 + -0.41562709 -0.70710677 -0.5720616 -0.57206154 -0.70710677 -0.41562706 -0.6724987 -0.70710677 -0.21850805 + -0.70710695 -0.70710677 0 -0.6724987 -0.70710677 0.21850805 -0.57206154 -0.70710677 0.415627 + -0.415627 -0.70710677 0.57206148 -0.21850805 -0.70710677 0.67249858 -2.1073424e-08 -0.70710677 0.70710683 + 0.21850799 -0.70710677 0.67249858 0.41562691 -0.70710677 0.57206142 0.57206142 -0.70710677 0.41562697 + 0.67249852 -0.70710677 0.21850802 0.70710677 -0.70710677 0 0.7694214 -0.58778524 -0.25000015 + 0.65450895 -0.58778524 -0.47552854 0.47552854 -0.58778524 -0.65450889 0.25000012 -0.58778524 -0.76942128 + 0 -0.58778524 -0.80901736 -0.25000012 -0.58778524 -0.76942122 -0.47552845 -0.58778524 -0.65450877 + -0.65450871 -0.58778524 -0.47552839 -0.7694211 -0.58778524 -0.25000006 -0.80901718 -0.58778524 0 + -0.7694211 -0.58778524 0.25000006 -0.65450865 -0.58778524 0.47552836 -0.47552836 -0.58778524 0.65450859 + -0.25000006 -0.58778524 0.76942098 -2.4110586e-08 -0.58778524 0.80901712 0.24999999 -0.58778524 0.76942098 + 0.47552827 -0.58778524 0.65450853 0.65450853 -0.58778524 0.4755283 0.76942092 -0.58778524 0.25 + 0.809017 -0.58778524 0 0.8473981 -0.45399052 -0.27533633 0.72083992 -0.45399052 -0.5237208 + 0.5237208 -0.45399052 -0.72083986 0.2753363 -0.45399052 -0.84739798 0 -0.45399052 -0.89100695 + -0.2753363 -0.45399052 -0.84739798 -0.52372068 -0.45399052 -0.72083968 -0.72083962 -0.45399052 -0.52372062 + -0.8473978 -0.45399052 -0.27533621 -0.89100677 -0.45399052 0 -0.8473978 -0.45399052 0.27533621 + -0.72083962 -0.45399052 0.52372062 -0.52372062 -0.45399052 0.72083956 -0.27533621 -0.45399052 0.84739769 + -2.6554064e-08 -0.45399052 0.89100665 0.27533615 -0.45399052 0.84739763 0.5237205 -0.45399052 0.7208395 + 0.72083944 -0.45399052 0.52372056 0.84739757 -0.45399052 0.27533618 0.89100653 -0.45399052 0 + 0.90450913 -0.30901697 -0.2938928 0.7694214 -0.30901697 -0.55901736 0.55901736 -0.30901697 -0.76942134 + 0.29389277 -0.30901697 -0.90450901 0 -0.30901697 -0.95105702 -0.29389277 -0.30901697 -0.90450895 + -0.55901724 -0.30901697 -0.76942122 -0.76942116 -0.30901697 -0.55901718 -0.90450877 -0.30901697 -0.29389271 + -0.95105678 -0.30901697 0 -0.90450877 -0.30901697 0.29389271 -0.7694211 -0.30901697 0.55901712 + -0.55901712 -0.30901697 0.76942104 -0.29389271 -0.30901697 0.90450865 -2.8343694e-08 -0.30901697 0.95105666 + 0.29389262 -0.30901697 0.90450859 0.559017 -0.30901697 0.76942098 0.76942092 -0.30901697 0.55901706 + 0.90450853 -0.30901697 0.29389265 0.95105654 -0.30901697 0 0.93934804 -0.15643437 -0.30521268 + 0.79905719 -0.15643437 -0.580549 0.580549 -0.15643437 -0.79905713 0.30521265 -0.15643437 -0.93934792 + 0 -0.15643437 -0.98768884 -0.30521265 -0.15643437 -0.93934786; + setAttr ".vt[166:331]" -0.58054888 -0.15643437 -0.79905695 -0.79905689 -0.15643437 -0.58054882 + -0.93934768 -0.15643437 -0.30521256 -0.9876886 -0.15643437 0 -0.93934768 -0.15643437 0.30521256 + -0.79905683 -0.15643437 0.58054876 -0.58054876 -0.15643437 0.79905677 -0.30521256 -0.15643437 0.93934757 + -2.9435407e-08 -0.15643437 0.98768848 0.30521247 -0.15643437 0.93934757 0.58054864 -0.15643437 0.79905671 + 0.79905665 -0.15643437 0.5805487 0.93934751 -0.15643437 0.3052125 0.98768836 -0.15643437 0 + 0.95105714 0 -0.30901718 0.80901754 0 -0.5877856 0.5877856 0 -0.80901748 0.30901715 0 -0.95105702 + 0 0 -1.000000476837 -0.30901715 0 -0.95105696 -0.58778548 0 -0.8090173 -0.80901724 0 -0.58778542 + -0.95105678 0 -0.30901706 -1.000000238419 0 0 -0.95105678 0 0.30901706 -0.80901718 0 0.58778536 + -0.58778536 0 0.80901712 -0.30901706 0 0.95105666 -2.9802322e-08 0 1.000000119209 + 0.30901697 0 0.9510566 0.58778524 0 0.80901706 0.809017 0 0.5877853 0.95105654 0 0.309017 + 1 0 0 0.93934804 0.15643437 -0.30521268 0.79905719 0.15643437 -0.580549 0.580549 0.15643437 -0.79905713 + 0.30521265 0.15643437 -0.93934792 0 0.15643437 -0.98768884 -0.30521265 0.15643437 -0.93934786 + -0.58054888 0.15643437 -0.79905695 -0.79905689 0.15643437 -0.58054882 -0.93934768 0.15643437 -0.30521256 + -0.9876886 0.15643437 0 -0.93934768 0.15643437 0.30521256 -0.79905683 0.15643437 0.58054876 + -0.58054876 0.15643437 0.79905677 -0.30521256 0.15643437 0.93934757 -2.9435407e-08 0.15643437 0.98768848 + 0.30521247 0.15643437 0.93934757 0.58054864 0.15643437 0.79905671 0.79905665 0.15643437 0.5805487 + 0.93934751 0.15643437 0.3052125 0.98768836 0.15643437 0 0.90450913 0.30901697 -0.2938928 + 0.7694214 0.30901697 -0.55901736 0.55901736 0.30901697 -0.76942134 0.29389277 0.30901697 -0.90450901 + 0 0.30901697 -0.95105702 -0.29389277 0.30901697 -0.90450895 -0.55901724 0.30901697 -0.76942122 + -0.76942116 0.30901697 -0.55901718 -0.90450877 0.30901697 -0.29389271 -0.95105678 0.30901697 0 + -0.90450877 0.30901697 0.29389271 -0.7694211 0.30901697 0.55901712 -0.55901712 0.30901697 0.76942104 + -0.29389271 0.30901697 0.90450865 -2.8343694e-08 0.30901697 0.95105666 0.29389262 0.30901697 0.90450859 + 0.559017 0.30901697 0.76942098 0.76942092 0.30901697 0.55901706 0.90450853 0.30901697 0.29389265 + 0.95105654 0.30901697 0 0.8473981 0.45399052 -0.27533633 0.72083992 0.45399052 -0.5237208 + 0.5237208 0.45399052 -0.72083986 0.2753363 0.45399052 -0.84739798 0 0.45399052 -0.89100695 + -0.2753363 0.45399052 -0.84739798 -0.52372068 0.45399052 -0.72083968 -0.72083962 0.45399052 -0.52372062 + -0.8473978 0.45399052 -0.27533621 -0.89100677 0.45399052 0 -0.8473978 0.45399052 0.27533621 + -0.72083962 0.45399052 0.52372062 -0.52372062 0.45399052 0.72083956 -0.27533621 0.45399052 0.84739769 + -2.6554064e-08 0.45399052 0.89100665 0.27533615 0.45399052 0.84739763 0.5237205 0.45399052 0.7208395 + 0.72083944 0.45399052 0.52372056 0.84739757 0.45399052 0.27533618 0.89100653 0.45399052 0 + 0.7694214 0.58778524 -0.25000015 0.65450895 0.58778524 -0.47552854 0.47552854 0.58778524 -0.65450889 + 0.25000012 0.58778524 -0.76942128 0 0.58778524 -0.80901736 -0.25000012 0.58778524 -0.76942122 + -0.47552845 0.58778524 -0.65450877 -0.65450871 0.58778524 -0.47552839 -0.7694211 0.58778524 -0.25000006 + -0.80901718 0.58778524 0 -0.7694211 0.58778524 0.25000006 -0.65450865 0.58778524 0.47552836 + -0.47552836 0.58778524 0.65450859 -0.25000006 0.58778524 0.76942098 -2.4110586e-08 0.58778524 0.80901712 + 0.24999999 0.58778524 0.76942098 0.47552827 0.58778524 0.65450853 0.65450853 0.58778524 0.4755283 + 0.76942092 0.58778524 0.25 0.809017 0.58778524 0 0.67249894 0.70710677 -0.21850814 + 0.57206178 0.70710677 -0.41562718 0.41562718 0.70710677 -0.57206172 0.21850812 0.70710677 -0.67249888 + 0 0.70710677 -0.70710713 -0.21850812 0.70710677 -0.67249882 -0.41562709 0.70710677 -0.5720616 + -0.57206154 0.70710677 -0.41562706 -0.6724987 0.70710677 -0.21850805 -0.70710695 0.70710677 0 + -0.6724987 0.70710677 0.21850805 -0.57206154 0.70710677 0.415627 -0.415627 0.70710677 0.57206148 + -0.21850805 0.70710677 0.67249858 -2.1073424e-08 0.70710677 0.70710683 0.21850799 0.70710677 0.67249858 + 0.41562691 0.70710677 0.57206142 0.57206142 0.70710677 0.41562697 0.67249852 0.70710677 0.21850802 + 0.70710677 0.70710677 0 0.55901736 0.809017 -0.18163574 0.47552857 0.809017 -0.34549171 + 0.34549171 0.809017 -0.47552854 0.18163572 0.809017 -0.5590173 0 0.809017 -0.58778554 + -0.18163572 0.809017 -0.55901724 -0.34549165 0.809017 -0.47552842 -0.47552839 0.809017 -0.34549159 + -0.55901712 0.809017 -0.18163566 -0.58778536 0.809017 0 -0.55901712 0.809017 0.18163566 + -0.47552836 0.809017 0.34549156 -0.34549156 0.809017 0.47552833 -0.18163566 0.809017 0.55901706 + -1.7517365e-08 0.809017 0.5877853 0.18163562 0.809017 0.55901706 0.3454915 0.809017 0.4755283 + 0.47552827 0.809017 0.34549153 0.559017 0.809017 0.18163563 0.58778524 0.809017 0 + 0.43177092 0.89100653 -0.14029087 0.36728629 0.89100653 -0.2668491 0.2668491 0.89100653 -0.36728626 + 0.14029086 0.89100653 -0.43177086 0 0.89100653 -0.45399073 -0.14029086 0.89100653 -0.43177083 + -0.26684904 0.89100653 -0.36728618 -0.36728615 0.89100653 -0.26684901 -0.43177077 0.89100653 -0.14029081 + -0.45399064 0.89100653 0 -0.43177077 0.89100653 0.14029081 -0.36728612 0.89100653 0.26684898; + setAttr ".vt[332:381]" -0.26684898 0.89100653 0.36728612 -0.14029081 0.89100653 0.43177071 + -1.3529972e-08 0.89100653 0.45399058 0.14029078 0.89100653 0.43177068 0.26684892 0.89100653 0.36728609 + 0.36728606 0.89100653 0.26684895 0.43177065 0.89100653 0.1402908 0.45399052 0.89100653 0 + 0.29389283 0.95105654 -0.095491566 0.25000018 0.95105654 -0.18163574 0.18163574 0.95105654 -0.25000015 + 0.095491551 0.95105654 -0.2938928 0 0.95105654 -0.30901715 -0.095491551 0.95105654 -0.29389277 + -0.18163571 0.95105654 -0.25000009 -0.25000009 0.95105654 -0.18163569 -0.29389271 0.95105654 -0.095491529 + -0.30901706 0.95105654 0 -0.29389271 0.95105654 0.095491529 -0.25000006 0.95105654 0.18163568 + -0.18163568 0.95105654 0.25000006 -0.095491529 0.95105654 0.29389268 -9.2094243e-09 0.95105654 0.30901703 + 0.095491499 0.95105654 0.29389265 0.18163563 0.95105654 0.25000003 0.25 0.95105654 0.18163565 + 0.29389265 0.95105654 0.095491506 0.309017 0.95105654 0 0.14877813 0.98768836 -0.048340943 + 0.12655823 0.98768836 -0.091949932 0.091949932 0.98768836 -0.12655823 0.048340935 0.98768836 -0.14877811 + 0 0.98768836 -0.15643455 -0.048340935 0.98768836 -0.1487781 -0.091949917 0.98768836 -0.1265582 + -0.12655818 0.98768836 -0.091949902 -0.14877807 0.98768836 -0.048340924 -0.15643452 0.98768836 0 + -0.14877807 0.98768836 0.048340924 -0.12655818 0.98768836 0.091949895 -0.091949895 0.98768836 0.12655817 + -0.048340924 0.98768836 0.14877805 -4.6621107e-09 0.98768836 0.15643449 0.048340909 0.98768836 0.14877804 + 0.09194988 0.98768836 0.12655815 0.12655815 0.98768836 0.091949888 0.14877804 0.98768836 0.048340913 + 0.15643448 0.98768836 0 0 -1 0 0 1 0; + setAttr -s 780 ".ed"; + setAttr ".ed[0:165]" 0 1 1 1 2 1 2 3 1 3 4 1 4 5 1 5 6 1 6 7 1 7 8 1 8 9 1 + 9 10 1 10 11 1 11 12 1 12 13 1 13 14 1 14 15 1 15 16 1 16 17 1 17 18 1 18 19 1 19 0 1 + 20 21 1 21 22 1 22 23 1 23 24 1 24 25 1 25 26 1 26 27 1 27 28 1 28 29 1 29 30 1 30 31 1 + 31 32 1 32 33 1 33 34 1 34 35 1 35 36 1 36 37 1 37 38 1 38 39 1 39 20 1 40 41 1 41 42 1 + 42 43 1 43 44 1 44 45 1 45 46 1 46 47 1 47 48 1 48 49 1 49 50 1 50 51 1 51 52 1 52 53 1 + 53 54 1 54 55 1 55 56 1 56 57 1 57 58 1 58 59 1 59 40 1 60 61 1 61 62 1 62 63 1 63 64 1 + 64 65 1 65 66 1 66 67 1 67 68 1 68 69 1 69 70 1 70 71 1 71 72 1 72 73 1 73 74 1 74 75 1 + 75 76 1 76 77 1 77 78 1 78 79 1 79 60 1 80 81 1 81 82 1 82 83 1 83 84 1 84 85 1 85 86 1 + 86 87 1 87 88 1 88 89 1 89 90 1 90 91 1 91 92 1 92 93 1 93 94 1 94 95 1 95 96 1 96 97 1 + 97 98 1 98 99 1 99 80 1 100 101 1 101 102 1 102 103 1 103 104 1 104 105 1 105 106 1 + 106 107 1 107 108 1 108 109 1 109 110 1 110 111 1 111 112 1 112 113 1 113 114 1 114 115 1 + 115 116 1 116 117 1 117 118 1 118 119 1 119 100 1 120 121 1 121 122 1 122 123 1 123 124 1 + 124 125 1 125 126 1 126 127 1 127 128 1 128 129 1 129 130 1 130 131 1 131 132 1 132 133 1 + 133 134 1 134 135 1 135 136 1 136 137 1 137 138 1 138 139 1 139 120 1 140 141 1 141 142 1 + 142 143 1 143 144 1 144 145 1 145 146 1 146 147 1 147 148 1 148 149 1 149 150 1 150 151 1 + 151 152 1 152 153 1 153 154 1 154 155 1 155 156 1 156 157 1 157 158 1 158 159 1 159 140 1 + 160 161 1 161 162 1 162 163 1 163 164 1 164 165 1 165 166 1; + setAttr ".ed[166:331]" 166 167 1 167 168 1 168 169 1 169 170 1 170 171 1 171 172 1 + 172 173 1 173 174 1 174 175 1 175 176 1 176 177 1 177 178 1 178 179 1 179 160 1 180 181 1 + 181 182 1 182 183 1 183 184 1 184 185 1 185 186 1 186 187 1 187 188 1 188 189 1 189 190 1 + 190 191 1 191 192 1 192 193 1 193 194 1 194 195 1 195 196 1 196 197 1 197 198 1 198 199 1 + 199 180 1 200 201 1 201 202 1 202 203 1 203 204 1 204 205 1 205 206 1 206 207 1 207 208 1 + 208 209 1 209 210 1 210 211 1 211 212 1 212 213 1 213 214 1 214 215 1 215 216 1 216 217 1 + 217 218 1 218 219 1 219 200 1 220 221 1 221 222 1 222 223 1 223 224 1 224 225 1 225 226 1 + 226 227 1 227 228 1 228 229 1 229 230 1 230 231 1 231 232 1 232 233 1 233 234 1 234 235 1 + 235 236 1 236 237 1 237 238 1 238 239 1 239 220 1 240 241 1 241 242 1 242 243 1 243 244 1 + 244 245 1 245 246 1 246 247 1 247 248 1 248 249 1 249 250 1 250 251 1 251 252 1 252 253 1 + 253 254 1 254 255 1 255 256 1 256 257 1 257 258 1 258 259 1 259 240 1 260 261 1 261 262 1 + 262 263 1 263 264 1 264 265 1 265 266 1 266 267 1 267 268 1 268 269 1 269 270 1 270 271 1 + 271 272 1 272 273 1 273 274 1 274 275 1 275 276 1 276 277 1 277 278 1 278 279 1 279 260 1 + 280 281 1 281 282 1 282 283 1 283 284 1 284 285 1 285 286 1 286 287 1 287 288 1 288 289 1 + 289 290 1 290 291 1 291 292 1 292 293 1 293 294 1 294 295 1 295 296 1 296 297 1 297 298 1 + 298 299 1 299 280 1 300 301 1 301 302 1 302 303 1 303 304 1 304 305 1 305 306 1 306 307 1 + 307 308 1 308 309 1 309 310 1 310 311 1 311 312 1 312 313 1 313 314 1 314 315 1 315 316 1 + 316 317 1 317 318 1 318 319 1 319 300 1 320 321 1 321 322 1 322 323 1 323 324 1 324 325 1 + 325 326 1 326 327 1 327 328 1 328 329 1 329 330 1 330 331 1 331 332 1; + setAttr ".ed[332:497]" 332 333 1 333 334 1 334 335 1 335 336 1 336 337 1 337 338 1 + 338 339 1 339 320 1 340 341 1 341 342 1 342 343 1 343 344 1 344 345 1 345 346 1 346 347 1 + 347 348 1 348 349 1 349 350 1 350 351 1 351 352 1 352 353 1 353 354 1 354 355 1 355 356 1 + 356 357 1 357 358 1 358 359 1 359 340 1 360 361 1 361 362 1 362 363 1 363 364 1 364 365 1 + 365 366 1 366 367 1 367 368 1 368 369 1 369 370 1 370 371 1 371 372 1 372 373 1 373 374 1 + 374 375 1 375 376 1 376 377 1 377 378 1 378 379 1 379 360 1 0 20 1 1 21 1 2 22 1 + 3 23 1 4 24 1 5 25 1 6 26 1 7 27 1 8 28 1 9 29 1 10 30 1 11 31 1 12 32 1 13 33 1 + 14 34 1 15 35 1 16 36 1 17 37 1 18 38 1 19 39 1 20 40 1 21 41 1 22 42 1 23 43 1 24 44 1 + 25 45 1 26 46 1 27 47 1 28 48 1 29 49 1 30 50 1 31 51 1 32 52 1 33 53 1 34 54 1 35 55 1 + 36 56 1 37 57 1 38 58 1 39 59 1 40 60 1 41 61 1 42 62 1 43 63 1 44 64 1 45 65 1 46 66 1 + 47 67 1 48 68 1 49 69 1 50 70 1 51 71 1 52 72 1 53 73 1 54 74 1 55 75 1 56 76 1 57 77 1 + 58 78 1 59 79 1 60 80 1 61 81 1 62 82 1 63 83 1 64 84 1 65 85 1 66 86 1 67 87 1 68 88 1 + 69 89 1 70 90 1 71 91 1 72 92 1 73 93 1 74 94 1 75 95 1 76 96 1 77 97 1 78 98 1 79 99 1 + 80 100 1 81 101 1 82 102 1 83 103 1 84 104 1 85 105 1 86 106 1 87 107 1 88 108 1 + 89 109 1 90 110 1 91 111 1 92 112 1 93 113 1 94 114 1 95 115 1 96 116 1 97 117 1 + 98 118 1 99 119 1 100 120 1 101 121 1 102 122 1 103 123 1 104 124 1 105 125 1 106 126 1 + 107 127 1 108 128 1 109 129 1 110 130 1 111 131 1 112 132 1 113 133 1 114 134 1 115 135 1 + 116 136 1 117 137 1; + setAttr ".ed[498:663]" 118 138 1 119 139 1 120 140 1 121 141 1 122 142 1 123 143 1 + 124 144 1 125 145 1 126 146 1 127 147 1 128 148 1 129 149 1 130 150 1 131 151 1 132 152 1 + 133 153 1 134 154 1 135 155 1 136 156 1 137 157 1 138 158 1 139 159 1 140 160 1 141 161 1 + 142 162 1 143 163 1 144 164 1 145 165 1 146 166 1 147 167 1 148 168 1 149 169 1 150 170 1 + 151 171 1 152 172 1 153 173 1 154 174 1 155 175 1 156 176 1 157 177 1 158 178 1 159 179 1 + 160 180 1 161 181 1 162 182 1 163 183 1 164 184 1 165 185 1 166 186 1 167 187 1 168 188 1 + 169 189 1 170 190 1 171 191 1 172 192 1 173 193 1 174 194 1 175 195 1 176 196 1 177 197 1 + 178 198 1 179 199 1 180 200 1 181 201 1 182 202 1 183 203 1 184 204 1 185 205 1 186 206 1 + 187 207 1 188 208 1 189 209 1 190 210 1 191 211 1 192 212 1 193 213 1 194 214 1 195 215 1 + 196 216 1 197 217 1 198 218 1 199 219 1 200 220 1 201 221 1 202 222 1 203 223 1 204 224 1 + 205 225 1 206 226 1 207 227 1 208 228 1 209 229 1 210 230 1 211 231 1 212 232 1 213 233 1 + 214 234 1 215 235 1 216 236 1 217 237 1 218 238 1 219 239 1 220 240 1 221 241 1 222 242 1 + 223 243 1 224 244 1 225 245 1 226 246 1 227 247 1 228 248 1 229 249 1 230 250 1 231 251 1 + 232 252 1 233 253 1 234 254 1 235 255 1 236 256 1 237 257 1 238 258 1 239 259 1 240 260 1 + 241 261 1 242 262 1 243 263 1 244 264 1 245 265 1 246 266 1 247 267 1 248 268 1 249 269 1 + 250 270 1 251 271 1 252 272 1 253 273 1 254 274 1 255 275 1 256 276 1 257 277 1 258 278 1 + 259 279 1 260 280 1 261 281 1 262 282 1 263 283 1 264 284 1 265 285 1 266 286 1 267 287 1 + 268 288 1 269 289 1 270 290 1 271 291 1 272 292 1 273 293 1 274 294 1 275 295 1 276 296 1 + 277 297 1 278 298 1 279 299 1 280 300 1 281 301 1 282 302 1 283 303 1; + setAttr ".ed[664:779]" 284 304 1 285 305 1 286 306 1 287 307 1 288 308 1 289 309 1 + 290 310 1 291 311 1 292 312 1 293 313 1 294 314 1 295 315 1 296 316 1 297 317 1 298 318 1 + 299 319 1 300 320 1 301 321 1 302 322 1 303 323 1 304 324 1 305 325 1 306 326 1 307 327 1 + 308 328 1 309 329 1 310 330 1 311 331 1 312 332 1 313 333 1 314 334 1 315 335 1 316 336 1 + 317 337 1 318 338 1 319 339 1 320 340 1 321 341 1 322 342 1 323 343 1 324 344 1 325 345 1 + 326 346 1 327 347 1 328 348 1 329 349 1 330 350 1 331 351 1 332 352 1 333 353 1 334 354 1 + 335 355 1 336 356 1 337 357 1 338 358 1 339 359 1 340 360 1 341 361 1 342 362 1 343 363 1 + 344 364 1 345 365 1 346 366 1 347 367 1 348 368 1 349 369 1 350 370 1 351 371 1 352 372 1 + 353 373 1 354 374 1 355 375 1 356 376 1 357 377 1 358 378 1 359 379 1 380 0 1 380 1 1 + 380 2 1 380 3 1 380 4 1 380 5 1 380 6 1 380 7 1 380 8 1 380 9 1 380 10 1 380 11 1 + 380 12 1 380 13 1 380 14 1 380 15 1 380 16 1 380 17 1 380 18 1 380 19 1 360 381 1 + 361 381 1 362 381 1 363 381 1 364 381 1 365 381 1 366 381 1 367 381 1 368 381 1 369 381 1 + 370 381 1 371 381 1 372 381 1 373 381 1 374 381 1 375 381 1 376 381 1 377 381 1 378 381 1 + 379 381 1; + setAttr -s 400 -ch 1560 ".fc[0:399]" -type "polyFaces" + f 4 0 381 -21 -381 + mu 0 4 0 1 22 21 + f 4 1 382 -22 -382 + mu 0 4 1 2 23 22 + f 4 2 383 -23 -383 + mu 0 4 2 3 24 23 + f 4 3 384 -24 -384 + mu 0 4 3 4 25 24 + f 4 4 385 -25 -385 + mu 0 4 4 5 26 25 + f 4 5 386 -26 -386 + mu 0 4 5 6 27 26 + f 4 6 387 -27 -387 + mu 0 4 6 7 28 27 + f 4 7 388 -28 -388 + mu 0 4 7 8 29 28 + f 4 8 389 -29 -389 + mu 0 4 8 9 30 29 + f 4 9 390 -30 -390 + mu 0 4 9 10 31 30 + f 4 10 391 -31 -391 + mu 0 4 10 11 32 31 + f 4 11 392 -32 -392 + mu 0 4 11 12 33 32 + f 4 12 393 -33 -393 + mu 0 4 12 13 34 33 + f 4 13 394 -34 -394 + mu 0 4 13 14 35 34 + f 4 14 395 -35 -395 + mu 0 4 14 15 36 35 + f 4 15 396 -36 -396 + mu 0 4 15 16 37 36 + f 4 16 397 -37 -397 + mu 0 4 16 17 38 37 + f 4 17 398 -38 -398 + mu 0 4 17 18 39 38 + f 4 18 399 -39 -399 + mu 0 4 18 19 40 39 + f 4 19 380 -40 -400 + mu 0 4 19 20 41 40 + f 4 20 401 -41 -401 + mu 0 4 21 22 43 42 + f 4 21 402 -42 -402 + mu 0 4 22 23 44 43 + f 4 22 403 -43 -403 + mu 0 4 23 24 45 44 + f 4 23 404 -44 -404 + mu 0 4 24 25 46 45 + f 4 24 405 -45 -405 + mu 0 4 25 26 47 46 + f 4 25 406 -46 -406 + mu 0 4 26 27 48 47 + f 4 26 407 -47 -407 + mu 0 4 27 28 49 48 + f 4 27 408 -48 -408 + mu 0 4 28 29 50 49 + f 4 28 409 -49 -409 + mu 0 4 29 30 51 50 + f 4 29 410 -50 -410 + mu 0 4 30 31 52 51 + f 4 30 411 -51 -411 + mu 0 4 31 32 53 52 + f 4 31 412 -52 -412 + mu 0 4 32 33 54 53 + f 4 32 413 -53 -413 + mu 0 4 33 34 55 54 + f 4 33 414 -54 -414 + mu 0 4 34 35 56 55 + f 4 34 415 -55 -415 + mu 0 4 35 36 57 56 + f 4 35 416 -56 -416 + mu 0 4 36 37 58 57 + f 4 36 417 -57 -417 + mu 0 4 37 38 59 58 + f 4 37 418 -58 -418 + mu 0 4 38 39 60 59 + f 4 38 419 -59 -419 + mu 0 4 39 40 61 60 + f 4 39 400 -60 -420 + mu 0 4 40 41 62 61 + f 4 40 421 -61 -421 + mu 0 4 42 43 64 63 + f 4 41 422 -62 -422 + mu 0 4 43 44 65 64 + f 4 42 423 -63 -423 + mu 0 4 44 45 66 65 + f 4 43 424 -64 -424 + mu 0 4 45 46 67 66 + f 4 44 425 -65 -425 + mu 0 4 46 47 68 67 + f 4 45 426 -66 -426 + mu 0 4 47 48 69 68 + f 4 46 427 -67 -427 + mu 0 4 48 49 70 69 + f 4 47 428 -68 -428 + mu 0 4 49 50 71 70 + f 4 48 429 -69 -429 + mu 0 4 50 51 72 71 + f 4 49 430 -70 -430 + mu 0 4 51 52 73 72 + f 4 50 431 -71 -431 + mu 0 4 52 53 74 73 + f 4 51 432 -72 -432 + mu 0 4 53 54 75 74 + f 4 52 433 -73 -433 + mu 0 4 54 55 76 75 + f 4 53 434 -74 -434 + mu 0 4 55 56 77 76 + f 4 54 435 -75 -435 + mu 0 4 56 57 78 77 + f 4 55 436 -76 -436 + mu 0 4 57 58 79 78 + f 4 56 437 -77 -437 + mu 0 4 58 59 80 79 + f 4 57 438 -78 -438 + mu 0 4 59 60 81 80 + f 4 58 439 -79 -439 + mu 0 4 60 61 82 81 + f 4 59 420 -80 -440 + mu 0 4 61 62 83 82 + f 4 60 441 -81 -441 + mu 0 4 63 64 85 84 + f 4 61 442 -82 -442 + mu 0 4 64 65 86 85 + f 4 62 443 -83 -443 + mu 0 4 65 66 87 86 + f 4 63 444 -84 -444 + mu 0 4 66 67 88 87 + f 4 64 445 -85 -445 + mu 0 4 67 68 89 88 + f 4 65 446 -86 -446 + mu 0 4 68 69 90 89 + f 4 66 447 -87 -447 + mu 0 4 69 70 91 90 + f 4 67 448 -88 -448 + mu 0 4 70 71 92 91 + f 4 68 449 -89 -449 + mu 0 4 71 72 93 92 + f 4 69 450 -90 -450 + mu 0 4 72 73 94 93 + f 4 70 451 -91 -451 + mu 0 4 73 74 95 94 + f 4 71 452 -92 -452 + mu 0 4 74 75 96 95 + f 4 72 453 -93 -453 + mu 0 4 75 76 97 96 + f 4 73 454 -94 -454 + mu 0 4 76 77 98 97 + f 4 74 455 -95 -455 + mu 0 4 77 78 99 98 + f 4 75 456 -96 -456 + mu 0 4 78 79 100 99 + f 4 76 457 -97 -457 + mu 0 4 79 80 101 100 + f 4 77 458 -98 -458 + mu 0 4 80 81 102 101 + f 4 78 459 -99 -459 + mu 0 4 81 82 103 102 + f 4 79 440 -100 -460 + mu 0 4 82 83 104 103 + f 4 80 461 -101 -461 + mu 0 4 84 85 106 105 + f 4 81 462 -102 -462 + mu 0 4 85 86 107 106 + f 4 82 463 -103 -463 + mu 0 4 86 87 108 107 + f 4 83 464 -104 -464 + mu 0 4 87 88 109 108 + f 4 84 465 -105 -465 + mu 0 4 88 89 110 109 + f 4 85 466 -106 -466 + mu 0 4 89 90 111 110 + f 4 86 467 -107 -467 + mu 0 4 90 91 112 111 + f 4 87 468 -108 -468 + mu 0 4 91 92 113 112 + f 4 88 469 -109 -469 + mu 0 4 92 93 114 113 + f 4 89 470 -110 -470 + mu 0 4 93 94 115 114 + f 4 90 471 -111 -471 + mu 0 4 94 95 116 115 + f 4 91 472 -112 -472 + mu 0 4 95 96 117 116 + f 4 92 473 -113 -473 + mu 0 4 96 97 118 117 + f 4 93 474 -114 -474 + mu 0 4 97 98 119 118 + f 4 94 475 -115 -475 + mu 0 4 98 99 120 119 + f 4 95 476 -116 -476 + mu 0 4 99 100 121 120 + f 4 96 477 -117 -477 + mu 0 4 100 101 122 121 + f 4 97 478 -118 -478 + mu 0 4 101 102 123 122 + f 4 98 479 -119 -479 + mu 0 4 102 103 124 123 + f 4 99 460 -120 -480 + mu 0 4 103 104 125 124 + f 4 100 481 -121 -481 + mu 0 4 105 106 127 126 + f 4 101 482 -122 -482 + mu 0 4 106 107 128 127 + f 4 102 483 -123 -483 + mu 0 4 107 108 129 128 + f 4 103 484 -124 -484 + mu 0 4 108 109 130 129 + f 4 104 485 -125 -485 + mu 0 4 109 110 131 130 + f 4 105 486 -126 -486 + mu 0 4 110 111 132 131 + f 4 106 487 -127 -487 + mu 0 4 111 112 133 132 + f 4 107 488 -128 -488 + mu 0 4 112 113 134 133 + f 4 108 489 -129 -489 + mu 0 4 113 114 135 134 + f 4 109 490 -130 -490 + mu 0 4 114 115 136 135 + f 4 110 491 -131 -491 + mu 0 4 115 116 137 136 + f 4 111 492 -132 -492 + mu 0 4 116 117 138 137 + f 4 112 493 -133 -493 + mu 0 4 117 118 139 138 + f 4 113 494 -134 -494 + mu 0 4 118 119 140 139 + f 4 114 495 -135 -495 + mu 0 4 119 120 141 140 + f 4 115 496 -136 -496 + mu 0 4 120 121 142 141 + f 4 116 497 -137 -497 + mu 0 4 121 122 143 142 + f 4 117 498 -138 -498 + mu 0 4 122 123 144 143 + f 4 118 499 -139 -499 + mu 0 4 123 124 145 144 + f 4 119 480 -140 -500 + mu 0 4 124 125 146 145 + f 4 120 501 -141 -501 + mu 0 4 126 127 148 147 + f 4 121 502 -142 -502 + mu 0 4 127 128 149 148 + f 4 122 503 -143 -503 + mu 0 4 128 129 150 149 + f 4 123 504 -144 -504 + mu 0 4 129 130 151 150 + f 4 124 505 -145 -505 + mu 0 4 130 131 152 151 + f 4 125 506 -146 -506 + mu 0 4 131 132 153 152 + f 4 126 507 -147 -507 + mu 0 4 132 133 154 153 + f 4 127 508 -148 -508 + mu 0 4 133 134 155 154 + f 4 128 509 -149 -509 + mu 0 4 134 135 156 155 + f 4 129 510 -150 -510 + mu 0 4 135 136 157 156 + f 4 130 511 -151 -511 + mu 0 4 136 137 158 157 + f 4 131 512 -152 -512 + mu 0 4 137 138 159 158 + f 4 132 513 -153 -513 + mu 0 4 138 139 160 159 + f 4 133 514 -154 -514 + mu 0 4 139 140 161 160 + f 4 134 515 -155 -515 + mu 0 4 140 141 162 161 + f 4 135 516 -156 -516 + mu 0 4 141 142 163 162 + f 4 136 517 -157 -517 + mu 0 4 142 143 164 163 + f 4 137 518 -158 -518 + mu 0 4 143 144 165 164 + f 4 138 519 -159 -519 + mu 0 4 144 145 166 165 + f 4 139 500 -160 -520 + mu 0 4 145 146 167 166 + f 4 140 521 -161 -521 + mu 0 4 147 148 169 168 + f 4 141 522 -162 -522 + mu 0 4 148 149 170 169 + f 4 142 523 -163 -523 + mu 0 4 149 150 171 170 + f 4 143 524 -164 -524 + mu 0 4 150 151 172 171 + f 4 144 525 -165 -525 + mu 0 4 151 152 173 172 + f 4 145 526 -166 -526 + mu 0 4 152 153 174 173 + f 4 146 527 -167 -527 + mu 0 4 153 154 175 174 + f 4 147 528 -168 -528 + mu 0 4 154 155 176 175 + f 4 148 529 -169 -529 + mu 0 4 155 156 177 176 + f 4 149 530 -170 -530 + mu 0 4 156 157 178 177 + f 4 150 531 -171 -531 + mu 0 4 157 158 179 178 + f 4 151 532 -172 -532 + mu 0 4 158 159 180 179 + f 4 152 533 -173 -533 + mu 0 4 159 160 181 180 + f 4 153 534 -174 -534 + mu 0 4 160 161 182 181 + f 4 154 535 -175 -535 + mu 0 4 161 162 183 182 + f 4 155 536 -176 -536 + mu 0 4 162 163 184 183 + f 4 156 537 -177 -537 + mu 0 4 163 164 185 184 + f 4 157 538 -178 -538 + mu 0 4 164 165 186 185 + f 4 158 539 -179 -539 + mu 0 4 165 166 187 186 + f 4 159 520 -180 -540 + mu 0 4 166 167 188 187 + f 4 160 541 -181 -541 + mu 0 4 168 169 190 189 + f 4 161 542 -182 -542 + mu 0 4 169 170 191 190 + f 4 162 543 -183 -543 + mu 0 4 170 171 192 191 + f 4 163 544 -184 -544 + mu 0 4 171 172 193 192 + f 4 164 545 -185 -545 + mu 0 4 172 173 194 193 + f 4 165 546 -186 -546 + mu 0 4 173 174 195 194 + f 4 166 547 -187 -547 + mu 0 4 174 175 196 195 + f 4 167 548 -188 -548 + mu 0 4 175 176 197 196 + f 4 168 549 -189 -549 + mu 0 4 176 177 198 197 + f 4 169 550 -190 -550 + mu 0 4 177 178 199 198 + f 4 170 551 -191 -551 + mu 0 4 178 179 200 199 + f 4 171 552 -192 -552 + mu 0 4 179 180 201 200 + f 4 172 553 -193 -553 + mu 0 4 180 181 202 201 + f 4 173 554 -194 -554 + mu 0 4 181 182 203 202 + f 4 174 555 -195 -555 + mu 0 4 182 183 204 203 + f 4 175 556 -196 -556 + mu 0 4 183 184 205 204 + f 4 176 557 -197 -557 + mu 0 4 184 185 206 205 + f 4 177 558 -198 -558 + mu 0 4 185 186 207 206 + f 4 178 559 -199 -559 + mu 0 4 186 187 208 207 + f 4 179 540 -200 -560 + mu 0 4 187 188 209 208 + f 4 180 561 -201 -561 + mu 0 4 189 190 211 210 + f 4 181 562 -202 -562 + mu 0 4 190 191 212 211 + f 4 182 563 -203 -563 + mu 0 4 191 192 213 212 + f 4 183 564 -204 -564 + mu 0 4 192 193 214 213 + f 4 184 565 -205 -565 + mu 0 4 193 194 215 214 + f 4 185 566 -206 -566 + mu 0 4 194 195 216 215 + f 4 186 567 -207 -567 + mu 0 4 195 196 217 216 + f 4 187 568 -208 -568 + mu 0 4 196 197 218 217 + f 4 188 569 -209 -569 + mu 0 4 197 198 219 218 + f 4 189 570 -210 -570 + mu 0 4 198 199 220 219 + f 4 190 571 -211 -571 + mu 0 4 199 200 221 220 + f 4 191 572 -212 -572 + mu 0 4 200 201 222 221 + f 4 192 573 -213 -573 + mu 0 4 201 202 223 222 + f 4 193 574 -214 -574 + mu 0 4 202 203 224 223 + f 4 194 575 -215 -575 + mu 0 4 203 204 225 224 + f 4 195 576 -216 -576 + mu 0 4 204 205 226 225 + f 4 196 577 -217 -577 + mu 0 4 205 206 227 226 + f 4 197 578 -218 -578 + mu 0 4 206 207 228 227 + f 4 198 579 -219 -579 + mu 0 4 207 208 229 228 + f 4 199 560 -220 -580 + mu 0 4 208 209 230 229 + f 4 200 581 -221 -581 + mu 0 4 210 211 232 231 + f 4 201 582 -222 -582 + mu 0 4 211 212 233 232 + f 4 202 583 -223 -583 + mu 0 4 212 213 234 233 + f 4 203 584 -224 -584 + mu 0 4 213 214 235 234 + f 4 204 585 -225 -585 + mu 0 4 214 215 236 235 + f 4 205 586 -226 -586 + mu 0 4 215 216 237 236 + f 4 206 587 -227 -587 + mu 0 4 216 217 238 237 + f 4 207 588 -228 -588 + mu 0 4 217 218 239 238 + f 4 208 589 -229 -589 + mu 0 4 218 219 240 239 + f 4 209 590 -230 -590 + mu 0 4 219 220 241 240 + f 4 210 591 -231 -591 + mu 0 4 220 221 242 241 + f 4 211 592 -232 -592 + mu 0 4 221 222 243 242 + f 4 212 593 -233 -593 + mu 0 4 222 223 244 243 + f 4 213 594 -234 -594 + mu 0 4 223 224 245 244 + f 4 214 595 -235 -595 + mu 0 4 224 225 246 245 + f 4 215 596 -236 -596 + mu 0 4 225 226 247 246 + f 4 216 597 -237 -597 + mu 0 4 226 227 248 247 + f 4 217 598 -238 -598 + mu 0 4 227 228 249 248 + f 4 218 599 -239 -599 + mu 0 4 228 229 250 249 + f 4 219 580 -240 -600 + mu 0 4 229 230 251 250 + f 4 220 601 -241 -601 + mu 0 4 231 232 253 252 + f 4 221 602 -242 -602 + mu 0 4 232 233 254 253 + f 4 222 603 -243 -603 + mu 0 4 233 234 255 254 + f 4 223 604 -244 -604 + mu 0 4 234 235 256 255 + f 4 224 605 -245 -605 + mu 0 4 235 236 257 256 + f 4 225 606 -246 -606 + mu 0 4 236 237 258 257 + f 4 226 607 -247 -607 + mu 0 4 237 238 259 258 + f 4 227 608 -248 -608 + mu 0 4 238 239 260 259 + f 4 228 609 -249 -609 + mu 0 4 239 240 261 260 + f 4 229 610 -250 -610 + mu 0 4 240 241 262 261 + f 4 230 611 -251 -611 + mu 0 4 241 242 263 262 + f 4 231 612 -252 -612 + mu 0 4 242 243 264 263 + f 4 232 613 -253 -613 + mu 0 4 243 244 265 264 + f 4 233 614 -254 -614 + mu 0 4 244 245 266 265 + f 4 234 615 -255 -615 + mu 0 4 245 246 267 266 + f 4 235 616 -256 -616 + mu 0 4 246 247 268 267 + f 4 236 617 -257 -617 + mu 0 4 247 248 269 268 + f 4 237 618 -258 -618 + mu 0 4 248 249 270 269 + f 4 238 619 -259 -619 + mu 0 4 249 250 271 270 + f 4 239 600 -260 -620 + mu 0 4 250 251 272 271 + f 4 240 621 -261 -621 + mu 0 4 252 253 274 273 + f 4 241 622 -262 -622 + mu 0 4 253 254 275 274 + f 4 242 623 -263 -623 + mu 0 4 254 255 276 275 + f 4 243 624 -264 -624 + mu 0 4 255 256 277 276 + f 4 244 625 -265 -625 + mu 0 4 256 257 278 277 + f 4 245 626 -266 -626 + mu 0 4 257 258 279 278 + f 4 246 627 -267 -627 + mu 0 4 258 259 280 279 + f 4 247 628 -268 -628 + mu 0 4 259 260 281 280 + f 4 248 629 -269 -629 + mu 0 4 260 261 282 281 + f 4 249 630 -270 -630 + mu 0 4 261 262 283 282 + f 4 250 631 -271 -631 + mu 0 4 262 263 284 283 + f 4 251 632 -272 -632 + mu 0 4 263 264 285 284 + f 4 252 633 -273 -633 + mu 0 4 264 265 286 285 + f 4 253 634 -274 -634 + mu 0 4 265 266 287 286 + f 4 254 635 -275 -635 + mu 0 4 266 267 288 287 + f 4 255 636 -276 -636 + mu 0 4 267 268 289 288 + f 4 256 637 -277 -637 + mu 0 4 268 269 290 289 + f 4 257 638 -278 -638 + mu 0 4 269 270 291 290 + f 4 258 639 -279 -639 + mu 0 4 270 271 292 291 + f 4 259 620 -280 -640 + mu 0 4 271 272 293 292 + f 4 260 641 -281 -641 + mu 0 4 273 274 295 294 + f 4 261 642 -282 -642 + mu 0 4 274 275 296 295 + f 4 262 643 -283 -643 + mu 0 4 275 276 297 296 + f 4 263 644 -284 -644 + mu 0 4 276 277 298 297 + f 4 264 645 -285 -645 + mu 0 4 277 278 299 298 + f 4 265 646 -286 -646 + mu 0 4 278 279 300 299 + f 4 266 647 -287 -647 + mu 0 4 279 280 301 300 + f 4 267 648 -288 -648 + mu 0 4 280 281 302 301 + f 4 268 649 -289 -649 + mu 0 4 281 282 303 302 + f 4 269 650 -290 -650 + mu 0 4 282 283 304 303 + f 4 270 651 -291 -651 + mu 0 4 283 284 305 304 + f 4 271 652 -292 -652 + mu 0 4 284 285 306 305 + f 4 272 653 -293 -653 + mu 0 4 285 286 307 306 + f 4 273 654 -294 -654 + mu 0 4 286 287 308 307 + f 4 274 655 -295 -655 + mu 0 4 287 288 309 308 + f 4 275 656 -296 -656 + mu 0 4 288 289 310 309 + f 4 276 657 -297 -657 + mu 0 4 289 290 311 310 + f 4 277 658 -298 -658 + mu 0 4 290 291 312 311 + f 4 278 659 -299 -659 + mu 0 4 291 292 313 312 + f 4 279 640 -300 -660 + mu 0 4 292 293 314 313 + f 4 280 661 -301 -661 + mu 0 4 294 295 316 315 + f 4 281 662 -302 -662 + mu 0 4 295 296 317 316 + f 4 282 663 -303 -663 + mu 0 4 296 297 318 317 + f 4 283 664 -304 -664 + mu 0 4 297 298 319 318 + f 4 284 665 -305 -665 + mu 0 4 298 299 320 319 + f 4 285 666 -306 -666 + mu 0 4 299 300 321 320 + f 4 286 667 -307 -667 + mu 0 4 300 301 322 321 + f 4 287 668 -308 -668 + mu 0 4 301 302 323 322 + f 4 288 669 -309 -669 + mu 0 4 302 303 324 323 + f 4 289 670 -310 -670 + mu 0 4 303 304 325 324 + f 4 290 671 -311 -671 + mu 0 4 304 305 326 325 + f 4 291 672 -312 -672 + mu 0 4 305 306 327 326 + f 4 292 673 -313 -673 + mu 0 4 306 307 328 327 + f 4 293 674 -314 -674 + mu 0 4 307 308 329 328 + f 4 294 675 -315 -675 + mu 0 4 308 309 330 329 + f 4 295 676 -316 -676 + mu 0 4 309 310 331 330 + f 4 296 677 -317 -677 + mu 0 4 310 311 332 331 + f 4 297 678 -318 -678 + mu 0 4 311 312 333 332 + f 4 298 679 -319 -679 + mu 0 4 312 313 334 333 + f 4 299 660 -320 -680 + mu 0 4 313 314 335 334 + f 4 300 681 -321 -681 + mu 0 4 315 316 337 336 + f 4 301 682 -322 -682 + mu 0 4 316 317 338 337 + f 4 302 683 -323 -683 + mu 0 4 317 318 339 338 + f 4 303 684 -324 -684 + mu 0 4 318 319 340 339 + f 4 304 685 -325 -685 + mu 0 4 319 320 341 340 + f 4 305 686 -326 -686 + mu 0 4 320 321 342 341 + f 4 306 687 -327 -687 + mu 0 4 321 322 343 342 + f 4 307 688 -328 -688 + mu 0 4 322 323 344 343 + f 4 308 689 -329 -689 + mu 0 4 323 324 345 344 + f 4 309 690 -330 -690 + mu 0 4 324 325 346 345 + f 4 310 691 -331 -691 + mu 0 4 325 326 347 346 + f 4 311 692 -332 -692 + mu 0 4 326 327 348 347 + f 4 312 693 -333 -693 + mu 0 4 327 328 349 348 + f 4 313 694 -334 -694 + mu 0 4 328 329 350 349 + f 4 314 695 -335 -695 + mu 0 4 329 330 351 350 + f 4 315 696 -336 -696 + mu 0 4 330 331 352 351 + f 4 316 697 -337 -697 + mu 0 4 331 332 353 352 + f 4 317 698 -338 -698 + mu 0 4 332 333 354 353 + f 4 318 699 -339 -699 + mu 0 4 333 334 355 354 + f 4 319 680 -340 -700 + mu 0 4 334 335 356 355 + f 4 320 701 -341 -701 + mu 0 4 336 337 358 357 + f 4 321 702 -342 -702 + mu 0 4 337 338 359 358 + f 4 322 703 -343 -703 + mu 0 4 338 339 360 359 + f 4 323 704 -344 -704 + mu 0 4 339 340 361 360 + f 4 324 705 -345 -705 + mu 0 4 340 341 362 361 + f 4 325 706 -346 -706 + mu 0 4 341 342 363 362 + f 4 326 707 -347 -707 + mu 0 4 342 343 364 363 + f 4 327 708 -348 -708 + mu 0 4 343 344 365 364 + f 4 328 709 -349 -709 + mu 0 4 344 345 366 365 + f 4 329 710 -350 -710 + mu 0 4 345 346 367 366 + f 4 330 711 -351 -711 + mu 0 4 346 347 368 367 + f 4 331 712 -352 -712 + mu 0 4 347 348 369 368 + f 4 332 713 -353 -713 + mu 0 4 348 349 370 369 + f 4 333 714 -354 -714 + mu 0 4 349 350 371 370 + f 4 334 715 -355 -715 + mu 0 4 350 351 372 371 + f 4 335 716 -356 -716 + mu 0 4 351 352 373 372 + f 4 336 717 -357 -717 + mu 0 4 352 353 374 373 + f 4 337 718 -358 -718 + mu 0 4 353 354 375 374 + f 4 338 719 -359 -719 + mu 0 4 354 355 376 375 + f 4 339 700 -360 -720 + mu 0 4 355 356 377 376 + f 4 340 721 -361 -721 + mu 0 4 357 358 379 378 + f 4 341 722 -362 -722 + mu 0 4 358 359 380 379 + f 4 342 723 -363 -723 + mu 0 4 359 360 381 380 + f 4 343 724 -364 -724 + mu 0 4 360 361 382 381 + f 4 344 725 -365 -725 + mu 0 4 361 362 383 382 + f 4 345 726 -366 -726 + mu 0 4 362 363 384 383 + f 4 346 727 -367 -727 + mu 0 4 363 364 385 384 + f 4 347 728 -368 -728 + mu 0 4 364 365 386 385 + f 4 348 729 -369 -729 + mu 0 4 365 366 387 386 + f 4 349 730 -370 -730 + mu 0 4 366 367 388 387 + f 4 350 731 -371 -731 + mu 0 4 367 368 389 388 + f 4 351 732 -372 -732 + mu 0 4 368 369 390 389 + f 4 352 733 -373 -733 + mu 0 4 369 370 391 390 + f 4 353 734 -374 -734 + mu 0 4 370 371 392 391 + f 4 354 735 -375 -735 + mu 0 4 371 372 393 392 + f 4 355 736 -376 -736 + mu 0 4 372 373 394 393 + f 4 356 737 -377 -737 + mu 0 4 373 374 395 394 + f 4 357 738 -378 -738 + mu 0 4 374 375 396 395 + f 4 358 739 -379 -739 + mu 0 4 375 376 397 396 + f 4 359 720 -380 -740 + mu 0 4 376 377 398 397 + f 3 -1 -741 741 + mu 0 3 1 0 399 + f 3 -2 -742 742 + mu 0 3 2 1 400 + f 3 -3 -743 743 + mu 0 3 3 2 401 + f 3 -4 -744 744 + mu 0 3 4 3 402 + f 3 -5 -745 745 + mu 0 3 5 4 403 + f 3 -6 -746 746 + mu 0 3 6 5 404 + f 3 -7 -747 747 + mu 0 3 7 6 405 + f 3 -8 -748 748 + mu 0 3 8 7 406 + f 3 -9 -749 749 + mu 0 3 9 8 407 + f 3 -10 -750 750 + mu 0 3 10 9 408 + f 3 -11 -751 751 + mu 0 3 11 10 409 + f 3 -12 -752 752 + mu 0 3 12 11 410 + f 3 -13 -753 753 + mu 0 3 13 12 411 + f 3 -14 -754 754 + mu 0 3 14 13 412 + f 3 -15 -755 755 + mu 0 3 15 14 413 + f 3 -16 -756 756 + mu 0 3 16 15 414 + f 3 -17 -757 757 + mu 0 3 17 16 415 + f 3 -18 -758 758 + mu 0 3 18 17 416 + f 3 -19 -759 759 + mu 0 3 19 18 417 + f 3 -20 -760 740 + mu 0 3 20 19 418 + f 3 360 761 -761 + mu 0 3 378 379 419 + f 3 361 762 -762 + mu 0 3 379 380 420 + f 3 362 763 -763 + mu 0 3 380 381 421 + f 3 363 764 -764 + mu 0 3 381 382 422 + f 3 364 765 -765 + mu 0 3 382 383 423 + f 3 365 766 -766 + mu 0 3 383 384 424 + f 3 366 767 -767 + mu 0 3 384 385 425 + f 3 367 768 -768 + mu 0 3 385 386 426 + f 3 368 769 -769 + mu 0 3 386 387 427 + f 3 369 770 -770 + mu 0 3 387 388 428 + f 3 370 771 -771 + mu 0 3 388 389 429 + f 3 371 772 -772 + mu 0 3 389 390 430 + f 3 372 773 -773 + mu 0 3 390 391 431 + f 3 373 774 -774 + mu 0 3 391 392 432 + f 3 374 775 -775 + mu 0 3 392 393 433 + f 3 375 776 -776 + mu 0 3 393 394 434 + f 3 376 777 -777 + mu 0 3 394 395 435 + f 3 377 778 -778 + mu 0 3 395 396 436 + f 3 378 779 -779 + mu 0 3 396 397 437 + f 3 379 760 -780 + mu 0 3 397 398 438; + setAttr ".cd" -type "dataPolyComponent" Index_Data Edge 0 ; + setAttr ".cvd" -type "dataPolyComponent" Index_Data Vertex 0 ; + setAttr ".pd[0]" -type "dataPolyComponent" Index_Data UV 0 ; + setAttr ".hfd" -type "dataPolyComponent" Index_Data Face 0 ; + setAttr ".dr" 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:6c77a15a98a9"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :lightList1; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//_"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".dar" 1.7777777910232544; +select -ne :defaultLightSet; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +// End of modelMain.ma diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc new file mode 100644 index 0000000000..d9e5425996 Binary files /dev/null and b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc differ diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma new file mode 100644 index 0000000000..9ee588337e --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma @@ -0,0 +1,1208 @@ +//Maya ASCII 2020 scene +//Name: modelMain.ma +//Last modified: Mon, Oct 24, 2022 02:57:47 PM +//Codeset: 1252 +requires maya "2020"; +requires "mtoa" "4.1.1"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2020"; +fileInfo "version" "2020"; +fileInfo "cutIdentifier" "202011110415-b1e20b88e2"; +fileInfo "osv" "Microsoft Windows 10 Technical Preview (Build 19044)\n"; +fileInfo "UUID" "A787A358-4FE7-6E55-0C81-61BFEB0C2726"; +createNode transform -n "model_GRP"; + rename -uid "445FDC20-4A9D-2C5B-C7BD-F98F6E660B5C"; + setAttr ".rlio[0]" 1 yes 0; +createNode transform -n "pSphere1_GEO" -p "model_GRP"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:302a4c6123a4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr -s 439 ".uvst[0].uvsp"; + setAttr ".uvst[0].uvsp[0:249]" -type "float2" 0 0.050000001 0.050000001 0.050000001 + 0.1 0.050000001 0.15000001 0.050000001 0.2 0.050000001 0.25 0.050000001 0.30000001 + 0.050000001 0.35000002 0.050000001 0.40000004 0.050000001 0.45000005 0.050000001 + 0.50000006 0.050000001 0.55000007 0.050000001 0.60000008 0.050000001 0.6500001 0.050000001 + 0.70000011 0.050000001 0.75000012 0.050000001 0.80000013 0.050000001 0.85000014 0.050000001 + 0.90000015 0.050000001 0.95000017 0.050000001 1.000000119209 0.050000001 0 0.1 0.050000001 + 0.1 0.1 0.1 0.15000001 0.1 0.2 0.1 0.25 0.1 0.30000001 0.1 0.35000002 0.1 0.40000004 + 0.1 0.45000005 0.1 0.50000006 0.1 0.55000007 0.1 0.60000008 0.1 0.6500001 0.1 0.70000011 + 0.1 0.75000012 0.1 0.80000013 0.1 0.85000014 0.1 0.90000015 0.1 0.95000017 0.1 1.000000119209 + 0.1 0 0.15000001 0.050000001 0.15000001 0.1 0.15000001 0.15000001 0.15000001 0.2 + 0.15000001 0.25 0.15000001 0.30000001 0.15000001 0.35000002 0.15000001 0.40000004 + 0.15000001 0.45000005 0.15000001 0.50000006 0.15000001 0.55000007 0.15000001 0.60000008 + 0.15000001 0.6500001 0.15000001 0.70000011 0.15000001 0.75000012 0.15000001 0.80000013 + 0.15000001 0.85000014 0.15000001 0.90000015 0.15000001 0.95000017 0.15000001 1.000000119209 + 0.15000001 0 0.2 0.050000001 0.2 0.1 0.2 0.15000001 0.2 0.2 0.2 0.25 0.2 0.30000001 + 0.2 0.35000002 0.2 0.40000004 0.2 0.45000005 0.2 0.50000006 0.2 0.55000007 0.2 0.60000008 + 0.2 0.6500001 0.2 0.70000011 0.2 0.75000012 0.2 0.80000013 0.2 0.85000014 0.2 0.90000015 + 0.2 0.95000017 0.2 1.000000119209 0.2 0 0.25 0.050000001 0.25 0.1 0.25 0.15000001 + 0.25 0.2 0.25 0.25 0.25 0.30000001 0.25 0.35000002 0.25 0.40000004 0.25 0.45000005 + 0.25 0.50000006 0.25 0.55000007 0.25 0.60000008 0.25 0.6500001 0.25 0.70000011 0.25 + 0.75000012 0.25 0.80000013 0.25 0.85000014 0.25 0.90000015 0.25 0.95000017 0.25 1.000000119209 + 0.25 0 0.30000001 0.050000001 0.30000001 0.1 0.30000001 0.15000001 0.30000001 0.2 + 0.30000001 0.25 0.30000001 0.30000001 0.30000001 0.35000002 0.30000001 0.40000004 + 0.30000001 0.45000005 0.30000001 0.50000006 0.30000001 0.55000007 0.30000001 0.60000008 + 0.30000001 0.6500001 0.30000001 0.70000011 0.30000001 0.75000012 0.30000001 0.80000013 + 0.30000001 0.85000014 0.30000001 0.90000015 0.30000001 0.95000017 0.30000001 1.000000119209 + 0.30000001 0 0.35000002 0.050000001 0.35000002 0.1 0.35000002 0.15000001 0.35000002 + 0.2 0.35000002 0.25 0.35000002 0.30000001 0.35000002 0.35000002 0.35000002 0.40000004 + 0.35000002 0.45000005 0.35000002 0.50000006 0.35000002 0.55000007 0.35000002 0.60000008 + 0.35000002 0.6500001 0.35000002 0.70000011 0.35000002 0.75000012 0.35000002 0.80000013 + 0.35000002 0.85000014 0.35000002 0.90000015 0.35000002 0.95000017 0.35000002 1.000000119209 + 0.35000002 0 0.40000004 0.050000001 0.40000004 0.1 0.40000004 0.15000001 0.40000004 + 0.2 0.40000004 0.25 0.40000004 0.30000001 0.40000004 0.35000002 0.40000004 0.40000004 + 0.40000004 0.45000005 0.40000004 0.50000006 0.40000004 0.55000007 0.40000004 0.60000008 + 0.40000004 0.6500001 0.40000004 0.70000011 0.40000004 0.75000012 0.40000004 0.80000013 + 0.40000004 0.85000014 0.40000004 0.90000015 0.40000004 0.95000017 0.40000004 1.000000119209 + 0.40000004 0 0.45000005 0.050000001 0.45000005 0.1 0.45000005 0.15000001 0.45000005 + 0.2 0.45000005 0.25 0.45000005 0.30000001 0.45000005 0.35000002 0.45000005 0.40000004 + 0.45000005 0.45000005 0.45000005 0.50000006 0.45000005 0.55000007 0.45000005 0.60000008 + 0.45000005 0.6500001 0.45000005 0.70000011 0.45000005 0.75000012 0.45000005 0.80000013 + 0.45000005 0.85000014 0.45000005 0.90000015 0.45000005 0.95000017 0.45000005 1.000000119209 + 0.45000005 0 0.50000006 0.050000001 0.50000006 0.1 0.50000006 0.15000001 0.50000006 + 0.2 0.50000006 0.25 0.50000006 0.30000001 0.50000006 0.35000002 0.50000006 0.40000004 + 0.50000006 0.45000005 0.50000006 0.50000006 0.50000006 0.55000007 0.50000006 0.60000008 + 0.50000006 0.6500001 0.50000006 0.70000011 0.50000006 0.75000012 0.50000006 0.80000013 + 0.50000006 0.85000014 0.50000006 0.90000015 0.50000006 0.95000017 0.50000006 1.000000119209 + 0.50000006 0 0.55000007 0.050000001 0.55000007 0.1 0.55000007 0.15000001 0.55000007 + 0.2 0.55000007 0.25 0.55000007 0.30000001 0.55000007 0.35000002 0.55000007 0.40000004 + 0.55000007 0.45000005 0.55000007 0.50000006 0.55000007 0.55000007 0.55000007 0.60000008 + 0.55000007 0.6500001 0.55000007 0.70000011 0.55000007 0.75000012 0.55000007 0.80000013 + 0.55000007 0.85000014 0.55000007 0.90000015 0.55000007 0.95000017 0.55000007 1.000000119209 + 0.55000007 0 0.60000008 0.050000001 0.60000008 0.1 0.60000008 0.15000001 0.60000008 + 0.2 0.60000008 0.25 0.60000008 0.30000001 0.60000008 0.35000002 0.60000008 0.40000004 + 0.60000008 0.45000005 0.60000008 0.50000006 0.60000008 0.55000007 0.60000008 0.60000008 + 0.60000008 0.6500001 0.60000008 0.70000011 0.60000008 0.75000012 0.60000008 0.80000013 + 0.60000008 0.85000014 0.60000008 0.90000015 0.60000008; + setAttr ".uvst[0].uvsp[250:438]" 0.95000017 0.60000008 1.000000119209 0.60000008 + 0 0.6500001 0.050000001 0.6500001 0.1 0.6500001 0.15000001 0.6500001 0.2 0.6500001 + 0.25 0.6500001 0.30000001 0.6500001 0.35000002 0.6500001 0.40000004 0.6500001 0.45000005 + 0.6500001 0.50000006 0.6500001 0.55000007 0.6500001 0.60000008 0.6500001 0.6500001 + 0.6500001 0.70000011 0.6500001 0.75000012 0.6500001 0.80000013 0.6500001 0.85000014 + 0.6500001 0.90000015 0.6500001 0.95000017 0.6500001 1.000000119209 0.6500001 0 0.70000011 + 0.050000001 0.70000011 0.1 0.70000011 0.15000001 0.70000011 0.2 0.70000011 0.25 0.70000011 + 0.30000001 0.70000011 0.35000002 0.70000011 0.40000004 0.70000011 0.45000005 0.70000011 + 0.50000006 0.70000011 0.55000007 0.70000011 0.60000008 0.70000011 0.6500001 0.70000011 + 0.70000011 0.70000011 0.75000012 0.70000011 0.80000013 0.70000011 0.85000014 0.70000011 + 0.90000015 0.70000011 0.95000017 0.70000011 1.000000119209 0.70000011 0 0.75000012 + 0.050000001 0.75000012 0.1 0.75000012 0.15000001 0.75000012 0.2 0.75000012 0.25 0.75000012 + 0.30000001 0.75000012 0.35000002 0.75000012 0.40000004 0.75000012 0.45000005 0.75000012 + 0.50000006 0.75000012 0.55000007 0.75000012 0.60000008 0.75000012 0.6500001 0.75000012 + 0.70000011 0.75000012 0.75000012 0.75000012 0.80000013 0.75000012 0.85000014 0.75000012 + 0.90000015 0.75000012 0.95000017 0.75000012 1.000000119209 0.75000012 0 0.80000013 + 0.050000001 0.80000013 0.1 0.80000013 0.15000001 0.80000013 0.2 0.80000013 0.25 0.80000013 + 0.30000001 0.80000013 0.35000002 0.80000013 0.40000004 0.80000013 0.45000005 0.80000013 + 0.50000006 0.80000013 0.55000007 0.80000013 0.60000008 0.80000013 0.6500001 0.80000013 + 0.70000011 0.80000013 0.75000012 0.80000013 0.80000013 0.80000013 0.85000014 0.80000013 + 0.90000015 0.80000013 0.95000017 0.80000013 1.000000119209 0.80000013 0 0.85000014 + 0.050000001 0.85000014 0.1 0.85000014 0.15000001 0.85000014 0.2 0.85000014 0.25 0.85000014 + 0.30000001 0.85000014 0.35000002 0.85000014 0.40000004 0.85000014 0.45000005 0.85000014 + 0.50000006 0.85000014 0.55000007 0.85000014 0.60000008 0.85000014 0.6500001 0.85000014 + 0.70000011 0.85000014 0.75000012 0.85000014 0.80000013 0.85000014 0.85000014 0.85000014 + 0.90000015 0.85000014 0.95000017 0.85000014 1.000000119209 0.85000014 0 0.90000015 + 0.050000001 0.90000015 0.1 0.90000015 0.15000001 0.90000015 0.2 0.90000015 0.25 0.90000015 + 0.30000001 0.90000015 0.35000002 0.90000015 0.40000004 0.90000015 0.45000005 0.90000015 + 0.50000006 0.90000015 0.55000007 0.90000015 0.60000008 0.90000015 0.6500001 0.90000015 + 0.70000011 0.90000015 0.75000012 0.90000015 0.80000013 0.90000015 0.85000014 0.90000015 + 0.90000015 0.90000015 0.95000017 0.90000015 1.000000119209 0.90000015 0 0.95000017 + 0.050000001 0.95000017 0.1 0.95000017 0.15000001 0.95000017 0.2 0.95000017 0.25 0.95000017 + 0.30000001 0.95000017 0.35000002 0.95000017 0.40000004 0.95000017 0.45000005 0.95000017 + 0.50000006 0.95000017 0.55000007 0.95000017 0.60000008 0.95000017 0.6500001 0.95000017 + 0.70000011 0.95000017 0.75000012 0.95000017 0.80000013 0.95000017 0.85000014 0.95000017 + 0.90000015 0.95000017 0.95000017 0.95000017 1.000000119209 0.95000017 0.025 0 0.075000003 + 0 0.125 0 0.17500001 0 0.22500001 0 0.27500001 0 0.32500002 0 0.375 0 0.42500001 + 0 0.47500002 0 0.52499998 0 0.57499999 0 0.625 0 0.67500001 0 0.72499996 0 0.77499998 + 0 0.82499999 0 0.875 0 0.92500001 0 0.97499996 0 0.025 1 0.075000003 1 0.125 1 0.17500001 + 1 0.22500001 1 0.27500001 1 0.32500002 1 0.375 1 0.42500001 1 0.47500002 1 0.52499998 + 1 0.57499999 1 0.625 1 0.67500001 1 0.72499996 1 0.77499998 1 0.82499999 1 0.875 + 1 0.92500001 1 0.97499996 1; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr -s 382 ".vt"; + setAttr ".vt[0:165]" 0.14877813 -0.98768836 -0.048340943 0.12655823 -0.98768836 -0.091949932 + 0.091949932 -0.98768836 -0.12655823 0.048340935 -0.98768836 -0.14877811 0 -0.98768836 -0.15643455 + -0.048340935 -0.98768836 -0.1487781 -0.091949917 -0.98768836 -0.1265582 -0.12655818 -0.98768836 -0.091949902 + -0.14877807 -0.98768836 -0.048340924 -0.15643452 -0.98768836 0 -0.14877807 -0.98768836 0.048340924 + -0.12655818 -0.98768836 0.091949895 -0.091949895 -0.98768836 0.12655817 -0.048340924 -0.98768836 0.14877805 + -4.6621107e-09 -0.98768836 0.15643449 0.048340909 -0.98768836 0.14877804 0.09194988 -0.98768836 0.12655815 + 0.12655815 -0.98768836 0.091949888 0.14877804 -0.98768836 0.048340913 0.15643448 -0.98768836 0 + 0.29389283 -0.95105654 -0.095491566 0.25000018 -0.95105654 -0.18163574 0.18163574 -0.95105654 -0.25000015 + 0.095491551 -0.95105654 -0.2938928 0 -0.95105654 -0.30901715 -0.095491551 -0.95105654 -0.29389277 + -0.18163571 -0.95105654 -0.25000009 -0.25000009 -0.95105654 -0.18163569 -0.29389271 -0.95105654 -0.095491529 + -0.30901706 -0.95105654 0 -0.29389271 -0.95105654 0.095491529 -0.25000006 -0.95105654 0.18163568 + -0.18163568 -0.95105654 0.25000006 -0.095491529 -0.95105654 0.29389268 -9.2094243e-09 -0.95105654 0.30901703 + 0.095491499 -0.95105654 0.29389265 0.18163563 -0.95105654 0.25000003 0.25 -0.95105654 0.18163565 + 0.29389265 -0.95105654 0.095491506 0.309017 -0.95105654 0 0.43177092 -0.89100653 -0.14029087 + 0.36728629 -0.89100653 -0.2668491 0.2668491 -0.89100653 -0.36728626 0.14029086 -0.89100653 -0.43177086 + 0 -0.89100653 -0.45399073 -0.14029086 -0.89100653 -0.43177083 -0.26684904 -0.89100653 -0.36728618 + -0.36728615 -0.89100653 -0.26684901 -0.43177077 -0.89100653 -0.14029081 -0.45399064 -0.89100653 0 + -0.43177077 -0.89100653 0.14029081 -0.36728612 -0.89100653 0.26684898 -0.26684898 -0.89100653 0.36728612 + -0.14029081 -0.89100653 0.43177071 -1.3529972e-08 -0.89100653 0.45399058 0.14029078 -0.89100653 0.43177068 + 0.26684892 -0.89100653 0.36728609 0.36728606 -0.89100653 0.26684895 0.43177065 -0.89100653 0.1402908 + 0.45399052 -0.89100653 0 0.55901736 -0.809017 -0.18163574 0.47552857 -0.809017 -0.34549171 + 0.34549171 -0.809017 -0.47552854 0.18163572 -0.809017 -0.5590173 0 -0.809017 -0.58778554 + -0.18163572 -0.809017 -0.55901724 -0.34549165 -0.809017 -0.47552842 -0.47552839 -0.809017 -0.34549159 + -0.55901712 -0.809017 -0.18163566 -0.58778536 -0.809017 0 -0.55901712 -0.809017 0.18163566 + -0.47552836 -0.809017 0.34549156 -0.34549156 -0.809017 0.47552833 -0.18163566 -0.809017 0.55901706 + -1.7517365e-08 -0.809017 0.5877853 0.18163562 -0.809017 0.55901706 0.3454915 -0.809017 0.4755283 + 0.47552827 -0.809017 0.34549153 0.559017 -0.809017 0.18163563 0.58778524 -0.809017 0 + 0.67249894 -0.70710677 -0.21850814 0.57206178 -0.70710677 -0.41562718 0.41562718 -0.70710677 -0.57206172 + 0.21850812 -0.70710677 -0.67249888 0 -0.70710677 -0.70710713 -0.21850812 -0.70710677 -0.67249882 + -0.41562709 -0.70710677 -0.5720616 -0.57206154 -0.70710677 -0.41562706 -0.6724987 -0.70710677 -0.21850805 + -0.70710695 -0.70710677 0 -0.6724987 -0.70710677 0.21850805 -0.57206154 -0.70710677 0.415627 + -0.415627 -0.70710677 0.57206148 -0.21850805 -0.70710677 0.67249858 -2.1073424e-08 -0.70710677 0.70710683 + 0.21850799 -0.70710677 0.67249858 0.41562691 -0.70710677 0.57206142 0.57206142 -0.70710677 0.41562697 + 0.67249852 -0.70710677 0.21850802 0.70710677 -0.70710677 0 0.7694214 -0.58778524 -0.25000015 + 0.65450895 -0.58778524 -0.47552854 0.47552854 -0.58778524 -0.65450889 0.25000012 -0.58778524 -0.76942128 + 0 -0.58778524 -0.80901736 -0.25000012 -0.58778524 -0.76942122 -0.47552845 -0.58778524 -0.65450877 + -0.65450871 -0.58778524 -0.47552839 -0.7694211 -0.58778524 -0.25000006 -0.80901718 -0.58778524 0 + -0.7694211 -0.58778524 0.25000006 -0.65450865 -0.58778524 0.47552836 -0.47552836 -0.58778524 0.65450859 + -0.25000006 -0.58778524 0.76942098 -2.4110586e-08 -0.58778524 0.80901712 0.24999999 -0.58778524 0.76942098 + 0.47552827 -0.58778524 0.65450853 0.65450853 -0.58778524 0.4755283 0.76942092 -0.58778524 0.25 + 0.809017 -0.58778524 0 0.8473981 -0.45399052 -0.27533633 0.72083992 -0.45399052 -0.5237208 + 0.5237208 -0.45399052 -0.72083986 0.2753363 -0.45399052 -0.84739798 0 -0.45399052 -0.89100695 + -0.2753363 -0.45399052 -0.84739798 -0.52372068 -0.45399052 -0.72083968 -0.72083962 -0.45399052 -0.52372062 + -0.8473978 -0.45399052 -0.27533621 -0.89100677 -0.45399052 0 -0.8473978 -0.45399052 0.27533621 + -0.72083962 -0.45399052 0.52372062 -0.52372062 -0.45399052 0.72083956 -0.27533621 -0.45399052 0.84739769 + -2.6554064e-08 -0.45399052 0.89100665 0.27533615 -0.45399052 0.84739763 0.5237205 -0.45399052 0.7208395 + 0.72083944 -0.45399052 0.52372056 0.84739757 -0.45399052 0.27533618 0.89100653 -0.45399052 0 + 0.90450913 -0.30901697 -0.2938928 0.7694214 -0.30901697 -0.55901736 0.55901736 -0.30901697 -0.76942134 + 0.29389277 -0.30901697 -0.90450901 0 -0.30901697 -0.95105702 -0.29389277 -0.30901697 -0.90450895 + -0.55901724 -0.30901697 -0.76942122 -0.76942116 -0.30901697 -0.55901718 -0.90450877 -0.30901697 -0.29389271 + -0.95105678 -0.30901697 0 -0.90450877 -0.30901697 0.29389271 -0.7694211 -0.30901697 0.55901712 + -0.55901712 -0.30901697 0.76942104 -0.29389271 -0.30901697 0.90450865 -2.8343694e-08 -0.30901697 0.95105666 + 0.29389262 -0.30901697 0.90450859 0.559017 -0.30901697 0.76942098 0.76942092 -0.30901697 0.55901706 + 0.90450853 -0.30901697 0.29389265 0.95105654 -0.30901697 0 0.93934804 -0.15643437 -0.30521268 + 0.79905719 -0.15643437 -0.580549 0.580549 -0.15643437 -0.79905713 0.30521265 -0.15643437 -0.93934792 + 0 -0.15643437 -0.98768884 -0.30521265 -0.15643437 -0.93934786; + setAttr ".vt[166:331]" -0.58054888 -0.15643437 -0.79905695 -0.79905689 -0.15643437 -0.58054882 + -0.93934768 -0.15643437 -0.30521256 -0.9876886 -0.15643437 0 -0.93934768 -0.15643437 0.30521256 + -0.79905683 -0.15643437 0.58054876 -0.58054876 -0.15643437 0.79905677 -0.30521256 -0.15643437 0.93934757 + -2.9435407e-08 -0.15643437 0.98768848 0.30521247 -0.15643437 0.93934757 0.58054864 -0.15643437 0.79905671 + 0.79905665 -0.15643437 0.5805487 0.93934751 -0.15643437 0.3052125 0.98768836 -0.15643437 0 + 0.95105714 0 -0.30901718 0.80901754 0 -0.5877856 0.5877856 0 -0.80901748 0.30901715 0 -0.95105702 + 0 0 -1.000000476837 -0.30901715 0 -0.95105696 -0.58778548 0 -0.8090173 -0.80901724 0 -0.58778542 + -0.95105678 0 -0.30901706 -1.000000238419 0 0 -0.95105678 0 0.30901706 -0.80901718 0 0.58778536 + -0.58778536 0 0.80901712 -0.30901706 0 0.95105666 -2.9802322e-08 0 1.000000119209 + 0.30901697 0 0.9510566 0.58778524 0 0.80901706 0.809017 0 0.5877853 0.95105654 0 0.309017 + 1 0 0 0.93934804 0.15643437 -0.30521268 0.79905719 0.15643437 -0.580549 0.580549 0.15643437 -0.79905713 + 0.30521265 0.15643437 -0.93934792 0 0.15643437 -0.98768884 -0.30521265 0.15643437 -0.93934786 + -0.58054888 0.15643437 -0.79905695 -0.79905689 0.15643437 -0.58054882 -0.93934768 0.15643437 -0.30521256 + -0.9876886 0.15643437 0 -0.93934768 0.15643437 0.30521256 -0.79905683 0.15643437 0.58054876 + -0.58054876 0.15643437 0.79905677 -0.30521256 0.15643437 0.93934757 -2.9435407e-08 0.15643437 0.98768848 + 0.30521247 0.15643437 0.93934757 0.58054864 0.15643437 0.79905671 0.79905665 0.15643437 0.5805487 + 0.93934751 0.15643437 0.3052125 0.98768836 0.15643437 0 0.90450913 0.30901697 -0.2938928 + 0.7694214 0.30901697 -0.55901736 0.55901736 0.30901697 -0.76942134 0.29389277 0.30901697 -0.90450901 + 0 0.30901697 -0.95105702 -0.29389277 0.30901697 -0.90450895 -0.55901724 0.30901697 -0.76942122 + -0.76942116 0.30901697 -0.55901718 -0.90450877 0.30901697 -0.29389271 -0.95105678 0.30901697 0 + -0.90450877 0.30901697 0.29389271 -0.7694211 0.30901697 0.55901712 -0.55901712 0.30901697 0.76942104 + -0.29389271 0.30901697 0.90450865 -2.8343694e-08 0.30901697 0.95105666 0.29389262 0.30901697 0.90450859 + 0.559017 0.30901697 0.76942098 0.76942092 0.30901697 0.55901706 0.90450853 0.30901697 0.29389265 + 0.95105654 0.30901697 0 0.8473981 0.45399052 -0.27533633 0.72083992 0.45399052 -0.5237208 + 0.5237208 0.45399052 -0.72083986 0.2753363 0.45399052 -0.84739798 0 0.45399052 -0.89100695 + -0.2753363 0.45399052 -0.84739798 -0.52372068 0.45399052 -0.72083968 -0.72083962 0.45399052 -0.52372062 + -0.8473978 0.45399052 -0.27533621 -0.89100677 0.45399052 0 -0.8473978 0.45399052 0.27533621 + -0.72083962 0.45399052 0.52372062 -0.52372062 0.45399052 0.72083956 -0.27533621 0.45399052 0.84739769 + -2.6554064e-08 0.45399052 0.89100665 0.27533615 0.45399052 0.84739763 0.5237205 0.45399052 0.7208395 + 0.72083944 0.45399052 0.52372056 0.84739757 0.45399052 0.27533618 0.89100653 0.45399052 0 + 0.7694214 0.58778524 -0.25000015 0.65450895 0.58778524 -0.47552854 0.47552854 0.58778524 -0.65450889 + 0.25000012 0.58778524 -0.76942128 0 0.58778524 -0.80901736 -0.25000012 0.58778524 -0.76942122 + -0.47552845 0.58778524 -0.65450877 -0.65450871 0.58778524 -0.47552839 -0.7694211 0.58778524 -0.25000006 + -0.80901718 0.58778524 0 -0.7694211 0.58778524 0.25000006 -0.65450865 0.58778524 0.47552836 + -0.47552836 0.58778524 0.65450859 -0.25000006 0.58778524 0.76942098 -2.4110586e-08 0.58778524 0.80901712 + 0.24999999 0.58778524 0.76942098 0.47552827 0.58778524 0.65450853 0.65450853 0.58778524 0.4755283 + 0.76942092 0.58778524 0.25 0.809017 0.58778524 0 0.67249894 0.70710677 -0.21850814 + 0.57206178 0.70710677 -0.41562718 0.41562718 0.70710677 -0.57206172 0.21850812 0.70710677 -0.67249888 + 0 0.70710677 -0.70710713 -0.21850812 0.70710677 -0.67249882 -0.41562709 0.70710677 -0.5720616 + -0.57206154 0.70710677 -0.41562706 -0.6724987 0.70710677 -0.21850805 -0.70710695 0.70710677 0 + -0.6724987 0.70710677 0.21850805 -0.57206154 0.70710677 0.415627 -0.415627 0.70710677 0.57206148 + -0.21850805 0.70710677 0.67249858 -2.1073424e-08 0.70710677 0.70710683 0.21850799 0.70710677 0.67249858 + 0.41562691 0.70710677 0.57206142 0.57206142 0.70710677 0.41562697 0.67249852 0.70710677 0.21850802 + 0.70710677 0.70710677 0 0.55901736 0.809017 -0.18163574 0.47552857 0.809017 -0.34549171 + 0.34549171 0.809017 -0.47552854 0.18163572 0.809017 -0.5590173 0 0.809017 -0.58778554 + -0.18163572 0.809017 -0.55901724 -0.34549165 0.809017 -0.47552842 -0.47552839 0.809017 -0.34549159 + -0.55901712 0.809017 -0.18163566 -0.58778536 0.809017 0 -0.55901712 0.809017 0.18163566 + -0.47552836 0.809017 0.34549156 -0.34549156 0.809017 0.47552833 -0.18163566 0.809017 0.55901706 + -1.7517365e-08 0.809017 0.5877853 0.18163562 0.809017 0.55901706 0.3454915 0.809017 0.4755283 + 0.47552827 0.809017 0.34549153 0.559017 0.809017 0.18163563 0.58778524 0.809017 0 + 0.43177092 0.89100653 -0.14029087 0.36728629 0.89100653 -0.2668491 0.2668491 0.89100653 -0.36728626 + 0.14029086 0.89100653 -0.43177086 0 0.89100653 -0.45399073 -0.14029086 0.89100653 -0.43177083 + -0.26684904 0.89100653 -0.36728618 -0.36728615 0.89100653 -0.26684901 -0.43177077 0.89100653 -0.14029081 + -0.45399064 0.89100653 0 -0.43177077 0.89100653 0.14029081 -0.36728612 0.89100653 0.26684898; + setAttr ".vt[332:381]" -0.26684898 0.89100653 0.36728612 -0.14029081 0.89100653 0.43177071 + -1.3529972e-08 0.89100653 0.45399058 0.14029078 0.89100653 0.43177068 0.26684892 0.89100653 0.36728609 + 0.36728606 0.89100653 0.26684895 0.43177065 0.89100653 0.1402908 0.45399052 0.89100653 0 + 0.29389283 0.95105654 -0.095491566 0.25000018 0.95105654 -0.18163574 0.18163574 0.95105654 -0.25000015 + 0.095491551 0.95105654 -0.2938928 0 0.95105654 -0.30901715 -0.095491551 0.95105654 -0.29389277 + -0.18163571 0.95105654 -0.25000009 -0.25000009 0.95105654 -0.18163569 -0.29389271 0.95105654 -0.095491529 + -0.30901706 0.95105654 0 -0.29389271 0.95105654 0.095491529 -0.25000006 0.95105654 0.18163568 + -0.18163568 0.95105654 0.25000006 -0.095491529 0.95105654 0.29389268 -9.2094243e-09 0.95105654 0.30901703 + 0.095491499 0.95105654 0.29389265 0.18163563 0.95105654 0.25000003 0.25 0.95105654 0.18163565 + 0.29389265 0.95105654 0.095491506 0.309017 0.95105654 0 0.14877813 0.98768836 -0.048340943 + 0.12655823 0.98768836 -0.091949932 0.091949932 0.98768836 -0.12655823 0.048340935 0.98768836 -0.14877811 + 0 0.98768836 -0.15643455 -0.048340935 0.98768836 -0.1487781 -0.091949917 0.98768836 -0.1265582 + -0.12655818 0.98768836 -0.091949902 -0.14877807 0.98768836 -0.048340924 -0.15643452 0.98768836 0 + -0.14877807 0.98768836 0.048340924 -0.12655818 0.98768836 0.091949895 -0.091949895 0.98768836 0.12655817 + -0.048340924 0.98768836 0.14877805 -4.6621107e-09 0.98768836 0.15643449 0.048340909 0.98768836 0.14877804 + 0.09194988 0.98768836 0.12655815 0.12655815 0.98768836 0.091949888 0.14877804 0.98768836 0.048340913 + 0.15643448 0.98768836 0 0 -1 0 0 1 0; + setAttr -s 780 ".ed"; + setAttr ".ed[0:165]" 0 1 1 1 2 1 2 3 1 3 4 1 4 5 1 5 6 1 6 7 1 7 8 1 8 9 1 + 9 10 1 10 11 1 11 12 1 12 13 1 13 14 1 14 15 1 15 16 1 16 17 1 17 18 1 18 19 1 19 0 1 + 20 21 1 21 22 1 22 23 1 23 24 1 24 25 1 25 26 1 26 27 1 27 28 1 28 29 1 29 30 1 30 31 1 + 31 32 1 32 33 1 33 34 1 34 35 1 35 36 1 36 37 1 37 38 1 38 39 1 39 20 1 40 41 1 41 42 1 + 42 43 1 43 44 1 44 45 1 45 46 1 46 47 1 47 48 1 48 49 1 49 50 1 50 51 1 51 52 1 52 53 1 + 53 54 1 54 55 1 55 56 1 56 57 1 57 58 1 58 59 1 59 40 1 60 61 1 61 62 1 62 63 1 63 64 1 + 64 65 1 65 66 1 66 67 1 67 68 1 68 69 1 69 70 1 70 71 1 71 72 1 72 73 1 73 74 1 74 75 1 + 75 76 1 76 77 1 77 78 1 78 79 1 79 60 1 80 81 1 81 82 1 82 83 1 83 84 1 84 85 1 85 86 1 + 86 87 1 87 88 1 88 89 1 89 90 1 90 91 1 91 92 1 92 93 1 93 94 1 94 95 1 95 96 1 96 97 1 + 97 98 1 98 99 1 99 80 1 100 101 1 101 102 1 102 103 1 103 104 1 104 105 1 105 106 1 + 106 107 1 107 108 1 108 109 1 109 110 1 110 111 1 111 112 1 112 113 1 113 114 1 114 115 1 + 115 116 1 116 117 1 117 118 1 118 119 1 119 100 1 120 121 1 121 122 1 122 123 1 123 124 1 + 124 125 1 125 126 1 126 127 1 127 128 1 128 129 1 129 130 1 130 131 1 131 132 1 132 133 1 + 133 134 1 134 135 1 135 136 1 136 137 1 137 138 1 138 139 1 139 120 1 140 141 1 141 142 1 + 142 143 1 143 144 1 144 145 1 145 146 1 146 147 1 147 148 1 148 149 1 149 150 1 150 151 1 + 151 152 1 152 153 1 153 154 1 154 155 1 155 156 1 156 157 1 157 158 1 158 159 1 159 140 1 + 160 161 1 161 162 1 162 163 1 163 164 1 164 165 1 165 166 1; + setAttr ".ed[166:331]" 166 167 1 167 168 1 168 169 1 169 170 1 170 171 1 171 172 1 + 172 173 1 173 174 1 174 175 1 175 176 1 176 177 1 177 178 1 178 179 1 179 160 1 180 181 1 + 181 182 1 182 183 1 183 184 1 184 185 1 185 186 1 186 187 1 187 188 1 188 189 1 189 190 1 + 190 191 1 191 192 1 192 193 1 193 194 1 194 195 1 195 196 1 196 197 1 197 198 1 198 199 1 + 199 180 1 200 201 1 201 202 1 202 203 1 203 204 1 204 205 1 205 206 1 206 207 1 207 208 1 + 208 209 1 209 210 1 210 211 1 211 212 1 212 213 1 213 214 1 214 215 1 215 216 1 216 217 1 + 217 218 1 218 219 1 219 200 1 220 221 1 221 222 1 222 223 1 223 224 1 224 225 1 225 226 1 + 226 227 1 227 228 1 228 229 1 229 230 1 230 231 1 231 232 1 232 233 1 233 234 1 234 235 1 + 235 236 1 236 237 1 237 238 1 238 239 1 239 220 1 240 241 1 241 242 1 242 243 1 243 244 1 + 244 245 1 245 246 1 246 247 1 247 248 1 248 249 1 249 250 1 250 251 1 251 252 1 252 253 1 + 253 254 1 254 255 1 255 256 1 256 257 1 257 258 1 258 259 1 259 240 1 260 261 1 261 262 1 + 262 263 1 263 264 1 264 265 1 265 266 1 266 267 1 267 268 1 268 269 1 269 270 1 270 271 1 + 271 272 1 272 273 1 273 274 1 274 275 1 275 276 1 276 277 1 277 278 1 278 279 1 279 260 1 + 280 281 1 281 282 1 282 283 1 283 284 1 284 285 1 285 286 1 286 287 1 287 288 1 288 289 1 + 289 290 1 290 291 1 291 292 1 292 293 1 293 294 1 294 295 1 295 296 1 296 297 1 297 298 1 + 298 299 1 299 280 1 300 301 1 301 302 1 302 303 1 303 304 1 304 305 1 305 306 1 306 307 1 + 307 308 1 308 309 1 309 310 1 310 311 1 311 312 1 312 313 1 313 314 1 314 315 1 315 316 1 + 316 317 1 317 318 1 318 319 1 319 300 1 320 321 1 321 322 1 322 323 1 323 324 1 324 325 1 + 325 326 1 326 327 1 327 328 1 328 329 1 329 330 1 330 331 1 331 332 1; + setAttr ".ed[332:497]" 332 333 1 333 334 1 334 335 1 335 336 1 336 337 1 337 338 1 + 338 339 1 339 320 1 340 341 1 341 342 1 342 343 1 343 344 1 344 345 1 345 346 1 346 347 1 + 347 348 1 348 349 1 349 350 1 350 351 1 351 352 1 352 353 1 353 354 1 354 355 1 355 356 1 + 356 357 1 357 358 1 358 359 1 359 340 1 360 361 1 361 362 1 362 363 1 363 364 1 364 365 1 + 365 366 1 366 367 1 367 368 1 368 369 1 369 370 1 370 371 1 371 372 1 372 373 1 373 374 1 + 374 375 1 375 376 1 376 377 1 377 378 1 378 379 1 379 360 1 0 20 1 1 21 1 2 22 1 + 3 23 1 4 24 1 5 25 1 6 26 1 7 27 1 8 28 1 9 29 1 10 30 1 11 31 1 12 32 1 13 33 1 + 14 34 1 15 35 1 16 36 1 17 37 1 18 38 1 19 39 1 20 40 1 21 41 1 22 42 1 23 43 1 24 44 1 + 25 45 1 26 46 1 27 47 1 28 48 1 29 49 1 30 50 1 31 51 1 32 52 1 33 53 1 34 54 1 35 55 1 + 36 56 1 37 57 1 38 58 1 39 59 1 40 60 1 41 61 1 42 62 1 43 63 1 44 64 1 45 65 1 46 66 1 + 47 67 1 48 68 1 49 69 1 50 70 1 51 71 1 52 72 1 53 73 1 54 74 1 55 75 1 56 76 1 57 77 1 + 58 78 1 59 79 1 60 80 1 61 81 1 62 82 1 63 83 1 64 84 1 65 85 1 66 86 1 67 87 1 68 88 1 + 69 89 1 70 90 1 71 91 1 72 92 1 73 93 1 74 94 1 75 95 1 76 96 1 77 97 1 78 98 1 79 99 1 + 80 100 1 81 101 1 82 102 1 83 103 1 84 104 1 85 105 1 86 106 1 87 107 1 88 108 1 + 89 109 1 90 110 1 91 111 1 92 112 1 93 113 1 94 114 1 95 115 1 96 116 1 97 117 1 + 98 118 1 99 119 1 100 120 1 101 121 1 102 122 1 103 123 1 104 124 1 105 125 1 106 126 1 + 107 127 1 108 128 1 109 129 1 110 130 1 111 131 1 112 132 1 113 133 1 114 134 1 115 135 1 + 116 136 1 117 137 1; + setAttr ".ed[498:663]" 118 138 1 119 139 1 120 140 1 121 141 1 122 142 1 123 143 1 + 124 144 1 125 145 1 126 146 1 127 147 1 128 148 1 129 149 1 130 150 1 131 151 1 132 152 1 + 133 153 1 134 154 1 135 155 1 136 156 1 137 157 1 138 158 1 139 159 1 140 160 1 141 161 1 + 142 162 1 143 163 1 144 164 1 145 165 1 146 166 1 147 167 1 148 168 1 149 169 1 150 170 1 + 151 171 1 152 172 1 153 173 1 154 174 1 155 175 1 156 176 1 157 177 1 158 178 1 159 179 1 + 160 180 1 161 181 1 162 182 1 163 183 1 164 184 1 165 185 1 166 186 1 167 187 1 168 188 1 + 169 189 1 170 190 1 171 191 1 172 192 1 173 193 1 174 194 1 175 195 1 176 196 1 177 197 1 + 178 198 1 179 199 1 180 200 1 181 201 1 182 202 1 183 203 1 184 204 1 185 205 1 186 206 1 + 187 207 1 188 208 1 189 209 1 190 210 1 191 211 1 192 212 1 193 213 1 194 214 1 195 215 1 + 196 216 1 197 217 1 198 218 1 199 219 1 200 220 1 201 221 1 202 222 1 203 223 1 204 224 1 + 205 225 1 206 226 1 207 227 1 208 228 1 209 229 1 210 230 1 211 231 1 212 232 1 213 233 1 + 214 234 1 215 235 1 216 236 1 217 237 1 218 238 1 219 239 1 220 240 1 221 241 1 222 242 1 + 223 243 1 224 244 1 225 245 1 226 246 1 227 247 1 228 248 1 229 249 1 230 250 1 231 251 1 + 232 252 1 233 253 1 234 254 1 235 255 1 236 256 1 237 257 1 238 258 1 239 259 1 240 260 1 + 241 261 1 242 262 1 243 263 1 244 264 1 245 265 1 246 266 1 247 267 1 248 268 1 249 269 1 + 250 270 1 251 271 1 252 272 1 253 273 1 254 274 1 255 275 1 256 276 1 257 277 1 258 278 1 + 259 279 1 260 280 1 261 281 1 262 282 1 263 283 1 264 284 1 265 285 1 266 286 1 267 287 1 + 268 288 1 269 289 1 270 290 1 271 291 1 272 292 1 273 293 1 274 294 1 275 295 1 276 296 1 + 277 297 1 278 298 1 279 299 1 280 300 1 281 301 1 282 302 1 283 303 1; + setAttr ".ed[664:779]" 284 304 1 285 305 1 286 306 1 287 307 1 288 308 1 289 309 1 + 290 310 1 291 311 1 292 312 1 293 313 1 294 314 1 295 315 1 296 316 1 297 317 1 298 318 1 + 299 319 1 300 320 1 301 321 1 302 322 1 303 323 1 304 324 1 305 325 1 306 326 1 307 327 1 + 308 328 1 309 329 1 310 330 1 311 331 1 312 332 1 313 333 1 314 334 1 315 335 1 316 336 1 + 317 337 1 318 338 1 319 339 1 320 340 1 321 341 1 322 342 1 323 343 1 324 344 1 325 345 1 + 326 346 1 327 347 1 328 348 1 329 349 1 330 350 1 331 351 1 332 352 1 333 353 1 334 354 1 + 335 355 1 336 356 1 337 357 1 338 358 1 339 359 1 340 360 1 341 361 1 342 362 1 343 363 1 + 344 364 1 345 365 1 346 366 1 347 367 1 348 368 1 349 369 1 350 370 1 351 371 1 352 372 1 + 353 373 1 354 374 1 355 375 1 356 376 1 357 377 1 358 378 1 359 379 1 380 0 1 380 1 1 + 380 2 1 380 3 1 380 4 1 380 5 1 380 6 1 380 7 1 380 8 1 380 9 1 380 10 1 380 11 1 + 380 12 1 380 13 1 380 14 1 380 15 1 380 16 1 380 17 1 380 18 1 380 19 1 360 381 1 + 361 381 1 362 381 1 363 381 1 364 381 1 365 381 1 366 381 1 367 381 1 368 381 1 369 381 1 + 370 381 1 371 381 1 372 381 1 373 381 1 374 381 1 375 381 1 376 381 1 377 381 1 378 381 1 + 379 381 1; + setAttr -s 400 -ch 1560 ".fc[0:399]" -type "polyFaces" + f 4 0 381 -21 -381 + mu 0 4 0 1 22 21 + f 4 1 382 -22 -382 + mu 0 4 1 2 23 22 + f 4 2 383 -23 -383 + mu 0 4 2 3 24 23 + f 4 3 384 -24 -384 + mu 0 4 3 4 25 24 + f 4 4 385 -25 -385 + mu 0 4 4 5 26 25 + f 4 5 386 -26 -386 + mu 0 4 5 6 27 26 + f 4 6 387 -27 -387 + mu 0 4 6 7 28 27 + f 4 7 388 -28 -388 + mu 0 4 7 8 29 28 + f 4 8 389 -29 -389 + mu 0 4 8 9 30 29 + f 4 9 390 -30 -390 + mu 0 4 9 10 31 30 + f 4 10 391 -31 -391 + mu 0 4 10 11 32 31 + f 4 11 392 -32 -392 + mu 0 4 11 12 33 32 + f 4 12 393 -33 -393 + mu 0 4 12 13 34 33 + f 4 13 394 -34 -394 + mu 0 4 13 14 35 34 + f 4 14 395 -35 -395 + mu 0 4 14 15 36 35 + f 4 15 396 -36 -396 + mu 0 4 15 16 37 36 + f 4 16 397 -37 -397 + mu 0 4 16 17 38 37 + f 4 17 398 -38 -398 + mu 0 4 17 18 39 38 + f 4 18 399 -39 -399 + mu 0 4 18 19 40 39 + f 4 19 380 -40 -400 + mu 0 4 19 20 41 40 + f 4 20 401 -41 -401 + mu 0 4 21 22 43 42 + f 4 21 402 -42 -402 + mu 0 4 22 23 44 43 + f 4 22 403 -43 -403 + mu 0 4 23 24 45 44 + f 4 23 404 -44 -404 + mu 0 4 24 25 46 45 + f 4 24 405 -45 -405 + mu 0 4 25 26 47 46 + f 4 25 406 -46 -406 + mu 0 4 26 27 48 47 + f 4 26 407 -47 -407 + mu 0 4 27 28 49 48 + f 4 27 408 -48 -408 + mu 0 4 28 29 50 49 + f 4 28 409 -49 -409 + mu 0 4 29 30 51 50 + f 4 29 410 -50 -410 + mu 0 4 30 31 52 51 + f 4 30 411 -51 -411 + mu 0 4 31 32 53 52 + f 4 31 412 -52 -412 + mu 0 4 32 33 54 53 + f 4 32 413 -53 -413 + mu 0 4 33 34 55 54 + f 4 33 414 -54 -414 + mu 0 4 34 35 56 55 + f 4 34 415 -55 -415 + mu 0 4 35 36 57 56 + f 4 35 416 -56 -416 + mu 0 4 36 37 58 57 + f 4 36 417 -57 -417 + mu 0 4 37 38 59 58 + f 4 37 418 -58 -418 + mu 0 4 38 39 60 59 + f 4 38 419 -59 -419 + mu 0 4 39 40 61 60 + f 4 39 400 -60 -420 + mu 0 4 40 41 62 61 + f 4 40 421 -61 -421 + mu 0 4 42 43 64 63 + f 4 41 422 -62 -422 + mu 0 4 43 44 65 64 + f 4 42 423 -63 -423 + mu 0 4 44 45 66 65 + f 4 43 424 -64 -424 + mu 0 4 45 46 67 66 + f 4 44 425 -65 -425 + mu 0 4 46 47 68 67 + f 4 45 426 -66 -426 + mu 0 4 47 48 69 68 + f 4 46 427 -67 -427 + mu 0 4 48 49 70 69 + f 4 47 428 -68 -428 + mu 0 4 49 50 71 70 + f 4 48 429 -69 -429 + mu 0 4 50 51 72 71 + f 4 49 430 -70 -430 + mu 0 4 51 52 73 72 + f 4 50 431 -71 -431 + mu 0 4 52 53 74 73 + f 4 51 432 -72 -432 + mu 0 4 53 54 75 74 + f 4 52 433 -73 -433 + mu 0 4 54 55 76 75 + f 4 53 434 -74 -434 + mu 0 4 55 56 77 76 + f 4 54 435 -75 -435 + mu 0 4 56 57 78 77 + f 4 55 436 -76 -436 + mu 0 4 57 58 79 78 + f 4 56 437 -77 -437 + mu 0 4 58 59 80 79 + f 4 57 438 -78 -438 + mu 0 4 59 60 81 80 + f 4 58 439 -79 -439 + mu 0 4 60 61 82 81 + f 4 59 420 -80 -440 + mu 0 4 61 62 83 82 + f 4 60 441 -81 -441 + mu 0 4 63 64 85 84 + f 4 61 442 -82 -442 + mu 0 4 64 65 86 85 + f 4 62 443 -83 -443 + mu 0 4 65 66 87 86 + f 4 63 444 -84 -444 + mu 0 4 66 67 88 87 + f 4 64 445 -85 -445 + mu 0 4 67 68 89 88 + f 4 65 446 -86 -446 + mu 0 4 68 69 90 89 + f 4 66 447 -87 -447 + mu 0 4 69 70 91 90 + f 4 67 448 -88 -448 + mu 0 4 70 71 92 91 + f 4 68 449 -89 -449 + mu 0 4 71 72 93 92 + f 4 69 450 -90 -450 + mu 0 4 72 73 94 93 + f 4 70 451 -91 -451 + mu 0 4 73 74 95 94 + f 4 71 452 -92 -452 + mu 0 4 74 75 96 95 + f 4 72 453 -93 -453 + mu 0 4 75 76 97 96 + f 4 73 454 -94 -454 + mu 0 4 76 77 98 97 + f 4 74 455 -95 -455 + mu 0 4 77 78 99 98 + f 4 75 456 -96 -456 + mu 0 4 78 79 100 99 + f 4 76 457 -97 -457 + mu 0 4 79 80 101 100 + f 4 77 458 -98 -458 + mu 0 4 80 81 102 101 + f 4 78 459 -99 -459 + mu 0 4 81 82 103 102 + f 4 79 440 -100 -460 + mu 0 4 82 83 104 103 + f 4 80 461 -101 -461 + mu 0 4 84 85 106 105 + f 4 81 462 -102 -462 + mu 0 4 85 86 107 106 + f 4 82 463 -103 -463 + mu 0 4 86 87 108 107 + f 4 83 464 -104 -464 + mu 0 4 87 88 109 108 + f 4 84 465 -105 -465 + mu 0 4 88 89 110 109 + f 4 85 466 -106 -466 + mu 0 4 89 90 111 110 + f 4 86 467 -107 -467 + mu 0 4 90 91 112 111 + f 4 87 468 -108 -468 + mu 0 4 91 92 113 112 + f 4 88 469 -109 -469 + mu 0 4 92 93 114 113 + f 4 89 470 -110 -470 + mu 0 4 93 94 115 114 + f 4 90 471 -111 -471 + mu 0 4 94 95 116 115 + f 4 91 472 -112 -472 + mu 0 4 95 96 117 116 + f 4 92 473 -113 -473 + mu 0 4 96 97 118 117 + f 4 93 474 -114 -474 + mu 0 4 97 98 119 118 + f 4 94 475 -115 -475 + mu 0 4 98 99 120 119 + f 4 95 476 -116 -476 + mu 0 4 99 100 121 120 + f 4 96 477 -117 -477 + mu 0 4 100 101 122 121 + f 4 97 478 -118 -478 + mu 0 4 101 102 123 122 + f 4 98 479 -119 -479 + mu 0 4 102 103 124 123 + f 4 99 460 -120 -480 + mu 0 4 103 104 125 124 + f 4 100 481 -121 -481 + mu 0 4 105 106 127 126 + f 4 101 482 -122 -482 + mu 0 4 106 107 128 127 + f 4 102 483 -123 -483 + mu 0 4 107 108 129 128 + f 4 103 484 -124 -484 + mu 0 4 108 109 130 129 + f 4 104 485 -125 -485 + mu 0 4 109 110 131 130 + f 4 105 486 -126 -486 + mu 0 4 110 111 132 131 + f 4 106 487 -127 -487 + mu 0 4 111 112 133 132 + f 4 107 488 -128 -488 + mu 0 4 112 113 134 133 + f 4 108 489 -129 -489 + mu 0 4 113 114 135 134 + f 4 109 490 -130 -490 + mu 0 4 114 115 136 135 + f 4 110 491 -131 -491 + mu 0 4 115 116 137 136 + f 4 111 492 -132 -492 + mu 0 4 116 117 138 137 + f 4 112 493 -133 -493 + mu 0 4 117 118 139 138 + f 4 113 494 -134 -494 + mu 0 4 118 119 140 139 + f 4 114 495 -135 -495 + mu 0 4 119 120 141 140 + f 4 115 496 -136 -496 + mu 0 4 120 121 142 141 + f 4 116 497 -137 -497 + mu 0 4 121 122 143 142 + f 4 117 498 -138 -498 + mu 0 4 122 123 144 143 + f 4 118 499 -139 -499 + mu 0 4 123 124 145 144 + f 4 119 480 -140 -500 + mu 0 4 124 125 146 145 + f 4 120 501 -141 -501 + mu 0 4 126 127 148 147 + f 4 121 502 -142 -502 + mu 0 4 127 128 149 148 + f 4 122 503 -143 -503 + mu 0 4 128 129 150 149 + f 4 123 504 -144 -504 + mu 0 4 129 130 151 150 + f 4 124 505 -145 -505 + mu 0 4 130 131 152 151 + f 4 125 506 -146 -506 + mu 0 4 131 132 153 152 + f 4 126 507 -147 -507 + mu 0 4 132 133 154 153 + f 4 127 508 -148 -508 + mu 0 4 133 134 155 154 + f 4 128 509 -149 -509 + mu 0 4 134 135 156 155 + f 4 129 510 -150 -510 + mu 0 4 135 136 157 156 + f 4 130 511 -151 -511 + mu 0 4 136 137 158 157 + f 4 131 512 -152 -512 + mu 0 4 137 138 159 158 + f 4 132 513 -153 -513 + mu 0 4 138 139 160 159 + f 4 133 514 -154 -514 + mu 0 4 139 140 161 160 + f 4 134 515 -155 -515 + mu 0 4 140 141 162 161 + f 4 135 516 -156 -516 + mu 0 4 141 142 163 162 + f 4 136 517 -157 -517 + mu 0 4 142 143 164 163 + f 4 137 518 -158 -518 + mu 0 4 143 144 165 164 + f 4 138 519 -159 -519 + mu 0 4 144 145 166 165 + f 4 139 500 -160 -520 + mu 0 4 145 146 167 166 + f 4 140 521 -161 -521 + mu 0 4 147 148 169 168 + f 4 141 522 -162 -522 + mu 0 4 148 149 170 169 + f 4 142 523 -163 -523 + mu 0 4 149 150 171 170 + f 4 143 524 -164 -524 + mu 0 4 150 151 172 171 + f 4 144 525 -165 -525 + mu 0 4 151 152 173 172 + f 4 145 526 -166 -526 + mu 0 4 152 153 174 173 + f 4 146 527 -167 -527 + mu 0 4 153 154 175 174 + f 4 147 528 -168 -528 + mu 0 4 154 155 176 175 + f 4 148 529 -169 -529 + mu 0 4 155 156 177 176 + f 4 149 530 -170 -530 + mu 0 4 156 157 178 177 + f 4 150 531 -171 -531 + mu 0 4 157 158 179 178 + f 4 151 532 -172 -532 + mu 0 4 158 159 180 179 + f 4 152 533 -173 -533 + mu 0 4 159 160 181 180 + f 4 153 534 -174 -534 + mu 0 4 160 161 182 181 + f 4 154 535 -175 -535 + mu 0 4 161 162 183 182 + f 4 155 536 -176 -536 + mu 0 4 162 163 184 183 + f 4 156 537 -177 -537 + mu 0 4 163 164 185 184 + f 4 157 538 -178 -538 + mu 0 4 164 165 186 185 + f 4 158 539 -179 -539 + mu 0 4 165 166 187 186 + f 4 159 520 -180 -540 + mu 0 4 166 167 188 187 + f 4 160 541 -181 -541 + mu 0 4 168 169 190 189 + f 4 161 542 -182 -542 + mu 0 4 169 170 191 190 + f 4 162 543 -183 -543 + mu 0 4 170 171 192 191 + f 4 163 544 -184 -544 + mu 0 4 171 172 193 192 + f 4 164 545 -185 -545 + mu 0 4 172 173 194 193 + f 4 165 546 -186 -546 + mu 0 4 173 174 195 194 + f 4 166 547 -187 -547 + mu 0 4 174 175 196 195 + f 4 167 548 -188 -548 + mu 0 4 175 176 197 196 + f 4 168 549 -189 -549 + mu 0 4 176 177 198 197 + f 4 169 550 -190 -550 + mu 0 4 177 178 199 198 + f 4 170 551 -191 -551 + mu 0 4 178 179 200 199 + f 4 171 552 -192 -552 + mu 0 4 179 180 201 200 + f 4 172 553 -193 -553 + mu 0 4 180 181 202 201 + f 4 173 554 -194 -554 + mu 0 4 181 182 203 202 + f 4 174 555 -195 -555 + mu 0 4 182 183 204 203 + f 4 175 556 -196 -556 + mu 0 4 183 184 205 204 + f 4 176 557 -197 -557 + mu 0 4 184 185 206 205 + f 4 177 558 -198 -558 + mu 0 4 185 186 207 206 + f 4 178 559 -199 -559 + mu 0 4 186 187 208 207 + f 4 179 540 -200 -560 + mu 0 4 187 188 209 208 + f 4 180 561 -201 -561 + mu 0 4 189 190 211 210 + f 4 181 562 -202 -562 + mu 0 4 190 191 212 211 + f 4 182 563 -203 -563 + mu 0 4 191 192 213 212 + f 4 183 564 -204 -564 + mu 0 4 192 193 214 213 + f 4 184 565 -205 -565 + mu 0 4 193 194 215 214 + f 4 185 566 -206 -566 + mu 0 4 194 195 216 215 + f 4 186 567 -207 -567 + mu 0 4 195 196 217 216 + f 4 187 568 -208 -568 + mu 0 4 196 197 218 217 + f 4 188 569 -209 -569 + mu 0 4 197 198 219 218 + f 4 189 570 -210 -570 + mu 0 4 198 199 220 219 + f 4 190 571 -211 -571 + mu 0 4 199 200 221 220 + f 4 191 572 -212 -572 + mu 0 4 200 201 222 221 + f 4 192 573 -213 -573 + mu 0 4 201 202 223 222 + f 4 193 574 -214 -574 + mu 0 4 202 203 224 223 + f 4 194 575 -215 -575 + mu 0 4 203 204 225 224 + f 4 195 576 -216 -576 + mu 0 4 204 205 226 225 + f 4 196 577 -217 -577 + mu 0 4 205 206 227 226 + f 4 197 578 -218 -578 + mu 0 4 206 207 228 227 + f 4 198 579 -219 -579 + mu 0 4 207 208 229 228 + f 4 199 560 -220 -580 + mu 0 4 208 209 230 229 + f 4 200 581 -221 -581 + mu 0 4 210 211 232 231 + f 4 201 582 -222 -582 + mu 0 4 211 212 233 232 + f 4 202 583 -223 -583 + mu 0 4 212 213 234 233 + f 4 203 584 -224 -584 + mu 0 4 213 214 235 234 + f 4 204 585 -225 -585 + mu 0 4 214 215 236 235 + f 4 205 586 -226 -586 + mu 0 4 215 216 237 236 + f 4 206 587 -227 -587 + mu 0 4 216 217 238 237 + f 4 207 588 -228 -588 + mu 0 4 217 218 239 238 + f 4 208 589 -229 -589 + mu 0 4 218 219 240 239 + f 4 209 590 -230 -590 + mu 0 4 219 220 241 240 + f 4 210 591 -231 -591 + mu 0 4 220 221 242 241 + f 4 211 592 -232 -592 + mu 0 4 221 222 243 242 + f 4 212 593 -233 -593 + mu 0 4 222 223 244 243 + f 4 213 594 -234 -594 + mu 0 4 223 224 245 244 + f 4 214 595 -235 -595 + mu 0 4 224 225 246 245 + f 4 215 596 -236 -596 + mu 0 4 225 226 247 246 + f 4 216 597 -237 -597 + mu 0 4 226 227 248 247 + f 4 217 598 -238 -598 + mu 0 4 227 228 249 248 + f 4 218 599 -239 -599 + mu 0 4 228 229 250 249 + f 4 219 580 -240 -600 + mu 0 4 229 230 251 250 + f 4 220 601 -241 -601 + mu 0 4 231 232 253 252 + f 4 221 602 -242 -602 + mu 0 4 232 233 254 253 + f 4 222 603 -243 -603 + mu 0 4 233 234 255 254 + f 4 223 604 -244 -604 + mu 0 4 234 235 256 255 + f 4 224 605 -245 -605 + mu 0 4 235 236 257 256 + f 4 225 606 -246 -606 + mu 0 4 236 237 258 257 + f 4 226 607 -247 -607 + mu 0 4 237 238 259 258 + f 4 227 608 -248 -608 + mu 0 4 238 239 260 259 + f 4 228 609 -249 -609 + mu 0 4 239 240 261 260 + f 4 229 610 -250 -610 + mu 0 4 240 241 262 261 + f 4 230 611 -251 -611 + mu 0 4 241 242 263 262 + f 4 231 612 -252 -612 + mu 0 4 242 243 264 263 + f 4 232 613 -253 -613 + mu 0 4 243 244 265 264 + f 4 233 614 -254 -614 + mu 0 4 244 245 266 265 + f 4 234 615 -255 -615 + mu 0 4 245 246 267 266 + f 4 235 616 -256 -616 + mu 0 4 246 247 268 267 + f 4 236 617 -257 -617 + mu 0 4 247 248 269 268 + f 4 237 618 -258 -618 + mu 0 4 248 249 270 269 + f 4 238 619 -259 -619 + mu 0 4 249 250 271 270 + f 4 239 600 -260 -620 + mu 0 4 250 251 272 271 + f 4 240 621 -261 -621 + mu 0 4 252 253 274 273 + f 4 241 622 -262 -622 + mu 0 4 253 254 275 274 + f 4 242 623 -263 -623 + mu 0 4 254 255 276 275 + f 4 243 624 -264 -624 + mu 0 4 255 256 277 276 + f 4 244 625 -265 -625 + mu 0 4 256 257 278 277 + f 4 245 626 -266 -626 + mu 0 4 257 258 279 278 + f 4 246 627 -267 -627 + mu 0 4 258 259 280 279 + f 4 247 628 -268 -628 + mu 0 4 259 260 281 280 + f 4 248 629 -269 -629 + mu 0 4 260 261 282 281 + f 4 249 630 -270 -630 + mu 0 4 261 262 283 282 + f 4 250 631 -271 -631 + mu 0 4 262 263 284 283 + f 4 251 632 -272 -632 + mu 0 4 263 264 285 284 + f 4 252 633 -273 -633 + mu 0 4 264 265 286 285 + f 4 253 634 -274 -634 + mu 0 4 265 266 287 286 + f 4 254 635 -275 -635 + mu 0 4 266 267 288 287 + f 4 255 636 -276 -636 + mu 0 4 267 268 289 288 + f 4 256 637 -277 -637 + mu 0 4 268 269 290 289 + f 4 257 638 -278 -638 + mu 0 4 269 270 291 290 + f 4 258 639 -279 -639 + mu 0 4 270 271 292 291 + f 4 259 620 -280 -640 + mu 0 4 271 272 293 292 + f 4 260 641 -281 -641 + mu 0 4 273 274 295 294 + f 4 261 642 -282 -642 + mu 0 4 274 275 296 295 + f 4 262 643 -283 -643 + mu 0 4 275 276 297 296 + f 4 263 644 -284 -644 + mu 0 4 276 277 298 297 + f 4 264 645 -285 -645 + mu 0 4 277 278 299 298 + f 4 265 646 -286 -646 + mu 0 4 278 279 300 299 + f 4 266 647 -287 -647 + mu 0 4 279 280 301 300 + f 4 267 648 -288 -648 + mu 0 4 280 281 302 301 + f 4 268 649 -289 -649 + mu 0 4 281 282 303 302 + f 4 269 650 -290 -650 + mu 0 4 282 283 304 303 + f 4 270 651 -291 -651 + mu 0 4 283 284 305 304 + f 4 271 652 -292 -652 + mu 0 4 284 285 306 305 + f 4 272 653 -293 -653 + mu 0 4 285 286 307 306 + f 4 273 654 -294 -654 + mu 0 4 286 287 308 307 + f 4 274 655 -295 -655 + mu 0 4 287 288 309 308 + f 4 275 656 -296 -656 + mu 0 4 288 289 310 309 + f 4 276 657 -297 -657 + mu 0 4 289 290 311 310 + f 4 277 658 -298 -658 + mu 0 4 290 291 312 311 + f 4 278 659 -299 -659 + mu 0 4 291 292 313 312 + f 4 279 640 -300 -660 + mu 0 4 292 293 314 313 + f 4 280 661 -301 -661 + mu 0 4 294 295 316 315 + f 4 281 662 -302 -662 + mu 0 4 295 296 317 316 + f 4 282 663 -303 -663 + mu 0 4 296 297 318 317 + f 4 283 664 -304 -664 + mu 0 4 297 298 319 318 + f 4 284 665 -305 -665 + mu 0 4 298 299 320 319 + f 4 285 666 -306 -666 + mu 0 4 299 300 321 320 + f 4 286 667 -307 -667 + mu 0 4 300 301 322 321 + f 4 287 668 -308 -668 + mu 0 4 301 302 323 322 + f 4 288 669 -309 -669 + mu 0 4 302 303 324 323 + f 4 289 670 -310 -670 + mu 0 4 303 304 325 324 + f 4 290 671 -311 -671 + mu 0 4 304 305 326 325 + f 4 291 672 -312 -672 + mu 0 4 305 306 327 326 + f 4 292 673 -313 -673 + mu 0 4 306 307 328 327 + f 4 293 674 -314 -674 + mu 0 4 307 308 329 328 + f 4 294 675 -315 -675 + mu 0 4 308 309 330 329 + f 4 295 676 -316 -676 + mu 0 4 309 310 331 330 + f 4 296 677 -317 -677 + mu 0 4 310 311 332 331 + f 4 297 678 -318 -678 + mu 0 4 311 312 333 332 + f 4 298 679 -319 -679 + mu 0 4 312 313 334 333 + f 4 299 660 -320 -680 + mu 0 4 313 314 335 334 + f 4 300 681 -321 -681 + mu 0 4 315 316 337 336 + f 4 301 682 -322 -682 + mu 0 4 316 317 338 337 + f 4 302 683 -323 -683 + mu 0 4 317 318 339 338 + f 4 303 684 -324 -684 + mu 0 4 318 319 340 339 + f 4 304 685 -325 -685 + mu 0 4 319 320 341 340 + f 4 305 686 -326 -686 + mu 0 4 320 321 342 341 + f 4 306 687 -327 -687 + mu 0 4 321 322 343 342 + f 4 307 688 -328 -688 + mu 0 4 322 323 344 343 + f 4 308 689 -329 -689 + mu 0 4 323 324 345 344 + f 4 309 690 -330 -690 + mu 0 4 324 325 346 345 + f 4 310 691 -331 -691 + mu 0 4 325 326 347 346 + f 4 311 692 -332 -692 + mu 0 4 326 327 348 347 + f 4 312 693 -333 -693 + mu 0 4 327 328 349 348 + f 4 313 694 -334 -694 + mu 0 4 328 329 350 349 + f 4 314 695 -335 -695 + mu 0 4 329 330 351 350 + f 4 315 696 -336 -696 + mu 0 4 330 331 352 351 + f 4 316 697 -337 -697 + mu 0 4 331 332 353 352 + f 4 317 698 -338 -698 + mu 0 4 332 333 354 353 + f 4 318 699 -339 -699 + mu 0 4 333 334 355 354 + f 4 319 680 -340 -700 + mu 0 4 334 335 356 355 + f 4 320 701 -341 -701 + mu 0 4 336 337 358 357 + f 4 321 702 -342 -702 + mu 0 4 337 338 359 358 + f 4 322 703 -343 -703 + mu 0 4 338 339 360 359 + f 4 323 704 -344 -704 + mu 0 4 339 340 361 360 + f 4 324 705 -345 -705 + mu 0 4 340 341 362 361 + f 4 325 706 -346 -706 + mu 0 4 341 342 363 362 + f 4 326 707 -347 -707 + mu 0 4 342 343 364 363 + f 4 327 708 -348 -708 + mu 0 4 343 344 365 364 + f 4 328 709 -349 -709 + mu 0 4 344 345 366 365 + f 4 329 710 -350 -710 + mu 0 4 345 346 367 366 + f 4 330 711 -351 -711 + mu 0 4 346 347 368 367 + f 4 331 712 -352 -712 + mu 0 4 347 348 369 368 + f 4 332 713 -353 -713 + mu 0 4 348 349 370 369 + f 4 333 714 -354 -714 + mu 0 4 349 350 371 370 + f 4 334 715 -355 -715 + mu 0 4 350 351 372 371 + f 4 335 716 -356 -716 + mu 0 4 351 352 373 372 + f 4 336 717 -357 -717 + mu 0 4 352 353 374 373 + f 4 337 718 -358 -718 + mu 0 4 353 354 375 374 + f 4 338 719 -359 -719 + mu 0 4 354 355 376 375 + f 4 339 700 -360 -720 + mu 0 4 355 356 377 376 + f 4 340 721 -361 -721 + mu 0 4 357 358 379 378 + f 4 341 722 -362 -722 + mu 0 4 358 359 380 379 + f 4 342 723 -363 -723 + mu 0 4 359 360 381 380 + f 4 343 724 -364 -724 + mu 0 4 360 361 382 381 + f 4 344 725 -365 -725 + mu 0 4 361 362 383 382 + f 4 345 726 -366 -726 + mu 0 4 362 363 384 383 + f 4 346 727 -367 -727 + mu 0 4 363 364 385 384 + f 4 347 728 -368 -728 + mu 0 4 364 365 386 385 + f 4 348 729 -369 -729 + mu 0 4 365 366 387 386 + f 4 349 730 -370 -730 + mu 0 4 366 367 388 387 + f 4 350 731 -371 -731 + mu 0 4 367 368 389 388 + f 4 351 732 -372 -732 + mu 0 4 368 369 390 389 + f 4 352 733 -373 -733 + mu 0 4 369 370 391 390 + f 4 353 734 -374 -734 + mu 0 4 370 371 392 391 + f 4 354 735 -375 -735 + mu 0 4 371 372 393 392 + f 4 355 736 -376 -736 + mu 0 4 372 373 394 393 + f 4 356 737 -377 -737 + mu 0 4 373 374 395 394 + f 4 357 738 -378 -738 + mu 0 4 374 375 396 395 + f 4 358 739 -379 -739 + mu 0 4 375 376 397 396 + f 4 359 720 -380 -740 + mu 0 4 376 377 398 397 + f 3 -1 -741 741 + mu 0 3 1 0 399 + f 3 -2 -742 742 + mu 0 3 2 1 400 + f 3 -3 -743 743 + mu 0 3 3 2 401 + f 3 -4 -744 744 + mu 0 3 4 3 402 + f 3 -5 -745 745 + mu 0 3 5 4 403 + f 3 -6 -746 746 + mu 0 3 6 5 404 + f 3 -7 -747 747 + mu 0 3 7 6 405 + f 3 -8 -748 748 + mu 0 3 8 7 406 + f 3 -9 -749 749 + mu 0 3 9 8 407 + f 3 -10 -750 750 + mu 0 3 10 9 408 + f 3 -11 -751 751 + mu 0 3 11 10 409 + f 3 -12 -752 752 + mu 0 3 12 11 410 + f 3 -13 -753 753 + mu 0 3 13 12 411 + f 3 -14 -754 754 + mu 0 3 14 13 412 + f 3 -15 -755 755 + mu 0 3 15 14 413 + f 3 -16 -756 756 + mu 0 3 16 15 414 + f 3 -17 -757 757 + mu 0 3 17 16 415 + f 3 -18 -758 758 + mu 0 3 18 17 416 + f 3 -19 -759 759 + mu 0 3 19 18 417 + f 3 -20 -760 740 + mu 0 3 20 19 418 + f 3 360 761 -761 + mu 0 3 378 379 419 + f 3 361 762 -762 + mu 0 3 379 380 420 + f 3 362 763 -763 + mu 0 3 380 381 421 + f 3 363 764 -764 + mu 0 3 381 382 422 + f 3 364 765 -765 + mu 0 3 382 383 423 + f 3 365 766 -766 + mu 0 3 383 384 424 + f 3 366 767 -767 + mu 0 3 384 385 425 + f 3 367 768 -768 + mu 0 3 385 386 426 + f 3 368 769 -769 + mu 0 3 386 387 427 + f 3 369 770 -770 + mu 0 3 387 388 428 + f 3 370 771 -771 + mu 0 3 388 389 429 + f 3 371 772 -772 + mu 0 3 389 390 430 + f 3 372 773 -773 + mu 0 3 390 391 431 + f 3 373 774 -774 + mu 0 3 391 392 432 + f 3 374 775 -775 + mu 0 3 392 393 433 + f 3 375 776 -776 + mu 0 3 393 394 434 + f 3 376 777 -777 + mu 0 3 394 395 435 + f 3 377 778 -778 + mu 0 3 395 396 436 + f 3 378 779 -779 + mu 0 3 396 397 437 + f 3 379 760 -780 + mu 0 3 397 398 438; + setAttr ".cd" -type "dataPolyComponent" Index_Data Edge 0 ; + setAttr ".cvd" -type "dataPolyComponent" Index_Data Vertex 0 ; + setAttr ".pd[0]" -type "dataPolyComponent" Index_Data UV 0 ; + setAttr ".hfd" -type "dataPolyComponent" Index_Data Face 0 ; + setAttr ".dr" 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:6c77a15a98a9"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :lightList1; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//_"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".dar" 1.7777777910232544; +select -ne :defaultLightSet; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +// End of modelMain.ma diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.exr b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.exr new file mode 100644 index 0000000000..b137a46dfd Binary files /dev/null and b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.exr differ diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.jpg b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.jpg new file mode 100644 index 0000000000..79ff799e8b Binary files /dev/null and b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001.jpg differ diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001_png.png b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001_png.png new file mode 100644 index 0000000000..09590fd9da Binary files /dev/null and b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/render/renderTest_taskMain_beauty/v001/test_project_test_asset_renderTest_taskMain_beauty_v001_png.png differ diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/workfile/workfileTest_task/v001/test_project_test_asset_workfileTest_task_v001.ma b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/workfile/workfileTest_task/v001/test_project_test_asset_workfileTest_task_v001.ma new file mode 100644 index 0000000000..c476a78086 --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/publish/workfile/workfileTest_task/v001/test_project_test_asset_workfileTest_task_v001.ma @@ -0,0 +1,525 @@ +//Maya ASCII 2023 scene +//Name: test_project_test_asset_test_task_v002.ma +//Last modified: Thu, Nov 09, 2023 11:59:33 AM +//Codeset: 1252 +requires maya "2023"; +requires -nodeType "simpleSelector" -nodeType "renderSetupLayer" -nodeType "renderSetup" + -nodeType "collection" "renderSetup.py" "1.0"; +requires -nodeType "aiOptions" -nodeType "aiAOVDriver" -nodeType "aiAOVFilter" -nodeType "aiSkyDomeLight" + "mtoa" "5.2.1.1"; +requires -nodeType "polyDisc" "modelingToolkit" "0.0.0.0"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2023"; +fileInfo "version" "2023"; +fileInfo "cutIdentifier" "202211021031-847a9f9623"; +fileInfo "osv" "Windows 10 Pro v2009 (Build: 19045)"; +fileInfo "license" "education"; +fileInfo "UUID" "591BA477-4DBF-D8A9-D1CE-AEB4E5E95DCA"; +fileInfo "OpenPypeContext" "eyJwdWJsaXNoX2F0dHJpYnV0ZXMiOiB7IlZhbGlkYXRlQ29udGFpbmVycyI6IHsiYWN0aXZlIjogdHJ1ZX19fQ=="; +createNode transform -s -n "persp"; + rename -uid "D52C935B-47C9-D868-A875-D799DD17B3A1"; + setAttr ".v" no; + setAttr ".t" -type "double3" 33.329836010894773 18.034068470832839 24.890981774804157 ; + setAttr ".r" -type "double3" 79.461647270402509 -44.999999999997357 183.99999999999159 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -s -n "perspShape" -p "persp"; + rename -uid "2399E6C0-490F-BA1F-485F-5AA8A01D27BC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 50.609449488607154; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -s -n "top"; + rename -uid "415C7426-413E-0FAE-FCC3-3DAED7443A52"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 1000.1 0 ; + setAttr ".r" -type "double3" 90 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" 0 -1000.1 1000.1 ; +createNode camera -s -n "topShape" -p "top"; + rename -uid "3BD0CF60-40DB-5278-5D8B-06ACBDA32122"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "top"; + setAttr ".den" -type "string" "top_depth"; + setAttr ".man" -type "string" "top_mask"; + setAttr ".hc" -type "string" "viewSet -t %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "front"; + rename -uid "D83DD5CE-4FE0-AB1B-81B2-87A63F0B7A05"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 0 1000.1 ; + setAttr ".r" -type "double3" 180 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; +createNode camera -s -n "frontShape" -p "front"; + rename -uid "23313CBA-42C2-0B3A-0FCF-EA965EAC5DEC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "front"; + setAttr ".den" -type "string" "front_depth"; + setAttr ".man" -type "string" "front_mask"; + setAttr ".hc" -type "string" "viewSet -f %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "side"; + rename -uid "F70F692C-4A0D-BE64-9EE4-A99B6FA2D56E"; + setAttr ".v" no; + setAttr ".t" -type "double3" 1000.1 0 0 ; + setAttr ".r" -type "double3" 180 -90 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" -1000.1 0 1000.1 ; +createNode camera -s -n "sideShape" -p "side"; + rename -uid "C05669C3-420E-CA11-E5FC-7EB64EF8B632"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "side"; + setAttr ".den" -type "string" "side_depth"; + setAttr ".man" -type "string" "side_mask"; + setAttr ".hc" -type "string" "viewSet -s %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -n "pDisc1"; + rename -uid "DED70CCF-4C19-16E4-9E5D-66A05037BA47"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:90e762703f08"; +createNode mesh -n "pDiscShape1" -p "pDisc1"; + rename -uid "E1FCDCCF-4DE1-D3B9-C4F8-3285F1CF5B25"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:4ee3da11a1a4"; +createNode transform -n "aiSkyDomeLight1"; + rename -uid "402BF091-4305-22E3-7CF0-9BA3D7F948F7"; +createNode aiSkyDomeLight -n "aiSkyDomeLightShape1" -p "aiSkyDomeLight1"; + rename -uid "CEF32074-4066-553D-A4FD-65B508A56ABE"; + addAttr -ci true -h true -sn "aal" -ln "attributeAliasList" -dt "attributeAlias"; + setAttr -k off ".v"; + setAttr ".csh" no; + setAttr ".rcsh" no; + setAttr ".aal" -type "attributeAlias" {"exposure","aiExposure"} ; +createNode transform -n "mainCamera"; + rename -uid "58651370-474E-02EE-39A9-A2AB27E0DD87"; + setAttr ".t" -type "double3" 33.329836010894773 18.034068470832839 24.890981774804157 ; + setAttr ".r" -type "double3" 79.461647270402509 -44.999999999997357 183.99999999999159 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -n "mainCameraShape" -p "mainCamera"; + rename -uid "CCE11369-4101-EE5E-5381-3F87DA963CA2"; + setAttr -k off ".v"; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 50.609449488607154; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -n "model_GRP"; + rename -uid "445FDC20-4A9D-2C5B-C7BD-F98F6E660B5C"; +createNode transform -n "pSphere1_GEO" -p "model_GRP"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:302a4c6123a4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:6c77a15a98a9"; +createNode lightLinker -s -n "lightLinker1"; + rename -uid "D9ADDBD2-49DE-91E2-4166-99A362986A3A"; + setAttr -s 2 ".lnk"; + setAttr -s 2 ".slnk"; +createNode shapeEditorManager -n "shapeEditorManager"; + rename -uid "A232A3B1-4B62-92E7-A7C9-9D9FC5EF010A"; +createNode poseInterpolatorManager -n "poseInterpolatorManager"; + rename -uid "B30BA35D-492A-834B-3448-49A80BBBFC39"; +createNode displayLayerManager -n "layerManager"; + rename -uid "4417380F-4A6A-16CC-B1DE-AA95ED9C7FB2"; +createNode displayLayer -n "defaultLayer"; + rename -uid "4A776D1B-401F-7069-1C74-A7AAE84CEE03"; + setAttr ".ufem" -type "stringArray" 0 ; +createNode renderLayerManager -n "renderLayerManager"; + rename -uid "EE21C644-43B8-C754-0BED-709D2EEB204D"; + setAttr -s 2 ".rlmi[1]" 1; + setAttr -s 2 ".rlmi"; +createNode renderLayer -n "defaultRenderLayer"; + rename -uid "B134920D-4508-23BD-A6CA-11B43DE03F53"; + setAttr ".g" yes; +createNode renderSetup -n "renderSetup"; + rename -uid "9A8F0D15-41AB-CA70-C2D8-B78840BF9BC1"; +createNode polySphere -n "polySphere1"; + rename -uid "DA319706-4ACF-B15C-53B2-48AC80D202EA"; +createNode script -n "uiConfigurationScriptNode"; + rename -uid "4B7AFB53-452E-E870-63E1-CCA1DD6EAF13"; + setAttr ".b" -type "string" ( + "// Maya Mel UI Configuration File.\n//\n// This script is machine generated. Edit at your own risk.\n//\n//\n\nglobal string $gMainPane;\nif (`paneLayout -exists $gMainPane`) {\n\n\tglobal int $gUseScenePanelConfig;\n\tint $useSceneConfig = $gUseScenePanelConfig;\n\tint $nodeEditorPanelVisible = stringArrayContains(\"nodeEditorPanel1\", `getPanel -vis`);\n\tint $nodeEditorWorkspaceControlOpen = (`workspaceControl -exists nodeEditorPanel1Window` && `workspaceControl -q -visible nodeEditorPanel1Window`);\n\tint $menusOkayInPanels = `optionVar -q allowMenusInPanels`;\n\tint $nVisPanes = `paneLayout -q -nvp $gMainPane`;\n\tint $nPanes = 0;\n\tstring $editorName;\n\tstring $panelName;\n\tstring $itemFilterName;\n\tstring $panelConfig;\n\n\t//\n\t// get current state of the UI\n\t//\n\tsceneUIReplacement -update $gMainPane;\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Top View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Top View\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n" + + " -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n" + + " -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 477\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Side View\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Side View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n" + + " -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n" + + " -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 476\n -height 345\n -sceneRenderFilter 0\n $editorName;\n" + + " modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Front View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Front View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|top\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n" + + " -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n" + + " -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n" + + " -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 477\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Persp View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Persp View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|mainCamera\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n" + + " -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n" + + " -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n" + + " -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 476\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"ToggledOutliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"ToggledOutliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -docTag \"isolOutln_fromSeln\" \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 1\n -showReferenceMembers 1\n" + + " -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n" + + " -isSet 0\n -isSetMember 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -selectCommand \"print(\\\"\\\")\" \n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n -renderFilterIndex 0\n -selectionOrder \"chronological\" \n -expandAttribute 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"Outliner\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"Outliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n" + + " -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n" + + " $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"graphEditor\" (localizedPanelLabel(\"Graph Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Graph Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 1\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n" + + " -showPublishedAsConnected 0\n -showParentContainers 1\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 0\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 1\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 1\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n" + + " -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 1\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"GraphEd\");\n animCurveEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -showPlayRangeShades \"on\" \n -lockPlayRangeShades \"off\" \n -smoothness \"fine\" \n -resultSamples 1.041667\n -resultScreenSamples 0\n -resultUpdate \"delayed\" \n -showUpstreamCurves 1\n -keyMinScale 1\n -stackedCurvesMin -1\n -stackedCurvesMax 1\n" + + " -stackedCurvesSpace 0.2\n -preSelectionHighlight 0\n -constrainDrag 0\n -valueLinesToggle 1\n -highlightAffectedCurves 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dopeSheetPanel\" (localizedPanelLabel(\"Dope Sheet\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dope Sheet\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n" + + " -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 1\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 0\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 1\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n" + + " -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"DopeSheetEd\");\n dopeSheetEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -outliner \"dopeSheetPanel1OutlineEd\" \n -showSummary 1\n -showScene 0\n -hierarchyBelow 0\n" + + " -showTicks 1\n -selectionWindow 0 0 0 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"timeEditorPanel\" (localizedPanelLabel(\"Time Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Time Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"clipEditorPanel\" (localizedPanelLabel(\"Trax Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Trax Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = clipEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 0 \n" + + " $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"sequenceEditorPanel\" (localizedPanelLabel(\"Camera Sequencer\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Camera Sequencer\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = sequenceEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperGraphPanel\" (localizedPanelLabel(\"Hypergraph Hierarchy\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypergraph Hierarchy\")) -mbv $menusOkayInPanels $panelName;\n" + + "\n\t\t\t$editorName = ($panelName+\"HyperGraphEd\");\n hyperGraph -e \n -graphLayoutStyle \"hierarchicalLayout\" \n -orientation \"horiz\" \n -mergeConnections 0\n -zoom 1\n -animateTransition 0\n -showRelationships 1\n -showShapes 0\n -showDeformers 0\n -showExpressions 0\n -showConstraints 0\n -showConnectionFromSelected 0\n -showConnectionToSelected 0\n -showConstraintLabels 0\n -showUnderworld 0\n -showInvisible 0\n -transitionFrames 1\n -opaqueContainers 0\n -freeform 0\n -imagePosition 0 0 \n -imageScale 1\n -imageEnabled 0\n -graphType \"DAG\" \n -heatMapDisplay 0\n -updateSelection 1\n -updateNodeAdded 1\n -useDrawOverrideColor 0\n -limitGraphTraversal -1\n" + + " -range 0 0 \n -iconSize \"smallIcons\" \n -showCachedConnections 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperShadePanel\" (localizedPanelLabel(\"Hypershade\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypershade\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"visorPanel\" (localizedPanelLabel(\"Visor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Visor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"nodeEditorPanel\" (localizedPanelLabel(\"Node Editor\")) `;\n\tif ($nodeEditorPanelVisible || $nodeEditorWorkspaceControlOpen) {\n" + + "\t\tif (\"\" == $panelName) {\n\t\t\tif ($useSceneConfig) {\n\t\t\t\t$panelName = `scriptedPanel -unParent -type \"nodeEditorPanel\" -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels `;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 1\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n" + + " -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\t}\n\t\t} else {\n\t\t\t$label = `panel -q -label $panelName`;\n\t\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n" + + " -additiveGraphingMode 1\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\tif (!$useSceneConfig) {\n\t\t\t\tpanel -e -l $label $panelName;\n\t\t\t}\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"createNodePanel\" (localizedPanelLabel(\"Create Node\")) `;\n\tif (\"\" != $panelName) {\n" + + "\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Create Node\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"polyTexturePlacementPanel\" (localizedPanelLabel(\"UV Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"UV Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"renderWindowPanel\" (localizedPanelLabel(\"Render View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Render View\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"shapePanel\" (localizedPanelLabel(\"Shape Editor\")) `;\n\tif (\"\" != $panelName) {\n" + + "\t\t$label = `panel -q -label $panelName`;\n\t\tshapePanel -edit -l (localizedPanelLabel(\"Shape Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"posePanel\" (localizedPanelLabel(\"Pose Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tposePanel -edit -l (localizedPanelLabel(\"Pose Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynRelEdPanel\" (localizedPanelLabel(\"Dynamic Relationships\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dynamic Relationships\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"relationshipPanel\" (localizedPanelLabel(\"Relationship Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Relationship Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"referenceEditorPanel\" (localizedPanelLabel(\"Reference Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Reference Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynPaintScriptedPanelType\" (localizedPanelLabel(\"Paint Effects\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Paint Effects\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"scriptEditorPanel\" (localizedPanelLabel(\"Script Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Script Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"profilerPanel\" (localizedPanelLabel(\"Profiler Tool\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Profiler Tool\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"contentBrowserPanel\" (localizedPanelLabel(\"Content Browser\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Content Browser\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\tif ($useSceneConfig) {\n string $configName = `getPanel -cwl (localizedPanelLabel(\"Current Layout\"))`;\n" + + " if (\"\" != $configName) {\n\t\t\tpanelConfiguration -edit -label (localizedPanelLabel(\"Current Layout\")) \n\t\t\t\t-userCreated false\n\t\t\t\t-defaultImage \"vacantCell.xP:/\"\n\t\t\t\t-image \"\"\n\t\t\t\t-sc false\n\t\t\t\t-configString \"global string $gMainPane; paneLayout -e -cn \\\"quad\\\" -ps 1 50 50 -ps 2 50 50 -ps 3 50 50 -ps 4 50 50 $gMainPane;\"\n\t\t\t\t-removeAllPanels\n\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Top View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Top View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera top` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Top View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera top` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Persp View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|mainCamera\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|mainCamera\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Side View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Side View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera side` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Side View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera side` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Front View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Front View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|top\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Front View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|top\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t$configName;\n\n setNamedPanelLayout (localizedPanelLabel(\"Current Layout\"));\n }\n\n panelHistory -e -clear mainPanelHistory;\n sceneUIReplacement -clear;\n\t}\n\n\ngrid -spacing 5 -size 12 -divisions 5 -displayAxes yes -displayGridLines yes -displayDivisionLines yes -displayPerspectiveLabels no -displayOrthographicLabels no -displayAxesBold yes -perspectiveLabelPosition axis -orthographicLabelPosition edge;\nviewManip -drawCompass 0 -compassAngle 0 -frontParameters \"\" -homeParameters \"\" -selectionLockParameters \"\";\n}\n"); + setAttr ".st" 3; +createNode script -n "sceneConfigurationScriptNode"; + rename -uid "72B19BC2-43A2-E229-0A73-2CB861A291D1"; + setAttr ".b" -type "string" "playbackOptions -min 1000 -max 1001 -ast 1000 -aet 1001 "; + setAttr ".st" 6; +createNode polyDisc -n "polyDisc1"; + rename -uid "9ED8A7BD-4FFD-6107-4322-35ACD1D3AC42"; +createNode aiOptions -s -n "defaultArnoldRenderOptions"; + rename -uid "31A81965-48A6-B90D-503D-2FA162B7C982"; +createNode aiAOVFilter -s -n "defaultArnoldFilter"; + rename -uid "77A2BCB1-4613-905E-080E-B997FD5E1C6F"; + setAttr ".ai_translator" -type "string" "gaussian"; +createNode aiAOVDriver -s -n "defaultArnoldDriver"; + rename -uid "C05729BE-4A33-F1DA-C222-3F8AB6EE7504"; + setAttr ".ai_translator" -type "string" "exr"; +createNode aiAOVDriver -s -n "defaultArnoldDisplayDriver"; + rename -uid "806C25D7-4284-C09D-A8AE-4A80DBFFFAAF"; + setAttr ".output_mode" 0; + setAttr ".ai_translator" -type "string" "maya"; +createNode renderSetupLayer -n "Main"; + rename -uid "DC3F077F-49F5-1D64-BFF3-AAAF06798636"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode renderLayer -n "rs_Main"; + rename -uid "D798EE14-43EE-D8EF-F4C9-D6B19C9BC029"; + setAttr ".do" 1; +createNode collection -n "defaultCollection"; + rename -uid "0194FCB7-43C4-DC06-C8D6-D9BA2721CCFA"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode simpleSelector -n "defaultCollectionSelector"; + rename -uid "37D69395-4785-D0BE-AEA0-EEA66D1FAEDF"; + setAttr ".pat" -type "string" "*"; +createNode objectSet -n "modelMain"; + rename -uid "811E4501-4B64-3016-BE29-E18EC09D90B7"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "writeColorSets" -ln "writeColorSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "writeFaceSets" -ln "writeFaceSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "attr" -ln "attr" -dt "string"; + addAttr -ci true -sn "attrPrefix" -ln "attrPrefix" -dt "string"; + addAttr -ci true -sn "includeParentHierarchy" -ln "includeParentHierarchy" -min + 0 -max 1 -at "bool"; + 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" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:49991563bf50"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "model"; + setAttr ".subset" -type "string" "modelMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.model"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr -cb on ".writeColorSets"; + setAttr -cb on ".writeFaceSets"; + setAttr ".attr" -type "string" ""; + setAttr ".attrPrefix" -type "string" ""; + setAttr -cb on ".includeParentHierarchy"; + setAttr ".task" -type "string" "test_task"; + setAttr ".publish_attributes" -type "string" "{\"ValidateNodeIDsRelated\": {\"active\": true}, \"ValidateInstanceInContext\": {\"active\": true}, \"ValidateTransformNamingSuffix\": {\"active\": true}, \"ValidateColorSets\": {\"active\": true}, \"ValidateMeshHasUVs\": {\"active\": true}, \"ValidateMeshNonZeroEdgeLength\": {\"active\": true}, \"ExtractModel\": {\"active\": true}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" "writeColorSets,writeFaceSets,includeParentHierarchy,attr,attrPrefix"; +createNode objectSet -n "_renderingMain:Main"; + rename -uid "4E1D2600-482D-425C-352A-74BA418DC374"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:752f6f925fe6"; +createNode objectSet -n "_renderingMain1:Main"; + rename -uid "A6382090-4537-44CB-E6DC-A5A58B98D008"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:d587b60d712e"; +createNode objectSet -n "workfileMain"; + rename -uid "0F32608C-4AA1-F493-205C-25BDABF95CEC"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + 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" + -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".hio" yes; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "workfile"; + setAttr ".subset" -type "string" "workfileTest_task"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.workfile"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".publish_attributes" -type "string" "{\"ValidateInstanceInContext\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" ""; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:eee200c4dd38"; +createNode objectSet -n "renderingMain1"; + rename -uid "9AC6AB5B-45EB-8439-BB6C-C197555E11E8"; + addAttr -ci true -sn "pre_creator_identifier" -ln "pre_creator_identifier" -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".pre_creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:e72a7af3a6c5"; +createNode objectSet -n "_renderingMain:Main1"; + rename -uid "7CFC031E-42E2-E431-89AB-5A991800F6F2"; + addAttr -s false -ci true -sn "renderlayer" -ln "renderlayer" -at "message"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "deadlineServers" -ln "deadlineServers" -dt "string"; + addAttr -ci true -sn "suspendPublishJob" -ln "suspendPublishJob" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "priority" -ln "priority" -at "long"; + addAttr -ci true -sn "tile_priority" -ln "tile_priority" -at "long"; + addAttr -ci true -sn "framesPerTask" -ln "framesPerTask" -at "long"; + addAttr -ci true -sn "whitelist" -ln "whitelist" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "machineList" -ln "machineList" -dt "string"; + addAttr -ci true -sn "useMayaBatch" -ln "useMayaBatch" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "primaryPool" -ln "primaryPool" -dt "string"; + addAttr -ci true -sn "secondaryPool" -ln "secondaryPool" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "review" -ln "review" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "extendFrames" -ln "extendFrames" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "overrideExistingFrame" -ln "overrideExistingFrame" -min 0 + -max 1 -at "bool"; + addAttr -ci true -sn "tileRendering" -ln "tileRendering" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "tilesX" -ln "tilesX" -at "long"; + addAttr -ci true -sn "tilesY" -ln "tilesY" -at "long"; + addAttr -ci true -sn "convertToScanline" -ln "convertToScanline" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "useReferencedAovs" -ln "useReferencedAovs" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "renderSetupIncludeLights" -ln "renderSetupIncludeLights" -min + 0 -max 1 -at "bool"; + 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" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:0850eb5268f2"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "renderlayer"; + setAttr ".subset" -type "string" "renderTest_taskMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".deadlineServers" -type "string" "default"; + setAttr -cb on ".suspendPublishJob"; + setAttr -cb on ".priority" 50; + setAttr -cb on ".tile_priority" 50; + setAttr -cb on ".framesPerTask" 1; + setAttr -cb on ".whitelist"; + setAttr ".machineList" -type "string" ""; + setAttr -cb on ".useMayaBatch"; + setAttr ".primaryPool" -type "string" "none"; + setAttr ".secondaryPool" -type "string" "-"; + setAttr ".task" -type "string" "test_task"; + setAttr -cb on ".review" yes; + setAttr -cb on ".extendFrames"; + setAttr -cb on ".overrideExistingFrame" yes; + setAttr -cb on ".tileRendering"; + setAttr -cb on ".tilesX" 2; + setAttr -cb on ".tilesY" 2; + setAttr -cb on ".convertToScanline"; + setAttr -cb on ".useReferencedAovs"; + setAttr -cb on ".renderSetupIncludeLights" yes; + setAttr ".publish_attributes" -type "string" "{\"CollectDeadlinePools\": {\"primaryPool\": \"\", \"secondaryPool\": \"\"}, \"ValidateResolution\": {\"active\": true}, \"ValidateDeadlinePools\": {\"active\": true}, \"ValidateFrameRange\": {\"active\": true}, \"ValidateInstanceInContext\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}, \"MayaSubmitDeadline\": {\"priority\": 50, \"chunkSize\": 1, \"machineList\": \"\", \"whitelist\": false, \"tile_priority\": 50, \"strict_error_checking\": true}, \"ProcessSubmittedJobOnFarm\": {\"publishJobState\": \"Active\"}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" "review,extendFrames,overrideExistingFrame,tileRendering,tilesX,tilesY,convertToScanline,useReferencedAovs,renderSetupIncludeLights"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :lightList1; +select -ne :standardSurface1; + setAttr ".b" 0.80000001192092896; + setAttr ".bc" -type "float3" 1 1 1 ; + setAttr ".s" 0.20000000298023224; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//_"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".dar" 1.7777777910232544; +select -ne :defaultLightSet; +select -ne :defaultColorMgtGlobals; + setAttr ".cfe" yes; + setAttr ".cfp" -type "string" "/OCIO-configs/Maya-legacy/config.ocio"; + setAttr ".vtn" -type "string" "sRGB gamma (legacy)"; + setAttr ".vn" -type "string" "sRGB gamma"; + setAttr ".dn" -type "string" "legacy"; + setAttr ".wsn" -type "string" "scene-linear Rec 709/sRGB"; + setAttr ".ovt" no; + setAttr ".povt" no; + setAttr ".otn" -type "string" "sRGB gamma (legacy)"; + setAttr ".potn" -type "string" "sRGB gamma (legacy)"; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "rs_Main.ri" ":persp.rlio[0]"; +connectAttr "rs_Main.ri" ":top.rlio[0]"; +connectAttr "rs_Main.ri" ":front.rlio[0]"; +connectAttr "rs_Main.ri" ":side.rlio[0]"; +connectAttr "rs_Main.ri" "pDisc1.rlio[0]"; +connectAttr "polyDisc1.output" "pDiscShape1.i"; +connectAttr "rs_Main.ri" "aiSkyDomeLight1.rlio[0]"; +connectAttr "rs_Main.ri" "mainCamera.rlio[0]"; +connectAttr "rs_Main.ri" "model_GRP.rlio[0]"; +connectAttr "polySphere1.out" "pSphere1_GEOShape1.i"; +relationship "link" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "link" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +connectAttr "layerManager.dli[0]" "defaultLayer.id"; +connectAttr "renderLayerManager.rlmi[0]" "defaultRenderLayer.rlid"; +connectAttr "Main.msg" "renderSetup.frl"; +connectAttr "Main.msg" "renderSetup.lrl"; +connectAttr ":defaultArnoldDisplayDriver.msg" ":defaultArnoldRenderOptions.drivers" + -na; +connectAttr ":defaultArnoldFilter.msg" ":defaultArnoldRenderOptions.filt"; +connectAttr ":defaultArnoldDriver.msg" ":defaultArnoldRenderOptions.drvr"; +connectAttr "rs_Main.msg" "Main.lrl"; +connectAttr "renderSetup.lit" "Main.pls"; +connectAttr "defaultCollection.msg" "Main.cl"; +connectAttr "defaultCollection.msg" "Main.ch"; +connectAttr "renderLayerManager.rlmi[1]" "rs_Main.rlid"; +connectAttr "defaultCollectionSelector.c" "defaultCollection.sel"; +connectAttr "Main.lit" "defaultCollection.pls"; +connectAttr "Main.nic" "defaultCollection.pic"; +connectAttr "model_GRP.iog" "modelMain.dsm" -na; +connectAttr "modelMain.msg" "_renderingMain:Main.dnsm" -na; +connectAttr "model_GRP.iog" "_renderingMain1:Main.dsm" -na; +connectAttr "_renderingMain:Main1.msg" "renderingMain1.dnsm" -na; +connectAttr "Main.msg" "_renderingMain:Main1.renderlayer"; +connectAttr "defaultRenderLayer.msg" ":defaultRenderingList1.r" -na; +connectAttr "rs_Main.msg" ":defaultRenderingList1.r" -na; +connectAttr "aiSkyDomeLightShape1.ltd" ":lightList1.l" -na; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "pDiscShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "aiSkyDomeLight1.iog" ":defaultLightSet.dsm" -na; +// End of test_project_test_asset_test_task_v002.ma diff --git a/tests/integration/hosts/maya/test_deadline_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_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma new file mode 100644 index 0000000000..c476a78086 --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma @@ -0,0 +1,525 @@ +//Maya ASCII 2023 scene +//Name: test_project_test_asset_test_task_v002.ma +//Last modified: Thu, Nov 09, 2023 11:59:33 AM +//Codeset: 1252 +requires maya "2023"; +requires -nodeType "simpleSelector" -nodeType "renderSetupLayer" -nodeType "renderSetup" + -nodeType "collection" "renderSetup.py" "1.0"; +requires -nodeType "aiOptions" -nodeType "aiAOVDriver" -nodeType "aiAOVFilter" -nodeType "aiSkyDomeLight" + "mtoa" "5.2.1.1"; +requires -nodeType "polyDisc" "modelingToolkit" "0.0.0.0"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2023"; +fileInfo "version" "2023"; +fileInfo "cutIdentifier" "202211021031-847a9f9623"; +fileInfo "osv" "Windows 10 Pro v2009 (Build: 19045)"; +fileInfo "license" "education"; +fileInfo "UUID" "591BA477-4DBF-D8A9-D1CE-AEB4E5E95DCA"; +fileInfo "OpenPypeContext" "eyJwdWJsaXNoX2F0dHJpYnV0ZXMiOiB7IlZhbGlkYXRlQ29udGFpbmVycyI6IHsiYWN0aXZlIjogdHJ1ZX19fQ=="; +createNode transform -s -n "persp"; + rename -uid "D52C935B-47C9-D868-A875-D799DD17B3A1"; + setAttr ".v" no; + setAttr ".t" -type "double3" 33.329836010894773 18.034068470832839 24.890981774804157 ; + setAttr ".r" -type "double3" 79.461647270402509 -44.999999999997357 183.99999999999159 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -s -n "perspShape" -p "persp"; + rename -uid "2399E6C0-490F-BA1F-485F-5AA8A01D27BC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 50.609449488607154; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -s -n "top"; + rename -uid "415C7426-413E-0FAE-FCC3-3DAED7443A52"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 1000.1 0 ; + setAttr ".r" -type "double3" 90 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" 0 -1000.1 1000.1 ; +createNode camera -s -n "topShape" -p "top"; + rename -uid "3BD0CF60-40DB-5278-5D8B-06ACBDA32122"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "top"; + setAttr ".den" -type "string" "top_depth"; + setAttr ".man" -type "string" "top_mask"; + setAttr ".hc" -type "string" "viewSet -t %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "front"; + rename -uid "D83DD5CE-4FE0-AB1B-81B2-87A63F0B7A05"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 0 1000.1 ; + setAttr ".r" -type "double3" 180 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; +createNode camera -s -n "frontShape" -p "front"; + rename -uid "23313CBA-42C2-0B3A-0FCF-EA965EAC5DEC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "front"; + setAttr ".den" -type "string" "front_depth"; + setAttr ".man" -type "string" "front_mask"; + setAttr ".hc" -type "string" "viewSet -f %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "side"; + rename -uid "F70F692C-4A0D-BE64-9EE4-A99B6FA2D56E"; + setAttr ".v" no; + setAttr ".t" -type "double3" 1000.1 0 0 ; + setAttr ".r" -type "double3" 180 -90 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" -1000.1 0 1000.1 ; +createNode camera -s -n "sideShape" -p "side"; + rename -uid "C05669C3-420E-CA11-E5FC-7EB64EF8B632"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "side"; + setAttr ".den" -type "string" "side_depth"; + setAttr ".man" -type "string" "side_mask"; + setAttr ".hc" -type "string" "viewSet -s %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -n "pDisc1"; + rename -uid "DED70CCF-4C19-16E4-9E5D-66A05037BA47"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:90e762703f08"; +createNode mesh -n "pDiscShape1" -p "pDisc1"; + rename -uid "E1FCDCCF-4DE1-D3B9-C4F8-3285F1CF5B25"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:4ee3da11a1a4"; +createNode transform -n "aiSkyDomeLight1"; + rename -uid "402BF091-4305-22E3-7CF0-9BA3D7F948F7"; +createNode aiSkyDomeLight -n "aiSkyDomeLightShape1" -p "aiSkyDomeLight1"; + rename -uid "CEF32074-4066-553D-A4FD-65B508A56ABE"; + addAttr -ci true -h true -sn "aal" -ln "attributeAliasList" -dt "attributeAlias"; + setAttr -k off ".v"; + setAttr ".csh" no; + setAttr ".rcsh" no; + setAttr ".aal" -type "attributeAlias" {"exposure","aiExposure"} ; +createNode transform -n "mainCamera"; + rename -uid "58651370-474E-02EE-39A9-A2AB27E0DD87"; + setAttr ".t" -type "double3" 33.329836010894773 18.034068470832839 24.890981774804157 ; + setAttr ".r" -type "double3" 79.461647270402509 -44.999999999997357 183.99999999999159 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -n "mainCameraShape" -p "mainCamera"; + rename -uid "CCE11369-4101-EE5E-5381-3F87DA963CA2"; + setAttr -k off ".v"; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 50.609449488607154; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -n "model_GRP"; + rename -uid "445FDC20-4A9D-2C5B-C7BD-F98F6E660B5C"; +createNode transform -n "pSphere1_GEO" -p "model_GRP"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:302a4c6123a4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:6c77a15a98a9"; +createNode lightLinker -s -n "lightLinker1"; + rename -uid "D9ADDBD2-49DE-91E2-4166-99A362986A3A"; + setAttr -s 2 ".lnk"; + setAttr -s 2 ".slnk"; +createNode shapeEditorManager -n "shapeEditorManager"; + rename -uid "A232A3B1-4B62-92E7-A7C9-9D9FC5EF010A"; +createNode poseInterpolatorManager -n "poseInterpolatorManager"; + rename -uid "B30BA35D-492A-834B-3448-49A80BBBFC39"; +createNode displayLayerManager -n "layerManager"; + rename -uid "4417380F-4A6A-16CC-B1DE-AA95ED9C7FB2"; +createNode displayLayer -n "defaultLayer"; + rename -uid "4A776D1B-401F-7069-1C74-A7AAE84CEE03"; + setAttr ".ufem" -type "stringArray" 0 ; +createNode renderLayerManager -n "renderLayerManager"; + rename -uid "EE21C644-43B8-C754-0BED-709D2EEB204D"; + setAttr -s 2 ".rlmi[1]" 1; + setAttr -s 2 ".rlmi"; +createNode renderLayer -n "defaultRenderLayer"; + rename -uid "B134920D-4508-23BD-A6CA-11B43DE03F53"; + setAttr ".g" yes; +createNode renderSetup -n "renderSetup"; + rename -uid "9A8F0D15-41AB-CA70-C2D8-B78840BF9BC1"; +createNode polySphere -n "polySphere1"; + rename -uid "DA319706-4ACF-B15C-53B2-48AC80D202EA"; +createNode script -n "uiConfigurationScriptNode"; + rename -uid "4B7AFB53-452E-E870-63E1-CCA1DD6EAF13"; + setAttr ".b" -type "string" ( + "// Maya Mel UI Configuration File.\n//\n// This script is machine generated. Edit at your own risk.\n//\n//\n\nglobal string $gMainPane;\nif (`paneLayout -exists $gMainPane`) {\n\n\tglobal int $gUseScenePanelConfig;\n\tint $useSceneConfig = $gUseScenePanelConfig;\n\tint $nodeEditorPanelVisible = stringArrayContains(\"nodeEditorPanel1\", `getPanel -vis`);\n\tint $nodeEditorWorkspaceControlOpen = (`workspaceControl -exists nodeEditorPanel1Window` && `workspaceControl -q -visible nodeEditorPanel1Window`);\n\tint $menusOkayInPanels = `optionVar -q allowMenusInPanels`;\n\tint $nVisPanes = `paneLayout -q -nvp $gMainPane`;\n\tint $nPanes = 0;\n\tstring $editorName;\n\tstring $panelName;\n\tstring $itemFilterName;\n\tstring $panelConfig;\n\n\t//\n\t// get current state of the UI\n\t//\n\tsceneUIReplacement -update $gMainPane;\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Top View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Top View\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n" + + " -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n" + + " -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 477\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Side View\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Side View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n" + + " -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n" + + " -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 476\n -height 345\n -sceneRenderFilter 0\n $editorName;\n" + + " modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Front View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Front View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|top\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n" + + " -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n" + + " -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n" + + " -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 477\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Persp View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Persp View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|mainCamera\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n" + + " -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n" + + " -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n" + + " -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 476\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"ToggledOutliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"ToggledOutliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -docTag \"isolOutln_fromSeln\" \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 1\n -showReferenceMembers 1\n" + + " -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n" + + " -isSet 0\n -isSetMember 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -selectCommand \"print(\\\"\\\")\" \n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n -renderFilterIndex 0\n -selectionOrder \"chronological\" \n -expandAttribute 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"Outliner\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"Outliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n" + + " -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n" + + " $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"graphEditor\" (localizedPanelLabel(\"Graph Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Graph Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 1\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n" + + " -showPublishedAsConnected 0\n -showParentContainers 1\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 0\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 1\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 1\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n" + + " -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 1\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"GraphEd\");\n animCurveEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -showPlayRangeShades \"on\" \n -lockPlayRangeShades \"off\" \n -smoothness \"fine\" \n -resultSamples 1.041667\n -resultScreenSamples 0\n -resultUpdate \"delayed\" \n -showUpstreamCurves 1\n -keyMinScale 1\n -stackedCurvesMin -1\n -stackedCurvesMax 1\n" + + " -stackedCurvesSpace 0.2\n -preSelectionHighlight 0\n -constrainDrag 0\n -valueLinesToggle 1\n -highlightAffectedCurves 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dopeSheetPanel\" (localizedPanelLabel(\"Dope Sheet\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dope Sheet\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n" + + " -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 1\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 0\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 1\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n" + + " -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"DopeSheetEd\");\n dopeSheetEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -outliner \"dopeSheetPanel1OutlineEd\" \n -showSummary 1\n -showScene 0\n -hierarchyBelow 0\n" + + " -showTicks 1\n -selectionWindow 0 0 0 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"timeEditorPanel\" (localizedPanelLabel(\"Time Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Time Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"clipEditorPanel\" (localizedPanelLabel(\"Trax Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Trax Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = clipEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 0 \n" + + " $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"sequenceEditorPanel\" (localizedPanelLabel(\"Camera Sequencer\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Camera Sequencer\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = sequenceEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperGraphPanel\" (localizedPanelLabel(\"Hypergraph Hierarchy\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypergraph Hierarchy\")) -mbv $menusOkayInPanels $panelName;\n" + + "\n\t\t\t$editorName = ($panelName+\"HyperGraphEd\");\n hyperGraph -e \n -graphLayoutStyle \"hierarchicalLayout\" \n -orientation \"horiz\" \n -mergeConnections 0\n -zoom 1\n -animateTransition 0\n -showRelationships 1\n -showShapes 0\n -showDeformers 0\n -showExpressions 0\n -showConstraints 0\n -showConnectionFromSelected 0\n -showConnectionToSelected 0\n -showConstraintLabels 0\n -showUnderworld 0\n -showInvisible 0\n -transitionFrames 1\n -opaqueContainers 0\n -freeform 0\n -imagePosition 0 0 \n -imageScale 1\n -imageEnabled 0\n -graphType \"DAG\" \n -heatMapDisplay 0\n -updateSelection 1\n -updateNodeAdded 1\n -useDrawOverrideColor 0\n -limitGraphTraversal -1\n" + + " -range 0 0 \n -iconSize \"smallIcons\" \n -showCachedConnections 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperShadePanel\" (localizedPanelLabel(\"Hypershade\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypershade\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"visorPanel\" (localizedPanelLabel(\"Visor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Visor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"nodeEditorPanel\" (localizedPanelLabel(\"Node Editor\")) `;\n\tif ($nodeEditorPanelVisible || $nodeEditorWorkspaceControlOpen) {\n" + + "\t\tif (\"\" == $panelName) {\n\t\t\tif ($useSceneConfig) {\n\t\t\t\t$panelName = `scriptedPanel -unParent -type \"nodeEditorPanel\" -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels `;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 1\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n" + + " -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\t}\n\t\t} else {\n\t\t\t$label = `panel -q -label $panelName`;\n\t\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n" + + " -additiveGraphingMode 1\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\tif (!$useSceneConfig) {\n\t\t\t\tpanel -e -l $label $panelName;\n\t\t\t}\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"createNodePanel\" (localizedPanelLabel(\"Create Node\")) `;\n\tif (\"\" != $panelName) {\n" + + "\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Create Node\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"polyTexturePlacementPanel\" (localizedPanelLabel(\"UV Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"UV Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"renderWindowPanel\" (localizedPanelLabel(\"Render View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Render View\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"shapePanel\" (localizedPanelLabel(\"Shape Editor\")) `;\n\tif (\"\" != $panelName) {\n" + + "\t\t$label = `panel -q -label $panelName`;\n\t\tshapePanel -edit -l (localizedPanelLabel(\"Shape Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"posePanel\" (localizedPanelLabel(\"Pose Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tposePanel -edit -l (localizedPanelLabel(\"Pose Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynRelEdPanel\" (localizedPanelLabel(\"Dynamic Relationships\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dynamic Relationships\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"relationshipPanel\" (localizedPanelLabel(\"Relationship Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Relationship Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"referenceEditorPanel\" (localizedPanelLabel(\"Reference Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Reference Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynPaintScriptedPanelType\" (localizedPanelLabel(\"Paint Effects\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Paint Effects\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"scriptEditorPanel\" (localizedPanelLabel(\"Script Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Script Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"profilerPanel\" (localizedPanelLabel(\"Profiler Tool\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Profiler Tool\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"contentBrowserPanel\" (localizedPanelLabel(\"Content Browser\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Content Browser\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\tif ($useSceneConfig) {\n string $configName = `getPanel -cwl (localizedPanelLabel(\"Current Layout\"))`;\n" + + " if (\"\" != $configName) {\n\t\t\tpanelConfiguration -edit -label (localizedPanelLabel(\"Current Layout\")) \n\t\t\t\t-userCreated false\n\t\t\t\t-defaultImage \"vacantCell.xP:/\"\n\t\t\t\t-image \"\"\n\t\t\t\t-sc false\n\t\t\t\t-configString \"global string $gMainPane; paneLayout -e -cn \\\"quad\\\" -ps 1 50 50 -ps 2 50 50 -ps 3 50 50 -ps 4 50 50 $gMainPane;\"\n\t\t\t\t-removeAllPanels\n\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Top View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Top View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera top` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Top View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera top` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Persp View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|mainCamera\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|mainCamera\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Side View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Side View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera side` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Side View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera side` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Front View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Front View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|top\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Front View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|top\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t$configName;\n\n setNamedPanelLayout (localizedPanelLabel(\"Current Layout\"));\n }\n\n panelHistory -e -clear mainPanelHistory;\n sceneUIReplacement -clear;\n\t}\n\n\ngrid -spacing 5 -size 12 -divisions 5 -displayAxes yes -displayGridLines yes -displayDivisionLines yes -displayPerspectiveLabels no -displayOrthographicLabels no -displayAxesBold yes -perspectiveLabelPosition axis -orthographicLabelPosition edge;\nviewManip -drawCompass 0 -compassAngle 0 -frontParameters \"\" -homeParameters \"\" -selectionLockParameters \"\";\n}\n"); + setAttr ".st" 3; +createNode script -n "sceneConfigurationScriptNode"; + rename -uid "72B19BC2-43A2-E229-0A73-2CB861A291D1"; + setAttr ".b" -type "string" "playbackOptions -min 1000 -max 1001 -ast 1000 -aet 1001 "; + setAttr ".st" 6; +createNode polyDisc -n "polyDisc1"; + rename -uid "9ED8A7BD-4FFD-6107-4322-35ACD1D3AC42"; +createNode aiOptions -s -n "defaultArnoldRenderOptions"; + rename -uid "31A81965-48A6-B90D-503D-2FA162B7C982"; +createNode aiAOVFilter -s -n "defaultArnoldFilter"; + rename -uid "77A2BCB1-4613-905E-080E-B997FD5E1C6F"; + setAttr ".ai_translator" -type "string" "gaussian"; +createNode aiAOVDriver -s -n "defaultArnoldDriver"; + rename -uid "C05729BE-4A33-F1DA-C222-3F8AB6EE7504"; + setAttr ".ai_translator" -type "string" "exr"; +createNode aiAOVDriver -s -n "defaultArnoldDisplayDriver"; + rename -uid "806C25D7-4284-C09D-A8AE-4A80DBFFFAAF"; + setAttr ".output_mode" 0; + setAttr ".ai_translator" -type "string" "maya"; +createNode renderSetupLayer -n "Main"; + rename -uid "DC3F077F-49F5-1D64-BFF3-AAAF06798636"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode renderLayer -n "rs_Main"; + rename -uid "D798EE14-43EE-D8EF-F4C9-D6B19C9BC029"; + setAttr ".do" 1; +createNode collection -n "defaultCollection"; + rename -uid "0194FCB7-43C4-DC06-C8D6-D9BA2721CCFA"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode simpleSelector -n "defaultCollectionSelector"; + rename -uid "37D69395-4785-D0BE-AEA0-EEA66D1FAEDF"; + setAttr ".pat" -type "string" "*"; +createNode objectSet -n "modelMain"; + rename -uid "811E4501-4B64-3016-BE29-E18EC09D90B7"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "writeColorSets" -ln "writeColorSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "writeFaceSets" -ln "writeFaceSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "attr" -ln "attr" -dt "string"; + addAttr -ci true -sn "attrPrefix" -ln "attrPrefix" -dt "string"; + addAttr -ci true -sn "includeParentHierarchy" -ln "includeParentHierarchy" -min + 0 -max 1 -at "bool"; + 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" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:49991563bf50"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "model"; + setAttr ".subset" -type "string" "modelMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.model"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr -cb on ".writeColorSets"; + setAttr -cb on ".writeFaceSets"; + setAttr ".attr" -type "string" ""; + setAttr ".attrPrefix" -type "string" ""; + setAttr -cb on ".includeParentHierarchy"; + setAttr ".task" -type "string" "test_task"; + setAttr ".publish_attributes" -type "string" "{\"ValidateNodeIDsRelated\": {\"active\": true}, \"ValidateInstanceInContext\": {\"active\": true}, \"ValidateTransformNamingSuffix\": {\"active\": true}, \"ValidateColorSets\": {\"active\": true}, \"ValidateMeshHasUVs\": {\"active\": true}, \"ValidateMeshNonZeroEdgeLength\": {\"active\": true}, \"ExtractModel\": {\"active\": true}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" "writeColorSets,writeFaceSets,includeParentHierarchy,attr,attrPrefix"; +createNode objectSet -n "_renderingMain:Main"; + rename -uid "4E1D2600-482D-425C-352A-74BA418DC374"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:752f6f925fe6"; +createNode objectSet -n "_renderingMain1:Main"; + rename -uid "A6382090-4537-44CB-E6DC-A5A58B98D008"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:d587b60d712e"; +createNode objectSet -n "workfileMain"; + rename -uid "0F32608C-4AA1-F493-205C-25BDABF95CEC"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + 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" + -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".hio" yes; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "workfile"; + setAttr ".subset" -type "string" "workfileTest_task"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.workfile"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".publish_attributes" -type "string" "{\"ValidateInstanceInContext\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" ""; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:eee200c4dd38"; +createNode objectSet -n "renderingMain1"; + rename -uid "9AC6AB5B-45EB-8439-BB6C-C197555E11E8"; + addAttr -ci true -sn "pre_creator_identifier" -ln "pre_creator_identifier" -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".pre_creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:e72a7af3a6c5"; +createNode objectSet -n "_renderingMain:Main1"; + rename -uid "7CFC031E-42E2-E431-89AB-5A991800F6F2"; + addAttr -s false -ci true -sn "renderlayer" -ln "renderlayer" -at "message"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "deadlineServers" -ln "deadlineServers" -dt "string"; + addAttr -ci true -sn "suspendPublishJob" -ln "suspendPublishJob" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "priority" -ln "priority" -at "long"; + addAttr -ci true -sn "tile_priority" -ln "tile_priority" -at "long"; + addAttr -ci true -sn "framesPerTask" -ln "framesPerTask" -at "long"; + addAttr -ci true -sn "whitelist" -ln "whitelist" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "machineList" -ln "machineList" -dt "string"; + addAttr -ci true -sn "useMayaBatch" -ln "useMayaBatch" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "primaryPool" -ln "primaryPool" -dt "string"; + addAttr -ci true -sn "secondaryPool" -ln "secondaryPool" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "review" -ln "review" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "extendFrames" -ln "extendFrames" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "overrideExistingFrame" -ln "overrideExistingFrame" -min 0 + -max 1 -at "bool"; + addAttr -ci true -sn "tileRendering" -ln "tileRendering" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "tilesX" -ln "tilesX" -at "long"; + addAttr -ci true -sn "tilesY" -ln "tilesY" -at "long"; + addAttr -ci true -sn "convertToScanline" -ln "convertToScanline" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "useReferencedAovs" -ln "useReferencedAovs" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "renderSetupIncludeLights" -ln "renderSetupIncludeLights" -min + 0 -max 1 -at "bool"; + 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" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:0850eb5268f2"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "renderlayer"; + setAttr ".subset" -type "string" "renderTest_taskMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".deadlineServers" -type "string" "default"; + setAttr -cb on ".suspendPublishJob"; + setAttr -cb on ".priority" 50; + setAttr -cb on ".tile_priority" 50; + setAttr -cb on ".framesPerTask" 1; + setAttr -cb on ".whitelist"; + setAttr ".machineList" -type "string" ""; + setAttr -cb on ".useMayaBatch"; + setAttr ".primaryPool" -type "string" "none"; + setAttr ".secondaryPool" -type "string" "-"; + setAttr ".task" -type "string" "test_task"; + setAttr -cb on ".review" yes; + setAttr -cb on ".extendFrames"; + setAttr -cb on ".overrideExistingFrame" yes; + setAttr -cb on ".tileRendering"; + setAttr -cb on ".tilesX" 2; + setAttr -cb on ".tilesY" 2; + setAttr -cb on ".convertToScanline"; + setAttr -cb on ".useReferencedAovs"; + setAttr -cb on ".renderSetupIncludeLights" yes; + setAttr ".publish_attributes" -type "string" "{\"CollectDeadlinePools\": {\"primaryPool\": \"\", \"secondaryPool\": \"\"}, \"ValidateResolution\": {\"active\": true}, \"ValidateDeadlinePools\": {\"active\": true}, \"ValidateFrameRange\": {\"active\": true}, \"ValidateInstanceInContext\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}, \"MayaSubmitDeadline\": {\"priority\": 50, \"chunkSize\": 1, \"machineList\": \"\", \"whitelist\": false, \"tile_priority\": 50, \"strict_error_checking\": true}, \"ProcessSubmittedJobOnFarm\": {\"publishJobState\": \"Active\"}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" "review,extendFrames,overrideExistingFrame,tileRendering,tilesX,tilesY,convertToScanline,useReferencedAovs,renderSetupIncludeLights"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :lightList1; +select -ne :standardSurface1; + setAttr ".b" 0.80000001192092896; + setAttr ".bc" -type "float3" 1 1 1 ; + setAttr ".s" 0.20000000298023224; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//_"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".dar" 1.7777777910232544; +select -ne :defaultLightSet; +select -ne :defaultColorMgtGlobals; + setAttr ".cfe" yes; + setAttr ".cfp" -type "string" "/OCIO-configs/Maya-legacy/config.ocio"; + setAttr ".vtn" -type "string" "sRGB gamma (legacy)"; + setAttr ".vn" -type "string" "sRGB gamma"; + setAttr ".dn" -type "string" "legacy"; + setAttr ".wsn" -type "string" "scene-linear Rec 709/sRGB"; + setAttr ".ovt" no; + setAttr ".povt" no; + setAttr ".otn" -type "string" "sRGB gamma (legacy)"; + setAttr ".potn" -type "string" "sRGB gamma (legacy)"; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "rs_Main.ri" ":persp.rlio[0]"; +connectAttr "rs_Main.ri" ":top.rlio[0]"; +connectAttr "rs_Main.ri" ":front.rlio[0]"; +connectAttr "rs_Main.ri" ":side.rlio[0]"; +connectAttr "rs_Main.ri" "pDisc1.rlio[0]"; +connectAttr "polyDisc1.output" "pDiscShape1.i"; +connectAttr "rs_Main.ri" "aiSkyDomeLight1.rlio[0]"; +connectAttr "rs_Main.ri" "mainCamera.rlio[0]"; +connectAttr "rs_Main.ri" "model_GRP.rlio[0]"; +connectAttr "polySphere1.out" "pSphere1_GEOShape1.i"; +relationship "link" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "link" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +connectAttr "layerManager.dli[0]" "defaultLayer.id"; +connectAttr "renderLayerManager.rlmi[0]" "defaultRenderLayer.rlid"; +connectAttr "Main.msg" "renderSetup.frl"; +connectAttr "Main.msg" "renderSetup.lrl"; +connectAttr ":defaultArnoldDisplayDriver.msg" ":defaultArnoldRenderOptions.drivers" + -na; +connectAttr ":defaultArnoldFilter.msg" ":defaultArnoldRenderOptions.filt"; +connectAttr ":defaultArnoldDriver.msg" ":defaultArnoldRenderOptions.drvr"; +connectAttr "rs_Main.msg" "Main.lrl"; +connectAttr "renderSetup.lit" "Main.pls"; +connectAttr "defaultCollection.msg" "Main.cl"; +connectAttr "defaultCollection.msg" "Main.ch"; +connectAttr "renderLayerManager.rlmi[1]" "rs_Main.rlid"; +connectAttr "defaultCollectionSelector.c" "defaultCollection.sel"; +connectAttr "Main.lit" "defaultCollection.pls"; +connectAttr "Main.nic" "defaultCollection.pic"; +connectAttr "model_GRP.iog" "modelMain.dsm" -na; +connectAttr "modelMain.msg" "_renderingMain:Main.dnsm" -na; +connectAttr "model_GRP.iog" "_renderingMain1:Main.dsm" -na; +connectAttr "_renderingMain:Main1.msg" "renderingMain1.dnsm" -na; +connectAttr "Main.msg" "_renderingMain:Main1.renderlayer"; +connectAttr "defaultRenderLayer.msg" ":defaultRenderingList1.r" -na; +connectAttr "rs_Main.msg" ":defaultRenderingList1.r" -na; +connectAttr "aiSkyDomeLightShape1.ltd" ":lightList1.l" -na; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "pDiscShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "aiSkyDomeLight1.iog" ":defaultLightSet.dsm" -na; +// End of test_project_test_asset_test_task_v002.ma diff --git a/tests/integration/hosts/maya/test_deadline_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_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v002.ma new file mode 100644 index 0000000000..c476a78086 --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v002.ma @@ -0,0 +1,525 @@ +//Maya ASCII 2023 scene +//Name: test_project_test_asset_test_task_v002.ma +//Last modified: Thu, Nov 09, 2023 11:59:33 AM +//Codeset: 1252 +requires maya "2023"; +requires -nodeType "simpleSelector" -nodeType "renderSetupLayer" -nodeType "renderSetup" + -nodeType "collection" "renderSetup.py" "1.0"; +requires -nodeType "aiOptions" -nodeType "aiAOVDriver" -nodeType "aiAOVFilter" -nodeType "aiSkyDomeLight" + "mtoa" "5.2.1.1"; +requires -nodeType "polyDisc" "modelingToolkit" "0.0.0.0"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2023"; +fileInfo "version" "2023"; +fileInfo "cutIdentifier" "202211021031-847a9f9623"; +fileInfo "osv" "Windows 10 Pro v2009 (Build: 19045)"; +fileInfo "license" "education"; +fileInfo "UUID" "591BA477-4DBF-D8A9-D1CE-AEB4E5E95DCA"; +fileInfo "OpenPypeContext" "eyJwdWJsaXNoX2F0dHJpYnV0ZXMiOiB7IlZhbGlkYXRlQ29udGFpbmVycyI6IHsiYWN0aXZlIjogdHJ1ZX19fQ=="; +createNode transform -s -n "persp"; + rename -uid "D52C935B-47C9-D868-A875-D799DD17B3A1"; + setAttr ".v" no; + setAttr ".t" -type "double3" 33.329836010894773 18.034068470832839 24.890981774804157 ; + setAttr ".r" -type "double3" 79.461647270402509 -44.999999999997357 183.99999999999159 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -s -n "perspShape" -p "persp"; + rename -uid "2399E6C0-490F-BA1F-485F-5AA8A01D27BC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 50.609449488607154; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -s -n "top"; + rename -uid "415C7426-413E-0FAE-FCC3-3DAED7443A52"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 1000.1 0 ; + setAttr ".r" -type "double3" 90 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" 0 -1000.1 1000.1 ; +createNode camera -s -n "topShape" -p "top"; + rename -uid "3BD0CF60-40DB-5278-5D8B-06ACBDA32122"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "top"; + setAttr ".den" -type "string" "top_depth"; + setAttr ".man" -type "string" "top_mask"; + setAttr ".hc" -type "string" "viewSet -t %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "front"; + rename -uid "D83DD5CE-4FE0-AB1B-81B2-87A63F0B7A05"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 0 1000.1 ; + setAttr ".r" -type "double3" 180 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; +createNode camera -s -n "frontShape" -p "front"; + rename -uid "23313CBA-42C2-0B3A-0FCF-EA965EAC5DEC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "front"; + setAttr ".den" -type "string" "front_depth"; + setAttr ".man" -type "string" "front_mask"; + setAttr ".hc" -type "string" "viewSet -f %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "side"; + rename -uid "F70F692C-4A0D-BE64-9EE4-A99B6FA2D56E"; + setAttr ".v" no; + setAttr ".t" -type "double3" 1000.1 0 0 ; + setAttr ".r" -type "double3" 180 -90 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" -1000.1 0 1000.1 ; +createNode camera -s -n "sideShape" -p "side"; + rename -uid "C05669C3-420E-CA11-E5FC-7EB64EF8B632"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "side"; + setAttr ".den" -type "string" "side_depth"; + setAttr ".man" -type "string" "side_mask"; + setAttr ".hc" -type "string" "viewSet -s %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -n "pDisc1"; + rename -uid "DED70CCF-4C19-16E4-9E5D-66A05037BA47"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:90e762703f08"; +createNode mesh -n "pDiscShape1" -p "pDisc1"; + rename -uid "E1FCDCCF-4DE1-D3B9-C4F8-3285F1CF5B25"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:4ee3da11a1a4"; +createNode transform -n "aiSkyDomeLight1"; + rename -uid "402BF091-4305-22E3-7CF0-9BA3D7F948F7"; +createNode aiSkyDomeLight -n "aiSkyDomeLightShape1" -p "aiSkyDomeLight1"; + rename -uid "CEF32074-4066-553D-A4FD-65B508A56ABE"; + addAttr -ci true -h true -sn "aal" -ln "attributeAliasList" -dt "attributeAlias"; + setAttr -k off ".v"; + setAttr ".csh" no; + setAttr ".rcsh" no; + setAttr ".aal" -type "attributeAlias" {"exposure","aiExposure"} ; +createNode transform -n "mainCamera"; + rename -uid "58651370-474E-02EE-39A9-A2AB27E0DD87"; + setAttr ".t" -type "double3" 33.329836010894773 18.034068470832839 24.890981774804157 ; + setAttr ".r" -type "double3" 79.461647270402509 -44.999999999997357 183.99999999999159 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -n "mainCameraShape" -p "mainCamera"; + rename -uid "CCE11369-4101-EE5E-5381-3F87DA963CA2"; + setAttr -k off ".v"; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 50.609449488607154; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -n "model_GRP"; + rename -uid "445FDC20-4A9D-2C5B-C7BD-F98F6E660B5C"; +createNode transform -n "pSphere1_GEO" -p "model_GRP"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:302a4c6123a4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:6c77a15a98a9"; +createNode lightLinker -s -n "lightLinker1"; + rename -uid "D9ADDBD2-49DE-91E2-4166-99A362986A3A"; + setAttr -s 2 ".lnk"; + setAttr -s 2 ".slnk"; +createNode shapeEditorManager -n "shapeEditorManager"; + rename -uid "A232A3B1-4B62-92E7-A7C9-9D9FC5EF010A"; +createNode poseInterpolatorManager -n "poseInterpolatorManager"; + rename -uid "B30BA35D-492A-834B-3448-49A80BBBFC39"; +createNode displayLayerManager -n "layerManager"; + rename -uid "4417380F-4A6A-16CC-B1DE-AA95ED9C7FB2"; +createNode displayLayer -n "defaultLayer"; + rename -uid "4A776D1B-401F-7069-1C74-A7AAE84CEE03"; + setAttr ".ufem" -type "stringArray" 0 ; +createNode renderLayerManager -n "renderLayerManager"; + rename -uid "EE21C644-43B8-C754-0BED-709D2EEB204D"; + setAttr -s 2 ".rlmi[1]" 1; + setAttr -s 2 ".rlmi"; +createNode renderLayer -n "defaultRenderLayer"; + rename -uid "B134920D-4508-23BD-A6CA-11B43DE03F53"; + setAttr ".g" yes; +createNode renderSetup -n "renderSetup"; + rename -uid "9A8F0D15-41AB-CA70-C2D8-B78840BF9BC1"; +createNode polySphere -n "polySphere1"; + rename -uid "DA319706-4ACF-B15C-53B2-48AC80D202EA"; +createNode script -n "uiConfigurationScriptNode"; + rename -uid "4B7AFB53-452E-E870-63E1-CCA1DD6EAF13"; + setAttr ".b" -type "string" ( + "// Maya Mel UI Configuration File.\n//\n// This script is machine generated. Edit at your own risk.\n//\n//\n\nglobal string $gMainPane;\nif (`paneLayout -exists $gMainPane`) {\n\n\tglobal int $gUseScenePanelConfig;\n\tint $useSceneConfig = $gUseScenePanelConfig;\n\tint $nodeEditorPanelVisible = stringArrayContains(\"nodeEditorPanel1\", `getPanel -vis`);\n\tint $nodeEditorWorkspaceControlOpen = (`workspaceControl -exists nodeEditorPanel1Window` && `workspaceControl -q -visible nodeEditorPanel1Window`);\n\tint $menusOkayInPanels = `optionVar -q allowMenusInPanels`;\n\tint $nVisPanes = `paneLayout -q -nvp $gMainPane`;\n\tint $nPanes = 0;\n\tstring $editorName;\n\tstring $panelName;\n\tstring $itemFilterName;\n\tstring $panelConfig;\n\n\t//\n\t// get current state of the UI\n\t//\n\tsceneUIReplacement -update $gMainPane;\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Top View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Top View\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n" + + " -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n" + + " -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 477\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Side View\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Side View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n" + + " -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n" + + " -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 476\n -height 345\n -sceneRenderFilter 0\n $editorName;\n" + + " modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Front View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Front View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|top\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n" + + " -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n" + + " -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n" + + " -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 477\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Persp View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Persp View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|mainCamera\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n" + + " -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n" + + " -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n" + + " -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 476\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"ToggledOutliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"ToggledOutliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -docTag \"isolOutln_fromSeln\" \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 1\n -showReferenceMembers 1\n" + + " -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n" + + " -isSet 0\n -isSetMember 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -selectCommand \"print(\\\"\\\")\" \n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n -renderFilterIndex 0\n -selectionOrder \"chronological\" \n -expandAttribute 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"Outliner\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"Outliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n" + + " -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n" + + " $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"graphEditor\" (localizedPanelLabel(\"Graph Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Graph Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 1\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n" + + " -showPublishedAsConnected 0\n -showParentContainers 1\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 0\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 1\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 1\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n" + + " -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 1\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"GraphEd\");\n animCurveEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -showPlayRangeShades \"on\" \n -lockPlayRangeShades \"off\" \n -smoothness \"fine\" \n -resultSamples 1.041667\n -resultScreenSamples 0\n -resultUpdate \"delayed\" \n -showUpstreamCurves 1\n -keyMinScale 1\n -stackedCurvesMin -1\n -stackedCurvesMax 1\n" + + " -stackedCurvesSpace 0.2\n -preSelectionHighlight 0\n -constrainDrag 0\n -valueLinesToggle 1\n -highlightAffectedCurves 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dopeSheetPanel\" (localizedPanelLabel(\"Dope Sheet\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dope Sheet\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n" + + " -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 1\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 0\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 1\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n" + + " -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"DopeSheetEd\");\n dopeSheetEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -outliner \"dopeSheetPanel1OutlineEd\" \n -showSummary 1\n -showScene 0\n -hierarchyBelow 0\n" + + " -showTicks 1\n -selectionWindow 0 0 0 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"timeEditorPanel\" (localizedPanelLabel(\"Time Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Time Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"clipEditorPanel\" (localizedPanelLabel(\"Trax Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Trax Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = clipEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 0 \n" + + " $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"sequenceEditorPanel\" (localizedPanelLabel(\"Camera Sequencer\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Camera Sequencer\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = sequenceEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperGraphPanel\" (localizedPanelLabel(\"Hypergraph Hierarchy\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypergraph Hierarchy\")) -mbv $menusOkayInPanels $panelName;\n" + + "\n\t\t\t$editorName = ($panelName+\"HyperGraphEd\");\n hyperGraph -e \n -graphLayoutStyle \"hierarchicalLayout\" \n -orientation \"horiz\" \n -mergeConnections 0\n -zoom 1\n -animateTransition 0\n -showRelationships 1\n -showShapes 0\n -showDeformers 0\n -showExpressions 0\n -showConstraints 0\n -showConnectionFromSelected 0\n -showConnectionToSelected 0\n -showConstraintLabels 0\n -showUnderworld 0\n -showInvisible 0\n -transitionFrames 1\n -opaqueContainers 0\n -freeform 0\n -imagePosition 0 0 \n -imageScale 1\n -imageEnabled 0\n -graphType \"DAG\" \n -heatMapDisplay 0\n -updateSelection 1\n -updateNodeAdded 1\n -useDrawOverrideColor 0\n -limitGraphTraversal -1\n" + + " -range 0 0 \n -iconSize \"smallIcons\" \n -showCachedConnections 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperShadePanel\" (localizedPanelLabel(\"Hypershade\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypershade\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"visorPanel\" (localizedPanelLabel(\"Visor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Visor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"nodeEditorPanel\" (localizedPanelLabel(\"Node Editor\")) `;\n\tif ($nodeEditorPanelVisible || $nodeEditorWorkspaceControlOpen) {\n" + + "\t\tif (\"\" == $panelName) {\n\t\t\tif ($useSceneConfig) {\n\t\t\t\t$panelName = `scriptedPanel -unParent -type \"nodeEditorPanel\" -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels `;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 1\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n" + + " -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\t}\n\t\t} else {\n\t\t\t$label = `panel -q -label $panelName`;\n\t\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n" + + " -additiveGraphingMode 1\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\tif (!$useSceneConfig) {\n\t\t\t\tpanel -e -l $label $panelName;\n\t\t\t}\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"createNodePanel\" (localizedPanelLabel(\"Create Node\")) `;\n\tif (\"\" != $panelName) {\n" + + "\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Create Node\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"polyTexturePlacementPanel\" (localizedPanelLabel(\"UV Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"UV Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"renderWindowPanel\" (localizedPanelLabel(\"Render View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Render View\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"shapePanel\" (localizedPanelLabel(\"Shape Editor\")) `;\n\tif (\"\" != $panelName) {\n" + + "\t\t$label = `panel -q -label $panelName`;\n\t\tshapePanel -edit -l (localizedPanelLabel(\"Shape Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"posePanel\" (localizedPanelLabel(\"Pose Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tposePanel -edit -l (localizedPanelLabel(\"Pose Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynRelEdPanel\" (localizedPanelLabel(\"Dynamic Relationships\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dynamic Relationships\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"relationshipPanel\" (localizedPanelLabel(\"Relationship Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Relationship Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"referenceEditorPanel\" (localizedPanelLabel(\"Reference Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Reference Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynPaintScriptedPanelType\" (localizedPanelLabel(\"Paint Effects\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Paint Effects\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"scriptEditorPanel\" (localizedPanelLabel(\"Script Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Script Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"profilerPanel\" (localizedPanelLabel(\"Profiler Tool\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Profiler Tool\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"contentBrowserPanel\" (localizedPanelLabel(\"Content Browser\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Content Browser\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\tif ($useSceneConfig) {\n string $configName = `getPanel -cwl (localizedPanelLabel(\"Current Layout\"))`;\n" + + " if (\"\" != $configName) {\n\t\t\tpanelConfiguration -edit -label (localizedPanelLabel(\"Current Layout\")) \n\t\t\t\t-userCreated false\n\t\t\t\t-defaultImage \"vacantCell.xP:/\"\n\t\t\t\t-image \"\"\n\t\t\t\t-sc false\n\t\t\t\t-configString \"global string $gMainPane; paneLayout -e -cn \\\"quad\\\" -ps 1 50 50 -ps 2 50 50 -ps 3 50 50 -ps 4 50 50 $gMainPane;\"\n\t\t\t\t-removeAllPanels\n\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Top View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Top View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera top` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Top View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera top` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Persp View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|mainCamera\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|mainCamera\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Side View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Side View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera side` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Side View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera side` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Front View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Front View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|top\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Front View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|top\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t$configName;\n\n setNamedPanelLayout (localizedPanelLabel(\"Current Layout\"));\n }\n\n panelHistory -e -clear mainPanelHistory;\n sceneUIReplacement -clear;\n\t}\n\n\ngrid -spacing 5 -size 12 -divisions 5 -displayAxes yes -displayGridLines yes -displayDivisionLines yes -displayPerspectiveLabels no -displayOrthographicLabels no -displayAxesBold yes -perspectiveLabelPosition axis -orthographicLabelPosition edge;\nviewManip -drawCompass 0 -compassAngle 0 -frontParameters \"\" -homeParameters \"\" -selectionLockParameters \"\";\n}\n"); + setAttr ".st" 3; +createNode script -n "sceneConfigurationScriptNode"; + rename -uid "72B19BC2-43A2-E229-0A73-2CB861A291D1"; + setAttr ".b" -type "string" "playbackOptions -min 1000 -max 1001 -ast 1000 -aet 1001 "; + setAttr ".st" 6; +createNode polyDisc -n "polyDisc1"; + rename -uid "9ED8A7BD-4FFD-6107-4322-35ACD1D3AC42"; +createNode aiOptions -s -n "defaultArnoldRenderOptions"; + rename -uid "31A81965-48A6-B90D-503D-2FA162B7C982"; +createNode aiAOVFilter -s -n "defaultArnoldFilter"; + rename -uid "77A2BCB1-4613-905E-080E-B997FD5E1C6F"; + setAttr ".ai_translator" -type "string" "gaussian"; +createNode aiAOVDriver -s -n "defaultArnoldDriver"; + rename -uid "C05729BE-4A33-F1DA-C222-3F8AB6EE7504"; + setAttr ".ai_translator" -type "string" "exr"; +createNode aiAOVDriver -s -n "defaultArnoldDisplayDriver"; + rename -uid "806C25D7-4284-C09D-A8AE-4A80DBFFFAAF"; + setAttr ".output_mode" 0; + setAttr ".ai_translator" -type "string" "maya"; +createNode renderSetupLayer -n "Main"; + rename -uid "DC3F077F-49F5-1D64-BFF3-AAAF06798636"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode renderLayer -n "rs_Main"; + rename -uid "D798EE14-43EE-D8EF-F4C9-D6B19C9BC029"; + setAttr ".do" 1; +createNode collection -n "defaultCollection"; + rename -uid "0194FCB7-43C4-DC06-C8D6-D9BA2721CCFA"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode simpleSelector -n "defaultCollectionSelector"; + rename -uid "37D69395-4785-D0BE-AEA0-EEA66D1FAEDF"; + setAttr ".pat" -type "string" "*"; +createNode objectSet -n "modelMain"; + rename -uid "811E4501-4B64-3016-BE29-E18EC09D90B7"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "writeColorSets" -ln "writeColorSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "writeFaceSets" -ln "writeFaceSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "attr" -ln "attr" -dt "string"; + addAttr -ci true -sn "attrPrefix" -ln "attrPrefix" -dt "string"; + addAttr -ci true -sn "includeParentHierarchy" -ln "includeParentHierarchy" -min + 0 -max 1 -at "bool"; + 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" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:49991563bf50"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "model"; + setAttr ".subset" -type "string" "modelMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.model"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr -cb on ".writeColorSets"; + setAttr -cb on ".writeFaceSets"; + setAttr ".attr" -type "string" ""; + setAttr ".attrPrefix" -type "string" ""; + setAttr -cb on ".includeParentHierarchy"; + setAttr ".task" -type "string" "test_task"; + setAttr ".publish_attributes" -type "string" "{\"ValidateNodeIDsRelated\": {\"active\": true}, \"ValidateInstanceInContext\": {\"active\": true}, \"ValidateTransformNamingSuffix\": {\"active\": true}, \"ValidateColorSets\": {\"active\": true}, \"ValidateMeshHasUVs\": {\"active\": true}, \"ValidateMeshNonZeroEdgeLength\": {\"active\": true}, \"ExtractModel\": {\"active\": true}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" "writeColorSets,writeFaceSets,includeParentHierarchy,attr,attrPrefix"; +createNode objectSet -n "_renderingMain:Main"; + rename -uid "4E1D2600-482D-425C-352A-74BA418DC374"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:752f6f925fe6"; +createNode objectSet -n "_renderingMain1:Main"; + rename -uid "A6382090-4537-44CB-E6DC-A5A58B98D008"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:d587b60d712e"; +createNode objectSet -n "workfileMain"; + rename -uid "0F32608C-4AA1-F493-205C-25BDABF95CEC"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + 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" + -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".hio" yes; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "workfile"; + setAttr ".subset" -type "string" "workfileTest_task"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.workfile"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".publish_attributes" -type "string" "{\"ValidateInstanceInContext\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" ""; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:eee200c4dd38"; +createNode objectSet -n "renderingMain1"; + rename -uid "9AC6AB5B-45EB-8439-BB6C-C197555E11E8"; + addAttr -ci true -sn "pre_creator_identifier" -ln "pre_creator_identifier" -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".pre_creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:e72a7af3a6c5"; +createNode objectSet -n "_renderingMain:Main1"; + rename -uid "7CFC031E-42E2-E431-89AB-5A991800F6F2"; + addAttr -s false -ci true -sn "renderlayer" -ln "renderlayer" -at "message"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "deadlineServers" -ln "deadlineServers" -dt "string"; + addAttr -ci true -sn "suspendPublishJob" -ln "suspendPublishJob" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "priority" -ln "priority" -at "long"; + addAttr -ci true -sn "tile_priority" -ln "tile_priority" -at "long"; + addAttr -ci true -sn "framesPerTask" -ln "framesPerTask" -at "long"; + addAttr -ci true -sn "whitelist" -ln "whitelist" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "machineList" -ln "machineList" -dt "string"; + addAttr -ci true -sn "useMayaBatch" -ln "useMayaBatch" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "primaryPool" -ln "primaryPool" -dt "string"; + addAttr -ci true -sn "secondaryPool" -ln "secondaryPool" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "review" -ln "review" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "extendFrames" -ln "extendFrames" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "overrideExistingFrame" -ln "overrideExistingFrame" -min 0 + -max 1 -at "bool"; + addAttr -ci true -sn "tileRendering" -ln "tileRendering" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "tilesX" -ln "tilesX" -at "long"; + addAttr -ci true -sn "tilesY" -ln "tilesY" -at "long"; + addAttr -ci true -sn "convertToScanline" -ln "convertToScanline" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "useReferencedAovs" -ln "useReferencedAovs" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "renderSetupIncludeLights" -ln "renderSetupIncludeLights" -min + 0 -max 1 -at "bool"; + 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" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:0850eb5268f2"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "renderlayer"; + setAttr ".subset" -type "string" "renderTest_taskMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".deadlineServers" -type "string" "default"; + setAttr -cb on ".suspendPublishJob"; + setAttr -cb on ".priority" 50; + setAttr -cb on ".tile_priority" 50; + setAttr -cb on ".framesPerTask" 1; + setAttr -cb on ".whitelist"; + setAttr ".machineList" -type "string" ""; + setAttr -cb on ".useMayaBatch"; + setAttr ".primaryPool" -type "string" "none"; + setAttr ".secondaryPool" -type "string" "-"; + setAttr ".task" -type "string" "test_task"; + setAttr -cb on ".review" yes; + setAttr -cb on ".extendFrames"; + setAttr -cb on ".overrideExistingFrame" yes; + setAttr -cb on ".tileRendering"; + setAttr -cb on ".tilesX" 2; + setAttr -cb on ".tilesY" 2; + setAttr -cb on ".convertToScanline"; + setAttr -cb on ".useReferencedAovs"; + setAttr -cb on ".renderSetupIncludeLights" yes; + setAttr ".publish_attributes" -type "string" "{\"CollectDeadlinePools\": {\"primaryPool\": \"\", \"secondaryPool\": \"\"}, \"ValidateResolution\": {\"active\": true}, \"ValidateDeadlinePools\": {\"active\": true}, \"ValidateFrameRange\": {\"active\": true}, \"ValidateInstanceInContext\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}, \"MayaSubmitDeadline\": {\"priority\": 50, \"chunkSize\": 1, \"machineList\": \"\", \"whitelist\": false, \"tile_priority\": 50, \"strict_error_checking\": true}, \"ProcessSubmittedJobOnFarm\": {\"publishJobState\": \"Active\"}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" "review,extendFrames,overrideExistingFrame,tileRendering,tilesX,tilesY,convertToScanline,useReferencedAovs,renderSetupIncludeLights"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :lightList1; +select -ne :standardSurface1; + setAttr ".b" 0.80000001192092896; + setAttr ".bc" -type "float3" 1 1 1 ; + setAttr ".s" 0.20000000298023224; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//_"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".dar" 1.7777777910232544; +select -ne :defaultLightSet; +select -ne :defaultColorMgtGlobals; + setAttr ".cfe" yes; + setAttr ".cfp" -type "string" "/OCIO-configs/Maya-legacy/config.ocio"; + setAttr ".vtn" -type "string" "sRGB gamma (legacy)"; + setAttr ".vn" -type "string" "sRGB gamma"; + setAttr ".dn" -type "string" "legacy"; + setAttr ".wsn" -type "string" "scene-linear Rec 709/sRGB"; + setAttr ".ovt" no; + setAttr ".povt" no; + setAttr ".otn" -type "string" "sRGB gamma (legacy)"; + setAttr ".potn" -type "string" "sRGB gamma (legacy)"; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "rs_Main.ri" ":persp.rlio[0]"; +connectAttr "rs_Main.ri" ":top.rlio[0]"; +connectAttr "rs_Main.ri" ":front.rlio[0]"; +connectAttr "rs_Main.ri" ":side.rlio[0]"; +connectAttr "rs_Main.ri" "pDisc1.rlio[0]"; +connectAttr "polyDisc1.output" "pDiscShape1.i"; +connectAttr "rs_Main.ri" "aiSkyDomeLight1.rlio[0]"; +connectAttr "rs_Main.ri" "mainCamera.rlio[0]"; +connectAttr "rs_Main.ri" "model_GRP.rlio[0]"; +connectAttr "polySphere1.out" "pSphere1_GEOShape1.i"; +relationship "link" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "link" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +connectAttr "layerManager.dli[0]" "defaultLayer.id"; +connectAttr "renderLayerManager.rlmi[0]" "defaultRenderLayer.rlid"; +connectAttr "Main.msg" "renderSetup.frl"; +connectAttr "Main.msg" "renderSetup.lrl"; +connectAttr ":defaultArnoldDisplayDriver.msg" ":defaultArnoldRenderOptions.drivers" + -na; +connectAttr ":defaultArnoldFilter.msg" ":defaultArnoldRenderOptions.filt"; +connectAttr ":defaultArnoldDriver.msg" ":defaultArnoldRenderOptions.drvr"; +connectAttr "rs_Main.msg" "Main.lrl"; +connectAttr "renderSetup.lit" "Main.pls"; +connectAttr "defaultCollection.msg" "Main.cl"; +connectAttr "defaultCollection.msg" "Main.ch"; +connectAttr "renderLayerManager.rlmi[1]" "rs_Main.rlid"; +connectAttr "defaultCollectionSelector.c" "defaultCollection.sel"; +connectAttr "Main.lit" "defaultCollection.pls"; +connectAttr "Main.nic" "defaultCollection.pic"; +connectAttr "model_GRP.iog" "modelMain.dsm" -na; +connectAttr "modelMain.msg" "_renderingMain:Main.dnsm" -na; +connectAttr "model_GRP.iog" "_renderingMain1:Main.dsm" -na; +connectAttr "_renderingMain:Main1.msg" "renderingMain1.dnsm" -na; +connectAttr "Main.msg" "_renderingMain:Main1.renderlayer"; +connectAttr "defaultRenderLayer.msg" ":defaultRenderingList1.r" -na; +connectAttr "rs_Main.msg" ":defaultRenderingList1.r" -na; +connectAttr "aiSkyDomeLightShape1.ltd" ":lightList1.l" -na; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "pDiscShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "aiSkyDomeLight1.iog" ":defaultLightSet.dsm" -na; +// End of test_project_test_asset_test_task_v002.ma diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/workspace.mel b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/workspace.mel new file mode 100644 index 0000000000..7b4dc18c55 --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/expected/test_project/test_asset/work/test_task/workspace.mel @@ -0,0 +1,10 @@ +workspace -fr "shaders" "renderData/shaders"; +workspace -fr "images" "renders/maya"; +workspace -fr "particles" "particles"; +workspace -fr "mayaAscii" ""; +workspace -fr "mayaBinary" ""; +workspace -fr "scene" ""; +workspace -fr "alembicCache" "cache/alembic"; +workspace -fr "renderData" "renderData"; +workspace -fr "sourceImages" "sourceimages"; +workspace -fr "fileCache" "cache/nCache"; diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/avalon_tests/test_project.bson b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/avalon_tests/test_project.bson new file mode 100644 index 0000000000..72293b3080 Binary files /dev/null and b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/avalon_tests/test_project.bson differ diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/avalon_tests/test_project.metadata.json b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/avalon_tests/test_project.metadata.json new file mode 100644 index 0000000000..36babfe61a --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/avalon_tests/test_project.metadata.json @@ -0,0 +1 @@ +{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"}],"uuid":"0fe6204d5115481a93ff43a1ad4b5fdd","collectionName":"test_project"} diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/openpype_tests/settings.bson b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/openpype_tests/settings.bson new file mode 100644 index 0000000000..e7bb56a33e Binary files /dev/null and b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/openpype_tests/settings.bson differ diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/openpype_tests/settings.metadata.json b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/openpype_tests/settings.metadata.json new file mode 100644 index 0000000000..beb3e3b6bb --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/dumps/openpype_tests/settings.metadata.json @@ -0,0 +1 @@ +{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"}],"uuid":"feec494c3f8045e9a23a5092522d157c","collectionName":"settings"} diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/env_vars/env_var.json b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/env_vars/env_var.json new file mode 100644 index 0000000000..805d1c7017 --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/env_vars/env_var.json @@ -0,0 +1,11 @@ +{ + "OPENPYPE_MONGO": "{TEST_OPENPYPE_MONGO}", + "AVALON_MONGO": "{TEST_OPENPYPE_MONGO}", + "OPENPYPE_DATABASE_NAME": "{TEST_OPENPYPE_NAME}", + "AVALON_TIMEOUT": "3000", + "AVALON_DB": "{TEST_DB_NAME}", + "AVALON_PROJECT": "{TEST_PROJECT_NAME}", + "PYPE_DEBUG": "3", + "AVALON_CONFIG": "openpype", + "IS_TEST": "1" +} diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/startup/userSetup.py b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/startup/userSetup.py new file mode 100644 index 0000000000..5ea83ef18e --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/startup/userSetup.py @@ -0,0 +1,13 @@ +import sys +print("\n".join(sys.path)) + +from maya import cmds +import pyblish.util +import openpype + +print("starting OpenPype usersetup for testing") +cmds.evalDeferred("pyblish.util.publish()") + +cmds.evalDeferred("cmds.quit(force=True)") +cmds.evalDeferred("cmds.quit") +print("finished OpenPype usersetup for testing") diff --git a/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma new file mode 100644 index 0000000000..2e882a5baa --- /dev/null +++ b/tests/integration/hosts/maya/test_deadline_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma @@ -0,0 +1,526 @@ +//Maya ASCII 2023 scene +//Name: test_project_test_asset_test_task_v002.ma +//Last modified: Thu, Nov 09, 2023 11:59:33 AM +//Codeset: 1252 +requires maya "2023"; +requires -nodeType "simpleSelector" -nodeType "renderSetupLayer" -nodeType "renderSetup" + -nodeType "collection" "renderSetup.py" "1.0"; +requires -nodeType "aiOptions" -nodeType "aiAOVDriver" -nodeType "aiAOVFilter" -nodeType "aiSkyDomeLight" + "mtoa" "5.2.1.1"; +requires -nodeType "polyDisc" "modelingToolkit" "0.0.0.0"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2023"; +fileInfo "version" "2023"; +fileInfo "cutIdentifier" "202211021031-847a9f9623"; +fileInfo "osv" "Windows 10 Pro v2009 (Build: 19045)"; +fileInfo "license" "education"; +fileInfo "UUID" "591BA477-4DBF-D8A9-D1CE-AEB4E5E95DCA"; +fileInfo "OpenPypeContext" "eyJwdWJsaXNoX2F0dHJpYnV0ZXMiOiB7IlZhbGlkYXRlQ29udGFpbmVycyI6IHsiYWN0aXZlIjogdHJ1ZX19fQ=="; +createNode transform -s -n "persp"; + rename -uid "D52C935B-47C9-D868-A875-D799DD17B3A1"; + setAttr ".v" no; + setAttr ".t" -type "double3" 33.329836010894773 18.034068470832839 24.890981774804157 ; + setAttr ".r" -type "double3" 79.461647270402509 -44.999999999997357 183.99999999999159 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -s -n "perspShape" -p "persp"; + rename -uid "2399E6C0-490F-BA1F-485F-5AA8A01D27BC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 50.609449488607154; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -s -n "top"; + rename -uid "415C7426-413E-0FAE-FCC3-3DAED7443A52"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 1000.1 0 ; + setAttr ".r" -type "double3" 90 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" 0 -1000.1 1000.1 ; +createNode camera -s -n "topShape" -p "top"; + rename -uid "3BD0CF60-40DB-5278-5D8B-06ACBDA32122"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "top"; + setAttr ".den" -type "string" "top_depth"; + setAttr ".man" -type "string" "top_mask"; + setAttr ".hc" -type "string" "viewSet -t %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "front"; + rename -uid "D83DD5CE-4FE0-AB1B-81B2-87A63F0B7A05"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 0 1000.1 ; + setAttr ".r" -type "double3" 180 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; +createNode camera -s -n "frontShape" -p "front"; + rename -uid "23313CBA-42C2-0B3A-0FCF-EA965EAC5DEC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "front"; + setAttr ".den" -type "string" "front_depth"; + setAttr ".man" -type "string" "front_mask"; + setAttr ".hc" -type "string" "viewSet -f %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "side"; + rename -uid "F70F692C-4A0D-BE64-9EE4-A99B6FA2D56E"; + setAttr ".v" no; + setAttr ".t" -type "double3" 1000.1 0 0 ; + setAttr ".r" -type "double3" 180 -90 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" -1000.1 0 1000.1 ; +createNode camera -s -n "sideShape" -p "side"; + rename -uid "C05669C3-420E-CA11-E5FC-7EB64EF8B632"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "side"; + setAttr ".den" -type "string" "side_depth"; + setAttr ".man" -type "string" "side_mask"; + setAttr ".hc" -type "string" "viewSet -s %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -n "pDisc1"; + rename -uid "DED70CCF-4C19-16E4-9E5D-66A05037BA47"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:90e762703f08"; +createNode mesh -n "pDiscShape1" -p "pDisc1"; + rename -uid "E1FCDCCF-4DE1-D3B9-C4F8-3285F1CF5B25"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:4ee3da11a1a4"; +createNode transform -n "aiSkyDomeLight1"; + rename -uid "402BF091-4305-22E3-7CF0-9BA3D7F948F7"; +createNode aiSkyDomeLight -n "aiSkyDomeLightShape1" -p "aiSkyDomeLight1"; + rename -uid "CEF32074-4066-553D-A4FD-65B508A56ABE"; + addAttr -ci true -h true -sn "aal" -ln "attributeAliasList" -dt "attributeAlias"; + setAttr -k off ".v"; + setAttr ".csh" no; + setAttr ".rcsh" no; + setAttr ".aal" -type "attributeAlias" {"exposure","aiExposure"} ; +createNode transform -n "mainCamera"; + rename -uid "58651370-474E-02EE-39A9-A2AB27E0DD87"; + setAttr ".t" -type "double3" 33.329836010894773 18.034068470832839 24.890981774804157 ; + setAttr ".r" -type "double3" 79.461647270402509 -44.999999999997357 183.99999999999159 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -n "mainCameraShape" -p "mainCamera"; + rename -uid "CCE11369-4101-EE5E-5381-3F87DA963CA2"; + setAttr -k off ".v"; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 50.609449488607154; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -n "model_GRP"; + rename -uid "445FDC20-4A9D-2C5B-C7BD-F98F6E660B5C"; +createNode transform -n "pSphere1_GEO" -p "model_GRP"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:302a4c6123a4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:6c77a15a98a9"; +createNode lightLinker -s -n "lightLinker1"; + rename -uid "D9ADDBD2-49DE-91E2-4166-99A362986A3A"; + setAttr -s 2 ".lnk"; + setAttr -s 2 ".slnk"; +createNode shapeEditorManager -n "shapeEditorManager"; + rename -uid "A232A3B1-4B62-92E7-A7C9-9D9FC5EF010A"; +createNode poseInterpolatorManager -n "poseInterpolatorManager"; + rename -uid "B30BA35D-492A-834B-3448-49A80BBBFC39"; +createNode displayLayerManager -n "layerManager"; + rename -uid "4417380F-4A6A-16CC-B1DE-AA95ED9C7FB2"; +createNode displayLayer -n "defaultLayer"; + rename -uid "4A776D1B-401F-7069-1C74-A7AAE84CEE03"; + setAttr ".ufem" -type "stringArray" 0 ; +createNode renderLayerManager -n "renderLayerManager"; + rename -uid "EE21C644-43B8-C754-0BED-709D2EEB204D"; + setAttr -s 2 ".rlmi[1]" 1; + setAttr -s 2 ".rlmi"; +createNode renderLayer -n "defaultRenderLayer"; + rename -uid "B134920D-4508-23BD-A6CA-11B43DE03F53"; + setAttr ".g" yes; +createNode renderSetup -n "renderSetup"; + rename -uid "9A8F0D15-41AB-CA70-C2D8-B78840BF9BC1"; +createNode polySphere -n "polySphere1"; + rename -uid "DA319706-4ACF-B15C-53B2-48AC80D202EA"; +createNode script -n "uiConfigurationScriptNode"; + rename -uid "4B7AFB53-452E-E870-63E1-CCA1DD6EAF13"; + setAttr ".b" -type "string" ( + "// Maya Mel UI Configuration File.\n//\n// This script is machine generated. Edit at your own risk.\n//\n//\n\nglobal string $gMainPane;\nif (`paneLayout -exists $gMainPane`) {\n\n\tglobal int $gUseScenePanelConfig;\n\tint $useSceneConfig = $gUseScenePanelConfig;\n\tint $nodeEditorPanelVisible = stringArrayContains(\"nodeEditorPanel1\", `getPanel -vis`);\n\tint $nodeEditorWorkspaceControlOpen = (`workspaceControl -exists nodeEditorPanel1Window` && `workspaceControl -q -visible nodeEditorPanel1Window`);\n\tint $menusOkayInPanels = `optionVar -q allowMenusInPanels`;\n\tint $nVisPanes = `paneLayout -q -nvp $gMainPane`;\n\tint $nPanes = 0;\n\tstring $editorName;\n\tstring $panelName;\n\tstring $itemFilterName;\n\tstring $panelConfig;\n\n\t//\n\t// get current state of the UI\n\t//\n\tsceneUIReplacement -update $gMainPane;\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Top View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Top View\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n" + + " -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n" + + " -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 477\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Side View\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Side View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n" + + " -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n" + + " -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 476\n -height 345\n -sceneRenderFilter 0\n $editorName;\n" + + " modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Front View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Front View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|top\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n" + + " -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n" + + " -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n" + + " -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 477\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Persp View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Persp View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|mainCamera\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n" + + " -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 32768\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n" + + " -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n" + + " -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 476\n -height 345\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"ToggledOutliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"ToggledOutliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -docTag \"isolOutln_fromSeln\" \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 1\n -showReferenceMembers 1\n" + + " -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n" + + " -isSet 0\n -isSetMember 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -selectCommand \"print(\\\"\\\")\" \n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n -renderFilterIndex 0\n -selectionOrder \"chronological\" \n -expandAttribute 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"Outliner\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"Outliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n" + + " -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n" + + " $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"graphEditor\" (localizedPanelLabel(\"Graph Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Graph Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 1\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n" + + " -showPublishedAsConnected 0\n -showParentContainers 1\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 0\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 1\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 1\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n" + + " -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 1\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"GraphEd\");\n animCurveEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -showPlayRangeShades \"on\" \n -lockPlayRangeShades \"off\" \n -smoothness \"fine\" \n -resultSamples 1.041667\n -resultScreenSamples 0\n -resultUpdate \"delayed\" \n -showUpstreamCurves 1\n -keyMinScale 1\n -stackedCurvesMin -1\n -stackedCurvesMax 1\n" + + " -stackedCurvesSpace 0.2\n -preSelectionHighlight 0\n -constrainDrag 0\n -valueLinesToggle 1\n -highlightAffectedCurves 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dopeSheetPanel\" (localizedPanelLabel(\"Dope Sheet\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dope Sheet\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n" + + " -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 1\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 0\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 1\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n" + + " -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"DopeSheetEd\");\n dopeSheetEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -outliner \"dopeSheetPanel1OutlineEd\" \n -showSummary 1\n -showScene 0\n -hierarchyBelow 0\n" + + " -showTicks 1\n -selectionWindow 0 0 0 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"timeEditorPanel\" (localizedPanelLabel(\"Time Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Time Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"clipEditorPanel\" (localizedPanelLabel(\"Trax Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Trax Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = clipEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 0 \n" + + " $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"sequenceEditorPanel\" (localizedPanelLabel(\"Camera Sequencer\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Camera Sequencer\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = sequenceEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperGraphPanel\" (localizedPanelLabel(\"Hypergraph Hierarchy\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypergraph Hierarchy\")) -mbv $menusOkayInPanels $panelName;\n" + + "\n\t\t\t$editorName = ($panelName+\"HyperGraphEd\");\n hyperGraph -e \n -graphLayoutStyle \"hierarchicalLayout\" \n -orientation \"horiz\" \n -mergeConnections 0\n -zoom 1\n -animateTransition 0\n -showRelationships 1\n -showShapes 0\n -showDeformers 0\n -showExpressions 0\n -showConstraints 0\n -showConnectionFromSelected 0\n -showConnectionToSelected 0\n -showConstraintLabels 0\n -showUnderworld 0\n -showInvisible 0\n -transitionFrames 1\n -opaqueContainers 0\n -freeform 0\n -imagePosition 0 0 \n -imageScale 1\n -imageEnabled 0\n -graphType \"DAG\" \n -heatMapDisplay 0\n -updateSelection 1\n -updateNodeAdded 1\n -useDrawOverrideColor 0\n -limitGraphTraversal -1\n" + + " -range 0 0 \n -iconSize \"smallIcons\" \n -showCachedConnections 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperShadePanel\" (localizedPanelLabel(\"Hypershade\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypershade\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"visorPanel\" (localizedPanelLabel(\"Visor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Visor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"nodeEditorPanel\" (localizedPanelLabel(\"Node Editor\")) `;\n\tif ($nodeEditorPanelVisible || $nodeEditorWorkspaceControlOpen) {\n" + + "\t\tif (\"\" == $panelName) {\n\t\t\tif ($useSceneConfig) {\n\t\t\t\t$panelName = `scriptedPanel -unParent -type \"nodeEditorPanel\" -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels `;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 1\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n" + + " -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\t}\n\t\t} else {\n\t\t\t$label = `panel -q -label $panelName`;\n\t\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n" + + " -additiveGraphingMode 1\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\tif (!$useSceneConfig) {\n\t\t\t\tpanel -e -l $label $panelName;\n\t\t\t}\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"createNodePanel\" (localizedPanelLabel(\"Create Node\")) `;\n\tif (\"\" != $panelName) {\n" + + "\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Create Node\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"polyTexturePlacementPanel\" (localizedPanelLabel(\"UV Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"UV Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"renderWindowPanel\" (localizedPanelLabel(\"Render View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Render View\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"shapePanel\" (localizedPanelLabel(\"Shape Editor\")) `;\n\tif (\"\" != $panelName) {\n" + + "\t\t$label = `panel -q -label $panelName`;\n\t\tshapePanel -edit -l (localizedPanelLabel(\"Shape Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"posePanel\" (localizedPanelLabel(\"Pose Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tposePanel -edit -l (localizedPanelLabel(\"Pose Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynRelEdPanel\" (localizedPanelLabel(\"Dynamic Relationships\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dynamic Relationships\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"relationshipPanel\" (localizedPanelLabel(\"Relationship Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Relationship Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"referenceEditorPanel\" (localizedPanelLabel(\"Reference Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Reference Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynPaintScriptedPanelType\" (localizedPanelLabel(\"Paint Effects\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Paint Effects\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"scriptEditorPanel\" (localizedPanelLabel(\"Script Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Script Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"profilerPanel\" (localizedPanelLabel(\"Profiler Tool\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Profiler Tool\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"contentBrowserPanel\" (localizedPanelLabel(\"Content Browser\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Content Browser\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\tif ($useSceneConfig) {\n string $configName = `getPanel -cwl (localizedPanelLabel(\"Current Layout\"))`;\n" + + " if (\"\" != $configName) {\n\t\t\tpanelConfiguration -edit -label (localizedPanelLabel(\"Current Layout\")) \n\t\t\t\t-userCreated false\n\t\t\t\t-defaultImage \"vacantCell.xP:/\"\n\t\t\t\t-image \"\"\n\t\t\t\t-sc false\n\t\t\t\t-configString \"global string $gMainPane; paneLayout -e -cn \\\"quad\\\" -ps 1 50 50 -ps 2 50 50 -ps 3 50 50 -ps 4 50 50 $gMainPane;\"\n\t\t\t\t-removeAllPanels\n\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Top View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Top View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera top` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Top View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera top` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Persp View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|mainCamera\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|mainCamera\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Side View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Side View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera side` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Side View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera side` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 476\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Front View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Front View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|top\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Front View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -camera \\\"|top\\\" \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 32768\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 477\\n -height 345\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName\"\n" + + "\t\t\t\t$configName;\n\n setNamedPanelLayout (localizedPanelLabel(\"Current Layout\"));\n }\n\n panelHistory -e -clear mainPanelHistory;\n sceneUIReplacement -clear;\n\t}\n\n\ngrid -spacing 5 -size 12 -divisions 5 -displayAxes yes -displayGridLines yes -displayDivisionLines yes -displayPerspectiveLabels no -displayOrthographicLabels no -displayAxesBold yes -perspectiveLabelPosition axis -orthographicLabelPosition edge;\nviewManip -drawCompass 0 -compassAngle 0 -frontParameters \"\" -homeParameters \"\" -selectionLockParameters \"\";\n}\n"); + setAttr ".st" 3; +createNode script -n "sceneConfigurationScriptNode"; + rename -uid "72B19BC2-43A2-E229-0A73-2CB861A291D1"; + setAttr ".b" -type "string" "playbackOptions -min 1000 -max 1001 -ast 1000 -aet 1001 "; + setAttr ".st" 6; +createNode polyDisc -n "polyDisc1"; + rename -uid "9ED8A7BD-4FFD-6107-4322-35ACD1D3AC42"; +createNode aiOptions -s -n "defaultArnoldRenderOptions"; + rename -uid "31A81965-48A6-B90D-503D-2FA162B7C982"; + setAttr ".skip_license_check" yes; +createNode aiAOVFilter -s -n "defaultArnoldFilter"; + rename -uid "77A2BCB1-4613-905E-080E-B997FD5E1C6F"; + setAttr ".ai_translator" -type "string" "gaussian"; +createNode aiAOVDriver -s -n "defaultArnoldDriver"; + rename -uid "C05729BE-4A33-F1DA-C222-3F8AB6EE7504"; + setAttr ".ai_translator" -type "string" "exr"; +createNode aiAOVDriver -s -n "defaultArnoldDisplayDriver"; + rename -uid "806C25D7-4284-C09D-A8AE-4A80DBFFFAAF"; + setAttr ".output_mode" 0; + setAttr ".ai_translator" -type "string" "maya"; +createNode renderSetupLayer -n "Main"; + rename -uid "DC3F077F-49F5-1D64-BFF3-AAAF06798636"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode renderLayer -n "rs_Main"; + rename -uid "D798EE14-43EE-D8EF-F4C9-D6B19C9BC029"; + setAttr ".do" 1; +createNode collection -n "defaultCollection"; + rename -uid "0194FCB7-43C4-DC06-C8D6-D9BA2721CCFA"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode simpleSelector -n "defaultCollectionSelector"; + rename -uid "37D69395-4785-D0BE-AEA0-EEA66D1FAEDF"; + setAttr ".pat" -type "string" "*"; +createNode objectSet -n "modelMain"; + rename -uid "811E4501-4B64-3016-BE29-E18EC09D90B7"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "writeColorSets" -ln "writeColorSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "writeFaceSets" -ln "writeFaceSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "attr" -ln "attr" -dt "string"; + addAttr -ci true -sn "attrPrefix" -ln "attrPrefix" -dt "string"; + addAttr -ci true -sn "includeParentHierarchy" -ln "includeParentHierarchy" -min + 0 -max 1 -at "bool"; + 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" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:49991563bf50"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "model"; + setAttr ".subset" -type "string" "modelMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.model"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr -cb on ".writeColorSets"; + setAttr -cb on ".writeFaceSets"; + setAttr ".attr" -type "string" ""; + setAttr ".attrPrefix" -type "string" ""; + setAttr -cb on ".includeParentHierarchy"; + setAttr ".task" -type "string" "test_task"; + setAttr ".publish_attributes" -type "string" "{\"ValidateNodeIDsRelated\": {\"active\": true}, \"ValidateInstanceInContext\": {\"active\": true}, \"ValidateTransformNamingSuffix\": {\"active\": true}, \"ValidateColorSets\": {\"active\": true}, \"ValidateMeshHasUVs\": {\"active\": true}, \"ValidateMeshNonZeroEdgeLength\": {\"active\": true}, \"ExtractModel\": {\"active\": true}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" "writeColorSets,writeFaceSets,includeParentHierarchy,attr,attrPrefix"; +createNode objectSet -n "_renderingMain:Main"; + rename -uid "4E1D2600-482D-425C-352A-74BA418DC374"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:752f6f925fe6"; +createNode objectSet -n "_renderingMain1:Main"; + rename -uid "A6382090-4537-44CB-E6DC-A5A58B98D008"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:d587b60d712e"; +createNode objectSet -n "workfileMain"; + rename -uid "0F32608C-4AA1-F493-205C-25BDABF95CEC"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + 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" + -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".hio" yes; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "workfile"; + setAttr ".subset" -type "string" "workfileTest_task"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.workfile"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".publish_attributes" -type "string" "{\"ValidateInstanceInContext\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" ""; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:eee200c4dd38"; +createNode objectSet -n "renderingMain1"; + rename -uid "9AC6AB5B-45EB-8439-BB6C-C197555E11E8"; + addAttr -ci true -sn "pre_creator_identifier" -ln "pre_creator_identifier" -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".pre_creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:e72a7af3a6c5"; +createNode objectSet -n "_renderingMain:Main1"; + rename -uid "7CFC031E-42E2-E431-89AB-5A991800F6F2"; + addAttr -s false -ci true -sn "renderlayer" -ln "renderlayer" -at "message"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "deadlineServers" -ln "deadlineServers" -dt "string"; + addAttr -ci true -sn "suspendPublishJob" -ln "suspendPublishJob" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "priority" -ln "priority" -at "long"; + addAttr -ci true -sn "tile_priority" -ln "tile_priority" -at "long"; + addAttr -ci true -sn "framesPerTask" -ln "framesPerTask" -at "long"; + addAttr -ci true -sn "whitelist" -ln "whitelist" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "machineList" -ln "machineList" -dt "string"; + addAttr -ci true -sn "useMayaBatch" -ln "useMayaBatch" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "primaryPool" -ln "primaryPool" -dt "string"; + addAttr -ci true -sn "secondaryPool" -ln "secondaryPool" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "review" -ln "review" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "extendFrames" -ln "extendFrames" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "overrideExistingFrame" -ln "overrideExistingFrame" -min 0 + -max 1 -at "bool"; + addAttr -ci true -sn "tileRendering" -ln "tileRendering" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "tilesX" -ln "tilesX" -at "long"; + addAttr -ci true -sn "tilesY" -ln "tilesY" -at "long"; + addAttr -ci true -sn "convertToScanline" -ln "convertToScanline" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "useReferencedAovs" -ln "useReferencedAovs" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "renderSetupIncludeLights" -ln "renderSetupIncludeLights" -min + 0 -max 1 -at "bool"; + 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" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:0850eb5268f2"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "renderlayer"; + setAttr ".subset" -type "string" "renderTest_taskMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".deadlineServers" -type "string" "default"; + setAttr -cb on ".suspendPublishJob"; + setAttr -cb on ".priority" 50; + setAttr -cb on ".tile_priority" 50; + setAttr -cb on ".framesPerTask" 1; + setAttr -cb on ".whitelist"; + setAttr ".machineList" -type "string" ""; + setAttr -cb on ".useMayaBatch"; + setAttr ".primaryPool" -type "string" "none"; + setAttr ".secondaryPool" -type "string" "-"; + setAttr ".task" -type "string" "test_task"; + setAttr -cb on ".review" yes; + setAttr -cb on ".extendFrames"; + setAttr -cb on ".overrideExistingFrame" yes; + setAttr -cb on ".tileRendering"; + setAttr -cb on ".tilesX" 2; + setAttr -cb on ".tilesY" 2; + setAttr -cb on ".convertToScanline"; + setAttr -cb on ".useReferencedAovs"; + setAttr -cb on ".renderSetupIncludeLights" yes; + setAttr ".publish_attributes" -type "string" "{\"CollectDeadlinePools\": {\"primaryPool\": \"\", \"secondaryPool\": \"\"}, \"ValidateResolution\": {\"active\": true}, \"ValidateDeadlinePools\": {\"active\": true}, \"ValidateFrameRange\": {\"active\": true}, \"ValidateInstanceInContext\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}, \"MayaSubmitDeadline\": {\"priority\": 50, \"chunkSize\": 1, \"machineList\": \"\", \"whitelist\": false, \"tile_priority\": 50, \"strict_error_checking\": true}, \"ProcessSubmittedJobOnFarm\": {\"publishJobState\": \"Active\"}}"; + setAttr ".creator_attributes" -type "string" "{}"; + setAttr ".__creator_attributes_keys" -type "string" "review,extendFrames,overrideExistingFrame,tileRendering,tilesX,tilesY,convertToScanline,useReferencedAovs,renderSetupIncludeLights"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :lightList1; +select -ne :standardSurface1; + setAttr ".b" 0.80000001192092896; + setAttr ".bc" -type "float3" 1 1 1 ; + setAttr ".s" 0.20000000298023224; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//_"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".dar" 1.7777777910232544; +select -ne :defaultLightSet; +select -ne :defaultColorMgtGlobals; + setAttr ".cfe" yes; + setAttr ".cfp" -type "string" "/OCIO-configs/Maya-legacy/config.ocio"; + setAttr ".vtn" -type "string" "sRGB gamma (legacy)"; + setAttr ".vn" -type "string" "sRGB gamma"; + setAttr ".dn" -type "string" "legacy"; + setAttr ".wsn" -type "string" "scene-linear Rec 709/sRGB"; + setAttr ".ovt" no; + setAttr ".povt" no; + setAttr ".otn" -type "string" "sRGB gamma (legacy)"; + setAttr ".potn" -type "string" "sRGB gamma (legacy)"; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "rs_Main.ri" ":persp.rlio[0]"; +connectAttr "rs_Main.ri" ":top.rlio[0]"; +connectAttr "rs_Main.ri" ":front.rlio[0]"; +connectAttr "rs_Main.ri" ":side.rlio[0]"; +connectAttr "rs_Main.ri" "pDisc1.rlio[0]"; +connectAttr "polyDisc1.output" "pDiscShape1.i"; +connectAttr "rs_Main.ri" "aiSkyDomeLight1.rlio[0]"; +connectAttr "rs_Main.ri" "mainCamera.rlio[0]"; +connectAttr "rs_Main.ri" "model_GRP.rlio[0]"; +connectAttr "polySphere1.out" "pSphere1_GEOShape1.i"; +relationship "link" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "link" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +connectAttr "layerManager.dli[0]" "defaultLayer.id"; +connectAttr "renderLayerManager.rlmi[0]" "defaultRenderLayer.rlid"; +connectAttr "Main.msg" "renderSetup.frl"; +connectAttr "Main.msg" "renderSetup.lrl"; +connectAttr ":defaultArnoldDisplayDriver.msg" ":defaultArnoldRenderOptions.drivers" + -na; +connectAttr ":defaultArnoldFilter.msg" ":defaultArnoldRenderOptions.filt"; +connectAttr ":defaultArnoldDriver.msg" ":defaultArnoldRenderOptions.drvr"; +connectAttr "rs_Main.msg" "Main.lrl"; +connectAttr "renderSetup.lit" "Main.pls"; +connectAttr "defaultCollection.msg" "Main.cl"; +connectAttr "defaultCollection.msg" "Main.ch"; +connectAttr "renderLayerManager.rlmi[1]" "rs_Main.rlid"; +connectAttr "defaultCollectionSelector.c" "defaultCollection.sel"; +connectAttr "Main.lit" "defaultCollection.pls"; +connectAttr "Main.nic" "defaultCollection.pic"; +connectAttr "model_GRP.iog" "modelMain.dsm" -na; +connectAttr "modelMain.msg" "_renderingMain:Main.dnsm" -na; +connectAttr "model_GRP.iog" "_renderingMain1:Main.dsm" -na; +connectAttr "_renderingMain:Main1.msg" "renderingMain1.dnsm" -na; +connectAttr "Main.msg" "_renderingMain:Main1.renderlayer"; +connectAttr "defaultRenderLayer.msg" ":defaultRenderingList1.r" -na; +connectAttr "rs_Main.msg" ":defaultRenderingList1.r" -na; +connectAttr "aiSkyDomeLightShape1.ltd" ":lightList1.l" -na; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "pDiscShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "aiSkyDomeLight1.iog" ":defaultLightSet.dsm" -na; +// End of test_project_test_asset_test_task_v002.ma diff --git a/tests/integration/hosts/maya/test_publish_in_maya.py b/tests/integration/hosts/maya/test_publish_in_maya.py index b7ee228aae..1a131b0bad 100644 --- a/tests/integration/hosts/maya/test_publish_in_maya.py +++ b/tests/integration/hosts/maya/test_publish_in_maya.py @@ -1,3 +1,6 @@ +import re +import os + from tests.lib.assert_classes import DBAssert from tests.integration.hosts.maya.lib import MayaLocalPublishTestClass @@ -26,7 +29,7 @@ class TestPublishInMaya(MayaLocalPublishTestClass): PERSIST = False TEST_FILES = [ - ("1BTSIIULJTuDc8VvXseuiJV_fL6-Bu7FP", "test_maya_publish.zip", "") + ("test_publish_in_maya", "", "") ] APP_GROUP = "maya" @@ -35,6 +38,32 @@ class TestPublishInMaya(MayaLocalPublishTestClass): TIMEOUT = 120 # publish timeout + def test_publish( + self, + dbcon, + publish_finished, + download_test_data + ): + """Testing Pyblish and Python logs within Maya.""" + + # All maya output via MAYA_CMD_FILE_OUTPUT env var during test run + logging_path = os.path.join(download_test_data, "output.log") + with open(logging_path, "r") as f: + logging_output = f.read() + + print(("-" * 50) + "LOGGING" + ("-" * 50)) + print(logging_output) + + # Check for pyblish errors. + error_regex = r"pyblish \(ERROR\)((.|\n)*?)((pyblish \())" + matches = re.findall(error_regex, logging_output) + assert not matches, matches[0][0] + + # Check for python errors. + error_regex = r"// Error((.|\n)*)" + matches = re.findall(error_regex, logging_output) + assert not matches, matches[0][0] + def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" print("test_db_asserts") @@ -67,7 +96,7 @@ class TestPublishInMaya(MayaLocalPublishTestClass): additional_args=additional_args)) additional_args = {"context.subset": "workfileTest_task", - "context.ext": "mb"} + "context.ext": "ma"} failures.append( DBAssert.count_of_types(dbcon, "representation", 1, additional_args=additional_args)) diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc new file mode 100644 index 0000000000..a1d07f8f31 Binary files /dev/null and b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.abc differ diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma new file mode 100644 index 0000000000..5088dfa11c --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/hero/test_project_test_asset_modelMain_hero.ma @@ -0,0 +1,1186 @@ +//Maya ASCII 2019 scene +//Name: modelMain.ma +//Last modified: Thu, Sep 02, 2021 03:24:19 PM +//Codeset: 1252 +requires maya "2019"; +requires "mtoa" "4.0.4.2"; +requires "mtoa" "4.0.4.2"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2019"; +fileInfo "version" "2019"; +fileInfo "cutIdentifier" "201812112215-434d8d9c04"; +fileInfo "osv" "Microsoft Windows 10 Technical Preview (Build 19041)\n"; +fileInfo "license" "education"; +createNode transform -n "pSphere1_GEO"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:440654b3dfe4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr -s 439 ".uvst[0].uvsp"; + setAttr ".uvst[0].uvsp[0:249]" -type "float2" 0 0.050000001 0.050000001 0.050000001 + 0.1 0.050000001 0.15000001 0.050000001 0.2 0.050000001 0.25 0.050000001 0.30000001 + 0.050000001 0.35000002 0.050000001 0.40000004 0.050000001 0.45000005 0.050000001 + 0.50000006 0.050000001 0.55000007 0.050000001 0.60000008 0.050000001 0.6500001 0.050000001 + 0.70000011 0.050000001 0.75000012 0.050000001 0.80000013 0.050000001 0.85000014 0.050000001 + 0.90000015 0.050000001 0.95000017 0.050000001 1.000000119209 0.050000001 0 0.1 0.050000001 + 0.1 0.1 0.1 0.15000001 0.1 0.2 0.1 0.25 0.1 0.30000001 0.1 0.35000002 0.1 0.40000004 + 0.1 0.45000005 0.1 0.50000006 0.1 0.55000007 0.1 0.60000008 0.1 0.6500001 0.1 0.70000011 + 0.1 0.75000012 0.1 0.80000013 0.1 0.85000014 0.1 0.90000015 0.1 0.95000017 0.1 1.000000119209 + 0.1 0 0.15000001 0.050000001 0.15000001 0.1 0.15000001 0.15000001 0.15000001 0.2 + 0.15000001 0.25 0.15000001 0.30000001 0.15000001 0.35000002 0.15000001 0.40000004 + 0.15000001 0.45000005 0.15000001 0.50000006 0.15000001 0.55000007 0.15000001 0.60000008 + 0.15000001 0.6500001 0.15000001 0.70000011 0.15000001 0.75000012 0.15000001 0.80000013 + 0.15000001 0.85000014 0.15000001 0.90000015 0.15000001 0.95000017 0.15000001 1.000000119209 + 0.15000001 0 0.2 0.050000001 0.2 0.1 0.2 0.15000001 0.2 0.2 0.2 0.25 0.2 0.30000001 + 0.2 0.35000002 0.2 0.40000004 0.2 0.45000005 0.2 0.50000006 0.2 0.55000007 0.2 0.60000008 + 0.2 0.6500001 0.2 0.70000011 0.2 0.75000012 0.2 0.80000013 0.2 0.85000014 0.2 0.90000015 + 0.2 0.95000017 0.2 1.000000119209 0.2 0 0.25 0.050000001 0.25 0.1 0.25 0.15000001 + 0.25 0.2 0.25 0.25 0.25 0.30000001 0.25 0.35000002 0.25 0.40000004 0.25 0.45000005 + 0.25 0.50000006 0.25 0.55000007 0.25 0.60000008 0.25 0.6500001 0.25 0.70000011 0.25 + 0.75000012 0.25 0.80000013 0.25 0.85000014 0.25 0.90000015 0.25 0.95000017 0.25 1.000000119209 + 0.25 0 0.30000001 0.050000001 0.30000001 0.1 0.30000001 0.15000001 0.30000001 0.2 + 0.30000001 0.25 0.30000001 0.30000001 0.30000001 0.35000002 0.30000001 0.40000004 + 0.30000001 0.45000005 0.30000001 0.50000006 0.30000001 0.55000007 0.30000001 0.60000008 + 0.30000001 0.6500001 0.30000001 0.70000011 0.30000001 0.75000012 0.30000001 0.80000013 + 0.30000001 0.85000014 0.30000001 0.90000015 0.30000001 0.95000017 0.30000001 1.000000119209 + 0.30000001 0 0.35000002 0.050000001 0.35000002 0.1 0.35000002 0.15000001 0.35000002 + 0.2 0.35000002 0.25 0.35000002 0.30000001 0.35000002 0.35000002 0.35000002 0.40000004 + 0.35000002 0.45000005 0.35000002 0.50000006 0.35000002 0.55000007 0.35000002 0.60000008 + 0.35000002 0.6500001 0.35000002 0.70000011 0.35000002 0.75000012 0.35000002 0.80000013 + 0.35000002 0.85000014 0.35000002 0.90000015 0.35000002 0.95000017 0.35000002 1.000000119209 + 0.35000002 0 0.40000004 0.050000001 0.40000004 0.1 0.40000004 0.15000001 0.40000004 + 0.2 0.40000004 0.25 0.40000004 0.30000001 0.40000004 0.35000002 0.40000004 0.40000004 + 0.40000004 0.45000005 0.40000004 0.50000006 0.40000004 0.55000007 0.40000004 0.60000008 + 0.40000004 0.6500001 0.40000004 0.70000011 0.40000004 0.75000012 0.40000004 0.80000013 + 0.40000004 0.85000014 0.40000004 0.90000015 0.40000004 0.95000017 0.40000004 1.000000119209 + 0.40000004 0 0.45000005 0.050000001 0.45000005 0.1 0.45000005 0.15000001 0.45000005 + 0.2 0.45000005 0.25 0.45000005 0.30000001 0.45000005 0.35000002 0.45000005 0.40000004 + 0.45000005 0.45000005 0.45000005 0.50000006 0.45000005 0.55000007 0.45000005 0.60000008 + 0.45000005 0.6500001 0.45000005 0.70000011 0.45000005 0.75000012 0.45000005 0.80000013 + 0.45000005 0.85000014 0.45000005 0.90000015 0.45000005 0.95000017 0.45000005 1.000000119209 + 0.45000005 0 0.50000006 0.050000001 0.50000006 0.1 0.50000006 0.15000001 0.50000006 + 0.2 0.50000006 0.25 0.50000006 0.30000001 0.50000006 0.35000002 0.50000006 0.40000004 + 0.50000006 0.45000005 0.50000006 0.50000006 0.50000006 0.55000007 0.50000006 0.60000008 + 0.50000006 0.6500001 0.50000006 0.70000011 0.50000006 0.75000012 0.50000006 0.80000013 + 0.50000006 0.85000014 0.50000006 0.90000015 0.50000006 0.95000017 0.50000006 1.000000119209 + 0.50000006 0 0.55000007 0.050000001 0.55000007 0.1 0.55000007 0.15000001 0.55000007 + 0.2 0.55000007 0.25 0.55000007 0.30000001 0.55000007 0.35000002 0.55000007 0.40000004 + 0.55000007 0.45000005 0.55000007 0.50000006 0.55000007 0.55000007 0.55000007 0.60000008 + 0.55000007 0.6500001 0.55000007 0.70000011 0.55000007 0.75000012 0.55000007 0.80000013 + 0.55000007 0.85000014 0.55000007 0.90000015 0.55000007 0.95000017 0.55000007 1.000000119209 + 0.55000007 0 0.60000008 0.050000001 0.60000008 0.1 0.60000008 0.15000001 0.60000008 + 0.2 0.60000008 0.25 0.60000008 0.30000001 0.60000008 0.35000002 0.60000008 0.40000004 + 0.60000008 0.45000005 0.60000008 0.50000006 0.60000008 0.55000007 0.60000008 0.60000008 + 0.60000008 0.6500001 0.60000008 0.70000011 0.60000008 0.75000012 0.60000008 0.80000013 + 0.60000008 0.85000014 0.60000008 0.90000015 0.60000008; + setAttr ".uvst[0].uvsp[250:438]" 0.95000017 0.60000008 1.000000119209 0.60000008 + 0 0.6500001 0.050000001 0.6500001 0.1 0.6500001 0.15000001 0.6500001 0.2 0.6500001 + 0.25 0.6500001 0.30000001 0.6500001 0.35000002 0.6500001 0.40000004 0.6500001 0.45000005 + 0.6500001 0.50000006 0.6500001 0.55000007 0.6500001 0.60000008 0.6500001 0.6500001 + 0.6500001 0.70000011 0.6500001 0.75000012 0.6500001 0.80000013 0.6500001 0.85000014 + 0.6500001 0.90000015 0.6500001 0.95000017 0.6500001 1.000000119209 0.6500001 0 0.70000011 + 0.050000001 0.70000011 0.1 0.70000011 0.15000001 0.70000011 0.2 0.70000011 0.25 0.70000011 + 0.30000001 0.70000011 0.35000002 0.70000011 0.40000004 0.70000011 0.45000005 0.70000011 + 0.50000006 0.70000011 0.55000007 0.70000011 0.60000008 0.70000011 0.6500001 0.70000011 + 0.70000011 0.70000011 0.75000012 0.70000011 0.80000013 0.70000011 0.85000014 0.70000011 + 0.90000015 0.70000011 0.95000017 0.70000011 1.000000119209 0.70000011 0 0.75000012 + 0.050000001 0.75000012 0.1 0.75000012 0.15000001 0.75000012 0.2 0.75000012 0.25 0.75000012 + 0.30000001 0.75000012 0.35000002 0.75000012 0.40000004 0.75000012 0.45000005 0.75000012 + 0.50000006 0.75000012 0.55000007 0.75000012 0.60000008 0.75000012 0.6500001 0.75000012 + 0.70000011 0.75000012 0.75000012 0.75000012 0.80000013 0.75000012 0.85000014 0.75000012 + 0.90000015 0.75000012 0.95000017 0.75000012 1.000000119209 0.75000012 0 0.80000013 + 0.050000001 0.80000013 0.1 0.80000013 0.15000001 0.80000013 0.2 0.80000013 0.25 0.80000013 + 0.30000001 0.80000013 0.35000002 0.80000013 0.40000004 0.80000013 0.45000005 0.80000013 + 0.50000006 0.80000013 0.55000007 0.80000013 0.60000008 0.80000013 0.6500001 0.80000013 + 0.70000011 0.80000013 0.75000012 0.80000013 0.80000013 0.80000013 0.85000014 0.80000013 + 0.90000015 0.80000013 0.95000017 0.80000013 1.000000119209 0.80000013 0 0.85000014 + 0.050000001 0.85000014 0.1 0.85000014 0.15000001 0.85000014 0.2 0.85000014 0.25 0.85000014 + 0.30000001 0.85000014 0.35000002 0.85000014 0.40000004 0.85000014 0.45000005 0.85000014 + 0.50000006 0.85000014 0.55000007 0.85000014 0.60000008 0.85000014 0.6500001 0.85000014 + 0.70000011 0.85000014 0.75000012 0.85000014 0.80000013 0.85000014 0.85000014 0.85000014 + 0.90000015 0.85000014 0.95000017 0.85000014 1.000000119209 0.85000014 0 0.90000015 + 0.050000001 0.90000015 0.1 0.90000015 0.15000001 0.90000015 0.2 0.90000015 0.25 0.90000015 + 0.30000001 0.90000015 0.35000002 0.90000015 0.40000004 0.90000015 0.45000005 0.90000015 + 0.50000006 0.90000015 0.55000007 0.90000015 0.60000008 0.90000015 0.6500001 0.90000015 + 0.70000011 0.90000015 0.75000012 0.90000015 0.80000013 0.90000015 0.85000014 0.90000015 + 0.90000015 0.90000015 0.95000017 0.90000015 1.000000119209 0.90000015 0 0.95000017 + 0.050000001 0.95000017 0.1 0.95000017 0.15000001 0.95000017 0.2 0.95000017 0.25 0.95000017 + 0.30000001 0.95000017 0.35000002 0.95000017 0.40000004 0.95000017 0.45000005 0.95000017 + 0.50000006 0.95000017 0.55000007 0.95000017 0.60000008 0.95000017 0.6500001 0.95000017 + 0.70000011 0.95000017 0.75000012 0.95000017 0.80000013 0.95000017 0.85000014 0.95000017 + 0.90000015 0.95000017 0.95000017 0.95000017 1.000000119209 0.95000017 0.025 0 0.075000003 + 0 0.125 0 0.17500001 0 0.22500001 0 0.27500001 0 0.32500002 0 0.375 0 0.42500001 + 0 0.47500002 0 0.52499998 0 0.57499999 0 0.625 0 0.67500001 0 0.72499996 0 0.77499998 + 0 0.82499999 0 0.875 0 0.92500001 0 0.97499996 0 0.025 1 0.075000003 1 0.125 1 0.17500001 + 1 0.22500001 1 0.27500001 1 0.32500002 1 0.375 1 0.42500001 1 0.47500002 1 0.52499998 + 1 0.57499999 1 0.625 1 0.67500001 1 0.72499996 1 0.77499998 1 0.82499999 1 0.875 + 1 0.92500001 1 0.97499996 1; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr -s 382 ".vt"; + setAttr ".vt[0:165]" 0.14877813 -0.98768836 -0.048340943 0.12655823 -0.98768836 -0.091949932 + 0.091949932 -0.98768836 -0.12655823 0.048340935 -0.98768836 -0.14877811 0 -0.98768836 -0.15643455 + -0.048340935 -0.98768836 -0.1487781 -0.091949917 -0.98768836 -0.1265582 -0.12655818 -0.98768836 -0.091949902 + -0.14877807 -0.98768836 -0.048340924 -0.15643452 -0.98768836 0 -0.14877807 -0.98768836 0.048340924 + -0.12655818 -0.98768836 0.091949895 -0.091949895 -0.98768836 0.12655817 -0.048340924 -0.98768836 0.14877805 + -4.6621107e-09 -0.98768836 0.15643449 0.048340909 -0.98768836 0.14877804 0.09194988 -0.98768836 0.12655815 + 0.12655815 -0.98768836 0.091949888 0.14877804 -0.98768836 0.048340913 0.15643448 -0.98768836 0 + 0.29389283 -0.95105654 -0.095491566 0.25000018 -0.95105654 -0.18163574 0.18163574 -0.95105654 -0.25000015 + 0.095491551 -0.95105654 -0.2938928 0 -0.95105654 -0.30901715 -0.095491551 -0.95105654 -0.29389277 + -0.18163571 -0.95105654 -0.25000009 -0.25000009 -0.95105654 -0.18163569 -0.29389271 -0.95105654 -0.095491529 + -0.30901706 -0.95105654 0 -0.29389271 -0.95105654 0.095491529 -0.25000006 -0.95105654 0.18163568 + -0.18163568 -0.95105654 0.25000006 -0.095491529 -0.95105654 0.29389268 -9.2094243e-09 -0.95105654 0.30901703 + 0.095491499 -0.95105654 0.29389265 0.18163563 -0.95105654 0.25000003 0.25 -0.95105654 0.18163565 + 0.29389265 -0.95105654 0.095491506 0.309017 -0.95105654 0 0.43177092 -0.89100653 -0.14029087 + 0.36728629 -0.89100653 -0.2668491 0.2668491 -0.89100653 -0.36728626 0.14029086 -0.89100653 -0.43177086 + 0 -0.89100653 -0.45399073 -0.14029086 -0.89100653 -0.43177083 -0.26684904 -0.89100653 -0.36728618 + -0.36728615 -0.89100653 -0.26684901 -0.43177077 -0.89100653 -0.14029081 -0.45399064 -0.89100653 0 + -0.43177077 -0.89100653 0.14029081 -0.36728612 -0.89100653 0.26684898 -0.26684898 -0.89100653 0.36728612 + -0.14029081 -0.89100653 0.43177071 -1.3529972e-08 -0.89100653 0.45399058 0.14029078 -0.89100653 0.43177068 + 0.26684892 -0.89100653 0.36728609 0.36728606 -0.89100653 0.26684895 0.43177065 -0.89100653 0.1402908 + 0.45399052 -0.89100653 0 0.55901736 -0.809017 -0.18163574 0.47552857 -0.809017 -0.34549171 + 0.34549171 -0.809017 -0.47552854 0.18163572 -0.809017 -0.5590173 0 -0.809017 -0.58778554 + -0.18163572 -0.809017 -0.55901724 -0.34549165 -0.809017 -0.47552842 -0.47552839 -0.809017 -0.34549159 + -0.55901712 -0.809017 -0.18163566 -0.58778536 -0.809017 0 -0.55901712 -0.809017 0.18163566 + -0.47552836 -0.809017 0.34549156 -0.34549156 -0.809017 0.47552833 -0.18163566 -0.809017 0.55901706 + -1.7517365e-08 -0.809017 0.5877853 0.18163562 -0.809017 0.55901706 0.3454915 -0.809017 0.4755283 + 0.47552827 -0.809017 0.34549153 0.559017 -0.809017 0.18163563 0.58778524 -0.809017 0 + 0.67249894 -0.70710677 -0.21850814 0.57206178 -0.70710677 -0.41562718 0.41562718 -0.70710677 -0.57206172 + 0.21850812 -0.70710677 -0.67249888 0 -0.70710677 -0.70710713 -0.21850812 -0.70710677 -0.67249882 + -0.41562709 -0.70710677 -0.5720616 -0.57206154 -0.70710677 -0.41562706 -0.6724987 -0.70710677 -0.21850805 + -0.70710695 -0.70710677 0 -0.6724987 -0.70710677 0.21850805 -0.57206154 -0.70710677 0.415627 + -0.415627 -0.70710677 0.57206148 -0.21850805 -0.70710677 0.67249858 -2.1073424e-08 -0.70710677 0.70710683 + 0.21850799 -0.70710677 0.67249858 0.41562691 -0.70710677 0.57206142 0.57206142 -0.70710677 0.41562697 + 0.67249852 -0.70710677 0.21850802 0.70710677 -0.70710677 0 0.7694214 -0.58778524 -0.25000015 + 0.65450895 -0.58778524 -0.47552854 0.47552854 -0.58778524 -0.65450889 0.25000012 -0.58778524 -0.76942128 + 0 -0.58778524 -0.80901736 -0.25000012 -0.58778524 -0.76942122 -0.47552845 -0.58778524 -0.65450877 + -0.65450871 -0.58778524 -0.47552839 -0.7694211 -0.58778524 -0.25000006 -0.80901718 -0.58778524 0 + -0.7694211 -0.58778524 0.25000006 -0.65450865 -0.58778524 0.47552836 -0.47552836 -0.58778524 0.65450859 + -0.25000006 -0.58778524 0.76942098 -2.4110586e-08 -0.58778524 0.80901712 0.24999999 -0.58778524 0.76942098 + 0.47552827 -0.58778524 0.65450853 0.65450853 -0.58778524 0.4755283 0.76942092 -0.58778524 0.25 + 0.809017 -0.58778524 0 0.8473981 -0.45399052 -0.27533633 0.72083992 -0.45399052 -0.5237208 + 0.5237208 -0.45399052 -0.72083986 0.2753363 -0.45399052 -0.84739798 0 -0.45399052 -0.89100695 + -0.2753363 -0.45399052 -0.84739798 -0.52372068 -0.45399052 -0.72083968 -0.72083962 -0.45399052 -0.52372062 + -0.8473978 -0.45399052 -0.27533621 -0.89100677 -0.45399052 0 -0.8473978 -0.45399052 0.27533621 + -0.72083962 -0.45399052 0.52372062 -0.52372062 -0.45399052 0.72083956 -0.27533621 -0.45399052 0.84739769 + -2.6554064e-08 -0.45399052 0.89100665 0.27533615 -0.45399052 0.84739763 0.5237205 -0.45399052 0.7208395 + 0.72083944 -0.45399052 0.52372056 0.84739757 -0.45399052 0.27533618 0.89100653 -0.45399052 0 + 0.90450913 -0.30901697 -0.2938928 0.7694214 -0.30901697 -0.55901736 0.55901736 -0.30901697 -0.76942134 + 0.29389277 -0.30901697 -0.90450901 0 -0.30901697 -0.95105702 -0.29389277 -0.30901697 -0.90450895 + -0.55901724 -0.30901697 -0.76942122 -0.76942116 -0.30901697 -0.55901718 -0.90450877 -0.30901697 -0.29389271 + -0.95105678 -0.30901697 0 -0.90450877 -0.30901697 0.29389271 -0.7694211 -0.30901697 0.55901712 + -0.55901712 -0.30901697 0.76942104 -0.29389271 -0.30901697 0.90450865 -2.8343694e-08 -0.30901697 0.95105666 + 0.29389262 -0.30901697 0.90450859 0.559017 -0.30901697 0.76942098 0.76942092 -0.30901697 0.55901706 + 0.90450853 -0.30901697 0.29389265 0.95105654 -0.30901697 0 0.93934804 -0.15643437 -0.30521268 + 0.79905719 -0.15643437 -0.580549 0.580549 -0.15643437 -0.79905713 0.30521265 -0.15643437 -0.93934792 + 0 -0.15643437 -0.98768884 -0.30521265 -0.15643437 -0.93934786; + setAttr ".vt[166:331]" -0.58054888 -0.15643437 -0.79905695 -0.79905689 -0.15643437 -0.58054882 + -0.93934768 -0.15643437 -0.30521256 -0.9876886 -0.15643437 0 -0.93934768 -0.15643437 0.30521256 + -0.79905683 -0.15643437 0.58054876 -0.58054876 -0.15643437 0.79905677 -0.30521256 -0.15643437 0.93934757 + -2.9435407e-08 -0.15643437 0.98768848 0.30521247 -0.15643437 0.93934757 0.58054864 -0.15643437 0.79905671 + 0.79905665 -0.15643437 0.5805487 0.93934751 -0.15643437 0.3052125 0.98768836 -0.15643437 0 + 0.95105714 0 -0.30901718 0.80901754 0 -0.5877856 0.5877856 0 -0.80901748 0.30901715 0 -0.95105702 + 0 0 -1.000000476837 -0.30901715 0 -0.95105696 -0.58778548 0 -0.8090173 -0.80901724 0 -0.58778542 + -0.95105678 0 -0.30901706 -1.000000238419 0 0 -0.95105678 0 0.30901706 -0.80901718 0 0.58778536 + -0.58778536 0 0.80901712 -0.30901706 0 0.95105666 -2.9802322e-08 0 1.000000119209 + 0.30901697 0 0.9510566 0.58778524 0 0.80901706 0.809017 0 0.5877853 0.95105654 0 0.309017 + 1 0 0 0.93934804 0.15643437 -0.30521268 0.79905719 0.15643437 -0.580549 0.580549 0.15643437 -0.79905713 + 0.30521265 0.15643437 -0.93934792 0 0.15643437 -0.98768884 -0.30521265 0.15643437 -0.93934786 + -0.58054888 0.15643437 -0.79905695 -0.79905689 0.15643437 -0.58054882 -0.93934768 0.15643437 -0.30521256 + -0.9876886 0.15643437 0 -0.93934768 0.15643437 0.30521256 -0.79905683 0.15643437 0.58054876 + -0.58054876 0.15643437 0.79905677 -0.30521256 0.15643437 0.93934757 -2.9435407e-08 0.15643437 0.98768848 + 0.30521247 0.15643437 0.93934757 0.58054864 0.15643437 0.79905671 0.79905665 0.15643437 0.5805487 + 0.93934751 0.15643437 0.3052125 0.98768836 0.15643437 0 0.90450913 0.30901697 -0.2938928 + 0.7694214 0.30901697 -0.55901736 0.55901736 0.30901697 -0.76942134 0.29389277 0.30901697 -0.90450901 + 0 0.30901697 -0.95105702 -0.29389277 0.30901697 -0.90450895 -0.55901724 0.30901697 -0.76942122 + -0.76942116 0.30901697 -0.55901718 -0.90450877 0.30901697 -0.29389271 -0.95105678 0.30901697 0 + -0.90450877 0.30901697 0.29389271 -0.7694211 0.30901697 0.55901712 -0.55901712 0.30901697 0.76942104 + -0.29389271 0.30901697 0.90450865 -2.8343694e-08 0.30901697 0.95105666 0.29389262 0.30901697 0.90450859 + 0.559017 0.30901697 0.76942098 0.76942092 0.30901697 0.55901706 0.90450853 0.30901697 0.29389265 + 0.95105654 0.30901697 0 0.8473981 0.45399052 -0.27533633 0.72083992 0.45399052 -0.5237208 + 0.5237208 0.45399052 -0.72083986 0.2753363 0.45399052 -0.84739798 0 0.45399052 -0.89100695 + -0.2753363 0.45399052 -0.84739798 -0.52372068 0.45399052 -0.72083968 -0.72083962 0.45399052 -0.52372062 + -0.8473978 0.45399052 -0.27533621 -0.89100677 0.45399052 0 -0.8473978 0.45399052 0.27533621 + -0.72083962 0.45399052 0.52372062 -0.52372062 0.45399052 0.72083956 -0.27533621 0.45399052 0.84739769 + -2.6554064e-08 0.45399052 0.89100665 0.27533615 0.45399052 0.84739763 0.5237205 0.45399052 0.7208395 + 0.72083944 0.45399052 0.52372056 0.84739757 0.45399052 0.27533618 0.89100653 0.45399052 0 + 0.7694214 0.58778524 -0.25000015 0.65450895 0.58778524 -0.47552854 0.47552854 0.58778524 -0.65450889 + 0.25000012 0.58778524 -0.76942128 0 0.58778524 -0.80901736 -0.25000012 0.58778524 -0.76942122 + -0.47552845 0.58778524 -0.65450877 -0.65450871 0.58778524 -0.47552839 -0.7694211 0.58778524 -0.25000006 + -0.80901718 0.58778524 0 -0.7694211 0.58778524 0.25000006 -0.65450865 0.58778524 0.47552836 + -0.47552836 0.58778524 0.65450859 -0.25000006 0.58778524 0.76942098 -2.4110586e-08 0.58778524 0.80901712 + 0.24999999 0.58778524 0.76942098 0.47552827 0.58778524 0.65450853 0.65450853 0.58778524 0.4755283 + 0.76942092 0.58778524 0.25 0.809017 0.58778524 0 0.67249894 0.70710677 -0.21850814 + 0.57206178 0.70710677 -0.41562718 0.41562718 0.70710677 -0.57206172 0.21850812 0.70710677 -0.67249888 + 0 0.70710677 -0.70710713 -0.21850812 0.70710677 -0.67249882 -0.41562709 0.70710677 -0.5720616 + -0.57206154 0.70710677 -0.41562706 -0.6724987 0.70710677 -0.21850805 -0.70710695 0.70710677 0 + -0.6724987 0.70710677 0.21850805 -0.57206154 0.70710677 0.415627 -0.415627 0.70710677 0.57206148 + -0.21850805 0.70710677 0.67249858 -2.1073424e-08 0.70710677 0.70710683 0.21850799 0.70710677 0.67249858 + 0.41562691 0.70710677 0.57206142 0.57206142 0.70710677 0.41562697 0.67249852 0.70710677 0.21850802 + 0.70710677 0.70710677 0 0.55901736 0.809017 -0.18163574 0.47552857 0.809017 -0.34549171 + 0.34549171 0.809017 -0.47552854 0.18163572 0.809017 -0.5590173 0 0.809017 -0.58778554 + -0.18163572 0.809017 -0.55901724 -0.34549165 0.809017 -0.47552842 -0.47552839 0.809017 -0.34549159 + -0.55901712 0.809017 -0.18163566 -0.58778536 0.809017 0 -0.55901712 0.809017 0.18163566 + -0.47552836 0.809017 0.34549156 -0.34549156 0.809017 0.47552833 -0.18163566 0.809017 0.55901706 + -1.7517365e-08 0.809017 0.5877853 0.18163562 0.809017 0.55901706 0.3454915 0.809017 0.4755283 + 0.47552827 0.809017 0.34549153 0.559017 0.809017 0.18163563 0.58778524 0.809017 0 + 0.43177092 0.89100653 -0.14029087 0.36728629 0.89100653 -0.2668491 0.2668491 0.89100653 -0.36728626 + 0.14029086 0.89100653 -0.43177086 0 0.89100653 -0.45399073 -0.14029086 0.89100653 -0.43177083 + -0.26684904 0.89100653 -0.36728618 -0.36728615 0.89100653 -0.26684901 -0.43177077 0.89100653 -0.14029081 + -0.45399064 0.89100653 0 -0.43177077 0.89100653 0.14029081 -0.36728612 0.89100653 0.26684898; + setAttr ".vt[332:381]" -0.26684898 0.89100653 0.36728612 -0.14029081 0.89100653 0.43177071 + -1.3529972e-08 0.89100653 0.45399058 0.14029078 0.89100653 0.43177068 0.26684892 0.89100653 0.36728609 + 0.36728606 0.89100653 0.26684895 0.43177065 0.89100653 0.1402908 0.45399052 0.89100653 0 + 0.29389283 0.95105654 -0.095491566 0.25000018 0.95105654 -0.18163574 0.18163574 0.95105654 -0.25000015 + 0.095491551 0.95105654 -0.2938928 0 0.95105654 -0.30901715 -0.095491551 0.95105654 -0.29389277 + -0.18163571 0.95105654 -0.25000009 -0.25000009 0.95105654 -0.18163569 -0.29389271 0.95105654 -0.095491529 + -0.30901706 0.95105654 0 -0.29389271 0.95105654 0.095491529 -0.25000006 0.95105654 0.18163568 + -0.18163568 0.95105654 0.25000006 -0.095491529 0.95105654 0.29389268 -9.2094243e-09 0.95105654 0.30901703 + 0.095491499 0.95105654 0.29389265 0.18163563 0.95105654 0.25000003 0.25 0.95105654 0.18163565 + 0.29389265 0.95105654 0.095491506 0.309017 0.95105654 0 0.14877813 0.98768836 -0.048340943 + 0.12655823 0.98768836 -0.091949932 0.091949932 0.98768836 -0.12655823 0.048340935 0.98768836 -0.14877811 + 0 0.98768836 -0.15643455 -0.048340935 0.98768836 -0.1487781 -0.091949917 0.98768836 -0.1265582 + -0.12655818 0.98768836 -0.091949902 -0.14877807 0.98768836 -0.048340924 -0.15643452 0.98768836 0 + -0.14877807 0.98768836 0.048340924 -0.12655818 0.98768836 0.091949895 -0.091949895 0.98768836 0.12655817 + -0.048340924 0.98768836 0.14877805 -4.6621107e-09 0.98768836 0.15643449 0.048340909 0.98768836 0.14877804 + 0.09194988 0.98768836 0.12655815 0.12655815 0.98768836 0.091949888 0.14877804 0.98768836 0.048340913 + 0.15643448 0.98768836 0 0 -1 0 0 1 0; + setAttr -s 780 ".ed"; + setAttr ".ed[0:165]" 0 1 1 1 2 1 2 3 1 3 4 1 4 5 1 5 6 1 6 7 1 7 8 1 8 9 1 + 9 10 1 10 11 1 11 12 1 12 13 1 13 14 1 14 15 1 15 16 1 16 17 1 17 18 1 18 19 1 19 0 1 + 20 21 1 21 22 1 22 23 1 23 24 1 24 25 1 25 26 1 26 27 1 27 28 1 28 29 1 29 30 1 30 31 1 + 31 32 1 32 33 1 33 34 1 34 35 1 35 36 1 36 37 1 37 38 1 38 39 1 39 20 1 40 41 1 41 42 1 + 42 43 1 43 44 1 44 45 1 45 46 1 46 47 1 47 48 1 48 49 1 49 50 1 50 51 1 51 52 1 52 53 1 + 53 54 1 54 55 1 55 56 1 56 57 1 57 58 1 58 59 1 59 40 1 60 61 1 61 62 1 62 63 1 63 64 1 + 64 65 1 65 66 1 66 67 1 67 68 1 68 69 1 69 70 1 70 71 1 71 72 1 72 73 1 73 74 1 74 75 1 + 75 76 1 76 77 1 77 78 1 78 79 1 79 60 1 80 81 1 81 82 1 82 83 1 83 84 1 84 85 1 85 86 1 + 86 87 1 87 88 1 88 89 1 89 90 1 90 91 1 91 92 1 92 93 1 93 94 1 94 95 1 95 96 1 96 97 1 + 97 98 1 98 99 1 99 80 1 100 101 1 101 102 1 102 103 1 103 104 1 104 105 1 105 106 1 + 106 107 1 107 108 1 108 109 1 109 110 1 110 111 1 111 112 1 112 113 1 113 114 1 114 115 1 + 115 116 1 116 117 1 117 118 1 118 119 1 119 100 1 120 121 1 121 122 1 122 123 1 123 124 1 + 124 125 1 125 126 1 126 127 1 127 128 1 128 129 1 129 130 1 130 131 1 131 132 1 132 133 1 + 133 134 1 134 135 1 135 136 1 136 137 1 137 138 1 138 139 1 139 120 1 140 141 1 141 142 1 + 142 143 1 143 144 1 144 145 1 145 146 1 146 147 1 147 148 1 148 149 1 149 150 1 150 151 1 + 151 152 1 152 153 1 153 154 1 154 155 1 155 156 1 156 157 1 157 158 1 158 159 1 159 140 1 + 160 161 1 161 162 1 162 163 1 163 164 1 164 165 1 165 166 1; + setAttr ".ed[166:331]" 166 167 1 167 168 1 168 169 1 169 170 1 170 171 1 171 172 1 + 172 173 1 173 174 1 174 175 1 175 176 1 176 177 1 177 178 1 178 179 1 179 160 1 180 181 1 + 181 182 1 182 183 1 183 184 1 184 185 1 185 186 1 186 187 1 187 188 1 188 189 1 189 190 1 + 190 191 1 191 192 1 192 193 1 193 194 1 194 195 1 195 196 1 196 197 1 197 198 1 198 199 1 + 199 180 1 200 201 1 201 202 1 202 203 1 203 204 1 204 205 1 205 206 1 206 207 1 207 208 1 + 208 209 1 209 210 1 210 211 1 211 212 1 212 213 1 213 214 1 214 215 1 215 216 1 216 217 1 + 217 218 1 218 219 1 219 200 1 220 221 1 221 222 1 222 223 1 223 224 1 224 225 1 225 226 1 + 226 227 1 227 228 1 228 229 1 229 230 1 230 231 1 231 232 1 232 233 1 233 234 1 234 235 1 + 235 236 1 236 237 1 237 238 1 238 239 1 239 220 1 240 241 1 241 242 1 242 243 1 243 244 1 + 244 245 1 245 246 1 246 247 1 247 248 1 248 249 1 249 250 1 250 251 1 251 252 1 252 253 1 + 253 254 1 254 255 1 255 256 1 256 257 1 257 258 1 258 259 1 259 240 1 260 261 1 261 262 1 + 262 263 1 263 264 1 264 265 1 265 266 1 266 267 1 267 268 1 268 269 1 269 270 1 270 271 1 + 271 272 1 272 273 1 273 274 1 274 275 1 275 276 1 276 277 1 277 278 1 278 279 1 279 260 1 + 280 281 1 281 282 1 282 283 1 283 284 1 284 285 1 285 286 1 286 287 1 287 288 1 288 289 1 + 289 290 1 290 291 1 291 292 1 292 293 1 293 294 1 294 295 1 295 296 1 296 297 1 297 298 1 + 298 299 1 299 280 1 300 301 1 301 302 1 302 303 1 303 304 1 304 305 1 305 306 1 306 307 1 + 307 308 1 308 309 1 309 310 1 310 311 1 311 312 1 312 313 1 313 314 1 314 315 1 315 316 1 + 316 317 1 317 318 1 318 319 1 319 300 1 320 321 1 321 322 1 322 323 1 323 324 1 324 325 1 + 325 326 1 326 327 1 327 328 1 328 329 1 329 330 1 330 331 1 331 332 1; + setAttr ".ed[332:497]" 332 333 1 333 334 1 334 335 1 335 336 1 336 337 1 337 338 1 + 338 339 1 339 320 1 340 341 1 341 342 1 342 343 1 343 344 1 344 345 1 345 346 1 346 347 1 + 347 348 1 348 349 1 349 350 1 350 351 1 351 352 1 352 353 1 353 354 1 354 355 1 355 356 1 + 356 357 1 357 358 1 358 359 1 359 340 1 360 361 1 361 362 1 362 363 1 363 364 1 364 365 1 + 365 366 1 366 367 1 367 368 1 368 369 1 369 370 1 370 371 1 371 372 1 372 373 1 373 374 1 + 374 375 1 375 376 1 376 377 1 377 378 1 378 379 1 379 360 1 0 20 1 1 21 1 2 22 1 + 3 23 1 4 24 1 5 25 1 6 26 1 7 27 1 8 28 1 9 29 1 10 30 1 11 31 1 12 32 1 13 33 1 + 14 34 1 15 35 1 16 36 1 17 37 1 18 38 1 19 39 1 20 40 1 21 41 1 22 42 1 23 43 1 24 44 1 + 25 45 1 26 46 1 27 47 1 28 48 1 29 49 1 30 50 1 31 51 1 32 52 1 33 53 1 34 54 1 35 55 1 + 36 56 1 37 57 1 38 58 1 39 59 1 40 60 1 41 61 1 42 62 1 43 63 1 44 64 1 45 65 1 46 66 1 + 47 67 1 48 68 1 49 69 1 50 70 1 51 71 1 52 72 1 53 73 1 54 74 1 55 75 1 56 76 1 57 77 1 + 58 78 1 59 79 1 60 80 1 61 81 1 62 82 1 63 83 1 64 84 1 65 85 1 66 86 1 67 87 1 68 88 1 + 69 89 1 70 90 1 71 91 1 72 92 1 73 93 1 74 94 1 75 95 1 76 96 1 77 97 1 78 98 1 79 99 1 + 80 100 1 81 101 1 82 102 1 83 103 1 84 104 1 85 105 1 86 106 1 87 107 1 88 108 1 + 89 109 1 90 110 1 91 111 1 92 112 1 93 113 1 94 114 1 95 115 1 96 116 1 97 117 1 + 98 118 1 99 119 1 100 120 1 101 121 1 102 122 1 103 123 1 104 124 1 105 125 1 106 126 1 + 107 127 1 108 128 1 109 129 1 110 130 1 111 131 1 112 132 1 113 133 1 114 134 1 115 135 1 + 116 136 1 117 137 1; + setAttr ".ed[498:663]" 118 138 1 119 139 1 120 140 1 121 141 1 122 142 1 123 143 1 + 124 144 1 125 145 1 126 146 1 127 147 1 128 148 1 129 149 1 130 150 1 131 151 1 132 152 1 + 133 153 1 134 154 1 135 155 1 136 156 1 137 157 1 138 158 1 139 159 1 140 160 1 141 161 1 + 142 162 1 143 163 1 144 164 1 145 165 1 146 166 1 147 167 1 148 168 1 149 169 1 150 170 1 + 151 171 1 152 172 1 153 173 1 154 174 1 155 175 1 156 176 1 157 177 1 158 178 1 159 179 1 + 160 180 1 161 181 1 162 182 1 163 183 1 164 184 1 165 185 1 166 186 1 167 187 1 168 188 1 + 169 189 1 170 190 1 171 191 1 172 192 1 173 193 1 174 194 1 175 195 1 176 196 1 177 197 1 + 178 198 1 179 199 1 180 200 1 181 201 1 182 202 1 183 203 1 184 204 1 185 205 1 186 206 1 + 187 207 1 188 208 1 189 209 1 190 210 1 191 211 1 192 212 1 193 213 1 194 214 1 195 215 1 + 196 216 1 197 217 1 198 218 1 199 219 1 200 220 1 201 221 1 202 222 1 203 223 1 204 224 1 + 205 225 1 206 226 1 207 227 1 208 228 1 209 229 1 210 230 1 211 231 1 212 232 1 213 233 1 + 214 234 1 215 235 1 216 236 1 217 237 1 218 238 1 219 239 1 220 240 1 221 241 1 222 242 1 + 223 243 1 224 244 1 225 245 1 226 246 1 227 247 1 228 248 1 229 249 1 230 250 1 231 251 1 + 232 252 1 233 253 1 234 254 1 235 255 1 236 256 1 237 257 1 238 258 1 239 259 1 240 260 1 + 241 261 1 242 262 1 243 263 1 244 264 1 245 265 1 246 266 1 247 267 1 248 268 1 249 269 1 + 250 270 1 251 271 1 252 272 1 253 273 1 254 274 1 255 275 1 256 276 1 257 277 1 258 278 1 + 259 279 1 260 280 1 261 281 1 262 282 1 263 283 1 264 284 1 265 285 1 266 286 1 267 287 1 + 268 288 1 269 289 1 270 290 1 271 291 1 272 292 1 273 293 1 274 294 1 275 295 1 276 296 1 + 277 297 1 278 298 1 279 299 1 280 300 1 281 301 1 282 302 1 283 303 1; + setAttr ".ed[664:779]" 284 304 1 285 305 1 286 306 1 287 307 1 288 308 1 289 309 1 + 290 310 1 291 311 1 292 312 1 293 313 1 294 314 1 295 315 1 296 316 1 297 317 1 298 318 1 + 299 319 1 300 320 1 301 321 1 302 322 1 303 323 1 304 324 1 305 325 1 306 326 1 307 327 1 + 308 328 1 309 329 1 310 330 1 311 331 1 312 332 1 313 333 1 314 334 1 315 335 1 316 336 1 + 317 337 1 318 338 1 319 339 1 320 340 1 321 341 1 322 342 1 323 343 1 324 344 1 325 345 1 + 326 346 1 327 347 1 328 348 1 329 349 1 330 350 1 331 351 1 332 352 1 333 353 1 334 354 1 + 335 355 1 336 356 1 337 357 1 338 358 1 339 359 1 340 360 1 341 361 1 342 362 1 343 363 1 + 344 364 1 345 365 1 346 366 1 347 367 1 348 368 1 349 369 1 350 370 1 351 371 1 352 372 1 + 353 373 1 354 374 1 355 375 1 356 376 1 357 377 1 358 378 1 359 379 1 380 0 1 380 1 1 + 380 2 1 380 3 1 380 4 1 380 5 1 380 6 1 380 7 1 380 8 1 380 9 1 380 10 1 380 11 1 + 380 12 1 380 13 1 380 14 1 380 15 1 380 16 1 380 17 1 380 18 1 380 19 1 360 381 1 + 361 381 1 362 381 1 363 381 1 364 381 1 365 381 1 366 381 1 367 381 1 368 381 1 369 381 1 + 370 381 1 371 381 1 372 381 1 373 381 1 374 381 1 375 381 1 376 381 1 377 381 1 378 381 1 + 379 381 1; + setAttr -s 400 -ch 1560 ".fc[0:399]" -type "polyFaces" + f 4 0 381 -21 -381 + mu 0 4 0 1 22 21 + f 4 1 382 -22 -382 + mu 0 4 1 2 23 22 + f 4 2 383 -23 -383 + mu 0 4 2 3 24 23 + f 4 3 384 -24 -384 + mu 0 4 3 4 25 24 + f 4 4 385 -25 -385 + mu 0 4 4 5 26 25 + f 4 5 386 -26 -386 + mu 0 4 5 6 27 26 + f 4 6 387 -27 -387 + mu 0 4 6 7 28 27 + f 4 7 388 -28 -388 + mu 0 4 7 8 29 28 + f 4 8 389 -29 -389 + mu 0 4 8 9 30 29 + f 4 9 390 -30 -390 + mu 0 4 9 10 31 30 + f 4 10 391 -31 -391 + mu 0 4 10 11 32 31 + f 4 11 392 -32 -392 + mu 0 4 11 12 33 32 + f 4 12 393 -33 -393 + mu 0 4 12 13 34 33 + f 4 13 394 -34 -394 + mu 0 4 13 14 35 34 + f 4 14 395 -35 -395 + mu 0 4 14 15 36 35 + f 4 15 396 -36 -396 + mu 0 4 15 16 37 36 + f 4 16 397 -37 -397 + mu 0 4 16 17 38 37 + f 4 17 398 -38 -398 + mu 0 4 17 18 39 38 + f 4 18 399 -39 -399 + mu 0 4 18 19 40 39 + f 4 19 380 -40 -400 + mu 0 4 19 20 41 40 + f 4 20 401 -41 -401 + mu 0 4 21 22 43 42 + f 4 21 402 -42 -402 + mu 0 4 22 23 44 43 + f 4 22 403 -43 -403 + mu 0 4 23 24 45 44 + f 4 23 404 -44 -404 + mu 0 4 24 25 46 45 + f 4 24 405 -45 -405 + mu 0 4 25 26 47 46 + f 4 25 406 -46 -406 + mu 0 4 26 27 48 47 + f 4 26 407 -47 -407 + mu 0 4 27 28 49 48 + f 4 27 408 -48 -408 + mu 0 4 28 29 50 49 + f 4 28 409 -49 -409 + mu 0 4 29 30 51 50 + f 4 29 410 -50 -410 + mu 0 4 30 31 52 51 + f 4 30 411 -51 -411 + mu 0 4 31 32 53 52 + f 4 31 412 -52 -412 + mu 0 4 32 33 54 53 + f 4 32 413 -53 -413 + mu 0 4 33 34 55 54 + f 4 33 414 -54 -414 + mu 0 4 34 35 56 55 + f 4 34 415 -55 -415 + mu 0 4 35 36 57 56 + f 4 35 416 -56 -416 + mu 0 4 36 37 58 57 + f 4 36 417 -57 -417 + mu 0 4 37 38 59 58 + f 4 37 418 -58 -418 + mu 0 4 38 39 60 59 + f 4 38 419 -59 -419 + mu 0 4 39 40 61 60 + f 4 39 400 -60 -420 + mu 0 4 40 41 62 61 + f 4 40 421 -61 -421 + mu 0 4 42 43 64 63 + f 4 41 422 -62 -422 + mu 0 4 43 44 65 64 + f 4 42 423 -63 -423 + mu 0 4 44 45 66 65 + f 4 43 424 -64 -424 + mu 0 4 45 46 67 66 + f 4 44 425 -65 -425 + mu 0 4 46 47 68 67 + f 4 45 426 -66 -426 + mu 0 4 47 48 69 68 + f 4 46 427 -67 -427 + mu 0 4 48 49 70 69 + f 4 47 428 -68 -428 + mu 0 4 49 50 71 70 + f 4 48 429 -69 -429 + mu 0 4 50 51 72 71 + f 4 49 430 -70 -430 + mu 0 4 51 52 73 72 + f 4 50 431 -71 -431 + mu 0 4 52 53 74 73 + f 4 51 432 -72 -432 + mu 0 4 53 54 75 74 + f 4 52 433 -73 -433 + mu 0 4 54 55 76 75 + f 4 53 434 -74 -434 + mu 0 4 55 56 77 76 + f 4 54 435 -75 -435 + mu 0 4 56 57 78 77 + f 4 55 436 -76 -436 + mu 0 4 57 58 79 78 + f 4 56 437 -77 -437 + mu 0 4 58 59 80 79 + f 4 57 438 -78 -438 + mu 0 4 59 60 81 80 + f 4 58 439 -79 -439 + mu 0 4 60 61 82 81 + f 4 59 420 -80 -440 + mu 0 4 61 62 83 82 + f 4 60 441 -81 -441 + mu 0 4 63 64 85 84 + f 4 61 442 -82 -442 + mu 0 4 64 65 86 85 + f 4 62 443 -83 -443 + mu 0 4 65 66 87 86 + f 4 63 444 -84 -444 + mu 0 4 66 67 88 87 + f 4 64 445 -85 -445 + mu 0 4 67 68 89 88 + f 4 65 446 -86 -446 + mu 0 4 68 69 90 89 + f 4 66 447 -87 -447 + mu 0 4 69 70 91 90 + f 4 67 448 -88 -448 + mu 0 4 70 71 92 91 + f 4 68 449 -89 -449 + mu 0 4 71 72 93 92 + f 4 69 450 -90 -450 + mu 0 4 72 73 94 93 + f 4 70 451 -91 -451 + mu 0 4 73 74 95 94 + f 4 71 452 -92 -452 + mu 0 4 74 75 96 95 + f 4 72 453 -93 -453 + mu 0 4 75 76 97 96 + f 4 73 454 -94 -454 + mu 0 4 76 77 98 97 + f 4 74 455 -95 -455 + mu 0 4 77 78 99 98 + f 4 75 456 -96 -456 + mu 0 4 78 79 100 99 + f 4 76 457 -97 -457 + mu 0 4 79 80 101 100 + f 4 77 458 -98 -458 + mu 0 4 80 81 102 101 + f 4 78 459 -99 -459 + mu 0 4 81 82 103 102 + f 4 79 440 -100 -460 + mu 0 4 82 83 104 103 + f 4 80 461 -101 -461 + mu 0 4 84 85 106 105 + f 4 81 462 -102 -462 + mu 0 4 85 86 107 106 + f 4 82 463 -103 -463 + mu 0 4 86 87 108 107 + f 4 83 464 -104 -464 + mu 0 4 87 88 109 108 + f 4 84 465 -105 -465 + mu 0 4 88 89 110 109 + f 4 85 466 -106 -466 + mu 0 4 89 90 111 110 + f 4 86 467 -107 -467 + mu 0 4 90 91 112 111 + f 4 87 468 -108 -468 + mu 0 4 91 92 113 112 + f 4 88 469 -109 -469 + mu 0 4 92 93 114 113 + f 4 89 470 -110 -470 + mu 0 4 93 94 115 114 + f 4 90 471 -111 -471 + mu 0 4 94 95 116 115 + f 4 91 472 -112 -472 + mu 0 4 95 96 117 116 + f 4 92 473 -113 -473 + mu 0 4 96 97 118 117 + f 4 93 474 -114 -474 + mu 0 4 97 98 119 118 + f 4 94 475 -115 -475 + mu 0 4 98 99 120 119 + f 4 95 476 -116 -476 + mu 0 4 99 100 121 120 + f 4 96 477 -117 -477 + mu 0 4 100 101 122 121 + f 4 97 478 -118 -478 + mu 0 4 101 102 123 122 + f 4 98 479 -119 -479 + mu 0 4 102 103 124 123 + f 4 99 460 -120 -480 + mu 0 4 103 104 125 124 + f 4 100 481 -121 -481 + mu 0 4 105 106 127 126 + f 4 101 482 -122 -482 + mu 0 4 106 107 128 127 + f 4 102 483 -123 -483 + mu 0 4 107 108 129 128 + f 4 103 484 -124 -484 + mu 0 4 108 109 130 129 + f 4 104 485 -125 -485 + mu 0 4 109 110 131 130 + f 4 105 486 -126 -486 + mu 0 4 110 111 132 131 + f 4 106 487 -127 -487 + mu 0 4 111 112 133 132 + f 4 107 488 -128 -488 + mu 0 4 112 113 134 133 + f 4 108 489 -129 -489 + mu 0 4 113 114 135 134 + f 4 109 490 -130 -490 + mu 0 4 114 115 136 135 + f 4 110 491 -131 -491 + mu 0 4 115 116 137 136 + f 4 111 492 -132 -492 + mu 0 4 116 117 138 137 + f 4 112 493 -133 -493 + mu 0 4 117 118 139 138 + f 4 113 494 -134 -494 + mu 0 4 118 119 140 139 + f 4 114 495 -135 -495 + mu 0 4 119 120 141 140 + f 4 115 496 -136 -496 + mu 0 4 120 121 142 141 + f 4 116 497 -137 -497 + mu 0 4 121 122 143 142 + f 4 117 498 -138 -498 + mu 0 4 122 123 144 143 + f 4 118 499 -139 -499 + mu 0 4 123 124 145 144 + f 4 119 480 -140 -500 + mu 0 4 124 125 146 145 + f 4 120 501 -141 -501 + mu 0 4 126 127 148 147 + f 4 121 502 -142 -502 + mu 0 4 127 128 149 148 + f 4 122 503 -143 -503 + mu 0 4 128 129 150 149 + f 4 123 504 -144 -504 + mu 0 4 129 130 151 150 + f 4 124 505 -145 -505 + mu 0 4 130 131 152 151 + f 4 125 506 -146 -506 + mu 0 4 131 132 153 152 + f 4 126 507 -147 -507 + mu 0 4 132 133 154 153 + f 4 127 508 -148 -508 + mu 0 4 133 134 155 154 + f 4 128 509 -149 -509 + mu 0 4 134 135 156 155 + f 4 129 510 -150 -510 + mu 0 4 135 136 157 156 + f 4 130 511 -151 -511 + mu 0 4 136 137 158 157 + f 4 131 512 -152 -512 + mu 0 4 137 138 159 158 + f 4 132 513 -153 -513 + mu 0 4 138 139 160 159 + f 4 133 514 -154 -514 + mu 0 4 139 140 161 160 + f 4 134 515 -155 -515 + mu 0 4 140 141 162 161 + f 4 135 516 -156 -516 + mu 0 4 141 142 163 162 + f 4 136 517 -157 -517 + mu 0 4 142 143 164 163 + f 4 137 518 -158 -518 + mu 0 4 143 144 165 164 + f 4 138 519 -159 -519 + mu 0 4 144 145 166 165 + f 4 139 500 -160 -520 + mu 0 4 145 146 167 166 + f 4 140 521 -161 -521 + mu 0 4 147 148 169 168 + f 4 141 522 -162 -522 + mu 0 4 148 149 170 169 + f 4 142 523 -163 -523 + mu 0 4 149 150 171 170 + f 4 143 524 -164 -524 + mu 0 4 150 151 172 171 + f 4 144 525 -165 -525 + mu 0 4 151 152 173 172 + f 4 145 526 -166 -526 + mu 0 4 152 153 174 173 + f 4 146 527 -167 -527 + mu 0 4 153 154 175 174 + f 4 147 528 -168 -528 + mu 0 4 154 155 176 175 + f 4 148 529 -169 -529 + mu 0 4 155 156 177 176 + f 4 149 530 -170 -530 + mu 0 4 156 157 178 177 + f 4 150 531 -171 -531 + mu 0 4 157 158 179 178 + f 4 151 532 -172 -532 + mu 0 4 158 159 180 179 + f 4 152 533 -173 -533 + mu 0 4 159 160 181 180 + f 4 153 534 -174 -534 + mu 0 4 160 161 182 181 + f 4 154 535 -175 -535 + mu 0 4 161 162 183 182 + f 4 155 536 -176 -536 + mu 0 4 162 163 184 183 + f 4 156 537 -177 -537 + mu 0 4 163 164 185 184 + f 4 157 538 -178 -538 + mu 0 4 164 165 186 185 + f 4 158 539 -179 -539 + mu 0 4 165 166 187 186 + f 4 159 520 -180 -540 + mu 0 4 166 167 188 187 + f 4 160 541 -181 -541 + mu 0 4 168 169 190 189 + f 4 161 542 -182 -542 + mu 0 4 169 170 191 190 + f 4 162 543 -183 -543 + mu 0 4 170 171 192 191 + f 4 163 544 -184 -544 + mu 0 4 171 172 193 192 + f 4 164 545 -185 -545 + mu 0 4 172 173 194 193 + f 4 165 546 -186 -546 + mu 0 4 173 174 195 194 + f 4 166 547 -187 -547 + mu 0 4 174 175 196 195 + f 4 167 548 -188 -548 + mu 0 4 175 176 197 196 + f 4 168 549 -189 -549 + mu 0 4 176 177 198 197 + f 4 169 550 -190 -550 + mu 0 4 177 178 199 198 + f 4 170 551 -191 -551 + mu 0 4 178 179 200 199 + f 4 171 552 -192 -552 + mu 0 4 179 180 201 200 + f 4 172 553 -193 -553 + mu 0 4 180 181 202 201 + f 4 173 554 -194 -554 + mu 0 4 181 182 203 202 + f 4 174 555 -195 -555 + mu 0 4 182 183 204 203 + f 4 175 556 -196 -556 + mu 0 4 183 184 205 204 + f 4 176 557 -197 -557 + mu 0 4 184 185 206 205 + f 4 177 558 -198 -558 + mu 0 4 185 186 207 206 + f 4 178 559 -199 -559 + mu 0 4 186 187 208 207 + f 4 179 540 -200 -560 + mu 0 4 187 188 209 208 + f 4 180 561 -201 -561 + mu 0 4 189 190 211 210 + f 4 181 562 -202 -562 + mu 0 4 190 191 212 211 + f 4 182 563 -203 -563 + mu 0 4 191 192 213 212 + f 4 183 564 -204 -564 + mu 0 4 192 193 214 213 + f 4 184 565 -205 -565 + mu 0 4 193 194 215 214 + f 4 185 566 -206 -566 + mu 0 4 194 195 216 215 + f 4 186 567 -207 -567 + mu 0 4 195 196 217 216 + f 4 187 568 -208 -568 + mu 0 4 196 197 218 217 + f 4 188 569 -209 -569 + mu 0 4 197 198 219 218 + f 4 189 570 -210 -570 + mu 0 4 198 199 220 219 + f 4 190 571 -211 -571 + mu 0 4 199 200 221 220 + f 4 191 572 -212 -572 + mu 0 4 200 201 222 221 + f 4 192 573 -213 -573 + mu 0 4 201 202 223 222 + f 4 193 574 -214 -574 + mu 0 4 202 203 224 223 + f 4 194 575 -215 -575 + mu 0 4 203 204 225 224 + f 4 195 576 -216 -576 + mu 0 4 204 205 226 225 + f 4 196 577 -217 -577 + mu 0 4 205 206 227 226 + f 4 197 578 -218 -578 + mu 0 4 206 207 228 227 + f 4 198 579 -219 -579 + mu 0 4 207 208 229 228 + f 4 199 560 -220 -580 + mu 0 4 208 209 230 229 + f 4 200 581 -221 -581 + mu 0 4 210 211 232 231 + f 4 201 582 -222 -582 + mu 0 4 211 212 233 232 + f 4 202 583 -223 -583 + mu 0 4 212 213 234 233 + f 4 203 584 -224 -584 + mu 0 4 213 214 235 234 + f 4 204 585 -225 -585 + mu 0 4 214 215 236 235 + f 4 205 586 -226 -586 + mu 0 4 215 216 237 236 + f 4 206 587 -227 -587 + mu 0 4 216 217 238 237 + f 4 207 588 -228 -588 + mu 0 4 217 218 239 238 + f 4 208 589 -229 -589 + mu 0 4 218 219 240 239 + f 4 209 590 -230 -590 + mu 0 4 219 220 241 240 + f 4 210 591 -231 -591 + mu 0 4 220 221 242 241 + f 4 211 592 -232 -592 + mu 0 4 221 222 243 242 + f 4 212 593 -233 -593 + mu 0 4 222 223 244 243 + f 4 213 594 -234 -594 + mu 0 4 223 224 245 244 + f 4 214 595 -235 -595 + mu 0 4 224 225 246 245 + f 4 215 596 -236 -596 + mu 0 4 225 226 247 246 + f 4 216 597 -237 -597 + mu 0 4 226 227 248 247 + f 4 217 598 -238 -598 + mu 0 4 227 228 249 248 + f 4 218 599 -239 -599 + mu 0 4 228 229 250 249 + f 4 219 580 -240 -600 + mu 0 4 229 230 251 250 + f 4 220 601 -241 -601 + mu 0 4 231 232 253 252 + f 4 221 602 -242 -602 + mu 0 4 232 233 254 253 + f 4 222 603 -243 -603 + mu 0 4 233 234 255 254 + f 4 223 604 -244 -604 + mu 0 4 234 235 256 255 + f 4 224 605 -245 -605 + mu 0 4 235 236 257 256 + f 4 225 606 -246 -606 + mu 0 4 236 237 258 257 + f 4 226 607 -247 -607 + mu 0 4 237 238 259 258 + f 4 227 608 -248 -608 + mu 0 4 238 239 260 259 + f 4 228 609 -249 -609 + mu 0 4 239 240 261 260 + f 4 229 610 -250 -610 + mu 0 4 240 241 262 261 + f 4 230 611 -251 -611 + mu 0 4 241 242 263 262 + f 4 231 612 -252 -612 + mu 0 4 242 243 264 263 + f 4 232 613 -253 -613 + mu 0 4 243 244 265 264 + f 4 233 614 -254 -614 + mu 0 4 244 245 266 265 + f 4 234 615 -255 -615 + mu 0 4 245 246 267 266 + f 4 235 616 -256 -616 + mu 0 4 246 247 268 267 + f 4 236 617 -257 -617 + mu 0 4 247 248 269 268 + f 4 237 618 -258 -618 + mu 0 4 248 249 270 269 + f 4 238 619 -259 -619 + mu 0 4 249 250 271 270 + f 4 239 600 -260 -620 + mu 0 4 250 251 272 271 + f 4 240 621 -261 -621 + mu 0 4 252 253 274 273 + f 4 241 622 -262 -622 + mu 0 4 253 254 275 274 + f 4 242 623 -263 -623 + mu 0 4 254 255 276 275 + f 4 243 624 -264 -624 + mu 0 4 255 256 277 276 + f 4 244 625 -265 -625 + mu 0 4 256 257 278 277 + f 4 245 626 -266 -626 + mu 0 4 257 258 279 278 + f 4 246 627 -267 -627 + mu 0 4 258 259 280 279 + f 4 247 628 -268 -628 + mu 0 4 259 260 281 280 + f 4 248 629 -269 -629 + mu 0 4 260 261 282 281 + f 4 249 630 -270 -630 + mu 0 4 261 262 283 282 + f 4 250 631 -271 -631 + mu 0 4 262 263 284 283 + f 4 251 632 -272 -632 + mu 0 4 263 264 285 284 + f 4 252 633 -273 -633 + mu 0 4 264 265 286 285 + f 4 253 634 -274 -634 + mu 0 4 265 266 287 286 + f 4 254 635 -275 -635 + mu 0 4 266 267 288 287 + f 4 255 636 -276 -636 + mu 0 4 267 268 289 288 + f 4 256 637 -277 -637 + mu 0 4 268 269 290 289 + f 4 257 638 -278 -638 + mu 0 4 269 270 291 290 + f 4 258 639 -279 -639 + mu 0 4 270 271 292 291 + f 4 259 620 -280 -640 + mu 0 4 271 272 293 292 + f 4 260 641 -281 -641 + mu 0 4 273 274 295 294 + f 4 261 642 -282 -642 + mu 0 4 274 275 296 295 + f 4 262 643 -283 -643 + mu 0 4 275 276 297 296 + f 4 263 644 -284 -644 + mu 0 4 276 277 298 297 + f 4 264 645 -285 -645 + mu 0 4 277 278 299 298 + f 4 265 646 -286 -646 + mu 0 4 278 279 300 299 + f 4 266 647 -287 -647 + mu 0 4 279 280 301 300 + f 4 267 648 -288 -648 + mu 0 4 280 281 302 301 + f 4 268 649 -289 -649 + mu 0 4 281 282 303 302 + f 4 269 650 -290 -650 + mu 0 4 282 283 304 303 + f 4 270 651 -291 -651 + mu 0 4 283 284 305 304 + f 4 271 652 -292 -652 + mu 0 4 284 285 306 305 + f 4 272 653 -293 -653 + mu 0 4 285 286 307 306 + f 4 273 654 -294 -654 + mu 0 4 286 287 308 307 + f 4 274 655 -295 -655 + mu 0 4 287 288 309 308 + f 4 275 656 -296 -656 + mu 0 4 288 289 310 309 + f 4 276 657 -297 -657 + mu 0 4 289 290 311 310 + f 4 277 658 -298 -658 + mu 0 4 290 291 312 311 + f 4 278 659 -299 -659 + mu 0 4 291 292 313 312 + f 4 279 640 -300 -660 + mu 0 4 292 293 314 313 + f 4 280 661 -301 -661 + mu 0 4 294 295 316 315 + f 4 281 662 -302 -662 + mu 0 4 295 296 317 316 + f 4 282 663 -303 -663 + mu 0 4 296 297 318 317 + f 4 283 664 -304 -664 + mu 0 4 297 298 319 318 + f 4 284 665 -305 -665 + mu 0 4 298 299 320 319 + f 4 285 666 -306 -666 + mu 0 4 299 300 321 320 + f 4 286 667 -307 -667 + mu 0 4 300 301 322 321 + f 4 287 668 -308 -668 + mu 0 4 301 302 323 322 + f 4 288 669 -309 -669 + mu 0 4 302 303 324 323 + f 4 289 670 -310 -670 + mu 0 4 303 304 325 324 + f 4 290 671 -311 -671 + mu 0 4 304 305 326 325 + f 4 291 672 -312 -672 + mu 0 4 305 306 327 326 + f 4 292 673 -313 -673 + mu 0 4 306 307 328 327 + f 4 293 674 -314 -674 + mu 0 4 307 308 329 328 + f 4 294 675 -315 -675 + mu 0 4 308 309 330 329 + f 4 295 676 -316 -676 + mu 0 4 309 310 331 330 + f 4 296 677 -317 -677 + mu 0 4 310 311 332 331 + f 4 297 678 -318 -678 + mu 0 4 311 312 333 332 + f 4 298 679 -319 -679 + mu 0 4 312 313 334 333 + f 4 299 660 -320 -680 + mu 0 4 313 314 335 334 + f 4 300 681 -321 -681 + mu 0 4 315 316 337 336 + f 4 301 682 -322 -682 + mu 0 4 316 317 338 337 + f 4 302 683 -323 -683 + mu 0 4 317 318 339 338 + f 4 303 684 -324 -684 + mu 0 4 318 319 340 339 + f 4 304 685 -325 -685 + mu 0 4 319 320 341 340 + f 4 305 686 -326 -686 + mu 0 4 320 321 342 341 + f 4 306 687 -327 -687 + mu 0 4 321 322 343 342 + f 4 307 688 -328 -688 + mu 0 4 322 323 344 343 + f 4 308 689 -329 -689 + mu 0 4 323 324 345 344 + f 4 309 690 -330 -690 + mu 0 4 324 325 346 345 + f 4 310 691 -331 -691 + mu 0 4 325 326 347 346 + f 4 311 692 -332 -692 + mu 0 4 326 327 348 347 + f 4 312 693 -333 -693 + mu 0 4 327 328 349 348 + f 4 313 694 -334 -694 + mu 0 4 328 329 350 349 + f 4 314 695 -335 -695 + mu 0 4 329 330 351 350 + f 4 315 696 -336 -696 + mu 0 4 330 331 352 351 + f 4 316 697 -337 -697 + mu 0 4 331 332 353 352 + f 4 317 698 -338 -698 + mu 0 4 332 333 354 353 + f 4 318 699 -339 -699 + mu 0 4 333 334 355 354 + f 4 319 680 -340 -700 + mu 0 4 334 335 356 355 + f 4 320 701 -341 -701 + mu 0 4 336 337 358 357 + f 4 321 702 -342 -702 + mu 0 4 337 338 359 358 + f 4 322 703 -343 -703 + mu 0 4 338 339 360 359 + f 4 323 704 -344 -704 + mu 0 4 339 340 361 360 + f 4 324 705 -345 -705 + mu 0 4 340 341 362 361 + f 4 325 706 -346 -706 + mu 0 4 341 342 363 362 + f 4 326 707 -347 -707 + mu 0 4 342 343 364 363 + f 4 327 708 -348 -708 + mu 0 4 343 344 365 364 + f 4 328 709 -349 -709 + mu 0 4 344 345 366 365 + f 4 329 710 -350 -710 + mu 0 4 345 346 367 366 + f 4 330 711 -351 -711 + mu 0 4 346 347 368 367 + f 4 331 712 -352 -712 + mu 0 4 347 348 369 368 + f 4 332 713 -353 -713 + mu 0 4 348 349 370 369 + f 4 333 714 -354 -714 + mu 0 4 349 350 371 370 + f 4 334 715 -355 -715 + mu 0 4 350 351 372 371 + f 4 335 716 -356 -716 + mu 0 4 351 352 373 372 + f 4 336 717 -357 -717 + mu 0 4 352 353 374 373 + f 4 337 718 -358 -718 + mu 0 4 353 354 375 374 + f 4 338 719 -359 -719 + mu 0 4 354 355 376 375 + f 4 339 700 -360 -720 + mu 0 4 355 356 377 376 + f 4 340 721 -361 -721 + mu 0 4 357 358 379 378 + f 4 341 722 -362 -722 + mu 0 4 358 359 380 379 + f 4 342 723 -363 -723 + mu 0 4 359 360 381 380 + f 4 343 724 -364 -724 + mu 0 4 360 361 382 381 + f 4 344 725 -365 -725 + mu 0 4 361 362 383 382 + f 4 345 726 -366 -726 + mu 0 4 362 363 384 383 + f 4 346 727 -367 -727 + mu 0 4 363 364 385 384 + f 4 347 728 -368 -728 + mu 0 4 364 365 386 385 + f 4 348 729 -369 -729 + mu 0 4 365 366 387 386 + f 4 349 730 -370 -730 + mu 0 4 366 367 388 387 + f 4 350 731 -371 -731 + mu 0 4 367 368 389 388 + f 4 351 732 -372 -732 + mu 0 4 368 369 390 389 + f 4 352 733 -373 -733 + mu 0 4 369 370 391 390 + f 4 353 734 -374 -734 + mu 0 4 370 371 392 391 + f 4 354 735 -375 -735 + mu 0 4 371 372 393 392 + f 4 355 736 -376 -736 + mu 0 4 372 373 394 393 + f 4 356 737 -377 -737 + mu 0 4 373 374 395 394 + f 4 357 738 -378 -738 + mu 0 4 374 375 396 395 + f 4 358 739 -379 -739 + mu 0 4 375 376 397 396 + f 4 359 720 -380 -740 + mu 0 4 376 377 398 397 + f 3 -1 -741 741 + mu 0 3 1 0 399 + f 3 -2 -742 742 + mu 0 3 2 1 400 + f 3 -3 -743 743 + mu 0 3 3 2 401 + f 3 -4 -744 744 + mu 0 3 4 3 402 + f 3 -5 -745 745 + mu 0 3 5 4 403 + f 3 -6 -746 746 + mu 0 3 6 5 404 + f 3 -7 -747 747 + mu 0 3 7 6 405 + f 3 -8 -748 748 + mu 0 3 8 7 406 + f 3 -9 -749 749 + mu 0 3 9 8 407 + f 3 -10 -750 750 + mu 0 3 10 9 408 + f 3 -11 -751 751 + mu 0 3 11 10 409 + f 3 -12 -752 752 + mu 0 3 12 11 410 + f 3 -13 -753 753 + mu 0 3 13 12 411 + f 3 -14 -754 754 + mu 0 3 14 13 412 + f 3 -15 -755 755 + mu 0 3 15 14 413 + f 3 -16 -756 756 + mu 0 3 16 15 414 + f 3 -17 -757 757 + mu 0 3 17 16 415 + f 3 -18 -758 758 + mu 0 3 18 17 416 + f 3 -19 -759 759 + mu 0 3 19 18 417 + f 3 -20 -760 740 + mu 0 3 20 19 418 + f 3 360 761 -761 + mu 0 3 378 379 419 + f 3 361 762 -762 + mu 0 3 379 380 420 + f 3 362 763 -763 + mu 0 3 380 381 421 + f 3 363 764 -764 + mu 0 3 381 382 422 + f 3 364 765 -765 + mu 0 3 382 383 423 + f 3 365 766 -766 + mu 0 3 383 384 424 + f 3 366 767 -767 + mu 0 3 384 385 425 + f 3 367 768 -768 + mu 0 3 385 386 426 + f 3 368 769 -769 + mu 0 3 386 387 427 + f 3 369 770 -770 + mu 0 3 387 388 428 + f 3 370 771 -771 + mu 0 3 388 389 429 + f 3 371 772 -772 + mu 0 3 389 390 430 + f 3 372 773 -773 + mu 0 3 390 391 431 + f 3 373 774 -774 + mu 0 3 391 392 432 + f 3 374 775 -775 + mu 0 3 392 393 433 + f 3 375 776 -776 + mu 0 3 393 394 434 + f 3 376 777 -777 + mu 0 3 394 395 435 + f 3 377 778 -778 + mu 0 3 395 396 436 + f 3 378 779 -779 + mu 0 3 396 397 437 + f 3 379 760 -780 + mu 0 3 397 398 438; + setAttr ".cd" -type "dataPolyComponent" Index_Data Edge 0 ; + setAttr ".cvd" -type "dataPolyComponent" Index_Data Vertex 0 ; + setAttr ".pd[0]" -type "dataPolyComponent" Index_Data UV 0 ; + setAttr ".hfd" -type "dataPolyComponent" Index_Data Face 0 ; + setAttr ".dr" 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:bf8e49bb98ec"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 4 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; +select -ne :initialShadingGroup; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + setAttr ".ren" -type "string" "arnold"; + setAttr ".fs" 1001; + setAttr ".ef" 1001; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +// End of modelMain.ma diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc new file mode 100644 index 0000000000..a1d07f8f31 Binary files /dev/null and b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.abc differ diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma new file mode 100644 index 0000000000..5088dfa11c --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/model/modelMain/v001/test_project_test_asset_modelMain_v001.ma @@ -0,0 +1,1186 @@ +//Maya ASCII 2019 scene +//Name: modelMain.ma +//Last modified: Thu, Sep 02, 2021 03:24:19 PM +//Codeset: 1252 +requires maya "2019"; +requires "mtoa" "4.0.4.2"; +requires "mtoa" "4.0.4.2"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2019"; +fileInfo "version" "2019"; +fileInfo "cutIdentifier" "201812112215-434d8d9c04"; +fileInfo "osv" "Microsoft Windows 10 Technical Preview (Build 19041)\n"; +fileInfo "license" "education"; +createNode transform -n "pSphere1_GEO"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:440654b3dfe4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr -s 439 ".uvst[0].uvsp"; + setAttr ".uvst[0].uvsp[0:249]" -type "float2" 0 0.050000001 0.050000001 0.050000001 + 0.1 0.050000001 0.15000001 0.050000001 0.2 0.050000001 0.25 0.050000001 0.30000001 + 0.050000001 0.35000002 0.050000001 0.40000004 0.050000001 0.45000005 0.050000001 + 0.50000006 0.050000001 0.55000007 0.050000001 0.60000008 0.050000001 0.6500001 0.050000001 + 0.70000011 0.050000001 0.75000012 0.050000001 0.80000013 0.050000001 0.85000014 0.050000001 + 0.90000015 0.050000001 0.95000017 0.050000001 1.000000119209 0.050000001 0 0.1 0.050000001 + 0.1 0.1 0.1 0.15000001 0.1 0.2 0.1 0.25 0.1 0.30000001 0.1 0.35000002 0.1 0.40000004 + 0.1 0.45000005 0.1 0.50000006 0.1 0.55000007 0.1 0.60000008 0.1 0.6500001 0.1 0.70000011 + 0.1 0.75000012 0.1 0.80000013 0.1 0.85000014 0.1 0.90000015 0.1 0.95000017 0.1 1.000000119209 + 0.1 0 0.15000001 0.050000001 0.15000001 0.1 0.15000001 0.15000001 0.15000001 0.2 + 0.15000001 0.25 0.15000001 0.30000001 0.15000001 0.35000002 0.15000001 0.40000004 + 0.15000001 0.45000005 0.15000001 0.50000006 0.15000001 0.55000007 0.15000001 0.60000008 + 0.15000001 0.6500001 0.15000001 0.70000011 0.15000001 0.75000012 0.15000001 0.80000013 + 0.15000001 0.85000014 0.15000001 0.90000015 0.15000001 0.95000017 0.15000001 1.000000119209 + 0.15000001 0 0.2 0.050000001 0.2 0.1 0.2 0.15000001 0.2 0.2 0.2 0.25 0.2 0.30000001 + 0.2 0.35000002 0.2 0.40000004 0.2 0.45000005 0.2 0.50000006 0.2 0.55000007 0.2 0.60000008 + 0.2 0.6500001 0.2 0.70000011 0.2 0.75000012 0.2 0.80000013 0.2 0.85000014 0.2 0.90000015 + 0.2 0.95000017 0.2 1.000000119209 0.2 0 0.25 0.050000001 0.25 0.1 0.25 0.15000001 + 0.25 0.2 0.25 0.25 0.25 0.30000001 0.25 0.35000002 0.25 0.40000004 0.25 0.45000005 + 0.25 0.50000006 0.25 0.55000007 0.25 0.60000008 0.25 0.6500001 0.25 0.70000011 0.25 + 0.75000012 0.25 0.80000013 0.25 0.85000014 0.25 0.90000015 0.25 0.95000017 0.25 1.000000119209 + 0.25 0 0.30000001 0.050000001 0.30000001 0.1 0.30000001 0.15000001 0.30000001 0.2 + 0.30000001 0.25 0.30000001 0.30000001 0.30000001 0.35000002 0.30000001 0.40000004 + 0.30000001 0.45000005 0.30000001 0.50000006 0.30000001 0.55000007 0.30000001 0.60000008 + 0.30000001 0.6500001 0.30000001 0.70000011 0.30000001 0.75000012 0.30000001 0.80000013 + 0.30000001 0.85000014 0.30000001 0.90000015 0.30000001 0.95000017 0.30000001 1.000000119209 + 0.30000001 0 0.35000002 0.050000001 0.35000002 0.1 0.35000002 0.15000001 0.35000002 + 0.2 0.35000002 0.25 0.35000002 0.30000001 0.35000002 0.35000002 0.35000002 0.40000004 + 0.35000002 0.45000005 0.35000002 0.50000006 0.35000002 0.55000007 0.35000002 0.60000008 + 0.35000002 0.6500001 0.35000002 0.70000011 0.35000002 0.75000012 0.35000002 0.80000013 + 0.35000002 0.85000014 0.35000002 0.90000015 0.35000002 0.95000017 0.35000002 1.000000119209 + 0.35000002 0 0.40000004 0.050000001 0.40000004 0.1 0.40000004 0.15000001 0.40000004 + 0.2 0.40000004 0.25 0.40000004 0.30000001 0.40000004 0.35000002 0.40000004 0.40000004 + 0.40000004 0.45000005 0.40000004 0.50000006 0.40000004 0.55000007 0.40000004 0.60000008 + 0.40000004 0.6500001 0.40000004 0.70000011 0.40000004 0.75000012 0.40000004 0.80000013 + 0.40000004 0.85000014 0.40000004 0.90000015 0.40000004 0.95000017 0.40000004 1.000000119209 + 0.40000004 0 0.45000005 0.050000001 0.45000005 0.1 0.45000005 0.15000001 0.45000005 + 0.2 0.45000005 0.25 0.45000005 0.30000001 0.45000005 0.35000002 0.45000005 0.40000004 + 0.45000005 0.45000005 0.45000005 0.50000006 0.45000005 0.55000007 0.45000005 0.60000008 + 0.45000005 0.6500001 0.45000005 0.70000011 0.45000005 0.75000012 0.45000005 0.80000013 + 0.45000005 0.85000014 0.45000005 0.90000015 0.45000005 0.95000017 0.45000005 1.000000119209 + 0.45000005 0 0.50000006 0.050000001 0.50000006 0.1 0.50000006 0.15000001 0.50000006 + 0.2 0.50000006 0.25 0.50000006 0.30000001 0.50000006 0.35000002 0.50000006 0.40000004 + 0.50000006 0.45000005 0.50000006 0.50000006 0.50000006 0.55000007 0.50000006 0.60000008 + 0.50000006 0.6500001 0.50000006 0.70000011 0.50000006 0.75000012 0.50000006 0.80000013 + 0.50000006 0.85000014 0.50000006 0.90000015 0.50000006 0.95000017 0.50000006 1.000000119209 + 0.50000006 0 0.55000007 0.050000001 0.55000007 0.1 0.55000007 0.15000001 0.55000007 + 0.2 0.55000007 0.25 0.55000007 0.30000001 0.55000007 0.35000002 0.55000007 0.40000004 + 0.55000007 0.45000005 0.55000007 0.50000006 0.55000007 0.55000007 0.55000007 0.60000008 + 0.55000007 0.6500001 0.55000007 0.70000011 0.55000007 0.75000012 0.55000007 0.80000013 + 0.55000007 0.85000014 0.55000007 0.90000015 0.55000007 0.95000017 0.55000007 1.000000119209 + 0.55000007 0 0.60000008 0.050000001 0.60000008 0.1 0.60000008 0.15000001 0.60000008 + 0.2 0.60000008 0.25 0.60000008 0.30000001 0.60000008 0.35000002 0.60000008 0.40000004 + 0.60000008 0.45000005 0.60000008 0.50000006 0.60000008 0.55000007 0.60000008 0.60000008 + 0.60000008 0.6500001 0.60000008 0.70000011 0.60000008 0.75000012 0.60000008 0.80000013 + 0.60000008 0.85000014 0.60000008 0.90000015 0.60000008; + setAttr ".uvst[0].uvsp[250:438]" 0.95000017 0.60000008 1.000000119209 0.60000008 + 0 0.6500001 0.050000001 0.6500001 0.1 0.6500001 0.15000001 0.6500001 0.2 0.6500001 + 0.25 0.6500001 0.30000001 0.6500001 0.35000002 0.6500001 0.40000004 0.6500001 0.45000005 + 0.6500001 0.50000006 0.6500001 0.55000007 0.6500001 0.60000008 0.6500001 0.6500001 + 0.6500001 0.70000011 0.6500001 0.75000012 0.6500001 0.80000013 0.6500001 0.85000014 + 0.6500001 0.90000015 0.6500001 0.95000017 0.6500001 1.000000119209 0.6500001 0 0.70000011 + 0.050000001 0.70000011 0.1 0.70000011 0.15000001 0.70000011 0.2 0.70000011 0.25 0.70000011 + 0.30000001 0.70000011 0.35000002 0.70000011 0.40000004 0.70000011 0.45000005 0.70000011 + 0.50000006 0.70000011 0.55000007 0.70000011 0.60000008 0.70000011 0.6500001 0.70000011 + 0.70000011 0.70000011 0.75000012 0.70000011 0.80000013 0.70000011 0.85000014 0.70000011 + 0.90000015 0.70000011 0.95000017 0.70000011 1.000000119209 0.70000011 0 0.75000012 + 0.050000001 0.75000012 0.1 0.75000012 0.15000001 0.75000012 0.2 0.75000012 0.25 0.75000012 + 0.30000001 0.75000012 0.35000002 0.75000012 0.40000004 0.75000012 0.45000005 0.75000012 + 0.50000006 0.75000012 0.55000007 0.75000012 0.60000008 0.75000012 0.6500001 0.75000012 + 0.70000011 0.75000012 0.75000012 0.75000012 0.80000013 0.75000012 0.85000014 0.75000012 + 0.90000015 0.75000012 0.95000017 0.75000012 1.000000119209 0.75000012 0 0.80000013 + 0.050000001 0.80000013 0.1 0.80000013 0.15000001 0.80000013 0.2 0.80000013 0.25 0.80000013 + 0.30000001 0.80000013 0.35000002 0.80000013 0.40000004 0.80000013 0.45000005 0.80000013 + 0.50000006 0.80000013 0.55000007 0.80000013 0.60000008 0.80000013 0.6500001 0.80000013 + 0.70000011 0.80000013 0.75000012 0.80000013 0.80000013 0.80000013 0.85000014 0.80000013 + 0.90000015 0.80000013 0.95000017 0.80000013 1.000000119209 0.80000013 0 0.85000014 + 0.050000001 0.85000014 0.1 0.85000014 0.15000001 0.85000014 0.2 0.85000014 0.25 0.85000014 + 0.30000001 0.85000014 0.35000002 0.85000014 0.40000004 0.85000014 0.45000005 0.85000014 + 0.50000006 0.85000014 0.55000007 0.85000014 0.60000008 0.85000014 0.6500001 0.85000014 + 0.70000011 0.85000014 0.75000012 0.85000014 0.80000013 0.85000014 0.85000014 0.85000014 + 0.90000015 0.85000014 0.95000017 0.85000014 1.000000119209 0.85000014 0 0.90000015 + 0.050000001 0.90000015 0.1 0.90000015 0.15000001 0.90000015 0.2 0.90000015 0.25 0.90000015 + 0.30000001 0.90000015 0.35000002 0.90000015 0.40000004 0.90000015 0.45000005 0.90000015 + 0.50000006 0.90000015 0.55000007 0.90000015 0.60000008 0.90000015 0.6500001 0.90000015 + 0.70000011 0.90000015 0.75000012 0.90000015 0.80000013 0.90000015 0.85000014 0.90000015 + 0.90000015 0.90000015 0.95000017 0.90000015 1.000000119209 0.90000015 0 0.95000017 + 0.050000001 0.95000017 0.1 0.95000017 0.15000001 0.95000017 0.2 0.95000017 0.25 0.95000017 + 0.30000001 0.95000017 0.35000002 0.95000017 0.40000004 0.95000017 0.45000005 0.95000017 + 0.50000006 0.95000017 0.55000007 0.95000017 0.60000008 0.95000017 0.6500001 0.95000017 + 0.70000011 0.95000017 0.75000012 0.95000017 0.80000013 0.95000017 0.85000014 0.95000017 + 0.90000015 0.95000017 0.95000017 0.95000017 1.000000119209 0.95000017 0.025 0 0.075000003 + 0 0.125 0 0.17500001 0 0.22500001 0 0.27500001 0 0.32500002 0 0.375 0 0.42500001 + 0 0.47500002 0 0.52499998 0 0.57499999 0 0.625 0 0.67500001 0 0.72499996 0 0.77499998 + 0 0.82499999 0 0.875 0 0.92500001 0 0.97499996 0 0.025 1 0.075000003 1 0.125 1 0.17500001 + 1 0.22500001 1 0.27500001 1 0.32500002 1 0.375 1 0.42500001 1 0.47500002 1 0.52499998 + 1 0.57499999 1 0.625 1 0.67500001 1 0.72499996 1 0.77499998 1 0.82499999 1 0.875 + 1 0.92500001 1 0.97499996 1; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr -s 382 ".vt"; + setAttr ".vt[0:165]" 0.14877813 -0.98768836 -0.048340943 0.12655823 -0.98768836 -0.091949932 + 0.091949932 -0.98768836 -0.12655823 0.048340935 -0.98768836 -0.14877811 0 -0.98768836 -0.15643455 + -0.048340935 -0.98768836 -0.1487781 -0.091949917 -0.98768836 -0.1265582 -0.12655818 -0.98768836 -0.091949902 + -0.14877807 -0.98768836 -0.048340924 -0.15643452 -0.98768836 0 -0.14877807 -0.98768836 0.048340924 + -0.12655818 -0.98768836 0.091949895 -0.091949895 -0.98768836 0.12655817 -0.048340924 -0.98768836 0.14877805 + -4.6621107e-09 -0.98768836 0.15643449 0.048340909 -0.98768836 0.14877804 0.09194988 -0.98768836 0.12655815 + 0.12655815 -0.98768836 0.091949888 0.14877804 -0.98768836 0.048340913 0.15643448 -0.98768836 0 + 0.29389283 -0.95105654 -0.095491566 0.25000018 -0.95105654 -0.18163574 0.18163574 -0.95105654 -0.25000015 + 0.095491551 -0.95105654 -0.2938928 0 -0.95105654 -0.30901715 -0.095491551 -0.95105654 -0.29389277 + -0.18163571 -0.95105654 -0.25000009 -0.25000009 -0.95105654 -0.18163569 -0.29389271 -0.95105654 -0.095491529 + -0.30901706 -0.95105654 0 -0.29389271 -0.95105654 0.095491529 -0.25000006 -0.95105654 0.18163568 + -0.18163568 -0.95105654 0.25000006 -0.095491529 -0.95105654 0.29389268 -9.2094243e-09 -0.95105654 0.30901703 + 0.095491499 -0.95105654 0.29389265 0.18163563 -0.95105654 0.25000003 0.25 -0.95105654 0.18163565 + 0.29389265 -0.95105654 0.095491506 0.309017 -0.95105654 0 0.43177092 -0.89100653 -0.14029087 + 0.36728629 -0.89100653 -0.2668491 0.2668491 -0.89100653 -0.36728626 0.14029086 -0.89100653 -0.43177086 + 0 -0.89100653 -0.45399073 -0.14029086 -0.89100653 -0.43177083 -0.26684904 -0.89100653 -0.36728618 + -0.36728615 -0.89100653 -0.26684901 -0.43177077 -0.89100653 -0.14029081 -0.45399064 -0.89100653 0 + -0.43177077 -0.89100653 0.14029081 -0.36728612 -0.89100653 0.26684898 -0.26684898 -0.89100653 0.36728612 + -0.14029081 -0.89100653 0.43177071 -1.3529972e-08 -0.89100653 0.45399058 0.14029078 -0.89100653 0.43177068 + 0.26684892 -0.89100653 0.36728609 0.36728606 -0.89100653 0.26684895 0.43177065 -0.89100653 0.1402908 + 0.45399052 -0.89100653 0 0.55901736 -0.809017 -0.18163574 0.47552857 -0.809017 -0.34549171 + 0.34549171 -0.809017 -0.47552854 0.18163572 -0.809017 -0.5590173 0 -0.809017 -0.58778554 + -0.18163572 -0.809017 -0.55901724 -0.34549165 -0.809017 -0.47552842 -0.47552839 -0.809017 -0.34549159 + -0.55901712 -0.809017 -0.18163566 -0.58778536 -0.809017 0 -0.55901712 -0.809017 0.18163566 + -0.47552836 -0.809017 0.34549156 -0.34549156 -0.809017 0.47552833 -0.18163566 -0.809017 0.55901706 + -1.7517365e-08 -0.809017 0.5877853 0.18163562 -0.809017 0.55901706 0.3454915 -0.809017 0.4755283 + 0.47552827 -0.809017 0.34549153 0.559017 -0.809017 0.18163563 0.58778524 -0.809017 0 + 0.67249894 -0.70710677 -0.21850814 0.57206178 -0.70710677 -0.41562718 0.41562718 -0.70710677 -0.57206172 + 0.21850812 -0.70710677 -0.67249888 0 -0.70710677 -0.70710713 -0.21850812 -0.70710677 -0.67249882 + -0.41562709 -0.70710677 -0.5720616 -0.57206154 -0.70710677 -0.41562706 -0.6724987 -0.70710677 -0.21850805 + -0.70710695 -0.70710677 0 -0.6724987 -0.70710677 0.21850805 -0.57206154 -0.70710677 0.415627 + -0.415627 -0.70710677 0.57206148 -0.21850805 -0.70710677 0.67249858 -2.1073424e-08 -0.70710677 0.70710683 + 0.21850799 -0.70710677 0.67249858 0.41562691 -0.70710677 0.57206142 0.57206142 -0.70710677 0.41562697 + 0.67249852 -0.70710677 0.21850802 0.70710677 -0.70710677 0 0.7694214 -0.58778524 -0.25000015 + 0.65450895 -0.58778524 -0.47552854 0.47552854 -0.58778524 -0.65450889 0.25000012 -0.58778524 -0.76942128 + 0 -0.58778524 -0.80901736 -0.25000012 -0.58778524 -0.76942122 -0.47552845 -0.58778524 -0.65450877 + -0.65450871 -0.58778524 -0.47552839 -0.7694211 -0.58778524 -0.25000006 -0.80901718 -0.58778524 0 + -0.7694211 -0.58778524 0.25000006 -0.65450865 -0.58778524 0.47552836 -0.47552836 -0.58778524 0.65450859 + -0.25000006 -0.58778524 0.76942098 -2.4110586e-08 -0.58778524 0.80901712 0.24999999 -0.58778524 0.76942098 + 0.47552827 -0.58778524 0.65450853 0.65450853 -0.58778524 0.4755283 0.76942092 -0.58778524 0.25 + 0.809017 -0.58778524 0 0.8473981 -0.45399052 -0.27533633 0.72083992 -0.45399052 -0.5237208 + 0.5237208 -0.45399052 -0.72083986 0.2753363 -0.45399052 -0.84739798 0 -0.45399052 -0.89100695 + -0.2753363 -0.45399052 -0.84739798 -0.52372068 -0.45399052 -0.72083968 -0.72083962 -0.45399052 -0.52372062 + -0.8473978 -0.45399052 -0.27533621 -0.89100677 -0.45399052 0 -0.8473978 -0.45399052 0.27533621 + -0.72083962 -0.45399052 0.52372062 -0.52372062 -0.45399052 0.72083956 -0.27533621 -0.45399052 0.84739769 + -2.6554064e-08 -0.45399052 0.89100665 0.27533615 -0.45399052 0.84739763 0.5237205 -0.45399052 0.7208395 + 0.72083944 -0.45399052 0.52372056 0.84739757 -0.45399052 0.27533618 0.89100653 -0.45399052 0 + 0.90450913 -0.30901697 -0.2938928 0.7694214 -0.30901697 -0.55901736 0.55901736 -0.30901697 -0.76942134 + 0.29389277 -0.30901697 -0.90450901 0 -0.30901697 -0.95105702 -0.29389277 -0.30901697 -0.90450895 + -0.55901724 -0.30901697 -0.76942122 -0.76942116 -0.30901697 -0.55901718 -0.90450877 -0.30901697 -0.29389271 + -0.95105678 -0.30901697 0 -0.90450877 -0.30901697 0.29389271 -0.7694211 -0.30901697 0.55901712 + -0.55901712 -0.30901697 0.76942104 -0.29389271 -0.30901697 0.90450865 -2.8343694e-08 -0.30901697 0.95105666 + 0.29389262 -0.30901697 0.90450859 0.559017 -0.30901697 0.76942098 0.76942092 -0.30901697 0.55901706 + 0.90450853 -0.30901697 0.29389265 0.95105654 -0.30901697 0 0.93934804 -0.15643437 -0.30521268 + 0.79905719 -0.15643437 -0.580549 0.580549 -0.15643437 -0.79905713 0.30521265 -0.15643437 -0.93934792 + 0 -0.15643437 -0.98768884 -0.30521265 -0.15643437 -0.93934786; + setAttr ".vt[166:331]" -0.58054888 -0.15643437 -0.79905695 -0.79905689 -0.15643437 -0.58054882 + -0.93934768 -0.15643437 -0.30521256 -0.9876886 -0.15643437 0 -0.93934768 -0.15643437 0.30521256 + -0.79905683 -0.15643437 0.58054876 -0.58054876 -0.15643437 0.79905677 -0.30521256 -0.15643437 0.93934757 + -2.9435407e-08 -0.15643437 0.98768848 0.30521247 -0.15643437 0.93934757 0.58054864 -0.15643437 0.79905671 + 0.79905665 -0.15643437 0.5805487 0.93934751 -0.15643437 0.3052125 0.98768836 -0.15643437 0 + 0.95105714 0 -0.30901718 0.80901754 0 -0.5877856 0.5877856 0 -0.80901748 0.30901715 0 -0.95105702 + 0 0 -1.000000476837 -0.30901715 0 -0.95105696 -0.58778548 0 -0.8090173 -0.80901724 0 -0.58778542 + -0.95105678 0 -0.30901706 -1.000000238419 0 0 -0.95105678 0 0.30901706 -0.80901718 0 0.58778536 + -0.58778536 0 0.80901712 -0.30901706 0 0.95105666 -2.9802322e-08 0 1.000000119209 + 0.30901697 0 0.9510566 0.58778524 0 0.80901706 0.809017 0 0.5877853 0.95105654 0 0.309017 + 1 0 0 0.93934804 0.15643437 -0.30521268 0.79905719 0.15643437 -0.580549 0.580549 0.15643437 -0.79905713 + 0.30521265 0.15643437 -0.93934792 0 0.15643437 -0.98768884 -0.30521265 0.15643437 -0.93934786 + -0.58054888 0.15643437 -0.79905695 -0.79905689 0.15643437 -0.58054882 -0.93934768 0.15643437 -0.30521256 + -0.9876886 0.15643437 0 -0.93934768 0.15643437 0.30521256 -0.79905683 0.15643437 0.58054876 + -0.58054876 0.15643437 0.79905677 -0.30521256 0.15643437 0.93934757 -2.9435407e-08 0.15643437 0.98768848 + 0.30521247 0.15643437 0.93934757 0.58054864 0.15643437 0.79905671 0.79905665 0.15643437 0.5805487 + 0.93934751 0.15643437 0.3052125 0.98768836 0.15643437 0 0.90450913 0.30901697 -0.2938928 + 0.7694214 0.30901697 -0.55901736 0.55901736 0.30901697 -0.76942134 0.29389277 0.30901697 -0.90450901 + 0 0.30901697 -0.95105702 -0.29389277 0.30901697 -0.90450895 -0.55901724 0.30901697 -0.76942122 + -0.76942116 0.30901697 -0.55901718 -0.90450877 0.30901697 -0.29389271 -0.95105678 0.30901697 0 + -0.90450877 0.30901697 0.29389271 -0.7694211 0.30901697 0.55901712 -0.55901712 0.30901697 0.76942104 + -0.29389271 0.30901697 0.90450865 -2.8343694e-08 0.30901697 0.95105666 0.29389262 0.30901697 0.90450859 + 0.559017 0.30901697 0.76942098 0.76942092 0.30901697 0.55901706 0.90450853 0.30901697 0.29389265 + 0.95105654 0.30901697 0 0.8473981 0.45399052 -0.27533633 0.72083992 0.45399052 -0.5237208 + 0.5237208 0.45399052 -0.72083986 0.2753363 0.45399052 -0.84739798 0 0.45399052 -0.89100695 + -0.2753363 0.45399052 -0.84739798 -0.52372068 0.45399052 -0.72083968 -0.72083962 0.45399052 -0.52372062 + -0.8473978 0.45399052 -0.27533621 -0.89100677 0.45399052 0 -0.8473978 0.45399052 0.27533621 + -0.72083962 0.45399052 0.52372062 -0.52372062 0.45399052 0.72083956 -0.27533621 0.45399052 0.84739769 + -2.6554064e-08 0.45399052 0.89100665 0.27533615 0.45399052 0.84739763 0.5237205 0.45399052 0.7208395 + 0.72083944 0.45399052 0.52372056 0.84739757 0.45399052 0.27533618 0.89100653 0.45399052 0 + 0.7694214 0.58778524 -0.25000015 0.65450895 0.58778524 -0.47552854 0.47552854 0.58778524 -0.65450889 + 0.25000012 0.58778524 -0.76942128 0 0.58778524 -0.80901736 -0.25000012 0.58778524 -0.76942122 + -0.47552845 0.58778524 -0.65450877 -0.65450871 0.58778524 -0.47552839 -0.7694211 0.58778524 -0.25000006 + -0.80901718 0.58778524 0 -0.7694211 0.58778524 0.25000006 -0.65450865 0.58778524 0.47552836 + -0.47552836 0.58778524 0.65450859 -0.25000006 0.58778524 0.76942098 -2.4110586e-08 0.58778524 0.80901712 + 0.24999999 0.58778524 0.76942098 0.47552827 0.58778524 0.65450853 0.65450853 0.58778524 0.4755283 + 0.76942092 0.58778524 0.25 0.809017 0.58778524 0 0.67249894 0.70710677 -0.21850814 + 0.57206178 0.70710677 -0.41562718 0.41562718 0.70710677 -0.57206172 0.21850812 0.70710677 -0.67249888 + 0 0.70710677 -0.70710713 -0.21850812 0.70710677 -0.67249882 -0.41562709 0.70710677 -0.5720616 + -0.57206154 0.70710677 -0.41562706 -0.6724987 0.70710677 -0.21850805 -0.70710695 0.70710677 0 + -0.6724987 0.70710677 0.21850805 -0.57206154 0.70710677 0.415627 -0.415627 0.70710677 0.57206148 + -0.21850805 0.70710677 0.67249858 -2.1073424e-08 0.70710677 0.70710683 0.21850799 0.70710677 0.67249858 + 0.41562691 0.70710677 0.57206142 0.57206142 0.70710677 0.41562697 0.67249852 0.70710677 0.21850802 + 0.70710677 0.70710677 0 0.55901736 0.809017 -0.18163574 0.47552857 0.809017 -0.34549171 + 0.34549171 0.809017 -0.47552854 0.18163572 0.809017 -0.5590173 0 0.809017 -0.58778554 + -0.18163572 0.809017 -0.55901724 -0.34549165 0.809017 -0.47552842 -0.47552839 0.809017 -0.34549159 + -0.55901712 0.809017 -0.18163566 -0.58778536 0.809017 0 -0.55901712 0.809017 0.18163566 + -0.47552836 0.809017 0.34549156 -0.34549156 0.809017 0.47552833 -0.18163566 0.809017 0.55901706 + -1.7517365e-08 0.809017 0.5877853 0.18163562 0.809017 0.55901706 0.3454915 0.809017 0.4755283 + 0.47552827 0.809017 0.34549153 0.559017 0.809017 0.18163563 0.58778524 0.809017 0 + 0.43177092 0.89100653 -0.14029087 0.36728629 0.89100653 -0.2668491 0.2668491 0.89100653 -0.36728626 + 0.14029086 0.89100653 -0.43177086 0 0.89100653 -0.45399073 -0.14029086 0.89100653 -0.43177083 + -0.26684904 0.89100653 -0.36728618 -0.36728615 0.89100653 -0.26684901 -0.43177077 0.89100653 -0.14029081 + -0.45399064 0.89100653 0 -0.43177077 0.89100653 0.14029081 -0.36728612 0.89100653 0.26684898; + setAttr ".vt[332:381]" -0.26684898 0.89100653 0.36728612 -0.14029081 0.89100653 0.43177071 + -1.3529972e-08 0.89100653 0.45399058 0.14029078 0.89100653 0.43177068 0.26684892 0.89100653 0.36728609 + 0.36728606 0.89100653 0.26684895 0.43177065 0.89100653 0.1402908 0.45399052 0.89100653 0 + 0.29389283 0.95105654 -0.095491566 0.25000018 0.95105654 -0.18163574 0.18163574 0.95105654 -0.25000015 + 0.095491551 0.95105654 -0.2938928 0 0.95105654 -0.30901715 -0.095491551 0.95105654 -0.29389277 + -0.18163571 0.95105654 -0.25000009 -0.25000009 0.95105654 -0.18163569 -0.29389271 0.95105654 -0.095491529 + -0.30901706 0.95105654 0 -0.29389271 0.95105654 0.095491529 -0.25000006 0.95105654 0.18163568 + -0.18163568 0.95105654 0.25000006 -0.095491529 0.95105654 0.29389268 -9.2094243e-09 0.95105654 0.30901703 + 0.095491499 0.95105654 0.29389265 0.18163563 0.95105654 0.25000003 0.25 0.95105654 0.18163565 + 0.29389265 0.95105654 0.095491506 0.309017 0.95105654 0 0.14877813 0.98768836 -0.048340943 + 0.12655823 0.98768836 -0.091949932 0.091949932 0.98768836 -0.12655823 0.048340935 0.98768836 -0.14877811 + 0 0.98768836 -0.15643455 -0.048340935 0.98768836 -0.1487781 -0.091949917 0.98768836 -0.1265582 + -0.12655818 0.98768836 -0.091949902 -0.14877807 0.98768836 -0.048340924 -0.15643452 0.98768836 0 + -0.14877807 0.98768836 0.048340924 -0.12655818 0.98768836 0.091949895 -0.091949895 0.98768836 0.12655817 + -0.048340924 0.98768836 0.14877805 -4.6621107e-09 0.98768836 0.15643449 0.048340909 0.98768836 0.14877804 + 0.09194988 0.98768836 0.12655815 0.12655815 0.98768836 0.091949888 0.14877804 0.98768836 0.048340913 + 0.15643448 0.98768836 0 0 -1 0 0 1 0; + setAttr -s 780 ".ed"; + setAttr ".ed[0:165]" 0 1 1 1 2 1 2 3 1 3 4 1 4 5 1 5 6 1 6 7 1 7 8 1 8 9 1 + 9 10 1 10 11 1 11 12 1 12 13 1 13 14 1 14 15 1 15 16 1 16 17 1 17 18 1 18 19 1 19 0 1 + 20 21 1 21 22 1 22 23 1 23 24 1 24 25 1 25 26 1 26 27 1 27 28 1 28 29 1 29 30 1 30 31 1 + 31 32 1 32 33 1 33 34 1 34 35 1 35 36 1 36 37 1 37 38 1 38 39 1 39 20 1 40 41 1 41 42 1 + 42 43 1 43 44 1 44 45 1 45 46 1 46 47 1 47 48 1 48 49 1 49 50 1 50 51 1 51 52 1 52 53 1 + 53 54 1 54 55 1 55 56 1 56 57 1 57 58 1 58 59 1 59 40 1 60 61 1 61 62 1 62 63 1 63 64 1 + 64 65 1 65 66 1 66 67 1 67 68 1 68 69 1 69 70 1 70 71 1 71 72 1 72 73 1 73 74 1 74 75 1 + 75 76 1 76 77 1 77 78 1 78 79 1 79 60 1 80 81 1 81 82 1 82 83 1 83 84 1 84 85 1 85 86 1 + 86 87 1 87 88 1 88 89 1 89 90 1 90 91 1 91 92 1 92 93 1 93 94 1 94 95 1 95 96 1 96 97 1 + 97 98 1 98 99 1 99 80 1 100 101 1 101 102 1 102 103 1 103 104 1 104 105 1 105 106 1 + 106 107 1 107 108 1 108 109 1 109 110 1 110 111 1 111 112 1 112 113 1 113 114 1 114 115 1 + 115 116 1 116 117 1 117 118 1 118 119 1 119 100 1 120 121 1 121 122 1 122 123 1 123 124 1 + 124 125 1 125 126 1 126 127 1 127 128 1 128 129 1 129 130 1 130 131 1 131 132 1 132 133 1 + 133 134 1 134 135 1 135 136 1 136 137 1 137 138 1 138 139 1 139 120 1 140 141 1 141 142 1 + 142 143 1 143 144 1 144 145 1 145 146 1 146 147 1 147 148 1 148 149 1 149 150 1 150 151 1 + 151 152 1 152 153 1 153 154 1 154 155 1 155 156 1 156 157 1 157 158 1 158 159 1 159 140 1 + 160 161 1 161 162 1 162 163 1 163 164 1 164 165 1 165 166 1; + setAttr ".ed[166:331]" 166 167 1 167 168 1 168 169 1 169 170 1 170 171 1 171 172 1 + 172 173 1 173 174 1 174 175 1 175 176 1 176 177 1 177 178 1 178 179 1 179 160 1 180 181 1 + 181 182 1 182 183 1 183 184 1 184 185 1 185 186 1 186 187 1 187 188 1 188 189 1 189 190 1 + 190 191 1 191 192 1 192 193 1 193 194 1 194 195 1 195 196 1 196 197 1 197 198 1 198 199 1 + 199 180 1 200 201 1 201 202 1 202 203 1 203 204 1 204 205 1 205 206 1 206 207 1 207 208 1 + 208 209 1 209 210 1 210 211 1 211 212 1 212 213 1 213 214 1 214 215 1 215 216 1 216 217 1 + 217 218 1 218 219 1 219 200 1 220 221 1 221 222 1 222 223 1 223 224 1 224 225 1 225 226 1 + 226 227 1 227 228 1 228 229 1 229 230 1 230 231 1 231 232 1 232 233 1 233 234 1 234 235 1 + 235 236 1 236 237 1 237 238 1 238 239 1 239 220 1 240 241 1 241 242 1 242 243 1 243 244 1 + 244 245 1 245 246 1 246 247 1 247 248 1 248 249 1 249 250 1 250 251 1 251 252 1 252 253 1 + 253 254 1 254 255 1 255 256 1 256 257 1 257 258 1 258 259 1 259 240 1 260 261 1 261 262 1 + 262 263 1 263 264 1 264 265 1 265 266 1 266 267 1 267 268 1 268 269 1 269 270 1 270 271 1 + 271 272 1 272 273 1 273 274 1 274 275 1 275 276 1 276 277 1 277 278 1 278 279 1 279 260 1 + 280 281 1 281 282 1 282 283 1 283 284 1 284 285 1 285 286 1 286 287 1 287 288 1 288 289 1 + 289 290 1 290 291 1 291 292 1 292 293 1 293 294 1 294 295 1 295 296 1 296 297 1 297 298 1 + 298 299 1 299 280 1 300 301 1 301 302 1 302 303 1 303 304 1 304 305 1 305 306 1 306 307 1 + 307 308 1 308 309 1 309 310 1 310 311 1 311 312 1 312 313 1 313 314 1 314 315 1 315 316 1 + 316 317 1 317 318 1 318 319 1 319 300 1 320 321 1 321 322 1 322 323 1 323 324 1 324 325 1 + 325 326 1 326 327 1 327 328 1 328 329 1 329 330 1 330 331 1 331 332 1; + setAttr ".ed[332:497]" 332 333 1 333 334 1 334 335 1 335 336 1 336 337 1 337 338 1 + 338 339 1 339 320 1 340 341 1 341 342 1 342 343 1 343 344 1 344 345 1 345 346 1 346 347 1 + 347 348 1 348 349 1 349 350 1 350 351 1 351 352 1 352 353 1 353 354 1 354 355 1 355 356 1 + 356 357 1 357 358 1 358 359 1 359 340 1 360 361 1 361 362 1 362 363 1 363 364 1 364 365 1 + 365 366 1 366 367 1 367 368 1 368 369 1 369 370 1 370 371 1 371 372 1 372 373 1 373 374 1 + 374 375 1 375 376 1 376 377 1 377 378 1 378 379 1 379 360 1 0 20 1 1 21 1 2 22 1 + 3 23 1 4 24 1 5 25 1 6 26 1 7 27 1 8 28 1 9 29 1 10 30 1 11 31 1 12 32 1 13 33 1 + 14 34 1 15 35 1 16 36 1 17 37 1 18 38 1 19 39 1 20 40 1 21 41 1 22 42 1 23 43 1 24 44 1 + 25 45 1 26 46 1 27 47 1 28 48 1 29 49 1 30 50 1 31 51 1 32 52 1 33 53 1 34 54 1 35 55 1 + 36 56 1 37 57 1 38 58 1 39 59 1 40 60 1 41 61 1 42 62 1 43 63 1 44 64 1 45 65 1 46 66 1 + 47 67 1 48 68 1 49 69 1 50 70 1 51 71 1 52 72 1 53 73 1 54 74 1 55 75 1 56 76 1 57 77 1 + 58 78 1 59 79 1 60 80 1 61 81 1 62 82 1 63 83 1 64 84 1 65 85 1 66 86 1 67 87 1 68 88 1 + 69 89 1 70 90 1 71 91 1 72 92 1 73 93 1 74 94 1 75 95 1 76 96 1 77 97 1 78 98 1 79 99 1 + 80 100 1 81 101 1 82 102 1 83 103 1 84 104 1 85 105 1 86 106 1 87 107 1 88 108 1 + 89 109 1 90 110 1 91 111 1 92 112 1 93 113 1 94 114 1 95 115 1 96 116 1 97 117 1 + 98 118 1 99 119 1 100 120 1 101 121 1 102 122 1 103 123 1 104 124 1 105 125 1 106 126 1 + 107 127 1 108 128 1 109 129 1 110 130 1 111 131 1 112 132 1 113 133 1 114 134 1 115 135 1 + 116 136 1 117 137 1; + setAttr ".ed[498:663]" 118 138 1 119 139 1 120 140 1 121 141 1 122 142 1 123 143 1 + 124 144 1 125 145 1 126 146 1 127 147 1 128 148 1 129 149 1 130 150 1 131 151 1 132 152 1 + 133 153 1 134 154 1 135 155 1 136 156 1 137 157 1 138 158 1 139 159 1 140 160 1 141 161 1 + 142 162 1 143 163 1 144 164 1 145 165 1 146 166 1 147 167 1 148 168 1 149 169 1 150 170 1 + 151 171 1 152 172 1 153 173 1 154 174 1 155 175 1 156 176 1 157 177 1 158 178 1 159 179 1 + 160 180 1 161 181 1 162 182 1 163 183 1 164 184 1 165 185 1 166 186 1 167 187 1 168 188 1 + 169 189 1 170 190 1 171 191 1 172 192 1 173 193 1 174 194 1 175 195 1 176 196 1 177 197 1 + 178 198 1 179 199 1 180 200 1 181 201 1 182 202 1 183 203 1 184 204 1 185 205 1 186 206 1 + 187 207 1 188 208 1 189 209 1 190 210 1 191 211 1 192 212 1 193 213 1 194 214 1 195 215 1 + 196 216 1 197 217 1 198 218 1 199 219 1 200 220 1 201 221 1 202 222 1 203 223 1 204 224 1 + 205 225 1 206 226 1 207 227 1 208 228 1 209 229 1 210 230 1 211 231 1 212 232 1 213 233 1 + 214 234 1 215 235 1 216 236 1 217 237 1 218 238 1 219 239 1 220 240 1 221 241 1 222 242 1 + 223 243 1 224 244 1 225 245 1 226 246 1 227 247 1 228 248 1 229 249 1 230 250 1 231 251 1 + 232 252 1 233 253 1 234 254 1 235 255 1 236 256 1 237 257 1 238 258 1 239 259 1 240 260 1 + 241 261 1 242 262 1 243 263 1 244 264 1 245 265 1 246 266 1 247 267 1 248 268 1 249 269 1 + 250 270 1 251 271 1 252 272 1 253 273 1 254 274 1 255 275 1 256 276 1 257 277 1 258 278 1 + 259 279 1 260 280 1 261 281 1 262 282 1 263 283 1 264 284 1 265 285 1 266 286 1 267 287 1 + 268 288 1 269 289 1 270 290 1 271 291 1 272 292 1 273 293 1 274 294 1 275 295 1 276 296 1 + 277 297 1 278 298 1 279 299 1 280 300 1 281 301 1 282 302 1 283 303 1; + setAttr ".ed[664:779]" 284 304 1 285 305 1 286 306 1 287 307 1 288 308 1 289 309 1 + 290 310 1 291 311 1 292 312 1 293 313 1 294 314 1 295 315 1 296 316 1 297 317 1 298 318 1 + 299 319 1 300 320 1 301 321 1 302 322 1 303 323 1 304 324 1 305 325 1 306 326 1 307 327 1 + 308 328 1 309 329 1 310 330 1 311 331 1 312 332 1 313 333 1 314 334 1 315 335 1 316 336 1 + 317 337 1 318 338 1 319 339 1 320 340 1 321 341 1 322 342 1 323 343 1 324 344 1 325 345 1 + 326 346 1 327 347 1 328 348 1 329 349 1 330 350 1 331 351 1 332 352 1 333 353 1 334 354 1 + 335 355 1 336 356 1 337 357 1 338 358 1 339 359 1 340 360 1 341 361 1 342 362 1 343 363 1 + 344 364 1 345 365 1 346 366 1 347 367 1 348 368 1 349 369 1 350 370 1 351 371 1 352 372 1 + 353 373 1 354 374 1 355 375 1 356 376 1 357 377 1 358 378 1 359 379 1 380 0 1 380 1 1 + 380 2 1 380 3 1 380 4 1 380 5 1 380 6 1 380 7 1 380 8 1 380 9 1 380 10 1 380 11 1 + 380 12 1 380 13 1 380 14 1 380 15 1 380 16 1 380 17 1 380 18 1 380 19 1 360 381 1 + 361 381 1 362 381 1 363 381 1 364 381 1 365 381 1 366 381 1 367 381 1 368 381 1 369 381 1 + 370 381 1 371 381 1 372 381 1 373 381 1 374 381 1 375 381 1 376 381 1 377 381 1 378 381 1 + 379 381 1; + setAttr -s 400 -ch 1560 ".fc[0:399]" -type "polyFaces" + f 4 0 381 -21 -381 + mu 0 4 0 1 22 21 + f 4 1 382 -22 -382 + mu 0 4 1 2 23 22 + f 4 2 383 -23 -383 + mu 0 4 2 3 24 23 + f 4 3 384 -24 -384 + mu 0 4 3 4 25 24 + f 4 4 385 -25 -385 + mu 0 4 4 5 26 25 + f 4 5 386 -26 -386 + mu 0 4 5 6 27 26 + f 4 6 387 -27 -387 + mu 0 4 6 7 28 27 + f 4 7 388 -28 -388 + mu 0 4 7 8 29 28 + f 4 8 389 -29 -389 + mu 0 4 8 9 30 29 + f 4 9 390 -30 -390 + mu 0 4 9 10 31 30 + f 4 10 391 -31 -391 + mu 0 4 10 11 32 31 + f 4 11 392 -32 -392 + mu 0 4 11 12 33 32 + f 4 12 393 -33 -393 + mu 0 4 12 13 34 33 + f 4 13 394 -34 -394 + mu 0 4 13 14 35 34 + f 4 14 395 -35 -395 + mu 0 4 14 15 36 35 + f 4 15 396 -36 -396 + mu 0 4 15 16 37 36 + f 4 16 397 -37 -397 + mu 0 4 16 17 38 37 + f 4 17 398 -38 -398 + mu 0 4 17 18 39 38 + f 4 18 399 -39 -399 + mu 0 4 18 19 40 39 + f 4 19 380 -40 -400 + mu 0 4 19 20 41 40 + f 4 20 401 -41 -401 + mu 0 4 21 22 43 42 + f 4 21 402 -42 -402 + mu 0 4 22 23 44 43 + f 4 22 403 -43 -403 + mu 0 4 23 24 45 44 + f 4 23 404 -44 -404 + mu 0 4 24 25 46 45 + f 4 24 405 -45 -405 + mu 0 4 25 26 47 46 + f 4 25 406 -46 -406 + mu 0 4 26 27 48 47 + f 4 26 407 -47 -407 + mu 0 4 27 28 49 48 + f 4 27 408 -48 -408 + mu 0 4 28 29 50 49 + f 4 28 409 -49 -409 + mu 0 4 29 30 51 50 + f 4 29 410 -50 -410 + mu 0 4 30 31 52 51 + f 4 30 411 -51 -411 + mu 0 4 31 32 53 52 + f 4 31 412 -52 -412 + mu 0 4 32 33 54 53 + f 4 32 413 -53 -413 + mu 0 4 33 34 55 54 + f 4 33 414 -54 -414 + mu 0 4 34 35 56 55 + f 4 34 415 -55 -415 + mu 0 4 35 36 57 56 + f 4 35 416 -56 -416 + mu 0 4 36 37 58 57 + f 4 36 417 -57 -417 + mu 0 4 37 38 59 58 + f 4 37 418 -58 -418 + mu 0 4 38 39 60 59 + f 4 38 419 -59 -419 + mu 0 4 39 40 61 60 + f 4 39 400 -60 -420 + mu 0 4 40 41 62 61 + f 4 40 421 -61 -421 + mu 0 4 42 43 64 63 + f 4 41 422 -62 -422 + mu 0 4 43 44 65 64 + f 4 42 423 -63 -423 + mu 0 4 44 45 66 65 + f 4 43 424 -64 -424 + mu 0 4 45 46 67 66 + f 4 44 425 -65 -425 + mu 0 4 46 47 68 67 + f 4 45 426 -66 -426 + mu 0 4 47 48 69 68 + f 4 46 427 -67 -427 + mu 0 4 48 49 70 69 + f 4 47 428 -68 -428 + mu 0 4 49 50 71 70 + f 4 48 429 -69 -429 + mu 0 4 50 51 72 71 + f 4 49 430 -70 -430 + mu 0 4 51 52 73 72 + f 4 50 431 -71 -431 + mu 0 4 52 53 74 73 + f 4 51 432 -72 -432 + mu 0 4 53 54 75 74 + f 4 52 433 -73 -433 + mu 0 4 54 55 76 75 + f 4 53 434 -74 -434 + mu 0 4 55 56 77 76 + f 4 54 435 -75 -435 + mu 0 4 56 57 78 77 + f 4 55 436 -76 -436 + mu 0 4 57 58 79 78 + f 4 56 437 -77 -437 + mu 0 4 58 59 80 79 + f 4 57 438 -78 -438 + mu 0 4 59 60 81 80 + f 4 58 439 -79 -439 + mu 0 4 60 61 82 81 + f 4 59 420 -80 -440 + mu 0 4 61 62 83 82 + f 4 60 441 -81 -441 + mu 0 4 63 64 85 84 + f 4 61 442 -82 -442 + mu 0 4 64 65 86 85 + f 4 62 443 -83 -443 + mu 0 4 65 66 87 86 + f 4 63 444 -84 -444 + mu 0 4 66 67 88 87 + f 4 64 445 -85 -445 + mu 0 4 67 68 89 88 + f 4 65 446 -86 -446 + mu 0 4 68 69 90 89 + f 4 66 447 -87 -447 + mu 0 4 69 70 91 90 + f 4 67 448 -88 -448 + mu 0 4 70 71 92 91 + f 4 68 449 -89 -449 + mu 0 4 71 72 93 92 + f 4 69 450 -90 -450 + mu 0 4 72 73 94 93 + f 4 70 451 -91 -451 + mu 0 4 73 74 95 94 + f 4 71 452 -92 -452 + mu 0 4 74 75 96 95 + f 4 72 453 -93 -453 + mu 0 4 75 76 97 96 + f 4 73 454 -94 -454 + mu 0 4 76 77 98 97 + f 4 74 455 -95 -455 + mu 0 4 77 78 99 98 + f 4 75 456 -96 -456 + mu 0 4 78 79 100 99 + f 4 76 457 -97 -457 + mu 0 4 79 80 101 100 + f 4 77 458 -98 -458 + mu 0 4 80 81 102 101 + f 4 78 459 -99 -459 + mu 0 4 81 82 103 102 + f 4 79 440 -100 -460 + mu 0 4 82 83 104 103 + f 4 80 461 -101 -461 + mu 0 4 84 85 106 105 + f 4 81 462 -102 -462 + mu 0 4 85 86 107 106 + f 4 82 463 -103 -463 + mu 0 4 86 87 108 107 + f 4 83 464 -104 -464 + mu 0 4 87 88 109 108 + f 4 84 465 -105 -465 + mu 0 4 88 89 110 109 + f 4 85 466 -106 -466 + mu 0 4 89 90 111 110 + f 4 86 467 -107 -467 + mu 0 4 90 91 112 111 + f 4 87 468 -108 -468 + mu 0 4 91 92 113 112 + f 4 88 469 -109 -469 + mu 0 4 92 93 114 113 + f 4 89 470 -110 -470 + mu 0 4 93 94 115 114 + f 4 90 471 -111 -471 + mu 0 4 94 95 116 115 + f 4 91 472 -112 -472 + mu 0 4 95 96 117 116 + f 4 92 473 -113 -473 + mu 0 4 96 97 118 117 + f 4 93 474 -114 -474 + mu 0 4 97 98 119 118 + f 4 94 475 -115 -475 + mu 0 4 98 99 120 119 + f 4 95 476 -116 -476 + mu 0 4 99 100 121 120 + f 4 96 477 -117 -477 + mu 0 4 100 101 122 121 + f 4 97 478 -118 -478 + mu 0 4 101 102 123 122 + f 4 98 479 -119 -479 + mu 0 4 102 103 124 123 + f 4 99 460 -120 -480 + mu 0 4 103 104 125 124 + f 4 100 481 -121 -481 + mu 0 4 105 106 127 126 + f 4 101 482 -122 -482 + mu 0 4 106 107 128 127 + f 4 102 483 -123 -483 + mu 0 4 107 108 129 128 + f 4 103 484 -124 -484 + mu 0 4 108 109 130 129 + f 4 104 485 -125 -485 + mu 0 4 109 110 131 130 + f 4 105 486 -126 -486 + mu 0 4 110 111 132 131 + f 4 106 487 -127 -487 + mu 0 4 111 112 133 132 + f 4 107 488 -128 -488 + mu 0 4 112 113 134 133 + f 4 108 489 -129 -489 + mu 0 4 113 114 135 134 + f 4 109 490 -130 -490 + mu 0 4 114 115 136 135 + f 4 110 491 -131 -491 + mu 0 4 115 116 137 136 + f 4 111 492 -132 -492 + mu 0 4 116 117 138 137 + f 4 112 493 -133 -493 + mu 0 4 117 118 139 138 + f 4 113 494 -134 -494 + mu 0 4 118 119 140 139 + f 4 114 495 -135 -495 + mu 0 4 119 120 141 140 + f 4 115 496 -136 -496 + mu 0 4 120 121 142 141 + f 4 116 497 -137 -497 + mu 0 4 121 122 143 142 + f 4 117 498 -138 -498 + mu 0 4 122 123 144 143 + f 4 118 499 -139 -499 + mu 0 4 123 124 145 144 + f 4 119 480 -140 -500 + mu 0 4 124 125 146 145 + f 4 120 501 -141 -501 + mu 0 4 126 127 148 147 + f 4 121 502 -142 -502 + mu 0 4 127 128 149 148 + f 4 122 503 -143 -503 + mu 0 4 128 129 150 149 + f 4 123 504 -144 -504 + mu 0 4 129 130 151 150 + f 4 124 505 -145 -505 + mu 0 4 130 131 152 151 + f 4 125 506 -146 -506 + mu 0 4 131 132 153 152 + f 4 126 507 -147 -507 + mu 0 4 132 133 154 153 + f 4 127 508 -148 -508 + mu 0 4 133 134 155 154 + f 4 128 509 -149 -509 + mu 0 4 134 135 156 155 + f 4 129 510 -150 -510 + mu 0 4 135 136 157 156 + f 4 130 511 -151 -511 + mu 0 4 136 137 158 157 + f 4 131 512 -152 -512 + mu 0 4 137 138 159 158 + f 4 132 513 -153 -513 + mu 0 4 138 139 160 159 + f 4 133 514 -154 -514 + mu 0 4 139 140 161 160 + f 4 134 515 -155 -515 + mu 0 4 140 141 162 161 + f 4 135 516 -156 -516 + mu 0 4 141 142 163 162 + f 4 136 517 -157 -517 + mu 0 4 142 143 164 163 + f 4 137 518 -158 -518 + mu 0 4 143 144 165 164 + f 4 138 519 -159 -519 + mu 0 4 144 145 166 165 + f 4 139 500 -160 -520 + mu 0 4 145 146 167 166 + f 4 140 521 -161 -521 + mu 0 4 147 148 169 168 + f 4 141 522 -162 -522 + mu 0 4 148 149 170 169 + f 4 142 523 -163 -523 + mu 0 4 149 150 171 170 + f 4 143 524 -164 -524 + mu 0 4 150 151 172 171 + f 4 144 525 -165 -525 + mu 0 4 151 152 173 172 + f 4 145 526 -166 -526 + mu 0 4 152 153 174 173 + f 4 146 527 -167 -527 + mu 0 4 153 154 175 174 + f 4 147 528 -168 -528 + mu 0 4 154 155 176 175 + f 4 148 529 -169 -529 + mu 0 4 155 156 177 176 + f 4 149 530 -170 -530 + mu 0 4 156 157 178 177 + f 4 150 531 -171 -531 + mu 0 4 157 158 179 178 + f 4 151 532 -172 -532 + mu 0 4 158 159 180 179 + f 4 152 533 -173 -533 + mu 0 4 159 160 181 180 + f 4 153 534 -174 -534 + mu 0 4 160 161 182 181 + f 4 154 535 -175 -535 + mu 0 4 161 162 183 182 + f 4 155 536 -176 -536 + mu 0 4 162 163 184 183 + f 4 156 537 -177 -537 + mu 0 4 163 164 185 184 + f 4 157 538 -178 -538 + mu 0 4 164 165 186 185 + f 4 158 539 -179 -539 + mu 0 4 165 166 187 186 + f 4 159 520 -180 -540 + mu 0 4 166 167 188 187 + f 4 160 541 -181 -541 + mu 0 4 168 169 190 189 + f 4 161 542 -182 -542 + mu 0 4 169 170 191 190 + f 4 162 543 -183 -543 + mu 0 4 170 171 192 191 + f 4 163 544 -184 -544 + mu 0 4 171 172 193 192 + f 4 164 545 -185 -545 + mu 0 4 172 173 194 193 + f 4 165 546 -186 -546 + mu 0 4 173 174 195 194 + f 4 166 547 -187 -547 + mu 0 4 174 175 196 195 + f 4 167 548 -188 -548 + mu 0 4 175 176 197 196 + f 4 168 549 -189 -549 + mu 0 4 176 177 198 197 + f 4 169 550 -190 -550 + mu 0 4 177 178 199 198 + f 4 170 551 -191 -551 + mu 0 4 178 179 200 199 + f 4 171 552 -192 -552 + mu 0 4 179 180 201 200 + f 4 172 553 -193 -553 + mu 0 4 180 181 202 201 + f 4 173 554 -194 -554 + mu 0 4 181 182 203 202 + f 4 174 555 -195 -555 + mu 0 4 182 183 204 203 + f 4 175 556 -196 -556 + mu 0 4 183 184 205 204 + f 4 176 557 -197 -557 + mu 0 4 184 185 206 205 + f 4 177 558 -198 -558 + mu 0 4 185 186 207 206 + f 4 178 559 -199 -559 + mu 0 4 186 187 208 207 + f 4 179 540 -200 -560 + mu 0 4 187 188 209 208 + f 4 180 561 -201 -561 + mu 0 4 189 190 211 210 + f 4 181 562 -202 -562 + mu 0 4 190 191 212 211 + f 4 182 563 -203 -563 + mu 0 4 191 192 213 212 + f 4 183 564 -204 -564 + mu 0 4 192 193 214 213 + f 4 184 565 -205 -565 + mu 0 4 193 194 215 214 + f 4 185 566 -206 -566 + mu 0 4 194 195 216 215 + f 4 186 567 -207 -567 + mu 0 4 195 196 217 216 + f 4 187 568 -208 -568 + mu 0 4 196 197 218 217 + f 4 188 569 -209 -569 + mu 0 4 197 198 219 218 + f 4 189 570 -210 -570 + mu 0 4 198 199 220 219 + f 4 190 571 -211 -571 + mu 0 4 199 200 221 220 + f 4 191 572 -212 -572 + mu 0 4 200 201 222 221 + f 4 192 573 -213 -573 + mu 0 4 201 202 223 222 + f 4 193 574 -214 -574 + mu 0 4 202 203 224 223 + f 4 194 575 -215 -575 + mu 0 4 203 204 225 224 + f 4 195 576 -216 -576 + mu 0 4 204 205 226 225 + f 4 196 577 -217 -577 + mu 0 4 205 206 227 226 + f 4 197 578 -218 -578 + mu 0 4 206 207 228 227 + f 4 198 579 -219 -579 + mu 0 4 207 208 229 228 + f 4 199 560 -220 -580 + mu 0 4 208 209 230 229 + f 4 200 581 -221 -581 + mu 0 4 210 211 232 231 + f 4 201 582 -222 -582 + mu 0 4 211 212 233 232 + f 4 202 583 -223 -583 + mu 0 4 212 213 234 233 + f 4 203 584 -224 -584 + mu 0 4 213 214 235 234 + f 4 204 585 -225 -585 + mu 0 4 214 215 236 235 + f 4 205 586 -226 -586 + mu 0 4 215 216 237 236 + f 4 206 587 -227 -587 + mu 0 4 216 217 238 237 + f 4 207 588 -228 -588 + mu 0 4 217 218 239 238 + f 4 208 589 -229 -589 + mu 0 4 218 219 240 239 + f 4 209 590 -230 -590 + mu 0 4 219 220 241 240 + f 4 210 591 -231 -591 + mu 0 4 220 221 242 241 + f 4 211 592 -232 -592 + mu 0 4 221 222 243 242 + f 4 212 593 -233 -593 + mu 0 4 222 223 244 243 + f 4 213 594 -234 -594 + mu 0 4 223 224 245 244 + f 4 214 595 -235 -595 + mu 0 4 224 225 246 245 + f 4 215 596 -236 -596 + mu 0 4 225 226 247 246 + f 4 216 597 -237 -597 + mu 0 4 226 227 248 247 + f 4 217 598 -238 -598 + mu 0 4 227 228 249 248 + f 4 218 599 -239 -599 + mu 0 4 228 229 250 249 + f 4 219 580 -240 -600 + mu 0 4 229 230 251 250 + f 4 220 601 -241 -601 + mu 0 4 231 232 253 252 + f 4 221 602 -242 -602 + mu 0 4 232 233 254 253 + f 4 222 603 -243 -603 + mu 0 4 233 234 255 254 + f 4 223 604 -244 -604 + mu 0 4 234 235 256 255 + f 4 224 605 -245 -605 + mu 0 4 235 236 257 256 + f 4 225 606 -246 -606 + mu 0 4 236 237 258 257 + f 4 226 607 -247 -607 + mu 0 4 237 238 259 258 + f 4 227 608 -248 -608 + mu 0 4 238 239 260 259 + f 4 228 609 -249 -609 + mu 0 4 239 240 261 260 + f 4 229 610 -250 -610 + mu 0 4 240 241 262 261 + f 4 230 611 -251 -611 + mu 0 4 241 242 263 262 + f 4 231 612 -252 -612 + mu 0 4 242 243 264 263 + f 4 232 613 -253 -613 + mu 0 4 243 244 265 264 + f 4 233 614 -254 -614 + mu 0 4 244 245 266 265 + f 4 234 615 -255 -615 + mu 0 4 245 246 267 266 + f 4 235 616 -256 -616 + mu 0 4 246 247 268 267 + f 4 236 617 -257 -617 + mu 0 4 247 248 269 268 + f 4 237 618 -258 -618 + mu 0 4 248 249 270 269 + f 4 238 619 -259 -619 + mu 0 4 249 250 271 270 + f 4 239 600 -260 -620 + mu 0 4 250 251 272 271 + f 4 240 621 -261 -621 + mu 0 4 252 253 274 273 + f 4 241 622 -262 -622 + mu 0 4 253 254 275 274 + f 4 242 623 -263 -623 + mu 0 4 254 255 276 275 + f 4 243 624 -264 -624 + mu 0 4 255 256 277 276 + f 4 244 625 -265 -625 + mu 0 4 256 257 278 277 + f 4 245 626 -266 -626 + mu 0 4 257 258 279 278 + f 4 246 627 -267 -627 + mu 0 4 258 259 280 279 + f 4 247 628 -268 -628 + mu 0 4 259 260 281 280 + f 4 248 629 -269 -629 + mu 0 4 260 261 282 281 + f 4 249 630 -270 -630 + mu 0 4 261 262 283 282 + f 4 250 631 -271 -631 + mu 0 4 262 263 284 283 + f 4 251 632 -272 -632 + mu 0 4 263 264 285 284 + f 4 252 633 -273 -633 + mu 0 4 264 265 286 285 + f 4 253 634 -274 -634 + mu 0 4 265 266 287 286 + f 4 254 635 -275 -635 + mu 0 4 266 267 288 287 + f 4 255 636 -276 -636 + mu 0 4 267 268 289 288 + f 4 256 637 -277 -637 + mu 0 4 268 269 290 289 + f 4 257 638 -278 -638 + mu 0 4 269 270 291 290 + f 4 258 639 -279 -639 + mu 0 4 270 271 292 291 + f 4 259 620 -280 -640 + mu 0 4 271 272 293 292 + f 4 260 641 -281 -641 + mu 0 4 273 274 295 294 + f 4 261 642 -282 -642 + mu 0 4 274 275 296 295 + f 4 262 643 -283 -643 + mu 0 4 275 276 297 296 + f 4 263 644 -284 -644 + mu 0 4 276 277 298 297 + f 4 264 645 -285 -645 + mu 0 4 277 278 299 298 + f 4 265 646 -286 -646 + mu 0 4 278 279 300 299 + f 4 266 647 -287 -647 + mu 0 4 279 280 301 300 + f 4 267 648 -288 -648 + mu 0 4 280 281 302 301 + f 4 268 649 -289 -649 + mu 0 4 281 282 303 302 + f 4 269 650 -290 -650 + mu 0 4 282 283 304 303 + f 4 270 651 -291 -651 + mu 0 4 283 284 305 304 + f 4 271 652 -292 -652 + mu 0 4 284 285 306 305 + f 4 272 653 -293 -653 + mu 0 4 285 286 307 306 + f 4 273 654 -294 -654 + mu 0 4 286 287 308 307 + f 4 274 655 -295 -655 + mu 0 4 287 288 309 308 + f 4 275 656 -296 -656 + mu 0 4 288 289 310 309 + f 4 276 657 -297 -657 + mu 0 4 289 290 311 310 + f 4 277 658 -298 -658 + mu 0 4 290 291 312 311 + f 4 278 659 -299 -659 + mu 0 4 291 292 313 312 + f 4 279 640 -300 -660 + mu 0 4 292 293 314 313 + f 4 280 661 -301 -661 + mu 0 4 294 295 316 315 + f 4 281 662 -302 -662 + mu 0 4 295 296 317 316 + f 4 282 663 -303 -663 + mu 0 4 296 297 318 317 + f 4 283 664 -304 -664 + mu 0 4 297 298 319 318 + f 4 284 665 -305 -665 + mu 0 4 298 299 320 319 + f 4 285 666 -306 -666 + mu 0 4 299 300 321 320 + f 4 286 667 -307 -667 + mu 0 4 300 301 322 321 + f 4 287 668 -308 -668 + mu 0 4 301 302 323 322 + f 4 288 669 -309 -669 + mu 0 4 302 303 324 323 + f 4 289 670 -310 -670 + mu 0 4 303 304 325 324 + f 4 290 671 -311 -671 + mu 0 4 304 305 326 325 + f 4 291 672 -312 -672 + mu 0 4 305 306 327 326 + f 4 292 673 -313 -673 + mu 0 4 306 307 328 327 + f 4 293 674 -314 -674 + mu 0 4 307 308 329 328 + f 4 294 675 -315 -675 + mu 0 4 308 309 330 329 + f 4 295 676 -316 -676 + mu 0 4 309 310 331 330 + f 4 296 677 -317 -677 + mu 0 4 310 311 332 331 + f 4 297 678 -318 -678 + mu 0 4 311 312 333 332 + f 4 298 679 -319 -679 + mu 0 4 312 313 334 333 + f 4 299 660 -320 -680 + mu 0 4 313 314 335 334 + f 4 300 681 -321 -681 + mu 0 4 315 316 337 336 + f 4 301 682 -322 -682 + mu 0 4 316 317 338 337 + f 4 302 683 -323 -683 + mu 0 4 317 318 339 338 + f 4 303 684 -324 -684 + mu 0 4 318 319 340 339 + f 4 304 685 -325 -685 + mu 0 4 319 320 341 340 + f 4 305 686 -326 -686 + mu 0 4 320 321 342 341 + f 4 306 687 -327 -687 + mu 0 4 321 322 343 342 + f 4 307 688 -328 -688 + mu 0 4 322 323 344 343 + f 4 308 689 -329 -689 + mu 0 4 323 324 345 344 + f 4 309 690 -330 -690 + mu 0 4 324 325 346 345 + f 4 310 691 -331 -691 + mu 0 4 325 326 347 346 + f 4 311 692 -332 -692 + mu 0 4 326 327 348 347 + f 4 312 693 -333 -693 + mu 0 4 327 328 349 348 + f 4 313 694 -334 -694 + mu 0 4 328 329 350 349 + f 4 314 695 -335 -695 + mu 0 4 329 330 351 350 + f 4 315 696 -336 -696 + mu 0 4 330 331 352 351 + f 4 316 697 -337 -697 + mu 0 4 331 332 353 352 + f 4 317 698 -338 -698 + mu 0 4 332 333 354 353 + f 4 318 699 -339 -699 + mu 0 4 333 334 355 354 + f 4 319 680 -340 -700 + mu 0 4 334 335 356 355 + f 4 320 701 -341 -701 + mu 0 4 336 337 358 357 + f 4 321 702 -342 -702 + mu 0 4 337 338 359 358 + f 4 322 703 -343 -703 + mu 0 4 338 339 360 359 + f 4 323 704 -344 -704 + mu 0 4 339 340 361 360 + f 4 324 705 -345 -705 + mu 0 4 340 341 362 361 + f 4 325 706 -346 -706 + mu 0 4 341 342 363 362 + f 4 326 707 -347 -707 + mu 0 4 342 343 364 363 + f 4 327 708 -348 -708 + mu 0 4 343 344 365 364 + f 4 328 709 -349 -709 + mu 0 4 344 345 366 365 + f 4 329 710 -350 -710 + mu 0 4 345 346 367 366 + f 4 330 711 -351 -711 + mu 0 4 346 347 368 367 + f 4 331 712 -352 -712 + mu 0 4 347 348 369 368 + f 4 332 713 -353 -713 + mu 0 4 348 349 370 369 + f 4 333 714 -354 -714 + mu 0 4 349 350 371 370 + f 4 334 715 -355 -715 + mu 0 4 350 351 372 371 + f 4 335 716 -356 -716 + mu 0 4 351 352 373 372 + f 4 336 717 -357 -717 + mu 0 4 352 353 374 373 + f 4 337 718 -358 -718 + mu 0 4 353 354 375 374 + f 4 338 719 -359 -719 + mu 0 4 354 355 376 375 + f 4 339 700 -360 -720 + mu 0 4 355 356 377 376 + f 4 340 721 -361 -721 + mu 0 4 357 358 379 378 + f 4 341 722 -362 -722 + mu 0 4 358 359 380 379 + f 4 342 723 -363 -723 + mu 0 4 359 360 381 380 + f 4 343 724 -364 -724 + mu 0 4 360 361 382 381 + f 4 344 725 -365 -725 + mu 0 4 361 362 383 382 + f 4 345 726 -366 -726 + mu 0 4 362 363 384 383 + f 4 346 727 -367 -727 + mu 0 4 363 364 385 384 + f 4 347 728 -368 -728 + mu 0 4 364 365 386 385 + f 4 348 729 -369 -729 + mu 0 4 365 366 387 386 + f 4 349 730 -370 -730 + mu 0 4 366 367 388 387 + f 4 350 731 -371 -731 + mu 0 4 367 368 389 388 + f 4 351 732 -372 -732 + mu 0 4 368 369 390 389 + f 4 352 733 -373 -733 + mu 0 4 369 370 391 390 + f 4 353 734 -374 -734 + mu 0 4 370 371 392 391 + f 4 354 735 -375 -735 + mu 0 4 371 372 393 392 + f 4 355 736 -376 -736 + mu 0 4 372 373 394 393 + f 4 356 737 -377 -737 + mu 0 4 373 374 395 394 + f 4 357 738 -378 -738 + mu 0 4 374 375 396 395 + f 4 358 739 -379 -739 + mu 0 4 375 376 397 396 + f 4 359 720 -380 -740 + mu 0 4 376 377 398 397 + f 3 -1 -741 741 + mu 0 3 1 0 399 + f 3 -2 -742 742 + mu 0 3 2 1 400 + f 3 -3 -743 743 + mu 0 3 3 2 401 + f 3 -4 -744 744 + mu 0 3 4 3 402 + f 3 -5 -745 745 + mu 0 3 5 4 403 + f 3 -6 -746 746 + mu 0 3 6 5 404 + f 3 -7 -747 747 + mu 0 3 7 6 405 + f 3 -8 -748 748 + mu 0 3 8 7 406 + f 3 -9 -749 749 + mu 0 3 9 8 407 + f 3 -10 -750 750 + mu 0 3 10 9 408 + f 3 -11 -751 751 + mu 0 3 11 10 409 + f 3 -12 -752 752 + mu 0 3 12 11 410 + f 3 -13 -753 753 + mu 0 3 13 12 411 + f 3 -14 -754 754 + mu 0 3 14 13 412 + f 3 -15 -755 755 + mu 0 3 15 14 413 + f 3 -16 -756 756 + mu 0 3 16 15 414 + f 3 -17 -757 757 + mu 0 3 17 16 415 + f 3 -18 -758 758 + mu 0 3 18 17 416 + f 3 -19 -759 759 + mu 0 3 19 18 417 + f 3 -20 -760 740 + mu 0 3 20 19 418 + f 3 360 761 -761 + mu 0 3 378 379 419 + f 3 361 762 -762 + mu 0 3 379 380 420 + f 3 362 763 -763 + mu 0 3 380 381 421 + f 3 363 764 -764 + mu 0 3 381 382 422 + f 3 364 765 -765 + mu 0 3 382 383 423 + f 3 365 766 -766 + mu 0 3 383 384 424 + f 3 366 767 -767 + mu 0 3 384 385 425 + f 3 367 768 -768 + mu 0 3 385 386 426 + f 3 368 769 -769 + mu 0 3 386 387 427 + f 3 369 770 -770 + mu 0 3 387 388 428 + f 3 370 771 -771 + mu 0 3 388 389 429 + f 3 371 772 -772 + mu 0 3 389 390 430 + f 3 372 773 -773 + mu 0 3 390 391 431 + f 3 373 774 -774 + mu 0 3 391 392 432 + f 3 374 775 -775 + mu 0 3 392 393 433 + f 3 375 776 -776 + mu 0 3 393 394 434 + f 3 376 777 -777 + mu 0 3 394 395 435 + f 3 377 778 -778 + mu 0 3 395 396 436 + f 3 378 779 -779 + mu 0 3 396 397 437 + f 3 379 760 -780 + mu 0 3 397 398 438; + setAttr ".cd" -type "dataPolyComponent" Index_Data Edge 0 ; + setAttr ".cvd" -type "dataPolyComponent" Index_Data Vertex 0 ; + setAttr ".pd[0]" -type "dataPolyComponent" Index_Data UV 0 ; + setAttr ".hfd" -type "dataPolyComponent" Index_Data Face 0 ; + setAttr ".dr" 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:bf8e49bb98ec"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 4 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; +select -ne :initialShadingGroup; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + setAttr ".ren" -type "string" "arnold"; + setAttr ".fs" 1001; + setAttr ".ef" 1001; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +// End of modelMain.ma diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/workfile/workfileTest_task/v001/test_project_test_asset_workfileTest_task_v001.ma b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/workfile/workfileTest_task/v001/test_project_test_asset_workfileTest_task_v001.ma new file mode 100644 index 0000000000..e438d80d5f --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/publish/workfile/workfileTest_task/v001/test_project_test_asset_workfileTest_task_v001.ma @@ -0,0 +1,476 @@ +//Maya ASCII 2022 scene +//Name: test_project_test_asset_test_task_v001.ma +//Last modified: Thu, Sep 14, 2023 06:31:00 PM +//Codeset: 1252 +requires maya "2022"; +requires -nodeType "polyDisc" "modelingToolkit" "0.0.0.0"; +requires "stereoCamera" "10.0"; +requires -nodeType "aiOptions" -nodeType "aiAOVDriver" -nodeType "aiAOVFilter" "mtoa" "5.2.2.1"; +requires -nodeType "simpleSelector" -nodeType "renderSetupLayer" -nodeType "renderSetup" + -nodeType "collection" "renderSetup.py" "1.0"; +requires "stereoCamera" "10.0"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2022"; +fileInfo "version" "2022"; +fileInfo "cutIdentifier" "202205171752-c25c06f306"; +fileInfo "osv" "Windows 10 Pro v2009 (Build: 19044)"; +fileInfo "license" "education"; +fileInfo "UUID" "019C7F50-40EF-1435-E27F-729F64685E67"; +fileInfo "OpenPypeContext" "eyJwdWJsaXNoX2F0dHJpYnV0ZXMiOiB7IlZhbGlkYXRlQ29udGFpbmVycyI6IHsiYWN0aXZlIjogdHJ1ZX19fQ=="; +createNode transform -s -n "persp"; + rename -uid "D52C935B-47C9-D868-A875-D799DD17B3A1"; + setAttr ".v" no; + setAttr ".t" -type "double3" 26.953352922736947 24.362683487437064 26.983403389430531 ; + setAttr ".r" -type "double3" 1064.7698975365424 54.173034578109736 1075.1660763544442 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -s -n "perspShape" -p "persp"; + rename -uid "2399E6C0-490F-BA1F-485F-5AA8A01D27BC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 46.895362757145833; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -s -n "top"; + rename -uid "415C7426-413E-0FAE-FCC3-3DAED7443A52"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 1000.1 0 ; + setAttr ".r" -type "double3" 90 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" 0 -1000.1 1000.1 ; +createNode camera -s -n "topShape" -p "top"; + rename -uid "3BD0CF60-40DB-5278-5D8B-06ACBDA32122"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "top"; + setAttr ".den" -type "string" "top_depth"; + setAttr ".man" -type "string" "top_mask"; + setAttr ".hc" -type "string" "viewSet -t %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "front"; + rename -uid "D83DD5CE-4FE0-AB1B-81B2-87A63F0B7A05"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 0 1000.1 ; + setAttr ".r" -type "double3" 180 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; +createNode camera -s -n "frontShape" -p "front"; + rename -uid "23313CBA-42C2-0B3A-0FCF-EA965EAC5DEC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "front"; + setAttr ".den" -type "string" "front_depth"; + setAttr ".man" -type "string" "front_mask"; + setAttr ".hc" -type "string" "viewSet -f %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "side"; + rename -uid "F70F692C-4A0D-BE64-9EE4-A99B6FA2D56E"; + setAttr ".v" no; + setAttr ".t" -type "double3" 1000.1 0 0 ; + setAttr ".r" -type "double3" 180 -90 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" -1000.1 0 1000.1 ; +createNode camera -s -n "sideShape" -p "side"; + rename -uid "C05669C3-420E-CA11-E5FC-7EB64EF8B632"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "side"; + setAttr ".den" -type "string" "side_depth"; + setAttr ".man" -type "string" "side_mask"; + setAttr ".hc" -type "string" "viewSet -s %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -n "pSphere1_GEO"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:440654b3dfe4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:bf8e49bb98ec"; +createNode transform -n "pDisc1"; + rename -uid "DED70CCF-4C19-16E4-9E5D-66A05037BA47"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:90e762703f08"; +createNode mesh -n "pDiscShape1" -p "pDisc1"; + rename -uid "E1FCDCCF-4DE1-D3B9-C4F8-3285F1CF5B25"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:4ee3da11a1a4"; +createNode transform -n "persp1"; + rename -uid "292F1351-4E41-A890-D6D5-A5A4F7D94C76"; + setAttr ".t" -type "double3" 3.7889010960863949 2.8416759114717678 3.7889010364817537 ; + setAttr ".r" -type "double3" -27.938352729602379 44.999999999999972 -5.172681101354183e-14 ; +createNode camera -n "perspShape1" -p "persp1"; + rename -uid "9277418C-43C8-5064-A7C6-64AC829A76F2"; + setAttr -k off ".v"; + setAttr ".ovr" 1.3; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 6.0652013012246453; + setAttr ".imn" -type "string" "persp1"; + setAttr ".den" -type "string" "persp1_depth"; + setAttr ".man" -type "string" "persp1_mask"; + setAttr ".tp" -type "double3" -1.1920928955078125e-07 0 -1.7881393432617188e-07 ; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".dr" yes; +createNode lightLinker -s -n "lightLinker1"; + rename -uid "6B9ADCD4-41B9-5BCC-826D-4A874A278510"; + setAttr -s 2 ".lnk"; + setAttr -s 2 ".slnk"; +createNode shapeEditorManager -n "shapeEditorManager"; + rename -uid "5B4518C5-46C9-6921-690E-EFAF77B21A36"; +createNode poseInterpolatorManager -n "poseInterpolatorManager"; + rename -uid "8518F293-4F06-BFF2-647A-72A099FBF025"; +createNode displayLayerManager -n "layerManager"; + rename -uid "04D880D5-4D86-0C58-CA3D-208ABE3E1E16"; +createNode displayLayer -n "defaultLayer"; + rename -uid "4A776D1B-401F-7069-1C74-A7AAE84CEE03"; +createNode renderLayerManager -n "renderLayerManager"; + rename -uid "B719B8BE-46BF-12E6-BEBA-B0AD4DBDBA87"; + setAttr -s 2 ".rlmi[1]" 1; + setAttr -s 2 ".rlmi"; +createNode renderLayer -n "defaultRenderLayer"; + rename -uid "B134920D-4508-23BD-A6CA-11B43DE03F53"; + setAttr ".g" yes; +createNode renderSetup -n "renderSetup"; + rename -uid "9A8F0D15-41AB-CA70-C2D8-B78840BF9BC1"; +createNode polySphere -n "polySphere1"; + rename -uid "DA319706-4ACF-B15C-53B2-48AC80D202EA"; +createNode objectSet -n "modelMain"; + rename -uid "A76AD4F8-4CF5-AA0D-4E98-BABEE6454CC3"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "writeColorSets" -ln "writeColorSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "writeFaceSets" -ln "writeFaceSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "includeParentHierarchy" -ln "includeParentHierarchy" -min + 0 -max 1 -at "bool"; + addAttr -ci true -sn "attr" -ln "attr" -dt "string"; + 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_keys" -ln "__creator_attributes_keys" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:7364ea6776c9"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "model"; + setAttr ".subset" -type "string" "modelMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.model"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "6889d3db-b813-43db-96de-9ba555dc4472"; + setAttr -cb on ".writeColorSets"; + setAttr -cb on ".writeFaceSets"; + setAttr -cb on ".includeParentHierarchy"; + setAttr ".attr" -type "string" ""; + setAttr ".attrPrefix" -type "string" ""; + setAttr ".publish_attributes" -type "string" "{\"ValidateNodeIDsRelated\": {\"active\": true}, \"ValidateTransformNamingSuffix\": {\"active\": true}, \"ValidateColorSets\": {\"active\": true}, \"ValidateMeshArnoldAttributes\": {\"active\": true}, \"ValidateMeshHasUVs\": {\"active\": true}, \"ValidateMeshNonZeroEdgeLength\": {\"active\": true}, \"ExtractModel\": {\"active\": true}}"; + setAttr ".__creator_attributes_keys" -type "string" "writeColorSets,writeFaceSets,includeParentHierarchy,attr,attrPrefix"; +createNode script -n "uiConfigurationScriptNode"; + rename -uid "4B7AFB53-452E-E870-63E1-CCA1DD6EAF13"; + setAttr ".b" -type "string" ( + "// Maya Mel UI Configuration File.\n//\n// This script is machine generated. Edit at your own risk.\n//\n//\n\nglobal string $gMainPane;\nif (`paneLayout -exists $gMainPane`) {\n\n\tglobal int $gUseScenePanelConfig;\n\tint $useSceneConfig = $gUseScenePanelConfig;\n\tint $nodeEditorPanelVisible = stringArrayContains(\"nodeEditorPanel1\", `getPanel -vis`);\n\tint $nodeEditorWorkspaceControlOpen = (`workspaceControl -exists nodeEditorPanel1Window` && `workspaceControl -q -visible nodeEditorPanel1Window`);\n\tint $menusOkayInPanels = `optionVar -q allowMenusInPanels`;\n\tint $nVisPanes = `paneLayout -q -nvp $gMainPane`;\n\tint $nPanes = 0;\n\tstring $editorName;\n\tstring $panelName;\n\tstring $itemFilterName;\n\tstring $panelConfig;\n\n\t//\n\t// get current state of the UI\n\t//\n\tsceneUIReplacement -update $gMainPane;\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Top View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Top View\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|top\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n" + + " -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n" + + " -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n" + + "\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Side View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Side View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|side\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n" + + " -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n" + + " -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n" + + " -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Front View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Front View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n" + + " -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n" + + " -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n" + + " -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Persp View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Persp View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|persp\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n" + + " -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n" + + " -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n" + + " -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1312\n -height 732\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"ToggledOutliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"ToggledOutliner\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n outlinerEditor -e \n -docTag \"isolOutln_fromSeln\" \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 1\n -showReferenceMembers 1\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n" + + " -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -isSet 0\n -isSetMember 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -selectCommand \"print(\\\"\\\")\" \n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 1\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n -renderFilterIndex 0\n" + + " -selectionOrder \"chronological\" \n -expandAttribute 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"Outliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"Outliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n" + + " -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n" + + " -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"graphEditor\" (localizedPanelLabel(\"Graph Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Graph Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n" + + " -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 1\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 0\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 1\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 1\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n" + + " -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 1\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"GraphEd\");\n animCurveEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -showPlayRangeShades \"on\" \n -lockPlayRangeShades \"off\" \n -smoothness \"fine\" \n -resultSamples 1.041667\n" + + " -resultScreenSamples 0\n -resultUpdate \"delayed\" \n -showUpstreamCurves 1\n -keyMinScale 1\n -stackedCurvesMin -1\n -stackedCurvesMax 1\n -stackedCurvesSpace 0.2\n -preSelectionHighlight 0\n -constrainDrag 0\n -valueLinesToggle 1\n -highlightAffectedCurves 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dopeSheetPanel\" (localizedPanelLabel(\"Dope Sheet\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dope Sheet\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n" + + " -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 0\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 1\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"0\" \n -showSetMembers 0\n" + + " -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"DopeSheetEd\");\n dopeSheetEditor -e \n -displayValues 0\n -snapTime \"integer\" \n" + + " -snapValue \"none\" \n -outliner \"dopeSheetPanel1OutlineEd\" \n -showSummary 1\n -showScene 0\n -hierarchyBelow 0\n -showTicks 1\n -selectionWindow 0 0 0 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"timeEditorPanel\" (localizedPanelLabel(\"Time Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Time Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"clipEditorPanel\" (localizedPanelLabel(\"Trax Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Trax Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = clipEditorNameFromPanel($panelName);\n" + + " clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"sequenceEditorPanel\" (localizedPanelLabel(\"Camera Sequencer\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Camera Sequencer\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = sequenceEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperGraphPanel\" (localizedPanelLabel(\"Hypergraph Hierarchy\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypergraph Hierarchy\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"HyperGraphEd\");\n hyperGraph -e \n -graphLayoutStyle \"hierarchicalLayout\" \n -orientation \"horiz\" \n -mergeConnections 0\n -zoom 1\n -animateTransition 0\n -showRelationships 1\n -showShapes 0\n -showDeformers 0\n -showExpressions 0\n -showConstraints 0\n -showConnectionFromSelected 0\n -showConnectionToSelected 0\n -showConstraintLabels 0\n -showUnderworld 0\n -showInvisible 0\n -transitionFrames 1\n -opaqueContainers 0\n -freeform 0\n -imagePosition 0 0 \n -imageScale 1\n -imageEnabled 0\n -graphType \"DAG\" \n" + + " -heatMapDisplay 0\n -updateSelection 1\n -updateNodeAdded 1\n -useDrawOverrideColor 0\n -limitGraphTraversal -1\n -range 0 0 \n -iconSize \"smallIcons\" \n -showCachedConnections 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperShadePanel\" (localizedPanelLabel(\"Hypershade\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypershade\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"visorPanel\" (localizedPanelLabel(\"Visor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Visor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n" + + "\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"nodeEditorPanel\" (localizedPanelLabel(\"Node Editor\")) `;\n\tif ($nodeEditorPanelVisible || $nodeEditorWorkspaceControlOpen) {\n\t\tif (\"\" == $panelName) {\n\t\t\tif ($useSceneConfig) {\n\t\t\t\t$panelName = `scriptedPanel -unParent -type \"nodeEditorPanel\" -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels `;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 0\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n" + + " -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\t}\n\t\t} else {\n\t\t\t$label = `panel -q -label $panelName`;\n\t\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n" + + " -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 0\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\tif (!$useSceneConfig) {\n\t\t\t\tpanel -e -l $label $panelName;\n\t\t\t}\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"createNodePanel\" (localizedPanelLabel(\"Create Node\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Create Node\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"polyTexturePlacementPanel\" (localizedPanelLabel(\"UV Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"UV Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"renderWindowPanel\" (localizedPanelLabel(\"Render View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Render View\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"shapePanel\" (localizedPanelLabel(\"Shape Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tshapePanel -edit -l (localizedPanelLabel(\"Shape Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"posePanel\" (localizedPanelLabel(\"Pose Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tposePanel -edit -l (localizedPanelLabel(\"Pose Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynRelEdPanel\" (localizedPanelLabel(\"Dynamic Relationships\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dynamic Relationships\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"relationshipPanel\" (localizedPanelLabel(\"Relationship Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Relationship Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"referenceEditorPanel\" (localizedPanelLabel(\"Reference Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Reference Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"componentEditorPanel\" (localizedPanelLabel(\"Component Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Component Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynPaintScriptedPanelType\" (localizedPanelLabel(\"Paint Effects\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Paint Effects\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"scriptEditorPanel\" (localizedPanelLabel(\"Script Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Script Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"profilerPanel\" (localizedPanelLabel(\"Profiler Tool\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Profiler Tool\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"contentBrowserPanel\" (localizedPanelLabel(\"Content Browser\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Content Browser\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"Stereo\" (localizedPanelLabel(\"Stereo\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Stereo\")) -mbv $menusOkayInPanels $panelName;\n{ string $editorName = ($panelName+\"Editor\");\n stereoCameraView -e \n -camera \"|persp\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"wireframe\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 1\n" + + " -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 4 4 \n -bumpResolution 4 4 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n" + + " -lowQualityLighting 0\n -maximumNumHardwareLights 0\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n" + + " -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 0\n -height 0\n -sceneRenderFilter 0\n -displayMode \"centerEye\" \n -viewColor 0 0 0 1 \n -useCustomBackground 1\n $editorName;\n stereoCameraView -e -viewSelected 0 $editorName;\n stereoCameraView -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName; };\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\tif ($useSceneConfig) {\n string $configName = `getPanel -cwl (localizedPanelLabel(\"Current Layout\"))`;\n" + + " if (\"\" != $configName) {\n\t\t\tpanelConfiguration -edit -label (localizedPanelLabel(\"Current Layout\")) \n\t\t\t\t-userCreated false\n\t\t\t\t-defaultImage \"vacantCell.xP:/\"\n\t\t\t\t-image \"\"\n\t\t\t\t-sc false\n\t\t\t\t-configString \"global string $gMainPane; paneLayout -e -cn \\\"single\\\" -ps 1 100 100 $gMainPane;\"\n\t\t\t\t-removeAllPanels\n\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Persp View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera persp` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 16384\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -greasePencils 1\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 1312\\n -height 732\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName;\\nmodelEditor -e \\n -pluginObjects \\\"gpuCacheDisplayFilter\\\" 1 \\n $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera persp` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 16384\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -greasePencils 1\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 1312\\n -height 732\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName;\\nmodelEditor -e \\n -pluginObjects \\\"gpuCacheDisplayFilter\\\" 1 \\n $editorName\"\n" + + "\t\t\t\t$configName;\n\n setNamedPanelLayout (localizedPanelLabel(\"Current Layout\"));\n }\n\n panelHistory -e -clear mainPanelHistory;\n sceneUIReplacement -clear;\n\t}\n\n\ngrid -spacing 5 -size 12 -divisions 5 -displayAxes yes -displayGridLines yes -displayDivisionLines yes -displayPerspectiveLabels no -displayOrthographicLabels no -displayAxesBold yes -perspectiveLabelPosition axis -orthographicLabelPosition edge;\nviewManip -drawCompass 0 -compassAngle 0 -frontParameters \"\" -homeParameters \"\" -selectionLockParameters \"\";\n}\n"); + setAttr ".st" 3; +createNode script -n "sceneConfigurationScriptNode"; + rename -uid "72B19BC2-43A2-E229-0A73-2CB861A291D1"; + setAttr ".b" -type "string" "playbackOptions -min 1000 -max 1001 -ast 1000 -aet 1001 "; + setAttr ".st" 6; +createNode polyDisc -n "polyDisc1"; + rename -uid "9ED8A7BD-4FFD-6107-4322-35ACD1D3AC42"; +createNode aiOptions -s -n "defaultArnoldRenderOptions"; + rename -uid "51BB3D7A-4C7D-FCDD-1B21-D89934107F98"; + setAttr ".skip_license_check" yes; +createNode aiAOVFilter -s -n "defaultArnoldFilter"; + rename -uid "989A5992-46C8-0CB2-B2B8-4E868F31FEA8"; + setAttr ".ai_translator" -type "string" "gaussian"; +createNode aiAOVDriver -s -n "defaultArnoldDriver"; + rename -uid "B8469AD8-47C8-9EF1-FE5E-5AB50ABC1536"; + setAttr ".merge_AOVs" yes; + setAttr ".ai_translator" -type "string" "exr"; +createNode aiAOVDriver -s -n "defaultArnoldDisplayDriver"; + rename -uid "21D4F4CD-4D78-001C-610D-798402CD7CF0"; + setAttr ".output_mode" 0; + setAttr ".ai_translator" -type "string" "maya"; +createNode objectSet -n "workfileMain"; + rename -uid "3C9B5D6F-4579-8E3B-5B7D-4C88865A1C68"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string"; + addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".hio" yes; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:30d256dac64c"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "workfile"; + setAttr ".subset" -type "string" "workfileTest_task"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.workfile"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "911dc92a-ad29-41e5-bbf9-733d56174fb9"; + setAttr ".publish_attributes" -type "string" "{\"ExtractImportReference\": {\"active\": false}}"; + setAttr ".__creator_attributes_keys" -type "string" ""; +createNode objectSet -n "renderingMain"; + rename -uid "8A999C2F-4922-B15D-8D5C-45A16465B69F"; + addAttr -ci true -sn "pre_creator_identifier" -ln "pre_creator_identifier" -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".pre_creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:042447475732"; +createNode renderSetupLayer -n "Main"; + rename -uid "2202E438-4CEF-F64E-737C-F48C65E31126"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode renderLayer -n "rs_Main"; + rename -uid "DF0259B1-4F96-DA7E-C74F-B599FBE15C18"; + setAttr ".do" 1; +createNode collection -n "defaultCollection"; + rename -uid "E5014237-4DA9-6FC0-633D-69AFA56161B3"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode simpleSelector -n "defaultCollectionSelector"; + rename -uid "7CA2F6D8-483C-B020-BC03-EF9563A52163"; + setAttr ".pat" -type "string" "*"; +createNode objectSet -n "_renderingMain:Main"; + rename -uid "1EEC3A3B-49CF-0C79-5340-39805174FB8A"; + addAttr -s false -ci true -sn "renderlayer" -ln "renderlayer" -at "message"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "review" -ln "review" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "extendFrames" -ln "extendFrames" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "overrideExistingFrame" -ln "overrideExistingFrame" -min 0 + -max 1 -at "bool"; + addAttr -ci true -sn "tileRendering" -ln "tileRendering" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "tilesX" -ln "tilesX" -at "long"; + addAttr -ci true -sn "tilesY" -ln "tilesY" -at "long"; + addAttr -ci true -sn "convertToScanline" -ln "convertToScanline" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "useReferencedAovs" -ln "useReferencedAovs" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "renderSetupIncludeLights" -ln "renderSetupIncludeLights" -min + 0 -max 1 -at "bool"; + addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string"; + addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys" + -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "renderlayer"; + setAttr ".subset" -type "string" "renderMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "8a9cfb85-9602-4e5e-a4bc-27a2986bae7f"; + setAttr -cb on ".review" yes; + setAttr -cb on ".extendFrames"; + setAttr -cb on ".overrideExistingFrame" yes; + setAttr -cb on ".tileRendering"; + setAttr -cb on ".tilesX" 2; + setAttr -cb on ".tilesY" 2; + setAttr -cb on ".convertToScanline"; + setAttr -cb on ".useReferencedAovs"; + setAttr -cb on ".renderSetupIncludeLights" yes; + setAttr ".publish_attributes" -type "string" "{\"CollectDeadlinePools\": {\"primaryPool\": \"\", \"secondaryPool\": \"\"}, \"ValidateDeadlinePools\": {\"active\": true}, \"ValidateFrameRange\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}, \"MayaSubmitDeadline\": {\"priority\": 50, \"chunkSize\": 1, \"machineList\": \"\", \"whitelist\": false, \"tile_priority\": 50, \"strict_error_checking\": true}, \"ProcessSubmittedJobOnFarm\": {\"publishJobState\": \"Active\"}}"; + setAttr ".__creator_attributes_keys" -type "string" "review,extendFrames,overrideExistingFrame,tileRendering,tilesX,tilesY,convertToScanline,useReferencedAovs,renderSetupIncludeLights"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:69960f336351"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :standardSurface1; + setAttr ".b" 0.80000001192092896; + setAttr ".bc" -type "float3" 1 1 1 ; + setAttr ".s" 0.20000000298023224; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".al" yes; + setAttr ".dar" 1.6666666269302368; +select -ne :defaultColorMgtGlobals; + setAttr ".cfe" yes; + setAttr ".cfp" -type "string" "/OCIO-configs/Maya-legacy/config.ocio"; + setAttr ".vtn" -type "string" "sRGB gamma (legacy)"; + setAttr ".vn" -type "string" "sRGB gamma"; + setAttr ".dn" -type "string" "legacy"; + setAttr ".wsn" -type "string" "scene-linear Rec 709/sRGB"; + setAttr ".ovt" no; + setAttr ".povt" no; + setAttr ".otn" -type "string" "sRGB gamma (legacy)"; + setAttr ".potn" -type "string" "sRGB gamma (legacy)"; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +select -ne :ikSystem; + setAttr -s 4 ".sol"; +connectAttr "rs_Main.ri" ":persp.rlio[0]"; +connectAttr "rs_Main.ri" ":top.rlio[0]"; +connectAttr "rs_Main.ri" ":front.rlio[0]"; +connectAttr "rs_Main.ri" ":side.rlio[0]"; +connectAttr "rs_Main.ri" "pSphere1_GEO.rlio[0]"; +connectAttr "polySphere1.out" "pSphere1_GEOShape1.i"; +connectAttr "rs_Main.ri" "pDisc1.rlio[0]"; +connectAttr "polyDisc1.output" "pDiscShape1.i"; +connectAttr "rs_Main.ri" "persp1.rlio[0]"; +relationship "link" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "link" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +connectAttr "layerManager.dli[0]" "defaultLayer.id"; +connectAttr "renderLayerManager.rlmi[0]" "defaultRenderLayer.rlid"; +connectAttr "Main.msg" "renderSetup.frl"; +connectAttr "Main.msg" "renderSetup.lrl"; +connectAttr "pSphere1_GEO.iog" "modelMain.dsm" -na; +connectAttr ":defaultArnoldDisplayDriver.msg" ":defaultArnoldRenderOptions.drivers" + -na; +connectAttr ":defaultArnoldFilter.msg" ":defaultArnoldRenderOptions.filt"; +connectAttr ":defaultArnoldDriver.msg" ":defaultArnoldRenderOptions.drvr"; +connectAttr "_renderingMain:Main.msg" "renderingMain.dnsm" -na; +connectAttr "rs_Main.msg" "Main.lrl"; +connectAttr "renderSetup.lit" "Main.pls"; +connectAttr "defaultCollection.msg" "Main.cl"; +connectAttr "defaultCollection.msg" "Main.ch"; +connectAttr "renderLayerManager.rlmi[1]" "rs_Main.rlid"; +connectAttr "defaultCollectionSelector.c" "defaultCollection.sel"; +connectAttr "Main.lit" "defaultCollection.pls"; +connectAttr "Main.nic" "defaultCollection.pic"; +connectAttr "Main.msg" "_renderingMain:Main.renderlayer"; +connectAttr "defaultRenderLayer.msg" ":defaultRenderingList1.r" -na; +connectAttr "rs_Main.msg" ":defaultRenderingList1.r" -na; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "pDiscShape1.iog" ":initialShadingGroup.dsm" -na; +// End of test_project_test_asset_test_task_v001.ma diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/renders/maya/test_project_test_asset_workfileTest_task_v001/Main/renderMain_metadata.json b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/renders/maya/test_project_test_asset_workfileTest_task_v001/Main/renderMain_metadata.json new file mode 100644 index 0000000000..8c9e4de21a --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/renders/maya/test_project_test_asset_workfileTest_task_v001/Main/renderMain_metadata.json @@ -0,0 +1,275 @@ +{ + "asset": "test_asset", + "comment": "", + "deadline_publish_job_id": "6540ba7fc27eabc1c1a3d387", + "fps": 25.0, + "frameEnd": 1001, + "frameStart": 1001, + "instances": [ + { + "asset": "test_asset", + "colorspace": null, + "comment": "", + "deadlineUrl": "http://127.0.0.1:8082", + "extendFrames": false, + "families": [ + "render", + "review", + "ftrack" + ], + "family": "render", + "fps": 25.0, + "frameEnd": 1001, + "frameEndHandle": 1001, + "frameStart": 1001, + "frameStartHandle": 1001, + "handleEnd": 0, + "handleStart": 0, + "inputVersions": [], + "jobBatchName": "", + "multipartExr": true, + "overrideExistingFrame": false, + "pixelAspect": 1.0, + "representations": [ + { + "colorspaceData": { + "colorspace": "scene-linear Rec 709/sRGB", + "config": { + "path": "C:/Program Files/Autodesk/Maya2024/resources/OCIO-configs/Maya-legacy/config.ocio", + "template": "C:/Program Files/Autodesk/Maya2024/resources/OCIO-configs/Maya-legacy/config.ocio" + }, + "display": "legacy", + "view": "sRGB gamma" + }, + "ext": "exr", + "files": "Main.1001.exr", + "fps": 25.0, + "frameEnd": 1001, + "frameStart": 1001, + "name": "exr", + "stagingDir": "{root[work]}/test_project/test_asset/work/test_task/renders/maya/test_project_test_asset_workfileTest_task_v001/Main", + "tags": [ + "review" + ] + } + ], + "resolutionHeight": 1080, + "resolutionWidth": 1920, + "review": true, + "source": "{root[work]}/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma", + "stagingDir_persistent": false, + "subset": "renderMain_beauty", + "subsetGroup": "renderMain", + "useSequenceForReview": true + } + ], + "intent": null, + "job": { + "Aux": [], + "Bad": [], + "ComFra": 0, + "CompletedChunks": 0, + "ConcurrencyToken": null, + "DataSize": -1, + "Date": "2023-10-31T08:27:42.9465949+00:00", + "DateComp": "0001-01-01T00:00:00Z", + "DateStart": "0001-01-01T00:00:00Z", + "Errs": 0, + "ExtraElements": null, + "FailedChunks": 0, + "IsSub": false, + "Mach": "DESKTOP-969549J", + "Main": false, + "MainEnd": 0, + "MainStart": 0, + "OutDir": [ + "C:/Users/TOKEJE~1/AppData/Local/Temp/tmpcjp1zvgv/output/test_project/test_asset/work/test_task/renders/maya/test_project_test_asset_workfileTest_task_v001/Main" + ], + "OutFile": [ + "Main.1001.exr" + ], + "PendingChunks": 0, + "Plug": "MayaBatch", + "Props": { + "AWSPortalAssetFileWhitelist": [], + "AWSPortalAssets": [], + "AutoTime": false, + "AuxSync": false, + "Batch": "test_project_test_asset_test_task_v001.ma31102023082742", + "Chunk": 1, + "Cmmt": "", + "Conc": 1, + "ConcLimt": true, + "Dep": [], + "DepComp": true, + "DepDel": false, + "DepFail": false, + "DepFrame": false, + "DepFrameEnd": 0, + "DepFrameStart": 0, + "DepPer": -1.0, + "Dept": "", + "Env": { + "AVALON_APP_NAME": "maya/2024", + "AVALON_ASSET": "test_asset", + "AVALON_PROJECT": "test_project", + "AVALON_TASK": "test_task", + "FTRACK_SERVER": "https://pype.ftrackapp.com", + "OPENPYPE_LOG_NO_COLORS": "1", + "OPENPYPE_MONGO": "mongodb://localhost:2707/", + "OPENPYPE_RENDER_JOB": "1" + }, + "EnvOnly": false, + "EventDir": "", + "EventOI": [], + "Ex0": "", + "Ex1": "", + "Ex2": "", + "Ex3": "", + "Ex4": "", + "Ex5": "", + "Ex6": "", + "Ex7": "", + "Ex8": "", + "Ex9": "", + "ExDic": {}, + "FrameTimeout": false, + "Frames": "1001", + "FriStart": "-10675199.02:48:05.4775808", + "FriStop": "-10675199.02:48:05.4775808", + "Grp": "none", + "InitializePluginTime": 0, + "Int": false, + "IntPer": 100, + "JobFailErr": 0, + "JobFailOvr": false, + "Limits": [], + "ListedSlaves": [], + "MachLmt": 0, + "MachLmtProg": -1.0, + "MaxTime": 0, + "MinTime": 0, + "MonStart": "-10675199.02:48:05.4775808", + "MonStop": "-10675199.02:48:05.4775808", + "Name": "test_project_test_asset_test_task_v001.ma31102023082742 - renderMain", + "NoBad": false, + "NoEvnt": false, + "NotEmail": [], + "NotNote": "", + "NotOvr": false, + "NotUser": [ + "tokejepsen" + ], + "OnComp": 2, + "OptIns": {}, + "OverAutoClean": false, + "OverClean": false, + "OverCleanDays": 0, + "OverCleanType": 1, + "OvrTaskEINames": false, + "PathMap": [], + "PlugDir": "", + "PlugInfo": { + "OutputFilePath": "C:/Users/TOKEJE~1/AppData/Local/Temp/tmpcjp1zvgv/output/test_project/test_asset/work/test_task/renders/maya", + "OutputFilePrefix": "//", + "ProjectPath": "C:\\Users\\TOKEJE~1\\AppData\\Local\\Temp\\tmpcjp1zvgv\\output\\test_project\\test_asset\\work\\test_task", + "RenderLayer": "rs_Main", + "RenderSetupIncludeLights": "True", + "Renderer": "arnold", + "SceneFile": "C:\\Users\\TOKEJE~1\\AppData\\Local\\Temp\\tmpcjp1zvgv\\output\\test_project\\test_asset\\publish\\workfile\\workfileTest_task\\v001\\test_project_test_asset_workfileTest_task_v001.ma", + "StrictErrorChecking": "True", + "UsingRenderLayers": "True", + "Version": "2024" + }, + "PoJobScrp": "", + "PoTskScrp": "", + "Pool": "none", + "PrJobScrp": "", + "PrTskScrp": "", + "Pri": 50, + "Protect": false, + "Region": "", + "Reload": false, + "RemTmT": 0, + "ReqAss": [ + { + "EndOffset": 0, + "FileName": "C:\\Users\\TOKEJE~1\\AppData\\Local\\Temp\\tmpcjp1zvgv\\output\\test_project\\test_asset\\publish\\workfile\\workfileTest_task\\v001\\test_project_test_asset_workfileTest_task_v001.ma", + "FrameString": "", + "IgnoreFrameOffsets": false, + "IsFrameAware": false, + "Notes": "", + "OverrideFrameOffsets": false, + "StartOffset": 0 + } + ], + "SatStart": "-10675199.02:48:05.4775808", + "SatStop": "-10675199.02:48:05.4775808", + "Schd": 0, + "SchdDate": "2023-10-31T08:27:42.9465593+00:00", + "SchdDays": 1, + "SchdStop": "0001-01-01T00:00:00Z", + "ScrDep": [], + "SecPool": "none", + "Seq": false, + "SndEmail": false, + "SndPopup": false, + "SndWarn": true, + "StartTime": 0, + "SunStart": "-10675199.02:48:05.4775808", + "SunStop": "-10675199.02:48:05.4775808", + "TaskEx0": "", + "TaskEx1": "", + "TaskEx2": "", + "TaskEx3": "", + "TaskEx4": "", + "TaskEx5": "", + "TaskEx6": "", + "TaskEx7": "", + "TaskEx8": "", + "TaskEx9": "", + "Tasks": 1, + "ThuStart": "-10675199.02:48:05.4775808", + "ThuStop": "-10675199.02:48:05.4775808", + "TimeScrpt": false, + "Timeout": 1, + "TskFailErr": 0, + "TskFailOvr": false, + "TueStart": "-10675199.02:48:05.4775808", + "TueStop": "-10675199.02:48:05.4775808", + "User": "tokejepsen", + "WedStart": "-10675199.02:48:05.4775808", + "WedStop": "-10675199.02:48:05.4775808", + "White": false + }, + "Purged": false, + "QueuedChunks": 0, + "RenderingChunks": 0, + "SnglTskPrg": "0 %", + "Stat": 6, + "SuspendedChunks": 0, + "Tile": false, + "TileCount": 0, + "TileFile": [], + "TileFrame": 0, + "TileX": 0, + "TileY": 0, + "_id": "6540ba7ec27eabc1c1a3d386" + }, + "session": { + "AVALON_APP": "maya", + "AVALON_ASSET": "test_asset", + "AVALON_DB": "avalon_tests", + "AVALON_LABEL": "OpenPype", + "AVALON_PROJECT": "test_project", + "AVALON_PROJECTS": "", + "AVALON_SCENEDIR": "", + "AVALON_TASK": "test_task", + "AVALON_TIMEOUT": "3000", + "AVALON_WORKDIR": "C:\\Users\\TOKEJE~1\\AppData\\Local\\Temp\\tmpcjp1zvgv\\output\\test_project\\test_asset\\work\\test_task", + "schema": "openpype:session-3.0" + }, + "source": "{root[work]}/test_project/test_asset/work/test_task/test_project_test_asset_test_task_v001.ma", + "user": "tokejepsen", + "version": 1 +} 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 new file mode 100644 index 0000000000..e438d80d5f --- /dev/null +++ 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 @@ -0,0 +1,476 @@ +//Maya ASCII 2022 scene +//Name: test_project_test_asset_test_task_v001.ma +//Last modified: Thu, Sep 14, 2023 06:31:00 PM +//Codeset: 1252 +requires maya "2022"; +requires -nodeType "polyDisc" "modelingToolkit" "0.0.0.0"; +requires "stereoCamera" "10.0"; +requires -nodeType "aiOptions" -nodeType "aiAOVDriver" -nodeType "aiAOVFilter" "mtoa" "5.2.2.1"; +requires -nodeType "simpleSelector" -nodeType "renderSetupLayer" -nodeType "renderSetup" + -nodeType "collection" "renderSetup.py" "1.0"; +requires "stereoCamera" "10.0"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2022"; +fileInfo "version" "2022"; +fileInfo "cutIdentifier" "202205171752-c25c06f306"; +fileInfo "osv" "Windows 10 Pro v2009 (Build: 19044)"; +fileInfo "license" "education"; +fileInfo "UUID" "019C7F50-40EF-1435-E27F-729F64685E67"; +fileInfo "OpenPypeContext" "eyJwdWJsaXNoX2F0dHJpYnV0ZXMiOiB7IlZhbGlkYXRlQ29udGFpbmVycyI6IHsiYWN0aXZlIjogdHJ1ZX19fQ=="; +createNode transform -s -n "persp"; + rename -uid "D52C935B-47C9-D868-A875-D799DD17B3A1"; + setAttr ".v" no; + setAttr ".t" -type "double3" 26.953352922736947 24.362683487437064 26.983403389430531 ; + setAttr ".r" -type "double3" 1064.7698975365424 54.173034578109736 1075.1660763544442 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -s -n "perspShape" -p "persp"; + rename -uid "2399E6C0-490F-BA1F-485F-5AA8A01D27BC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 46.895362757145833; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -s -n "top"; + rename -uid "415C7426-413E-0FAE-FCC3-3DAED7443A52"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 1000.1 0 ; + setAttr ".r" -type "double3" 90 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" 0 -1000.1 1000.1 ; +createNode camera -s -n "topShape" -p "top"; + rename -uid "3BD0CF60-40DB-5278-5D8B-06ACBDA32122"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "top"; + setAttr ".den" -type "string" "top_depth"; + setAttr ".man" -type "string" "top_mask"; + setAttr ".hc" -type "string" "viewSet -t %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "front"; + rename -uid "D83DD5CE-4FE0-AB1B-81B2-87A63F0B7A05"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 0 1000.1 ; + setAttr ".r" -type "double3" 180 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; +createNode camera -s -n "frontShape" -p "front"; + rename -uid "23313CBA-42C2-0B3A-0FCF-EA965EAC5DEC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "front"; + setAttr ".den" -type "string" "front_depth"; + setAttr ".man" -type "string" "front_mask"; + setAttr ".hc" -type "string" "viewSet -f %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "side"; + rename -uid "F70F692C-4A0D-BE64-9EE4-A99B6FA2D56E"; + setAttr ".v" no; + setAttr ".t" -type "double3" 1000.1 0 0 ; + setAttr ".r" -type "double3" 180 -90 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" -1000.1 0 1000.1 ; +createNode camera -s -n "sideShape" -p "side"; + rename -uid "C05669C3-420E-CA11-E5FC-7EB64EF8B632"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "side"; + setAttr ".den" -type "string" "side_depth"; + setAttr ".man" -type "string" "side_mask"; + setAttr ".hc" -type "string" "viewSet -s %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -n "pSphere1_GEO"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:440654b3dfe4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:bf8e49bb98ec"; +createNode transform -n "pDisc1"; + rename -uid "DED70CCF-4C19-16E4-9E5D-66A05037BA47"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:90e762703f08"; +createNode mesh -n "pDiscShape1" -p "pDisc1"; + rename -uid "E1FCDCCF-4DE1-D3B9-C4F8-3285F1CF5B25"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:4ee3da11a1a4"; +createNode transform -n "persp1"; + rename -uid "292F1351-4E41-A890-D6D5-A5A4F7D94C76"; + setAttr ".t" -type "double3" 3.7889010960863949 2.8416759114717678 3.7889010364817537 ; + setAttr ".r" -type "double3" -27.938352729602379 44.999999999999972 -5.172681101354183e-14 ; +createNode camera -n "perspShape1" -p "persp1"; + rename -uid "9277418C-43C8-5064-A7C6-64AC829A76F2"; + setAttr -k off ".v"; + setAttr ".ovr" 1.3; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 6.0652013012246453; + setAttr ".imn" -type "string" "persp1"; + setAttr ".den" -type "string" "persp1_depth"; + setAttr ".man" -type "string" "persp1_mask"; + setAttr ".tp" -type "double3" -1.1920928955078125e-07 0 -1.7881393432617188e-07 ; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".dr" yes; +createNode lightLinker -s -n "lightLinker1"; + rename -uid "6B9ADCD4-41B9-5BCC-826D-4A874A278510"; + setAttr -s 2 ".lnk"; + setAttr -s 2 ".slnk"; +createNode shapeEditorManager -n "shapeEditorManager"; + rename -uid "5B4518C5-46C9-6921-690E-EFAF77B21A36"; +createNode poseInterpolatorManager -n "poseInterpolatorManager"; + rename -uid "8518F293-4F06-BFF2-647A-72A099FBF025"; +createNode displayLayerManager -n "layerManager"; + rename -uid "04D880D5-4D86-0C58-CA3D-208ABE3E1E16"; +createNode displayLayer -n "defaultLayer"; + rename -uid "4A776D1B-401F-7069-1C74-A7AAE84CEE03"; +createNode renderLayerManager -n "renderLayerManager"; + rename -uid "B719B8BE-46BF-12E6-BEBA-B0AD4DBDBA87"; + setAttr -s 2 ".rlmi[1]" 1; + setAttr -s 2 ".rlmi"; +createNode renderLayer -n "defaultRenderLayer"; + rename -uid "B134920D-4508-23BD-A6CA-11B43DE03F53"; + setAttr ".g" yes; +createNode renderSetup -n "renderSetup"; + rename -uid "9A8F0D15-41AB-CA70-C2D8-B78840BF9BC1"; +createNode polySphere -n "polySphere1"; + rename -uid "DA319706-4ACF-B15C-53B2-48AC80D202EA"; +createNode objectSet -n "modelMain"; + rename -uid "A76AD4F8-4CF5-AA0D-4E98-BABEE6454CC3"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "writeColorSets" -ln "writeColorSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "writeFaceSets" -ln "writeFaceSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "includeParentHierarchy" -ln "includeParentHierarchy" -min + 0 -max 1 -at "bool"; + addAttr -ci true -sn "attr" -ln "attr" -dt "string"; + 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_keys" -ln "__creator_attributes_keys" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:7364ea6776c9"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "model"; + setAttr ".subset" -type "string" "modelMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.model"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "6889d3db-b813-43db-96de-9ba555dc4472"; + setAttr -cb on ".writeColorSets"; + setAttr -cb on ".writeFaceSets"; + setAttr -cb on ".includeParentHierarchy"; + setAttr ".attr" -type "string" ""; + setAttr ".attrPrefix" -type "string" ""; + setAttr ".publish_attributes" -type "string" "{\"ValidateNodeIDsRelated\": {\"active\": true}, \"ValidateTransformNamingSuffix\": {\"active\": true}, \"ValidateColorSets\": {\"active\": true}, \"ValidateMeshArnoldAttributes\": {\"active\": true}, \"ValidateMeshHasUVs\": {\"active\": true}, \"ValidateMeshNonZeroEdgeLength\": {\"active\": true}, \"ExtractModel\": {\"active\": true}}"; + setAttr ".__creator_attributes_keys" -type "string" "writeColorSets,writeFaceSets,includeParentHierarchy,attr,attrPrefix"; +createNode script -n "uiConfigurationScriptNode"; + rename -uid "4B7AFB53-452E-E870-63E1-CCA1DD6EAF13"; + setAttr ".b" -type "string" ( + "// Maya Mel UI Configuration File.\n//\n// This script is machine generated. Edit at your own risk.\n//\n//\n\nglobal string $gMainPane;\nif (`paneLayout -exists $gMainPane`) {\n\n\tglobal int $gUseScenePanelConfig;\n\tint $useSceneConfig = $gUseScenePanelConfig;\n\tint $nodeEditorPanelVisible = stringArrayContains(\"nodeEditorPanel1\", `getPanel -vis`);\n\tint $nodeEditorWorkspaceControlOpen = (`workspaceControl -exists nodeEditorPanel1Window` && `workspaceControl -q -visible nodeEditorPanel1Window`);\n\tint $menusOkayInPanels = `optionVar -q allowMenusInPanels`;\n\tint $nVisPanes = `paneLayout -q -nvp $gMainPane`;\n\tint $nPanes = 0;\n\tstring $editorName;\n\tstring $panelName;\n\tstring $itemFilterName;\n\tstring $panelConfig;\n\n\t//\n\t// get current state of the UI\n\t//\n\tsceneUIReplacement -update $gMainPane;\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Top View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Top View\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|top\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n" + + " -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n" + + " -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n" + + "\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Side View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Side View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|side\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n" + + " -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n" + + " -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n" + + " -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Front View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Front View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n" + + " -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n" + + " -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n" + + " -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Persp View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Persp View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|persp\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n" + + " -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n" + + " -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n" + + " -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1312\n -height 732\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"ToggledOutliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"ToggledOutliner\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n outlinerEditor -e \n -docTag \"isolOutln_fromSeln\" \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 1\n -showReferenceMembers 1\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n" + + " -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -isSet 0\n -isSetMember 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -selectCommand \"print(\\\"\\\")\" \n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 1\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n -renderFilterIndex 0\n" + + " -selectionOrder \"chronological\" \n -expandAttribute 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"Outliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"Outliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n" + + " -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n" + + " -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"graphEditor\" (localizedPanelLabel(\"Graph Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Graph Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n" + + " -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 1\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 0\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 1\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 1\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n" + + " -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 1\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"GraphEd\");\n animCurveEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -showPlayRangeShades \"on\" \n -lockPlayRangeShades \"off\" \n -smoothness \"fine\" \n -resultSamples 1.041667\n" + + " -resultScreenSamples 0\n -resultUpdate \"delayed\" \n -showUpstreamCurves 1\n -keyMinScale 1\n -stackedCurvesMin -1\n -stackedCurvesMax 1\n -stackedCurvesSpace 0.2\n -preSelectionHighlight 0\n -constrainDrag 0\n -valueLinesToggle 1\n -highlightAffectedCurves 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dopeSheetPanel\" (localizedPanelLabel(\"Dope Sheet\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dope Sheet\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n" + + " -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 0\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 1\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"0\" \n -showSetMembers 0\n" + + " -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"DopeSheetEd\");\n dopeSheetEditor -e \n -displayValues 0\n -snapTime \"integer\" \n" + + " -snapValue \"none\" \n -outliner \"dopeSheetPanel1OutlineEd\" \n -showSummary 1\n -showScene 0\n -hierarchyBelow 0\n -showTicks 1\n -selectionWindow 0 0 0 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"timeEditorPanel\" (localizedPanelLabel(\"Time Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Time Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"clipEditorPanel\" (localizedPanelLabel(\"Trax Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Trax Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = clipEditorNameFromPanel($panelName);\n" + + " clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"sequenceEditorPanel\" (localizedPanelLabel(\"Camera Sequencer\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Camera Sequencer\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = sequenceEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperGraphPanel\" (localizedPanelLabel(\"Hypergraph Hierarchy\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypergraph Hierarchy\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"HyperGraphEd\");\n hyperGraph -e \n -graphLayoutStyle \"hierarchicalLayout\" \n -orientation \"horiz\" \n -mergeConnections 0\n -zoom 1\n -animateTransition 0\n -showRelationships 1\n -showShapes 0\n -showDeformers 0\n -showExpressions 0\n -showConstraints 0\n -showConnectionFromSelected 0\n -showConnectionToSelected 0\n -showConstraintLabels 0\n -showUnderworld 0\n -showInvisible 0\n -transitionFrames 1\n -opaqueContainers 0\n -freeform 0\n -imagePosition 0 0 \n -imageScale 1\n -imageEnabled 0\n -graphType \"DAG\" \n" + + " -heatMapDisplay 0\n -updateSelection 1\n -updateNodeAdded 1\n -useDrawOverrideColor 0\n -limitGraphTraversal -1\n -range 0 0 \n -iconSize \"smallIcons\" \n -showCachedConnections 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperShadePanel\" (localizedPanelLabel(\"Hypershade\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypershade\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"visorPanel\" (localizedPanelLabel(\"Visor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Visor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n" + + "\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"nodeEditorPanel\" (localizedPanelLabel(\"Node Editor\")) `;\n\tif ($nodeEditorPanelVisible || $nodeEditorWorkspaceControlOpen) {\n\t\tif (\"\" == $panelName) {\n\t\t\tif ($useSceneConfig) {\n\t\t\t\t$panelName = `scriptedPanel -unParent -type \"nodeEditorPanel\" -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels `;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 0\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n" + + " -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\t}\n\t\t} else {\n\t\t\t$label = `panel -q -label $panelName`;\n\t\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n" + + " -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 0\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\tif (!$useSceneConfig) {\n\t\t\t\tpanel -e -l $label $panelName;\n\t\t\t}\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"createNodePanel\" (localizedPanelLabel(\"Create Node\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Create Node\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"polyTexturePlacementPanel\" (localizedPanelLabel(\"UV Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"UV Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"renderWindowPanel\" (localizedPanelLabel(\"Render View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Render View\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"shapePanel\" (localizedPanelLabel(\"Shape Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tshapePanel -edit -l (localizedPanelLabel(\"Shape Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"posePanel\" (localizedPanelLabel(\"Pose Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tposePanel -edit -l (localizedPanelLabel(\"Pose Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynRelEdPanel\" (localizedPanelLabel(\"Dynamic Relationships\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dynamic Relationships\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"relationshipPanel\" (localizedPanelLabel(\"Relationship Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Relationship Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"referenceEditorPanel\" (localizedPanelLabel(\"Reference Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Reference Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"componentEditorPanel\" (localizedPanelLabel(\"Component Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Component Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynPaintScriptedPanelType\" (localizedPanelLabel(\"Paint Effects\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Paint Effects\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"scriptEditorPanel\" (localizedPanelLabel(\"Script Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Script Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"profilerPanel\" (localizedPanelLabel(\"Profiler Tool\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Profiler Tool\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"contentBrowserPanel\" (localizedPanelLabel(\"Content Browser\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Content Browser\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"Stereo\" (localizedPanelLabel(\"Stereo\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Stereo\")) -mbv $menusOkayInPanels $panelName;\n{ string $editorName = ($panelName+\"Editor\");\n stereoCameraView -e \n -camera \"|persp\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"wireframe\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 1\n" + + " -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 4 4 \n -bumpResolution 4 4 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n" + + " -lowQualityLighting 0\n -maximumNumHardwareLights 0\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n" + + " -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 0\n -height 0\n -sceneRenderFilter 0\n -displayMode \"centerEye\" \n -viewColor 0 0 0 1 \n -useCustomBackground 1\n $editorName;\n stereoCameraView -e -viewSelected 0 $editorName;\n stereoCameraView -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName; };\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\tif ($useSceneConfig) {\n string $configName = `getPanel -cwl (localizedPanelLabel(\"Current Layout\"))`;\n" + + " if (\"\" != $configName) {\n\t\t\tpanelConfiguration -edit -label (localizedPanelLabel(\"Current Layout\")) \n\t\t\t\t-userCreated false\n\t\t\t\t-defaultImage \"vacantCell.xP:/\"\n\t\t\t\t-image \"\"\n\t\t\t\t-sc false\n\t\t\t\t-configString \"global string $gMainPane; paneLayout -e -cn \\\"single\\\" -ps 1 100 100 $gMainPane;\"\n\t\t\t\t-removeAllPanels\n\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Persp View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera persp` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 16384\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -greasePencils 1\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 1312\\n -height 732\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName;\\nmodelEditor -e \\n -pluginObjects \\\"gpuCacheDisplayFilter\\\" 1 \\n $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera persp` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 16384\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -greasePencils 1\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 1312\\n -height 732\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName;\\nmodelEditor -e \\n -pluginObjects \\\"gpuCacheDisplayFilter\\\" 1 \\n $editorName\"\n" + + "\t\t\t\t$configName;\n\n setNamedPanelLayout (localizedPanelLabel(\"Current Layout\"));\n }\n\n panelHistory -e -clear mainPanelHistory;\n sceneUIReplacement -clear;\n\t}\n\n\ngrid -spacing 5 -size 12 -divisions 5 -displayAxes yes -displayGridLines yes -displayDivisionLines yes -displayPerspectiveLabels no -displayOrthographicLabels no -displayAxesBold yes -perspectiveLabelPosition axis -orthographicLabelPosition edge;\nviewManip -drawCompass 0 -compassAngle 0 -frontParameters \"\" -homeParameters \"\" -selectionLockParameters \"\";\n}\n"); + setAttr ".st" 3; +createNode script -n "sceneConfigurationScriptNode"; + rename -uid "72B19BC2-43A2-E229-0A73-2CB861A291D1"; + setAttr ".b" -type "string" "playbackOptions -min 1000 -max 1001 -ast 1000 -aet 1001 "; + setAttr ".st" 6; +createNode polyDisc -n "polyDisc1"; + rename -uid "9ED8A7BD-4FFD-6107-4322-35ACD1D3AC42"; +createNode aiOptions -s -n "defaultArnoldRenderOptions"; + rename -uid "51BB3D7A-4C7D-FCDD-1B21-D89934107F98"; + setAttr ".skip_license_check" yes; +createNode aiAOVFilter -s -n "defaultArnoldFilter"; + rename -uid "989A5992-46C8-0CB2-B2B8-4E868F31FEA8"; + setAttr ".ai_translator" -type "string" "gaussian"; +createNode aiAOVDriver -s -n "defaultArnoldDriver"; + rename -uid "B8469AD8-47C8-9EF1-FE5E-5AB50ABC1536"; + setAttr ".merge_AOVs" yes; + setAttr ".ai_translator" -type "string" "exr"; +createNode aiAOVDriver -s -n "defaultArnoldDisplayDriver"; + rename -uid "21D4F4CD-4D78-001C-610D-798402CD7CF0"; + setAttr ".output_mode" 0; + setAttr ".ai_translator" -type "string" "maya"; +createNode objectSet -n "workfileMain"; + rename -uid "3C9B5D6F-4579-8E3B-5B7D-4C88865A1C68"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string"; + addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".hio" yes; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:30d256dac64c"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "workfile"; + setAttr ".subset" -type "string" "workfileTest_task"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.workfile"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "911dc92a-ad29-41e5-bbf9-733d56174fb9"; + setAttr ".publish_attributes" -type "string" "{\"ExtractImportReference\": {\"active\": false}}"; + setAttr ".__creator_attributes_keys" -type "string" ""; +createNode objectSet -n "renderingMain"; + rename -uid "8A999C2F-4922-B15D-8D5C-45A16465B69F"; + addAttr -ci true -sn "pre_creator_identifier" -ln "pre_creator_identifier" -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".pre_creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:042447475732"; +createNode renderSetupLayer -n "Main"; + rename -uid "2202E438-4CEF-F64E-737C-F48C65E31126"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode renderLayer -n "rs_Main"; + rename -uid "DF0259B1-4F96-DA7E-C74F-B599FBE15C18"; + setAttr ".do" 1; +createNode collection -n "defaultCollection"; + rename -uid "E5014237-4DA9-6FC0-633D-69AFA56161B3"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode simpleSelector -n "defaultCollectionSelector"; + rename -uid "7CA2F6D8-483C-B020-BC03-EF9563A52163"; + setAttr ".pat" -type "string" "*"; +createNode objectSet -n "_renderingMain:Main"; + rename -uid "1EEC3A3B-49CF-0C79-5340-39805174FB8A"; + addAttr -s false -ci true -sn "renderlayer" -ln "renderlayer" -at "message"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "review" -ln "review" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "extendFrames" -ln "extendFrames" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "overrideExistingFrame" -ln "overrideExistingFrame" -min 0 + -max 1 -at "bool"; + addAttr -ci true -sn "tileRendering" -ln "tileRendering" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "tilesX" -ln "tilesX" -at "long"; + addAttr -ci true -sn "tilesY" -ln "tilesY" -at "long"; + addAttr -ci true -sn "convertToScanline" -ln "convertToScanline" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "useReferencedAovs" -ln "useReferencedAovs" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "renderSetupIncludeLights" -ln "renderSetupIncludeLights" -min + 0 -max 1 -at "bool"; + addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string"; + addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys" + -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "renderlayer"; + setAttr ".subset" -type "string" "renderMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "8a9cfb85-9602-4e5e-a4bc-27a2986bae7f"; + setAttr -cb on ".review" yes; + setAttr -cb on ".extendFrames"; + setAttr -cb on ".overrideExistingFrame" yes; + setAttr -cb on ".tileRendering"; + setAttr -cb on ".tilesX" 2; + setAttr -cb on ".tilesY" 2; + setAttr -cb on ".convertToScanline"; + setAttr -cb on ".useReferencedAovs"; + setAttr -cb on ".renderSetupIncludeLights" yes; + setAttr ".publish_attributes" -type "string" "{\"CollectDeadlinePools\": {\"primaryPool\": \"\", \"secondaryPool\": \"\"}, \"ValidateDeadlinePools\": {\"active\": true}, \"ValidateFrameRange\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}, \"MayaSubmitDeadline\": {\"priority\": 50, \"chunkSize\": 1, \"machineList\": \"\", \"whitelist\": false, \"tile_priority\": 50, \"strict_error_checking\": true}, \"ProcessSubmittedJobOnFarm\": {\"publishJobState\": \"Active\"}}"; + setAttr ".__creator_attributes_keys" -type "string" "review,extendFrames,overrideExistingFrame,tileRendering,tilesX,tilesY,convertToScanline,useReferencedAovs,renderSetupIncludeLights"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:69960f336351"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :standardSurface1; + setAttr ".b" 0.80000001192092896; + setAttr ".bc" -type "float3" 1 1 1 ; + setAttr ".s" 0.20000000298023224; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".al" yes; + setAttr ".dar" 1.6666666269302368; +select -ne :defaultColorMgtGlobals; + setAttr ".cfe" yes; + setAttr ".cfp" -type "string" "/OCIO-configs/Maya-legacy/config.ocio"; + setAttr ".vtn" -type "string" "sRGB gamma (legacy)"; + setAttr ".vn" -type "string" "sRGB gamma"; + setAttr ".dn" -type "string" "legacy"; + setAttr ".wsn" -type "string" "scene-linear Rec 709/sRGB"; + setAttr ".ovt" no; + setAttr ".povt" no; + setAttr ".otn" -type "string" "sRGB gamma (legacy)"; + setAttr ".potn" -type "string" "sRGB gamma (legacy)"; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +select -ne :ikSystem; + setAttr -s 4 ".sol"; +connectAttr "rs_Main.ri" ":persp.rlio[0]"; +connectAttr "rs_Main.ri" ":top.rlio[0]"; +connectAttr "rs_Main.ri" ":front.rlio[0]"; +connectAttr "rs_Main.ri" ":side.rlio[0]"; +connectAttr "rs_Main.ri" "pSphere1_GEO.rlio[0]"; +connectAttr "polySphere1.out" "pSphere1_GEOShape1.i"; +connectAttr "rs_Main.ri" "pDisc1.rlio[0]"; +connectAttr "polyDisc1.output" "pDiscShape1.i"; +connectAttr "rs_Main.ri" "persp1.rlio[0]"; +relationship "link" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "link" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +connectAttr "layerManager.dli[0]" "defaultLayer.id"; +connectAttr "renderLayerManager.rlmi[0]" "defaultRenderLayer.rlid"; +connectAttr "Main.msg" "renderSetup.frl"; +connectAttr "Main.msg" "renderSetup.lrl"; +connectAttr "pSphere1_GEO.iog" "modelMain.dsm" -na; +connectAttr ":defaultArnoldDisplayDriver.msg" ":defaultArnoldRenderOptions.drivers" + -na; +connectAttr ":defaultArnoldFilter.msg" ":defaultArnoldRenderOptions.filt"; +connectAttr ":defaultArnoldDriver.msg" ":defaultArnoldRenderOptions.drvr"; +connectAttr "_renderingMain:Main.msg" "renderingMain.dnsm" -na; +connectAttr "rs_Main.msg" "Main.lrl"; +connectAttr "renderSetup.lit" "Main.pls"; +connectAttr "defaultCollection.msg" "Main.cl"; +connectAttr "defaultCollection.msg" "Main.ch"; +connectAttr "renderLayerManager.rlmi[1]" "rs_Main.rlid"; +connectAttr "defaultCollectionSelector.c" "defaultCollection.sel"; +connectAttr "Main.lit" "defaultCollection.pls"; +connectAttr "Main.nic" "defaultCollection.pic"; +connectAttr "Main.msg" "_renderingMain:Main.renderlayer"; +connectAttr "defaultRenderLayer.msg" ":defaultRenderingList1.r" -na; +connectAttr "rs_Main.msg" ":defaultRenderingList1.r" -na; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "pDiscShape1.iog" ":initialShadingGroup.dsm" -na; +// End of test_project_test_asset_test_task_v001.ma 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 new file mode 100644 index 0000000000..57bcfdae5b --- /dev/null +++ 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 @@ -0,0 +1,480 @@ +//Maya ASCII 2024 scene +//Name: test_project_test_asset_test_task_v002.ma +//Last modified: Tue, Oct 31, 2023 08:24:19 AM +//Codeset: 1252 +requires maya "2024"; +requires -nodeType "polyDisc" "modelingToolkit" "0.0.0.0"; +requires "stereoCamera" "10.0"; +requires -nodeType "aiOptions" -nodeType "aiAOVDriver" -nodeType "aiAOVFilter" "mtoa" "5.3.0"; +requires -nodeType "simpleSelector" -nodeType "renderSetupLayer" -nodeType "renderSetup" + -nodeType "collection" "renderSetup.py" "1.0"; +requires "stereoCamera" "10.0"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2024"; +fileInfo "version" "2024"; +fileInfo "cutIdentifier" "202302170737-4500172811"; +fileInfo "osv" "Windows 10 Pro v2009 (Build: 19045)"; +fileInfo "license" "education"; +fileInfo "UUID" "6C5CC57A-4CC3-7373-4411-B3B80BC40815"; +fileInfo "OpenPypeContext" "eyJwdWJsaXNoX2F0dHJpYnV0ZXMiOiB7IlZhbGlkYXRlQ29udGFpbmVycyI6IHsiYWN0aXZlIjogdHJ1ZX19fQ=="; +createNode transform -s -n "persp"; + rename -uid "D52C935B-47C9-D868-A875-D799DD17B3A1"; + setAttr ".v" no; + setAttr ".t" -type "double3" 26.953352922736947 24.362683487437064 26.983403389430531 ; + setAttr ".r" -type "double3" 1064.7698975365424 54.173034578109736 1075.1660763544442 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -s -n "perspShape" -p "persp"; + rename -uid "2399E6C0-490F-BA1F-485F-5AA8A01D27BC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 46.895362757145833; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -s -n "top"; + rename -uid "415C7426-413E-0FAE-FCC3-3DAED7443A52"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 1000.1 0 ; + setAttr ".r" -type "double3" 90 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" 0 -1000.1 1000.1 ; +createNode camera -s -n "topShape" -p "top"; + rename -uid "3BD0CF60-40DB-5278-5D8B-06ACBDA32122"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "top"; + setAttr ".den" -type "string" "top_depth"; + setAttr ".man" -type "string" "top_mask"; + setAttr ".hc" -type "string" "viewSet -t %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "front"; + rename -uid "D83DD5CE-4FE0-AB1B-81B2-87A63F0B7A05"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 0 1000.1 ; + setAttr ".r" -type "double3" 180 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; +createNode camera -s -n "frontShape" -p "front"; + rename -uid "23313CBA-42C2-0B3A-0FCF-EA965EAC5DEC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "front"; + setAttr ".den" -type "string" "front_depth"; + setAttr ".man" -type "string" "front_mask"; + setAttr ".hc" -type "string" "viewSet -f %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "side"; + rename -uid "F70F692C-4A0D-BE64-9EE4-A99B6FA2D56E"; + setAttr ".v" no; + setAttr ".t" -type "double3" 1000.1 0 0 ; + setAttr ".r" -type "double3" 180 -90 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" -1000.1 0 1000.1 ; +createNode camera -s -n "sideShape" -p "side"; + rename -uid "C05669C3-420E-CA11-E5FC-7EB64EF8B632"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "side"; + setAttr ".den" -type "string" "side_depth"; + setAttr ".man" -type "string" "side_mask"; + setAttr ".hc" -type "string" "viewSet -s %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -n "pSphere1_GEO"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:440654b3dfe4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:bf8e49bb98ec"; +createNode transform -n "pDisc1"; + rename -uid "DED70CCF-4C19-16E4-9E5D-66A05037BA47"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:90e762703f08"; +createNode mesh -n "pDiscShape1" -p "pDisc1"; + rename -uid "E1FCDCCF-4DE1-D3B9-C4F8-3285F1CF5B25"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:4ee3da11a1a4"; +createNode transform -n "persp1"; + rename -uid "292F1351-4E41-A890-D6D5-A5A4F7D94C76"; + setAttr ".t" -type "double3" 3.7889010960863949 2.8416759114717678 3.7889010364817537 ; + setAttr ".r" -type "double3" -27.938352729602379 44.999999999999972 -5.172681101354183e-14 ; +createNode camera -n "perspShape1" -p "persp1"; + rename -uid "9277418C-43C8-5064-A7C6-64AC829A76F2"; + setAttr -k off ".v"; + setAttr ".ovr" 1.3; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 6.0652013012246453; + setAttr ".imn" -type "string" "persp1"; + setAttr ".den" -type "string" "persp1_depth"; + setAttr ".man" -type "string" "persp1_mask"; + setAttr ".tp" -type "double3" -1.1920928955078125e-07 0 -1.7881393432617188e-07 ; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".dr" yes; +createNode lightLinker -s -n "lightLinker1"; + rename -uid "D0EBF10B-4952-5C9F-42A8-D6A660FF173F"; + setAttr -s 2 ".lnk"; + setAttr -s 2 ".slnk"; +createNode shapeEditorManager -n "shapeEditorManager"; + rename -uid "30DE6463-4107-330C-8FE3-4EA1C402A632"; +createNode poseInterpolatorManager -n "poseInterpolatorManager"; + rename -uid "64911358-4E6A-5ACC-75A4-4F965FCD606E"; +createNode displayLayerManager -n "layerManager"; + rename -uid "B3C9D791-45C2-E529-2A9A-9B88F2D5E17E"; +createNode displayLayer -n "defaultLayer"; + rename -uid "4A776D1B-401F-7069-1C74-A7AAE84CEE03"; + setAttr ".ufem" -type "stringArray" 0 ; +createNode renderLayerManager -n "renderLayerManager"; + rename -uid "68F97550-4CB6-1D4A-99B0-CCA5DBE5D6B1"; + setAttr -s 2 ".rlmi[1]" 1; + setAttr -s 2 ".rlmi"; +createNode renderLayer -n "defaultRenderLayer"; + rename -uid "B134920D-4508-23BD-A6CA-11B43DE03F53"; + setAttr ".g" yes; +createNode renderSetup -n "renderSetup"; + rename -uid "9A8F0D15-41AB-CA70-C2D8-B78840BF9BC1"; +createNode polySphere -n "polySphere1"; + rename -uid "DA319706-4ACF-B15C-53B2-48AC80D202EA"; +createNode objectSet -n "modelMain"; + rename -uid "A76AD4F8-4CF5-AA0D-4E98-BABEE6454CC3"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "writeColorSets" -ln "writeColorSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "writeFaceSets" -ln "writeFaceSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "includeParentHierarchy" -ln "includeParentHierarchy" -min + 0 -max 1 -at "bool"; + addAttr -ci true -sn "attr" -ln "attr" -dt "string"; + 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_keys" -ln "__creator_attributes_keys" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:7364ea6776c9"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "model"; + setAttr ".subset" -type "string" "modelMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.model"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "6889d3db-b813-43db-96de-9ba555dc4472"; + setAttr -cb on ".writeColorSets"; + setAttr -cb on ".writeFaceSets"; + setAttr -cb on ".includeParentHierarchy"; + setAttr ".attr" -type "string" ""; + setAttr ".attrPrefix" -type "string" ""; + setAttr ".publish_attributes" -type "string" "{\"ValidateNodeIDsRelated\": {\"active\": true}, \"ValidateTransformNamingSuffix\": {\"active\": true}, \"ValidateColorSets\": {\"active\": true}, \"ValidateMeshArnoldAttributes\": {\"active\": true}, \"ValidateMeshHasUVs\": {\"active\": true}, \"ValidateMeshNonZeroEdgeLength\": {\"active\": true}, \"ExtractModel\": {\"active\": true}}"; + setAttr ".__creator_attributes_keys" -type "string" "writeColorSets,writeFaceSets,includeParentHierarchy,attr,attrPrefix"; +createNode script -n "uiConfigurationScriptNode"; + rename -uid "4B7AFB53-452E-E870-63E1-CCA1DD6EAF13"; + setAttr ".b" -type "string" ( + "// Maya Mel UI Configuration File.\n//\n// This script is machine generated. Edit at your own risk.\n//\n//\n\nglobal string $gMainPane;\nif (`paneLayout -exists $gMainPane`) {\n\n\tglobal int $gUseScenePanelConfig;\n\tint $useSceneConfig = $gUseScenePanelConfig;\n\tint $nodeEditorPanelVisible = stringArrayContains(\"nodeEditorPanel1\", `getPanel -vis`);\n\tint $nodeEditorWorkspaceControlOpen = (`workspaceControl -exists nodeEditorPanel1Window` && `workspaceControl -q -visible nodeEditorPanel1Window`);\n\tint $menusOkayInPanels = `optionVar -q allowMenusInPanels`;\n\tint $nVisPanes = `paneLayout -q -nvp $gMainPane`;\n\tint $nPanes = 0;\n\tstring $editorName;\n\tstring $panelName;\n\tstring $itemFilterName;\n\tstring $panelConfig;\n\n\t//\n\t// get current state of the UI\n\t//\n\tsceneUIReplacement -update $gMainPane;\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Top View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Top View\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|top\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n" + + " -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n" + + " -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -excludeObjectPreset \"All\" \n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n" + + "\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Side View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Side View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|side\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n" + + " -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n" + + " -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -excludeObjectPreset \"All\" \n" + + " -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Front View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Front View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n" + + " -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n" + + " -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n" + + " -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -excludeObjectPreset \"All\" \n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Persp View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Persp View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n" + + " -camera \"|persp\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n" + + " -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n" + + " -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -excludeObjectPreset \"All\" \n -shadows 0\n -captureSequenceNumber -1\n -width 1319\n -height 718\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n" + + "\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"ToggledOutliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"ToggledOutliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -docTag \"isolOutln_fromSeln\" \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 1\n -showReferenceMembers 1\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n" + + " -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -isSet 0\n -isSetMember 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n" + + " -longNames 0\n -niceNames 1\n -selectCommand \"print(\\\"\\\")\" \n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n -renderFilterIndex 0\n -selectionOrder \"chronological\" \n -expandAttribute 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"Outliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"Outliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 0\n -showConnected 0\n" + + " -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n" + + " -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"graphEditor\" (localizedPanelLabel(\"Graph Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Graph Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n" + + " outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 1\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 0\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n" + + " -autoSelectNewObjects 1\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 1\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 1\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n" + + " -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"GraphEd\");\n animCurveEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -showPlayRangeShades \"on\" \n -lockPlayRangeShades \"off\" \n -smoothness \"fine\" \n -resultSamples 1.041667\n -resultScreenSamples 0\n -resultUpdate \"delayed\" \n -showUpstreamCurves 1\n -keyMinScale 1\n -stackedCurvesMin -1\n -stackedCurvesMax 1\n -stackedCurvesSpace 0.2\n -preSelectionHighlight 0\n -constrainDrag 0\n -valueLinesToggle 1\n -highlightAffectedCurves 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dopeSheetPanel\" (localizedPanelLabel(\"Dope Sheet\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dope Sheet\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n" + + " -showUpstreamCurves 1\n -showUnitlessCurves 0\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 1\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -showUfeItems 1\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n" + + " -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"DopeSheetEd\");\n dopeSheetEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -outliner \"dopeSheetPanel1OutlineEd\" \n -showSummary 1\n -showScene 0\n -hierarchyBelow 0\n -showTicks 1\n -selectionWindow 0 0 0 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"timeEditorPanel\" (localizedPanelLabel(\"Time Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Time Editor\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"clipEditorPanel\" (localizedPanelLabel(\"Trax Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Trax Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = clipEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"sequenceEditorPanel\" (localizedPanelLabel(\"Camera Sequencer\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Camera Sequencer\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = sequenceEditorNameFromPanel($panelName);\n" + + " clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperGraphPanel\" (localizedPanelLabel(\"Hypergraph Hierarchy\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypergraph Hierarchy\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"HyperGraphEd\");\n hyperGraph -e \n -graphLayoutStyle \"hierarchicalLayout\" \n -orientation \"horiz\" \n -mergeConnections 0\n -zoom 1\n -animateTransition 0\n -showRelationships 1\n -showShapes 0\n -showDeformers 0\n -showExpressions 0\n -showConstraints 0\n" + + " -showConnectionFromSelected 0\n -showConnectionToSelected 0\n -showConstraintLabels 0\n -showUnderworld 0\n -showInvisible 0\n -transitionFrames 1\n -opaqueContainers 0\n -freeform 0\n -imagePosition 0 0 \n -imageScale 1\n -imageEnabled 0\n -graphType \"DAG\" \n -heatMapDisplay 0\n -updateSelection 1\n -updateNodeAdded 1\n -useDrawOverrideColor 0\n -limitGraphTraversal -1\n -range 0 0 \n -iconSize \"smallIcons\" \n -showCachedConnections 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperShadePanel\" (localizedPanelLabel(\"Hypershade\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypershade\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"visorPanel\" (localizedPanelLabel(\"Visor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Visor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"nodeEditorPanel\" (localizedPanelLabel(\"Node Editor\")) `;\n\tif ($nodeEditorPanelVisible || $nodeEditorWorkspaceControlOpen) {\n\t\tif (\"\" == $panelName) {\n\t\t\tif ($useSceneConfig) {\n\t\t\t\t$panelName = `scriptedPanel -unParent -type \"nodeEditorPanel\" -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels `;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n" + + " -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 0\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\t}\n\t\t} else {\n\t\t\t$label = `panel -q -label $panelName`;\n" + + "\t\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 0\n -connectedGraphingMode 1\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n" + + " -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -showUnitConversions 0\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\tif (!$useSceneConfig) {\n\t\t\t\tpanel -e -l $label $panelName;\n\t\t\t}\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"createNodePanel\" (localizedPanelLabel(\"Create Node\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Create Node\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"polyTexturePlacementPanel\" (localizedPanelLabel(\"UV Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"UV Editor\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"renderWindowPanel\" (localizedPanelLabel(\"Render View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Render View\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"shapePanel\" (localizedPanelLabel(\"Shape Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tshapePanel -edit -l (localizedPanelLabel(\"Shape Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"posePanel\" (localizedPanelLabel(\"Pose Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tposePanel -edit -l (localizedPanelLabel(\"Pose Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n" + + "\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynRelEdPanel\" (localizedPanelLabel(\"Dynamic Relationships\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dynamic Relationships\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"relationshipPanel\" (localizedPanelLabel(\"Relationship Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Relationship Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"referenceEditorPanel\" (localizedPanelLabel(\"Reference Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Reference Editor\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynPaintScriptedPanelType\" (localizedPanelLabel(\"Paint Effects\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Paint Effects\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"scriptEditorPanel\" (localizedPanelLabel(\"Script Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Script Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"profilerPanel\" (localizedPanelLabel(\"Profiler Tool\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Profiler Tool\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"contentBrowserPanel\" (localizedPanelLabel(\"Content Browser\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Content Browser\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"Stereo\" (localizedPanelLabel(\"Stereo\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Stereo\")) -mbv $menusOkayInPanels $panelName;\n{ string $editorName = ($panelName+\"Editor\");\n stereoCameraView -e \n -camera \"|persp\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"wireframe\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n" + + " -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 1\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 4 4 \n" + + " -bumpResolution 4 4 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 0\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n" + + " -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -bluePencil 1\n -greasePencils 0\n -shadows 0\n -captureSequenceNumber -1\n -width 0\n -height 0\n -sceneRenderFilter 0\n -displayMode \"centerEye\" \n -viewColor 0 0 0 1 \n -useCustomBackground 1\n $editorName;\n stereoCameraView -e -viewSelected 0 $editorName;\n stereoCameraView -e \n" + + " -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName; };\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\tif ($useSceneConfig) {\n string $configName = `getPanel -cwl (localizedPanelLabel(\"Current Layout\"))`;\n if (\"\" != $configName) {\n\t\t\tpanelConfiguration -edit -label (localizedPanelLabel(\"Current Layout\")) \n\t\t\t\t-userCreated false\n\t\t\t\t-defaultImage \"vacantCell.xP:/\"\n\t\t\t\t-image \"\"\n\t\t\t\t-sc false\n\t\t\t\t-configString \"global string $gMainPane; paneLayout -e -cn \\\"single\\\" -ps 1 100 100 $gMainPane;\"\n\t\t\t\t-removeAllPanels\n\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Persp View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera persp` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 16384\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -excludeObjectPreset \\\"All\\\" \\n -shadows 0\\n -captureSequenceNumber -1\\n -width 1319\\n -height 718\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName;\\nmodelEditor -e \\n -pluginObjects \\\"gpuCacheDisplayFilter\\\" 1 \\n $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera persp` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 16384\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -bluePencil 1\\n -greasePencils 0\\n -excludeObjectPreset \\\"All\\\" \\n -shadows 0\\n -captureSequenceNumber -1\\n -width 1319\\n -height 718\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName;\\nmodelEditor -e \\n -pluginObjects \\\"gpuCacheDisplayFilter\\\" 1 \\n $editorName\"\n" + + "\t\t\t\t$configName;\n\n setNamedPanelLayout (localizedPanelLabel(\"Current Layout\"));\n }\n\n panelHistory -e -clear mainPanelHistory;\n sceneUIReplacement -clear;\n\t}\n\n\ngrid -spacing 5 -size 12 -divisions 5 -displayAxes yes -displayGridLines yes -displayDivisionLines yes -displayPerspectiveLabels no -displayOrthographicLabels no -displayAxesBold yes -perspectiveLabelPosition axis -orthographicLabelPosition edge;\nviewManip -drawCompass 0 -compassAngle 0 -frontParameters \"\" -homeParameters \"\" -selectionLockParameters \"\";\n}\n"); + setAttr ".st" 3; +createNode script -n "sceneConfigurationScriptNode"; + rename -uid "72B19BC2-43A2-E229-0A73-2CB861A291D1"; + setAttr ".b" -type "string" "playbackOptions -min 1000 -max 1001 -ast 1000 -aet 1001 "; + setAttr ".st" 6; +createNode polyDisc -n "polyDisc1"; + rename -uid "9ED8A7BD-4FFD-6107-4322-35ACD1D3AC42"; +createNode aiOptions -s -n "defaultArnoldRenderOptions"; + rename -uid "51BB3D7A-4C7D-FCDD-1B21-D89934107F98"; + setAttr ".skip_license_check" yes; +createNode aiAOVFilter -s -n "defaultArnoldFilter"; + rename -uid "989A5992-46C8-0CB2-B2B8-4E868F31FEA8"; + setAttr ".ai_translator" -type "string" "gaussian"; +createNode aiAOVDriver -s -n "defaultArnoldDriver"; + rename -uid "B8469AD8-47C8-9EF1-FE5E-5AB50ABC1536"; + setAttr ".merge_AOVs" yes; + setAttr ".ai_translator" -type "string" "exr"; +createNode aiAOVDriver -s -n "defaultArnoldDisplayDriver"; + rename -uid "21D4F4CD-4D78-001C-610D-798402CD7CF0"; + setAttr ".output_mode" 0; + setAttr ".ai_translator" -type "string" "maya"; +createNode objectSet -n "workfileMain"; + rename -uid "3C9B5D6F-4579-8E3B-5B7D-4C88865A1C68"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string"; + addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".hio" yes; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:30d256dac64c"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "workfile"; + setAttr ".subset" -type "string" "workfileTest_task"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.workfile"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "911dc92a-ad29-41e5-bbf9-733d56174fb9"; + setAttr ".publish_attributes" -type "string" "{\"ExtractImportReference\": {\"active\": false}}"; + setAttr ".__creator_attributes_keys" -type "string" ""; +createNode objectSet -n "renderingMain"; + rename -uid "8A999C2F-4922-B15D-8D5C-45A16465B69F"; + addAttr -ci true -sn "pre_creator_identifier" -ln "pre_creator_identifier" -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".pre_creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:042447475732"; +createNode renderSetupLayer -n "Main"; + rename -uid "2202E438-4CEF-F64E-737C-F48C65E31126"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode renderLayer -n "rs_Main"; + rename -uid "DF0259B1-4F96-DA7E-C74F-B599FBE15C18"; + setAttr ".do" 1; +createNode collection -n "defaultCollection"; + rename -uid "E5014237-4DA9-6FC0-633D-69AFA56161B3"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode simpleSelector -n "defaultCollectionSelector"; + rename -uid "7CA2F6D8-483C-B020-BC03-EF9563A52163"; + setAttr ".pat" -type "string" "*"; +createNode objectSet -n "_renderingMain:Main"; + rename -uid "1EEC3A3B-49CF-0C79-5340-39805174FB8A"; + addAttr -s false -ci true -sn "renderlayer" -ln "renderlayer" -at "message"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "review" -ln "review" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "extendFrames" -ln "extendFrames" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "overrideExistingFrame" -ln "overrideExistingFrame" -min 0 + -max 1 -at "bool"; + addAttr -ci true -sn "tileRendering" -ln "tileRendering" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "tilesX" -ln "tilesX" -at "long"; + addAttr -ci true -sn "tilesY" -ln "tilesY" -at "long"; + addAttr -ci true -sn "convertToScanline" -ln "convertToScanline" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "useReferencedAovs" -ln "useReferencedAovs" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "renderSetupIncludeLights" -ln "renderSetupIncludeLights" -min + 0 -max 1 -at "bool"; + addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string"; + addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys" + -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "renderlayer"; + setAttr ".subset" -type "string" "renderMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "8a9cfb85-9602-4e5e-a4bc-27a2986bae7f"; + setAttr -cb on ".review" yes; + setAttr -cb on ".extendFrames"; + setAttr -cb on ".overrideExistingFrame" yes; + setAttr -cb on ".tileRendering"; + setAttr -cb on ".tilesX" 2; + setAttr -cb on ".tilesY" 2; + setAttr -cb on ".convertToScanline"; + setAttr -cb on ".useReferencedAovs"; + setAttr -cb on ".renderSetupIncludeLights" yes; + setAttr ".publish_attributes" -type "string" "{\"CollectDeadlinePools\": {\"primaryPool\": \"\", \"secondaryPool\": \"\"}, \"ValidateDeadlinePools\": {\"active\": true}, \"ValidateFrameRange\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}, \"MayaSubmitDeadline\": {\"priority\": 50, \"chunkSize\": 1, \"machineList\": \"\", \"whitelist\": false, \"tile_priority\": 50, \"strict_error_checking\": true}, \"ProcessSubmittedJobOnFarm\": {\"publishJobState\": \"Active\"}}"; + setAttr ".__creator_attributes_keys" -type "string" "review,extendFrames,overrideExistingFrame,tileRendering,tilesX,tilesY,convertToScanline,useReferencedAovs,renderSetupIncludeLights"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:69960f336351"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".dli" 1; + setAttr ".fprt" yes; + setAttr ".rtfm" 1; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :standardSurface1; + setAttr ".b" 0.80000001192092896; + setAttr ".bc" -type "float3" 1 1 1 ; + setAttr ".s" 0.20000000298023224; + setAttr ".sr" 0.40000000596046448; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".al" yes; + setAttr ".dar" 1.6666666269302368; +select -ne :defaultColorMgtGlobals; + setAttr ".cfe" yes; + setAttr ".cfp" -type "string" "/OCIO-configs/Maya-legacy/config.ocio"; + setAttr ".vtn" -type "string" "sRGB gamma (legacy)"; + setAttr ".vn" -type "string" "sRGB gamma"; + setAttr ".dn" -type "string" "legacy"; + setAttr ".wsn" -type "string" "scene-linear Rec 709/sRGB"; + setAttr ".ovt" no; + setAttr ".povt" no; + setAttr ".otn" -type "string" "sRGB gamma (legacy)"; + setAttr ".potn" -type "string" "sRGB gamma (legacy)"; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +select -ne :ikSystem; + setAttr -s 4 ".sol"; +connectAttr "rs_Main.ri" ":persp.rlio[0]"; +connectAttr "rs_Main.ri" ":top.rlio[0]"; +connectAttr "rs_Main.ri" ":front.rlio[0]"; +connectAttr "rs_Main.ri" ":side.rlio[0]"; +connectAttr "rs_Main.ri" "pSphere1_GEO.rlio[0]"; +connectAttr "polySphere1.out" "pSphere1_GEOShape1.i"; +connectAttr "rs_Main.ri" "pDisc1.rlio[0]"; +connectAttr "polyDisc1.output" "pDiscShape1.i"; +connectAttr "rs_Main.ri" "persp1.rlio[0]"; +relationship "link" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "link" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +connectAttr "layerManager.dli[0]" "defaultLayer.id"; +connectAttr "renderLayerManager.rlmi[0]" "defaultRenderLayer.rlid"; +connectAttr "Main.msg" "renderSetup.frl"; +connectAttr "Main.msg" "renderSetup.lrl"; +connectAttr "pSphere1_GEO.iog" "modelMain.dsm" -na; +connectAttr ":defaultArnoldDisplayDriver.msg" ":defaultArnoldRenderOptions.drivers" + -na; +connectAttr ":defaultArnoldFilter.msg" ":defaultArnoldRenderOptions.filt"; +connectAttr ":defaultArnoldDriver.msg" ":defaultArnoldRenderOptions.drvr"; +connectAttr "_renderingMain:Main.msg" "renderingMain.dnsm" -na; +connectAttr "rs_Main.msg" "Main.lrl"; +connectAttr "renderSetup.lit" "Main.pls"; +connectAttr "defaultCollection.msg" "Main.cl"; +connectAttr "defaultCollection.msg" "Main.ch"; +connectAttr "renderLayerManager.rlmi[1]" "rs_Main.rlid"; +connectAttr "defaultCollectionSelector.c" "defaultCollection.sel"; +connectAttr "Main.lit" "defaultCollection.pls"; +connectAttr "Main.nic" "defaultCollection.pic"; +connectAttr "Main.msg" "_renderingMain:Main.renderlayer"; +connectAttr "defaultRenderLayer.msg" ":defaultRenderingList1.r" -na; +connectAttr "rs_Main.msg" ":defaultRenderingList1.r" -na; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "pDiscShape1.iog" ":initialShadingGroup.dsm" -na; +// End of test_project_test_asset_test_task_v002.ma diff --git a/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/workspace.mel b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/workspace.mel new file mode 100644 index 0000000000..bc6e74b55c --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/expected/test_project/test_asset/work/test_task/workspace.mel @@ -0,0 +1,12 @@ +//Maya 2019 Project Definition + +workspace -fr "shaders" "renderData/shaders"; +workspace -fr "images" "renders"; +workspace -fr "mayaAscii" ""; +workspace -fr "particles" "particles"; +workspace -fr "mayaBinary" ""; +workspace -fr "scene" ""; +workspace -fr "alembicCache" "cache/alembic"; +workspace -fr "renderData" "renderData"; +workspace -fr "sourceImages" "sourceimages"; +workspace -fr "fileCache" "cache/nCache"; diff --git a/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/avalon_tests/test_project.bson b/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/avalon_tests/test_project.bson new file mode 100644 index 0000000000..72293b3080 Binary files /dev/null and b/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/avalon_tests/test_project.bson differ diff --git a/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/avalon_tests/test_project.metadata.json b/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/avalon_tests/test_project.metadata.json new file mode 100644 index 0000000000..20b0216e2a --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/avalon_tests/test_project.metadata.json @@ -0,0 +1 @@ +{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"}],"uuid":"8d778e3bbb3448ff9311bc7619ed478c","collectionName":"test_project"} \ No newline at end of file diff --git a/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/openpype_tests/settings.bson b/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/openpype_tests/settings.bson new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/openpype_tests/settings.metadata.json b/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/openpype_tests/settings.metadata.json new file mode 100644 index 0000000000..d637c9296c --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/input/dumps/openpype_tests/settings.metadata.json @@ -0,0 +1 @@ +{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"}],"uuid":"3a0a55846c164eb5920568a766510c6d","collectionName":"settings"} \ No newline at end of file diff --git a/tests/integration/hosts/maya/test_publish_in_maya/input/env_vars/env_var.json b/tests/integration/hosts/maya/test_publish_in_maya/input/env_vars/env_var.json new file mode 100644 index 0000000000..7b830b4fb9 --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/input/env_vars/env_var.json @@ -0,0 +1,11 @@ +{ + "OPENPYPE_MONGO": "{TEST_OPENPYPE_MONGO}", + "AVALON_MONGO": "{TEST_OPENPYPE_MONGO}", + "OPENPYPE_DATABASE_NAME": "{TEST_OPENPYPE_NAME}", + "AVALON_TIMEOUT": "3000", + "AVALON_DB": "{TEST_DB_NAME}", + "AVALON_PROJECT": "{TEST_PROJECT_NAME}", + "PYPE_DEBUG": "3", + "AVALON_CONFIG": "openpype", + "IS_TEST": "1" +} \ No newline at end of file diff --git a/tests/integration/hosts/maya/test_publish_in_maya/input/startup/userSetup.py b/tests/integration/hosts/maya/test_publish_in_maya/input/startup/userSetup.py new file mode 100644 index 0000000000..eb6e2411b5 --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/input/startup/userSetup.py @@ -0,0 +1,28 @@ +import logging +import sys + +from maya import cmds + +import pyblish.util + + +def setup_pyblish_logging(): + log = logging.getLogger("pyblish") + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter( + "pyblish (%(levelname)s) (line: %(lineno)d) %(name)s:" + "\n%(message)s" + ) + handler.setFormatter(formatter) + log.addHandler(handler) + + +def _run_publish_test_deferred(): + try: + setup_pyblish_logging() + pyblish.util.publish() + finally: + cmds.quit(force=True) + + +cmds.evalDeferred("_run_publish_test_deferred()", lowestPriority=True) 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 new file mode 100644 index 0000000000..e438d80d5f --- /dev/null +++ b/tests/integration/hosts/maya/test_publish_in_maya/input/workfile/test_project_test_asset_test_task_v001.ma @@ -0,0 +1,476 @@ +//Maya ASCII 2022 scene +//Name: test_project_test_asset_test_task_v001.ma +//Last modified: Thu, Sep 14, 2023 06:31:00 PM +//Codeset: 1252 +requires maya "2022"; +requires -nodeType "polyDisc" "modelingToolkit" "0.0.0.0"; +requires "stereoCamera" "10.0"; +requires -nodeType "aiOptions" -nodeType "aiAOVDriver" -nodeType "aiAOVFilter" "mtoa" "5.2.2.1"; +requires -nodeType "simpleSelector" -nodeType "renderSetupLayer" -nodeType "renderSetup" + -nodeType "collection" "renderSetup.py" "1.0"; +requires "stereoCamera" "10.0"; +currentUnit -l centimeter -a degree -t pal; +fileInfo "application" "maya"; +fileInfo "product" "Maya 2022"; +fileInfo "version" "2022"; +fileInfo "cutIdentifier" "202205171752-c25c06f306"; +fileInfo "osv" "Windows 10 Pro v2009 (Build: 19044)"; +fileInfo "license" "education"; +fileInfo "UUID" "019C7F50-40EF-1435-E27F-729F64685E67"; +fileInfo "OpenPypeContext" "eyJwdWJsaXNoX2F0dHJpYnV0ZXMiOiB7IlZhbGlkYXRlQ29udGFpbmVycyI6IHsiYWN0aXZlIjogdHJ1ZX19fQ=="; +createNode transform -s -n "persp"; + rename -uid "D52C935B-47C9-D868-A875-D799DD17B3A1"; + setAttr ".v" no; + setAttr ".t" -type "double3" 26.953352922736947 24.362683487437064 26.983403389430531 ; + setAttr ".r" -type "double3" 1064.7698975365424 54.173034578109736 1075.1660763544442 ; + setAttr ".rp" -type "double3" -2.0401242849359917e-14 2.2609331405046354e-14 -44.821869662029947 ; + setAttr ".rpt" -type "double3" -27.999999999999989 -21.000000000000025 16.82186966202995 ; +createNode camera -s -n "perspShape" -p "persp"; + rename -uid "2399E6C0-490F-BA1F-485F-5AA8A01D27BC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 46.895362757145833; + setAttr ".imn" -type "string" "persp"; + setAttr ".den" -type "string" "persp_depth"; + setAttr ".man" -type "string" "persp_mask"; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".ai_translator" -type "string" "perspective"; +createNode transform -s -n "top"; + rename -uid "415C7426-413E-0FAE-FCC3-3DAED7443A52"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 1000.1 0 ; + setAttr ".r" -type "double3" 90 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" 0 -1000.1 1000.1 ; +createNode camera -s -n "topShape" -p "top"; + rename -uid "3BD0CF60-40DB-5278-5D8B-06ACBDA32122"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "top"; + setAttr ".den" -type "string" "top_depth"; + setAttr ".man" -type "string" "top_mask"; + setAttr ".hc" -type "string" "viewSet -t %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "front"; + rename -uid "D83DD5CE-4FE0-AB1B-81B2-87A63F0B7A05"; + setAttr ".v" no; + setAttr ".t" -type "double3" 0 0 1000.1 ; + setAttr ".r" -type "double3" 180 0 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; +createNode camera -s -n "frontShape" -p "front"; + rename -uid "23313CBA-42C2-0B3A-0FCF-EA965EAC5DEC"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "front"; + setAttr ".den" -type "string" "front_depth"; + setAttr ".man" -type "string" "front_mask"; + setAttr ".hc" -type "string" "viewSet -f %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -s -n "side"; + rename -uid "F70F692C-4A0D-BE64-9EE4-A99B6FA2D56E"; + setAttr ".v" no; + setAttr ".t" -type "double3" 1000.1 0 0 ; + setAttr ".r" -type "double3" 180 -90 0 ; + setAttr ".rp" -type "double3" 0 0 -1000.1 ; + setAttr ".rpt" -type "double3" -1000.1 0 1000.1 ; +createNode camera -s -n "sideShape" -p "side"; + rename -uid "C05669C3-420E-CA11-E5FC-7EB64EF8B632"; + setAttr -k off ".v" no; + setAttr ".rnd" no; + setAttr ".coi" 1000.1; + setAttr ".ow" 30; + setAttr ".imn" -type "string" "side"; + setAttr ".den" -type "string" "side_depth"; + setAttr ".man" -type "string" "side_mask"; + setAttr ".hc" -type "string" "viewSet -s %camera"; + setAttr ".o" yes; + setAttr ".ai_translator" -type "string" "orthographic"; +createNode transform -n "pSphere1_GEO"; + rename -uid "7445A43F-444F-B2D3-4315-2AA013D2E0B6"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:440654b3dfe4"; +createNode mesh -n "pSphere1_GEOShape1" -p "pSphere1_GEO"; + rename -uid "7C731260-45C6-339E-07BF-359446B08EA1"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:bf8e49bb98ec"; +createNode transform -n "pDisc1"; + rename -uid "DED70CCF-4C19-16E4-9E5D-66A05037BA47"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:90e762703f08"; +createNode mesh -n "pDiscShape1" -p "pDisc1"; + rename -uid "E1FCDCCF-4DE1-D3B9-C4F8-3285F1CF5B25"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr -k off ".v"; + setAttr ".vir" yes; + setAttr ".vif" yes; + setAttr ".uvst[0].uvsn" -type "string" "map1"; + setAttr ".cuvs" -type "string" "map1"; + setAttr ".dcc" -type "string" "Ambient+Diffuse"; + setAttr ".covm[0]" 0 1 1; + setAttr ".cdvm[0]" 0 1 1; + setAttr ".ai_translator" -type "string" "polymesh"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:4ee3da11a1a4"; +createNode transform -n "persp1"; + rename -uid "292F1351-4E41-A890-D6D5-A5A4F7D94C76"; + setAttr ".t" -type "double3" 3.7889010960863949 2.8416759114717678 3.7889010364817537 ; + setAttr ".r" -type "double3" -27.938352729602379 44.999999999999972 -5.172681101354183e-14 ; +createNode camera -n "perspShape1" -p "persp1"; + rename -uid "9277418C-43C8-5064-A7C6-64AC829A76F2"; + setAttr -k off ".v"; + setAttr ".ovr" 1.3; + setAttr ".fl" 34.999999999999993; + setAttr ".coi" 6.0652013012246453; + setAttr ".imn" -type "string" "persp1"; + setAttr ".den" -type "string" "persp1_depth"; + setAttr ".man" -type "string" "persp1_mask"; + setAttr ".tp" -type "double3" -1.1920928955078125e-07 0 -1.7881393432617188e-07 ; + setAttr ".hc" -type "string" "viewSet -p %camera"; + setAttr ".dr" yes; +createNode lightLinker -s -n "lightLinker1"; + rename -uid "6B9ADCD4-41B9-5BCC-826D-4A874A278510"; + setAttr -s 2 ".lnk"; + setAttr -s 2 ".slnk"; +createNode shapeEditorManager -n "shapeEditorManager"; + rename -uid "5B4518C5-46C9-6921-690E-EFAF77B21A36"; +createNode poseInterpolatorManager -n "poseInterpolatorManager"; + rename -uid "8518F293-4F06-BFF2-647A-72A099FBF025"; +createNode displayLayerManager -n "layerManager"; + rename -uid "04D880D5-4D86-0C58-CA3D-208ABE3E1E16"; +createNode displayLayer -n "defaultLayer"; + rename -uid "4A776D1B-401F-7069-1C74-A7AAE84CEE03"; +createNode renderLayerManager -n "renderLayerManager"; + rename -uid "B719B8BE-46BF-12E6-BEBA-B0AD4DBDBA87"; + setAttr -s 2 ".rlmi[1]" 1; + setAttr -s 2 ".rlmi"; +createNode renderLayer -n "defaultRenderLayer"; + rename -uid "B134920D-4508-23BD-A6CA-11B43DE03F53"; + setAttr ".g" yes; +createNode renderSetup -n "renderSetup"; + rename -uid "9A8F0D15-41AB-CA70-C2D8-B78840BF9BC1"; +createNode polySphere -n "polySphere1"; + rename -uid "DA319706-4ACF-B15C-53B2-48AC80D202EA"; +createNode objectSet -n "modelMain"; + rename -uid "A76AD4F8-4CF5-AA0D-4E98-BABEE6454CC3"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "writeColorSets" -ln "writeColorSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "writeFaceSets" -ln "writeFaceSets" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "includeParentHierarchy" -ln "includeParentHierarchy" -min + 0 -max 1 -at "bool"; + addAttr -ci true -sn "attr" -ln "attr" -dt "string"; + 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_keys" -ln "__creator_attributes_keys" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:7364ea6776c9"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "model"; + setAttr ".subset" -type "string" "modelMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.model"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "6889d3db-b813-43db-96de-9ba555dc4472"; + setAttr -cb on ".writeColorSets"; + setAttr -cb on ".writeFaceSets"; + setAttr -cb on ".includeParentHierarchy"; + setAttr ".attr" -type "string" ""; + setAttr ".attrPrefix" -type "string" ""; + setAttr ".publish_attributes" -type "string" "{\"ValidateNodeIDsRelated\": {\"active\": true}, \"ValidateTransformNamingSuffix\": {\"active\": true}, \"ValidateColorSets\": {\"active\": true}, \"ValidateMeshArnoldAttributes\": {\"active\": true}, \"ValidateMeshHasUVs\": {\"active\": true}, \"ValidateMeshNonZeroEdgeLength\": {\"active\": true}, \"ExtractModel\": {\"active\": true}}"; + setAttr ".__creator_attributes_keys" -type "string" "writeColorSets,writeFaceSets,includeParentHierarchy,attr,attrPrefix"; +createNode script -n "uiConfigurationScriptNode"; + rename -uid "4B7AFB53-452E-E870-63E1-CCA1DD6EAF13"; + setAttr ".b" -type "string" ( + "// Maya Mel UI Configuration File.\n//\n// This script is machine generated. Edit at your own risk.\n//\n//\n\nglobal string $gMainPane;\nif (`paneLayout -exists $gMainPane`) {\n\n\tglobal int $gUseScenePanelConfig;\n\tint $useSceneConfig = $gUseScenePanelConfig;\n\tint $nodeEditorPanelVisible = stringArrayContains(\"nodeEditorPanel1\", `getPanel -vis`);\n\tint $nodeEditorWorkspaceControlOpen = (`workspaceControl -exists nodeEditorPanel1Window` && `workspaceControl -q -visible nodeEditorPanel1Window`);\n\tint $menusOkayInPanels = `optionVar -q allowMenusInPanels`;\n\tint $nVisPanes = `paneLayout -q -nvp $gMainPane`;\n\tint $nPanes = 0;\n\tstring $editorName;\n\tstring $panelName;\n\tstring $itemFilterName;\n\tstring $panelConfig;\n\n\t//\n\t// get current state of the UI\n\t//\n\tsceneUIReplacement -update $gMainPane;\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Top View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Top View\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|top\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n" + + " -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n" + + " -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n" + + "\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Side View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Side View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|side\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n" + + " -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n" + + " -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n" + + " -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Front View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Front View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|front\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n" + + " -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n" + + " -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n" + + " -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1\n -height 1\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"modelPanel\" (localizedPanelLabel(\"Persp View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tmodelPanel -edit -l (localizedPanelLabel(\"Persp View\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n modelEditor -e \n -camera \"|persp\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"smoothShaded\" \n -activeOnly 0\n -ignorePanZoom 0\n" + + " -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 0\n -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -rendererName \"vp2Renderer\" \n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 256 256 \n -bumpResolution 512 512 \n" + + " -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n -lowQualityLighting 0\n -maximumNumHardwareLights 1\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n" + + " -nCloths 1\n -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 1312\n -height 732\n -sceneRenderFilter 0\n $editorName;\n modelEditor -e -viewSelected 0 $editorName;\n modelEditor -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"ToggledOutliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"ToggledOutliner\")) -mbv $menusOkayInPanels $panelName;\n" + + "\t\t$editorName = $panelName;\n outlinerEditor -e \n -docTag \"isolOutln_fromSeln\" \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 1\n -showReferenceMembers 1\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n" + + " -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -isSet 0\n -isSetMember 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -selectCommand \"print(\\\"\\\")\" \n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 1\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n -renderFilterIndex 0\n" + + " -selectionOrder \"chronological\" \n -expandAttribute 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"outlinerPanel\" (localizedPanelLabel(\"Outliner\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\toutlinerPanel -edit -l (localizedPanelLabel(\"Outliner\")) -mbv $menusOkayInPanels $panelName;\n\t\t$editorName = $panelName;\n outlinerEditor -e \n -showShapes 0\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 0\n -showConnected 0\n -showAnimCurvesOnly 0\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 1\n -showAssets 1\n -showContainedOnly 1\n -showPublishedAsConnected 0\n" + + " -showParentContainers 0\n -showContainerContents 1\n -ignoreDagHierarchy 0\n -expandConnections 0\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 0\n -highlightActive 1\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"defaultSetFilter\" \n -showSetMembers 1\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n" + + " -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 0\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"graphEditor\" (localizedPanelLabel(\"Graph Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Graph Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n" + + " -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 1\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 1\n -showCompounds 0\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 1\n -doNotSelectNewObjects 0\n -dropIsParent 1\n -transmitFilters 1\n -setFilter \"0\" \n -showSetMembers 0\n -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n" + + " -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 1\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"GraphEd\");\n animCurveEditor -e \n -displayValues 0\n -snapTime \"integer\" \n -snapValue \"none\" \n -showPlayRangeShades \"on\" \n -lockPlayRangeShades \"off\" \n -smoothness \"fine\" \n -resultSamples 1.041667\n" + + " -resultScreenSamples 0\n -resultUpdate \"delayed\" \n -showUpstreamCurves 1\n -keyMinScale 1\n -stackedCurvesMin -1\n -stackedCurvesMax 1\n -stackedCurvesSpace 0.2\n -preSelectionHighlight 0\n -constrainDrag 0\n -valueLinesToggle 1\n -highlightAffectedCurves 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dopeSheetPanel\" (localizedPanelLabel(\"Dope Sheet\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dope Sheet\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"OutlineEd\");\n outlinerEditor -e \n -showShapes 1\n -showAssignedMaterials 0\n -showTimeEditor 1\n -showReferenceNodes 0\n -showReferenceMembers 0\n" + + " -showAttributes 1\n -showConnected 1\n -showAnimCurvesOnly 1\n -showMuteInfo 0\n -organizeByLayer 1\n -organizeByClip 1\n -showAnimLayerWeight 1\n -autoExpandLayers 1\n -autoExpand 0\n -showDagOnly 0\n -showAssets 1\n -showContainedOnly 0\n -showPublishedAsConnected 0\n -showParentContainers 0\n -showContainerContents 0\n -ignoreDagHierarchy 0\n -expandConnections 1\n -showUpstreamCurves 1\n -showUnitlessCurves 0\n -showCompounds 1\n -showLeafs 1\n -showNumericAttrsOnly 1\n -highlightActive 0\n -autoSelectNewObjects 0\n -doNotSelectNewObjects 1\n -dropIsParent 1\n -transmitFilters 0\n -setFilter \"0\" \n -showSetMembers 0\n" + + " -allowMultiSelection 1\n -alwaysToggleSelect 0\n -directSelect 0\n -displayMode \"DAG\" \n -expandObjects 0\n -setsIgnoreFilters 1\n -containersIgnoreFilters 0\n -editAttrName 0\n -showAttrValues 0\n -highlightSecondary 0\n -showUVAttrsOnly 0\n -showTextureNodesOnly 0\n -attrAlphaOrder \"default\" \n -animLayerFilterOptions \"allAffecting\" \n -sortOrder \"none\" \n -longNames 0\n -niceNames 1\n -showNamespace 1\n -showPinIcons 0\n -mapMotionTrails 1\n -ignoreHiddenAttribute 0\n -ignoreOutlinerColor 0\n -renderFilterVisible 0\n $editorName;\n\n\t\t\t$editorName = ($panelName+\"DopeSheetEd\");\n dopeSheetEditor -e \n -displayValues 0\n -snapTime \"integer\" \n" + + " -snapValue \"none\" \n -outliner \"dopeSheetPanel1OutlineEd\" \n -showSummary 1\n -showScene 0\n -hierarchyBelow 0\n -showTicks 1\n -selectionWindow 0 0 0 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"timeEditorPanel\" (localizedPanelLabel(\"Time Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Time Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"clipEditorPanel\" (localizedPanelLabel(\"Trax Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Trax Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = clipEditorNameFromPanel($panelName);\n" + + " clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 0 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"sequenceEditorPanel\" (localizedPanelLabel(\"Camera Sequencer\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Camera Sequencer\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = sequenceEditorNameFromPanel($panelName);\n clipEditor -e \n -displayValues 0\n -snapTime \"none\" \n -snapValue \"none\" \n -initialized 0\n -manageSequencer 1 \n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperGraphPanel\" (localizedPanelLabel(\"Hypergraph Hierarchy\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypergraph Hierarchy\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"HyperGraphEd\");\n hyperGraph -e \n -graphLayoutStyle \"hierarchicalLayout\" \n -orientation \"horiz\" \n -mergeConnections 0\n -zoom 1\n -animateTransition 0\n -showRelationships 1\n -showShapes 0\n -showDeformers 0\n -showExpressions 0\n -showConstraints 0\n -showConnectionFromSelected 0\n -showConnectionToSelected 0\n -showConstraintLabels 0\n -showUnderworld 0\n -showInvisible 0\n -transitionFrames 1\n -opaqueContainers 0\n -freeform 0\n -imagePosition 0 0 \n -imageScale 1\n -imageEnabled 0\n -graphType \"DAG\" \n" + + " -heatMapDisplay 0\n -updateSelection 1\n -updateNodeAdded 1\n -useDrawOverrideColor 0\n -limitGraphTraversal -1\n -range 0 0 \n -iconSize \"smallIcons\" \n -showCachedConnections 0\n $editorName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"hyperShadePanel\" (localizedPanelLabel(\"Hypershade\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Hypershade\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"visorPanel\" (localizedPanelLabel(\"Visor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Visor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n" + + "\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"nodeEditorPanel\" (localizedPanelLabel(\"Node Editor\")) `;\n\tif ($nodeEditorPanelVisible || $nodeEditorWorkspaceControlOpen) {\n\t\tif (\"\" == $panelName) {\n\t\t\tif ($useSceneConfig) {\n\t\t\t\t$panelName = `scriptedPanel -unParent -type \"nodeEditorPanel\" -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels `;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 0\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n" + + " -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\t}\n\t\t} else {\n\t\t\t$label = `panel -q -label $panelName`;\n\t\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Node Editor\")) -mbv $menusOkayInPanels $panelName;\n\n\t\t\t$editorName = ($panelName+\"NodeEditorEd\");\n nodeEditor -e \n -allAttributes 0\n -allNodes 0\n -autoSizeNodes 1\n -consistentNameSize 1\n -createNodeCommand \"nodeEdCreateNodeCommand\" \n -connectNodeOnCreation 0\n -connectOnDrop 0\n" + + " -copyConnectionsOnPaste 0\n -connectionStyle \"bezier\" \n -defaultPinnedState 0\n -additiveGraphingMode 0\n -settingsChangedCallback \"nodeEdSyncControls\" \n -traversalDepthLimit -1\n -keyPressCommand \"nodeEdKeyPressCommand\" \n -nodeTitleMode \"name\" \n -gridSnap 0\n -gridVisibility 1\n -crosshairOnEdgeDragging 0\n -popupMenuScript \"nodeEdBuildPanelMenus\" \n -showNamespace 1\n -showShapes 1\n -showSGShapes 0\n -showTransforms 1\n -useAssets 1\n -syncedSelection 1\n -extendToShapes 1\n -editorMode \"default\" \n -hasWatchpoint 0\n $editorName;\n\t\t\tif (!$useSceneConfig) {\n\t\t\t\tpanel -e -l $label $panelName;\n\t\t\t}\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"createNodePanel\" (localizedPanelLabel(\"Create Node\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Create Node\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"polyTexturePlacementPanel\" (localizedPanelLabel(\"UV Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"UV Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"renderWindowPanel\" (localizedPanelLabel(\"Render View\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Render View\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"shapePanel\" (localizedPanelLabel(\"Shape Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tshapePanel -edit -l (localizedPanelLabel(\"Shape Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextPanel \"posePanel\" (localizedPanelLabel(\"Pose Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tposePanel -edit -l (localizedPanelLabel(\"Pose Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynRelEdPanel\" (localizedPanelLabel(\"Dynamic Relationships\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Dynamic Relationships\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"relationshipPanel\" (localizedPanelLabel(\"Relationship Editor\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Relationship Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"referenceEditorPanel\" (localizedPanelLabel(\"Reference Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Reference Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"componentEditorPanel\" (localizedPanelLabel(\"Component Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Component Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"dynPaintScriptedPanelType\" (localizedPanelLabel(\"Paint Effects\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Paint Effects\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"scriptEditorPanel\" (localizedPanelLabel(\"Script Editor\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Script Editor\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"profilerPanel\" (localizedPanelLabel(\"Profiler Tool\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Profiler Tool\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"contentBrowserPanel\" (localizedPanelLabel(\"Content Browser\")) `;\n" + + "\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Content Browser\")) -mbv $menusOkayInPanels $panelName;\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\t$panelName = `sceneUIReplacement -getNextScriptedPanel \"Stereo\" (localizedPanelLabel(\"Stereo\")) `;\n\tif (\"\" != $panelName) {\n\t\t$label = `panel -q -label $panelName`;\n\t\tscriptedPanel -edit -l (localizedPanelLabel(\"Stereo\")) -mbv $menusOkayInPanels $panelName;\n{ string $editorName = ($panelName+\"Editor\");\n stereoCameraView -e \n -camera \"|persp\" \n -useInteractiveMode 0\n -displayLights \"default\" \n -displayAppearance \"wireframe\" \n -activeOnly 0\n -ignorePanZoom 0\n -wireframeOnShaded 0\n -headsUpDisplay 1\n -holdOuts 1\n -selectionHiliteDisplay 1\n -useDefaultMaterial 0\n -bufferMode \"double\" \n -twoSidedLighting 1\n" + + " -backfaceCulling 0\n -xray 0\n -jointXray 0\n -activeComponentsXray 0\n -displayTextures 0\n -smoothWireframe 0\n -lineWidth 1\n -textureAnisotropic 0\n -textureHilight 1\n -textureSampling 2\n -textureDisplay \"modulate\" \n -textureMaxSize 16384\n -fogging 0\n -fogSource \"fragment\" \n -fogMode \"linear\" \n -fogStart 0\n -fogEnd 100\n -fogDensity 0.1\n -fogColor 0.5 0.5 0.5 1 \n -depthOfFieldPreview 1\n -maxConstantTransparency 1\n -objectFilterShowInHUD 1\n -isFiltered 0\n -colorResolution 4 4 \n -bumpResolution 4 4 \n -textureCompression 0\n -transparencyAlgorithm \"frontAndBackCull\" \n -transpInShadows 0\n -cullingOverride \"none\" \n" + + " -lowQualityLighting 0\n -maximumNumHardwareLights 0\n -occlusionCulling 0\n -shadingModel 0\n -useBaseRenderer 0\n -useReducedRenderer 0\n -smallObjectCulling 0\n -smallObjectThreshold -1 \n -interactiveDisableShadows 0\n -interactiveBackFaceCull 0\n -sortTransparent 1\n -controllers 1\n -nurbsCurves 1\n -nurbsSurfaces 1\n -polymeshes 1\n -subdivSurfaces 1\n -planes 1\n -lights 1\n -cameras 1\n -controlVertices 1\n -hulls 1\n -grid 1\n -imagePlane 1\n -joints 1\n -ikHandles 1\n -deformers 1\n -dynamics 1\n -particleInstancers 1\n -fluids 1\n -hairSystems 1\n -follicles 1\n -nCloths 1\n" + + " -nParticles 1\n -nRigids 1\n -dynamicConstraints 1\n -locators 1\n -manipulators 1\n -pluginShapes 1\n -dimensions 1\n -handles 1\n -pivots 1\n -textures 1\n -strokes 1\n -motionTrails 1\n -clipGhosts 1\n -greasePencils 1\n -shadows 0\n -captureSequenceNumber -1\n -width 0\n -height 0\n -sceneRenderFilter 0\n -displayMode \"centerEye\" \n -viewColor 0 0 0 1 \n -useCustomBackground 1\n $editorName;\n stereoCameraView -e -viewSelected 0 $editorName;\n stereoCameraView -e \n -pluginObjects \"gpuCacheDisplayFilter\" 1 \n $editorName; };\n\t\tif (!$useSceneConfig) {\n\t\t\tpanel -e -l $label $panelName;\n\t\t}\n\t}\n\n\n\tif ($useSceneConfig) {\n string $configName = `getPanel -cwl (localizedPanelLabel(\"Current Layout\"))`;\n" + + " if (\"\" != $configName) {\n\t\t\tpanelConfiguration -edit -label (localizedPanelLabel(\"Current Layout\")) \n\t\t\t\t-userCreated false\n\t\t\t\t-defaultImage \"vacantCell.xP:/\"\n\t\t\t\t-image \"\"\n\t\t\t\t-sc false\n\t\t\t\t-configString \"global string $gMainPane; paneLayout -e -cn \\\"single\\\" -ps 1 100 100 $gMainPane;\"\n\t\t\t\t-removeAllPanels\n\t\t\t\t-ap false\n\t\t\t\t\t(localizedPanelLabel(\"Persp View\")) \n\t\t\t\t\t\"modelPanel\"\n" + + "\t\t\t\t\t\"$panelName = `modelPanel -unParent -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels `;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera persp` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 16384\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -greasePencils 1\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 1312\\n -height 732\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName;\\nmodelEditor -e \\n -pluginObjects \\\"gpuCacheDisplayFilter\\\" 1 \\n $editorName\"\n" + + "\t\t\t\t\t\"modelPanel -edit -l (localizedPanelLabel(\\\"Persp View\\\")) -mbv $menusOkayInPanels $panelName;\\n$editorName = $panelName;\\nmodelEditor -e \\n -cam `findStartUpCamera persp` \\n -useInteractiveMode 0\\n -displayLights \\\"default\\\" \\n -displayAppearance \\\"smoothShaded\\\" \\n -activeOnly 0\\n -ignorePanZoom 0\\n -wireframeOnShaded 0\\n -headsUpDisplay 1\\n -holdOuts 1\\n -selectionHiliteDisplay 1\\n -useDefaultMaterial 0\\n -bufferMode \\\"double\\\" \\n -twoSidedLighting 0\\n -backfaceCulling 0\\n -xray 0\\n -jointXray 0\\n -activeComponentsXray 0\\n -displayTextures 0\\n -smoothWireframe 0\\n -lineWidth 1\\n -textureAnisotropic 0\\n -textureHilight 1\\n -textureSampling 2\\n -textureDisplay \\\"modulate\\\" \\n -textureMaxSize 16384\\n -fogging 0\\n -fogSource \\\"fragment\\\" \\n -fogMode \\\"linear\\\" \\n -fogStart 0\\n -fogEnd 100\\n -fogDensity 0.1\\n -fogColor 0.5 0.5 0.5 1 \\n -depthOfFieldPreview 1\\n -maxConstantTransparency 1\\n -rendererName \\\"vp2Renderer\\\" \\n -objectFilterShowInHUD 1\\n -isFiltered 0\\n -colorResolution 256 256 \\n -bumpResolution 512 512 \\n -textureCompression 0\\n -transparencyAlgorithm \\\"frontAndBackCull\\\" \\n -transpInShadows 0\\n -cullingOverride \\\"none\\\" \\n -lowQualityLighting 0\\n -maximumNumHardwareLights 1\\n -occlusionCulling 0\\n -shadingModel 0\\n -useBaseRenderer 0\\n -useReducedRenderer 0\\n -smallObjectCulling 0\\n -smallObjectThreshold -1 \\n -interactiveDisableShadows 0\\n -interactiveBackFaceCull 0\\n -sortTransparent 1\\n -controllers 1\\n -nurbsCurves 1\\n -nurbsSurfaces 1\\n -polymeshes 1\\n -subdivSurfaces 1\\n -planes 1\\n -lights 1\\n -cameras 1\\n -controlVertices 1\\n -hulls 1\\n -grid 1\\n -imagePlane 1\\n -joints 1\\n -ikHandles 1\\n -deformers 1\\n -dynamics 1\\n -particleInstancers 1\\n -fluids 1\\n -hairSystems 1\\n -follicles 1\\n -nCloths 1\\n -nParticles 1\\n -nRigids 1\\n -dynamicConstraints 1\\n -locators 1\\n -manipulators 1\\n -pluginShapes 1\\n -dimensions 1\\n -handles 1\\n -pivots 1\\n -textures 1\\n -strokes 1\\n -motionTrails 1\\n -clipGhosts 1\\n -greasePencils 1\\n -shadows 0\\n -captureSequenceNumber -1\\n -width 1312\\n -height 732\\n -sceneRenderFilter 0\\n $editorName;\\nmodelEditor -e -viewSelected 0 $editorName;\\nmodelEditor -e \\n -pluginObjects \\\"gpuCacheDisplayFilter\\\" 1 \\n $editorName\"\n" + + "\t\t\t\t$configName;\n\n setNamedPanelLayout (localizedPanelLabel(\"Current Layout\"));\n }\n\n panelHistory -e -clear mainPanelHistory;\n sceneUIReplacement -clear;\n\t}\n\n\ngrid -spacing 5 -size 12 -divisions 5 -displayAxes yes -displayGridLines yes -displayDivisionLines yes -displayPerspectiveLabels no -displayOrthographicLabels no -displayAxesBold yes -perspectiveLabelPosition axis -orthographicLabelPosition edge;\nviewManip -drawCompass 0 -compassAngle 0 -frontParameters \"\" -homeParameters \"\" -selectionLockParameters \"\";\n}\n"); + setAttr ".st" 3; +createNode script -n "sceneConfigurationScriptNode"; + rename -uid "72B19BC2-43A2-E229-0A73-2CB861A291D1"; + setAttr ".b" -type "string" "playbackOptions -min 1000 -max 1001 -ast 1000 -aet 1001 "; + setAttr ".st" 6; +createNode polyDisc -n "polyDisc1"; + rename -uid "9ED8A7BD-4FFD-6107-4322-35ACD1D3AC42"; +createNode aiOptions -s -n "defaultArnoldRenderOptions"; + rename -uid "51BB3D7A-4C7D-FCDD-1B21-D89934107F98"; + setAttr ".skip_license_check" yes; +createNode aiAOVFilter -s -n "defaultArnoldFilter"; + rename -uid "989A5992-46C8-0CB2-B2B8-4E868F31FEA8"; + setAttr ".ai_translator" -type "string" "gaussian"; +createNode aiAOVDriver -s -n "defaultArnoldDriver"; + rename -uid "B8469AD8-47C8-9EF1-FE5E-5AB50ABC1536"; + setAttr ".merge_AOVs" yes; + setAttr ".ai_translator" -type "string" "exr"; +createNode aiAOVDriver -s -n "defaultArnoldDisplayDriver"; + rename -uid "21D4F4CD-4D78-001C-610D-798402CD7CF0"; + setAttr ".output_mode" 0; + setAttr ".ai_translator" -type "string" "maya"; +createNode objectSet -n "workfileMain"; + rename -uid "3C9B5D6F-4579-8E3B-5B7D-4C88865A1C68"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string"; + addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys" + -dt "string"; + setAttr ".ihi" 0; + setAttr ".hio" yes; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:30d256dac64c"; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "workfile"; + setAttr ".subset" -type "string" "workfileTest_task"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.workfile"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "911dc92a-ad29-41e5-bbf9-733d56174fb9"; + setAttr ".publish_attributes" -type "string" "{\"ExtractImportReference\": {\"active\": false}}"; + setAttr ".__creator_attributes_keys" -type "string" ""; +createNode objectSet -n "renderingMain"; + rename -uid "8A999C2F-4922-B15D-8D5C-45A16465B69F"; + addAttr -ci true -sn "pre_creator_identifier" -ln "pre_creator_identifier" -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".pre_creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:042447475732"; +createNode renderSetupLayer -n "Main"; + rename -uid "2202E438-4CEF-F64E-737C-F48C65E31126"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode renderLayer -n "rs_Main"; + rename -uid "DF0259B1-4F96-DA7E-C74F-B599FBE15C18"; + setAttr ".do" 1; +createNode collection -n "defaultCollection"; + rename -uid "E5014237-4DA9-6FC0-633D-69AFA56161B3"; + addAttr -ci true -sn "es" -ln "expandedState" -min 0 -max 1 -at "bool"; + setAttr ".es" yes; +createNode simpleSelector -n "defaultCollectionSelector"; + rename -uid "7CA2F6D8-483C-B020-BC03-EF9563A52163"; + setAttr ".pat" -type "string" "*"; +createNode objectSet -n "_renderingMain:Main"; + rename -uid "1EEC3A3B-49CF-0C79-5340-39805174FB8A"; + addAttr -s false -ci true -sn "renderlayer" -ln "renderlayer" -at "message"; + addAttr -ci true -sn "id" -ln "id" -dt "string"; + addAttr -ci true -sn "family" -ln "family" -dt "string"; + addAttr -ci true -sn "subset" -ln "subset" -dt "string"; + addAttr -ci true -sn "active" -ln "active" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "creator_identifier" -ln "creator_identifier" -dt "string"; + addAttr -ci true -sn "variant" -ln "variant" -dt "string"; + addAttr -ci true -sn "asset" -ln "asset" -dt "string"; + addAttr -ci true -sn "task" -ln "task" -dt "string"; + addAttr -ci true -sn "instance_id" -ln "instance_id" -dt "string"; + addAttr -ci true -sn "review" -ln "review" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "extendFrames" -ln "extendFrames" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "overrideExistingFrame" -ln "overrideExistingFrame" -min 0 + -max 1 -at "bool"; + addAttr -ci true -sn "tileRendering" -ln "tileRendering" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "tilesX" -ln "tilesX" -at "long"; + addAttr -ci true -sn "tilesY" -ln "tilesY" -at "long"; + addAttr -ci true -sn "convertToScanline" -ln "convertToScanline" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "useReferencedAovs" -ln "useReferencedAovs" -min 0 -max 1 -at "bool"; + addAttr -ci true -sn "renderSetupIncludeLights" -ln "renderSetupIncludeLights" -min + 0 -max 1 -at "bool"; + addAttr -ci true -sn "publish_attributes" -ln "publish_attributes" -dt "string"; + addAttr -ci true -sn "__creator_attributes_keys" -ln "__creator_attributes_keys" + -dt "string"; + addAttr -ci true -sn "cbId" -ln "cbId" -dt "string"; + setAttr ".ihi" 0; + setAttr ".id" -type "string" "pyblish.avalon.instance"; + setAttr ".family" -type "string" "renderlayer"; + setAttr ".subset" -type "string" "renderMain"; + setAttr -cb on ".active" yes; + setAttr ".creator_identifier" -type "string" "io.openpype.creators.maya.renderlayer"; + setAttr ".variant" -type "string" "Main"; + setAttr ".asset" -type "string" "test_asset"; + setAttr ".task" -type "string" "test_task"; + setAttr ".instance_id" -type "string" "8a9cfb85-9602-4e5e-a4bc-27a2986bae7f"; + setAttr -cb on ".review" yes; + setAttr -cb on ".extendFrames"; + setAttr -cb on ".overrideExistingFrame" yes; + setAttr -cb on ".tileRendering"; + setAttr -cb on ".tilesX" 2; + setAttr -cb on ".tilesY" 2; + setAttr -cb on ".convertToScanline"; + setAttr -cb on ".useReferencedAovs"; + setAttr -cb on ".renderSetupIncludeLights" yes; + setAttr ".publish_attributes" -type "string" "{\"CollectDeadlinePools\": {\"primaryPool\": \"\", \"secondaryPool\": \"\"}, \"ValidateDeadlinePools\": {\"active\": true}, \"ValidateFrameRange\": {\"active\": true}, \"ExtractImportReference\": {\"active\": false}, \"MayaSubmitDeadline\": {\"priority\": 50, \"chunkSize\": 1, \"machineList\": \"\", \"whitelist\": false, \"tile_priority\": 50, \"strict_error_checking\": true}, \"ProcessSubmittedJobOnFarm\": {\"publishJobState\": \"Active\"}}"; + setAttr ".__creator_attributes_keys" -type "string" "review,extendFrames,overrideExistingFrame,tileRendering,tilesX,tilesY,convertToScanline,useReferencedAovs,renderSetupIncludeLights"; + setAttr ".cbId" -type "string" "60df31e2be2b48bd3695c056:69960f336351"; +select -ne :time1; + setAttr ".o" 1001; + setAttr ".unw" 1001; +select -ne :hardwareRenderingGlobals; + setAttr ".otfna" -type "stringArray" 22 "NURBS Curves" "NURBS Surfaces" "Polygons" "Subdiv Surface" "Particles" "Particle Instance" "Fluids" "Strokes" "Image Planes" "UI" "Lights" "Cameras" "Locators" "Joints" "IK Handles" "Deformers" "Motion Trails" "Components" "Hair Systems" "Follicles" "Misc. UI" "Ornaments" ; + setAttr ".otfva" -type "Int32Array" 22 0 1 1 1 1 1 + 1 1 1 0 0 0 0 0 0 0 0 0 + 0 0 0 0 ; + setAttr ".fprt" yes; +select -ne :renderPartition; + setAttr -s 2 ".st"; +select -ne :renderGlobalsList1; +select -ne :defaultShaderList1; + setAttr -s 5 ".s"; +select -ne :postProcessList1; + setAttr -s 2 ".p"; +select -ne :defaultRenderingList1; + setAttr -s 2 ".r"; +select -ne :standardSurface1; + setAttr ".b" 0.80000001192092896; + setAttr ".bc" -type "float3" 1 1 1 ; + setAttr ".s" 0.20000000298023224; +select -ne :initialShadingGroup; + setAttr -s 2 ".dsm"; + setAttr ".ro" yes; +select -ne :initialParticleSE; + setAttr ".ro" yes; +select -ne :defaultRenderGlobals; + addAttr -ci true -h true -sn "dss" -ln "defaultSurfaceShader" -dt "string"; + setAttr ".ren" -type "string" "arnold"; + setAttr ".outf" 51; + setAttr ".imfkey" -type "string" "exr"; + setAttr ".an" yes; + setAttr ".fs" 1001; + setAttr ".ef" 1001; + setAttr ".oft" -type "string" ""; + setAttr ".pff" yes; + setAttr ".ifp" -type "string" "//"; + setAttr ".rv" -type "string" ""; + setAttr ".pram" -type "string" ""; + setAttr ".poam" -type "string" ""; + setAttr ".prlm" -type "string" ""; + setAttr ".polm" -type "string" ""; + setAttr ".prm" -type "string" ""; + setAttr ".pom" -type "string" ""; + setAttr ".dss" -type "string" "lambert1"; +select -ne :defaultResolution; + setAttr ".w" 1920; + setAttr ".h" 1080; + setAttr ".pa" 1; + setAttr ".al" yes; + setAttr ".dar" 1.6666666269302368; +select -ne :defaultColorMgtGlobals; + setAttr ".cfe" yes; + setAttr ".cfp" -type "string" "/OCIO-configs/Maya-legacy/config.ocio"; + setAttr ".vtn" -type "string" "sRGB gamma (legacy)"; + setAttr ".vn" -type "string" "sRGB gamma"; + setAttr ".dn" -type "string" "legacy"; + setAttr ".wsn" -type "string" "scene-linear Rec 709/sRGB"; + setAttr ".ovt" no; + setAttr ".povt" no; + setAttr ".otn" -type "string" "sRGB gamma (legacy)"; + setAttr ".potn" -type "string" "sRGB gamma (legacy)"; +select -ne :hardwareRenderGlobals; + setAttr ".ctrs" 256; + setAttr ".btrs" 512; +select -ne :ikSystem; + setAttr -s 4 ".sol"; +connectAttr "rs_Main.ri" ":persp.rlio[0]"; +connectAttr "rs_Main.ri" ":top.rlio[0]"; +connectAttr "rs_Main.ri" ":front.rlio[0]"; +connectAttr "rs_Main.ri" ":side.rlio[0]"; +connectAttr "rs_Main.ri" "pSphere1_GEO.rlio[0]"; +connectAttr "polySphere1.out" "pSphere1_GEOShape1.i"; +connectAttr "rs_Main.ri" "pDisc1.rlio[0]"; +connectAttr "polyDisc1.output" "pDiscShape1.i"; +connectAttr "rs_Main.ri" "persp1.rlio[0]"; +relationship "link" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "link" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialShadingGroup.message" ":defaultLightSet.message"; +relationship "shadowLink" ":lightLinker1" ":initialParticleSE.message" ":defaultLightSet.message"; +connectAttr "layerManager.dli[0]" "defaultLayer.id"; +connectAttr "renderLayerManager.rlmi[0]" "defaultRenderLayer.rlid"; +connectAttr "Main.msg" "renderSetup.frl"; +connectAttr "Main.msg" "renderSetup.lrl"; +connectAttr "pSphere1_GEO.iog" "modelMain.dsm" -na; +connectAttr ":defaultArnoldDisplayDriver.msg" ":defaultArnoldRenderOptions.drivers" + -na; +connectAttr ":defaultArnoldFilter.msg" ":defaultArnoldRenderOptions.filt"; +connectAttr ":defaultArnoldDriver.msg" ":defaultArnoldRenderOptions.drvr"; +connectAttr "_renderingMain:Main.msg" "renderingMain.dnsm" -na; +connectAttr "rs_Main.msg" "Main.lrl"; +connectAttr "renderSetup.lit" "Main.pls"; +connectAttr "defaultCollection.msg" "Main.cl"; +connectAttr "defaultCollection.msg" "Main.ch"; +connectAttr "renderLayerManager.rlmi[1]" "rs_Main.rlid"; +connectAttr "defaultCollectionSelector.c" "defaultCollection.sel"; +connectAttr "Main.lit" "defaultCollection.pls"; +connectAttr "Main.nic" "defaultCollection.pic"; +connectAttr "Main.msg" "_renderingMain:Main.renderlayer"; +connectAttr "defaultRenderLayer.msg" ":defaultRenderingList1.r" -na; +connectAttr "rs_Main.msg" ":defaultRenderingList1.r" -na; +connectAttr "pSphere1_GEOShape1.iog" ":initialShadingGroup.dsm" -na; +connectAttr "pDiscShape1.iog" ":initialShadingGroup.dsm" -na; +// End of test_project_test_asset_test_task_v001.ma diff --git a/tests/integration/hosts/nuke/test_publish_in_nuke.py b/tests/integration/hosts/nuke/test_publish_in_nuke.py index bfd84e4fd5..b7bb8716c0 100644 --- a/tests/integration/hosts/nuke/test_publish_in_nuke.py +++ b/tests/integration/hosts/nuke/test_publish_in_nuke.py @@ -68,7 +68,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass): name="workfileTest_task")) failures.append( - DBAssert.count_of_types(dbcon, "representation", 4)) + DBAssert.count_of_types(dbcon, "representation", 3)) additional_args = {"context.subset": "workfileTest_task", "context.ext": "nk"} @@ -85,7 +85,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass): additional_args = {"context.subset": "renderTest_taskMain", "name": "thumbnail"} failures.append( - DBAssert.count_of_types(dbcon, "representation", 1, + DBAssert.count_of_types(dbcon, "representation", 0, additional_args=additional_args)) additional_args = {"context.subset": "renderTest_taskMain", diff --git a/tests/lib/file_handler.py b/tests/lib/file_handler.py index 07f6962c98..82d08804b7 100644 --- a/tests/lib/file_handler.py +++ b/tests/lib/file_handler.py @@ -8,15 +8,16 @@ import itertools import hashlib import tarfile import zipfile - +from abc import ABCMeta, abstractmethod +import six +import shutil import requests USER_AGENT = "AYON-launcher" -class RemoteFileHandler: - """Download file from url, might be GDrive shareable link""" - +@six.add_metaclass(ABCMeta) +class BaseFileHandler: IMPLEMENTED_ZIP_FORMATS = { "zip", "tar", "tgz", "tar.gz", "tar.xz", "tar.bz2" } @@ -33,6 +34,7 @@ class RemoteFileHandler: def check_md5(fpath, md5, **kwargs): return md5 == RemoteFileHandler.calculate_md5(fpath, **kwargs) + @staticmethod def calculate_sha256(fpath): """Calculate sha256 for content of the file. @@ -69,6 +71,75 @@ class RemoteFileHandler: if hash_type == "sha256": return RemoteFileHandler.check_sha256(fpath, hash_value) + @staticmethod + def unzip(path, destination_path=None): + if not destination_path: + destination_path = os.path.dirname(path) + + _, archive_type = os.path.splitext(path) + archive_type = archive_type.lstrip(".") + + if archive_type in ["zip"]: + print(f"Unzipping {path}->{destination_path}") + zip_file = zipfile.ZipFile(path) + zip_file.extractall(destination_path) + zip_file.close() + + elif archive_type in [ + "tar", "tgz", "tar.gz", "tar.xz", "tar.bz2" + ]: + print(f"Unzipping {path}->{destination_path}") + if archive_type == "tar": + tar_type = "r:" + elif archive_type.endswith("xz"): + tar_type = "r:xz" + elif archive_type.endswith("gz"): + tar_type = "r:gz" + elif archive_type.endswith("bz2"): + tar_type = "r:bz2" + else: + tar_type = "r:*" + try: + tar_file = tarfile.open(path, tar_type) + except tarfile.ReadError: + raise SystemExit("corrupted archive") + tar_file.extractall(destination_path) + tar_file.close() + + @staticmethod + @abstractmethod + def download_test_source_files(file_id, root, filename=None): + """Download a test source files and place it in root. + Args: + file_id (str): id of file to be downloaded + root (str): Directory to place downloaded file in + filename (str, optional): Name to save the file under. + If None, use the id of the file. + """ + raise NotImplementedError + + +class LocalFileHandler(BaseFileHandler): + + @staticmethod + def download_test_source_files(source_path, tmp_dir, filename=None): + tmp_dir = os.path.expanduser(tmp_dir) + if os.path.isdir(source_path): + shutil.copytree(source_path, tmp_dir, dirs_exist_ok=True) + else: + file_name = os.path.basename(source_path) + shutil.copy(source_path, + os.path.join(tmp_dir, file_name)) + + +class RemoteFileHandler(BaseFileHandler): + """Download file from url, might be GDrive shareable link""" + + @staticmethod + def download_test_source_files(file_id, tmp_dir, filename=None): + RemoteFileHandler.download_file_from_google_drive(file_id, tmp_dir, + filename) + @staticmethod def download_url( url, @@ -124,7 +195,7 @@ class RemoteFileHandler: @staticmethod def download_file_from_google_drive( - file_id, root, filename=None + file_id, tmp_dir, filename=None ): """Download a Google Drive file from and place it in root. Args: @@ -137,12 +208,12 @@ class RemoteFileHandler: url = "https://docs.google.com/uc?export=download" - root = os.path.expanduser(root) + tmp_dir = os.path.expanduser(tmp_dir) if not filename: filename = file_id - fpath = os.path.join(root, filename) + fpath = os.path.join(tmp_dir, filename) - os.makedirs(root, exist_ok=True) + os.makedirs(tmp_dir, exist_ok=True) if os.path.isfile(fpath) and RemoteFileHandler.check_integrity(fpath): print(f"Using downloaded and verified file: {fpath}") @@ -175,41 +246,6 @@ class RemoteFileHandler: response_content_generator), fpath) response.close() - @staticmethod - def unzip(path, destination_path=None): - if not destination_path: - destination_path = os.path.dirname(path) - - _, archive_type = os.path.splitext(path) - archive_type = archive_type.lstrip(".") - - if archive_type in ["zip"]: - print(f"Unzipping {path}->{destination_path}") - zip_file = zipfile.ZipFile(path) - zip_file.extractall(destination_path) - zip_file.close() - - elif archive_type in [ - "tar", "tgz", "tar.gz", "tar.xz", "tar.bz2" - ]: - print(f"Unzipping {path}->{destination_path}") - if archive_type == "tar": - tar_type = "r:" - elif archive_type.endswith("xz"): - tar_type = "r:xz" - elif archive_type.endswith("gz"): - tar_type = "r:gz" - elif archive_type.endswith("bz2"): - tar_type = "r:bz2" - else: - tar_type = "r:*" - try: - tar_file = tarfile.open(path, tar_type) - except tarfile.ReadError: - raise SystemExit("corrupted archive") - tar_file.extractall(destination_path) - tar_file.close() - @staticmethod def _urlretrieve(url, filename, chunk_size=None, headers=None): final_headers = {"User-Agent": USER_AGENT} diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 2af4af02de..7700381aa6 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -10,9 +10,11 @@ import glob import platform import requests import re +import inspect +import time from tests.lib.db_handler import DBHandler -from tests.lib.file_handler import RemoteFileHandler +from tests.lib.file_handler import RemoteFileHandler, LocalFileHandler from openpype.modules import ModulesManager from openpype.settings import get_project_settings @@ -79,14 +81,25 @@ class ModuleUnitTest(BaseTest): for test_file in self.TEST_FILES: file_id, file_name, md5 = test_file - f_name, ext = os.path.splitext(file_name) + current_dir = os.path.dirname(os.path.abspath( + inspect.getfile(self.__class__))) + if os.path.exists(file_id): + handler_class = LocalFileHandler + elif os.path.exists(os.path.join(current_dir, file_id)): + file_id = os.path.join(current_dir, file_id) + handler_class = LocalFileHandler + else: + handler_class = RemoteFileHandler - RemoteFileHandler.download_file_from_google_drive(file_id, - str(tmpdir), - file_name) + handler_class.download_test_source_files(file_id, str(tmpdir), + file_name) + ext = None + if "." in file_name: + _, ext = os.path.splitext(file_name) + + if ext and ext.lstrip('.') in handler_class.IMPLEMENTED_ZIP_FORMATS: # noqa: E501 + handler_class.unzip(os.path.join(tmpdir, file_name)) - if ext.lstrip('.') in RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS: # noqa: E501 - RemoteFileHandler.unzip(os.path.join(tmpdir, file_name)) yield tmpdir persist = (persist or self.PERSIST or @@ -105,7 +118,7 @@ class ModuleUnitTest(BaseTest): yield path @pytest.fixture(scope="module") - def env_var(self, monkeypatch_session, download_test_data): + def env_var(self, monkeypatch_session, download_test_data, mongo_url): """Sets temporary env vars from json file.""" env_url = os.path.join(download_test_data, "input", "env_vars", "env_var.json") @@ -129,6 +142,9 @@ class ModuleUnitTest(BaseTest): monkeypatch_session.setenv(key, str(value)) #reset connection to openpype DB with new env var + if mongo_url: + monkeypatch_session.setenv("OPENPYPE_MONGO", mongo_url) + import openpype.settings.lib as sett_lib sett_lib._SETTINGS_HANDLER = None sett_lib._LOCAL_SETTINGS_HANDLER = None @@ -147,10 +163,9 @@ class ModuleUnitTest(BaseTest): @pytest.fixture(scope="module") def db_setup(self, download_test_data, env_var, monkeypatch_session, - request): + request, mongo_url): """Restore prepared MongoDB dumps into selected DB.""" backup_dir = os.path.join(download_test_data, "input", "dumps") - uri = os.environ.get("OPENPYPE_MONGO") db_handler = DBHandler(uri) db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir, @@ -203,11 +218,7 @@ class ModuleUnitTest(BaseTest): yield mongo_client[self.TEST_OPENPYPE_NAME]["settings"] def is_test_failed(self, request): - # if request.node doesn't have rep_call, something failed - try: - return request.node.rep_call.failed - except AttributeError: - return True + return getattr(request.node, "module_test_failure", False) class PublishTest(ModuleUnitTest): @@ -246,19 +257,22 @@ class PublishTest(ModuleUnitTest): SETUP_ONLY = False @pytest.fixture(scope="module") - def app_name(self, app_variant): + def app_name(self, app_variant, app_group): """Returns calculated value for ApplicationManager. Eg.(nuke/12-2)""" from openpype.lib import ApplicationManager app_variant = app_variant or self.APP_VARIANT + app_group = app_group or self.APP_GROUP application_manager = ApplicationManager() if not app_variant: variant = ( application_manager.find_latest_available_variant_for_group( - self.APP_GROUP)) + app_group + ) + ) app_variant = variant.name - yield "{}/{}".format(self.APP_GROUP, app_variant) + yield "{}/{}".format(app_group, app_variant) @pytest.fixture(scope="module") def app_args(self, download_test_data): @@ -329,7 +343,7 @@ class PublishTest(ModuleUnitTest): print("Creating only setup for test, not launching app") yield False return - import time + time_start = time.time() timeout = timeout or self.TIMEOUT timeout = float(timeout) @@ -359,26 +373,42 @@ class PublishTest(ModuleUnitTest): expected_dir_base = os.path.join(download_test_data, "expected") - print("Comparing published:'{}' : expected:'{}'".format( - published_dir_base, expected_dir_base)) - published = set(f.replace(published_dir_base, '') for f in - glob.glob(published_dir_base + "\\**", recursive=True) - if f != published_dir_base and os.path.exists(f)) - expected = set(f.replace(expected_dir_base, '') for f in - glob.glob(expected_dir_base + "\\**", recursive=True) - if f != expected_dir_base and os.path.exists(f)) + print( + "Comparing published: '{}' | expected: '{}'".format( + published_dir_base, expected_dir_base + ) + ) - filtered_published = self._filter_files(published, - skip_compare_folders) + def get_files(dir_base): + result = set() + + for f in glob.glob(dir_base + "\\**", recursive=True): + if os.path.isdir(f): + continue + + if f != dir_base and os.path.exists(f): + result.add(f.replace(dir_base, "")) + + return result + + published = get_files(published_dir_base) + expected = get_files(expected_dir_base) + + filtered_published = self._filter_files( + published, skip_compare_folders + ) # filter out temp files also in expected # could be polluted by accident by copying 'output' to zip file filtered_expected = self._filter_files(expected, skip_compare_folders) - not_mtched = filtered_expected.symmetric_difference(filtered_published) - if not_mtched: - raise AssertionError("Missing {} files".format( - "\n".join(sorted(not_mtched)))) + not_matched = filtered_expected.symmetric_difference( + filtered_published + ) + if not_matched: + raise AssertionError( + "Missing {} files".format("\n".join(sorted(not_matched))) + ) def _filter_files(self, source_files, skip_compare_folders): """Filter list of files according to regex pattern.""" diff --git a/tests/unit/openpype/hosts/photoshop/test_lib.py b/tests/unit/openpype/hosts/photoshop/test_lib.py new file mode 100644 index 0000000000..ad4feb42ae --- /dev/null +++ b/tests/unit/openpype/hosts/photoshop/test_lib.py @@ -0,0 +1,25 @@ +import pytest + +from openpype.hosts.photoshop.lib import clean_subset_name + +""" +Tests cleanup of unused layer placeholder ({layer}) from subset name. +Layer differentiation might be desired in subset name, but in some cases it +might be used (in `auto_image` - only single image without layer diff., +single image instance created without toggled use of subset name etc.) +""" + + +def test_no_layer_placeholder(): + clean_subset = clean_subset_name("imageMain") + assert "imageMain" == clean_subset + + +@pytest.mark.parametrize("subset_name", + ["imageMain{Layer}", + "imageMain_{layer}", # trailing _ + "image{Layer}Main", + "image{LAYER}Main"]) +def test_not_used_layer_placeholder(subset_name): + clean_subset = clean_subset_name(subset_name) + assert "imageMain" == clean_subset diff --git a/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py b/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py index 17e47c9f64..f472b8052a 100644 --- a/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py +++ b/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py @@ -19,7 +19,7 @@ import logging from pyblish.api import Instance as PyblishInstance from tests.lib.testing_classes import BaseTest -from openpype.plugins.publish.validate_sequence_frames import ( +from openpype.hosts.unreal.plugins.publish.validate_sequence_frames import ( ValidateSequenceFrames ) @@ -38,7 +38,13 @@ class TestValidateSequenceFrames(BaseTest): data = { "frameStart": 1001, "frameEnd": 1002, - "representations": [] + "representations": [], + "assetEntity": { + "data": { + "clipIn": 1001, + "clipOut": 1002, + } + } } yield Instance @@ -58,6 +64,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1001 + instance.data["assetEntity"]["data"]["clipOut"] = 1001 plugin.process(instance) @@ -84,49 +91,11 @@ class TestValidateSequenceFrames(BaseTest): plugin.process(instance) - @pytest.mark.parametrize("files", - [["Main_beauty.1001.v001.exr", - "Main_beauty.1002.v001.exr"]]) - def test_validate_sequence_frames_wrong_name(self, instance, - plugin, files): - # tests for names with number inside, caused clique failure before - representations = [ - { - "ext": "exr", - "files": files, - } - ] - instance.data["representations"] = representations - - with pytest.raises(AssertionError) as excinfo: - plugin.process(instance) - assert ("Must detect single collection" in - str(excinfo.value)) - - @pytest.mark.parametrize("files", - [["Main_beauty.v001.1001.ass.gz", - "Main_beauty.v001.1002.ass.gz"]]) - def test_validate_sequence_frames_possible_wrong_name( - self, instance, plugin, files): - # currently pattern fails on extensions with dots - representations = [ - { - "files": files, - } - ] - instance.data["representations"] = representations - - with pytest.raises(AssertionError) as excinfo: - plugin.process(instance) - assert ("Must not have remainder" in - str(excinfo.value)) - @pytest.mark.parametrize("files", [["Main_beauty.v001.1001.ass.gz", "Main_beauty.v001.1002.ass.gz"]]) def test_validate_sequence_frames__correct_ext( self, instance, plugin, files): - # currently pattern fails on extensions with dots representations = [ { "ext": "ass.gz", @@ -147,6 +116,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 plugin.process(instance) @@ -160,6 +130,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 with pytest.raises(ValueError) as excinfo: plugin.process(instance) @@ -175,6 +146,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 with pytest.raises(AssertionError) as excinfo: plugin.process(instance) @@ -195,6 +167,7 @@ class TestValidateSequenceFrames(BaseTest): instance.data["slate"] = True instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 plugin.process(instance) diff --git a/tests/unit/openpype/lib/test_delivery.py b/tests/unit/openpype/lib/test_delivery.py index 04a71655e3..f1e435f3f8 100644 --- a/tests/unit/openpype/lib/test_delivery.py +++ b/tests/unit/openpype/lib/test_delivery.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """Test suite for delivery functions.""" -from openpype.lib.delivery import collect_frames +from openpype.lib import collect_frames def test_collect_frames_multi_sequence(): @@ -153,4 +153,3 @@ def test_collect_frames_single_file(): print(ret) assert ret == expected, "Not matching" - diff --git a/tests/unit/openpype/lib/test_event_system.py b/tests/unit/openpype/lib/test_event_system.py new file mode 100644 index 0000000000..aa3f929065 --- /dev/null +++ b/tests/unit/openpype/lib/test_event_system.py @@ -0,0 +1,83 @@ +from openpype.lib.events import EventSystem, QueuedEventSystem + + +def test_default_event_system(): + output = [] + expected_output = [3, 2, 1] + event_system = EventSystem() + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + assert output == expected_output, ( + "Callbacks were not called in correct order") + + +def test_base_event_system_queue(): + output = [] + expected_output = [1, 2, 3] + event_system = QueuedEventSystem() + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + assert output == expected_output, ( + "Callbacks were not called in correct order") + + +def test_manual_event_system_queue(): + output = [] + expected_output = [1, 2, 3] + event_system = QueuedEventSystem(auto_execute=False) + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + while True: + if event_system.process_next_event() is None: + break + + assert output == expected_output, ( + "Callbacks were not called in correct order") diff --git a/tests/unit/openpype/modules/sync_server/test_site_operations.py b/tests/unit/openpype/modules/sync_server/test_site_operations.py index 6a861100a4..c4a83e33a6 100644 --- a/tests/unit/openpype/modules/sync_server/test_site_operations.py +++ b/tests/unit/openpype/modules/sync_server/test_site_operations.py @@ -12,16 +12,19 @@ removes temporary databases (?) """ import pytest +from bson.objectid import ObjectId from tests.lib.testing_classes import ModuleUnitTest -from bson.objectid import ObjectId + +from openpype.modules.sync_server.utils import SiteAlreadyPresentError + class TestSiteOperation(ModuleUnitTest): REPRESENTATION_ID = "60e578d0c987036c6a7b741d" - TEST_FILES = [("1eCwPljuJeOI8A3aisfOIBKKjcmIycTEt", + TEST_FILES = [("1FHE70Hi7y05LLT_1O3Y6jGxwZGXKV9zX", "test_site_operations.zip", '')] @pytest.fixture(scope="module") @@ -71,7 +74,7 @@ class TestSiteOperation(ModuleUnitTest): @pytest.mark.usefixtures("setup_sync_server_module") def test_add_site_again(self, dbcon, setup_sync_server_module): """Depends on test_add_site, must throw exception.""" - with pytest.raises(ValueError): + with pytest.raises(SiteAlreadyPresentError): setup_sync_server_module.add_site(self.TEST_PROJECT_NAME, self.REPRESENTATION_ID, site_name='test_site') diff --git a/tests/unit/openpype/pipeline/publish/test_publish_plugins.py b/tests/unit/openpype/pipeline/publish/test_publish_plugins.py index aace8cf7e3..1f7f551237 100644 --- a/tests/unit/openpype/pipeline/publish/test_publish_plugins.py +++ b/tests/unit/openpype/pipeline/publish/test_publish_plugins.py @@ -37,7 +37,7 @@ class TestPipelinePublishPlugins(TestPipeline): # files are the same as those used in `test_pipeline_colorspace` TEST_FILES = [ ( - "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh", + "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA", "test_pipeline_colorspace.zip", "" ) @@ -123,8 +123,7 @@ class TestPipelinePublishPlugins(TestPipeline): def test_get_colorspace_settings(self, context, config_path_asset): expected_config_template = ( - "{root[work]}/{project[name]}" - "/{hierarchy}/{asset}/config/aces.ocio" + "{root[work]}/{project[name]}/config/aces.ocio" ) expected_file_rules = { "comp_review": { @@ -177,16 +176,16 @@ class TestPipelinePublishPlugins(TestPipeline): # load plugin function for testing plugin = publish_plugins.ColormanagedPyblishPluginMixin() plugin.log = log + context.data["imageioSettings"] = (config_data_nuke, file_rules_nuke) plugin.set_representation_colorspace( - representation_nuke, context, - colorspace_settings=(config_data_nuke, file_rules_nuke) + representation_nuke, context ) # load plugin function for testing plugin = publish_plugins.ColormanagedPyblishPluginMixin() plugin.log = log + context.data["imageioSettings"] = (config_data_hiero, file_rules_hiero) plugin.set_representation_colorspace( - representation_hiero, context, - colorspace_settings=(config_data_hiero, file_rules_hiero) + representation_hiero, context ) colorspace_data_nuke = representation_nuke.get("colorspaceData") diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index c22acee2d4..85faa8ff5d 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -26,12 +26,12 @@ class TestPipelineColorspace(TestPipeline): Example: cd to OpenPype repo root dir - poetry run python ./start.py runtests ../tests/unit/openpype/pipeline - """ + poetry run python ./start.py runtests /tests/unit/openpype/pipeline/test_colorspace.py + """ # noqa: E501 TEST_FILES = [ ( - "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh", + "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA", "test_pipeline_colorspace.zip", "" ) @@ -132,14 +132,14 @@ class TestPipelineColorspace(TestPipeline): path_1 = "renderCompMain_ACES2065-1.####.exr" expected_1 = "ACES2065-1" ret_1 = colorspace.parse_colorspace_from_filepath( - path_1, "nuke", "test_project", project_settings=project_settings + path_1, config_path=config_path_asset ) assert ret_1 == expected_1, f"Not matching colorspace {expected_1}" path_2 = "renderCompMain_BMDFilm_WideGamut_Gen5.mov" expected_2 = "BMDFilm WideGamut Gen5" ret_2 = colorspace.parse_colorspace_from_filepath( - path_2, "nuke", "test_project", project_settings=project_settings + path_2, config_path=config_path_asset ) assert ret_2 == expected_2, f"Not matching colorspace {expected_2}" @@ -185,5 +185,70 @@ class TestPipelineColorspace(TestPipeline): assert expected_hiero == hiero_file_rules, ( f"Not matching file rules {expected_hiero}") + def test_get_imageio_colorspace_from_filepath_p3(self, project_settings): + """Test Colorspace from filepath with python 3 compatibility mode + + Also test ocio v2 file rules + """ + nuke_filepath = "renderCompMain_baking_h264.mp4" + hiero_filepath = "prerenderCompMain.mp4" + + expected_nuke = "Camera Rec.709" + expected_hiero = "Gamma 2.2 Rec.709 - Texture" + + nuke_colorspace = colorspace.get_colorspace_name_from_filepath( + nuke_filepath, + "nuke", + "test_project", + project_settings=project_settings + ) + assert expected_nuke == nuke_colorspace, ( + f"Not matching colorspace {expected_nuke}") + + hiero_colorspace = colorspace.get_colorspace_name_from_filepath( + hiero_filepath, + "hiero", + "test_project", + project_settings=project_settings + ) + assert expected_hiero == hiero_colorspace, ( + f"Not matching colorspace {expected_hiero}") + + def test_get_imageio_colorspace_from_filepath_python2mode( + self, project_settings): + """Test Colorspace from filepath with python 2 compatibility mode + + Also test ocio v2 file rules + """ + nuke_filepath = "renderCompMain_baking_h264.mp4" + hiero_filepath = "prerenderCompMain.mp4" + + expected_nuke = "Camera Rec.709" + expected_hiero = "Gamma 2.2 Rec.709 - Texture" + + # switch to python 2 compatibility mode + colorspace.CachedData.has_compatible_ocio_package = False + + nuke_colorspace = colorspace.get_colorspace_name_from_filepath( + nuke_filepath, + "nuke", + "test_project", + project_settings=project_settings + ) + assert expected_nuke == nuke_colorspace, ( + f"Not matching colorspace {expected_nuke}") + + hiero_colorspace = colorspace.get_colorspace_name_from_filepath( + hiero_filepath, + "hiero", + "test_project", + project_settings=project_settings + ) + assert expected_hiero == hiero_colorspace, ( + f"Not matching colorspace {expected_hiero}") + + # return to python 3 compatibility mode + colorspace.CachedData.python3compatible = None + test_case = TestPipelineColorspace() diff --git a/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py b/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py new file mode 100644 index 0000000000..56ac2a5d28 --- /dev/null +++ b/tests/unit/openpype/pipeline/test_colorspace_convert_colorspace_enumerator_item.py @@ -0,0 +1,118 @@ +import unittest +from openpype.pipeline.colorspace import convert_colorspace_enumerator_item + + +class TestConvertColorspaceEnumeratorItem(unittest.TestCase): + def setUp(self): + self.config_items = { + "colorspaces": { + "sRGB": { + "aliases": ["sRGB_1"], + "family": "colorspace", + "categories": ["colors"], + "equalitygroup": "equalitygroup", + }, + "Rec.709": { + "aliases": ["rec709_1", "rec709_2"], + }, + }, + "looks": { + "sRGB_to_Rec.709": { + "process_space": "sRGB", + }, + }, + "displays_views": { + "sRGB (ACES)": { + "view": "sRGB", + "display": "ACES", + }, + "Rec.709 (ACES)": { + "view": "Rec.709", + "display": "ACES", + }, + }, + "roles": { + "compositing_linear": { + "colorspace": "linear", + }, + }, + } + + def test_valid_item(self): + colorspace_item_data = convert_colorspace_enumerator_item( + "colorspaces::sRGB", self.config_items) + self.assertEqual( + colorspace_item_data, + { + "name": "sRGB", + "type": "colorspaces", + "aliases": ["sRGB_1"], + "family": "colorspace", + "categories": ["colors"], + "equalitygroup": "equalitygroup" + } + ) + + alias_item_data = convert_colorspace_enumerator_item( + "aliases::rec709_1", self.config_items) + self.assertEqual( + alias_item_data, + { + "aliases": ["rec709_1", "rec709_2"], + "name": "Rec.709", + "type": "colorspace" + } + ) + + display_view_item_data = convert_colorspace_enumerator_item( + "displays_views::sRGB (ACES)", self.config_items) + self.assertEqual( + display_view_item_data, + { + "type": "displays_views", + "name": "sRGB (ACES)", + "view": "sRGB", + "display": "ACES" + } + ) + + role_item_data = convert_colorspace_enumerator_item( + "roles::compositing_linear", self.config_items) + self.assertEqual( + role_item_data, + { + "name": "compositing_linear", + "type": "roles", + "colorspace": "linear" + } + ) + + look_item_data = convert_colorspace_enumerator_item( + "looks::sRGB_to_Rec.709", self.config_items) + self.assertEqual( + look_item_data, + { + "type": "looks", + "name": "sRGB_to_Rec.709", + "process_space": "sRGB" + } + ) + + def test_invalid_item(self): + config_items = { + "RGB": { + "sRGB": {"red": 255, "green": 255, "blue": 255}, + "AdobeRGB": {"red": 255, "green": 255, "blue": 255}, + } + } + with self.assertRaises(KeyError): + convert_colorspace_enumerator_item("RGB::invalid", config_items) + + def test_missing_config_data(self): + config_items = {} + with self.assertRaises(KeyError): + convert_colorspace_enumerator_item("RGB::sRGB", config_items) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py b/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py new file mode 100644 index 0000000000..c221712d70 --- /dev/null +++ b/tests/unit/openpype/pipeline/test_colorspace_get_colorspaces_enumerator_items.py @@ -0,0 +1,121 @@ +import unittest + +from openpype.pipeline.colorspace import get_colorspaces_enumerator_items + + +class TestGetColorspacesEnumeratorItems(unittest.TestCase): + def setUp(self): + self.config_items = { + "colorspaces": { + "sRGB": { + "aliases": ["sRGB_1"], + }, + "Rec.709": { + "aliases": ["rec709_1", "rec709_2"], + }, + }, + "looks": { + "sRGB_to_Rec.709": { + "process_space": "sRGB", + }, + }, + "displays_views": { + "sRGB (ACES)": { + "view": "sRGB", + "display": "ACES", + }, + "Rec.709 (ACES)": { + "view": "Rec.709", + "display": "ACES", + }, + }, + "roles": { + "compositing_linear": { + "colorspace": "linear", + }, + }, + } + + def test_colorspaces(self): + result = get_colorspaces_enumerator_items(self.config_items) + expected = [ + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ] + self.assertEqual(result, expected) + + def test_aliases(self): + result = get_colorspaces_enumerator_items( + self.config_items, include_aliases=True) + expected = [ + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ("aliases::rec709_1", "[alias] rec709_1 (Rec.709)"), + ("aliases::rec709_2", "[alias] rec709_2 (Rec.709)"), + ("aliases::sRGB_1", "[alias] sRGB_1 (sRGB)"), + ] + self.assertEqual(result, expected) + + def test_looks(self): + result = get_colorspaces_enumerator_items( + self.config_items, include_looks=True) + expected = [ + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ("looks::sRGB_to_Rec.709", "[look] sRGB_to_Rec.709 (sRGB)"), + ] + self.assertEqual(result, expected) + + def test_display_views(self): + result = get_colorspaces_enumerator_items( + self.config_items, include_display_views=True) + expected = [ + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), # noqa: E501 + ("displays_views::sRGB (ACES)", "[view (display)] sRGB (ACES)"), + + ] + self.assertEqual(result, expected) + + def test_roles(self): + result = get_colorspaces_enumerator_items( + self.config_items, include_roles=True) + expected = [ + ("roles::compositing_linear", "[role] compositing_linear (linear)"), # noqa: E501 + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ] + self.assertEqual(result, expected) + + def test_all(self): + message_config_keys = ", ".join( + "'{}':{}".format( + key, + set(self.config_items.get(key, {}).keys()) + ) for key in self.config_items.keys() + ) + print("Testing with config: [{}]".format(message_config_keys)) + result = get_colorspaces_enumerator_items( + self.config_items, + include_aliases=True, + include_looks=True, + include_roles=True, + include_display_views=True, + ) + expected = [ + ("roles::compositing_linear", "[role] compositing_linear (linear)"), # noqa: E501 + ("colorspaces::Rec.709", "[colorspace] Rec.709"), + ("colorspaces::sRGB", "[colorspace] sRGB"), + ("aliases::rec709_1", "[alias] rec709_1 (Rec.709)"), + ("aliases::rec709_2", "[alias] rec709_2 (Rec.709)"), + ("aliases::sRGB_1", "[alias] sRGB_1 (sRGB)"), + ("looks::sRGB_to_Rec.709", "[look] sRGB_to_Rec.709 (sRGB)"), + ("displays_views::Rec.709 (ACES)", "[view (display)] Rec.709 (ACES)"), # noqa: E501 + ("displays_views::sRGB (ACES)", "[view (display)] sRGB (ACES)"), + ] + self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/docker_build.ps1 b/tools/docker_build.ps1 new file mode 100644 index 0000000000..392165288c --- /dev/null +++ b/tools/docker_build.ps1 @@ -0,0 +1,98 @@ +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$repo_root = (Get-Item $script_dir).parent.FullName + +$env:PSModulePath = $env:PSModulePath + ";$($repo_root)\tools\modules\powershell" + +function Exit-WithCode($exitcode) { + # Only exit this host process if it's a child of another PowerShell parent process... + $parentPID = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" | Select-Object -Property ParentProcessId).ParentProcessId + $parentProcName = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentPID" | Select-Object -Property Name).Name + if ('powershell.exe' -eq $parentProcName) { $host.SetShouldExit($exitcode) } + + exit $exitcode +} + +function Restore-Cwd() { + $tmp_current_dir = Get-Location + if ("$tmp_current_dir" -ne "$current_dir") { + Write-Color -Text ">>> ", "Restoring current directory" -Color Green, Gray + Set-Location -Path $current_dir + } +} + +function Get-Container { + if (-not (Test-Path -PathType Leaf -Path "$($repo_root)\build\docker-image.id")) { + Write-Color -Text "!!! ", "Docker command failed, cannot find image id." -Color Red, Yellow + Restore-Cwd + Exit-WithCode 1 + } + $id = Get-Content "$($repo_root)\build\docker-image.id" + Write-Color -Text ">>> ", "Creating container from image id ", "[", $id, "]" -Color Green, Gray, White, Cyan, White + $cid = docker create $id bash + if ($LASTEXITCODE -ne 0) { + Write-Color -Text "!!! ", "Cannot create container." -Color Red, Yellow + Restore-Cwd + Exit-WithCode 1 + } + return $cid +} + +function Change-Cwd() { + Set-Location -Path $repo_root +} + +function New-DockerBuild { + $version_file = Get-Content -Path "$($repo_root)\openpype\version.py" + $result = [regex]::Matches($version_file, '__version__ = "(?\d+\.\d+.\d+.*)"') + $openpype_version = $result[0].Groups['version'].Value + $startTime = [int][double]::Parse((Get-Date -UFormat %s)) + Write-Color -Text ">>> ", "Building OpenPype using Docker ..." -Color Green, Gray, White + $variant = $args[0] + if ($variant.Length -eq 0) { + $dockerfile = "$($repo_root)\Dockerfile" + } else { + $dockerfile = "$( $repo_root )\Dockerfile.$variant" + } + if (-not (Test-Path -PathType Leaf -Path $dockerfile)) { + Write-Color -Text "!!! ", "Dockerfile for specifed platform ", "[", $variant, "]", "doesn't exist." -Color Red, Yellow, Cyan, White, Cyan, Yellow + Restore-Cwd + Exit-WithCode 1 + } + Write-Color -Text ">>> ", "Using Dockerfile for ", "[ ", $variant, " ]" -Color Green, Gray, White, Cyan, White + + $build_dir = "$($repo_root)\build" + if (-not(Test-Path $build_dir)) { + New-Item -ItemType Directory -Path $build_dir + } + Write-Color -Text "--- ", "Cleaning build directory ..." -Color Yellow, Gray + try { + Remove-Item -Recurse -Force "$($build_dir)\*" + } catch { + Write-Color -Text "!!! ", "Cannot clean build directory, possibly because process is using it." -Color Red, Gray + Write-Color -Text $_.Exception.Message -Color Red + Exit-WithCode 1 + } + + Write-Color -Text ">>> ", "Running Docker build ..." -Color Green, Gray, White + docker build --pull --iidfile $repo_root/build/docker-image.id --build-arg BUILD_DATE=$(Get-Date -UFormat %Y-%m-%dT%H:%M:%SZ) --build-arg VERSION=$openpype_version -t pypeclub/openpype:$openpype_version -f $dockerfile . + if ($LASTEXITCODE -ne 0) { + Write-Color -Text "!!! ", "Docker command failed.", $LASTEXITCODE -Color Red, Yellow, Red + Restore-Cwd + Exit-WithCode 1 + } + Write-Color -Text ">>> ", "Copying build from container ..." -Color Green, Gray, White + $cid = Get-Container + + docker cp "$($cid):/opt/openpype/build/exe.linux-x86_64-3.9" "$($repo_root)/build" + docker cp "$($cid):/opt/openpype/build/build.log" "$($repo_root)/build" + + $endTime = [int][double]::Parse((Get-Date -UFormat %s)) + try { + New-BurntToastNotification -AppLogo "$openpype_root/openpype/resources/icons/openpype_icon.png" -Text "OpenPype build complete!", "All done in $( $endTime - $startTime ) secs. You will find OpenPype and build log in build directory." + } catch {} + Write-Color -Text "*** ", "All done in ", $($endTime - $startTime), " secs. You will find OpenPype and build log in ", "'.\build'", " directory." -Color Green, Gray, White, Gray, White, Gray +} + +Change-Cwd +New-DockerBuild $ARGS diff --git a/website/docs/admin_hosts_aftereffects.md b/website/docs/admin_hosts_aftereffects.md index 974428fe06..72fdb32faf 100644 --- a/website/docs/admin_hosts_aftereffects.md +++ b/website/docs/admin_hosts_aftereffects.md @@ -18,6 +18,10 @@ Location: Settings > Project > AfterEffects ## Publish plugins +### Collect Review + +Enable/disable creation of auto instance of review. + ### Validate Scene Settings #### Skip Resolution Check for Tasks @@ -28,6 +32,10 @@ Set regex pattern(s) to look for in a Task name to skip resolution check against Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. +### ValidateContainers + +By default this validator will look loaded items with lower version than latest. This validator is context wide so it must be disabled in Context button. + ### AfterEffects Submit to Deadline * `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. diff --git a/website/docs/admin_hosts_houdini.md b/website/docs/admin_hosts_houdini.md index 64c54db591..18c390e07f 100644 --- a/website/docs/admin_hosts_houdini.md +++ b/website/docs/admin_hosts_houdini.md @@ -3,9 +3,36 @@ id: admin_hosts_houdini title: Houdini sidebar_label: Houdini --- +## General Settings +### Houdini Vars + +Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task. + +Using template keys is supported but formatting keys capitalization variants is not, e.g. `{Asset}` and `{ASSET}` won't work + + +:::note +If `Treat as directory` toggle is activated, Openpype will consider the given value is a path of a folder. + +If the folder does not exist on the context change it will be created by this feature so that the path will always try to point to an existing folder. +::: + +Disabling `Update Houdini vars on context change` feature will leave all Houdini vars unmanaged and thus no context update changes will occur. + +> If `$JOB` is present in the Houdini var list and has an empty value, OpenPype will set its value to `$HIP` + + +:::note +For consistency reasons we always force all vars to be uppercase. +e.g. `myvar` will be `MYVAR` +::: + +![update-houdini-vars-context-change](assets/houdini/update-houdini-vars-context-change.png) + + ## Shelves Manager You can add your custom shelf set into Houdini by setting your shelf sets, shelves and tools in **Houdini -> Shelves Manager**. ![Custom menu definition](assets/houdini-admin_shelvesmanager.png) -The Shelf Set Path is used to load a .shelf file to generate your shelf set. If the path is specified, you don't have to set the shelves and tools. \ No newline at end of file +The Shelf Set Path is used to load a .shelf file to generate your shelf set. If the path is specified, you don't have to set the shelves and tools. diff --git a/website/docs/admin_hosts_photoshop.md b/website/docs/admin_hosts_photoshop.md index de684f01d2..d79789760e 100644 --- a/website/docs/admin_hosts_photoshop.md +++ b/website/docs/admin_hosts_photoshop.md @@ -33,7 +33,6 @@ Provides list of [variants](artist_concepts.md#variant) that will be shown to an Provides simplified publishing process. It will create single `image` instance for artist automatically. This instance will produce flatten image from all visible layers in a workfile. -- Subset template for flatten image - provide template for subset name for this instance (example `imageBeauty`) - Review - should be separate review created for this instance ### Create Review @@ -111,11 +110,11 @@ Set Byte limit for review file. Applicable if gigantic `image` instances are pro #### Extract jpg Options -Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults. +Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults. #### Extract mov Options -Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults. +Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults. ### Workfile Builder @@ -124,4 +123,4 @@ Allows to open prepared workfile for an artist when no workfile exists. Useful t Could be configured per `Task type`, eg. `composition` task type could use different `.psd` template file than `art` task. Workfile template must be accessible for all artists. -(Currently not handled by [SiteSync](module_site_sync.md)) \ No newline at end of file +(Currently not handled by [SiteSync](module_site_sync.md)) diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md index b254beb53b..8935b29fb5 100644 --- a/website/docs/admin_settings_local.md +++ b/website/docs/admin_settings_local.md @@ -16,13 +16,19 @@ OpenPype stores some of it's settings and configuration in local file system. Th ## Categories ### OpenPype Mongo URL +The **Mongo URL** is the database URL given by your Studio. More details [here](artist_getting_started.md#mongodb). ### General +**OpenPype Username** : enter your username (if not provided, it uses computer session username by default). This username is used to sign your actions on **OpenPype**, for example the "author" on a publish. +**Admin permissions** : When enabled you do not need to enter a password (if defined in Studio Settings) to access to the **Admin** section. ### Experimental tools +Future version of existing tools or new ones. +### Environments +Local replacement of the environment data of each software and additional internal data necessary to be loaded correctly. ### Applications +Local override of software executable paths for each version. More details [here](admin_settings_system.md#applications). ### Project Settings - - +The **Project Settings** allows to determine the root folder. More details [here](module_site_sync.md#local-settings). diff --git a/website/docs/artist_hosts_3dsmax.md b/website/docs/artist_hosts_3dsmax.md index fffab8ca5d..6f23a19103 100644 --- a/website/docs/artist_hosts_3dsmax.md +++ b/website/docs/artist_hosts_3dsmax.md @@ -118,4 +118,28 @@ Current OpenPype integration (ver 3.15.0) supports only ```PointCache```, ```Ca This part of documentation is still work in progress. ::: +## Validators + +Current Openpype integration supports different validators such as Frame Range and Attributes. +Some validators are mandatory while some are optional and user can choose to enable them in the setting. + +**Validate Frame Range**: Optional Validator for checking Frame Range + +**Validate Attributes**: Optional Validator for checking if object properties' attributes are valid + in MaxWrapper Class. +:::note + Users can write the properties' attributes they want to check in dict format in the setting + before validation. The attributes are then to be converted into Maxscript and do a check. + E.g. ```renderers.current.separateAovFiles``` and ```renderers.current.PrimaryGIEngine``` + User can put the attributes in the dict format below + ``` + { + "renderer.current":{ + "separateAovFiles" : True + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" + } + } + ``` + ![Validate Attribute Setting](assets/3dsmax_validate_attributes.png) +::: ## ...to be added diff --git a/website/docs/artist_hosts_houdini.md b/website/docs/artist_hosts_houdini.md index 940d5ac351..c4e8293c36 100644 --- a/website/docs/artist_hosts_houdini.md +++ b/website/docs/artist_hosts_houdini.md @@ -83,6 +83,30 @@ select your render camera. All the render outputs are stored in the pyblish/render directory within your project path.\ For Karma-specific render, it also outputs the USD render as default. +## Publishing cache to Deadline +Artist can publish cache to deadline which increases productivity as artist can use local machine +could be used for other tasks. +Caching on the farm is supported for: + +**Arnold ASS (.ass)** +**Pointcache (.bgeo and .abc)** +**VDB (.vdb)** +**Redshift Proxy (.rs)** + +To submit your cache to deadline, you need to create the instance(s) with clicking +**Submitting to Farm** and you can also enable **Use selection** to +select the object for caching in farm. +![Houdini Farm Cache Creator](assets/houdini_farm_cache_creator.png) + +When you go to Publish Tab and click the instance(s), you can set up your preferred +**Frame per task**. +![Houdini Farm Per Task](assets/houdini_frame_per_task.png) + +Once you hit **Publish**, the cache would be submitted and rendered in deadline. +When the render is finished, all the caches would be located in your publish folder. +You can see them in the Loader. +![Houdini Farm Per Task](assets/houdini_farm_cache_loader.png) + ## USD (experimental support) ### Publishing USD You can publish your Solaris Stage as USD file. diff --git a/website/docs/artist_publish.md b/website/docs/artist_publish.md index 321eb5c56a..b1be2e629e 100644 --- a/website/docs/artist_publish.md +++ b/website/docs/artist_publish.md @@ -33,39 +33,41 @@ The Instances are categorized into β€˜families’ based on what type of data the Following family definitions and requirements are OpenPype defaults and what we consider good industry practice, but most of the requirements can be easily altered to suit the studio or project needs. Here's a list of supported families -| Family | Comment | Example Subsets | -| ----------------------- | ------------------------------------------------ | ------------------------- | -| [Model](#model) | Cleaned geo without materials | main, proxy, broken | -| [Look](#look) | Package of shaders, assignments and textures | main, wet, dirty | -| [Rig](#rig) | Characters or props with animation controls | main, deform, sim | -| [Assembly](#assembly) | A complex model made from multiple other models. | main, deform, sim | -| [Layout](#layout) | Simple representation of the environment | main, | -| [Setdress](#setdress) | Environment containing only referenced assets | main, | -| [Camera](#camera) | May contain trackers or proxy geo | main, tracked, anim | -| [Animation](#animation) | Animation exported from a rig. | characterA, vehicleB | -| [Cache](#cache) | Arbitrary animated geometry or fx cache | rest, ROM , pose01 | -| MayaAscii | Maya publishes that don't fit other categories | | -| [Render](#render) | Rendered frames from CG or Comp | | -| RenderSetup | Scene render settings, AOVs and layers | | -| Plate | Ingested, transcode, conformed footage | raw, graded, imageplane | -| Write | Nuke write nodes for rendering | | -| Image | Any non-plate image to be used by artists | Reference, ConceptArt | -| LayeredImage | Software agnostic layered image with metadata | Reference, ConceptArt | -| Review | Reviewable video or image. | | -| Matchmove | Matchmoved camera, potentially with geometry | main | -| Workfile | Backup of the workfile with all its content | uses the task name | -| Nukenodes | Any collection of nuke nodes | maskSetup, usefulBackdrop | -| Yeticache | Cached out yeti fur setup | | -| YetiRig | Yeti groom ready to be applied to geometry cache | main, destroyed | -| VrayProxy | Vray proxy geometry for rendering | | -| VrayScene | Vray full scene export | | -| ArnodldStandin | All arnold .ass archives for rendering | main, wet, dirty | -| LUT | | | -| Nukenodes | | | -| Gizmo | | | -| Nukenodes | | | -| Harmony.template | | | -| Harmony.palette | | | +| Family | Comment | Example Subsets | +|-------------------------|-------------------------------------------------------| ------------------------- | +| [Model](#model) | Cleaned geo without materials | main, proxy, broken | +| [Look](#look) | Package of shaders, assignments and textures | main, wet, dirty | +| [Rig](#rig) | Characters or props with animation controls | main, deform, sim | +| [Assembly](#assembly) | A complex model made from multiple other models. | main, deform, sim | +| [Layout](#layout) | Simple representation of the environment | main, | +| [Setdress](#setdress) | Environment containing only referenced assets | main, | +| [Camera](#camera) | May contain trackers or proxy geo, only single camera | main, tracked, anim | +| | expected. | | +| [Animation](#animation) | Animation exported from a rig. | characterA, vehicleB | +| [Cache](#cache) | Arbitrary animated geometry or fx cache | rest, ROM , pose01 | +| MayaAscii | Maya publishes that don't fit other categories | | +| [Render](#render) | Rendered frames from CG or Comp | | +| RenderSetup | Scene render settings, AOVs and layers | | +| Plate | Ingested, transcode, conformed footage | raw, graded, imageplane | +| Write | Nuke write nodes for rendering | | +| Image | Any non-plate image to be used by artists | Reference, ConceptArt | +| LayeredImage | Software agnostic layered image with metadata | Reference, ConceptArt | +| Review | Reviewable video or image. | | +| Matchmove | Matchmoved camera, potentially with geometry, allows | main | +| | multiple cameras even with planes. | | +| Workfile | Backup of the workfile with all its content | uses the task name | +| Nukenodes | Any collection of nuke nodes | maskSetup, usefulBackdrop | +| Yeticache | Cached out yeti fur setup | | +| YetiRig | Yeti groom ready to be applied to geometry cache | main, destroyed | +| VrayProxy | Vray proxy geometry for rendering | | +| VrayScene | Vray full scene export | | +| ArnodldStandin | All arnold .ass archives for rendering | main, wet, dirty | +| LUT | | | +| Nukenodes | | | +| Gizmo | | | +| Nukenodes | | | +| Harmony.template | | | +| Harmony.palette | | | @@ -161,7 +163,7 @@ Example Representations: ### Animation Published result of an animation created with a rig. Animation can be extracted -as animation curves, cached out geometry or even fully animated rig with all the controllers. +as animation curves, cached out geometry or even fully animated rig with all the controllers. Animation cache is usually defined by a rigger in the rig file of a character or by FX TD in the effects rig, to ensure consistency of outputs. diff --git a/website/docs/assets/3dsmax_validate_attributes.png b/website/docs/assets/3dsmax_validate_attributes.png new file mode 100644 index 0000000000..5af8236188 Binary files /dev/null and b/website/docs/assets/3dsmax_validate_attributes.png differ diff --git a/website/docs/assets/admin_hosts_photoshop_settings.png b/website/docs/assets/admin_hosts_photoshop_settings.png index aaa6ecbed7..9478fbedf7 100644 Binary files a/website/docs/assets/admin_hosts_photoshop_settings.png and b/website/docs/assets/admin_hosts_photoshop_settings.png differ diff --git a/website/docs/assets/houdini/update-houdini-vars-context-change.png b/website/docs/assets/houdini/update-houdini-vars-context-change.png new file mode 100644 index 0000000000..74ac8d86c9 Binary files /dev/null and b/website/docs/assets/houdini/update-houdini-vars-context-change.png differ diff --git a/website/docs/assets/houdini_farm_cache_creator.png b/website/docs/assets/houdini_farm_cache_creator.png new file mode 100644 index 0000000000..bf0af80336 Binary files /dev/null and b/website/docs/assets/houdini_farm_cache_creator.png differ diff --git a/website/docs/assets/houdini_farm_cache_loader.png b/website/docs/assets/houdini_farm_cache_loader.png new file mode 100644 index 0000000000..27b0122fab Binary files /dev/null and b/website/docs/assets/houdini_farm_cache_loader.png differ diff --git a/website/docs/assets/houdini_frame_per_task.png b/website/docs/assets/houdini_frame_per_task.png new file mode 100644 index 0000000000..a7d8b33114 Binary files /dev/null and b/website/docs/assets/houdini_frame_per_task.png differ diff --git a/website/docs/assets/settings/settings_local.png b/website/docs/assets/settings/settings_local.png index d2cf1c920d..725c332747 100644 Binary files a/website/docs/assets/settings/settings_local.png and b/website/docs/assets/settings/settings_local.png differ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 5ddf247d98..27aa60a464 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -189,10 +189,10 @@ A profile may generate multiple outputs from a single input. Each output must de - Profile filtering defines which group of output definitions is used but output definitions may require more specific filters on their own. - They may filter by subset name (regex can be used) or publish families. Publish families are more complex as are based on knowing code base. - Filtering by custom tags -> this is used for targeting to output definitions from other extractors using settings (at this moment only Nuke bake extractor can target using custom tags). - - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataMov/outputs/baking/add_custom_tags` + - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewIntermediates/outputs/baking/add_custom_tags` - Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input. - + ### Extract Burnin Plugin is responsible for adding burnins into review representations. @@ -226,13 +226,13 @@ A burnin profile may set multiple burnin outputs from one input. The burnin's na | **Bottom Centered** | Bottom center content. | str | "{username}" | | **Bottom Right** | Bottom right corner content. | str | "{frame_start}-{current_frame}-{frame_end}" | -Each burnin profile can be configured with additional family filtering and can -add additional tags to the burnin representation, these can be configured under +Each burnin profile can be configured with additional family filtering and can +add additional tags to the burnin representation, these can be configured under the profile's **Additional filtering** section. :::note Filename suffix -The filename suffix is appended to filename of the source representation. For -example, if the source representation has suffix **"h264"** and the burnin +The filename suffix is appended to filename of the source representation. For +example, if the source representation has suffix **"h264"** and the burnin suffix is **"client"** then the final suffix is **"h264_client"**. ::: @@ -343,6 +343,10 @@ One of the key advantages of this feature is that it allows users to choose the In some cases, these DCCs (Nuke, Houdini, Maya) automatically add a rendering path during the creation stage, which is then used in publishing. Creators and extractors of such DCCs need to use these profiles to fill paths in DCC's nodes to use this functionality. +:::note +Maya's setting `project_settings/maya/RenderSettings/default_render_image_folder` is be overwritten by the custom staging dir. +::: + The custom staging folder uses a path template configured in `project_anatomy/templates/others` with `transient` being a default example path that could be used. The template requires a 'folder' key for it to be usable as custom staging folder. ##### Known issues diff --git a/website/docs/pype2/admin_presets_plugins.md b/website/docs/pype2/admin_presets_plugins.md index 6a057f4bb4..b5e8a3b8a8 100644 --- a/website/docs/pype2/admin_presets_plugins.md +++ b/website/docs/pype2/admin_presets_plugins.md @@ -534,8 +534,7 @@ Plugin responsible for generating thumbnails with colorspace controlled by Nuke. } ``` -### `ExtractReviewDataMov` - +### `ExtractReviewIntermediates` `viewer_lut_raw` **true** will publish the baked mov file without any colorspace conversion. It will be baked with the workfile workspace. This can happen in case the Viewer input process uses baked screen space luts. #### baking with controlled colorspace