Merge branch 'develop' into feature/OP-4247_Data-Exchange-proxies

This commit is contained in:
Milan Kolar 2023-05-29 23:26:20 +02:00 committed by GitHub
commit fd07632068
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
379 changed files with 11607 additions and 5944 deletions

View file

@ -35,6 +35,19 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.15.9-nightly.1
- 3.15.8
- 3.15.8-nightly.3
- 3.15.8-nightly.2
- 3.15.8-nightly.1
- 3.15.7
- 3.15.7-nightly.3
- 3.15.7-nightly.2
- 3.15.7-nightly.1
- 3.15.6
- 3.15.6-nightly.3
- 3.15.6-nightly.2
- 3.15.6-nightly.1
- 3.15.5
- 3.15.5-nightly.2
- 3.15.5-nightly.1
@ -122,19 +135,6 @@ body:
- 3.14.2-nightly.5
- 3.14.2-nightly.4
- 3.14.2-nightly.3
- 3.14.2-nightly.2
- 3.14.2-nightly.1
- 3.14.1
- 3.14.1-nightly.4
- 3.14.1-nightly.3
- 3.14.1-nightly.2
- 3.14.1-nightly.1
- 3.14.0
- 3.14.0-nightly.1
- 3.13.1-nightly.3
- 3.13.1-nightly.2
- 3.13.1-nightly.1
- 3.13.0
validations:
required: true
- type: dropdown

View file

@ -18,10 +18,16 @@ jobs:
uses: ynput/gha-populate-form-version@main
with:
github_token: ${{ secrets.YNPUT_BOT_TOKEN }}
github_user: ${{ secrets.CI_USER }}
github_email: ${{ secrets.CI_EMAIL }}
registry: github
dropdown: _version
limit_to: 100
form: .github/ISSUE_TEMPLATE/bug_report.yml
commit_message: 'chore(): update bug report / version'
dry_run: no-push
- name: Push to protected develop branch
uses: CasperWA/push-protected@v2.10.0
with:
token: ${{ secrets.YNPUT_BOT_TOKEN }}
branch: develop
unprotect_reviews: true

6
.gitignore vendored
View file

@ -112,3 +112,9 @@ tools/run_eventserver.*
tools/dev_*
.github_changelog_generator
# Addons
########
/openpype/addons/*
!/openpype/addons/README.md

5
.gitmodules vendored
View file

@ -4,4 +4,7 @@
[submodule "tools/modules/powershell/PSWriteColor"]
path = tools/modules/powershell/PSWriteColor
url = https://github.com/EvotecIT/PSWriteColor.git
url = https://github.com/EvotecIT/PSWriteColor.git
[submodule "openpype/hosts/unreal/integration"]
path = openpype/hosts/unreal/integration
url = https://github.com/ynput/ayon-unreal-plugin.git

View file

@ -1,6 +1,849 @@
# Changelog
## [3.15.8](https://github.com/ynput/OpenPype/tree/3.15.8)
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.7...3.15.8)
### **🆕 New features**
<details>
<summary>Publisher: Show instances in report page <a href="https://github.com/ynput/OpenPype/pull/4915">#4915</a></summary>
Show publish instances in report page. Also added basic log view with logs grouped by instance. Validation error detail now have 2 colums, one with erro details second with logs. Crashed state shows fast access to report action buttons. Success will show only logs. Publish frame is shrunked automatically on publish stop.
___
</details>
<details>
<summary>Fusion - Loader plugins updates <a href="https://github.com/ynput/OpenPype/pull/4920">#4920</a></summary>
Update to some Fusion loader plugins:The sequence loader can now load footage from the image and online family.The FBX loader can now import all formats Fusions FBX node can read.You can now import the content of another workfile into your current comp with the workfile loader.
___
</details>
<details>
<summary>Fusion: deadline farm rendering <a href="https://github.com/ynput/OpenPype/pull/4955">#4955</a></summary>
Enabling Fusion for deadline farm rendering.
___
</details>
<details>
<summary>AfterEffects: set frame range and resolution <a href="https://github.com/ynput/OpenPype/pull/4983">#4983</a></summary>
Frame information (frame start, duration, fps) and resolution (width and height) is applied to selected composition from Asset Management System (Ftrack or DB) automatically when published instance is created.It is also possible explicitly propagate both values from DB to selected composition by newly added menu buttons.
___
</details>
<details>
<summary>Publish: Enhance automated publish plugin settings <a href="https://github.com/ynput/OpenPype/pull/4986">#4986</a></summary>
Added plugins option to define settings category where to look for settings of a plugin and added public helper functions to apply settings `get_plugin_settings` and `apply_plugin_settings_automatically`.
___
</details>
### **🚀 Enhancements**
<details>
<summary>Load Rig References - Change Rig to Animation in Animation instance <a href="https://github.com/ynput/OpenPype/pull/4877">#4877</a></summary>
We are using the template builder to build an animation scene. All the rig placeholders are imported correctly, but the automatically created animation instances retain the rig family in their names and subsets. In our example, we need animationMain instead of rigMain, because this name will be used in the following steps like lighting.Here is the result we need. I checked, and it's not a template builder problem, because even if I load a rig as a reference, the result is the same. For me, since we are in the animation instance, it makes more sense to have animation instead of rig in the name. The naming is just fine if we use create from the Openpype menu.
___
</details>
<details>
<summary>Enhancement: Resolve prelaunch code refactoring and update defaults <a href="https://github.com/ynput/OpenPype/pull/4916">#4916</a></summary>
The main reason of this PR is wrong default settings in `openpype/settings/defaults/system_settings/applications.json` for Resolve host. The `bin` folder should not be a part of the macos and Linux `RESOLVE_PYTHON3_PATH` variable.The rest of this PR is some code cleanups for Resolve prelaunch hook to simplify further development.Also added a .gitignore for vscode workspace files.
___
</details>
<details>
<summary>Unreal: 🚚 move Unreal plugin to separate repository <a href="https://github.com/ynput/OpenPype/pull/4980">#4980</a></summary>
To support Epic Marketplace have to move AYON Unreal integration plugins to separate repository. This is replacing current files with git submodule, so the change should be functionally without impact.New repository lives here: https://github.com/ynput/ayon-unreal-plugin
___
</details>
<details>
<summary>General: Lib code cleanup <a href="https://github.com/ynput/OpenPype/pull/5003">#5003</a></summary>
Small cleanup in lib files in openpype.
___
</details>
<details>
<summary>Allow to open with djv by extension instead of representation name <a href="https://github.com/ynput/OpenPype/pull/5004">#5004</a></summary>
Filter open in djv action by extension instead of representation.
___
</details>
<details>
<summary>DJV open action `extensions` as `set` <a href="https://github.com/ynput/OpenPype/pull/5005">#5005</a></summary>
Change `extensions` attribute to `set`.
___
</details>
<details>
<summary>Nuke: extract thumbnail with multiple reposition nodes <a href="https://github.com/ynput/OpenPype/pull/5011">#5011</a></summary>
Added support for multiple reposition nodes.
___
</details>
<details>
<summary>Enhancement: Improve logging levels and messages for artist facing publish reports <a href="https://github.com/ynput/OpenPype/pull/5018">#5018</a></summary>
Tweak the logging levels and messages to try and only show those logs that an artist should see and could understand. Move anything that's slightly more involved into a "debug" message instead.
___
</details>
### **🐛 Bug fixes**
<details>
<summary>Bugfix/frame variable fix <a href="https://github.com/ynput/OpenPype/pull/4978">#4978</a></summary>
Renamed variables to match OpenPype terminology to reduce confusion and add consistency.
___
</details>
<details>
<summary>Global: plugins cleanup plugin will leave beauty rendered files <a href="https://github.com/ynput/OpenPype/pull/4790">#4790</a></summary>
Attempt to mark more files to be cleaned up explicitly in intermediate `renders` folder in work area for farm jobs.
___
</details>
<details>
<summary>Fix: Download last workfile doesn't work if not already downloaded <a href="https://github.com/ynput/OpenPype/pull/4942">#4942</a></summary>
Some optimization condition is messing with the feature: if the published workfile is not already downloaded, it won't download it...
___
</details>
<details>
<summary>Unreal: Fix transform when loading layout to match existing assets <a href="https://github.com/ynput/OpenPype/pull/4972">#4972</a></summary>
Fixed transform when loading layout to match existing assets.
___
</details>
<details>
<summary>fix the bug of fbx loaders in Max <a href="https://github.com/ynput/OpenPype/pull/4977">#4977</a></summary>
bug fix of fbx loaders for not being able to parent to the CON instances while importing cameras(and models) which is published from other DCCs such as Maya.
___
</details>
<details>
<summary>AfterEffects: allow returning stub with not saved workfile <a href="https://github.com/ynput/OpenPype/pull/4984">#4984</a></summary>
Allows to use Workfile app to Save first empty workfile.
___
</details>
<details>
<summary>Blender: Fix Alembic loading <a href="https://github.com/ynput/OpenPype/pull/4985">#4985</a></summary>
Fixed problem occurring when trying to load an Alembic model in Blender.
___
</details>
<details>
<summary>Unreal: Addon Py2 compatibility <a href="https://github.com/ynput/OpenPype/pull/4994">#4994</a></summary>
Fixed Python 2 compatibility of unreal addon.
___
</details>
<details>
<summary>Nuke: fixed missing files key in representation <a href="https://github.com/ynput/OpenPype/pull/4999">#4999</a></summary>
Issue with missing keys once rendering target set to existing frames is fixed. Instance has to be evaluated in validation for missing files.
___
</details>
<details>
<summary>Unreal: Fix the frame range when loading camera <a href="https://github.com/ynput/OpenPype/pull/5002">#5002</a></summary>
The keyframes of the camera, when loaded, were not using the correct frame range.
___
</details>
<details>
<summary>Fusion: fixing frame range targeting <a href="https://github.com/ynput/OpenPype/pull/5013">#5013</a></summary>
Frame range targeting at Rendering instances is now following configured options.
___
</details>
<details>
<summary>Deadline: fix selection from multiple webservices <a href="https://github.com/ynput/OpenPype/pull/5015">#5015</a></summary>
Multiple different DL webservice could be configured. First they must by configured in System Settings., then they could be configured per project in `project_settings/deadline/deadline_servers`.Only single webservice could be a target of publish though.
___
</details>
### **Merged pull requests**
<details>
<summary>3dsmax: Refactored publish plugins to use proper implementation of pymxs <a href="https://github.com/ynput/OpenPype/pull/4988">#4988</a></summary>
___
</details>
## [3.15.7](https://github.com/ynput/OpenPype/tree/3.15.7)
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.6...3.15.7)
### **🆕 New features**
<details>
<summary>Addons directory <a href="https://github.com/ynput/OpenPype/pull/4893">#4893</a></summary>
This adds a directory for Addons, for easier distribution of studio specific code.
___
</details>
<details>
<summary>Kitsu - Add "image", "online" and "plate" to review families <a href="https://github.com/ynput/OpenPype/pull/4923">#4923</a></summary>
This PR adds "image", "online" and "plate" to the review families so they also can be uploaded to Kitsu.It also adds the `Add review to Kitsu` tag to the default png review. Without it the user would manually need to add it for single image uploads to Kitsu and might confuse users (it confused me first for a while as movies did work).
___
</details>
<details>
<summary>Feature/remove and load inv action <a href="https://github.com/ynput/OpenPype/pull/4930">#4930</a></summary>
Added the ability to remove and load a container, as a way to reset it.This can be useful in cases where a container breaks in a way that can be fixed by removing it, then reloading it.Also added the ability to add `InventoryAction` plugins by placing them in `openpype/plugins/inventory`.
___
</details>
### **🚀 Enhancements**
<details>
<summary>Load Rig References - Change Rig to Animation in Animation instance <a href="https://github.com/ynput/OpenPype/pull/4877">#4877</a></summary>
We are using the template builder to build an animation scene. All the rig placeholders are imported correctly, but the automatically created animation instances retain the rig family in their names and subsets. In our example, we need animationMain instead of rigMain, because this name will be used in the following steps like lighting.Here is the result we need. I checked, and it's not a template builder problem, because even if I load a rig as a reference, the result is the same. For me, since we are in the animation instance, it makes more sense to have animation instead of rig in the name. The naming is just fine if we use create from the Openpype menu.
___
</details>
<details>
<summary>Maya template builder - preserve all references when importing a template <a href="https://github.com/ynput/OpenPype/pull/4797">#4797</a></summary>
When building a template with Maya template builder, we import the template and also the references inside the template file. This causes some problems:
- We cannot use the references to version assets imported by the template.
- When we import the file, the internal reference files are also imported. As a side effect, Maya complains about a reference that no longer exists.`// Error: file: /xxx/maya/2023.3/linux/scripts/AETemplates/AEtransformRelated.mel line 58: Reference node 'turntable_mayaSceneMain_01_RN' is not associated with a reference file.`
___
</details>
<details>
<summary>Unreal: Renaming the integration plugin to Ayon. <a href="https://github.com/ynput/OpenPype/pull/4646">#4646</a></summary>
Renamed the .h, and .cpp files to Ayon. Also renamed the classes to with the Ayon keyword.
___
</details>
<details>
<summary>3dsMax: render dialogue needs to be closed <a href="https://github.com/ynput/OpenPype/pull/4729">#4729</a></summary>
Make sure the render setup dialog is in a closed state for the update of resolution and other render settings
___
</details>
<details>
<summary>Maya Template Builder - Remove default cameras from renderable cameras <a href="https://github.com/ynput/OpenPype/pull/4815">#4815</a></summary>
When we build an asset workfile with build workfile from template inside Maya, we load our turntable camera. But then we end up with 2 renderables camera : **persp** the one imported from the template.We need to remove the **persp** camera (or any other default camera) from renderable cameras when building the work file.
___
</details>
<details>
<summary>Validators for Frame Range in Max <a href="https://github.com/ynput/OpenPype/pull/4914">#4914</a></summary>
Switch Render Frame Range Type to 3 for specific ranges (initial setup for the range type is 4)Reset Frame Range will also set the frame range for render settingsRender Collector won't take the frame range from context data but take the range directly from render settingAdd validators for render frame range type and frame range respectively with repair action
___
</details>
<details>
<summary>Fusion: Saver creator settings <a href="https://github.com/ynput/OpenPype/pull/4943">#4943</a></summary>
Adding Saver creator settings and enhanced rendering path with template.
___
</details>
<details>
<summary>General: Project Anatomy on creators <a href="https://github.com/ynput/OpenPype/pull/4962">#4962</a></summary>
Anatomy object of current project is available on `CreateContext` and create plugins.
___
</details>
### **🐛 Bug fixes**
<details>
<summary>Maya: Validate shader name - OP-5903 <a href="https://github.com/ynput/OpenPype/pull/4971">#4971</a></summary>
Running the plugin would error with:
```
// TypeError: 'str' object cannot be interpreted as an integer
```Fixed and added setting `active`.
___
</details>
<details>
<summary>Houdini: Fix slow Houdini launch due to shelves generation <a href="https://github.com/ynput/OpenPype/pull/4829">#4829</a></summary>
Shelf generation during Houdini startup would add an insane amount of delay for the Houdini UI to launch correctly. By deferring the shelf generation this takes away the 5+ minutes of delay for the Houdini UI to launch.
___
</details>
<details>
<summary>Fusion - Fixed "optional validation" <a href="https://github.com/ynput/OpenPype/pull/4912">#4912</a></summary>
Added OptionalPyblishPluginMixin and is_active checks for all publish tools that should be optional
___
</details>
<details>
<summary>Bug: add missing `pyblish.util` import <a href="https://github.com/ynput/OpenPype/pull/4937">#4937</a></summary>
remote publishing was missing import of `remote_publish`. This is adding it back.
___
</details>
<details>
<summary>Unreal: Fix missing 'object_path' property <a href="https://github.com/ynput/OpenPype/pull/4938">#4938</a></summary>
Epic removed the `object_path` property from `AssetData`. This PR fixes usages of that property.Fixes #4936
___
</details>
<details>
<summary>Remove obsolete global validator <a href="https://github.com/ynput/OpenPype/pull/4939">#4939</a></summary>
Removing `Validate Sequence Frames` validator from global plugins as it wasn't handling correctly many things and was by mistake enabled, breaking functionality on Deadline.
___
</details>
<details>
<summary>General: fix build_workfile get_linked_assets missing project_name arg <a href="https://github.com/ynput/OpenPype/pull/4940">#4940</a></summary>
Linked assets collection don't work within `build_workfile` because `get_linked_assets` function call has a missing `project_name`argument.
- Added the `project_name` arg to the `get_linked_assets` function call.
___
</details>
<details>
<summary>General: fix Scene Inventory switch version error dialog missing parent arg on init <a href="https://github.com/ynput/OpenPype/pull/4941">#4941</a></summary>
QuickFix for the switch version error dialog to set inventory widget as parent.
___
</details>
<details>
<summary>Unreal: Fix camera frame range <a href="https://github.com/ynput/OpenPype/pull/4956">#4956</a></summary>
Fix the frame range of the level sequence for the Camera in Unreal.
___
</details>
<details>
<summary>Unreal: Fix missing parameter when updating Alembic StaticMesh <a href="https://github.com/ynput/OpenPype/pull/4957">#4957</a></summary>
Fix an error when updating an Alembic StaticMesh in Unreal, due to a missing parameter in a function call.
___
</details>
<details>
<summary>Unreal: Fix render extraction <a href="https://github.com/ynput/OpenPype/pull/4963">#4963</a></summary>
Fix a problem with the extraction of renders in Unreal.
___
</details>
<details>
<summary>Unreal: Remove Python 3.8 syntax from addon <a href="https://github.com/ynput/OpenPype/pull/4965">#4965</a></summary>
Removed Python 3.8 syntax from addon.
___
</details>
<details>
<summary>Ftrack: Fix editorial task creation <a href="https://github.com/ynput/OpenPype/pull/4966">#4966</a></summary>
Fix key assignment on instance data during editorial publishing in ftrack hierarchy integration.
___
</details>
### **Merged pull requests**
<details>
<summary>Add "shortcut" to Scripts Menu Definition <a href="https://github.com/ynput/OpenPype/pull/4927">#4927</a></summary>
Add the possibility to associate a shorcut for an entry in the script menu definition with the key "shortcut"
___
</details>
## [3.15.6](https://github.com/ynput/OpenPype/tree/3.15.6)
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.5...3.15.6)
### **🆕 New features**
<details>
<summary>Substance Painter Integration <a href="https://github.com/ynput/OpenPype/pull/4283">#4283</a></summary>
<strong>This implements a part of #4205 by implementing a Substance Painter integration
</strong>Status:
- [x] Implement Host
- [x] start substance with last workfile using `AddLastWorkfileToLaunchArgs` prelaunch hook
- [x] Implement Qt tools
- [x] Implement loaders
- [x] Implemented a Set project mesh loader (this is relatively special case because a Project will always have exactly one mesh - a Substance Painter project cannot exist without a mesh).
- [x] Implement project open callback
- [x] On project open it notifies the user if the loaded model is outdated
- [x] Implement publishing logic
- [x] Workfile publishing
- [x] Export Texture Sets
- [x] Support OCIO using #4195 (draft brach is set up - see comment)
- [ ] Likely needs more testing on the OCIO front
- [x] Validate all outputs of the Export template are exported/generated
- [x] Allow validation to be optional **(issue: there's no API method to detect what maps will be exported without doing an actual export to disk)**
- [x] Support extracting/integration if not all outputs are generated
- [x] Support multiple materials/texture sets per instance
- [ ] Add validator that can enforce only a single texture set output if studio prefers that.
- [ ] Implement Export File Format (extensions) override in Creator
- [ ] Add settings so Admin can choose which extensions are available.
___
</details>
<details>
<summary>Data Exchange: Geometry in 3dsMax <a href="https://github.com/ynput/OpenPype/pull/4555">#4555</a></summary>
<strong>Introduces and updates a creator, extractors and loaders for model family
</strong>Introduces new creator, extractors and loaders for model family while adding model families into the existing max scene loader and extractor
- [x] creators
- [x] adding model family into max scene loader and extractor
- [x] fbx loader
- [x] fbx extractor
- [x] usd loader
- [x] usd extractor
- [x] validator for model family
- [x] obj loader(update function)
- [x] fix the update function of the loader as #4675
- [x] Add documentation
___
</details>
<details>
<summary>AfterEffects: add review flag to each instance <a href="https://github.com/ynput/OpenPype/pull/4884">#4884</a></summary>
Adds `mark_for_review` flag to the Creator to allow artists to disable review if necessary.Exposed this flag in Settings, by default set to True (eg. same behavior as previously).
___
</details>
### **🚀 Enhancements**
<details>
<summary>Houdini: Fix Validate Output Node (VDB) <a href="https://github.com/ynput/OpenPype/pull/4819">#4819</a></summary>
- Removes plug-in that was a duplicate of this plug-in.
- Optimize logging of many prims slightly
- Fix error reporting like https://github.com/ynput/OpenPype/pull/4818 did
___
</details>
<details>
<summary>Houdini: Add null node as output indicator when using TAB search <a href="https://github.com/ynput/OpenPype/pull/4834">#4834</a></summary>
___
</details>
<details>
<summary>Houdini: Don't error in collect review if camera is not set correctly <a href="https://github.com/ynput/OpenPype/pull/4874">#4874</a></summary>
Do not raise an error in collector when invalid path is set as camera path. Allow camera path to not be set correctly in review instance until validation so it's nicely shown in a validation report.
___
</details>
<details>
<summary>Project packager: Backup and restore can store only database <a href="https://github.com/ynput/OpenPype/pull/4879">#4879</a></summary>
Pack project functionality have option to zip only project database without project files. Unpack project can skip project copy if the folder is not found.Added helper functions to `openpype.client.mongo` that can be also used for tests as replacement of mongo dump.
___
</details>
<details>
<summary>Houdini: ExtractOpenGL for Review instance not optional <a href="https://github.com/ynput/OpenPype/pull/4881">#4881</a></summary>
Don't make ExtractOpenGL optional for review instance optional.
___
</details>
<details>
<summary>Publisher: Small style changes <a href="https://github.com/ynput/OpenPype/pull/4894">#4894</a></summary>
Small changes in styles and form of publisher UI.
___
</details>
<details>
<summary>Houdini: Workfile icon in new publisher <a href="https://github.com/ynput/OpenPype/pull/4898">#4898</a></summary>
Fix icon for the workfile instance in new publisher
___
</details>
<details>
<summary>Fusion: Simplify creator icons code <a href="https://github.com/ynput/OpenPype/pull/4899">#4899</a></summary>
Simplify code for setting the icons for the Fusion creators
___
</details>
<details>
<summary>Enhancement: Fix PySide 6.5 support for loader <a href="https://github.com/ynput/OpenPype/pull/4900">#4900</a></summary>
Fixes PySide 6.5 support in Loader.
___
</details>
### **🐛 Bug fixes**
<details>
<summary>Maya: Validate Attributes <a href="https://github.com/ynput/OpenPype/pull/4917">#4917</a></summary>
This plugin was broken due to bad fetching of data and wrong repair action.
___
</details>
<details>
<summary>Fix: Locally copied version of last published workfile is not incremented <a href="https://github.com/ynput/OpenPype/pull/4722">#4722</a></summary>
### Fix 1
When copied, the local workfile version keeps the published version number, when it must be +1 to follow OP's naming convention.
### Fix 2
Local workfile version's name is built from anatomy. This avoids to get workfiles with their publish template naming.
### Fix 3
In the case a subset has at least two tasks with published workfiles, for example `Modeling` and `Rigging`, launching `Rigging` was getting the first one with the `next` and trying to find representations, therefore `workfileModeling` and trying to match the current `task_name` (`Rigging`) with the `representation["context"]["task"]["name"]` of a Modeling representation, which was ending up to a `workfile_representation` to `None`, and exiting the process.
Trying to find the `task_name` in the `subset['name']` fixes it.
### Fix 4
Fetch input dependencies of workfile.
Replacing https://github.com/ynput/OpenPype/pull/4102 for changes to bring this home.
___
</details>
<details>
<summary>Maya: soft-fail when pan/zoom locked on camera when playblasting <a href="https://github.com/ynput/OpenPype/pull/4929">#4929</a></summary>
When pan/zoom enabled attribute on camera is locked, playblasting with pan/zoom fails because it is trying to restore it. This is fixing it by skipping over with warning.
___
</details>
### **Merged pull requests**
<details>
<summary>Maya Load References - Add Display Handle Setting <a href="https://github.com/ynput/OpenPype/pull/4904">#4904</a></summary>
When we load a reference in Maya using OpenPype loader, display handle is checked by default and prevent us to select easily the object in the viewport. I understand that some productions like to keep this option, so I propose to add display handle to the reference loader settings.
___
</details>
<details>
<summary>Photoshop: add autocreators for review and flat image <a href="https://github.com/ynput/OpenPype/pull/4871">#4871</a></summary>
Review and flatten image (produced when no instance of `image` family was created) were created somehow magically. This PRintroduces two new auto creators which allow artists to disable review or flatten image.For all `image` instances `Review` flag was added to provide functionality to create separate review per `image` instance. Previously was possible only to have separate instance of `review` family.Review is not enabled on `image` family by default. (Eg. follows original behavior)Review auto creator is enabled by default as it was before.Flatten image creator must be set in Settings in `project_settings/photoshop/create/AutoImageCreator`.
___
</details>
## [3.15.5](https://github.com/ynput/OpenPype/tree/3.15.5)

View file

@ -14,10 +14,10 @@ AppId={{B9E9DF6A-5BDA-42DD-9F35-C09D564C4D93}
AppName={#MyAppName}
AppVersion={#AppVer}
AppVerName={#MyAppName} version {#AppVer}
AppPublisher=Orbi Tools s.r.o
AppPublisherURL=http://pype.club
AppSupportURL=http://pype.club
AppUpdatesURL=http://pype.club
AppPublisher=Ynput s.r.o
AppPublisherURL=https://ynput.io
AppSupportURL=https://ynput.io
AppUpdatesURL=https://ynput.io
DefaultDirName={autopf}\{#MyAppName}\{#AppVer}
UsePreviousAppDir=no
DisableProgramGroupPage=yes

View file

@ -0,0 +1,3 @@
This directory is for storing external addons that needs to be included in the pipeline when distributed.
The directory is ignored by Git, but included in the zip and installation files.

View file

@ -25,6 +25,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"blender",
"photoshop",
"tvpaint",
"substancepainter",
"aftereffects"
]

View file

@ -0,0 +1,37 @@
from openpype.lib import PreLaunchHook
from openpype.pipeline.colorspace import get_imageio_config
from openpype.pipeline.template_data import get_template_data
class PreLaunchHostSetOCIO(PreLaunchHook):
"""Set OCIO environment for the host"""
order = 0
app_groups = ["substancepainter"]
def execute(self):
"""Hook entry method."""
anatomy_data = get_template_data(
project_doc=self.data["project_doc"],
asset_doc=self.data["asset_doc"],
task_name=self.data["task_name"],
host_name=self.host_name,
system_settings=self.data["system_settings"]
)
ocio_config = get_imageio_config(
project_name=self.data["project_doc"]["name"],
host_name=self.host_name,
project_settings=self.data["project_settings"],
anatomy_data=anatomy_data,
anatomy=self.data["anatomy"]
)
if ocio_config:
ocio_path = ocio_config["path"]
self.log.info(f"Setting OCIO config path: {ocio_path}")
self.launch_context.env["OCIO"] = ocio_path
else:
self.log.debug("OCIO not set or enabled")

View file

@ -4,9 +4,8 @@ Anything that isn't defined here is INTERNAL and unreliable for external use.
"""
from .launch_logic import (
from .ws_stub import (
get_stub,
stub,
)
from .pipeline import (
@ -18,7 +17,8 @@ from .pipeline import (
from .lib import (
maintained_selection,
get_extension_manifest_path,
get_asset_settings
get_asset_settings,
set_settings
)
from .plugin import (
@ -27,9 +27,8 @@ from .plugin import (
__all__ = [
# launch_logic
# ws_stub
"get_stub",
"stub",
# pipeline
"ls",
@ -39,6 +38,7 @@ __all__ = [
"maintained_selection",
"get_extension_manifest_path",
"get_asset_settings",
"set_settings",
# plugin
"AfterEffectsLoader"

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<ExtensionManifest Version="8.0" ExtensionBundleId="com.openpype.AE.panel" ExtensionBundleVersion="1.0.24"
ExtensionBundleName="openpype" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ExtensionManifest Version="8.0" ExtensionBundleId="com.openpype.AE.panel" ExtensionBundleVersion="1.0.25"
ExtensionBundleName="com.openpype.AE.panel" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ExtensionList>
<Extension Id="com.openpype.AE.panel" Version="1.0" />
</ExtensionList>

View file

@ -2,7 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="css/topcoat-desktop-dark.min.css"/>
<link id="hostStyle" rel="stylesheet" href="css/styles.css"/>
@ -25,11 +25,11 @@
<title></title>
<script src="js/libs/jquery-2.0.2.min.js"></script>
<script type=text/javascript>
$(function() {
$("a#workfiles-button").bind("click", function() {
RPC.call('AfterEffects.workfiles_route').then(function (data) {
}, function (error) {
alert(error);
@ -37,7 +37,7 @@
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#loader-button").bind("click", function() {
@ -48,7 +48,7 @@
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#publish-button").bind("click", function() {
@ -59,7 +59,7 @@
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#sceneinventory-button").bind("click", function() {
@ -70,7 +70,40 @@
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#setresolution-button").bind("click", function() {
RPC.call('AfterEffects.setresolution_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#setframes-button").bind("click", function() {
RPC.call('AfterEffects.setframes_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#setall-button").bind("click", function() {
RPC.call('AfterEffects.setall_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#experimental-button").bind("click", function() {
@ -80,25 +113,28 @@
});
});
});
</script>
</script>
</head>
<body class="hostElt">
<div id="content">
<div>
<div id="content">
<div>
<div></div><a href=# id=workfiles-button><button class="hostFontSize">Workfiles...</button></a></div>
<div><a href=# id=loader-button><button class="hostFontSize">Load...</button></a></div>
<div><a href=# id=publish-button><button class="hostFontSize">Publish...</button></a></div>
<div><a href=# id=sceneinventory-button><button class="hostFontSize">Manage...</button></a></div>
<div><a href=# id=setresolution-button><button class="hostFontSize">Set Resolution</button></a></div>
<div><a href=# id=setframes-button><button class="hostFontSize">Set Frame Range</button></a></div>
<div><a href=# id=setall-button><button class="hostFontSize">Apply All Settings</button></a></div>
<div><a href=# id=experimental-button><button class="hostFontSize">Experimental Tools...</button></a></div>
</div>
</div>
</div>
<!-- <script src="js/libs/PlayerDebugMode"></script> -->
<script src="js/libs/wsrpc.js"></script>
<script src="js/libs/loglevel.min.js"></script>
@ -107,6 +143,6 @@
<script src="js/themeManager.js"></script>
<script src="js/main.js"></script>
</body>
</html>
</html>

View file

@ -4,7 +4,7 @@ indent: 4, maxerr: 50 */
var csInterface = new CSInterface();
log.warn("script start");
WSRPC.DEBUG = false;
@ -14,7 +14,7 @@ WSRPC.TRACE = false;
async function startUp(url){
promis = runEvalScript("getEnv('" + url + "')");
var res = await promis;
var res = await promis;
log.warn("res: " + res);
promis = runEvalScript("getEnv('OPENPYPE_DEBUG')");
@ -56,7 +56,7 @@ function get_extension_version(){
}
function main(websocket_url){
// creates connection to 'websocket_url', registers routes
// creates connection to 'websocket_url', registers routes
var default_url = 'ws://localhost:8099/ws/';
if (websocket_url == ''){
@ -66,7 +66,7 @@ function main(websocket_url){
RPC.connect();
log.warn("connected");
log.warn("connected");
RPC.addRoute('AfterEffects.open', function (data) {
log.warn('Server called client route "open":', data);
@ -88,7 +88,7 @@ function main(websocket_url){
});
RPC.addRoute('AfterEffects.get_active_document_name', function (data) {
log.warn('Server called client route ' +
log.warn('Server called client route ' +
'"get_active_document_name":', data);
return runEvalScript("getActiveDocumentName()")
.then(function(result){
@ -98,7 +98,7 @@ function main(websocket_url){
});
RPC.addRoute('AfterEffects.get_active_document_full_name', function (data){
log.warn('Server called client route ' +
log.warn('Server called client route ' +
'"get_active_document_full_name":', data);
return runEvalScript("getActiveDocumentFullName()")
.then(function(result){
@ -118,7 +118,7 @@ function main(websocket_url){
});
});
RPC.addRoute('AfterEffects.get_selected_items', function (data) {
log.warn('Server called client route "get_selected_items":', data);
return runEvalScript("getSelectedItems(" + data.comps + "," +
@ -194,23 +194,25 @@ function main(websocket_url){
});
});
RPC.addRoute('AfterEffects.get_work_area', function (data) {
log.warn('Server called client route "get_work_area":', data);
return runEvalScript("getWorkArea(" + data.item_id + ")")
RPC.addRoute('AfterEffects.get_comp_properties', function (data) {
log.warn('Server called client route "get_comp_properties":', data);
return runEvalScript("getCompProperties(" + data.item_id + ")")
.then(function(result){
log.warn("getWorkArea: " + result);
log.warn("get_comp_properties: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.set_work_area', function (data) {
RPC.addRoute('AfterEffects.set_comp_properties', function (data) {
log.warn('Server called client route "set_work_area":', data);
return runEvalScript("setWorkArea(" + data.item_id + ',' +
return runEvalScript("setCompProperties(" + data.item_id + ',' +
data.start + ',' +
data.duration + ',' +
data.frame_rate + ")")
data.frame_rate + ',' +
data.width + ',' +
data.height + ")")
.then(function(result){
log.warn("getWorkArea: " + result);
log.warn("set_comp_properties: " + result);
return result;
});
});
@ -255,7 +257,7 @@ function main(websocket_url){
RPC.addRoute('AfterEffects.import_background', function (data) {
log.warn('Server called client route "import_background":', data);
return runEvalScript("importBackground(" + data.comp_id + ", " +
return runEvalScript("importBackground(" + data.comp_id + ", " +
"'" + data.comp_name + "', " +
JSON.stringify(data.files) + ")")
.then(function(result){
@ -266,7 +268,7 @@ function main(websocket_url){
RPC.addRoute('AfterEffects.reload_background', function (data) {
log.warn('Server called client route "reload_background":', data);
return runEvalScript("reloadBackground(" + data.comp_id + ", " +
return runEvalScript("reloadBackground(" + data.comp_id + ", " +
"'" + data.comp_name + "', " +
JSON.stringify(data.files) + ")")
.then(function(result){
@ -314,6 +316,16 @@ function main(websocket_url){
log.warn('Server called client route "close":', data);
return runEvalScript("close()");
});
RPC.addRoute('AfterEffects.print_msg', function (data) {
log.warn('Server called client route "print_msg":', data);
var escaped_msg = EscapeStringForJSX(data.msg);
return runEvalScript("printMsg('" + escaped_msg +"')")
.then(function(result){
log.warn("print_msg: " + result);
return result;
});
});
}
/** main entry point **/
@ -323,17 +335,17 @@ startUp("WEBSOCKET_URL");
'use strict';
var csInterface = new CSInterface();
function init() {
themeManager.init();
$("#btn_test").click(function () {
csInterface.evalScript('sayHello()');
});
}
init();
}());

View file

@ -1,7 +1,7 @@
/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true,
indent: 4, maxerr: 50 */
/*global $, Folder*/
#include "../js/libs/json.js";
//@include "../js/libs/json.js"
/* All public API function should return JSON! */
@ -29,13 +29,13 @@ function getEnv(variable){
function getMetadata(){
/**
* Returns payload in 'Label' field of project's metadata
*
*
**/
if (ExternalObject.AdobeXMPScript === undefined){
ExternalObject.AdobeXMPScript =
new ExternalObject('lib:AdobeXMPScript');
}
var proj = app.project;
var meta = new XMPMeta(app.project.xmpPacket);
var schemaNS = XMPMeta.getNamespaceURI("xmp");
@ -53,7 +53,7 @@ function getMetadata(){
function imprint(payload){
/**
* Stores payload in 'Label' field of project's metadata
*
*
* Args:
* payload (string): json content
*/
@ -61,14 +61,14 @@ function imprint(payload){
ExternalObject.AdobeXMPScript =
new ExternalObject('lib:AdobeXMPScript');
}
var proj = app.project;
var meta = new XMPMeta(app.project.xmpPacket);
var schemaNS = XMPMeta.getNamespaceURI("xmp");
var label = "xmp:Label";
meta.setProperty(schemaNS, label, payload);
app.project.xmpPacket = meta.serialize();
}
@ -116,14 +116,14 @@ function getItems(comps, folders, footages){
/**
* Returns JSON representation of compositions and
* if 'collectLayers' then layers in comps too.
*
*
* Args:
* comps (bool): return selected compositions
* folders (bool): return folders
* footages (bool): return FootageItem
* Returns:
* (list) of JSON items
*/
*/
var items = []
for (i = 1; i <= app.project.items.length; ++i){
var item = app.project.items[i];
@ -142,14 +142,14 @@ function getItems(comps, folders, footages){
function getSelectedItems(comps, folders, footages){
/**
* Returns list of selected items from Project menu
*
*
* Args:
* comps (bool): return selected compositions
* folders (bool): return folders
* footages (bool): return FootageItem
* Returns:
* (list) of JSON items
*/
*/
var items = []
for (i = 0; i < app.project.selection.length; ++i){
var item = app.project.selection[i];
@ -166,9 +166,9 @@ function getSelectedItems(comps, folders, footages){
function _getItem(item, comps, folders, footages){
/**
* Auxiliary function as project items and selections
* Auxiliary function as project items and selections
* are indexed in different way :/
* Refactor
* Refactor
*/
var item_type = '';
if (item instanceof FolderItem){
@ -189,7 +189,7 @@ function _getItem(item, comps, folders, footages){
return "{}";
}
}
var item = {"name": item.name,
"id": item.id,
"type": item_type};
@ -200,7 +200,7 @@ function importFile(path, item_name, import_options){
/**
* Imports file (image tested for now) as a FootageItem.
* Creates new composition
*
*
* Args:
* path (string): absolute path to image file
* item_name (string): label for composition
@ -218,7 +218,7 @@ function importFile(path, item_name, import_options){
app.beginUndoGroup("Import File");
fp = new File(path);
if (fp.exists){
try {
try {
im_opt = new ImportOptions(fp);
importAsType = import_options["ImportAsType"];
@ -234,18 +234,18 @@ function importFile(path, item_name, import_options){
}
if (importAsType.indexOf('PROJECT') > 0){
im_opt.importAs = ImportAsType.PROJECT;
}
}
}
if ('sequence' in import_options){
im_opt.sequence = true;
}
comp = app.project.importFile(im_opt);
if (app.project.selection.length == 2 &&
app.project.selection[0] instanceof FolderItem){
comp.parentFolder = app.project.selection[0]
comp.parentFolder = app.project.selection[0]
}
} catch (error) {
return _prepareError(error.toString() + importOptions.file.fsName);
@ -283,14 +283,14 @@ function setLabelColor(comp_id, color_idx){
function replaceItem(comp_id, path, item_name){
/**
* Replaces loaded file with new file and updates name
*
*
* Args:
* comp_id (int): id of composition, not a index!
* path (string): absolute path to new file
* item_name (string): new composition name
*/
app.beginUndoGroup("Replace File");
fp = new File(path);
if (!fp.exists){
return _prepareError("File " + path + " not found.");
@ -303,7 +303,7 @@ function replaceItem(comp_id, path, item_name){
}else{
item.replace(fp);
}
item.name = item_name;
} catch (error) {
return _prepareError(error.toString() + path);
@ -319,7 +319,7 @@ function replaceItem(comp_id, path, item_name){
function renameItem(item_id, new_name){
/**
* Renames item with 'item_id' to 'new_name'
*
*
* Args:
* item_id (int): id to search item
* new_name (str)
@ -335,7 +335,7 @@ function renameItem(item_id, new_name){
function deleteItem(item_id){
/**
* Delete any 'item_id'
*
*
* Not restricted only to comp, it could delete
* any item with 'id'
*/
@ -347,38 +347,76 @@ function deleteItem(item_id){
}
}
function getWorkArea(comp_id){
function getCompProperties(comp_id){
/**
* Returns information about workarea - are that will be
* rendered. All calculation will be done in OpenPype,
* easier to modify without redeploy of extension.
*
* Returns information about composition - are that will be
* rendered.
*
* Returns
* (dict)
*/
var item = app.project.itemByID(comp_id);
if (item){
return JSON.stringify({
"workAreaStart": item.displayStartFrame,
"workAreaDuration": item.duration,
"frameRate": item.frameRate});
}else{
var comp = app.project.itemByID(comp_id);
if (!comp){
return _prepareError("There is no composition with "+ comp_id);
}
return JSON.stringify({
"id": comp.id,
"name": comp.name,
"frameStart": comp.displayStartFrame,
"framesDuration": comp.duration * comp.frameRate,
"frameRate": comp.frameRate,
"width": comp.width,
"height": comp.height});
}
function setWorkArea(comp_id, workAreaStart, workAreaDuration, frameRate){
function setCompProperties(comp_id, frameStart, framesCount, frameRate,
width, height){
/**
* Sets work area info from outside (from Ftrack via OpenPype)
*/
var item = app.project.itemByID(comp_id);
if (item){
item.displayStartTime = workAreaStart;
item.duration = workAreaDuration;
item.frameRate = frameRate;
}else{
var comp = app.project.itemByID(comp_id);
if (!comp){
return _prepareError("There is no composition with "+ comp_id);
}
app.beginUndoGroup('change comp properties');
if (frameStart && framesCount && frameRate){
comp.displayStartFrame = frameStart;
comp.duration = framesCount / frameRate;
comp.frameRate = frameRate;
}
if (width && height){
var widthOld = comp.width;
var widthNew = width;
var widthDelta = widthNew - widthOld;
var heightOld = comp.height;
var heightNew = height;
var heightDelta = heightNew - heightOld;
var offset = [widthDelta / 2, heightDelta / 2];
comp.width = widthNew;
comp.height = heightNew;
for (var i = 1, il = comp.numLayers; i <= il; i++) {
var layer = comp.layer(i);
var positionProperty = layer.property('ADBE Transform Group').property('ADBE Position');
if (positionProperty.numKeys > 0) {
for (var j = 1, jl = positionProperty.numKeys; j <= jl; j++) {
var keyValue = positionProperty.keyValue(j);
positionProperty.setValueAtKey(j, keyValue + offset);
}
} else {
var positionValue = positionProperty.value;
positionProperty.setValue(positionValue + offset);
}
}
}
app.endUndoGroup();
}
function save(){
@ -504,7 +542,7 @@ function addItemAsLayerToComp(comp_id, item_id, found_comp){
* Args:
* comp_id (int): id of target composition
* item_id (int): FootageItem.id
* found_comp (CompItem, optional): to limit querying if
* found_comp (CompItem, optional): to limit quering if
* comp already found previously
*/
var comp = found_comp || app.project.itemByID(comp_id);
@ -749,7 +787,7 @@ function render(target_folder, comp_id){
var om1 = app.project.renderQueue.item(i).outputModule(1);
var file_name = File.decode( om1.file.name ).replace('℗', ''); // Name contains special character, space?
var omItem1_settable_str = app.project.renderQueue.item(i).outputModule(1).getSettings( GetSettingsFormat.STRING_SETTABLE );
var targetFolder = new Folder(target_folder);
@ -763,7 +801,7 @@ function render(target_folder, comp_id){
render_item.render = false;
}
}
}
app.beginSuppressDialogs();
app.project.renderQueue.render();
@ -779,6 +817,10 @@ function getAppVersion(){
return _prepareSingleValue(app.version);
}
function printMsg(msg){
alert(msg);
}
function _prepareSingleValue(value){
return JSON.stringify({"result": value})
}

View file

@ -1,49 +1,77 @@
import os
import sys
import subprocess
import collections
import logging
import asyncio
import functools
import traceback
from wsrpc_aiohttp import (
WebSocketRoute,
WebSocketAsync
)
from qtpy import QtCore
from qtpy import QtCore, QtWidgets
from openpype.lib import Logger
from openpype.pipeline import legacy_io
from openpype.tools.utils import host_tools
from openpype.tests.lib import is_in_tests
from openpype.pipeline import install_host, legacy_io
from openpype.modules import ModulesManager
from openpype.tools.adobe_webserver.app import WebServerTool
from .ws_stub import AfterEffectsServerStub
from .ws_stub import get_stub
from .lib import set_settings
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
class ConnectionNotEstablishedYet(Exception):
pass
def safe_excepthook(*args):
traceback.print_exception(*args)
def get_stub():
"""
Convenience function to get server RPC stub to call methods directed
for host (Photoshop).
It expects already created connection, started from client.
Currently created when panel is opened (PS: Window>Extensions>Avalon)
:return: <PhotoshopClientStub> where functions could be called from
"""
ae_stub = AfterEffectsServerStub()
if not ae_stub.client:
raise ConnectionNotEstablishedYet("Connection is not created yet")
def main(*subprocess_args):
"""Main entrypoint to AE launching, called from pre hook."""
sys.excepthook = safe_excepthook
return ae_stub
from openpype.hosts.aftereffects.api import AfterEffectsHost
host = AfterEffectsHost()
install_host(host)
def stub():
return get_stub()
os.environ["OPENPYPE_LOG_NO_COLORS"] = "False"
app = QtWidgets.QApplication([])
app.setQuitOnLastWindowClosed(False)
launcher = ProcessLauncher(subprocess_args)
launcher.start()
if os.environ.get("HEADLESS_PUBLISH"):
manager = ModulesManager()
webpublisher_addon = manager["webpublisher"]
launcher.execute_in_main_thread(
functools.partial(
webpublisher_addon.headless_publish,
log,
"CloseAE",
is_in_tests()
)
)
elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True):
save = False
if os.getenv("WORKFILES_SAVE_AS"):
save = True
launcher.execute_in_main_thread(
lambda: host_tools.show_tool_by_name("workfiles", save=save)
)
sys.exit(app.exec_())
def show_tool_by_name(tool_name):
@ -55,6 +83,7 @@ def show_tool_by_name(tool_name):
class ProcessLauncher(QtCore.QObject):
"""Launches webserver, connects to it, runs main thread."""
route_name = "AfterEffects"
_main_thread_callbacks = collections.deque()
@ -296,6 +325,15 @@ class AfterEffectsRoute(WebSocketRoute):
async def sceneinventory_route(self):
self._tool_route("sceneinventory")
async def setresolution_route(self):
self._settings_route(False, True)
async def setframes_route(self):
self._settings_route(True, False)
async def setall_route(self):
self._settings_route(True, True)
async def experimental_tools_route(self):
self._tool_route("experimental_tools")
@ -309,3 +347,13 @@ class AfterEffectsRoute(WebSocketRoute):
# Required return statement.
return "nothing"
def _settings_route(self, frames, resolution):
partial_method = functools.partial(set_settings,
frames,
resolution)
ProcessLauncher.execute_in_main_thread(partial_method)
# Required return statement.
return "nothing"

View file

@ -1,69 +1,17 @@
import os
import sys
import re
import json
import contextlib
import traceback
import logging
from functools import partial
from qtpy import QtWidgets
from openpype.pipeline import install_host
from openpype.modules import ModulesManager
from openpype.tools.utils import host_tools
from openpype.tests.lib import is_in_tests
from .launch_logic import ProcessLauncher, get_stub
from openpype.pipeline.context_tools import get_current_context
from openpype.client import get_asset_by_name
from .ws_stub import get_stub
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
def safe_excepthook(*args):
traceback.print_exception(*args)
def main(*subprocess_args):
sys.excepthook = safe_excepthook
from openpype.hosts.aftereffects.api import AfterEffectsHost
host = AfterEffectsHost()
install_host(host)
os.environ["OPENPYPE_LOG_NO_COLORS"] = "False"
app = QtWidgets.QApplication([])
app.setQuitOnLastWindowClosed(False)
launcher = ProcessLauncher(subprocess_args)
launcher.start()
if os.environ.get("HEADLESS_PUBLISH"):
manager = ModulesManager()
webpublisher_addon = manager["webpublisher"]
launcher.execute_in_main_thread(
partial(
webpublisher_addon.headless_publish,
log,
"CloseAE",
is_in_tests()
)
)
elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True):
save = False
if os.getenv("WORKFILES_SAVE_AS"):
save = True
launcher.execute_in_main_thread(
lambda: host_tools.show_tool_by_name("workfiles", save=save)
)
sys.exit(app.exec_())
@contextlib.contextmanager
def maintained_selection():
"""Maintain selection during context."""
@ -145,13 +93,13 @@ def get_asset_settings(asset_doc):
"""
asset_data = asset_doc["data"]
fps = asset_data.get("fps")
frame_start = asset_data.get("frameStart")
frame_end = asset_data.get("frameEnd")
handle_start = asset_data.get("handleStart")
handle_end = asset_data.get("handleEnd")
resolution_width = asset_data.get("resolutionWidth")
resolution_height = asset_data.get("resolutionHeight")
fps = asset_data.get("fps", 0)
frame_start = asset_data.get("frameStart", 0)
frame_end = asset_data.get("frameEnd", 0)
handle_start = asset_data.get("handleStart", 0)
handle_end = asset_data.get("handleEnd", 0)
resolution_width = asset_data.get("resolutionWidth", 0)
resolution_height = asset_data.get("resolutionHeight", 0)
duration = (frame_end - frame_start + 1) + handle_start + handle_end
return {
@ -164,3 +112,49 @@ def get_asset_settings(asset_doc):
"resolutionHeight": resolution_height,
"duration": duration
}
def set_settings(frames, resolution, comp_ids=None, print_msg=True):
"""Sets number of frames and resolution to selected comps.
Args:
frames (bool): True if set frame info
resolution (bool): True if set resolution
comp_ids (list): specific composition ids, if empty
it tries to look for currently selected
print_msg (bool): True throw JS alert with msg
"""
frame_start = frames_duration = fps = width = height = None
current_context = get_current_context()
asset_doc = get_asset_by_name(current_context["project_name"],
current_context["asset_name"])
settings = get_asset_settings(asset_doc)
msg = ''
if frames:
frame_start = settings["frameStart"] - settings["handleStart"]
frames_duration = settings["duration"]
fps = settings["fps"]
msg += f"frame start:{frame_start}, duration:{frames_duration}, "\
f"fps:{fps}"
if resolution:
width = settings["resolutionWidth"]
height = settings["resolutionHeight"]
msg += f"width:{width} and height:{height}"
stub = get_stub()
if not comp_ids:
comps = stub.get_selected_items(True, False, False)
comp_ids = [comp.id for comp in comps]
if not comp_ids:
stub.print_msg("Select at least one composition to apply settings.")
return
for comp_id in comp_ids:
msg = f"Setting for comp {comp_id} " + msg
log.debug(msg)
stub.set_comp_properties(comp_id, frame_start, frames_duration,
fps, width, height)
if print_msg:
stub.print_msg(msg)

View file

@ -8,10 +8,7 @@ from openpype.lib import Logger, register_event_callback
from openpype.pipeline import (
register_loader_plugin_path,
register_creator_plugin_path,
deregister_loader_plugin_path,
deregister_creator_plugin_path,
AVALON_CONTAINER_ID,
legacy_io,
)
from openpype.pipeline.load import any_outdated_containers
import openpype.hosts.aftereffects
@ -23,7 +20,8 @@ from openpype.host import (
IPublishHost
)
from .launch_logic import get_stub, ConnectionNotEstablishedYet
from .launch_logic import get_stub
from .ws_stub import ConnectionNotEstablishedYet
log = Logger.get_logger(__name__)
@ -60,9 +58,6 @@ class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
print("Not connected yet, ignoring")
return
if not stub.get_active_document_name():
return
self._stub = stub
return self._stub

View file

@ -11,6 +11,10 @@ from wsrpc_aiohttp import WebSocketAsync
from openpype.tools.adobe_webserver.app import WebServerTool
class ConnectionNotEstablishedYet(Exception):
pass
@attr.s
class AEItem(object):
"""
@ -24,8 +28,8 @@ class AEItem(object):
# all imported elements, single for
# regular image, array for Backgrounds
members = attr.ib(factory=list)
workAreaStart = attr.ib(default=None)
workAreaDuration = attr.ib(default=None)
frameStart = attr.ib(default=None)
framesDuration = attr.ib(default=None)
frameRate = attr.ib(default=None)
file_name = attr.ib(default=None)
instance_id = attr.ib(default=None) # New Publisher
@ -355,42 +359,50 @@ class AfterEffectsServerStub():
return self._handle_return(res)
def get_work_area(self, item_id):
""" Get work are information for render purposes
def get_comp_properties(self, comp_id):
""" Get composition information for render purposes
Returns startFrame, frameDuration, fps, width, height.
Args:
item_id (int):
comp_id (int):
Returns:
(AEItem)
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.get_work_area',
item_id=item_id
('AfterEffects.get_comp_properties',
item_id=comp_id
))
records = self._to_records(self._handle_return(res))
if records:
return records.pop()
def set_work_area(self, item, start, duration, frame_rate):
def set_comp_properties(self, comp_id, start, duration, frame_rate,
width, height):
"""
Set work area to predefined values (from Ftrack).
Work area directs what gets rendered.
Beware of rounding, AE expects seconds, not frames directly.
Args:
item (dict):
start (float): workAreaStart in seconds
duration (float): in seconds
comp_id (int):
start (int): workAreaStart in frames
duration (int): in frames
frame_rate (float): frames in seconds
width (int): resolution width
height (int): resolution height
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.set_work_area',
item_id=item.id,
('AfterEffects.set_comp_properties',
item_id=comp_id,
start=start,
duration=duration,
frame_rate=frame_rate))
frame_rate=frame_rate,
width=width,
height=height))
return self._handle_return(res)
def save(self):
@ -554,6 +566,12 @@ class AfterEffectsServerStub():
return self._handle_return(res)
def print_msg(self, msg):
"""Triggers Javascript alert dialog."""
self.websocketserver.call(self.client.call
('AfterEffects.print_msg',
msg=msg))
def _handle_return(self, res):
"""Wraps return, throws ValueError if 'error' key is present."""
if res and isinstance(res, str) and res != "undefined":
@ -608,8 +626,8 @@ class AfterEffectsServerStub():
d.get('name'),
d.get('type'),
d.get('members'),
d.get('workAreaStart'),
d.get('workAreaDuration'),
d.get('frameStart'),
d.get('framesDuration'),
d.get('frameRate'),
d.get('file_name'),
d.get("instance_id"),
@ -618,3 +636,18 @@ class AfterEffectsServerStub():
ret.append(item)
return ret
def get_stub():
"""
Convenience function to get server RPC stub to call methods directed
for host (Photoshop).
It expects already created connection, started from client.
Currently created when panel is opened (PS: Window>Extensions>Avalon)
:return: <PhotoshopClientStub> where functions could be called from
"""
ae_stub = AfterEffectsServerStub()
if not ae_stub.client:
raise ConnectionNotEstablishedYet("Connection is not created yet")
return ae_stub

View file

@ -9,6 +9,7 @@ from openpype.pipeline import (
CreatorError
)
from openpype.hosts.aftereffects.api.pipeline import cache_and_get_instances
from openpype.hosts.aftereffects.api.lib import set_settings
from openpype.lib import prepare_template_data
from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS
@ -26,15 +27,20 @@ class RenderCreator(Creator):
create_allow_context_change = True
def __init__(self, project_settings, *args, **kwargs):
super(RenderCreator, self).__init__(project_settings, *args, **kwargs)
self._default_variants = (project_settings["aftereffects"]
["create"]
["RenderCreator"]
["defaults"])
# Settings
default_variants = []
mark_for_review = True
def create(self, subset_name_from_ui, data, pre_create_data):
stub = api.get_stub() # only after After Effects is up
try:
_ = stub.get_active_document_full_name()
except ValueError:
raise CreatorError(
"Please save workfile via Workfile app first!"
)
if pre_create_data.get("use_selection"):
comps = stub.get_selected_items(
comps=True, folders=False, footages=False
@ -44,8 +50,8 @@ class RenderCreator(Creator):
if not comps:
raise CreatorError(
"Nothing to create. Select composition "
"if 'useSelection' or create at least "
"Nothing to create. Select composition in Project Bin if "
"'Use selection' is toggled or create at least "
"one composition."
)
use_composition_name = (pre_create_data.get("use_composition_name") or
@ -82,28 +88,44 @@ class RenderCreator(Creator):
use_farm = pre_create_data["farm"]
new_instance.creator_attributes["farm"] = use_farm
review = pre_create_data["mark_for_review"]
new_instance.creator_attributes["mark_for_review"] = review
api.get_stub().imprint(new_instance.id,
new_instance.data_to_store())
self._add_instance_to_context(new_instance)
stub.rename_item(comp.id, subset_name)
def get_default_variants(self):
return self._default_variants
def get_instance_attr_defs(self):
return [BoolDef("farm", label="Render on farm")]
set_settings(True, True, [comp.id], print_msg=False)
def get_pre_create_attr_defs(self):
output = [
BoolDef("use_selection", default=True, label="Use selection"),
BoolDef("use_selection",
tooltip="Composition for publishable instance should be "
"selected by default.",
default=True, label="Use selection"),
BoolDef("use_composition_name",
label="Use composition name in subset"),
UISeparatorDef(),
BoolDef("farm", label="Render on farm")
BoolDef("farm", label="Render on farm"),
BoolDef(
"mark_for_review",
label="Review",
default=self.mark_for_review
)
]
return output
def get_instance_attr_defs(self):
return [
BoolDef("farm", label="Render on farm"),
BoolDef(
"mark_for_review",
label="Review",
default=False
)
]
def get_icon(self):
return resources.get_openpype_splash_filepath()
@ -143,6 +165,13 @@ class RenderCreator(Creator):
api.get_stub().rename_item(comp_id,
new_comp_name)
def apply_settings(self, project_settings, system_settings):
plugin_settings = (
project_settings["aftereffects"]["create"]["RenderCreator"]
)
self.mark_for_review = plugin_settings["mark_for_review"]
def get_detail_description(self):
return """Creator for Render instances
@ -201,4 +230,7 @@ class RenderCreator(Creator):
instance_data["creator_attributes"] = {"farm": is_old_farm}
instance_data["family"] = self.family
if instance_data["creator_attributes"].get("mark_for_review") is None:
instance_data["creator_attributes"]["mark_for_review"] = True
return instance_data

View file

@ -66,19 +66,19 @@ class CollectAERender(publish.AbstractCollectRender):
comp_id = int(inst.data["members"][0])
work_area_info = CollectAERender.get_stub().get_work_area(comp_id)
comp_info = CollectAERender.get_stub().get_comp_properties(
comp_id)
if not work_area_info:
if not comp_info:
self.log.warning("Orphaned instance, deleting metadata")
inst_id = inst.get("instance_id") or str(comp_id)
inst_id = inst.data.get("instance_id") or str(comp_id)
CollectAERender.get_stub().remove_instance(inst_id)
continue
frame_start = work_area_info.workAreaStart
frame_end = round(work_area_info.workAreaStart +
float(work_area_info.workAreaDuration) *
float(work_area_info.frameRate)) - 1
fps = work_area_info.frameRate
frame_start = comp_info.frameStart
frame_end = round(comp_info.frameStart +
comp_info.framesDuration) - 1
fps = comp_info.frameRate
# TODO add resolution when supported by extension
task_name = inst.data.get("task") # legacy
@ -88,10 +88,11 @@ class CollectAERender(publish.AbstractCollectRender):
raise ValueError("No file extension set in Render Queue")
render_item = render_q[0]
instance_families = inst.data.get("families", [])
subset_name = inst.data["subset"]
instance = AERenderInstance(
family="render",
families=inst.data.get("families", []),
families=instance_families,
version=version,
time="",
source=current_file,
@ -109,6 +110,7 @@ class CollectAERender(publish.AbstractCollectRender):
tileRendering=False,
tilesX=0,
tilesY=0,
review="review" in instance_families,
frameStart=frame_start,
frameEnd=frame_end,
frameStep=1,
@ -139,6 +141,9 @@ class CollectAERender(publish.AbstractCollectRender):
instance.toBeRenderedOn = "deadline"
instance.renderer = "aerender"
instance.farm = True # to skip integrate
if "review" in instance.families:
# to skip ExtractReview locally
instance.families.remove("review")
instances.append(instance)
instances_to_remove.append(inst)
@ -218,15 +223,4 @@ class CollectAERender(publish.AbstractCollectRender):
if fam not in instance.families:
instance.families.append(fam)
settings = get_project_settings(os.getenv("AVALON_PROJECT"))
reviewable_subset_filter = (settings["deadline"]
["publish"]
["ProcessSubmittedJobOnFarm"]
["aov_filter"].get(self.hosts[0]))
for aov_pattern in reviewable_subset_filter:
if re.match(aov_pattern, instance.subset):
instance.families.append("review")
instance.review = True
break
return instance

View file

@ -0,0 +1,25 @@
"""
Requires:
None
Provides:
instance -> family ("review")
"""
import pyblish.api
class CollectReview(pyblish.api.ContextPlugin):
"""Add review to families if instance created with 'mark_for_review' flag
"""
label = "Collect Review"
hosts = ["aftereffects"]
order = pyblish.api.CollectorOrder + 0.1
def process(self, context):
for instance in context:
creator_attributes = instance.data.get("creator_attributes") or {}
if (
creator_attributes.get("mark_for_review")
and "review" not in instance.data["families"]
):
instance.data["families"].append("review")

View file

@ -66,33 +66,9 @@ class ExtractLocalRender(publish.Extractor):
first_repre = not representations
if instance.data["review"] and first_repre:
repre_data["tags"] = ["review"]
thumbnail_path = os.path.join(staging_dir, files[0])
instance.data["thumbnailSource"] = thumbnail_path
representations.append(repre_data)
instance.data["representations"] = representations
ffmpeg_path = get_ffmpeg_tool_path("ffmpeg")
# Generate thumbnail.
thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg")
args = [
ffmpeg_path, "-y",
"-i", first_file_path,
"-vf", "scale=300:-1",
"-vframes", "1",
thumbnail_path
]
self.log.debug("Thumbnail args:: {}".format(args))
try:
output = run_subprocess(args)
except TypeError:
self.log.warning("Error in creating thumbnail")
six.reraise(*sys.exc_info())
instance.data["representations"].append({
"name": "thumbnail",
"ext": "jpg",
"files": os.path.basename(thumbnail_path),
"stagingDir": staging_dir,
"tags": ["thumbnail"]
})

View file

@ -65,37 +65,19 @@ class CacheModelLoader(plugin.AssetLoader):
imported = lib.get_selection()
empties = [obj for obj in imported if obj.type == 'EMPTY']
container = None
for empty in empties:
if not empty.parent:
container = empty
break
assert container, "No asset group found"
# Children must be linked before parents,
# otherwise the hierarchy will break
objects = []
nodes = list(container.children)
for obj in nodes:
for obj in imported:
obj.parent = asset_group
bpy.data.objects.remove(container)
for obj in nodes:
for obj in imported:
objects.append(obj)
nodes.extend(list(obj.children))
imported.extend(list(obj.children))
objects.reverse()
for obj in objects:
parent.objects.link(obj)
collection.objects.unlink(obj)
for obj in objects:
name = obj.name
obj.name = f"{group_name}:{name}"
@ -138,13 +120,14 @@ class CacheModelLoader(plugin.AssetLoader):
group_name = plugin.asset_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)
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)
asset_group = bpy.data.objects.new(group_name, object_data=None)
avalon_container.objects.link(asset_group)
avalon_containers.objects.link(asset_group)
objects = self._process(libpath, asset_group, group_name)

View file

@ -13,6 +13,7 @@ from .lib import (
update_frame_range,
set_asset_framerange,
get_current_comp,
get_bmd_library,
comp_lock_and_undo_chunk
)

View file

@ -256,8 +256,11 @@ def switch_item(container,
@contextlib.contextmanager
def maintained_selection():
comp = get_current_comp()
def maintained_selection(comp=None):
"""Reset comp selection from before the context after the context"""
if comp is None:
comp = get_current_comp()
previous_selection = comp.GetToolList(True).values()
try:
yield
@ -269,6 +272,33 @@ def maintained_selection():
flow.Select(tool, True)
@contextlib.contextmanager
def maintained_comp_range(comp=None,
global_start=True,
global_end=True,
render_start=True,
render_end=True):
"""Reset comp frame ranges from before the context after the context"""
if comp is None:
comp = get_current_comp()
comp_attrs = comp.GetAttrs()
preserve_attrs = {}
if global_start:
preserve_attrs["COMPN_GlobalStart"] = comp_attrs["COMPN_GlobalStart"]
if global_end:
preserve_attrs["COMPN_GlobalEnd"] = comp_attrs["COMPN_GlobalEnd"]
if render_start:
preserve_attrs["COMPN_RenderStart"] = comp_attrs["COMPN_RenderStart"]
if render_end:
preserve_attrs["COMPN_RenderEnd"] = comp_attrs["COMPN_RenderEnd"]
try:
yield
finally:
comp.SetAttrs(preserve_attrs)
def get_frame_path(path):
"""Get filename for the Fusion Saver with padded number as '#'
@ -309,6 +339,12 @@ def get_fusion_module():
return fusion
def get_bmd_library():
"""Get bmd library"""
bmd = getattr(sys.modules["__main__"], "bmd", None)
return bmd
def get_current_comp():
"""Get current comp in this session"""
fusion = get_fusion_module()

View file

@ -1,3 +1,4 @@
from copy import deepcopy
import os
from openpype.hosts.fusion.api import (
@ -11,15 +12,13 @@ from openpype.lib import (
)
from openpype.pipeline import (
legacy_io,
Creator,
Creator as NewCreator,
CreatedInstance,
)
from openpype.client import (
get_asset_by_name,
Anatomy
)
class CreateSaver(Creator):
class CreateSaver(NewCreator):
identifier = "io.openpype.creators.fusion.saver"
label = "Render (saver)"
name = "render"
@ -28,9 +27,29 @@ class CreateSaver(Creator):
description = "Fusion Saver to generate image sequence"
icon = "fa5.eye"
instance_attributes = ["reviewable"]
instance_attributes = [
"reviewable"
]
default_variants = [
"Main",
"Mask"
]
# TODO: This should be renamed together with Nuke so it is aligned
temp_rendering_path_template = (
"{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}")
def create(self, subset_name, instance_data, pre_create_data):
self.pass_pre_attributes_to_instance(
instance_data,
pre_create_data
)
instance_data.update({
"id": "pyblish.avalon.instance",
"subset": subset_name
})
# TODO: Add pre_create attributes to choose file format?
file_format = "OpenEXRFormat"
@ -39,7 +58,6 @@ class CreateSaver(Creator):
args = (-32768, -32768) # Magical position numbers
saver = comp.AddTool("Saver", *args)
instance_data["subset"] = subset_name
self._update_tool_with_data(saver, data=instance_data)
saver["OutputFormat"] = file_format
@ -78,7 +96,7 @@ class CreateSaver(Creator):
for tool in tools:
data = self.get_managed_tool_data(tool)
if not data:
data = self._collect_unmanaged_saver(tool)
continue
# Add instance
created_instance = CreatedInstance.from_existing(data, self)
@ -125,60 +143,35 @@ class CreateSaver(Creator):
original_subset = tool.GetData("openpype.subset")
subset = data["subset"]
if original_subset != subset:
# Subset change detected
# Update output filepath
workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"])
filename = f"{subset}..exr"
filepath = os.path.join(workdir, "render", subset, filename)
tool["Clip"] = filepath
self._configure_saver_tool(data, tool, subset)
# Rename tool
if tool.Name != subset:
print(f"Renaming {tool.Name} -> {subset}")
tool.SetAttrs({"TOOLS_Name": subset})
def _configure_saver_tool(self, data, tool, subset):
formatting_data = deepcopy(data)
def _collect_unmanaged_saver(self, tool):
# TODO: this should not be done this way - this should actually
# get the data as stored on the tool explicitly (however)
# that would disallow any 'regular saver' to be collected
# unless the instance data is stored on it to begin with
print("Collecting unmanaged saver..")
comp = tool.Comp()
# Allow regular non-managed savers to also be picked up
project = legacy_io.Session["AVALON_PROJECT"]
asset = legacy_io.Session["AVALON_ASSET"]
task = legacy_io.Session["AVALON_TASK"]
asset_doc = get_asset_by_name(project_name=project, asset_name=asset)
path = tool["Clip"][comp.TIME_UNDEFINED]
fname = os.path.basename(path)
fname, _ext = os.path.splitext(fname)
variant = fname.rstrip(".")
subset = self.get_subset_name(
variant=variant,
task_name=task,
asset_doc=asset_doc,
project_name=project,
# get frame padding from anatomy templates
anatomy = Anatomy()
frame_padding = int(
anatomy.templates["render"].get("frame_padding", 4)
)
attrs = tool.GetAttrs()
passthrough = attrs["TOOLB_PassThrough"]
return {
# Required data
"project": project,
"asset": asset,
"subset": subset,
"task": task,
"variant": variant,
"active": not passthrough,
"family": self.family,
# Unique identifier for instance and this creator
"id": "pyblish.avalon.instance",
"creator_identifier": self.identifier,
}
# Subset change detected
workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"])
formatting_data.update({
"workdir": workdir,
"frame": "0" * frame_padding,
"ext": "exr"
})
# build file path to render
filepath = self.temp_rendering_path_template.format(
**formatting_data)
tool["Clip"] = os.path.normpath(filepath)
# Rename tool
if tool.Name != subset:
print(f"Renaming {tool.Name} -> {subset}")
tool.SetAttrs({"TOOLS_Name": subset})
def get_managed_tool_data(self, tool):
"""Return data of the tool if it matches creator identifier"""
@ -206,20 +199,25 @@ class CreateSaver(Creator):
attr_defs = [
self._get_render_target_enum(),
self._get_reviewable_bool(),
self._get_frame_range_enum()
]
return attr_defs
def get_instance_attr_defs(self):
"""Settings for publish page"""
attr_defs = [
self._get_render_target_enum(),
self._get_reviewable_bool(),
]
return attr_defs
return self.get_pre_create_attr_defs()
def pass_pre_attributes_to_instance(
self,
instance_data,
pre_create_data
):
creator_attrs = instance_data["creator_attributes"] = {}
for pass_key in pre_create_data.keys():
creator_attrs[pass_key] = pre_create_data[pass_key]
# These functions below should be moved to another file
# so it can be used by other plugins. plugin.py ?
def _get_render_target_enum(self):
rendering_targets = {
"local": "Local machine rendering",
@ -232,9 +230,44 @@ class CreateSaver(Creator):
"render_target", items=rendering_targets, label="Render target"
)
def _get_frame_range_enum(self):
frame_range_options = {
"asset_db": "Current asset context",
"render_range": "From render in/out",
"comp_range": "From composition timeline"
}
return EnumDef(
"frame_range_source",
items=frame_range_options,
label="Frame range source"
)
def _get_reviewable_bool(self):
return BoolDef(
"review",
default=("reviewable" in self.instance_attributes),
label="Review",
)
def apply_settings(
self,
project_settings,
system_settings
):
"""Method called on initialization of plugin to apply settings."""
# plugin settings
plugin_settings = (
project_settings["fusion"]["create"][self.__class__.__name__]
)
# individual attributes
self.instance_attributes = plugin_settings.get(
"instance_attributes") or self.instance_attributes
self.default_variants = plugin_settings.get(
"default_variants") or self.default_variants
self.temp_rendering_path_template = (
plugin_settings.get("temp_rendering_path_template")
or self.temp_rendering_path_template
)

View file

@ -1,4 +1,3 @@
from openpype.pipeline import (
load,
get_representation_path,
@ -6,7 +5,7 @@ from openpype.pipeline import (
from openpype.hosts.fusion.api import (
imprint_container,
get_current_comp,
comp_lock_and_undo_chunk
comp_lock_and_undo_chunk,
)
@ -15,7 +14,21 @@ class FusionLoadFBXMesh(load.LoaderPlugin):
families = ["*"]
representations = ["*"]
extensions = {"fbx"}
extensions = {
"3ds",
"amc",
"aoa",
"asf",
"bvh",
"c3d",
"dae",
"dxf",
"fbx",
"htr",
"mcd",
"obj",
"trc",
}
label = "Load FBX mesh"
order = -10
@ -27,23 +40,24 @@ class FusionLoadFBXMesh(load.LoaderPlugin):
def load(self, context, name, namespace, data):
# Fallback to asset name when namespace is None
if namespace is None:
namespace = context['asset']['name']
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["ImportFile"] = path
imprint_container(tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__)
imprint_container(
tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__,
)
def switch(self, container, representation):
self.update(container, representation)

View file

@ -3,17 +3,14 @@ import contextlib
import openpype.pipeline.load as load
from openpype.pipeline.load import (
get_representation_context,
get_representation_path_from_context
get_representation_path_from_context,
)
from openpype.hosts.fusion.api import (
imprint_container,
get_current_comp,
comp_lock_and_undo_chunk
)
from openpype.lib.transcoding import (
IMAGE_EXTENSIONS,
VIDEO_EXTENSIONS
comp_lock_and_undo_chunk,
)
from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS
comp = get_current_comp()
@ -57,20 +54,23 @@ def preserve_trim(loader, log=None):
try:
yield
finally:
length = loader.GetAttrs()["TOOLIT_Clip_Length"][1] - 1
if trim_from_start > length:
trim_from_start = length
if log:
log.warning("Reducing trim in to %d "
"(because of less frames)" % trim_from_start)
log.warning(
"Reducing trim in to %d "
"(because of less frames)" % trim_from_start
)
remainder = length - trim_from_start
if trim_from_end > remainder:
trim_from_end = remainder
if log:
log.warning("Reducing trim in to %d "
"(because of less frames)" % trim_from_end)
log.warning(
"Reducing trim in to %d "
"(because of less frames)" % trim_from_end
)
loader["ClipTimeStart"][time] = trim_from_start
loader["ClipTimeEnd"][time] = length - trim_from_end
@ -109,11 +109,15 @@ def loader_shift(loader, frame, relative=True):
# Shifting global in will try to automatically compensate for the change
# in the "ClipTimeStart" and "HoldFirstFrame" inputs, so we preserve those
# input values to "just shift" the clip
with preserve_inputs(loader, inputs=["ClipTimeStart",
"ClipTimeEnd",
"HoldFirstFrame",
"HoldLastFrame"]):
with preserve_inputs(
loader,
inputs=[
"ClipTimeStart",
"ClipTimeEnd",
"HoldFirstFrame",
"HoldLastFrame",
],
):
# GlobalIn cannot be set past GlobalOut or vice versa
# so we must apply them in the order of the shift.
if shift > 0:
@ -129,7 +133,14 @@ def loader_shift(loader, frame, relative=True):
class FusionLoadSequence(load.LoaderPlugin):
"""Load image sequence into Fusion"""
families = ["imagesequence", "review", "render", "plate"]
families = [
"imagesequence",
"review",
"render",
"plate",
"image",
"onilne",
]
representations = ["*"]
extensions = set(
ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS)
@ -143,7 +154,7 @@ class FusionLoadSequence(load.LoaderPlugin):
def load(self, context, name, namespace, data):
# Fallback to asset name when namespace is None
if namespace is None:
namespace = context['asset']['name']
namespace = context["asset"]["name"]
# Use the first file for now
path = get_representation_path_from_context(context)
@ -151,7 +162,6 @@ class FusionLoadSequence(load.LoaderPlugin):
# Create the Loader with the filename path set
comp = get_current_comp()
with comp_lock_and_undo_chunk(comp, "Create Loader"):
args = (-32768, -32768)
tool = comp.AddTool("Loader", *args)
tool["Clip"] = path
@ -160,11 +170,13 @@ class FusionLoadSequence(load.LoaderPlugin):
start = self._get_start(context["version"], tool)
loader_shift(tool, start, relative=False)
imprint_container(tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__)
imprint_container(
tool,
name=name,
namespace=namespace,
context=context,
loader=self.__class__.__name__,
)
def switch(self, container, representation):
self.update(container, representation)
@ -222,24 +234,28 @@ class FusionLoadSequence(load.LoaderPlugin):
start = self._get_start(context["version"], tool)
with comp_lock_and_undo_chunk(comp, "Update Loader"):
# Update the loader's path whilst preserving some values
with preserve_trim(tool, log=self.log):
with preserve_inputs(tool,
inputs=("HoldFirstFrame",
"HoldLastFrame",
"Reverse",
"Depth",
"KeyCode",
"TimeCodeOffset")):
with preserve_inputs(
tool,
inputs=(
"HoldFirstFrame",
"HoldLastFrame",
"Reverse",
"Depth",
"KeyCode",
"TimeCodeOffset",
),
):
tool["Clip"] = path
# Set the global in to the start frame of the sequence
global_in_changed = loader_shift(tool, start, relative=False)
if global_in_changed:
# Log this change to the user
self.log.debug("Changed '%s' global in: %d" % (tool.Name,
start))
self.log.debug(
"Changed '%s' global in: %d" % (tool.Name, start)
)
# Update the imprinted representation
tool.SetData("avalon.representation", str(representation["_id"]))
@ -264,9 +280,11 @@ class FusionLoadSequence(load.LoaderPlugin):
# Get frame start without handles
start = data.get("frameStart")
if start is None:
self.log.warning("Missing start frame for version "
"assuming starts at frame 0 for: "
"{}".format(tool.Name))
self.log.warning(
"Missing start frame for version "
"assuming starts at frame 0 for: "
"{}".format(tool.Name)
)
return 0
# Use `handleStart` if the data is available

View file

@ -0,0 +1,32 @@
"""Import workfiles into your current comp.
As all imported nodes are free floating and will probably be changed there
is no update or reload function added for this plugin
"""
from openpype.pipeline import load
from openpype.hosts.fusion.api import (
get_current_comp,
get_bmd_library,
)
class FusionLoadWorkfile(load.LoaderPlugin):
"""Load the content of a workfile into Fusion"""
families = ["workfile"]
representations = ["*"]
extensions = {"comp"}
label = "Load Workfile"
order = -10
icon = "code-fork"
color = "orange"
def load(self, context, name, namespace, data):
# Get needed elements
bmd = get_bmd_library()
comp = get_current_comp()
# Paste the content of the file into the current comp
comp.Paste(bmd.readfile(self.fname))

View file

@ -35,9 +35,10 @@ class CollectFusionCompFrameRanges(pyblish.api.ContextPlugin):
# Store comp render ranges
start, end, global_start, global_end = get_comp_render_range(comp)
context.data["frameStart"] = int(start)
context.data["frameEnd"] = int(end)
context.data["frameStartHandle"] = int(global_start)
context.data["frameEndHandle"] = int(global_end)
context.data["handleStart"] = int(start) - int(global_start)
context.data["handleEnd"] = int(global_end) - int(end)
context.data.update({
"renderFrameStart": int(start),
"renderFrameEnd": int(end),
"compFrameStart": int(global_start),
"compFrameEnd": int(global_end)
})

View file

@ -1,50 +0,0 @@
import pyblish.api
from openpype.pipeline import publish
import os
class CollectFusionExpectedFrames(
pyblish.api.InstancePlugin, publish.ColormanagedPyblishPluginMixin
):
"""Collect all frames needed to publish expected frames"""
order = pyblish.api.CollectorOrder + 0.5
label = "Collect Expected Frames"
hosts = ["fusion"]
families = ["render"]
def process(self, instance):
context = instance.context
frame_start = context.data["frameStartHandle"]
frame_end = context.data["frameEndHandle"]
path = instance.data["path"]
output_dir = instance.data["outputDir"]
basename = os.path.basename(path)
head, ext = os.path.splitext(basename)
files = [
f"{head}{str(frame).zfill(4)}{ext}"
for frame in range(frame_start, frame_end + 1)
]
repre = {
"name": ext[1:],
"ext": ext[1:],
"frameStart": f"%0{len(str(frame_end))}d" % frame_start,
"files": files,
"stagingDir": output_dir,
}
self.set_representation_colorspace(
representation=repre,
context=context,
)
# review representation
if instance.data.get("review", False):
repre["tags"] = ["review"]
# add the repre to the instance
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(repre)

View file

@ -1,22 +0,0 @@
import pyblish.api
class CollectFusionVersion(pyblish.api.ContextPlugin):
"""Collect current comp"""
order = pyblish.api.CollectorOrder
label = "Collect Fusion Version"
hosts = ["fusion"]
def process(self, context):
"""Collect all image sequence tools"""
comp = context.data.get("currentComp")
if not comp:
raise RuntimeError("No comp previously collected, unable to "
"retrieve Fusion version.")
version = comp.GetApp().Version
context.data["fusionVersion"] = version
self.log.info("Fusion version: %s" % version)

View file

@ -113,4 +113,4 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin):
inputs = [c["representation"] for c in containers]
instance.data["inputRepresentations"] = inputs
self.log.info("Collected inputs: %s" % inputs)
self.log.debug("Collected inputs: %s" % inputs)

View file

@ -1,5 +1,3 @@
import os
import pyblish.api
@ -24,23 +22,63 @@ class CollectInstanceData(pyblish.api.InstancePlugin):
creator_attributes = instance.data["creator_attributes"]
instance.data.update(creator_attributes)
# Include start and end render frame in label
subset = instance.data["subset"]
frame_range_source = creator_attributes.get("frame_range_source")
instance.data["frame_range_source"] = frame_range_source
# get asset frame ranges to all instances
# render family instances `asset_db` render target
start = context.data["frameStart"]
end = context.data["frameEnd"]
label = "{subset} ({start}-{end})".format(subset=subset,
start=int(start),
end=int(end))
handle_start = context.data["handleStart"]
handle_end = context.data["handleEnd"]
start_with_handle = start - handle_start
end_with_handle = end + handle_end
# conditions for render family instances
if frame_range_source == "render_range":
# set comp render frame ranges
start = context.data["renderFrameStart"]
end = context.data["renderFrameEnd"]
handle_start = 0
handle_end = 0
start_with_handle = start
end_with_handle = end
if frame_range_source == "comp_range":
comp_start = context.data["compFrameStart"]
comp_end = context.data["compFrameEnd"]
render_start = context.data["renderFrameStart"]
render_end = context.data["renderFrameEnd"]
# set comp frame ranges
start = render_start
end = render_end
handle_start = render_start - comp_start
handle_end = comp_end - render_end
start_with_handle = comp_start
end_with_handle = comp_end
# Include start and end render frame in label
subset = instance.data["subset"]
label = (
"{subset} ({start}-{end}) [{handle_start}-{handle_end}]"
).format(
subset=subset,
start=int(start),
end=int(end),
handle_start=int(handle_start),
handle_end=int(handle_end)
)
instance.data.update({
"label": label,
# todo: Allow custom frame range per instance
"frameStart": context.data["frameStart"],
"frameEnd": context.data["frameEnd"],
"frameStartHandle": context.data["frameStartHandle"],
"frameEndHandle": context.data["frameStartHandle"],
"handleStart": context.data["handleStart"],
"handleEnd": context.data["handleEnd"],
"frameStart": start,
"frameEnd": end,
"frameStartHandle": start_with_handle,
"frameEndHandle": end_with_handle,
"handleStart": handle_start,
"handleEnd": handle_end,
"fps": context.data["fps"],
})
@ -49,31 +87,3 @@ class CollectInstanceData(pyblish.api.InstancePlugin):
if instance.data.get("review", False):
self.log.info("Adding review family..")
instance.data["families"].append("review")
if instance.data["family"] == "render":
# TODO: This should probably move into a collector of
# its own for the "render" family
from openpype.hosts.fusion.api.lib import get_frame_path
comp = context.data["currentComp"]
# This is only the case for savers currently but not
# for workfile instances. So we assume saver here.
tool = instance.data["transientData"]["tool"]
path = tool["Clip"][comp.TIME_UNDEFINED]
filename = os.path.basename(path)
head, padding, tail = get_frame_path(filename)
ext = os.path.splitext(path)[1]
assert tail == ext, ("Tail does not match %s" % ext)
instance.data.update({
"path": path,
"outputDir": os.path.dirname(path),
"ext": ext, # todo: should be redundant?
# Backwards compatibility: embed tool in instance.data
"tool": tool
})
# Add tool itself as member
instance.append(tool)

View file

@ -0,0 +1,209 @@
import os
import attr
import pyblish.api
from openpype.pipeline import publish
from openpype.pipeline.publish import RenderInstance
from openpype.hosts.fusion.api.lib import get_frame_path
@attr.s
class FusionRenderInstance(RenderInstance):
# extend generic, composition name is needed
fps = attr.ib(default=None)
projectEntity = attr.ib(default=None)
stagingDir = attr.ib(default=None)
app_version = attr.ib(default=None)
tool = attr.ib(default=None)
workfileComp = attr.ib(default=None)
publish_attributes = attr.ib(default={})
frameStartHandle = attr.ib(default=None)
frameEndHandle = attr.ib(default=None)
class CollectFusionRender(
publish.AbstractCollectRender,
publish.ColormanagedPyblishPluginMixin
):
order = pyblish.api.CollectorOrder + 0.09
label = "Collect Fusion Render"
hosts = ["fusion"]
def get_instances(self, context):
comp = context.data.get("currentComp")
comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat")
aspect_x = comp_frame_format_prefs["AspectX"]
aspect_y = comp_frame_format_prefs["AspectY"]
instances = []
instances_to_remove = []
current_file = context.data["currentFile"]
version = context.data["version"]
project_entity = context.data["projectEntity"]
for inst in context:
if not inst.data.get("active", True):
continue
family = inst.data["family"]
if family != "render":
continue
task_name = context.data["task"]
tool = inst.data["transientData"]["tool"]
instance_families = inst.data.get("families", [])
subset_name = inst.data["subset"]
instance = FusionRenderInstance(
family="render",
tool=tool,
workfileComp=comp,
families=instance_families,
version=version,
time="",
source=current_file,
label=inst.data["label"],
subset=subset_name,
asset=inst.data["asset"],
task=task_name,
attachTo=False,
setMembers='',
publish=True,
name=subset_name,
resolutionWidth=comp_frame_format_prefs.get("Width"),
resolutionHeight=comp_frame_format_prefs.get("Height"),
pixelAspect=aspect_x / aspect_y,
tileRendering=False,
tilesX=0,
tilesY=0,
review="review" in instance_families,
frameStart=inst.data["frameStart"],
frameEnd=inst.data["frameEnd"],
handleStart=inst.data["handleStart"],
handleEnd=inst.data["handleEnd"],
frameStartHandle=inst.data["frameStartHandle"],
frameEndHandle=inst.data["frameEndHandle"],
frameStep=1,
fps=comp_frame_format_prefs.get("Rate"),
app_version=comp.GetApp().Version,
publish_attributes=inst.data.get("publish_attributes", {})
)
render_target = inst.data["creator_attributes"]["render_target"]
# Add render target family
render_target_family = f"render.{render_target}"
if render_target_family not in instance.families:
instance.families.append(render_target_family)
# Add render target specific data
if render_target in {"local", "frames"}:
instance.projectEntity = project_entity
if render_target == "farm":
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
instance.families.remove("review")
# add new instance to the list and remove the original
# instance since it is not needed anymore
instances.append(instance)
instances_to_remove.append(inst)
for instance in instances_to_remove:
context.remove(instance)
return instances
def post_collecting_action(self):
for instance in self._context:
if "render.frames" in instance.data.get("families", []):
# adding representation data to the instance
self._update_for_frames(instance)
def get_expected_files(self, render_instance):
"""
Returns list of rendered files that should be created by
Deadline. These are not published directly, they are source
for later 'submit_publish_job'.
Args:
render_instance (RenderInstance): to pull anatomy and parts used
in url
Returns:
(list) of absolute urls to rendered file
"""
start = render_instance.frameStart - render_instance.handleStart
end = render_instance.frameEnd + render_instance.handleEnd
path = (
render_instance.tool["Clip"]
[render_instance.workfileComp.TIME_UNDEFINED]
)
output_dir = os.path.dirname(path)
render_instance.outputDir = output_dir
basename = os.path.basename(path)
head, padding, ext = get_frame_path(basename)
expected_files = []
for frame in range(start, end + 1):
expected_files.append(
os.path.join(
output_dir,
f"{head}{str(frame).zfill(padding)}{ext}"
)
)
return expected_files
def _update_for_frames(self, instance):
"""Updating instance for render.frames family
Adding representation data to the instance. Also setting
colorspaceData to the representation based on file rules.
"""
expected_files = instance.data["expectedFiles"]
start = instance.data["frameStart"] - instance.data["handleStart"]
path = expected_files[0]
basename = os.path.basename(path)
staging_dir = os.path.dirname(path)
_, padding, ext = get_frame_path(basename)
repre = {
"name": ext[1:],
"ext": ext[1:],
"frameStart": f"%0{padding}d" % start,
"files": [os.path.basename(f) for f in expected_files],
"stagingDir": staging_dir,
}
self.set_representation_colorspace(
representation=repre,
context=instance.context,
)
# review representation
if instance.data.get("review", False):
repre["tags"] = ["review"]
# add the repre to the instance
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(repre)
return instance

View file

@ -1,25 +0,0 @@
import pyblish.api
class CollectFusionRenders(pyblish.api.InstancePlugin):
"""Collect current saver node's render Mode
Options:
local (Render locally)
frames (Use existing frames)
"""
order = pyblish.api.CollectorOrder + 0.4
label = "Collect Renders"
hosts = ["fusion"]
families = ["render"]
def process(self, instance):
render_target = instance.data["render_target"]
family = instance.data["family"]
# add targeted family to families
instance.data["families"].append(
"{}.{}".format(family, render_target)
)

View file

@ -1,8 +1,12 @@
import os
import logging
import contextlib
import collections
import pyblish.api
from openpype.hosts.fusion.api import comp_lock_and_undo_chunk
from openpype.pipeline import publish
from openpype.hosts.fusion.api import comp_lock_and_undo_chunk
from openpype.hosts.fusion.api.lib import get_frame_path, maintained_comp_range
log = logging.getLogger(__name__)
@ -38,7 +42,10 @@ def enabled_savers(comp, savers):
saver.SetAttrs({"TOOLB_PassThrough": original_state})
class FusionRenderLocal(pyblish.api.InstancePlugin):
class FusionRenderLocal(
pyblish.api.InstancePlugin,
publish.ColormanagedPyblishPluginMixin
):
"""Render the current Fusion composition locally."""
order = pyblish.api.ExtractorOrder - 0.2
@ -46,11 +53,16 @@ class FusionRenderLocal(pyblish.api.InstancePlugin):
hosts = ["fusion"]
families = ["render.local"]
is_rendered_key = "_fusionrenderlocal_has_rendered"
def process(self, instance):
context = instance.context
# Start render
self.render_once(context)
result = self.render(instance)
if result is False:
raise RuntimeError(f"Comp render failed for {instance}")
self._add_representation(instance)
# Log render status
self.log.info(
@ -61,39 +73,48 @@ class FusionRenderLocal(pyblish.api.InstancePlugin):
)
)
def render_once(self, context):
"""Render context comp only once, even with more render instances"""
def render(self, instance):
"""Render instance.
# This plug-in assumes all render nodes get rendered at the same time
# to speed up the rendering. The check below makes sure that we only
# execute the rendering once and not for each instance.
key = f"__hasRun{self.__class__.__name__}"
We try to render the minimal amount of times by combining the instances
that have a matching frame range in one Fusion render. Then for the
batch of instances we store whether the render succeeded or failed.
savers_to_render = [
# Get the saver tool from the instance
instance[0] for instance in context if
# Only active instances
instance.data.get("publish", True) and
# Only render.local instances
"render.local" in instance.data["families"]
]
"""
if key not in context.data:
# We initialize as false to indicate it wasn't successful yet
# so we can keep track of whether Fusion succeeded
context.data[key] = False
if self.is_rendered_key in instance.data:
# This instance was already processed in batch with another
# instance, so we just return the render result directly
self.log.debug(f"Instance {instance} was already rendered")
return instance.data[self.is_rendered_key]
current_comp = context.data["currentComp"]
frame_start = context.data["frameStartHandle"]
frame_end = context.data["frameEndHandle"]
instances_by_frame_range = self.get_render_instances_by_frame_range(
instance.context
)
self.log.info("Starting Fusion render")
self.log.info(f"Start frame: {frame_start}")
self.log.info(f"End frame: {frame_end}")
saver_names = ", ".join(saver.Name for saver in savers_to_render)
self.log.info(f"Rendering tools: {saver_names}")
# Render matching batch of instances that share the same frame range
frame_range = self.get_instance_render_frame_range(instance)
render_instances = instances_by_frame_range[frame_range]
with comp_lock_and_undo_chunk(current_comp):
# We initialize render state false to indicate it wasn't successful
# yet to keep track of whether Fusion succeeded. This is for cases
# where an error below this might cause the comp render result not
# to be stored for the instances of this batch
for render_instance in render_instances:
render_instance.data[self.is_rendered_key] = False
savers_to_render = [inst.data["tool"] for inst in render_instances]
current_comp = instance.context.data["currentComp"]
frame_start, frame_end = frame_range
self.log.info(
f"Starting Fusion render frame range {frame_start}-{frame_end}"
)
saver_names = ", ".join(saver.Name for saver in savers_to_render)
self.log.info(f"Rendering tools: {saver_names}")
with comp_lock_and_undo_chunk(current_comp):
with maintained_comp_range(current_comp):
with enabled_savers(current_comp, savers_to_render):
result = current_comp.Render(
{
@ -103,7 +124,76 @@ class FusionRenderLocal(pyblish.api.InstancePlugin):
}
)
context.data[key] = bool(result)
# Store the render state for all the rendered instances
for render_instance in render_instances:
render_instance.data[self.is_rendered_key] = bool(result)
if context.data[key] is False:
raise RuntimeError("Comp render failed")
return result
def _add_representation(self, instance):
"""Add representation to instance"""
expected_files = instance.data["expectedFiles"]
start = instance.data["frameStart"] - instance.data["handleStart"]
path = expected_files[0]
_, padding, ext = get_frame_path(path)
staging_dir = os.path.dirname(path)
repre = {
"name": ext[1:],
"ext": ext[1:],
"frameStart": f"%0{padding}d" % start,
"files": [os.path.basename(f) for f in expected_files],
"stagingDir": staging_dir,
}
self.set_representation_colorspace(
representation=repre,
context=instance.context,
)
# review representation
if instance.data.get("review", False):
repre["tags"] = ["review"]
# add the repre to the instance
if "representations" not in instance.data:
instance.data["representations"] = []
instance.data["representations"].append(repre)
return instance
def get_render_instances_by_frame_range(self, context):
"""Return enabled render.local instances grouped by their frame range.
Arguments:
context (pyblish.Context): The pyblish context
Returns:
dict: (start, end): instances mapping
"""
instances_to_render = [
instance for instance in context if
# Only active instances
instance.data.get("publish", True) and
# Only render.local instances
"render.local" in instance.data.get("families", [])
]
# Instances by frame ranges
instances_by_frame_range = collections.defaultdict(list)
for instance in instances_to_render:
start, end = self.get_instance_render_frame_range(instance)
instances_by_frame_range[(start, end)].append(instance)
return dict(instances_by_frame_range)
def get_instance_render_frame_range(self, instance):
start = instance.data["frameStartHandle"]
end = instance.data["frameEndHandle"]
return start, end

View file

@ -1,29 +1,39 @@
import pyblish.api
from openpype.pipeline import OptionalPyblishPluginMixin
from openpype.pipeline import KnownPublishError
class FusionIncrementCurrentFile(pyblish.api.ContextPlugin):
class FusionIncrementCurrentFile(
pyblish.api.ContextPlugin, OptionalPyblishPluginMixin
):
"""Increment the current file.
Saves the current file with an increased version number.
"""
label = "Increment current file"
label = "Increment workfile version"
order = pyblish.api.IntegratorOrder + 9.0
hosts = ["fusion"]
families = ["workfile"]
optional = True
def process(self, context):
if not self.is_active(context.data):
return
from openpype.lib import version_up
from openpype.pipeline.publish import get_errored_plugins_from_context
errored_plugins = get_errored_plugins_from_context(context)
if any(plugin.__name__ == "FusionSubmitDeadline"
for plugin in errored_plugins):
raise RuntimeError("Skipping incrementing current file because "
"submission to render farm failed.")
if any(
plugin.__name__ == "FusionSubmitDeadline"
for plugin in errored_plugins
):
raise KnownPublishError(
"Skipping incrementing current file because "
"submission to render farm failed."
)
comp = context.data.get("currentComp")
assert comp, "Must have comp"

View file

@ -17,5 +17,5 @@ class FusionSaveComp(pyblish.api.ContextPlugin):
current = comp.GetAttrs().get("COMPS_FileName", "")
assert context.data['currentFile'] == current
self.log.info("Saving current file..")
self.log.info("Saving current file: {}".format(current))
comp.Save()

View file

@ -1,12 +1,17 @@
import pyblish.api
from openpype.pipeline.publish import RepairAction
from openpype.pipeline import PublishValidationError
from openpype.pipeline import (
publish,
OptionalPyblishPluginMixin,
PublishValidationError,
)
from openpype.hosts.fusion.api.action import SelectInvalidAction
class ValidateBackgroundDepth(pyblish.api.InstancePlugin):
class ValidateBackgroundDepth(
pyblish.api.InstancePlugin, OptionalPyblishPluginMixin
):
"""Validate if all Background tool are set to float32 bit"""
order = pyblish.api.ValidatorOrder
@ -15,11 +20,10 @@ class ValidateBackgroundDepth(pyblish.api.InstancePlugin):
families = ["render"]
optional = True
actions = [SelectInvalidAction, RepairAction]
actions = [SelectInvalidAction, publish.RepairAction]
@classmethod
def get_invalid(cls, instance):
context = instance.context
comp = context.data.get("currentComp")
assert comp, "Must have Comp object"
@ -31,12 +35,16 @@ class ValidateBackgroundDepth(pyblish.api.InstancePlugin):
return [i for i in backgrounds if i.GetInput("Depth") != 4.0]
def process(self, instance):
if not self.is_active(instance.data):
return
invalid = self.get_invalid(instance)
if invalid:
raise PublishValidationError(
"Found {} Backgrounds tools which"
" are not set to float32".format(len(invalid)),
title=self.label)
title=self.label,
)
@classmethod
def repair(cls, instance):

View file

@ -21,7 +21,7 @@ class ValidateCreateFolderChecked(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
tool = instance[0]
tool = instance.data["tool"]
create_dir = tool.GetInput("CreateDir")
if create_dir == 0.0:
cls.log.error(

View file

@ -14,7 +14,7 @@ class ValidateLocalFramesExistence(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Expected Frames Exists"
families = ["render"]
families = ["render.frames"]
hosts = ["fusion"]
actions = [RepairAction, SelectInvalidAction]
@ -23,31 +23,20 @@ class ValidateLocalFramesExistence(pyblish.api.InstancePlugin):
if non_existing_frames is None:
non_existing_frames = []
if instance.data.get("render_target") == "frames":
tool = instance[0]
tool = instance.data["tool"]
frame_start = instance.data["frameStart"]
frame_end = instance.data["frameEnd"]
path = instance.data["path"]
output_dir = instance.data["outputDir"]
expected_files = instance.data["expectedFiles"]
basename = os.path.basename(path)
head, ext = os.path.splitext(basename)
files = [
f"{head}{str(frame).zfill(4)}{ext}"
for frame in range(frame_start, frame_end + 1)
]
for file in expected_files:
if not os.path.exists(file):
cls.log.error(
f"Missing file: {file}"
)
non_existing_frames.append(file)
for file in files:
if not os.path.exists(os.path.join(output_dir, file)):
cls.log.error(
f"Missing file: {os.path.join(output_dir, file)}"
)
non_existing_frames.append(file)
if len(non_existing_frames) > 0:
cls.log.error(f"Some of {tool.Name}'s files does not exist")
return [tool]
if len(non_existing_frames) > 0:
cls.log.error(f"Some of {tool.Name}'s files does not exist")
return [tool]
def process(self, instance):
non_existing_frames = []
@ -67,8 +56,7 @@ class ValidateLocalFramesExistence(pyblish.api.InstancePlugin):
def repair(cls, instance):
invalid = cls.get_invalid(instance)
if invalid:
tool = invalid[0]
tool = instance.data["tool"]
# Change render target to local to render locally
tool.SetData("openpype.creator_attributes.render_target", "local")

View file

@ -30,11 +30,11 @@ class ValidateFilenameHasExtension(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
path = instance.data["path"]
path = instance.data["expectedFiles"][0]
fname, ext = os.path.splitext(path)
if not ext:
tool = instance[0]
tool = instance.data["tool"]
cls.log.error("%s has no extension specified" % tool.Name)
return [tool]

View file

@ -0,0 +1,41 @@
import pyblish.api
from openpype.pipeline import PublishValidationError
class ValidateInstanceFrameRange(pyblish.api.InstancePlugin):
"""Validate instance frame range is within comp's global render range."""
order = pyblish.api.ValidatorOrder
label = "Validate Filename Has Extension"
families = ["render"]
hosts = ["fusion"]
def process(self, instance):
context = instance.context
global_start = context.data["compFrameStart"]
global_end = context.data["compFrameEnd"]
render_start = instance.data["frameStartHandle"]
render_end = instance.data["frameEndHandle"]
if render_start < global_start or render_end > global_end:
message = (
f"Instance {instance} render frame range "
f"({render_start}-{render_end}) is outside of the comp's "
f"global render range ({global_start}-{global_end}) and thus "
f"can't be rendered. "
)
description = (
f"{message}\n\n"
f"Either update the comp's global range or the instance's "
f"frame range to ensure the comp's frame range includes the "
f"to render frame range for the instance."
)
raise PublishValidationError(
title="Frame range outside of comp range",
message=message,
description=description
)

View file

@ -20,7 +20,7 @@ class ValidateSaverHasInput(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
saver = instance[0]
saver = instance.data["tool"]
if not saver.Input.GetConnectedOutput():
return [saver]

View file

@ -37,7 +37,7 @@ class ValidateSaverPassthrough(pyblish.api.ContextPlugin):
def is_invalid(self, instance):
saver = instance[0]
saver = instance.data["tool"]
attr = saver.GetAttrs()
active = not attr["TOOLB_PassThrough"]

View file

@ -0,0 +1,46 @@
import pyblish.api
import hou
from openpype.pipeline.publish import get_errored_instances_from_context
class SelectInvalidAction(pyblish.api.Action):
"""Select invalid nodes in Maya when plug-in failed.
To retrieve the invalid nodes this assumes a static `get_invalid()`
method is available on the plugin.
"""
label = "Select invalid"
on = "failed" # This action is only available on a failed plug-in
icon = "search" # Icon from Awesome Icon
def process(self, context, plugin):
errored_instances = get_errored_instances_from_context(context)
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(errored_instances, plugin)
# Get the invalid nodes for the plug-ins
self.log.info("Finding invalid nodes..")
invalid = list()
for instance in instances:
invalid_nodes = plugin.get_invalid(instance)
if invalid_nodes:
if isinstance(invalid_nodes, (list, tuple)):
invalid.extend(invalid_nodes)
else:
self.log.warning("Plug-in returned to be invalid, "
"but has no selectable nodes.")
hou.clearAllSelected()
if invalid:
self.log.info("Selecting invalid nodes: {}".format(
", ".join(node.path() for node in invalid)
))
for node in invalid:
node.setSelected(True)
node.setCurrent(True)
else:
self.log.info("No invalid nodes found.")

View file

@ -12,26 +12,43 @@ import tempfile
import logging
import os
from openpype.client import get_asset_by_name
from openpype.pipeline import registered_host
from openpype.pipeline.create import CreateContext
from openpype.resources import get_openpype_icon_filepath
import hou
import stateutils
import soptoolutils
import loptoolutils
import cop2toolutils
log = logging.getLogger(__name__)
CATEGORY_GENERIC_TOOL = {
hou.sopNodeTypeCategory(): soptoolutils.genericTool,
hou.cop2NodeTypeCategory(): cop2toolutils.genericTool,
hou.lopNodeTypeCategory(): loptoolutils.genericTool
}
CREATE_SCRIPT = """
from openpype.hosts.houdini.api.creator_node_shelves import create_interactive
create_interactive("{identifier}")
create_interactive("{identifier}", **kwargs)
"""
def create_interactive(creator_identifier):
def create_interactive(creator_identifier, **kwargs):
"""Create a Creator using its identifier interactively.
This is used by the generated shelf tools as callback when a user selects
the creator from the node tab search menu.
The `kwargs` should be what Houdini passes to the tool create scripts
context. For more information see:
https://www.sidefx.com/docs/houdini/hom/tool_script.html#arguments
Args:
creator_identifier (str): The creator identifier of the Creator plugin
to create.
@ -58,6 +75,33 @@ def create_interactive(creator_identifier):
host = registered_host()
context = CreateContext(host)
creator = context.manual_creators.get(creator_identifier)
if not creator:
raise RuntimeError("Invalid creator identifier: "
"{}".format(creator_identifier))
# TODO: Once more elaborate unique create behavior should exist per Creator
# instead of per network editor area then we should move this from here
# to a method on the Creators for which this could be the default
# implementation.
pane = stateutils.activePane(kwargs)
if isinstance(pane, hou.NetworkEditor):
pwd = pane.pwd()
subset_name = creator.get_subset_name(
variant=variant,
task_name=context.get_current_task_name(),
asset_doc=get_asset_by_name(
project_name=context.get_current_project_name(),
asset_name=context.get_current_asset_name()
),
project_name=context.get_current_project_name(),
host_name=context.host_name
)
tool_fn = CATEGORY_GENERIC_TOOL.get(pwd.childTypeCategory())
if tool_fn is not None:
out_null = tool_fn(kwargs, "null")
out_null.setName("OUT_{}".format(subset_name), unique_name=True)
before = context.instances_by_id.copy()
@ -135,12 +179,20 @@ def install():
log.debug("Writing OpenPype Creator nodes to shelf: {}".format(filepath))
tools = []
with shelves_change_block():
for identifier, creator in create_context.manual_creators.items():
# TODO: Allow the creator plug-in itself to override the categories
# for where they are shown, by e.g. defining
# `Creator.get_network_categories()`
# Allow the creator plug-in itself to override the categories
# for where they are shown with `Creator.get_network_categories()`
if not hasattr(creator, "get_network_categories"):
log.debug("Creator {} has no `get_network_categories` method "
"and will not be added to TAB search.")
continue
network_categories = creator.get_network_categories()
if not network_categories:
continue
key = "openpype_create.{}".format(identifier)
log.debug(f"Registering {key}")
@ -153,17 +205,13 @@ def install():
creator.label
),
"help_url": None,
"network_categories": [
hou.ropNodeTypeCategory(),
hou.sopNodeTypeCategory()
],
"network_categories": network_categories,
"viewer_categories": [],
"cop_viewer_categories": [],
"network_op_type": None,
"viewer_op_type": None,
"locations": ["OpenPype"]
}
label = "Create {}".format(creator.label)
tool = hou.shelves.tool(key)
if tool:

View file

@ -81,7 +81,13 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
# TODO: make sure this doesn't trigger when
# opening with last workfile.
_set_context_settings()
shelves.generate_shelves()
if not IS_HEADLESS:
import hdefereval # noqa, hdefereval is only available in ui mode
# Defer generation of shelves due to issue on Windows where shelf
# initialization during start up delays Houdini UI by minutes
# making it extremely slow to launch.
hdefereval.executeDeferred(shelves.generate_shelves)
if not IS_HEADLESS:
import hdefereval # noqa, hdefereval is only available in ui mode

View file

@ -276,3 +276,19 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase):
color = hou.Color((0.616, 0.871, 0.769))
node.setUserData('nodeshape', shape)
node.setColor(color)
def get_network_categories(self):
"""Return in which network view type this creator should show.
The node type categories returned here will be used to define where
the creator will show up in the TAB search for nodes in Houdini's
Network View.
This can be overridden in inherited classes to define where that
particular Creator should be visible in the TAB search.
Returns:
list: List of houdini node type categories
"""
return [hou.ropNodeTypeCategory()]

View file

@ -3,6 +3,8 @@
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance, CreatorError
import hou
class CreateAlembicCamera(plugin.HoudiniCreator):
"""Single baked camera from Alembic ROP."""
@ -47,3 +49,9 @@ class CreateAlembicCamera(plugin.HoudiniCreator):
self.lock_parameters(instance_node, to_lock)
instance_node.parm("trange").set(1)
def get_network_categories(self):
return [
hou.ropNodeTypeCategory(),
hou.objNodeTypeCategory()
]

View file

@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating composite sequences."""
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance
from openpype.pipeline import CreatedInstance, CreatorError
import hou
class CreateCompositeSequence(plugin.HoudiniCreator):
@ -35,8 +37,20 @@ class CreateCompositeSequence(plugin.HoudiniCreator):
"copoutput": filepath
}
if self.selected_nodes:
if len(self.selected_nodes) > 1:
raise CreatorError("More than one item selected.")
path = self.selected_nodes[0].path()
parms["coppath"] = path
instance_node.setParms(parms)
# Lock any parameters in this list
to_lock = ["prim_to_detail_pattern"]
self.lock_parameters(instance_node, to_lock)
def get_network_categories(self):
return [
hou.ropNodeTypeCategory(),
hou.cop2NodeTypeCategory()
]

View file

@ -3,6 +3,8 @@
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance
import hou
class CreatePointCache(plugin.HoudiniCreator):
"""Alembic ROP to pointcache"""
@ -49,3 +51,9 @@ class CreatePointCache(plugin.HoudiniCreator):
# Lock any parameters in this list
to_lock = ["prim_to_detail_pattern"]
self.lock_parameters(instance_node, to_lock)
def get_network_categories(self):
return [
hou.ropNodeTypeCategory(),
hou.sopNodeTypeCategory()
]

View file

@ -3,6 +3,8 @@
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance
import hou
class CreateUSD(plugin.HoudiniCreator):
"""Universal Scene Description"""
@ -13,7 +15,6 @@ class CreateUSD(plugin.HoudiniCreator):
enabled = False
def create(self, subset_name, instance_data, pre_create_data):
import hou # noqa
instance_data.pop("active", None)
instance_data.update({"node_type": "usd"})
@ -43,3 +44,9 @@ class CreateUSD(plugin.HoudiniCreator):
"id",
]
self.lock_parameters(instance_node, to_lock)
def get_network_categories(self):
return [
hou.ropNodeTypeCategory(),
hou.lopNodeTypeCategory()
]

View file

@ -3,6 +3,8 @@
from openpype.hosts.houdini.api import plugin
from openpype.pipeline import CreatedInstance
import hou
class CreateVDBCache(plugin.HoudiniCreator):
"""OpenVDB from Geometry ROP"""
@ -34,3 +36,9 @@ class CreateVDBCache(plugin.HoudiniCreator):
parms["soppath"] = self.selected_nodes[0].path()
instance_node.setParms(parms)
def get_network_categories(self):
return [
hou.ropNodeTypeCategory(),
hou.sopNodeTypeCategory()
]

View file

@ -14,7 +14,7 @@ class CreateWorkfile(plugin.HoudiniCreatorBase, AutoCreator):
identifier = "io.openpype.creators.houdini.workfile"
label = "Workfile"
family = "workfile"
icon = "document"
icon = "fa5.file"
default_variant = "Main"

View file

@ -8,7 +8,6 @@ import pyblish.api
from openpype.hosts.houdini.api import lib
class CollectFrames(pyblish.api.InstancePlugin):
"""Collect all frames which would be saved from the ROP nodes"""
@ -34,8 +33,10 @@ class CollectFrames(pyblish.api.InstancePlugin):
self.log.warning("Using current frame: {}".format(hou.frame()))
output = output_parm.eval()
_, ext = lib.splitext(output,
allowed_multidot_extensions=[".ass.gz"])
_, ext = lib.splitext(
output,
allowed_multidot_extensions=[".ass.gz"]
)
file_name = os.path.basename(output)
result = file_name

View file

@ -117,4 +117,4 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin):
inputs = [c["representation"] for c in containers]
instance.data["inputRepresentations"] = inputs
self.log.info("Collected inputs: %s" % inputs)
self.log.debug("Collected inputs: %s" % inputs)

View file

@ -55,7 +55,9 @@ class CollectInstances(pyblish.api.ContextPlugin):
has_family = node.evalParm("family")
assert has_family, "'%s' is missing 'family'" % node.name()
self.log.info("processing {}".format(node))
self.log.info(
"Processing legacy instance node {}".format(node.path())
)
data = lib.read(node)
# Check bypass state and reverse

View file

@ -19,6 +19,9 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin):
instance.data["handleEnd"] = 0
instance.data["fps"] = instance.context.data["fps"]
# Enable ftrack functionality
instance.data.setdefault("families", []).append('ftrack')
# Get the camera from the rop node to collect the focal length
ropnode_path = instance.data["instance_node"]
ropnode = hou.node(ropnode_path)
@ -26,8 +29,9 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin):
camera_path = ropnode.parm("camera").eval()
camera_node = hou.node(camera_path)
if not camera_node:
raise RuntimeError("No valid camera node found on review node: "
"{}".format(camera_path))
self.log.warning("No valid camera node found on review node: "
"{}".format(camera_path))
return
# Collect focal length.
focal_length_parm = camera_node.parm("focal")
@ -49,5 +53,3 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin):
# Store focal length in `burninDataMembers`
burnin_members = instance.data.setdefault("burninDataMembers", {})
burnin_members["focalLength"] = focal_length
instance.data.setdefault("families", []).append('ftrack')

View file

@ -32,5 +32,4 @@ class CollectWorkfile(pyblish.api.InstancePlugin):
"stagingDir": folder,
}]
self.log.info('Collected instance: {}'.format(file))
self.log.info('staging Dir: {}'.format(folder))
self.log.debug('Collected workfile instance: {}'.format(file))

View file

@ -2,27 +2,20 @@ import os
import pyblish.api
from openpype.pipeline import (
publish,
OptionalPyblishPluginMixin
)
from openpype.pipeline import publish
from openpype.hosts.houdini.api.lib import render_rop
import hou
class ExtractOpenGL(publish.Extractor,
OptionalPyblishPluginMixin):
class ExtractOpenGL(publish.Extractor):
order = pyblish.api.ExtractorOrder - 0.01
label = "Extract OpenGL"
families = ["review"]
hosts = ["houdini"]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
ropnode = hou.node(instance.data.get("instance_node"))
output = ropnode.evalParm("picture")

View file

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Scene setting</title>
<description>
## Invalid input node
VDB input must have the same number of VDBs, points, primitives and vertices as output.
</description>
<detail>
### __Detailed Info__ (optional)
A VDB is an inherited type of Prim, holds the following data:
- Primitives: 1
- Points: 1
- Vertices: 1
- VDBs: 1
</detail>
</error>
</root>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Invalid VDB</title>
<description>
## Invalid VDB output
All primitives of the output geometry must be VDBs, no other primitive
types are allowed. That means that regardless of the amount of VDBs in the
geometry it will have an equal amount of VDBs, points, primitives and
vertices since each VDB primitive is one point, one vertex and one VDB.
This validation only checks the geometry on the first frame of the export
frame range.
</description>
<detail>
### Detailed Info
ROP node `{rop_path}` is set to export SOP path `{sop_path}`.
{message}
</detail>
</error>
</root>

View file

@ -20,7 +20,7 @@ class SaveCurrentScene(pyblish.api.ContextPlugin):
)
if host.has_unsaved_changes():
self.log.info("Saving current file {}...".format(current_file))
self.log.info("Saving current file: {}".format(current_file))
host.save_workfile(current_file)
else:
self.log.debug("No unsaved changes, skipping file save..")

View file

@ -16,15 +16,19 @@ class ValidateSceneReview(pyblish.api.InstancePlugin):
label = "Scene Setting for review"
def process(self, instance):
invalid = self.get_invalid_scene_path(instance)
report = []
if invalid:
report.append(
"Scene path does not exist: '%s'" % invalid[0],
)
instance_node = hou.node(instance.data.get("instance_node"))
invalid = self.get_invalid_resolution(instance)
invalid = self.get_invalid_scene_path(instance_node)
if invalid:
report.append(invalid)
invalid = self.get_invalid_camera_path(instance_node)
if invalid:
report.append(invalid)
invalid = self.get_invalid_resolution(instance_node)
if invalid:
report.extend(invalid)
@ -33,26 +37,36 @@ class ValidateSceneReview(pyblish.api.InstancePlugin):
"\n\n".join(report),
title=self.label)
def get_invalid_scene_path(self, instance):
node = hou.node(instance.data.get("instance_node"))
scene_path_parm = node.parm("scenepath")
def get_invalid_scene_path(self, rop_node):
scene_path_parm = rop_node.parm("scenepath")
scene_path_node = scene_path_parm.evalAsNode()
if not scene_path_node:
return [scene_path_parm.evalAsString()]
path = scene_path_parm.evalAsString()
return "Scene path does not exist: '{}'".format(path)
def get_invalid_resolution(self, instance):
node = hou.node(instance.data.get("instance_node"))
def get_invalid_camera_path(self, rop_node):
camera_path_parm = rop_node.parm("camera")
camera_node = camera_path_parm.evalAsNode()
path = camera_path_parm.evalAsString()
if not camera_node:
return "Camera path does not exist: '{}'".format(path)
type_name = camera_node.type().name()
if type_name != "cam":
return "Camera path is not a camera: '{}' (type: {})".format(
path, type_name
)
def get_invalid_resolution(self, rop_node):
# The resolution setting is only used when Override Camera Resolution
# is enabled. So we skip validation if it is disabled.
override = node.parm("tres").eval()
override = rop_node.parm("tres").eval()
if not override:
return
invalid = []
res_width = node.parm("res1").eval()
res_height = node.parm("res2").eval()
res_width = rop_node.parm("res1").eval()
res_height = rop_node.parm("res2").eval()
if res_width == 0:
invalid.append("Override Resolution width is set to zero.")
if res_height == 0:

View file

@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
import pyblish.api
from openpype.pipeline import (
PublishValidationError
)
class ValidateVDBInputNode(pyblish.api.InstancePlugin):
"""Validate that the node connected to the output node is of type VDB.
Regardless of the amount of VDBs create the output will need to have an
equal amount of VDBs, points, primitives and vertices
A VDB is an inherited type of Prim, holds the following data:
- Primitives: 1
- Points: 1
- Vertices: 1
- VDBs: 1
"""
order = pyblish.api.ValidatorOrder + 0.1
families = ["vdbcache"]
hosts = ["houdini"]
label = "Validate Input Node (VDB)"
def process(self, instance):
invalid = self.get_invalid(instance)
if invalid:
raise PublishValidationError(
self,
"Node connected to the output node is not of type VDB",
title=self.label
)
@classmethod
def get_invalid(cls, instance):
node = instance.data["output_node"]
prims = node.geometry().prims()
nr_of_prims = len(prims)
nr_of_points = len(node.geometry().points())
if nr_of_points != nr_of_prims:
cls.log.error("The number of primitives and points do not match")
return [instance]
for prim in prims:
if prim.numVertices() != 1:
cls.log.error("Found primitive with more than 1 vertex!")
return [instance]

View file

@ -1,14 +1,73 @@
# -*- coding: utf-8 -*-
import contextlib
import pyblish.api
import hou
from openpype.pipeline import PublishValidationError
from openpype.pipeline import PublishXmlValidationError
from openpype.hosts.houdini.api.action import SelectInvalidAction
def group_consecutive_numbers(nums):
"""
Args:
nums (list): List of sorted integer numbers.
Yields:
str: Group ranges as {start}-{end} if more than one number in the range
else it yields {end}
"""
start = None
end = None
def _result(a, b):
if a == b:
return "{}".format(a)
else:
return "{}-{}".format(a, b)
for num in nums:
if start is None:
start = num
end = num
elif num == end + 1:
end = num
else:
yield _result(start, end)
start = num
end = num
if start is not None:
yield _result(start, end)
@contextlib.contextmanager
def update_mode_context(mode):
original = hou.updateModeSetting()
try:
hou.setUpdateMode(mode)
yield
finally:
hou.setUpdateMode(original)
def get_geometry_at_frame(sop_node, frame, force=True):
"""Return geometry at frame but force a cooked value."""
with update_mode_context(hou.updateMode.AutoUpdate):
sop_node.cook(force=force, frame_range=(frame, frame))
return sop_node.geometryAtFrame(frame)
class ValidateVDBOutputNode(pyblish.api.InstancePlugin):
"""Validate that the node connected to the output node is of type VDB.
Regardless of the amount of VDBs create the output will need to have an
equal amount of VDBs, points, primitives and vertices
All primitives of the output geometry must be VDBs, no other primitive
types are allowed. That means that regardless of the amount of VDBs in the
geometry it will have an equal amount of VDBs, points, primitives and
vertices since each VDB primitive is one point, one vertex and one VDB.
This validation only checks the geometry on the first frame of the export
frame range for optimization purposes.
A VDB is an inherited type of Prim, holds the following data:
- Primitives: 1
@ -22,54 +81,95 @@ class ValidateVDBOutputNode(pyblish.api.InstancePlugin):
families = ["vdbcache"]
hosts = ["houdini"]
label = "Validate Output Node (VDB)"
actions = [SelectInvalidAction]
def process(self, instance):
invalid = self.get_invalid(instance)
if invalid:
raise PublishValidationError(
"Node connected to the output node is not" " of type VDB!",
title=self.label
invalid_nodes, message = self.get_invalid_with_message(instance)
if invalid_nodes:
# instance_node is str, but output_node is hou.Node so we convert
output = instance.data.get("output_node")
output_path = output.path() if output else None
raise PublishXmlValidationError(
self,
"Invalid VDB content: {}".format(message),
formatting_data={
"message": message,
"rop_path": instance.data.get("instance_node"),
"sop_path": output_path
}
)
@classmethod
def get_invalid(cls, instance):
def get_invalid_with_message(cls, instance):
node = instance.data["output_node"]
node = instance.data.get("output_node")
if node is None:
cls.log.error(
instance_node = instance.data.get("instance_node")
error = (
"SOP path is not correctly set on "
"ROP node '%s'." % instance.data.get("instance_node")
"ROP node `{}`.".format(instance_node)
)
return [instance]
return [hou.node(instance_node), error]
frame = instance.data.get("frameStart", 0)
geometry = node.geometryAtFrame(frame)
geometry = get_geometry_at_frame(node, frame)
if geometry is None:
# No geometry data on this node, maybe the node hasn't cooked?
cls.log.error(
"SOP node has no geometry data. "
"Is it cooked? %s" % node.path()
error = (
"SOP node `{}` has no geometry data. "
"Was it unable to cook?".format(node.path())
)
return [node]
return [node, error]
prims = geometry.prims()
nr_of_prims = len(prims)
num_prims = geometry.intrinsicValue("primitivecount")
num_points = geometry.intrinsicValue("pointcount")
if num_prims == 0 and num_points == 0:
# Since we are only checking the first frame it doesn't mean there
# won't be VDB prims in a few frames. As such we'll assume for now
# the user knows what he or she is doing
cls.log.warning(
"SOP node `{}` has no primitives on start frame {}. "
"Validation is skipped and it is assumed elsewhere in the "
"frame range VDB prims and only VDB prims will exist."
"".format(node.path(), int(frame))
)
return [None, None]
# All primitives must be hou.VDB
invalid_prim = False
for prim in prims:
if not isinstance(prim, hou.VDB):
cls.log.error("Found non-VDB primitive: %s" % prim)
invalid_prim = True
if invalid_prim:
return [instance]
num_vdb_prims = geometry.countPrimType(hou.primType.VDB)
cls.log.debug("Detected {} VDB primitives".format(num_vdb_prims))
if num_prims != num_vdb_prims:
# There's at least one primitive that is not a VDB.
# Search them and report them to the artist.
prims = geometry.prims()
invalid_prims = [prim for prim in prims
if not isinstance(prim, hou.VDB)]
if invalid_prims:
# Log prim numbers as consecutive ranges so logging isn't very
# slow for large number of primitives
error = (
"Found non-VDB primitives for `{}`. "
"Primitive indices {} are not VDB primitives.".format(
node.path(),
", ".join(group_consecutive_numbers(
prim.number() for prim in invalid_prims
))
)
)
return [node, error]
nr_of_points = len(geometry.points())
if nr_of_points != nr_of_prims:
cls.log.error("The number of primitives and points do not match")
return [instance]
if num_points != num_vdb_prims:
# We have points unrelated to the VDB primitives.
error = (
"The number of primitives and points do not match in '{}'. "
"This likely means you have unconnected points, which we do "
"not allow in the VDB output.".format(node.path()))
return [node, error]
for prim in prims:
if prim.numVertices() != 1:
cls.log.error("Found primitive with more than 1 vertex!")
return [instance]
return [None, None]
@classmethod
def get_invalid(cls, instance):
nodes, _ = cls.get_invalid_with_message(instance)
return nodes

View file

@ -28,18 +28,37 @@ class ValidateWorkfilePaths(
if not self.is_active(instance.data):
return
invalid = self.get_invalid()
self.log.info(
"node types to check: {}".format(", ".join(self.node_types)))
self.log.info(
"prohibited vars: {}".format(", ".join(self.prohibited_vars))
self.log.debug(
"Checking node types: {}".format(", ".join(self.node_types)))
self.log.debug(
"Searching prohibited vars: {}".format(
", ".join(self.prohibited_vars)
)
)
if invalid:
for param in invalid:
self.log.error(
"{}: {}".format(param.path(), param.unexpandedString()))
raise PublishValidationError(
"Invalid paths found", title=self.label)
if invalid:
all_container_vars = set()
for param in invalid:
value = param.unexpandedString()
contained_vars = [
var for var in self.prohibited_vars
if var in value
]
all_container_vars.update(contained_vars)
self.log.error(
"Parm {} contains prohibited vars {}: {}".format(
param.path(),
", ".join(contained_vars),
value)
)
message = (
"Prohibited vars {} found in parameter values".format(
", ".join(all_container_vars)
)
)
raise PublishValidationError(message, title=self.label)
@classmethod
def get_invalid(cls):
@ -63,7 +82,7 @@ class ValidateWorkfilePaths(
def repair(cls, instance):
invalid = cls.get_invalid()
for param in invalid:
cls.log.info("processing: {}".format(param.path()))
cls.log.info("Processing: {}".format(param.path()))
cls.log.info("Replacing {} for {}".format(
param.unexpandedString(),
hou.text.expandString(param.unexpandedString())))

View file

@ -145,7 +145,7 @@ def get_default_render_folder(project_setting=None):
["default_render_image_folder"])
def set_framerange(start_frame, end_frame):
def set_render_frame_range(start_frame, end_frame):
"""
Note:
Frame range can be specified in different types. Possible values are:
@ -157,10 +157,10 @@ def set_framerange(start_frame, end_frame):
Todo:
Current type is hard-coded, there should be a custom setting for this.
"""
rt.rendTimeType = 4
rt.rendTimeType = 3
if start_frame is not None and end_frame is not None:
frame_range = "{0}-{1}".format(start_frame, end_frame)
rt.rendPickupFrames = frame_range
rt.rendStart = int(start_frame)
rt.rendEnd = int(end_frame)
def get_multipass_setting(project_setting=None):
@ -180,10 +180,16 @@ def set_scene_resolution(width: int, height: int):
None
"""
# make sure the render dialog is closed
# for the update of resolution
# Changing the Render Setup dialog settingsshould be done
# with the actual Render Setup dialog in a closed state.
if rt.renderSceneDialog.isOpen():
rt.renderSceneDialog.close()
rt.renderWidth = width
rt.renderHeight = height
def reset_scene_resolution():
"""Apply the scene resolution from the project definition
@ -246,10 +252,15 @@ 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 = frame_range["frameStart"] - int(frame_range["handleStart"])
frame_end = frame_range["frameEnd"] + int(frame_range["handleEnd"])
frange_cmd = f"animationRange = interval {frame_start} {frame_end}"
frame_start_handle = frame_range["frameStart"] - int(
frame_range["handleStart"]
)
frame_end_handle = frame_range["frameEnd"] + int(frame_range["handleEnd"])
frange_cmd = (
f"animationRange = interval {frame_start_handle} {frame_end_handle}"
)
rt.execute(frange_cmd)
set_render_frame_range(frame_start_handle, frame_end_handle)
def set_context_setting():
@ -266,6 +277,7 @@ def set_context_setting():
None
"""
reset_scene_resolution()
reset_frame_range()
def get_max_version():

View file

@ -36,8 +36,9 @@ class RenderProducts(object):
container)
context = get_current_project_asset()
startFrame = context["data"].get("frameStart")
endFrame = context["data"].get("frameEnd") + 1
# TODO: change the frame range follows the current render setting
startFrame = int(rt.rendStart)
endFrame = int(rt.rendEnd) + 1
img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa
full_render_list = self.beauty_render_product(output_file,

View file

@ -6,7 +6,7 @@ from openpype.pipeline import legacy_io
from openpype.pipeline.context_tools import get_current_project_asset
from openpype.hosts.max.api.lib import (
set_framerange,
set_render_frame_range,
get_current_renderer,
get_default_render_folder
)
@ -68,7 +68,7 @@ class RenderSettings(object):
# Set Frame Range
frame_start = context["data"].get("frame_start")
frame_end = context["data"].get("frame_end")
set_framerange(frame_start, frame_end)
set_render_frame_range(frame_start, frame_end)
# get the production render
renderer_class = get_current_renderer()
renderer = str(renderer_class).split(":")[0]
@ -105,6 +105,9 @@ class RenderSettings(object):
rt.rendSaveFile = True
if rt.renderSceneDialog.isOpen():
rt.renderSceneDialog.close()
def arnold_setup(self):
# get Arnold RenderView run in the background
# for setting up renderable camera

View file

@ -52,6 +52,7 @@ class MaxHost(HostBase, IWorkfileHost, ILoadHost, INewPublisher):
def context_setting():
return lib.set_context_setting()
rt.callbacks.addScript(rt.Name('systemPostNew'),
context_setting)

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""Creator plugin for model."""
from openpype.hosts.max.api import plugin
from openpype.pipeline import CreatedInstance
class CreateModel(plugin.MaxCreator):
identifier = "io.openpype.creators.max.model"
label = "Model"
family = "model"
icon = "gear"
def create(self, subset_name, instance_data, pre_create_data):
from pymxs import runtime as rt
instance = super(CreateModel, self).create(
subset_name,
instance_data,
pre_create_data) # type: CreatedInstance
container = rt.getNodeByName(instance.data.get("instance_node"))
# TODO: Disable "Add to Containers?" Panel
# parent the selected cameras into the container
sel_obj = None
if self.selected_nodes:
sel_obj = list(self.selected_nodes)
for obj in sel_obj:
obj.parent = container
# for additional work on the node:
# instance_node = rt.getNodeByName(instance.get("instance_node"))

View file

@ -27,6 +27,11 @@ class CreateRender(plugin.MaxCreator):
# for additional work on the node:
# instance_node = rt.getNodeByName(instance.get("instance_node"))
# make sure the render dialog is closed
# for the update of resolution
# Changing the Render Setup dialog settings should be done
# with the actual Render Setup dialog in a closed state.
# set viewport camera for rendering(mandatory for deadline)
RenderSettings().set_render_camera(sel_obj)
# set output paths for rendering(mandatory for deadline)

View file

@ -20,28 +20,25 @@ class FbxLoader(load.LoaderPlugin):
from pymxs import runtime as rt
filepath = os.path.normpath(self.fname)
rt.FBXImporterSetParam("Animation", True)
rt.FBXImporterSetParam("Camera", True)
rt.FBXImporterSetParam("AxisConversionMethod", True)
rt.FBXImporterSetParam("Preserveinstances", True)
rt.importFile(
filepath,
rt.name("noPrompt"),
using=rt.FBXIMP)
fbx_import_cmd = (
f"""
container = rt.getNodeByName(f"{name}")
if not container:
container = rt.container()
container.name = f"{name}"
FBXImporterSetParam "Animation" true
FBXImporterSetParam "Cameras" true
FBXImporterSetParam "AxisConversionMethod" true
FbxExporterSetParam "UpAxis" "Y"
FbxExporterSetParam "Preserveinstances" true
importFile @"{filepath}" #noPrompt using:FBXIMP
""")
self.log.debug(f"Executing command: {fbx_import_cmd}")
rt.execute(fbx_import_cmd)
container_name = f"{name}_CON"
asset = rt.getNodeByName(f"{name}")
for selection in rt.getCurrentSelection():
selection.Parent = container
return containerise(
name, [asset], context, loader=self.__class__.__name__)
name, [container], context, loader=self.__class__.__name__)
def update(self, container, representation):
from pymxs import runtime as rt

View file

@ -10,7 +10,9 @@ class MaxSceneLoader(load.LoaderPlugin):
"""Max Scene Loader"""
families = ["camera",
"maxScene"]
"maxScene",
"model"]
representations = ["max"]
order = -8
icon = "code-fork"

View file

@ -0,0 +1,109 @@
import os
from openpype.pipeline import (
load, get_representation_path
)
from openpype.hosts.max.api.pipeline import containerise
from openpype.hosts.max.api import lib
from openpype.hosts.max.api.lib import maintained_selection
class ModelAbcLoader(load.LoaderPlugin):
"""Loading model with the Alembic loader."""
families = ["model"]
label = "Load Model(Alembic)"
representations = ["abc"]
order = -10
icon = "code-fork"
color = "orange"
def load(self, context, name=None, namespace=None, data=None):
from pymxs import runtime as rt
file_path = os.path.normpath(self.fname)
abc_before = {
c for c in rt.rootNode.Children
if rt.classOf(c) == rt.AlembicContainer
}
abc_import_cmd = (f"""
AlembicImport.ImportToRoot = false
AlembicImport.CustomAttributes = true
AlembicImport.UVs = true
AlembicImport.VertexColors = true
importFile @"{file_path}" #noPrompt
""")
self.log.debug(f"Executing command: {abc_import_cmd}")
rt.execute(abc_import_cmd)
abc_after = {
c for c in rt.rootNode.Children
if rt.classOf(c) == rt.AlembicContainer
}
# This should yield new AlembicContainer node
abc_containers = abc_after.difference(abc_before)
if len(abc_containers) != 1:
self.log.error("Something failed when loading.")
abc_container = abc_containers.pop()
return containerise(
name, [abc_container], context, 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)
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
with maintained_selection():
rt.select(node)
lib.imprint(container["instance_node"], {
"representation": str(representation["_id"])
})
def switch(self, container, representation):
self.update(container, representation)
def remove(self, container):
from pymxs import runtime as rt
node = rt.getNodeByName(container["instance_node"])
rt.delete(node)
@staticmethod
def get_container_children(parent, type_name):
from pymxs import runtime as rt
def list_children(node):
children = []
for c in node.Children:
children.append(c)
children += list_children(c)
return children
filtered = []
for child in list_children(parent):
class_type = str(rt.classOf(child.baseObject))
if class_type == type_name:
filtered.append(child)
return filtered

View file

@ -0,0 +1,75 @@
import os
from openpype.pipeline import (
load,
get_representation_path
)
from openpype.hosts.max.api.pipeline import containerise
from openpype.hosts.max.api import lib
from openpype.hosts.max.api.lib import maintained_selection
class FbxModelLoader(load.LoaderPlugin):
"""Fbx Model Loader"""
families = ["model"]
representations = ["fbx"]
order = -9
icon = "code-fork"
color = "white"
def load(self, context, name=None, namespace=None, data=None):
from pymxs import runtime as rt
filepath = os.path.normpath(self.fname)
rt.FBXImporterSetParam("Animation", False)
rt.FBXImporterSetParam("Cameras", False)
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}"
for selection in rt.getCurrentSelection():
selection.Parent = container
return containerise(
name, [container], context, 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"""
FBXImporterSetParam "Animation" false
FBXImporterSetParam "Cameras" false
FBXImporterSetParam "AxisConversionMethod" true
FbxExporterSetParam "UpAxis" "Y"
FbxExporterSetParam "Preserveinstances" true
importFile @"{path}" #noPrompt using:FBXIMP
""")
rt.execute(fbx_reimport_cmd)
with maintained_selection():
rt.select(node)
lib.imprint(container["instance_node"], {
"representation": str(representation["_id"])
})
def switch(self, container, representation):
self.update(container, representation)
def remove(self, container):
from pymxs import runtime as rt
node = rt.getNodeByName(container["instance_node"])
rt.delete(node)

View file

@ -0,0 +1,68 @@
import os
from openpype.pipeline import (
load,
get_representation_path
)
from openpype.hosts.max.api.pipeline import containerise
from openpype.hosts.max.api import lib
from openpype.hosts.max.api.lib import maintained_selection
class ObjLoader(load.LoaderPlugin):
"""Obj Loader"""
families = ["model"]
representations = ["obj"]
order = -9
icon = "code-fork"
color = "white"
def load(self, context, name=None, namespace=None, data=None):
from pymxs import runtime as rt
filepath = os.path.normpath(self.fname)
self.log.debug(f"Executing command to import..")
rt.execute(f'importFile @"{filepath}" #noPrompt using:ObjImp')
# create "missing" container for obj import
container = rt.container()
container.name = f"{name}"
# get current selection
for selection in rt.getCurrentSelection():
selection.Parent = container
asset = rt.getNodeByName(f"{name}")
return containerise(
name, [asset], context, 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 n in container.Children:
rt.delete(n)
rt.execute(f'importFile @"{path}" #noPrompt using:ObjImp')
# get current selection
for selection in rt.getCurrentSelection():
selection.Parent = container
with maintained_selection():
rt.select(node)
lib.imprint(node_name, {
"representation": str(representation["_id"])
})
def remove(self, container):
from pymxs import runtime as rt
node = rt.getNodeByName(container["instance_node"])
rt.delete(node)

View file

@ -0,0 +1,78 @@
import os
from openpype.pipeline import (
load, get_representation_path
)
from openpype.hosts.max.api.pipeline import containerise
from openpype.hosts.max.api import lib
from openpype.hosts.max.api.lib import maintained_selection
class ModelUSDLoader(load.LoaderPlugin):
"""Loading model with the USD loader."""
families = ["model"]
label = "Load Model(USD)"
representations = ["usda"]
order = -10
icon = "code-fork"
color = "orange"
def load(self, context, name=None, namespace=None, data=None):
from pymxs import runtime as rt
# asset_filepath
filepath = os.path.normpath(self.fname)
import_options = rt.USDImporter.CreateOptions()
base_filename = os.path.basename(filepath)
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)
asset = rt.getNodeByName(f"{name}")
return containerise(
name, [asset], context, 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)
rt.delete(n)
instance_name, _ = node_name.split("_")
import_options = rt.USDImporter.CreateOptions()
base_filename = os.path.basename(path)
_, ext = os.path.splitext(base_filename)
log_filepath = path.replace(ext, "txt")
rt.LogPath = log_filepath
rt.LogLevel = rt.name('info')
rt.USDImporter.importFile(path,
importOptions=import_options)
asset = rt.getNodeByName(f"{instance_name}")
asset.Parent = node
with maintained_selection():
rt.select(node)
lib.imprint(node_name, {
"representation": str(representation["_id"])
})
def switch(self, container, representation):
self.update(container, representation)
def remove(self, container):
from pymxs import runtime as rt
node = rt.getNodeByName(container["instance_node"])
rt.delete(node)

View file

@ -15,8 +15,7 @@ from openpype.hosts.max.api import lib
class AbcLoader(load.LoaderPlugin):
"""Alembic loader."""
families = ["model",
"camera",
families = ["camera",
"animation",
"pointcache"]
label = "Load Alembic"

View file

@ -46,7 +46,6 @@ class CollectRender(pyblish.api.InstancePlugin):
self.log.debug(f"Setting {version_int} to context.")
context.data["version"] = version_int
# setup the plugin as 3dsmax for the internal renderer
data = {
"subset": instance.name,
@ -59,8 +58,8 @@ class CollectRender(pyblish.api.InstancePlugin):
"source": filepath,
"expectedFiles": render_layer_files,
"plugin": "3dsmax",
"frameStart": context.data['frameStart'],
"frameEnd": context.data['frameEnd'],
"frameStart": int(rt.rendStart),
"frameEnd": int(rt.rendEnd),
"version": version_int,
"farm": True
}

View file

@ -1,18 +1,11 @@
import os
import pyblish.api
from openpype.pipeline import (
publish,
OptionalPyblishPluginMixin
)
from openpype.pipeline import publish, OptionalPyblishPluginMixin
from pymxs import runtime as rt
from openpype.hosts.max.api import (
maintained_selection,
get_all_children
)
from openpype.hosts.max.api import maintained_selection, get_all_children
class ExtractCameraAlembic(publish.Extractor,
OptionalPyblishPluginMixin):
class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin):
"""
Extract Camera with AlembicExport
"""
@ -38,38 +31,33 @@ class ExtractCameraAlembic(publish.Extractor,
path = os.path.join(stagingdir, filename)
# We run the render
self.log.info("Writing alembic '%s' to '%s'" % (filename,
stagingdir))
self.log.info("Writing alembic '%s' to '%s'" % (filename, stagingdir))
export_cmd = (
f"""
AlembicExport.ArchiveType = #ogawa
AlembicExport.CoordinateSystem = #maya
AlembicExport.StartFrame = {start}
AlembicExport.EndFrame = {end}
AlembicExport.CustomAttributes = true
exportFile @"{path}" #noPrompt selectedOnly:on using:AlembicExport
""")
self.log.debug(f"Executing command: {export_cmd}")
rt.AlembicExport.ArchiveType = rt.name("ogawa")
rt.AlembicExport.CoordinateSystem = rt.name("maya")
rt.AlembicExport.StartFrame = start
rt.AlembicExport.EndFrame = end
rt.AlembicExport.CustomAttributes = True
with maintained_selection():
# select and export
rt.select(get_all_children(rt.getNodeByName(container)))
rt.execute(export_cmd)
rt.exportFile(
path,
rt.name("noPrompt"),
selectedOnly=True,
using=rt.AlembicExport,
)
self.log.info("Performing Extraction ...")
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'abc',
'ext': 'abc',
'files': filename,
"name": "abc",
"ext": "abc",
"files": filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s" % (instance.name,
path))
self.log.info("Extracted instance '%s' to: %s" % (instance.name, path))

View file

@ -1,18 +1,11 @@
import os
import pyblish.api
from openpype.pipeline import (
publish,
OptionalPyblishPluginMixin
)
from openpype.pipeline import publish, OptionalPyblishPluginMixin
from pymxs import runtime as rt
from openpype.hosts.max.api import (
maintained_selection,
get_all_children
)
from openpype.hosts.max.api import maintained_selection, get_all_children
class ExtractCameraFbx(publish.Extractor,
OptionalPyblishPluginMixin):
class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin):
"""
Extract Camera with FbxExporter
"""
@ -33,43 +26,35 @@ class ExtractCameraFbx(publish.Extractor,
filename = "{name}.fbx".format(**instance.data)
filepath = os.path.join(stagingdir, filename)
self.log.info("Writing fbx file '%s' to '%s'" % (filename,
filepath))
self.log.info("Writing fbx file '%s' to '%s'" % (filename, filepath))
# Need to export:
# Animation = True
# Cameras = True
# AxisConversionMethod
fbx_export_cmd = (
f"""
FBXExporterSetParam "Animation" true
FBXExporterSetParam "Cameras" true
FBXExporterSetParam "AxisConversionMethod" "Animation"
FbxExporterSetParam "UpAxis" "Y"
FbxExporterSetParam "Preserveinstances" true
exportFile @"{filepath}" #noPrompt selectedOnly:true using:FBXEXP
""")
self.log.debug(f"Executing command: {fbx_export_cmd}")
rt.FBXExporterSetParam("Animation", True)
rt.FBXExporterSetParam("Cameras", True)
rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
rt.FBXExporterSetParam("UpAxis", "Y")
rt.FBXExporterSetParam("Preserveinstances", True)
with maintained_selection():
# select and export
rt.select(get_all_children(rt.getNodeByName(container)))
rt.execute(fbx_export_cmd)
rt.exportFile(
filepath,
rt.name("noPrompt"),
selectedOnly=True,
using=rt.FBXEXP,
)
self.log.info("Performing Extraction ...")
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'fbx',
'ext': 'fbx',
'files': filename,
"name": "fbx",
"ext": "fbx",
"files": filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s" % (instance.name,
filepath))
self.log.info(
"Extracted instance '%s' to: %s" % (instance.name, filepath)
)

View file

@ -1,18 +1,11 @@
import os
import pyblish.api
from openpype.pipeline import (
publish,
OptionalPyblishPluginMixin
)
from openpype.pipeline import publish, OptionalPyblishPluginMixin
from pymxs import runtime as rt
from openpype.hosts.max.api import (
maintained_selection,
get_all_children
)
from openpype.hosts.max.api import get_all_children
class ExtractMaxSceneRaw(publish.Extractor,
OptionalPyblishPluginMixin):
class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin):
"""
Extract Raw Max Scene with SaveSelected
"""
@ -20,8 +13,7 @@ class ExtractMaxSceneRaw(publish.Extractor,
order = pyblish.api.ExtractorOrder - 0.2
label = "Extract Max Scene (Raw)"
hosts = ["max"]
families = ["camera",
"maxScene"]
families = ["camera", "maxScene", "model"]
optional = True
def process(self, instance):
@ -36,26 +28,23 @@ class ExtractMaxSceneRaw(publish.Extractor,
filename = "{name}.max".format(**instance.data)
max_path = os.path.join(stagingdir, filename)
self.log.info("Writing max file '%s' to '%s'" % (filename,
max_path))
self.log.info("Writing max file '%s' to '%s'" % (filename, max_path))
if "representations" not in instance.data:
instance.data["representations"] = []
# saving max scene
with maintained_selection():
# need to figure out how to select the camera
rt.select(get_all_children(rt.getNodeByName(container)))
rt.execute(f'saveNodes selection "{max_path}" quiet:true')
nodes = get_all_children(rt.getNodeByName(container))
rt.saveNodes(nodes, max_path, quiet=True)
self.log.info("Performing Extraction ...")
representation = {
'name': 'max',
'ext': 'max',
'files': filename,
"name": "max",
"ext": "max",
"files": filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s" % (instance.name,
max_path))
self.log.info(
"Extracted instance '%s' to: %s" % (instance.name, max_path)
)

View file

@ -0,0 +1,64 @@
import os
import pyblish.api
from openpype.pipeline import publish, OptionalPyblishPluginMixin
from pymxs import runtime as rt
from openpype.hosts.max.api import maintained_selection, get_all_children
class ExtractModel(publish.Extractor, OptionalPyblishPluginMixin):
"""
Extract Geometry in Alembic Format
"""
order = pyblish.api.ExtractorOrder - 0.1
label = "Extract Geometry (Alembic)"
hosts = ["max"]
families = ["model"]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
container = instance.data["instance_node"]
self.log.info("Extracting Geometry ...")
stagingdir = self.staging_dir(instance)
filename = "{name}.abc".format(**instance.data)
filepath = os.path.join(stagingdir, filename)
# We run the render
self.log.info("Writing alembic '%s' to '%s'" % (filename, stagingdir))
rt.AlembicExport.ArchiveType = rt.name("ogawa")
rt.AlembicExport.CoordinateSystem = rt.name("maya")
rt.AlembicExport.CustomAttributes = True
rt.AlembicExport.UVs = True
rt.AlembicExport.VertexColors = True
rt.AlembicExport.PreserveInstances = True
with maintained_selection():
# select and export
rt.select(get_all_children(rt.getNodeByName(container)))
rt.exportFile(
filepath,
rt.name("noPrompt"),
selectedOnly=True,
using=rt.AlembicExport,
)
self.log.info("Performing Extraction ...")
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
"name": "abc",
"ext": "abc",
"files": filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info(
"Extracted instance '%s' to: %s" % (instance.name, filepath)
)

View file

@ -0,0 +1,63 @@
import os
import pyblish.api
from openpype.pipeline import publish, OptionalPyblishPluginMixin
from pymxs import runtime as rt
from openpype.hosts.max.api import maintained_selection, get_all_children
class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin):
"""
Extract Geometry in FBX Format
"""
order = pyblish.api.ExtractorOrder - 0.05
label = "Extract FBX"
hosts = ["max"]
families = ["model"]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
container = instance.data["instance_node"]
self.log.info("Extracting Geometry ...")
stagingdir = self.staging_dir(instance)
filename = "{name}.fbx".format(**instance.data)
filepath = os.path.join(stagingdir, filename)
self.log.info("Writing FBX '%s' to '%s'" % (filepath, stagingdir))
rt.FBXExporterSetParam("Animation", False)
rt.FBXExporterSetParam("Cameras", False)
rt.FBXExporterSetParam("Lights", False)
rt.FBXExporterSetParam("PointCache", False)
rt.FBXExporterSetParam("AxisConversionMethod", "Animation")
rt.FBXExporterSetParam("UpAxis", "Y")
rt.FBXExporterSetParam("Preserveinstances", True)
with maintained_selection():
# select and export
rt.select(get_all_children(rt.getNodeByName(container)))
rt.exportFile(
filepath,
rt.name("noPrompt"),
selectedOnly=True,
using=rt.FBXEXP,
)
self.log.info("Performing Extraction ...")
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
"name": "fbx",
"ext": "fbx",
"files": filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info(
"Extracted instance '%s' to: %s" % (instance.name, filepath)
)

View file

@ -0,0 +1,56 @@
import os
import pyblish.api
from openpype.pipeline import publish, OptionalPyblishPluginMixin
from pymxs import runtime as rt
from openpype.hosts.max.api import maintained_selection, get_all_children
class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin):
"""
Extract Geometry in OBJ Format
"""
order = pyblish.api.ExtractorOrder - 0.05
label = "Extract OBJ"
hosts = ["max"]
families = ["model"]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
container = instance.data["instance_node"]
self.log.info("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))
with maintained_selection():
# select and export
rt.select(get_all_children(rt.getNodeByName(container)))
rt.exportFile(
filepath,
rt.name("noPrompt"),
selectedOnly=True,
using=rt.ObjExp,
)
self.log.info("Performing Extraction ...")
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
"name": "obj",
"ext": "obj",
"files": filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info(
"Extracted instance '%s' to: %s" % (instance.name, filepath)
)

View file

@ -0,0 +1,114 @@
import os
import pyblish.api
from openpype.pipeline import (
publish,
OptionalPyblishPluginMixin
)
from pymxs import runtime as rt
from openpype.hosts.max.api import (
maintained_selection
)
class ExtractModelUSD(publish.Extractor,
OptionalPyblishPluginMixin):
"""
Extract Geometry in USDA Format
"""
order = pyblish.api.ExtractorOrder - 0.05
label = "Extract Geometry (USD)"
hosts = ["max"]
families = ["model"]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
container = instance.data["instance_node"]
self.log.info("Extracting Geometry ...")
stagingdir = self.staging_dir(instance)
asset_filename = "{name}.usda".format(**instance.data)
asset_filepath = os.path.join(stagingdir,
asset_filename)
self.log.info("Writing USD '%s' to '%s'" % (asset_filepath,
stagingdir))
log_filename = "{name}.txt".format(**instance.data)
log_filepath = os.path.join(stagingdir,
log_filename)
self.log.info("Writing log '%s' to '%s'" % (log_filepath,
stagingdir))
# get the nodes which need to be exported
export_options = self.get_export_options(log_filepath)
with maintained_selection():
# select and export
node_list = self.get_node_list(container)
rt.USDExporter.ExportFile(asset_filepath,
exportOptions=export_options,
contentSource=rt.name("selected"),
nodeList=node_list)
self.log.info("Performing Extraction ...")
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'usda',
'ext': 'usda',
'files': asset_filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
log_representation = {
'name': 'txt',
'ext': 'txt',
'files': log_filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(log_representation)
self.log.info("Extracted instance '%s' to: %s" % (instance.name,
asset_filepath))
def get_node_list(self, container):
"""
Get the target nodes which are
the children of the container
"""
node_list = []
container_node = rt.getNodeByName(container)
target_node = container_node.Children
rt.select(target_node)
for sel in rt.selection:
node_list.append(sel)
return node_list
def get_export_options(self, log_path):
"""Set Export Options for USD Exporter"""
export_options = rt.USDExporter.createOptions()
export_options.Meshes = True
export_options.Shapes = False
export_options.Lights = False
export_options.Cameras = False
export_options.Materials = False
export_options.MeshFormat = rt.name('fromScene')
export_options.FileFormat = rt.name('ascii')
export_options.UpAxis = rt.name('y')
export_options.LogLevel = rt.name('info')
export_options.LogPath = log_path
export_options.PreserveEdgeOrientation = True
export_options.TimeMode = rt.name('current')
rt.USDexporter.UIOptions = export_options
return export_options

View file

@ -41,10 +41,7 @@ import os
import pyblish.api
from openpype.pipeline import publish
from pymxs import runtime as rt
from openpype.hosts.max.api import (
maintained_selection,
get_all_children
)
from openpype.hosts.max.api import maintained_selection, get_all_children
class ExtractAlembic(publish.Extractor):
@ -66,35 +63,30 @@ class ExtractAlembic(publish.Extractor):
path = os.path.join(parent_dir, file_name)
# We run the render
self.log.info("Writing alembic '%s' to '%s'" % (file_name,
parent_dir))
self.log.info("Writing alembic '%s' to '%s'" % (file_name, parent_dir))
abc_export_cmd = (
f"""
AlembicExport.ArchiveType = #ogawa
AlembicExport.CoordinateSystem = #maya
AlembicExport.StartFrame = {start}
AlembicExport.EndFrame = {end}
exportFile @"{path}" #noPrompt selectedOnly:on using:AlembicExport
""")
self.log.debug(f"Executing command: {abc_export_cmd}")
rt.AlembicExport.ArchiveType = rt.name("ogawa")
rt.AlembicExport.CoordinateSystem = rt.name("maya")
rt.AlembicExport.StartFrame = start
rt.AlembicExport.EndFrame = end
with maintained_selection():
# select and export
rt.select(get_all_children(rt.getNodeByName(container)))
rt.execute(abc_export_cmd)
rt.exportFile(
path,
rt.name("noPrompt"),
selectedOnly=True,
using=rt.AlembicExport,
)
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'abc',
'ext': 'abc',
'files': file_name,
"name": "abc",
"ext": "abc",
"files": file_name,
"stagingDir": parent_dir,
}
instance.data["representations"].append(representation)

View file

@ -0,0 +1,64 @@
import pyblish.api
from pymxs import runtime as rt
from openpype.pipeline import (
OptionalPyblishPluginMixin
)
from openpype.pipeline.publish import (
RepairAction,
ValidateContentsOrder,
PublishValidationError
)
class ValidateFrameRange(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validates the frame ranges.
This is an optional validator checking if the frame range on instance
matches the frame range specified for the asset.
It also validates render frame ranges of render layers.
Repair action will change everything to match the asset frame range.
This can be turned off by the artist to allow custom ranges.
"""
label = "Validate Frame Range"
order = ValidateContentsOrder
families = ["maxrender"]
hosts = ["max"]
optional = True
actions = [RepairAction]
def process(self, instance):
if not self.is_active(instance.data):
self.log.info("Skipping validation...")
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"))
errors = []
if frame_start != 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:
errors.append(
f"End frame ({inst_frame_end}) on instance does not match "
f"with the end frame ({frame_start}) from the asset data. ")
if errors:
errors.append("You can use repair action to fix it.")
raise PublishValidationError("\n".join(errors))
@classmethod
def repair(cls, instance):
rt.rendStart = instance.context.data.get("frameStart")
rt.rendEnd = instance.context.data.get("frameEnd")

View file

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
import pyblish.api
from openpype.pipeline import PublishValidationError
from pymxs import runtime as rt
class ValidateModelContent(pyblish.api.InstancePlugin):
"""Validates Model instance contents.
A model instance may only hold either geometry-related
object(excluding Shapes) or editable meshes.
"""
order = pyblish.api.ValidatorOrder
families = ["model"]
hosts = ["max"]
label = "Model Contents"
def process(self, instance):
invalid = self.get_invalid(instance)
if invalid:
raise PublishValidationError("Model instance must only include"
"Geometry and Editable Mesh")
def get_invalid(self, instance):
"""
Get invalid nodes if the instance is not camera
"""
invalid = list()
container = instance.data["instance_node"]
self.log.info("Validating look content for "
"{}".format(container))
con = rt.getNodeByName(container)
selection_list = list(con.Children) or rt.getCurrentSelection()
for sel in selection_list:
if rt.classOf(sel) in rt.Camera.classes:
invalid.append(sel)
if rt.classOf(sel) in rt.Light.classes:
invalid.append(sel)
if rt.classOf(sel) in rt.Shape.classes:
invalid.append(sel)
return invalid

View file

@ -0,0 +1,65 @@
import pyblish.api
from openpype.pipeline import (
PublishValidationError,
OptionalPyblishPluginMixin
)
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):
"""Validate the resolution setting aligned with DB"""
order = pyblish.api.ValidatorOrder - 0.01
families = ["maxrender"]
hosts = ["max"]
label = "Validate Resolution Setting"
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
width, height = self.get_db_resolution(instance)
current_width = rt.renderwidth
current_height = rt.renderHeight
if current_width != width and current_height != height:
raise PublishValidationError("Resolution Setting "
"not matching resolution "
"set on asset or shot.")
if current_width != width:
raise PublishValidationError("Width in Resolution Setting "
"not matching resolution set "
"on asset or shot.")
if current_height != height:
raise PublishValidationError("Height in Resolution Setting "
"not matching resolution set "
"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))
return width, height
@classmethod
def repair(cls, instance):
reset_scene_resolution()

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
import pyblish.api
from openpype.pipeline import PublishValidationError
from pymxs import runtime as rt
class ValidateUSDPlugin(pyblish.api.InstancePlugin):
"""Validates if USD plugin is installed or loaded in Max
"""
order = pyblish.api.ValidatorOrder - 0.01
families = ["model"]
hosts = ["max"]
label = "USD Plugin"
def process(self, instance):
plugin_mgr = rt.pluginManager
plugin_count = plugin_mgr.pluginDllCount
plugin_info = self.get_plugins(plugin_mgr,
plugin_count)
usd_import = "usdimport.dli"
if usd_import not in plugin_info:
raise PublishValidationError("USD Plugin {}"
" not found".format(usd_import))
usd_export = "usdexport.dle"
if usd_export not in plugin_info:
raise PublishValidationError("USD Plugin {}"
" not found".format(usd_export))
def get_plugins(self, manager, count):
plugin_info_list = list()
for p in range(1, count + 1):
plugin_info = manager.pluginDllName(p)
plugin_info_list.append(plugin_info)
return plugin_info_list

View file

@ -190,6 +190,44 @@ def maintained_selection():
cmds.select(clear=True)
def get_custom_namespace(custom_namespace):
"""Return unique namespace.
The input namespace can contain a single group
of '#' number tokens to indicate where the namespace's
unique index should go. The amount of tokens defines
the zero padding of the number, e.g ### turns into 001.
Warning: Note that a namespace will always be
prefixed with a _ if it starts with a digit
Example:
>>> get_custom_namespace("myspace_##_")
# myspace_01_
>>> get_custom_namespace("##_myspace")
# _01_myspace
>>> get_custom_namespace("myspace##")
# myspace01
"""
split = re.split("([#]+)", custom_namespace, 1)
if len(split) == 3:
base, padding, suffix = split
padding = "%0{}d".format(len(padding))
else:
base = split[0]
padding = "%02d" # default padding
suffix = ""
return unique_namespace(
base,
format=padding,
prefix="_" if not base or base[0].isdigit() else "",
suffix=suffix
)
def unique_namespace(namespace, format="%02d", prefix="", suffix=""):
"""Return unique namespace
@ -316,11 +354,13 @@ def collect_animation_data(fps=False):
# get scene values as defaults
frame_start = cmds.playbackOptions(query=True, minTime=True)
frame_end = cmds.playbackOptions(query=True, maxTime=True)
handle_start = cmds.playbackOptions(query=True, animationStartTime=True)
handle_end = cmds.playbackOptions(query=True, animationEndTime=True)
frame_start_handle = cmds.playbackOptions(
query=True, animationStartTime=True
)
frame_end_handle = cmds.playbackOptions(query=True, animationEndTime=True)
handle_start = frame_start - handle_start
handle_end = handle_end - frame_end
handle_start = frame_start - frame_start_handle
handle_end = frame_end_handle - frame_end
# build attributes
data = OrderedDict()
@ -3937,7 +3977,9 @@ def get_capture_preset(task_name, task_type, subset, project_settings, log):
return capture_preset or {}
def create_rig_animation_instance(nodes, context, namespace, log=None):
def create_rig_animation_instance(
nodes, context, namespace, options=None, log=None
):
"""Create an animation publish instance for loaded rigs.
See the RecreateRigAnimationInstance inventory action on how to use this
@ -3947,12 +3989,16 @@ def create_rig_animation_instance(nodes, context, namespace, log=None):
nodes (list): Member nodes of the rig instance.
context (dict): Representation context of the rig container
namespace (str): Namespace of the rig container
options (dict, optional): Additional loader data
log (logging.Logger, optional): Logger to log to if provided
Returns:
None
"""
if options is None:
options = {}
output = next((node for node in nodes if
node.endswith("out_SET")), None)
controls = next((node for node in nodes if
@ -3971,6 +4017,23 @@ def create_rig_animation_instance(nodes, context, namespace, log=None):
asset = legacy_io.Session["AVALON_ASSET"]
dependency = str(context["representation"]["_id"])
custom_subset = options.get("animationSubsetName")
if custom_subset:
formatting_data = {
"asset_name": context['asset']['name'],
"asset_type": context['asset']['type'],
"subset": context['subset']['name'],
"family": (
context['subset']['data'].get('family') or
context['subset']['data']['families'][0]
)
}
namespace = get_custom_namespace(
custom_subset.format(
**formatting_data
)
)
if log:
log.info("Creating subset: {}".format(namespace))

View file

@ -84,44 +84,6 @@ def get_reference_node_parents(ref):
return parents
def get_custom_namespace(custom_namespace):
"""Return unique namespace.
The input namespace can contain a single group
of '#' number tokens to indicate where the namespace's
unique index should go. The amount of tokens defines
the zero padding of the number, e.g ### turns into 001.
Warning: Note that a namespace will always be
prefixed with a _ if it starts with a digit
Example:
>>> get_custom_namespace("myspace_##_")
# myspace_01_
>>> get_custom_namespace("##_myspace")
# _01_myspace
>>> get_custom_namespace("myspace##")
# myspace01
"""
split = re.split("([#]+)", custom_namespace, 1)
if len(split) == 3:
base, padding, suffix = split
padding = "%0{}d".format(len(padding))
else:
base = split[0]
padding = "%02d" # default padding
suffix = ""
return lib.unique_namespace(
base,
format=padding,
prefix="_" if not base or base[0].isdigit() else "",
suffix=suffix
)
class Creator(LegacyCreator):
defaults = ['Main']
@ -216,7 +178,7 @@ class ReferenceLoader(Loader):
count = options.get("count") or 1
for c in range(0, count):
namespace = get_custom_namespace(custom_namespace)
namespace = lib.get_custom_namespace(custom_namespace)
group_name = "{}:{}".format(
namespace,
custom_group_name

View file

@ -43,7 +43,24 @@ class MayaTemplateBuilder(AbstractTemplateBuilder):
))
cmds.sets(name=PLACEHOLDER_SET, empty=True)
new_nodes = cmds.file(path, i=True, returnNewNodes=True)
new_nodes = cmds.file(
path,
i=True,
returnNewNodes=True,
preserveReferences=True,
loadReferenceDepth="all",
)
# make default cameras non-renderable
default_cameras = [cam for cam in cmds.ls(cameras=True)
if cmds.camera(cam, query=True, startupCamera=True)]
for cam in default_cameras:
if not cmds.attributeQuery("renderable", node=cam, exists=True):
self.log.debug(
"Camera {} has no attribute 'renderable'".format(cam)
)
continue
cmds.setAttr("{}.renderable".format(cam), 0)
cmds.setAttr(PLACEHOLDER_SET + ".hiddenInOutliner", True)

View file

@ -162,9 +162,15 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
with parent_nodes(roots, parent=None):
cmds.xform(group_name, zeroTransformPivots=True)
cmds.setAttr("{}.displayHandle".format(group_name), 1)
settings = get_project_settings(os.environ['AVALON_PROJECT'])
display_handle = settings['maya']['load'].get(
'reference_loader', {}
).get('display_handle', True)
cmds.setAttr(
"{}.displayHandle".format(group_name), display_handle
)
colors = settings['maya']['load']['colors']
c = colors.get(family)
if c is not None:
@ -174,7 +180,9 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
(float(c[1]) / 255),
(float(c[2]) / 255))
cmds.setAttr("{}.displayHandle".format(group_name), 1)
cmds.setAttr(
"{}.displayHandle".format(group_name), display_handle
)
# get bounding box
bbox = cmds.exactWorldBoundingBox(group_name)
# get pivot position on world space
@ -215,7 +223,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader):
def _post_process_rig(self, name, namespace, context, options):
nodes = self[:]
create_rig_animation_instance(
nodes, context, namespace, log=self.log
nodes, context, namespace, options=options, log=self.log
)
def _lock_camera_transforms(self, nodes):

Some files were not shown because too many files have changed in this diff Show more