diff --git a/.github/pr-branch-labeler.yml b/.github/pr-branch-labeler.yml new file mode 100644 index 0000000000..ca82051006 --- /dev/null +++ b/.github/pr-branch-labeler.yml @@ -0,0 +1,15 @@ +# Apply label "feature" if head matches "feature/*" +'type: feature': + head: "feature/*" + +# Apply label "feature" if head matches "feature/*" +'type: enhancement': + head: "enhancement/*" + +# Apply label "bugfix" if head matches one of "bugfix/*" or "hotfix/*" +'type: bug': + head: ["bugfix/*", "hotfix/*"] + +# Apply label "release" if base matches "release/*" +'Bump Minor': + base: "release/next-minor" \ No newline at end of file diff --git a/.github/pr-glob-labeler.yml b/.github/pr-glob-labeler.yml new file mode 100644 index 0000000000..286e7768b5 --- /dev/null +++ b/.github/pr-glob-labeler.yml @@ -0,0 +1,102 @@ +# Add type: unittest label if any changes in tests folders +'type: unittest': +- '*/*tests*/**/*' + +# any changes in documentation structure +'type: documentation': +- '*/**/*website*/**/*' +- '*/**/*docs*/**/*' + +# hosts triage +'host: Nuke': +- '*/**/*nuke*' +- '*/**/*nuke*/**/*' + +'host: Photoshop': +- '*/**/*photoshop*' +- '*/**/*photoshop*/**/*' + +'host: Harmony': +- '*/**/*harmony*' +- '*/**/*harmony*/**/*' + +'host: UE': +- '*/**/*unreal*' +- '*/**/*unreal*/**/*' + +'host: Houdini': +- '*/**/*houdini*' +- '*/**/*houdini*/**/*' + +'host: Maya': +- '*/**/*maya*' +- '*/**/*maya*/**/*' + +'host: Resolve': +- '*/**/*resolve*' +- '*/**/*resolve*/**/*' + +'host: Blender': +- '*/**/*blender*' +- '*/**/*blender*/**/*' + +'host: Hiero': +- '*/**/*hiero*' +- '*/**/*hiero*/**/*' + +'host: Fusion': +- '*/**/*fusion*' +- '*/**/*fusion*/**/*' + +'host: Flame': +- '*/**/*flame*' +- '*/**/*flame*/**/*' + +'host: TrayPublisher': +- '*/**/*traypublisher*' +- '*/**/*traypublisher*/**/*' + +'host: 3dsmax': +- '*/**/*max*' +- '*/**/*max*/**/*' + +'host: TV Paint': +- '*/**/*tvpaint*' +- '*/**/*tvpaint*/**/*' + +'host: CelAction': +- '*/**/*celaction*' +- '*/**/*celaction*/**/*' + +'host: After Effects': +- '*/**/*aftereffects*' +- '*/**/*aftereffects*/**/*' + +'host: Substance Painter': +- '*/**/*substancepainter*' +- '*/**/*substancepainter*/**/*' + +# modules triage +'module: Deadline': +- '*/**/*deadline*' +- '*/**/*deadline*/**/*' + +'module: RoyalRender': +- '*/**/*royalrender*' +- '*/**/*royalrender*/**/*' + +'module: Sitesync': +- '*/**/*sync_server*' +- '*/**/*sync_server*/**/*' + +'module: Ftrack': +- '*/**/*ftrack*' +- '*/**/*ftrack*/**/*' + +'module: Shotgrid': +- '*/**/*shotgrid*' +- '*/**/*shotgrid*/**/*' + +'module: Kitsu': +- '*/**/*kitsu*' +- '*/**/*kitsu*/**/*' diff --git a/.github/workflows/project_actions.yml b/.github/workflows/project_actions.yml new file mode 100644 index 0000000000..f425b233d4 --- /dev/null +++ b/.github/workflows/project_actions.yml @@ -0,0 +1,72 @@ +name: project-actions + +on: + pull_request_target: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + pr_review_requested: + name: pr_review_requested + runs-on: ubuntu-latest + if: github.event_name == 'pull_request_review' && github.event.review.state == 'changes_requested' + steps: + - name: Move PR to 'Change Requested' + uses: leonsteinhaeuser/project-beta-automations@v2.1.0 + with: + gh_token: ${{ secrets.YNPUT_BOT_TOKEN }} + organization: ynput + project_id: 11 + resource_node_id: ${{ github.event.pull_request.node_id }} + status_value: Change Requested + + size-label: + name: pr_size_label + runs-on: ubuntu-latest + if: | + ${{(github.event_name == 'pull_request' && github.event.action == 'assigned') + || (github.event_name == 'pull_request' && github.event.action == 'opened')}} + + steps: + - name: Add size label + uses: "pascalgn/size-label-action@v0.4.3" + env: + GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" + IGNORED: ".gitignore\n*.md\n*.json" + with: + sizes: > + { + "0": "XS", + "100": "S", + "500": "M", + "1000": "L", + "1500": "XL", + "2500": "XXL" + } + + label_prs_branch: + name: pr_branch_label + runs-on: ubuntu-latest + if: | + ${{(github.event_name == 'pull_request' && github.event.action == 'assigned') + || (github.event_name == 'pull_request' && github.event.action == 'opened')}} + steps: + - name: Label PRs - Branch name detection + uses: ffittschen/pr-branch-labeler@v1 + with: + repo-token: ${{ secrets.YNPUT_BOT_TOKEN }} + + label_prs_globe: + name: pr_globe_label + runs-on: ubuntu-latest + if: | + ${{(github.event_name == 'pull_request' && github.event.action == 'assigned') + || (github.event_name == 'pull_request' && github.event.action == 'opened')}} + steps: + - name: Label PRs - Globe detection + uses: actions/labeler@v4.0.3 + with: + repo-token: ${{ secrets.YNPUT_BOT_TOKEN }} + configuration-path: ".github/pr-glob-labeler.yml" + sync-labels: false diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000000..912780d803 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,77 @@ +# Architecture + +OpenPype is a monolithic Python project that bundles several parts, this document will try to give a birds eye overview of the project and, to a certain degree, each of the sub-projects. +The current file structure looks like this: + +``` +. +├── common - Code in this folder is backend portion of Addon distribution logic for v4 server. +├── docs - Documentation of the source code. +├── igniter - The OpenPype bootstrapper, deals with running version resolution and setting up the connection to the mongodb. +├── openpype - The actual OpenPype core package. +├── schema - Collection of JSON files describing schematics of objects. This follows Avalon's convention. +├── tests - Integration and unit tests. +├── tools - Conveninece scripts to perform common actions (in both bash and ps1). +├── vendor - When using the igniter, it deploys third party tools in here, such as ffmpeg. +└── website - Source files for https://openpype.io/ which is Docusaursus (https://docusaurus.io/). +``` + +The core functionality of the pipeline can be found in `igniter` and `openpype`, which in turn rely on the `schema` files, whenever you build (or download a pre-built) version of OpenPype, these two are bundled in there, and `Igniter` is the entry point. + + +## Igniter + +It's the setup and update tool for OpenPype, unless you want to package `openpype` separately and deal with all the config manually, this will most likely be your entry point. + +``` +igniter/ +├── bootstrap_repos.py - Module that will find or install OpenPype versions in the system. +├── __init__.py - Igniter entry point. +├── install_dialog.py- Show dialog for choosing central pype repository. +├── install_thread.py - Threading helpers for the install process. +├── __main__.py - Like `__init__.py` ? +├── message_dialog.py - Qt Dialog with a message and "Ok" button. +├── nice_progress_bar.py - Fancy Qt progress bar. +├── splash.txt - ASCII art for the terminal installer. +├── stylesheet.css - Installer Qt styles. +├── terminal_splash.py - Terminal installer animation, relies in `splash.txt`. +├── tools.py - Collection of methods that don't fit in other modules. +├── update_thread.py - Threading helper to update existing OpenPype installs. +├── update_window.py - Qt UI to update OpenPype installs. +├── user_settings.py - Interface for the OpenPype user settings. +└── version.py - Igniter's version number. +``` + +## OpenPype + +This is the main package of the OpenPype logic, it could be loosely described as a combination of [Avalon](https://getavalon.github.io), [Pyblish](https://pyblish.com/) and glue around those with custom OpenPype only elements, things are in progress of being moved around to better prepare for V4, which will be released under a new name AYON. + +``` +openpype/ +├── client - Interface for the MongoDB. +├── hooks - Hooks to be executed on certain OpenPype Applications defined in `openpype.lib.applications`. +├── host - Base class for the different hosts. +├── hosts - Integration with the different DCCs (hosts) using the `host` base class. +├── lib - Libraries that stitch together the package, some have been moved into other parts. +├── modules - OpenPype modules should contain separated logic of specific kind of implementation, such as Ftrack connection and its python API. +├── pipeline - Core of the OpenPype pipeline, handles creation of data, publishing, etc. +├── plugins - Global/core plugins for loader and publisher tool. +├── resources - Icons, fonts, etc. +├── scripts - Loose scipts that get run by tools/publishers. +├── settings - OpenPype settings interface. +├── style - Qt styling. +├── tests - Unit tests. +├── tools - Core tools, check out https://openpype.io/docs/artist_tools. +├── vendor - Vendoring of needed required Python packes. +├── widgets - Common re-usable Qt Widgets. +├── action.py - LEGACY: Lives now in `openpype.pipeline.publish.action` Pyblish actions. +├── cli.py - Command line interface, leverages `click`. +├── __init__.py - Sets two constants. +├── __main__.py - Entry point, calls the `cli.py` +├── plugin.py - Pyblish plugins. +├── pype_commands.py - Implementation of OpenPype commands. +└── version.py - Current version number. +``` + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 145c2e2c1a..4e22b783c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,931 @@ # Changelog +## [3.15.3](https://github.com/ynput/OpenPype/tree/3.15.3) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.2...3.15.3) + +### **🆕 New features** + + +
+Blender: Extract Review #3616 + +Added Review to Blender. + +This implementation is based on #3508 but made compatible for the current implementation of OpenPype for Blender. + + +___ + +
+ + +
+Data Exchanges: Point Cloud for 3dsMax #4532 + +Publish PRT format with tyFlow in 3dsmax + +Publish PRT format with tyFlow in 3dsmax and possibly set up loader to load the format too. +- [x] creator +- [x] extractor +- [x] validator +- [x] loader + + +___ + +
+ + +
+Global: persistent staging directory for renders #4583 + +Allows configure if staging directory (`stagingDir`) should be persistent with use of profiles. + +With this feature, users can specify a transient data folder path based on presets, which can be used during the creation and publishing stages. In some cases, these DCCs automatically add a rendering path during the creation stage, which is then used in publishing.One of the key advantages of this feature is that it allows users to take advantage of faster storages for rendering, which can help improve workflow efficiency. Additionally, this feature allows users to keep their rendered data persistent, and use their own infrastructure for regular cleaning.However, it should be noted that some productions may want to use this feature without persistency. Furthermore, there may be a need for retargeting the rendering folder to faster storages, which is also not supported at the moment.It is studio responsibility to clean up obsolete folders with data.Location of the folder is configured in `project_anatomy/templates/others`. ('transient' key is expected, with 'folder' key, could be more templates)Which family/task type/subset is applicable is configured in:`project_settings/global/tools/publish/transient_dir_profiles` + + +___ + +
+ + +
+Kitsu custom comment template #4599 + +Kitsu allows to write markdown in its comment field. This can be something very powerful to deliver dynamic comments with the help the data from the instance.This feature is defaults to off so the admin have to manually set up the comment field the way they want.I have added a basic example on how the comment can look like as the comment-fields default value.To this I want to add some documentation also but that's on its way when the code itself looks good for the reviewers. + + +___ + +
+ + +
+MaxScene Family #4615 + +Introduction of the Max Scene Family + + +___ + +
+ +### **🚀 Enhancements** + + +
+Maya: Multiple values on single render attribute - OP-4131 #4631 + +When validating render attributes, this adds support for multiple values. When repairing first value in list is used. + + +___ + +
+ + +
+Maya: enable 2D Pan/Zoom for playblasts - OP-5213 #4687 + +Setting for enabling 2D Pan/Zoom on reviews. + + +___ + +
+ + +
+Copy existing or generate new Fusion profile on prelaunch #4572 + +Fusion preferences will be copied to the predefined `~/.openpype/hosts/fusion/prefs` folder (or any other folder set in system settings) on launch. + +The idea is to create a copy of existing Fusion profile, adding an OpenPype menu to the Fusion instance.By default the copy setting is turned off, so no file copying is performed. Instead the clean Fusion profile is created by Fusion in the predefined folder. The default locaion is set to `~/.openpype/hosts/fusion/prefs`, to better comply with the other os platforms. After creating the default profile, some modifications are applied: +- forced Python3 +- forced English interface +- setup Openpype specific path maps.If the `copy_prefs` checkbox is toggled, a copy of existing Fusion profile folder will be placed in the mentioned location. Then they are altered the same way as described above. The operation is run only once, on the first launch, unless the `force_sync [Resync profile on each launch]` is toggled.English interface is forced because the `FUSION16_PROFILE_DIR` environment variable is not read otherwise (seems to be a Fusion bug). + + +___ + +
+ + +
+Houdini: Create button open new publisher's "create" tab #4601 + +During a talk with @maxpareschi he mentioned that the new publisher in Houdini felt super confusing due to "Create" going to the older creator but now being completely empty and the publish button directly went to the publish tab.This resolves that by fixing the Create button to now open the new publisher but on the Create tab.Also made publish button enforce going to the "publish" tab for consistency in usage.@antirotor I think changing the Create button's callback was just missed in this commit or was there a specific reason to not change that around yet? + + +___ + +
+ + +
+Clockify: refresh and fix the integration #4607 + +Due to recent API changes, Clockify requires `user_id` to operate with the timers. I updated this part and currently it is a WIP for making it fully functional. Most functions, such as start and stop timer, and projects sync are currently working. For the rate limiting task new dependency is added: https://pypi.org/project/ratelimiter/ + + +___ + +
+ + +
+Fusion publish existing frames #4611 + +This PR adds the function to publish existing frames instead of having to re-render all of them for each new publish.I have split the render_locally plugin so the review-part is its own plugin now.I also change the saver-creator-plugin's label from Saver to Render (saver) as I intend to add a Prerender creator like in Nuke. + + +___ + +
+ + +
+Resolution settings referenced from DB record for 3dsMax #4652 + +- Add Callback for setting the resolution according to DB after the new scene is created. +- Add a new Action into openpype menu which allows the user to reset the resolution in 3dsMax + + +___ + +
+ + +
+3dsmax: render instance settings in Publish tab #4658 + +Allows user preset the pools, group and use_published settings in Render Creator in the Max Hosts.User can set the settings before or after creating instance in the new publisher + + +___ + +
+ + +
+scene length setting referenced from DB record for 3dsMax #4665 + +Setting the timeline length based on DB record in 3dsMax Hosts + + +___ + +
+ + +
+Publisher: Windows reduce command window pop-ups during Publishing #4672 + +Reduce the command line pop-ups that show on Windows during publishing. + + +___ + +
+ + +
+Publisher: Explicit save #4676 + +Publisher have explicit button to save changes, so reset can happen without saving any changes. Save still happens automatically when publishing is started or on publisher window close. But a popup is shown if context of host has changed. Important context was enhanced by workfile path (if host integration supports it) so workfile changes are captured too. In that case a dialog with confirmation is shown to user. All callbacks that may require save of context were moved to main window to be able handle dialog show at one place. Save changes now returns success so the rest of logic is skipped -> publishing won't start, when save of instances fails.Save and reset buttons have shortcuts (Ctrl + s and Ctrls + r). + + +___ + +
+ + +
+CelAction: conditional workfile parameters from settings #4677 + +Since some productions were requesting excluding some workfile parameters from publishing submission, we needed to move them to settings so those could be altered per project. + + +___ + +
+ + +
+Improve logging of used app + tool envs on application launch #4682 + +Improve logging of what apps + tool environments got loaded for an application launch. + + +___ + +
+ + +
+Fix name and docstring for Create Workdir Extra Folders prelaunch hook #4683 + +Fix class name and docstring for Create Workdir Extra Folders prelaunch hookThe class name and docstring were originally copied from another plug-in and didn't match the plug-in logic.This also fixes potentially seeing this twice in your logs. Before:After:Where it was actually running both this prelaunch hook and the actual `AddLastWorkfileToLaunchArgs` plugin. + + +___ + +
+ + +
+Application launch context: Include app group name in logger #4684 + +Clarify in logs better what app group the ApplicationLaunchContext belongs to and what application is being launched.Before:After: + + +___ + +
+ + +
+increment workfile version 3dsmax #4685 + +increment workfile version in 3dsmax as if in blender and maya hosts. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Fix getting non-active model panel. #2968 + +When capturing multiple cameras with image planes that have file sequences playing, only the active (first) camera will play through the file sequence. + + +___ + +
+ + +
+Maya: Fix broken review publishing. #4549 + +Resolves #4547 + + +___ + +
+ + +
+Maya: Avoid error on right click in Loader if `mtoa` is not loaded #4616 + +Fix an error on right clicking in the Loader when `mtoa` is not a loaded plug-in.Additionally if `mtoa` isn't loaded the loader will now load the plug-in before trying to create the arnold standin. + + +___ + +
+ + +
+Maya: Fix extract look colorspace detection #4618 + +Fix the logic which guesses the colorspace using `arnold` python library. +- Previously it'd error if `mtoa` was not available on path so it still required `mtoa` to be available. +- The guessing colorspace logic doesn't actually require `mtoa` to be loaded, but just the `arnold` python library to be available. This changes the logic so it doesn't require the `mtoa` plugin to get loaded to guess the colorspace. +- The if/else branch was likely not doing what was intended `cmds.loadPlugin("mtoa", quiet=True)` returns None if the plug-in was already loaded. So this would only ever be true if it ends up loading the `mtoa` plugin the first time. +```python +# Tested in Maya 2022.1 +print(cmds.loadPlugin("mtoa", quiet=True)) +# ['mtoa'] +print(cmds.loadPlugin("mtoa", quiet=True)) +# None +``` + + +___ + +
+ + +
+Maya: Maya Playblast Options overrides - OP-3847 #4634 + +When publishing a review in Maya, the extractor would fail due to wrong (long) panel name. + + +___ + +
+ + +
+Bugfix/op 2834 fix extract playblast #4701 + +Paragraphs contain detailed information on the changes made to the product or service, providing an in-depth description of the updates and enhancements. They can be used to explain the reasoning behind the changes, or to highlight the importance of the new features. Paragraphs can often include links to further information or support documentation. + + +___ + +
+ + +
+Bugfix/op 2834 fix extract playblast #4704 + +Paragraphs contain detailed information on the changes made to the product or service, providing an in-depth description of the updates and enhancements. They can be used to explain the reasoning behind the changes, or to highlight the importance of the new features. Paragraphs can often include links to further information or support documentation. + + +___ + +
+ + +
+Maya: bug fix for passing zoom settings if review is attached to subset #4716 + +Fix for attaching review to subset with pan/zoom option. + + +___ + +
+ + +
+Maya: tile assembly fail in draft - OP-4820 #4416 + +Tile assembly in Deadline was broken. + +Initial bug report revealed other areas of the tile assembly that needed fixing. + + +___ + +
+ + +
+Maya: Yeti Validate Rig Input - OP-3454 #4554 + +Fix Yeti Validate Rig Input + +Existing workflow was broken due to this #3297. + + +___ + +
+ + +
+Scene inventory: Fix code errors when "not found" entries are found #4594 + +Whenever a "NOT FOUND" entry is present a lot of errors happened in the Scene Inventory: +- It started spamming a lot of errors for the VersionDelegate since it had no numeric version (no version at all).Error reported on Discord: +```python +Traceback (most recent call last): + File "C:\Users\videopro\Documents\github\OpenPype\openpype\tools\utils\delegates.py", line 65, in paint + text = self.displayText( + File "C:\Users\videopro\Documents\github\OpenPype\openpype\tools\utils\delegates.py", line 33, in displayText + assert isinstance(value, numbers.Integral), ( +AssertionError: Version is not integer. "None" +``` +- Right click menu would error on NOT FOUND entries, and thus not show. With this PR it will now _disregard_ not found items for "Set version" and "Remove" but still allow actions.This PR resolves those. + + +___ + +
+ + +
+Kitsu: Sync OP with zou, make sure value-data is int or float #4596 + +Currently the data zou pulls is a string and not a value causing some bugs in the pipe where a value is expected (like `Set frame range` in Fusion). + + + +This PR makes sure each value is set with int() or float() so these bugs can't happen later on. + + + +_(A request to cgwire has also bin sent to allow force values only for some metadata columns, but currently the user can enter what ever they want in there)_ + + +___ + +
+ + +
+Max: fix the bug of removing an instance #4617 + +fix the bug of removing an instance in 3dsMax + + +___ + +
+ + +
+Global | Nuke: fixing farm publishing workflow #4623 + +After Nuke had adopted new publisher with new creators new issues were introduced. Those issues were addressed with this PR. Those are for example broken reviewable video files publishing if published via farm. Also fixed local publishing. + + +___ + +
+ + +
+Ftrack: Ftrack additional families filtering #4633 + +Ftrack family collector makes sure the subset family is also in instance families for additional families filtering. + + +___ + +
+ + +
+Ftrack: Hierarchical <> Non-Hierarchical attributes sync fix #4635 + +Sync between hierarchical and non-hierarchical attributes should be fixed and work as expected. Action should sync the values as expected and event handler should do it too and only on newly created entities. + + +___ + +
+ + +
+bugfix for 3dsmax publishing error #4637 + +fix the bug of failing publishing job in 3dsMax + + +___ + +
+ + +
+General: Use right validation for ffmpeg executable #4640 + +Use ffmpeg exec validation for ffmpeg executables instead of oiio exec validation. The validation is used as last possible source of ffmpeg from `PATH` environment variables, which is an edge case but can cause issues. + + +___ + +
+ + +
+3dsmax: opening last workfile #4644 + +Supports opening last saved workfile in 3dsmax host. + + +___ + +
+ + +
+Fixed a bug where a QThread in the splash screen could be destroyed before finishing execution #4647 + +This should fix the occasional behavior of the QThread being destroyed before even its worker returns from the `run()` function.After quiting, it should wait for the QThread object to properly close itself. + + +___ + +
+ + +
+General: Use right plugin class for Collect Comment #4653 + +Collect Comment plugin is instance plugin so should inherit from `InstancePlugin` instead of `ContextPlugin`. + + +___ + +
+ + +
+Global: add tags field to thumbnail representation #4660 + +Thumbnail representation might be missing tags field. + + +___ + +
+ + +
+Integrator: Enforce unique destination transfers, disallow overwrites in queued transfers #4662 + +Fix #4656 by enforcing unique destination transfers in the Integrator. It's now disallowed to a destination in the file transaction queue with a new source path during the publish. + + +___ + +
+ + +
+Hiero: Creator with correct workfile numeric padding input #4666 + +Creator was showing 99 in workfile input for long time, even if users set default value to 1001 in studio settings. This has been fixed now. + + +___ + +
+ + +
+Nuke: Nukenodes family instance without frame range #4669 + +No need to add frame range data into `nukenodes` (backdrop) family publishes - since those are timeless. + + +___ + +
+ + +
+TVPaint: Optional Validation plugins can be de/activated by user #4674 + +Added `OptionalPyblishPluginMixin` to TVpaint plugins that can be optional. + + +___ + +
+ + +
+Kitsu: Slightly less strict with instance data #4678 + +- Allow to take task name from context if asset doesn't have any. Fixes an issue with Photoshop's review instance not having `task` in data. +- Allow to match "review" against both `instance.data["family"]` and `instance.data["families"]` because some instances don't have the primary family in families, e.g. in Photoshop and TVPaint. +- Do not error on Integrate Kitsu Review whenever for whatever reason Integrate Kitsu Note did not created a comment but just log the message that it was unable to connect a review. + + +___ + +
+ + +
+Publisher: Fix reset shortcut sequence #4694 + +Fix bug created in https://github.com/ynput/OpenPype/pull/4676 where key sequence is checked using unsupported method. The check was changed to convert event into `QKeySequence` object which can be compared to prepared sequence. + + +___ + +
+ + +
+Refactor _capture #4702 + +Paragraphs contain detailed information on the changes made to the product or service, providing an in-depth description of the updates and enhancements. They can be used to explain the reasoning behind the changes, or to highlight the importance of the new features. Paragraphs can often include links to further information or support documentation. + + +___ + +
+ + +
+Hiero: correct container colors if UpToDate #4708 + +Colors on loaded containers are now correctly identifying real state of version. `Red` for out of date and `green` for up to date. + + +___ + +
+ +### **🔀 Refactored code** + + +
+Look Assigner: Move Look Assigner tool since it's Maya only #4604 + +Fix #4357: Move Look Assigner tool to maya since it's Maya only + + +___ + +
+ + +
+Maya: Remove unused functions from Extract Look #4671 + +Remove unused functions from Maya Extract Look plug-in + + +___ + +
+ + +
+Extract Review code refactor #3930 + +Trying to reduce complexity of Extract Review plug-in +- Re-use profile filtering from lib +- Remove "combination families" additional filtering which supposedly was from OP v2 +- Simplify 'formatting' for filling gaps +- Use `legacy_io.Session` over `os.environ` + + +___ + +
+ + +
+Maya: Replace last usages of Qt module #4610 + +Replace last usage of `Qt` module with `qtpy`. This change is needed for `PySide6` support. All changes happened in Maya loader plugins. + + +___ + +
+ + +
+Update tests and documentation for `ColormanagedPyblishPluginMixin` #4612 + +Refactor `ExtractorColormanaged` to `ColormanagedPyblishPluginMixin` in tests and documentation. + + +___ + +
+ + +
+Improve logging of used app + tool envs on application launch (minor tweak) #4686 + +Use `app.full_name` for change done in #4682 + + +___ + +
+ +### **📃 Documentation** + + +
+Docs/add architecture document #4344 + +Add `ARCHITECTURE.md` document. + +his document attemps to give a quick overview of the project to help onboarding, it's not an extensive documentation but more of a elevator pitch one-line descriptions of files/directories and what the attempt to do. + + +___ + +
+ + +
+Documentation: Tweak grammar and fix some typos #4613 + +This resolves some grammar and typos in the documentation.Also fixes the extension of some images in after effects docs which used uppercase extension even though files were lowercase extension. + + +___ + +
+ + +
+Docs: Fix some minor grammar/typos #4680 + +Typo/grammar fixes in documentation. + + +___ + +
+ +### **Merged pull requests** + + +
+Maya: Implement image file node loader #4313 + +Implements a loader for loading texture image into a `file` node in Maya. + +Similar to Maya's hypershade creation of textures on load you have the option to choose for three modes of creating: +- Texture +- Projection +- StencilThese should match what Maya generates if you create those in Maya. +- [x] Load and manage file nodes +- [x] Apply color spaces after #4195 +- [x] Support for _either_ UDIM or image sequence - currently it seems to always load sequences as UDIM automatically. +- [ ] Add support for animation sequences of UDIM textures using the `..exr` path format? + + +___ + +
+ + +
+Maya Look Assigner: Don't rely on containers for get all assets #4600 + +This resolves #4044 by not actually relying on containers in the scene but instead just rely on finding nodes with `cbId` attributes. As such, imported nodes would also be found and a shader can be assigned (similar to when using get from selection).**Please take into consideration the potential downsides below**Potential downsides would be: +- IF an already loaded look has any dagNodes, say a 3D Projection node - then that will also show up as a loaded asset where previously nodes from loaded looks were ignored. +- If any dag nodes were created locally - they would have gotten `cbId` attributes on scene save and thus the current asset would almost always show? + + +___ + +
+ + +
+Maya: Unify menu labels for "Set Frame Range" and "Set Resolution" #4605 + +Fix #4109: Unify menu labels for "Set Frame Range" and "Set Resolution"This also tweaks it in Houdini from Reset Frame Range to Set Frame Range. + + +___ + +
+ + +
+Resolve missing OPENPYPE_MONGO in deadline global job preload #4484 + +In the GlobalJobPreLoad plugin, we propose to replace the SpawnProcess by a sub-process and to pass the environment variables in the parameters, since the SpawnProcess under Centos Linux does not pass the environment variables. + +In the GlobalJobPreLoad plugin, the Deadline SpawnProcess is used to start the OpenPype process. The problem is that the SpawnProcess does not pass environment variables, including OPENPYPE_MONGO, to the process when it is under Centos7 linux, and the process gets stuck. We propose to replace it by a subprocess and to pass the variable in the parameters. + + +___ + +
+ + +
+Tests: Added setup_only to tests #4591 + +Allows to download test zip, unzip and restore DB in preparation for new test. + + +___ + +
+ + +
+Maya: Arnold don't reset maya timeline frame range on render creation (or setting render settings) #4603 + +Fix #4429: Do not reset fps or playback timeline on applying or creating render settings + + +___ + +
+ + +
+Bump @sideway/formula from 3.0.0 to 3.0.1 in /website #4609 + +Bumps [@sideway/formula](https://github.com/sideway/formula) from 3.0.0 to 3.0.1. +
+Commits + +
+
+Maintainer changes +

This version was pushed to npm by marsup, a new releaser for @​sideway/formula since your current version.

+
+
+ + +[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@sideway/formula&package-manager=npm_and_yarn&previous-version=3.0.0&new-version=3.0.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually +- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) +- `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language +- `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language +- `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language +- `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language + +You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts). + +
+___ + +
+ + +
+Update artist_hosts_maya_arnold.md #4626 + +Correct Arnold docs. +___ + +
+ + +
+Maya: Add "Include Parent Hierarchy" option in animation creator plugin #4645 + +Add an option in Project Settings > Maya > Creator Plugins > Create Animation to include (or not) parent hierarchy. This is to avoid artists to check manually the option for all create animation. + + +___ + +
+ + +
+General: Filter available applications #4667 + +Added option to filter applications that don't have valid executable available in settings in launcher and ftrack actions. This option can be disabled in new settings category `Applications`. The filtering is by default disabled. + + +___ + +
+ + +
+3dsmax: make sure that startup script executes #4695 + +Fixing reliability of OpenPype startup in 3dsmax. + + +___ + +
+ + +
+Project Manager: Change minimum frame start/end to '0' #4719 + +Project manager can have frame start/end set to `0`. + + +___ + +
+ + + +## [3.15.2](https://github.com/ynput/OpenPype/tree/3.15.2) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.1...3.15.2) diff --git a/openpype/client/entities.py b/openpype/client/entities.py index c415be8816..7054658c64 100644 --- a/openpype/client/entities.py +++ b/openpype/client/entities.py @@ -3,7 +3,7 @@ Goal is that most of functions here are called on (or with) an object that has project name as a context (e.g. on 'ProjectEntity'?). -+ We will need more specific functions doing wery specific queires really fast. ++ We will need more specific functions doing very specific queries really fast. """ import re @@ -193,7 +193,7 @@ def _get_assets( be found. asset_names (Iterable[str]): Name assets that should be found. parent_ids (Iterable[Union[str, ObjectId]]): Parent asset ids. - standard (bool): Query standart assets (type 'asset'). + standard (bool): Query standard assets (type 'asset'). archived (bool): Query archived assets (type 'archived_asset'). fields (Iterable[str]): Fields that should be returned. All fields are returned if 'None' is passed. @@ -1185,7 +1185,7 @@ def get_representations( standard=True, fields=None ): - """Representaion entities data from one project filtered by filters. + """Representation entities data from one project filtered by filters. Filters are additive (all conditions must pass to return subset). @@ -1231,7 +1231,7 @@ def get_archived_representations( names_by_version_ids=None, fields=None ): - """Archived representaion entities data from project with applied filters. + """Archived representation entities data from project with applied filters. Filters are additive (all conditions must pass to return subset). diff --git a/openpype/client/notes.md b/openpype/client/notes.md index a261b86eca..59743892eb 100644 --- a/openpype/client/notes.md +++ b/openpype/client/notes.md @@ -2,7 +2,7 @@ ## Reason Preparation for OpenPype v4 server. Goal is to remove direct mongo calls in code to prepare a little bit for different source of data for code before. To start think about database calls less as mongo calls but more universally. To do so was implemented simple wrapper around database calls to not use pymongo specific code. -Current goal is not to make universal database model which can be easily replaced with any different source of data but to make it close as possible. Current implementation of OpenPype is too tighly connected to pymongo and it's abilities so we're trying to get closer with long term changes that can be used even in current state. +Current goal is not to make universal database model which can be easily replaced with any different source of data but to make it close as possible. Current implementation of OpenPype is too tightly connected to pymongo and it's abilities so we're trying to get closer with long term changes that can be used even in current state. ## Queries Query functions don't use full potential of mongo queries like very specific queries based on subdictionaries or unknown structures. We try to avoid these calls as much as possible because they'll probably won't be available in future. If it's really necessary a new function can be added but only if it's reasonable for overall logic. All query functions were moved to `~/client/entities.py`. Each function has arguments with available filters and possible reduce of returned keys for each entity. @@ -14,7 +14,7 @@ Changes are a little bit complicated. Mongo has many options how update can happ Create operations expect already prepared document data, for that are prepared functions creating skeletal structures of documents (do not fill all required data), except `_id` all data should be right. Existence of entity is not validated so if the same creation operation is send n times it will create the entity n times which can cause issues. ### Update -Update operation require entity id and keys that should be changed, update dictionary must have {"key": value}. If value should be set in nested dictionary the key must have also all subkeys joined with dot `.` (e.g. `{"data": {"fps": 25}}` -> `{"data.fps": 25}`). To simplify update dictionaries were prepared functions which does that for you, their name has template `prepare__update_data` - they work on comparison of previous document and new document. If there is missing function for requested entity type it is because we didn't need it yet and require implementaion. +Update operation require entity id and keys that should be changed, update dictionary must have {"key": value}. If value should be set in nested dictionary the key must have also all subkeys joined with dot `.` (e.g. `{"data": {"fps": 25}}` -> `{"data.fps": 25}`). To simplify update dictionaries were prepared functions which does that for you, their name has template `prepare__update_data` - they work on comparison of previous document and new document. If there is missing function for requested entity type it is because we didn't need it yet and require implementation. ### Delete Delete operation need entity id. Entity will be deleted from mongo. diff --git a/openpype/client/operations.py b/openpype/client/operations.py index fd639c34a7..ef48f2a1c4 100644 --- a/openpype/client/operations.py +++ b/openpype/client/operations.py @@ -368,7 +368,7 @@ def prepare_workfile_info_update_data(old_doc, new_doc, replace=True): class AbstractOperation(object): """Base operation class. - Opration represent a call into database. The call can create, change or + Operation represent a call into database. The call can create, change or remove data. Args: @@ -409,7 +409,7 @@ class AbstractOperation(object): pass def to_data(self): - """Convert opration to data that can be converted to json or others. + """Convert operation to data that can be converted to json or others. Warning: Current state returns ObjectId objects which cannot be parsed by @@ -428,7 +428,7 @@ class AbstractOperation(object): class CreateOperation(AbstractOperation): - """Opeartion to create an entity. + """Operation to create an entity. Args: project_name (str): On which project operation will happen. @@ -485,7 +485,7 @@ class CreateOperation(AbstractOperation): class UpdateOperation(AbstractOperation): - """Opeartion to update an entity. + """Operation to update an entity. Args: project_name (str): On which project operation will happen. @@ -552,7 +552,7 @@ class UpdateOperation(AbstractOperation): class DeleteOperation(AbstractOperation): - """Opeartion to delete an entity. + """Operation to delete an entity. Args: project_name (str): On which project operation will happen. diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 1c8746c559..2558daef30 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -14,6 +14,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): # Execute after workfile template copy order = 10 app_groups = [ + "3dsmax", "maya", "nuke", "nukex", diff --git a/openpype/hooks/pre_create_extra_workdir_folders.py b/openpype/hooks/pre_create_extra_workdir_folders.py index c5af620c87..8856281120 100644 --- a/openpype/hooks/pre_create_extra_workdir_folders.py +++ b/openpype/hooks/pre_create_extra_workdir_folders.py @@ -3,10 +3,13 @@ from openpype.lib import PreLaunchHook from openpype.pipeline.workfile import create_workdir_extra_folders -class AddLastWorkfileToLaunchArgs(PreLaunchHook): - """Add last workfile path to launch arguments. +class CreateWorkdirExtraFolders(PreLaunchHook): + """Create extra folders for the work directory. + + Based on setting `project_settings/global/tools/Workfiles/extra_folders` + profile filtering will decide whether extra folders need to be created in + the work directory. - This is not possible to do for all applications the same way. """ # Execute after workfile template copy diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py index 2092d5025d..21ec8e7881 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_foundry_apps.py @@ -7,7 +7,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook): Nuke is executed "like" python process so it is required to pass `CREATE_NEW_CONSOLE` flag on windows to trigger creation of new console. - At the same time the newly created console won't create it's own stdout + At the same time the newly created console won't create its own stdout and stderr handlers so they should not be redirected to DEVNULL. """ @@ -18,7 +18,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook): def execute(self): # Change `creationflags` to CREATE_NEW_CONSOLE - # - on Windows will nuke create new window using it's console + # - on Windows nuke will create new window using its console # Set `stdout` and `stderr` to None so new created console does not # have redirected output to DEVNULL in build self.launch_context.kwargs.update({ diff --git a/openpype/host/dirmap.py b/openpype/host/dirmap.py index 1d084cccad..42bf80ecec 100644 --- a/openpype/host/dirmap.py +++ b/openpype/host/dirmap.py @@ -2,7 +2,7 @@ Idea for current dirmap implementation was used from Maya where is possible to enter source and destination roots and maya will try each found source -in referenced file replace with each destionation paths. First path which +in referenced file replace with each destination paths. First path which exists is used. """ @@ -183,7 +183,7 @@ class HostDirmap(object): project_name, remote_site ) # dirmap has sense only with regular disk provider, in the workfile - # wont be root on cloud or sftp provider + # won't be root on cloud or sftp provider if remote_provider != "local_drive": remote_site = "studio" for root_name, active_site_dir in active_overrides.items(): diff --git a/openpype/host/host.py b/openpype/host/host.py index d2335c0062..630fb873a8 100644 --- a/openpype/host/host.py +++ b/openpype/host/host.py @@ -18,7 +18,7 @@ class HostBase(object): Compared to 'avalon' concept: What was before considered as functions in host implementation folder. The host implementation should primarily care about adding ability of creation - (mark subsets to be published) and optionaly about referencing published + (mark subsets to be published) and optionally about referencing published representations as containers. Host may need extend some functionality like working with workfiles @@ -129,9 +129,9 @@ class HostBase(object): """Get current context information. This method should be used to get current context of host. Usage of - this method can be crutial for host implementations in DCCs where + this method can be crucial for host implementations in DCCs where can be opened multiple workfiles at one moment and change of context - can't be catched properly. + can't be caught properly. Default implementation returns values from 'legacy_io.Session'. diff --git a/openpype/host/interfaces.py b/openpype/host/interfaces.py index 999aefd254..7c6057acf0 100644 --- a/openpype/host/interfaces.py +++ b/openpype/host/interfaces.py @@ -81,7 +81,7 @@ class ILoadHost: @abstractmethod def get_containers(self): - """Retreive referenced containers from scene. + """Retrieve referenced containers from scene. This can be implemented in hosts where referencing can be used. @@ -191,7 +191,7 @@ class IWorkfileHost: @abstractmethod def get_current_workfile(self): - """Retreive path to current opened file. + """Retrieve path to current opened file. Returns: str: Path to file which is currently opened. @@ -220,8 +220,8 @@ class IWorkfileHost: Default implementation keeps workdir untouched. Warnings: - We must handle this modification with more sofisticated way because - this can't be called out of DCC so opening of last workfile + We must handle this modification with more sophisticated way + because this can't be called out of DCC so opening of last workfile (calculated before DCC is launched) is complicated. Also breaking defined work template is not a good idea. Only place where it's really used and can make sense is Maya. There @@ -302,7 +302,7 @@ class IPublishHost: required methods. Returns: - list[str]: Missing method implementations for new publsher + list[str]: Missing method implementations for new publisher workflow. """ diff --git a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx index 9b211207de..5c1d163439 100644 --- a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx +++ b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx @@ -504,7 +504,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 quering if + * found_comp (CompItem, optional): to limit querying if * comp already found previously */ var comp = found_comp || app.project.itemByID(comp_id); diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index e5d6d9ed89..f094c7fa2a 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -80,7 +80,7 @@ class AfterEffectsServerStub(): Get complete stored JSON with metadata from AE.Metadata.Label field. - It contains containers loaded by any Loader OR instances creted + It contains containers loaded by any Loader OR instances created by Creator. Returns: diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index e017d74d91..75a11affde 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -31,10 +31,13 @@ from .lib import ( lsattrs, read, maintained_selection, + maintained_time, get_selection, # unique_name, ) +from .capture import capture + __all__ = [ "install", @@ -56,9 +59,11 @@ __all__ = [ # Utility functions "maintained_selection", + "maintained_time", "lsattr", "lsattrs", "read", "get_selection", + "capture", # "unique_name", ] diff --git a/openpype/hosts/blender/api/capture.py b/openpype/hosts/blender/api/capture.py new file mode 100644 index 0000000000..849f8ee629 --- /dev/null +++ b/openpype/hosts/blender/api/capture.py @@ -0,0 +1,278 @@ + +"""Blender Capture +Playblasting with independent viewport, camera and display options +""" +import contextlib +import bpy + +from .lib import maintained_time +from .plugin import deselect_all, create_blender_context + + +def capture( + camera=None, + width=None, + height=None, + filename=None, + start_frame=None, + end_frame=None, + step_frame=None, + sound=None, + isolate=None, + maintain_aspect_ratio=True, + overwrite=False, + image_settings=None, + display_options=None +): + """Playblast in an independent windows + Arguments: + camera (str, optional): Name of camera, defaults to "Camera" + width (int, optional): Width of output in pixels + height (int, optional): Height of output in pixels + filename (str, optional): Name of output file path. Defaults to current + render output path. + start_frame (int, optional): Defaults to current start frame. + end_frame (int, optional): Defaults to current end frame. + step_frame (int, optional): Defaults to 1. + sound (str, optional): Specify the sound node to be used during + playblast. When None (default) no sound will be used. + isolate (list): List of nodes to isolate upon capturing + maintain_aspect_ratio (bool, optional): Modify height in order to + maintain aspect ratio. + overwrite (bool, optional): Whether or not to overwrite if file + already exists. If disabled and file exists and error will be + raised. + image_settings (dict, optional): Supplied image settings for render, + using `ImageSettings` + display_options (dict, optional): Supplied display options for render + """ + + scene = bpy.context.scene + camera = camera or "Camera" + + # Ensure camera exists. + if camera not in scene.objects and camera != "AUTO": + raise RuntimeError("Camera does not exist: {0}".format(camera)) + + # Ensure resolution. + if width and height: + maintain_aspect_ratio = False + width = width or scene.render.resolution_x + height = height or scene.render.resolution_y + if maintain_aspect_ratio: + ratio = scene.render.resolution_x / scene.render.resolution_y + height = round(width / ratio) + + # Get frame range. + if start_frame is None: + start_frame = scene.frame_start + if end_frame is None: + end_frame = scene.frame_end + if step_frame is None: + step_frame = 1 + frame_range = (start_frame, end_frame, step_frame) + + if filename is None: + filename = scene.render.filepath + + render_options = { + "filepath": "{}.".format(filename.rstrip(".")), + "resolution_x": width, + "resolution_y": height, + "use_overwrite": overwrite, + } + + with _independent_window() as window: + + applied_view(window, camera, isolate, options=display_options) + + with contextlib.ExitStack() as stack: + stack.enter_context(maintain_camera(window, camera)) + stack.enter_context(applied_frame_range(window, *frame_range)) + stack.enter_context(applied_render_options(window, render_options)) + stack.enter_context(applied_image_settings(window, image_settings)) + stack.enter_context(maintained_time()) + + bpy.ops.render.opengl( + animation=True, + render_keyed_only=False, + sequencer=False, + write_still=False, + view_context=True + ) + + return filename + + +ImageSettings = { + "file_format": "FFMPEG", + "color_mode": "RGB", + "ffmpeg": { + "format": "QUICKTIME", + "use_autosplit": False, + "codec": "H264", + "constant_rate_factor": "MEDIUM", + "gopsize": 18, + "use_max_b_frames": False, + }, +} + + +def isolate_objects(window, objects): + """Isolate selection""" + deselect_all() + + for obj in objects: + obj.select_set(True) + + context = create_blender_context(selected=objects, window=window) + + bpy.ops.view3d.view_axis(context, type="FRONT") + bpy.ops.view3d.localview(context) + + deselect_all() + + +def _apply_options(entity, options): + for option, value in options.items(): + if isinstance(value, dict): + _apply_options(getattr(entity, option), value) + else: + setattr(entity, option, value) + + +def applied_view(window, camera, isolate=None, options=None): + """Apply view options to window.""" + area = window.screen.areas[0] + space = area.spaces[0] + + area.ui_type = "VIEW_3D" + + meshes = [obj for obj in window.scene.objects if obj.type == "MESH"] + + if camera == "AUTO": + space.region_3d.view_perspective = "ORTHO" + isolate_objects(window, isolate or meshes) + else: + isolate_objects(window, isolate or meshes) + space.camera = window.scene.objects.get(camera) + space.region_3d.view_perspective = "CAMERA" + + if isinstance(options, dict): + _apply_options(space, options) + else: + space.shading.type = "SOLID" + space.shading.color_type = "MATERIAL" + space.show_gizmo = False + space.overlay.show_overlays = False + + +@contextlib.contextmanager +def applied_frame_range(window, start, end, step): + """Context manager for setting frame range.""" + # Store current frame range + current_frame_start = window.scene.frame_start + current_frame_end = window.scene.frame_end + current_frame_step = window.scene.frame_step + # Apply frame range + window.scene.frame_start = start + window.scene.frame_end = end + window.scene.frame_step = step + try: + yield + finally: + # Restore frame range + window.scene.frame_start = current_frame_start + window.scene.frame_end = current_frame_end + window.scene.frame_step = current_frame_step + + +@contextlib.contextmanager +def applied_render_options(window, options): + """Context manager for setting render options.""" + render = window.scene.render + + # Store current settings + original = {} + for opt in options.copy(): + try: + original[opt] = getattr(render, opt) + except ValueError: + options.pop(opt) + + # Apply settings + _apply_options(render, options) + + try: + yield + finally: + # Restore previous settings + _apply_options(render, original) + + +@contextlib.contextmanager +def applied_image_settings(window, options): + """Context manager to override image settings.""" + + options = options or ImageSettings.copy() + ffmpeg = options.pop("ffmpeg", {}) + render = window.scene.render + + # Store current image settings + original = {} + for opt in options.copy(): + try: + original[opt] = getattr(render.image_settings, opt) + except ValueError: + options.pop(opt) + + # Store current ffmpeg settings + original_ffmpeg = {} + for opt in ffmpeg.copy(): + try: + original_ffmpeg[opt] = getattr(render.ffmpeg, opt) + except ValueError: + ffmpeg.pop(opt) + + # Apply image settings + for opt, value in options.items(): + setattr(render.image_settings, opt, value) + + # Apply ffmpeg settings + for opt, value in ffmpeg.items(): + setattr(render.ffmpeg, opt, value) + + try: + yield + finally: + # Restore previous settings + for opt, value in original.items(): + setattr(render.image_settings, opt, value) + for opt, value in original_ffmpeg.items(): + setattr(render.ffmpeg, opt, value) + + +@contextlib.contextmanager +def maintain_camera(window, camera): + """Context manager to override camera.""" + current_camera = window.scene.camera + if camera in window.scene.objects: + window.scene.camera = window.scene.objects.get(camera) + try: + yield + finally: + window.scene.camera = current_camera + + +@contextlib.contextmanager +def _independent_window(): + """Create capture-window context.""" + context = create_blender_context() + current_windows = set(bpy.context.window_manager.windows) + bpy.ops.wm.window_new(context) + window = list(set(bpy.context.window_manager.windows) - current_windows)[0] + context["window"] = window + try: + yield window + finally: + bpy.ops.wm.window_close(context) diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index 05912885f7..6526f1fb87 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -284,3 +284,13 @@ def maintained_selection(): # This could happen if the active node was deleted during the # context. log.exception("Failed to set active object.") + + +@contextlib.contextmanager +def maintained_time(): + """Maintain current frame during context.""" + current_time = bpy.context.scene.frame_current + try: + yield + finally: + bpy.context.scene.frame_current = current_time diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py index 481c199db2..91cbfe524f 100644 --- a/openpype/hosts/blender/api/ops.py +++ b/openpype/hosts/blender/api/ops.py @@ -24,7 +24,7 @@ from .workio import OpenFileCacher PREVIEW_COLLECTIONS: Dict = dict() # This seems like a good value to keep the Qt app responsive and doesn't slow -# down Blender. At least on macOS I the interace of Blender gets very laggy if +# down Blender. At least on macOS I the interface of Blender gets very laggy if # you make it smaller. TIMER_INTERVAL: float = 0.01 if platform.system() == "Windows" else 0.1 @@ -84,11 +84,11 @@ class MainThreadItem: self.kwargs = kwargs def execute(self): - """Execute callback and store it's result. + """Execute callback and store its result. Method must be called from main thread. Item is marked as `done` when callback execution finished. Store output of callback of exception - information when callback raise one. + information when callback raises one. """ print("Executing process in main thread") if self.done: @@ -382,8 +382,8 @@ class TOPBAR_MT_avalon(bpy.types.Menu): layout.operator(LaunchLibrary.bl_idname, text="Library...") layout.separator() layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...") - # TODO (jasper): maybe add 'Reload Pipeline', 'Reset Frame Range' and - # 'Reset Resolution'? + # TODO (jasper): maybe add 'Reload Pipeline', 'Set Frame Range' and + # 'Set Resolution'? def draw_avalon_menu(self, context): diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index c59be8d7ff..1274795c6b 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -62,7 +62,8 @@ def prepare_data(data, container_name=None): def create_blender_context(active: Optional[bpy.types.Object] = None, - selected: Optional[bpy.types.Object] = None,): + selected: Optional[bpy.types.Object] = None, + window: Optional[bpy.types.Window] = None): """Create a new Blender context. If an object is passed as parameter, it is set as selected and active. """ @@ -72,7 +73,9 @@ def create_blender_context(active: Optional[bpy.types.Object] = None, override_context = bpy.context.copy() - for win in bpy.context.window_manager.windows: + windows = [window] if window else bpy.context.window_manager.windows + + for win in windows: for area in win.screen.areas: if area.type == 'VIEW_3D': for region in area.regions: diff --git a/openpype/hosts/blender/plugins/create/create_review.py b/openpype/hosts/blender/plugins/create/create_review.py new file mode 100644 index 0000000000..bf4ea6a7cd --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_review.py @@ -0,0 +1,47 @@ +"""Create review.""" + +import bpy + +from openpype.pipeline import legacy_io +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES + + +class CreateReview(plugin.Creator): + """Single baked camera""" + + name = "reviewDefault" + label = "Review" + family = "review" + icon = "video-camera" + + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process) + ops.execute_in_main_thread(mti) + + def _process(self): + # Get Instance Container or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object + asset = self.data["asset"] + subset = self.data["subset"] + name = plugin.asset_name(asset, subset) + asset_group = bpy.data.collections.new(name=name) + instances.children.link(asset_group) + self.data['task'] = legacy_io.Session.get('AVALON_TASK') + lib.imprint(asset_group, self.data) + + if (self.options or {}).get("useSelection"): + selected = lib.get_selection() + for obj in selected: + asset_group.objects.link(obj) + elif (self.options or {}).get("asset_group"): + obj = (self.options or {}).get("asset_group") + asset_group.objects.link(obj) + + return asset_group diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py new file mode 100644 index 0000000000..d6abd9d967 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/collect_review.py @@ -0,0 +1,64 @@ +import bpy + +import pyblish.api +from openpype.pipeline import legacy_io + + +class CollectReview(pyblish.api.InstancePlugin): + """Collect Review data + + """ + + order = pyblish.api.CollectorOrder + 0.3 + label = "Collect Review Data" + families = ["review"] + + def process(self, instance): + + self.log.debug(f"instance: {instance}") + + # get cameras + cameras = [ + obj + for obj in instance + if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA" + ] + + assert len(cameras) == 1, ( + f"Not a single camera found in extraction: {cameras}" + ) + camera = cameras[0].name + self.log.debug(f"camera: {camera}") + + # get isolate objects list from meshes instance members . + isolate_objects = [ + obj + for obj in instance + if isinstance(obj, bpy.types.Object) and obj.type == "MESH" + ] + + if not instance.data.get("remove"): + + task = legacy_io.Session.get("AVALON_TASK") + + instance.data.update({ + "subset": f"{task}Review", + "review_camera": camera, + "frameStart": instance.context.data["frameStart"], + "frameEnd": instance.context.data["frameEnd"], + "fps": instance.context.data["fps"], + "isolate": isolate_objects, + }) + + self.log.debug(f"instance data: {instance.data}") + + # TODO : Collect audio + audio_tracks = [] + instance.data["audio"] = [] + for track in audio_tracks: + instance.data["audio"].append( + { + "offset": track.offset.get(), + "filename": track.filename.get(), + } + ) diff --git a/openpype/hosts/blender/plugins/publish/extract_playblast.py b/openpype/hosts/blender/plugins/publish/extract_playblast.py new file mode 100644 index 0000000000..196e75b8cc --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_playblast.py @@ -0,0 +1,122 @@ +import os +import clique + +import bpy + +import pyblish.api +from openpype.pipeline import publish +from openpype.hosts.blender.api import capture +from openpype.hosts.blender.api.lib import maintained_time + + +class ExtractPlayblast(publish.Extractor): + """ + Extract viewport playblast. + + Takes review camera and creates review Quicktime video based on viewport + capture. + """ + + label = "Extract Playblast" + hosts = ["blender"] + families = ["review"] + optional = True + order = pyblish.api.ExtractorOrder + 0.01 + + def process(self, instance): + self.log.info("Extracting capture..") + + self.log.info(instance.data) + + # get scene fps + fps = instance.data.get("fps") + if fps is None: + fps = bpy.context.scene.render.fps + instance.data["fps"] = fps + + self.log.info(f"fps: {fps}") + + # If start and end frames cannot be determined, + # get them from Blender timeline. + start = instance.data.get("frameStart", bpy.context.scene.frame_start) + end = instance.data.get("frameEnd", bpy.context.scene.frame_end) + + self.log.info(f"start: {start}, end: {end}") + assert end > start, "Invalid time range !" + + # get cameras + camera = instance.data("review_camera", None) + + # get isolate objects list + isolate = instance.data("isolate", None) + + # get output path + stagingdir = self.staging_dir(instance) + filename = instance.name + path = os.path.join(stagingdir, filename) + + self.log.info(f"Outputting images to {path}") + + project_settings = instance.context.data["project_settings"]["blender"] + presets = project_settings["publish"]["ExtractPlayblast"]["presets"] + preset = presets.get("default") + preset.update({ + "camera": camera, + "start_frame": start, + "end_frame": end, + "filename": path, + "overwrite": True, + "isolate": isolate, + }) + preset.setdefault( + "image_settings", + { + "file_format": "PNG", + "color_mode": "RGB", + "color_depth": "8", + "compression": 15, + }, + ) + + with maintained_time(): + path = capture(**preset) + + self.log.debug(f"playblast path {path}") + + collected_files = os.listdir(stagingdir) + collections, remainder = clique.assemble( + collected_files, + patterns=[f"{filename}\\.{clique.DIGITS_PATTERN}\\.png$"], + ) + + if len(collections) > 1: + raise RuntimeError( + f"More than one collection found in stagingdir: {stagingdir}" + ) + elif len(collections) == 0: + raise RuntimeError( + f"No collection found in stagingdir: {stagingdir}" + ) + + frame_collection = collections[0] + + self.log.info(f"We found collection of interest {frame_collection}") + + instance.data.setdefault("representations", []) + + tags = ["review"] + if not instance.data.get("keepImages"): + tags.append("delete") + + representation = { + "name": "png", + "ext": "png", + "files": list(frame_collection), + "stagingDir": stagingdir, + "frameStart": start, + "frameEnd": end, + "fps": fps, + "tags": tags, + "camera_name": camera + } + instance.data["representations"].append(representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_thumbnail.py b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py new file mode 100644 index 0000000000..65c3627375 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_thumbnail.py @@ -0,0 +1,99 @@ +import os +import glob + +import pyblish.api +from openpype.pipeline import publish +from openpype.hosts.blender.api import capture +from openpype.hosts.blender.api.lib import maintained_time + +import bpy + + +class ExtractThumbnail(publish.Extractor): + """Extract viewport thumbnail. + + Takes review camera and creates a thumbnail based on viewport + capture. + + """ + + label = "Extract Thumbnail" + hosts = ["blender"] + families = ["review"] + order = pyblish.api.ExtractorOrder + 0.01 + presets = {} + + def process(self, instance): + self.log.info("Extracting capture..") + + stagingdir = self.staging_dir(instance) + filename = instance.name + path = os.path.join(stagingdir, filename) + + self.log.info(f"Outputting images to {path}") + + camera = instance.data.get("review_camera", "AUTO") + start = instance.data.get("frameStart", bpy.context.scene.frame_start) + family = instance.data.get("family") + isolate = instance.data("isolate", None) + + preset = self.presets.get(family, {}) + + preset.update({ + "camera": camera, + "start_frame": start, + "end_frame": start, + "filename": path, + "overwrite": True, + "isolate": isolate, + }) + preset.setdefault( + "image_settings", + { + "file_format": "JPEG", + "color_mode": "RGB", + "quality": 100, + }, + ) + + with maintained_time(): + path = capture(**preset) + + thumbnail = os.path.basename(self._fix_output_path(path)) + + self.log.info(f"thumbnail: {thumbnail}") + + instance.data.setdefault("representations", []) + + representation = { + "name": "thumbnail", + "ext": "jpg", + "files": thumbnail, + "stagingDir": stagingdir, + "thumbnail": True + } + instance.data["representations"].append(representation) + + def _fix_output_path(self, filepath): + """"Workaround to return correct filepath. + + To workaround this we just glob.glob() for any file extensions and + assume the latest modified file is the correct file and return it. + + """ + # Catch cancelled playblast + if filepath is None: + self.log.warning( + "Playblast did not result in output path. " + "Playblast is probably interrupted." + ) + return None + + if not os.path.exists(filepath): + files = glob.glob(f"{filepath}.*.jpg") + + if not files: + raise RuntimeError(f"Couldn't find playblast from: {filepath}") + filepath = max(files, key=os.path.getmtime) + + return filepath diff --git a/openpype/hosts/celaction/hooks/pre_celaction_setup.py b/openpype/hosts/celaction/hooks/pre_celaction_setup.py index 62cebf99ed..96e784875c 100644 --- a/openpype/hosts/celaction/hooks/pre_celaction_setup.py +++ b/openpype/hosts/celaction/hooks/pre_celaction_setup.py @@ -38,8 +38,9 @@ class CelactionPrelaunchHook(PreLaunchHook): ) path_to_cli = os.path.join(CELACTION_SCRIPTS_DIR, "publish_cli.py") - subproces_args = get_openpype_execute_args("run", path_to_cli) - openpype_executable = subproces_args.pop(0) + subprocess_args = get_openpype_execute_args("run", path_to_cli) + openpype_executable = subprocess_args.pop(0) + workfile_settings = self.get_workfile_settings() winreg.SetValueEx( hKey, @@ -49,20 +50,34 @@ class CelactionPrelaunchHook(PreLaunchHook): openpype_executable ) - parameters = subproces_args + [ - "--currentFile", "*SCENE*", - "--chunk", "*CHUNK*", - "--frameStart", "*START*", - "--frameEnd", "*END*", - "--resolutionWidth", "*X*", - "--resolutionHeight", "*Y*" + # add required arguments for workfile path + parameters = subprocess_args + [ + "--currentFile", "*SCENE*" ] + # Add custom parameters from workfile settings + if "render_chunk" in workfile_settings["submission_overrides"]: + parameters += [ + "--chunk", "*CHUNK*" + ] + if "resolution" in workfile_settings["submission_overrides"]: + parameters += [ + "--resolutionWidth", "*X*", + "--resolutionHeight", "*Y*" + ] + if "frame_range" in workfile_settings["submission_overrides"]: + parameters += [ + "--frameStart", "*START*", + "--frameEnd", "*END*" + ] + winreg.SetValueEx( hKey, "SubmitParametersTitle", 0, winreg.REG_SZ, subprocess.list2cmdline(parameters) ) + self.log.debug(f"__ parameters: \"{parameters}\"") + # setting resolution parameters path_submit = "\\".join([ path_user_settings, "Dialogs", "SubmitOutput" @@ -135,3 +150,6 @@ class CelactionPrelaunchHook(PreLaunchHook): self.log.info(f"Workfile to open: \"{workfile_path}\"") return workfile_path + + def get_workfile_settings(self): + return self.data["project_settings"]["celaction"]["workfile"] diff --git a/openpype/hosts/celaction/plugins/publish/collect_celaction_cli_kwargs.py b/openpype/hosts/celaction/plugins/publish/collect_celaction_cli_kwargs.py index 43b81b83e7..54dea15dff 100644 --- a/openpype/hosts/celaction/plugins/publish/collect_celaction_cli_kwargs.py +++ b/openpype/hosts/celaction/plugins/publish/collect_celaction_cli_kwargs.py @@ -39,7 +39,7 @@ class CollectCelactionCliKwargs(pyblish.api.Collector): passing_kwargs[key] = value if missing_kwargs: - raise RuntimeError("Missing arguments {}".format( + self.log.debug("Missing arguments {}".format( ", ".join( [f'"{key}"' for key in missing_kwargs] ) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 6aca5c5ce6..ab713aed84 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -773,7 +773,7 @@ class MediaInfoFile(object): if logger: self.log = logger - # test if `dl_get_media_info` paht exists + # test if `dl_get_media_info` path exists self._validate_media_script_path() # derivate other feed variables @@ -993,7 +993,7 @@ class MediaInfoFile(object): def _validate_media_script_path(self): if not os.path.isfile(self.MEDIA_SCRIPT_PATH): - raise IOError("Media Scirpt does not exist: `{}`".format( + raise IOError("Media Script does not exist: `{}`".format( self.MEDIA_SCRIPT_PATH)) def _generate_media_info_file(self, fpath, feed_ext, feed_dir): diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 3a23389961..d6fbf750ba 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -38,7 +38,7 @@ def install(): pyblish.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info("OpenPype Flame plug-ins registred ...") + log.info("OpenPype Flame plug-ins registered ...") # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 983d7486b3..df8c1ac887 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -157,7 +157,7 @@ class CreatorWidget(QtWidgets.QDialog): # convert label text to normal capitalized text with spaces label_text = self.camel_case_split(text) - # assign the new text to lable widget + # assign the new text to label widget label = QtWidgets.QLabel(label_text) label.setObjectName("LineLabel") @@ -345,8 +345,8 @@ class PublishableClip: "track": "sequence", } - # parents search patern - parents_search_patern = r"\{([a-z]*?)\}" + # parents search pattern + parents_search_pattern = r"\{([a-z]*?)\}" # default templates for non-ui use rename_default = False @@ -445,7 +445,7 @@ class PublishableClip: return self.current_segment def _populate_segment_default_data(self): - """ Populate default formating data from segment. """ + """ Populate default formatting data from segment. """ self.current_segment_default_data = { "_folder_": "shots", @@ -538,7 +538,7 @@ class PublishableClip: if not self.index_from_segment: self.count_steps *= self.rename_index - hierarchy_formating_data = {} + hierarchy_formatting_data = {} hierarchy_data = deepcopy(self.hierarchy_data) _data = self.current_segment_default_data.copy() if self.ui_inputs: @@ -552,7 +552,7 @@ class PublishableClip: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): - # if review layer is defined and not the same as defalut + # if review layer is defined and not the same as default self.review_layer = self.review_track # shot num calculate @@ -578,13 +578,13 @@ class PublishableClip: # fill up pythonic expresisons in hierarchy data for k, _v in hierarchy_data.items(): - hierarchy_formating_data[k] = _v["value"].format(**_data) + hierarchy_formatting_data[k] = _v["value"].format(**_data) else: # if no gui mode then just pass default data - hierarchy_formating_data = hierarchy_data + hierarchy_formatting_data = hierarchy_data tag_hierarchy_data = self._solve_tag_hierarchy_data( - hierarchy_formating_data + hierarchy_formatting_data ) tag_hierarchy_data.update({"heroTrack": True}) @@ -615,27 +615,27 @@ class PublishableClip: # in case track name and subset name is the same then add if self.subset_name == self.track_name: _hero_data["subset"] = self.subset - # assing data to return hierarchy data to tag + # assign data to return hierarchy data to tag tag_hierarchy_data = _hero_data break # add data to return data dict self.marker_data.update(tag_hierarchy_data) - def _solve_tag_hierarchy_data(self, hierarchy_formating_data): + def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve marker data from hierarchy data and templates. """ # fill up clip name and hierarchy keys - hierarchy_filled = self.hierarchy.format(**hierarchy_formating_data) - clip_name_filled = self.clip_name.format(**hierarchy_formating_data) + hierarchy_filled = self.hierarchy.format(**hierarchy_formatting_data) + clip_name_filled = self.clip_name.format(**hierarchy_formatting_data) # remove shot from hierarchy data: is not needed anymore - hierarchy_formating_data.pop("shot") + hierarchy_formatting_data.pop("shot") return { "newClipName": clip_name_filled, "hierarchy": hierarchy_filled, "parents": self.parents, - "hierarchyData": hierarchy_formating_data, + "hierarchyData": hierarchy_formatting_data, "subset": self.subset, "family": self.subset_family, "families": [self.family] @@ -650,17 +650,17 @@ class PublishableClip: type ) - # first collect formating data to use for formating template - formating_data = {} + # first collect formatting data to use for formatting template + formatting_data = {} for _k, _v in self.hierarchy_data.items(): value = _v["value"].format( **self.current_segment_default_data) - formating_data[_k] = value + formatting_data[_k] = value return { "entity_type": entity_type, "entity_name": template.format( - **formating_data + **formatting_data ) } @@ -668,9 +668,9 @@ class PublishableClip: """ Create parents and return it in list. """ self.parents = [] - patern = re.compile(self.parents_search_patern) + pattern = re.compile(self.parents_search_pattern) - par_split = [(patern.findall(t).pop(), t) + par_split = [(pattern.findall(t).pop(), t) for t in self.hierarchy.split("/")] for type, template in par_split: @@ -902,22 +902,22 @@ class OpenClipSolver(flib.MediaInfoFile): ): return - formating_data = self._update_formating_data( + formatting_data = self._update_formatting_data( layerName=layer_name, layerUID=layer_uid ) name_obj.text = StringTemplate( self.layer_rename_template - ).format(formating_data) + ).format(formatting_data) - def _update_formating_data(self, **kwargs): - """ Updating formating data for layer rename + def _update_formatting_data(self, **kwargs): + """ Updating formatting data for layer rename Attributes: - key=value (optional): will be included to formating data + key=value (optional): will be included to formatting data as {key: value} Returns: - dict: anatomy context data for formating + dict: anatomy context data for formatting """ self.log.debug(">> self.clip_data: {}".format(self.clip_data)) clip_name_obj = self.clip_data.find("name") diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index 4825ff4386..a74172c405 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -203,7 +203,7 @@ class WireTapCom(object): list: all available volumes in server Rises: - AttributeError: unable to get any volumes childs from server + AttributeError: unable to get any volumes children from server """ root = WireTapNodeHandle(self._server, "/volumes") children_num = WireTapInt(0) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index fb8bdee42d..80a5c47e89 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -108,7 +108,7 @@ def _sync_utility_scripts(env=None): shutil.copy2(src, dst) except (PermissionError, FileExistsError) as msg: log.warning( - "Not able to coppy to: `{}`, Problem with: `{}`".format( + "Not able to copy to: `{}`, Problem with: `{}`".format( dst, msg ) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 713daf1031..8034885c47 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -153,7 +153,7 @@ class FlamePrelaunch(PreLaunchHook): def _add_pythonpath(self): pythonpath = self.launch_context.env.get("PYTHONPATH") - # separate it explicity by `;` that is what we use in settings + # separate it explicitly by `;` that is what we use in settings new_pythonpath = self.flame_pythonpath.split(os.pathsep) new_pythonpath += pythonpath.split(os.pathsep) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 4fb041a4b2..b01354c313 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -209,7 +209,7 @@ class CreateShotClip(opfapi.Creator): "type": "QComboBox", "label": "Subset Name", "target": "ui", - "toolTip": "chose subset name patern, if [ track name ] is selected, name of track layer will be used", # noqa + "toolTip": "chose subset name pattern, if [ track name ] is selected, name of track layer will be used", # noqa "order": 0}, "subsetFamily": { "value": ["plate", "take"], diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index 25b31c94a3..dfb2d2b6f0 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -61,9 +61,9 @@ class LoadClip(opfapi.ClipLoader): self.layer_rename_template = self.layer_rename_template.replace( "output", "representation") - formating_data = deepcopy(context["representation"]["context"]) + formatting_data = deepcopy(context["representation"]["context"]) clip_name = StringTemplate(self.clip_name_template).format( - formating_data) + formatting_data) # convert colorspace with ocio to flame mapping # in imageio flame section @@ -88,7 +88,7 @@ class LoadClip(opfapi.ClipLoader): "version": "v{:0>3}".format(version_name), "layer_rename_template": self.layer_rename_template, "layer_rename_patterns": self.layer_rename_patterns, - "context_data": formating_data + "context_data": formatting_data } self.log.debug(pformat( loading_context diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index 86bc0f8f1e..5c5a77f0d0 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -58,11 +58,11 @@ class LoadClipBatch(opfapi.ClipLoader): self.layer_rename_template = self.layer_rename_template.replace( "output", "representation") - formating_data = deepcopy(context["representation"]["context"]) - formating_data["batch"] = self.batch.name.get_value() + formatting_data = deepcopy(context["representation"]["context"]) + formatting_data["batch"] = self.batch.name.get_value() clip_name = StringTemplate(self.clip_name_template).format( - formating_data) + formatting_data) # convert colorspace with ocio to flame mapping # in imageio flame section @@ -88,7 +88,7 @@ class LoadClipBatch(opfapi.ClipLoader): "version": "v{:0>3}".format(version_name), "layer_rename_template": self.layer_rename_template, "layer_rename_patterns": self.layer_rename_patterns, - "context_data": formating_data + "context_data": formatting_data } self.log.debug(pformat( loading_context diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 76d48dded2..23fdf5e785 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -203,7 +203,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): self._get_xml_preset_attrs( attributes, split) - # add xml overides resolution to instance data + # add xml overrides resolution to instance data xml_overrides = attributes["xml_overrides"] if xml_overrides.get("width"): attributes.update({ @@ -284,7 +284,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): self.log.debug("__ head: `{}`".format(head)) self.log.debug("__ tail: `{}`".format(tail)) - # HACK: it is here to serve for versions bellow 2021.1 + # HACK: it is here to serve for versions below 2021.1 if not any([head, tail]): retimed_attributes = get_media_range_with_retimes( otio_clip, handle_start, handle_end) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 5082217db0..a7979ab4d5 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -227,7 +227,7 @@ class ExtractSubsetResources(publish.Extractor): self.hide_others( exporting_clip, segment_name, s_track_name) - # change name patern + # change name pattern name_patern_xml = ( "__{}.").format( unique_name) @@ -358,7 +358,7 @@ class ExtractSubsetResources(publish.Extractor): representation_data["stagingDir"] = n_stage_dir files = n_files - # add files to represetation but add + # add files to representation but add # imagesequence as list if ( # first check if path in files is not mov extension diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 4d45f67ded..4f3945bb0f 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -50,7 +50,7 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): self._load_clip_to_context(instance, bgroup) def _add_nodes_to_batch_with_links(self, instance, task_data, batch_group): - # get write file node properties > OrederDict because order does mater + # get write file node properties > OrederDict because order does matter write_pref_data = self._get_write_prefs(instance, task_data) batch_nodes = [ diff --git a/openpype/hosts/fusion/__init__.py b/openpype/hosts/fusion/__init__.py index ddae01890b..1da11ba9d1 100644 --- a/openpype/hosts/fusion/__init__.py +++ b/openpype/hosts/fusion/__init__.py @@ -1,10 +1,14 @@ from .addon import ( + get_fusion_version, FusionAddon, FUSION_HOST_DIR, + FUSION_VERSIONS_DICT, ) __all__ = ( + "get_fusion_version", "FusionAddon", "FUSION_HOST_DIR", + "FUSION_VERSIONS_DICT", ) diff --git a/openpype/hosts/fusion/addon.py b/openpype/hosts/fusion/addon.py index d1bd1566b7..45683cfbde 100644 --- a/openpype/hosts/fusion/addon.py +++ b/openpype/hosts/fusion/addon.py @@ -1,8 +1,52 @@ import os +import re from openpype.modules import OpenPypeModule, IHostAddon +from openpype.lib import Logger FUSION_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) +# FUSION_VERSIONS_DICT is used by the pre-launch hooks +# The keys correspond to all currently supported Fusion versions +# Each value is a list of corresponding Python home variables and a profile +# number, which is used by the profile hook to set Fusion profile variables. +FUSION_VERSIONS_DICT = { + 9: ("FUSION_PYTHON36_HOME", 9), + 16: ("FUSION16_PYTHON36_HOME", 16), + 17: ("FUSION16_PYTHON36_HOME", 16), + 18: ("FUSION_PYTHON3_HOME", 16), +} + + +def get_fusion_version(app_name): + """ + The function is triggered by the prelaunch hooks to get the fusion version. + + `app_name` is obtained by prelaunch hooks from the + `launch_context.env.get("AVALON_APP_NAME")`. + + To get a correct Fusion version, a version number should be present + in the `applications/fusion/variants` key + of the Blackmagic Fusion Application Settings. + """ + + log = Logger.get_logger(__name__) + + if not app_name: + return + + app_version_candidates = re.findall(r"\d+", app_name) + if not app_version_candidates: + return + for app_version in app_version_candidates: + if int(app_version) in FUSION_VERSIONS_DICT: + return int(app_version) + else: + log.info( + "Unsupported Fusion version: {app_version}".format( + app_version=app_version + ) + ) + class FusionAddon(OpenPypeModule, IHostAddon): name = "fusion" @@ -14,15 +58,11 @@ class FusionAddon(OpenPypeModule, IHostAddon): def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] - return [ - os.path.join(FUSION_HOST_DIR, "hooks") - ] + return [os.path.join(FUSION_HOST_DIR, "hooks")] def add_implementation_envs(self, env, _app): # Set default values if are not already set via settings - defaults = { - "OPENPYPE_LOG_NO_COLORS": "Yes" - } + defaults = {"OPENPYPE_LOG_NO_COLORS": "Yes"} for key, value in defaults.items(): if not env.get(key): env[key] = value diff --git a/openpype/hosts/fusion/api/action.py b/openpype/hosts/fusion/api/action.py index 1750920950..ff5dd14caa 100644 --- a/openpype/hosts/fusion/api/action.py +++ b/openpype/hosts/fusion/api/action.py @@ -6,12 +6,13 @@ from openpype.pipeline.publish import get_errored_instances_from_context class SelectInvalidAction(pyblish.api.Action): - """Select invalid nodes in Maya when plug-in failed. + """Select invalid nodes in Fusion 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 @@ -31,8 +32,10 @@ class SelectInvalidAction(pyblish.api.Action): 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.") + self.log.warning( + "Plug-in returned to be invalid, " + "but has no selectable nodes." + ) if not invalid: # Assume relevant comp is current comp and clear selection @@ -51,4 +54,6 @@ class SelectInvalidAction(pyblish.api.Action): for tool in invalid: flow.Select(tool, True) names.add(tool.Name) - self.log.info("Selecting invalid tools: %s" % ", ".join(sorted(names))) + self.log.info( + "Selecting invalid tools: %s" % ", ".join(sorted(names)) + ) diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index 88a3f0b49b..40cc4d2963 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -303,10 +303,18 @@ def get_frame_path(path): return filename, padding, ext -def get_current_comp(): - """Hack to get current comp in this session""" +def get_fusion_module(): + """Get current Fusion instance""" fusion = getattr(sys.modules["__main__"], "fusion", None) - return fusion.CurrentComp if fusion else None + return fusion + + +def get_current_comp(): + """Get current comp in this session""" + fusion = get_fusion_module() + if fusion is not None: + comp = fusion.CurrentComp + return comp @contextlib.contextmanager diff --git a/openpype/hosts/fusion/api/menu.py b/openpype/hosts/fusion/api/menu.py index 343f5f803a..92f38a64c2 100644 --- a/openpype/hosts/fusion/api/menu.py +++ b/openpype/hosts/fusion/api/menu.py @@ -6,7 +6,6 @@ from openpype.tools.utils import host_tools from openpype.style import load_stylesheet from openpype.lib import register_event_callback from openpype.hosts.fusion.scripts import ( - set_rendermode, duplicate_with_inputs, ) from openpype.hosts.fusion.api.lib import ( @@ -60,7 +59,6 @@ class OpenPypeMenu(QtWidgets.QWidget): publish_btn = QtWidgets.QPushButton("Publish...", self) manager_btn = QtWidgets.QPushButton("Manage...", self) libload_btn = QtWidgets.QPushButton("Library...", self) - rendermode_btn = QtWidgets.QPushButton("Set render mode...", self) set_framerange_btn = QtWidgets.QPushButton("Set Frame Range", self) set_resolution_btn = QtWidgets.QPushButton("Set Resolution", self) duplicate_with_inputs_btn = QtWidgets.QPushButton( @@ -91,7 +89,6 @@ class OpenPypeMenu(QtWidgets.QWidget): layout.addWidget(set_framerange_btn) layout.addWidget(set_resolution_btn) - layout.addWidget(rendermode_btn) layout.addSpacing(20) @@ -108,7 +105,6 @@ class OpenPypeMenu(QtWidgets.QWidget): load_btn.clicked.connect(self.on_load_clicked) manager_btn.clicked.connect(self.on_manager_clicked) libload_btn.clicked.connect(self.on_libload_clicked) - rendermode_btn.clicked.connect(self.on_rendermode_clicked) duplicate_with_inputs_btn.clicked.connect( self.on_duplicate_with_inputs_clicked ) @@ -162,15 +158,6 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_libload_clicked(self): host_tools.show_library_loader() - def on_rendermode_clicked(self): - if self.render_mode_widget is None: - window = set_rendermode.SetRenderMode() - window.setStyleSheet(load_stylesheet()) - window.show() - self.render_mode_widget = window - else: - self.render_mode_widget.show() - def on_duplicate_with_inputs_clicked(self): duplicate_with_inputs.duplicate_with_input_connections() diff --git a/openpype/hosts/fusion/deploy/fusion_shared.prefs b/openpype/hosts/fusion/deploy/fusion_shared.prefs index 998c6a6d66..b379ea7c66 100644 --- a/openpype/hosts/fusion/deploy/fusion_shared.prefs +++ b/openpype/hosts/fusion/deploy/fusion_shared.prefs @@ -1,19 +1,19 @@ { Locked = true, Global = { - Paths = { - Map = { - ["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy", - ["Reactor:"] = "$(REACTOR)", - - ["Config:"] = "UserPaths:Config;OpenPype:Config", - ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts;OpenPype:Scripts", - ["UserPaths:"] = "UserData:;AllData:;Fusion:;Reactor:Deploy" - }, - }, - Script = { - PythonVersion = 3, - Python3Forced = true - }, + Paths = { + Map = { + ["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy", + ["Config:"] = "UserPaths:Config;OpenPype:Config", + ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts;OpenPype:Scripts", }, -} \ No newline at end of file + }, + Script = { + PythonVersion = 3, + Python3Forced = true + }, + UserInterface = { + Language = "en_US" + }, + }, +} diff --git a/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py new file mode 100644 index 0000000000..fd726ccda1 --- /dev/null +++ b/openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py @@ -0,0 +1,161 @@ +import os +import shutil +import platform +from pathlib import Path +from openpype.lib import PreLaunchHook, ApplicationLaunchFailed +from openpype.hosts.fusion import ( + FUSION_HOST_DIR, + FUSION_VERSIONS_DICT, + get_fusion_version, +) + + +class FusionCopyPrefsPrelaunch(PreLaunchHook): + """ + Prepares local Fusion profile directory, copies existing Fusion profile. + This also sets FUSION MasterPrefs variable, which is used + to apply Master.prefs file to override some Fusion profile settings to: + - enable the OpenPype menu + - force Python 3 over Python 2 + - force English interface + Master.prefs is defined in openpype/hosts/fusion/deploy/fusion_shared.prefs + """ + + app_groups = ["fusion"] + order = 2 + + def get_fusion_profile_name(self, profile_version) -> str: + # Returns 'Default', unless FUSION16_PROFILE is set + return os.getenv(f"FUSION{profile_version}_PROFILE", "Default") + + def get_fusion_profile_dir(self, profile_version) -> Path: + # Get FUSION_PROFILE_DIR variable + fusion_profile = self.get_fusion_profile_name(profile_version) + fusion_var_prefs_dir = os.getenv( + f"FUSION{profile_version}_PROFILE_DIR" + ) + + # Check if FUSION_PROFILE_DIR exists + if fusion_var_prefs_dir and Path(fusion_var_prefs_dir).is_dir(): + fu_prefs_dir = Path(fusion_var_prefs_dir, fusion_profile) + self.log.info(f"{fusion_var_prefs_dir} is set to {fu_prefs_dir}") + return fu_prefs_dir + + def get_profile_source(self, profile_version) -> Path: + """Get Fusion preferences profile location. + See Per-User_Preferences_and_Paths on VFXpedia for reference. + """ + fusion_profile = self.get_fusion_profile_name(profile_version) + profile_source = self.get_fusion_profile_dir(profile_version) + if profile_source: + return profile_source + # otherwise get default location of the profile folder + fu_prefs_dir = f"Blackmagic Design/Fusion/Profiles/{fusion_profile}" + if platform.system() == "Windows": + profile_source = Path(os.getenv("AppData"), fu_prefs_dir) + elif platform.system() == "Darwin": + profile_source = Path( + "~/Library/Application Support/", fu_prefs_dir + ).expanduser() + elif platform.system() == "Linux": + profile_source = Path("~/.fusion", fu_prefs_dir).expanduser() + self.log.info( + f"Locating source Fusion prefs directory: {profile_source}" + ) + return profile_source + + def get_copy_fusion_prefs_settings(self): + # Get copy preferences options from the global application settings + + copy_fusion_settings = self.data["project_settings"]["fusion"].get( + "copy_fusion_settings", {} + ) + if not copy_fusion_settings: + self.log.error("Copy prefs settings not found") + copy_status = copy_fusion_settings.get("copy_status", False) + force_sync = copy_fusion_settings.get("force_sync", False) + copy_path = copy_fusion_settings.get("copy_path") or None + if copy_path: + copy_path = Path(copy_path).expanduser() + return copy_status, copy_path, force_sync + + def copy_fusion_profile( + self, copy_from: Path, copy_to: Path, force_sync: bool + ) -> None: + """On the first Fusion launch copy the contents of Fusion profile + directory to the working predefined location. If the Openpype profile + folder exists, skip copying, unless re-sync is checked. + If the prefs were not copied on the first launch, + clean Fusion profile will be created in fu_profile_dir. + """ + if copy_to.exists() and not force_sync: + self.log.info( + "Destination Fusion preferences folder already exists: " + f"{copy_to} " + ) + return + self.log.info("Starting copying Fusion preferences") + self.log.debug(f"force_sync option is set to {force_sync}") + try: + copy_to.mkdir(exist_ok=True, parents=True) + except PermissionError: + self.log.warning(f"Creating the folder not permitted at {copy_to}") + return + if not copy_from.exists(): + self.log.warning(f"Fusion preferences not found in {copy_from}") + return + for file in copy_from.iterdir(): + if file.suffix in ( + ".prefs", + ".def", + ".blocklist", + ".fu", + ".toolbars", + ): + # convert Path to str to be compatible with Python 3.6+ + shutil.copy(str(file), str(copy_to)) + self.log.info( + f"Successfully copied preferences: {copy_from} to {copy_to}" + ) + + def execute(self): + ( + copy_status, + fu_profile_dir, + force_sync, + ) = self.get_copy_fusion_prefs_settings() + + # Get launched application context and return correct app version + app_name = self.launch_context.env.get("AVALON_APP_NAME") + app_version = get_fusion_version(app_name) + if app_version is None: + version_names = ", ".join(str(x) for x in FUSION_VERSIONS_DICT) + raise ApplicationLaunchFailed( + "Unable to detect valid Fusion version number from app " + f"name: {app_name}.\nMake sure to include at least a digit " + "to indicate the Fusion version like '18'.\n" + f"Detectable Fusion versions are: {version_names}" + ) + + _, profile_version = FUSION_VERSIONS_DICT[app_version] + fu_profile = self.get_fusion_profile_name(profile_version) + + # do a copy of Fusion profile if copy_status toggle is enabled + if copy_status and fu_profile_dir is not None: + profile_source = self.get_profile_source(profile_version) + dest_folder = Path(fu_profile_dir, fu_profile) + self.copy_fusion_profile(profile_source, dest_folder, force_sync) + + # Add temporary profile directory variables to customize Fusion + # to define where it can read custom scripts and tools from + fu_profile_dir_variable = f"FUSION{profile_version}_PROFILE_DIR" + self.log.info(f"Setting {fu_profile_dir_variable}: {fu_profile_dir}") + self.launch_context.env[fu_profile_dir_variable] = str(fu_profile_dir) + + # Add custom Fusion Master Prefs and the temporary + # profile directory variables to customize Fusion + # to define where it can read custom scripts and tools from + master_prefs_variable = f"FUSION{profile_version}_MasterPrefs" + master_prefs = Path(FUSION_HOST_DIR, "deploy", "fusion_shared.prefs") + self.log.info(f"Setting {master_prefs_variable}: {master_prefs}") + self.launch_context.env[master_prefs_variable] = str(master_prefs) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index 323b8b0029..f27cd1674b 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -1,32 +1,43 @@ import os from openpype.lib import PreLaunchHook, ApplicationLaunchFailed -from openpype.hosts.fusion import FUSION_HOST_DIR +from openpype.hosts.fusion import ( + FUSION_HOST_DIR, + FUSION_VERSIONS_DICT, + get_fusion_version, +) class FusionPrelaunch(PreLaunchHook): - """Prepares OpenPype Fusion environment - - Requires FUSION_PYTHON3_HOME to be defined in the environment for Fusion - to point at a valid Python 3 build for Fusion. That is Python 3.3-3.10 - for Fusion 18 and Fusion 3.6 for Fusion 16 and 17. - - This also sets FUSION16_MasterPrefs to apply the fusion master prefs - as set in openpype/hosts/fusion/deploy/fusion_shared.prefs to enable - the OpenPype menu and force Python 3 over Python 2. - """ + Prepares OpenPype Fusion environment. + Requires correct Python home variable to be defined in the environment + settings for Fusion to point at a valid Python 3 build for Fusion. + Python3 versions that are supported by Fusion: + Fusion 9, 16, 17 : Python 3.6 + Fusion 18 : Python 3.6 - 3.10 + """ + app_groups = ["fusion"] + order = 1 def execute(self): # making sure python 3 is installed at provided path # Py 3.3-3.10 for Fusion 18+ or Py 3.6 for Fu 16-17 - py3_var = "FUSION_PYTHON3_HOME" + app_data = self.launch_context.env.get("AVALON_APP_NAME") + app_version = get_fusion_version(app_data) + if not app_version: + raise ApplicationLaunchFailed( + "Fusion version information not found in System settings.\n" + "The key field in the 'applications/fusion/variants' should " + "consist a number, corresponding to major Fusion version." + ) + py3_var, _ = FUSION_VERSIONS_DICT[app_version] fusion_python3_home = self.launch_context.env.get(py3_var, "") - self.log.info(f"Looking for Python 3 in: {fusion_python3_home}") for path in fusion_python3_home.split(os.pathsep): - # Allow defining multiple paths to allow "fallback" to other - # path. But make to set only a single path as final variable. + # Allow defining multiple paths, separated by os.pathsep, + # to allow "fallback" to other path. + # But make to set only a single path as final variable. py3_dir = os.path.normpath(path) if os.path.isdir(py3_dir): break @@ -43,19 +54,10 @@ class FusionPrelaunch(PreLaunchHook): self.launch_context.env[py3_var] = py3_dir # Fusion 18+ requires FUSION_PYTHON3_HOME to also be on PATH - self.launch_context.env["PATH"] += ";" + py3_dir + if app_version >= 18: + self.launch_context.env["PATH"] += os.pathsep + py3_dir - # Fusion 16 and 17 use FUSION16_PYTHON36_HOME instead of - # FUSION_PYTHON3_HOME and will only work with a Python 3.6 version - # TODO: Detect Fusion version to only set for specific Fusion build - self.launch_context.env["FUSION16_PYTHON36_HOME"] = py3_dir + self.launch_context.env[py3_var] = py3_dir - # Add our Fusion Master Prefs which is the only way to customize - # Fusion to define where it can read custom scripts and tools from self.log.info(f"Setting OPENPYPE_FUSION: {FUSION_HOST_DIR}") self.launch_context.env["OPENPYPE_FUSION"] = FUSION_HOST_DIR - - pref_var = "FUSION16_MasterPrefs" # used by Fusion 16, 17 and 18 - prefs = os.path.join(FUSION_HOST_DIR, "deploy", "fusion_shared.prefs") - self.log.info(f"Setting {pref_var}: {prefs}") - self.launch_context.env[pref_var] = prefs diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index e581bac20f..56085b0a06 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -4,29 +4,34 @@ import qtawesome from openpype.hosts.fusion.api import ( get_current_comp, - comp_lock_and_undo_chunk + comp_lock_and_undo_chunk, ) -from openpype.lib import BoolDef +from openpype.lib import ( + BoolDef, + EnumDef, +) from openpype.pipeline import ( legacy_io, Creator, - CreatedInstance + CreatedInstance, +) +from openpype.client import ( + get_asset_by_name, ) -from openpype.client import get_asset_by_name class CreateSaver(Creator): identifier = "io.openpype.creators.fusion.saver" - name = "saver" - label = "Saver" + label = "Render (saver)" + name = "render" family = "render" - default_variants = ["Main"] - + default_variants = ["Main", "Mask"] description = "Fusion Saver to generate image sequence" - def create(self, subset_name, instance_data, pre_create_data): + instance_attributes = ["reviewable"] + def create(self, subset_name, instance_data, pre_create_data): # TODO: Add pre_create attributes to choose file format? file_format = "OpenEXRFormat" @@ -58,7 +63,8 @@ class CreateSaver(Creator): family=self.family, subset_name=subset_name, data=instance_data, - creator=self) + creator=self, + ) # Insert the transient data instance.transient_data["tool"] = saver @@ -68,11 +74,9 @@ class CreateSaver(Creator): return instance def collect_instances(self): - comp = get_current_comp() tools = comp.GetToolList(False, "Saver").values() for tool in tools: - data = self.get_managed_tool_data(tool) if not data: data = self._collect_unmanaged_saver(tool) @@ -90,7 +94,6 @@ class CreateSaver(Creator): def update_instances(self, update_list): for created_inst, _changes in update_list: - new_data = created_inst.data_to_store() tool = created_inst.transient_data["tool"] self._update_tool_with_data(tool, new_data) @@ -139,7 +142,6 @@ class CreateSaver(Creator): tool.SetAttrs({"TOOLS_Name": subset}) 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 @@ -153,8 +155,7 @@ class CreateSaver(Creator): asset = legacy_io.Session["AVALON_ASSET"] task = legacy_io.Session["AVALON_TASK"] - asset_doc = get_asset_by_name(project_name=project, - asset_name=asset) + asset_doc = get_asset_by_name(project_name=project, asset_name=asset) path = tool["Clip"][comp.TIME_UNDEFINED] fname = os.path.basename(path) @@ -178,21 +179,20 @@ class CreateSaver(Creator): "variant": variant, "active": not passthrough, "family": self.family, - # Unique identifier for instance and this creator "id": "pyblish.avalon.instance", - "creator_identifier": self.identifier + "creator_identifier": self.identifier, } def get_managed_tool_data(self, tool): """Return data of the tool if it matches creator identifier""" - data = tool.GetData('openpype') + data = tool.GetData("openpype") if not isinstance(data, dict): return required = { "id": "pyblish.avalon.instance", - "creator_identifier": self.identifier + "creator_identifier": self.identifier, } for key, value in required.items(): if key not in data or data[key] != value: @@ -205,11 +205,40 @@ class CreateSaver(Creator): return data - def get_instance_attr_defs(self): - return [ - BoolDef( - "review", - default=True, - label="Review" - ) + def get_pre_create_attr_defs(self): + """Settings for create page""" + attr_defs = [ + self._get_render_target_enum(), + self._get_reviewable_bool(), ] + 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 + + # These functions below should be moved to another file + # so it can be used by other plugins. plugin.py ? + + def _get_render_target_enum(self): + rendering_targets = { + "local": "Local machine rendering", + "frames": "Use existing frames", + } + if "farm_rendering" in self.instance_attributes: + rendering_targets["farm"] = "Farm rendering" + + return EnumDef( + "render_target", items=rendering_targets, label="Render target" + ) + + def _get_reviewable_bool(self): + return BoolDef( + "review", + default=("reviewable" in self.instance_attributes), + label="Review", + ) diff --git a/openpype/hosts/fusion/plugins/publish/collect_expected_frames.py b/openpype/hosts/fusion/plugins/publish/collect_expected_frames.py new file mode 100644 index 0000000000..0ba777629f --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/collect_expected_frames.py @@ -0,0 +1,50 @@ +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) diff --git a/openpype/hosts/fusion/plugins/publish/collect_render_target.py b/openpype/hosts/fusion/plugins/publish/collect_render_target.py deleted file mode 100644 index 39017f32e0..0000000000 --- a/openpype/hosts/fusion/plugins/publish/collect_render_target.py +++ /dev/null @@ -1,44 +0,0 @@ -import pyblish.api - - -class CollectFusionRenderMode(pyblish.api.InstancePlugin): - """Collect current comp's render Mode - - Options: - local - farm - - Note that this value is set for each comp separately. When you save the - comp this information will be stored in that file. If for some reason the - available tool does not visualize which render mode is set for the - current comp, please run the following line in the console (Py2) - - comp.GetData("openpype.rendermode") - - This will return the name of the current render mode as seen above under - Options. - - """ - - order = pyblish.api.CollectorOrder + 0.4 - label = "Collect Render Mode" - hosts = ["fusion"] - families = ["render"] - - def process(self, instance): - """Collect all image sequence tools""" - options = ["local", "farm"] - - comp = instance.context.data.get("currentComp") - if not comp: - raise RuntimeError("No comp previously collected, unable to " - "retrieve Fusion version.") - - rendermode = comp.GetData("openpype.rendermode") or "local" - assert rendermode in options, "Must be supported render mode" - - self.log.info("Render mode: {0}".format(rendermode)) - - # Append family - family = "render.{0}".format(rendermode) - instance.data["families"].append(family) diff --git a/openpype/hosts/fusion/plugins/publish/collect_renders.py b/openpype/hosts/fusion/plugins/publish/collect_renders.py new file mode 100644 index 0000000000..7f38e68447 --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/collect_renders.py @@ -0,0 +1,25 @@ +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) + ) diff --git a/openpype/hosts/fusion/plugins/publish/extract_render_local.py b/openpype/hosts/fusion/plugins/publish/extract_render_local.py new file mode 100644 index 0000000000..5a0140c525 --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/extract_render_local.py @@ -0,0 +1,109 @@ +import logging +import contextlib +import pyblish.api +from openpype.hosts.fusion.api import comp_lock_and_undo_chunk + + +log = logging.getLogger(__name__) + + +@contextlib.contextmanager +def enabled_savers(comp, savers): + """Enable only the `savers` in Comp during the context. + + Any Saver tool in the passed composition that is not in the savers list + will be set to passthrough during the context. + + Args: + comp (object): Fusion composition object. + savers (list): List of Saver tool objects. + + """ + passthrough_key = "TOOLB_PassThrough" + original_states = {} + enabled_save_names = {saver.Name for saver in savers} + try: + all_savers = comp.GetToolList(False, "Saver").values() + for saver in all_savers: + original_state = saver.GetAttrs()[passthrough_key] + original_states[saver] = original_state + + # The passthrough state we want to set (passthrough != enabled) + state = saver.Name not in enabled_save_names + if state != original_state: + saver.SetAttrs({passthrough_key: state}) + yield + finally: + for saver, original_state in original_states.items(): + saver.SetAttrs({"TOOLB_PassThrough": original_state}) + + +class FusionRenderLocal(pyblish.api.InstancePlugin): + """Render the current Fusion composition locally.""" + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Render Local" + hosts = ["fusion"] + families = ["render.local"] + + def process(self, instance): + context = instance.context + + # Start render + self.render_once(context) + + # Log render status + self.log.info( + "Rendered '{nm}' for asset '{ast}' under the task '{tsk}'".format( + nm=instance.data["name"], + ast=instance.data["asset"], + tsk=instance.data["task"], + ) + ) + + def render_once(self, context): + """Render context comp only once, even with more render instances""" + + # 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__}" + + 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 + + current_comp = context.data["currentComp"] + frame_start = context.data["frameStartHandle"] + frame_end = context.data["frameEndHandle"] + + 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}") + + with comp_lock_and_undo_chunk(current_comp): + with enabled_savers(current_comp, savers_to_render): + result = current_comp.Render( + { + "Start": frame_start, + "End": frame_end, + "Wait": True, + } + ) + + context.data[key] = bool(result) + + if context.data[key] is False: + raise RuntimeError("Comp render failed") diff --git a/openpype/hosts/fusion/plugins/publish/render_local.py b/openpype/hosts/fusion/plugins/publish/render_local.py deleted file mode 100644 index 7d5f1a40c7..0000000000 --- a/openpype/hosts/fusion/plugins/publish/render_local.py +++ /dev/null @@ -1,100 +0,0 @@ -import os -import pyblish.api -from openpype.pipeline import publish -from openpype.hosts.fusion.api import comp_lock_and_undo_chunk - - -class Fusionlocal(pyblish.api.InstancePlugin, - publish.ColormanagedPyblishPluginMixin): - """Render the current Fusion composition locally. - - Extract the result of savers by starting a comp render - This will run the local render of Fusion. - - """ - - order = pyblish.api.ExtractorOrder - 0.1 - label = "Render Local" - hosts = ["fusion"] - families = ["render.local"] - - def process(self, instance): - context = instance.context - - # Start render - self.render_once(context) - - # Log render status - self.log.info( - "Rendered '{nm}' for asset '{ast}' under the task '{tsk}'".format( - nm=instance.data["name"], - ast=instance.data["asset"], - tsk=instance.data["task"], - ) - ) - - 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, - ) - - if "representations" not in instance.data: - instance.data["representations"] = [] - instance.data["representations"].append(repre) - - # review representation - if instance.data.get("review", False): - repre["tags"] = ["review", "ftrackreview"] - - def render_once(self, context): - """Render context comp only once, even with more render instances""" - - # 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__}" - 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 - - current_comp = context.data["currentComp"] - frame_start = context.data["frameStartHandle"] - frame_end = context.data["frameEndHandle"] - - self.log.info("Starting Fusion render") - self.log.info(f"Start frame: {frame_start}") - self.log.info(f"End frame: {frame_end}") - - with comp_lock_and_undo_chunk(current_comp): - result = current_comp.Render( - { - "Start": frame_start, - "End": frame_end, - "Wait": True, - } - ) - - context.data[key] = bool(result) - - if context.data[key] is False: - raise RuntimeError("Comp render failed") diff --git a/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py b/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py index ba943abacb..8a91f23578 100644 --- a/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py +++ b/openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py @@ -14,22 +14,19 @@ class ValidateCreateFolderChecked(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - actions = [RepairAction] label = "Validate Create Folder Checked" families = ["render"] hosts = ["fusion"] - actions = [SelectInvalidAction] + actions = [RepairAction, SelectInvalidAction] @classmethod def get_invalid(cls, instance): - active = instance.data.get("active", instance.data.get("publish")) - if not active: - return [] - tool = instance[0] create_dir = tool.GetInput("CreateDir") if create_dir == 0.0: - cls.log.error("%s has Create Folder turned off" % instance[0].Name) + cls.log.error( + "%s has Create Folder turned off" % instance[0].Name + ) return [tool] def process(self, instance): @@ -37,7 +34,8 @@ class ValidateCreateFolderChecked(pyblish.api.InstancePlugin): if invalid: raise PublishValidationError( "Found Saver with Create Folder During Render checked off", - title=self.label) + title=self.label, + ) @classmethod def repair(cls, instance): diff --git a/openpype/hosts/fusion/plugins/publish/validate_expected_frames_existence.py b/openpype/hosts/fusion/plugins/publish/validate_expected_frames_existence.py new file mode 100644 index 0000000000..c208b8ef15 --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/validate_expected_frames_existence.py @@ -0,0 +1,78 @@ +import os +import pyblish.api + +from openpype.pipeline.publish import RepairAction +from openpype.pipeline import PublishValidationError + +from openpype.hosts.fusion.api.action import SelectInvalidAction + + +class ValidateLocalFramesExistence(pyblish.api.InstancePlugin): + """Checks if files for savers that's set + to publish expected frames exists + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Expected Frames Exists" + families = ["render"] + hosts = ["fusion"] + actions = [RepairAction, SelectInvalidAction] + + @classmethod + def get_invalid(cls, instance, non_existing_frames=None): + if non_existing_frames is None: + non_existing_frames = [] + + if instance.data.get("render_target") == "frames": + tool = instance[0] + + frame_start = instance.data["frameStart"] + frame_end = instance.data["frameEnd"] + 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) + ] + + 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] + + def process(self, instance): + non_existing_frames = [] + invalid = self.get_invalid(instance, non_existing_frames) + if invalid: + raise PublishValidationError( + "{} is set to publish existing frames but " + "some frames are missing. " + "The missing file(s) are:\n\n{}".format( + invalid[0].Name, + "\n\n".join(non_existing_frames), + ), + title=self.label, + ) + + @classmethod + def repair(cls, instance): + invalid = cls.get_invalid(instance) + if invalid: + tool = invalid[0] + + # Change render target to local to render locally + tool.SetData("openpype.creator_attributes.render_target", "local") + + cls.log.info( + f"Reload the publisher and {tool.Name} " + "will be set to render locally" + ) diff --git a/openpype/hosts/fusion/scripts/set_rendermode.py b/openpype/hosts/fusion/scripts/set_rendermode.py deleted file mode 100644 index 9d2bfef310..0000000000 --- a/openpype/hosts/fusion/scripts/set_rendermode.py +++ /dev/null @@ -1,112 +0,0 @@ -from qtpy import QtWidgets -import qtawesome -from openpype.hosts.fusion.api import get_current_comp - - -_help = {"local": "Render the comp on your own machine and publish " - "it from that the destination folder", - "farm": "Submit a Fusion render job to a Render farm to use all other" - " computers and add a publish job"} - - -class SetRenderMode(QtWidgets.QWidget): - - def __init__(self, parent=None): - QtWidgets.QWidget.__init__(self, parent) - - self._comp = get_current_comp() - self._comp_name = self._get_comp_name() - - self.setWindowTitle("Set Render Mode") - self.setFixedSize(300, 175) - - layout = QtWidgets.QVBoxLayout() - - # region comp info - comp_info_layout = QtWidgets.QHBoxLayout() - - update_btn = QtWidgets.QPushButton(qtawesome.icon("fa.refresh", - color="white"), "") - update_btn.setFixedWidth(25) - update_btn.setFixedHeight(25) - - comp_information = QtWidgets.QLineEdit() - comp_information.setEnabled(False) - - comp_info_layout.addWidget(comp_information) - comp_info_layout.addWidget(update_btn) - # endregion comp info - - # region modes - mode_options = QtWidgets.QComboBox() - mode_options.addItems(_help.keys()) - - mode_information = QtWidgets.QTextEdit() - mode_information.setReadOnly(True) - # endregion modes - - accept_btn = QtWidgets.QPushButton("Accept") - - layout.addLayout(comp_info_layout) - layout.addWidget(mode_options) - layout.addWidget(mode_information) - layout.addWidget(accept_btn) - - self.setLayout(layout) - - self.comp_information = comp_information - self.update_btn = update_btn - - self.mode_options = mode_options - self.mode_information = mode_information - - self.accept_btn = accept_btn - - self.connections() - self.update() - - # Force updated render mode help text - self._update_rendermode_info() - - def connections(self): - """Build connections between code and buttons""" - - self.update_btn.clicked.connect(self.update) - self.accept_btn.clicked.connect(self._set_comp_rendermode) - self.mode_options.currentIndexChanged.connect( - self._update_rendermode_info) - - def update(self): - """Update all information in the UI""" - - self._comp = get_current_comp() - self._comp_name = self._get_comp_name() - self.comp_information.setText(self._comp_name) - - # Update current comp settings - mode = self._get_comp_rendermode() - index = self.mode_options.findText(mode) - self.mode_options.setCurrentIndex(index) - - def _update_rendermode_info(self): - rendermode = self.mode_options.currentText() - self.mode_information.setText(_help[rendermode]) - - def _get_comp_name(self): - return self._comp.GetAttrs("COMPS_Name") - - def _get_comp_rendermode(self): - return self._comp.GetData("openpype.rendermode") or "local" - - def _set_comp_rendermode(self): - rendermode = self.mode_options.currentText() - self._comp.SetData("openpype.rendermode", rendermode) - - self._comp.Print("Updated render mode to '%s'\n" % rendermode) - self.hide() - - def _validation(self): - ui_mode = self.mode_options.currentText() - comp_mode = self._get_comp_rendermode() - - return comp_mode == ui_mode diff --git a/openpype/hosts/harmony/api/README.md b/openpype/hosts/harmony/api/README.md index b39f900886..12f21f551a 100644 --- a/openpype/hosts/harmony/api/README.md +++ b/openpype/hosts/harmony/api/README.md @@ -432,11 +432,11 @@ copy_files = """function copyFile(srcFilename, dstFilename) import_files = """function %s_import_files() { - var PNGTransparencyMode = 0; // Premultiplied wih Black - var TGATransparencyMode = 0; // Premultiplied wih Black - var SGITransparencyMode = 0; // Premultiplied wih Black + var PNGTransparencyMode = 0; // Premultiplied with Black + var TGATransparencyMode = 0; // Premultiplied with Black + var SGITransparencyMode = 0; // Premultiplied with Black var LayeredPSDTransparencyMode = 1; // Straight - var FlatPSDTransparencyMode = 2; // Premultiplied wih White + var FlatPSDTransparencyMode = 2; // Premultiplied with White function getUniqueColumnName( column_prefix ) { diff --git a/openpype/hosts/harmony/api/TB_sceneOpened.js b/openpype/hosts/harmony/api/TB_sceneOpened.js index e7cd555332..a284a6ec5c 100644 --- a/openpype/hosts/harmony/api/TB_sceneOpened.js +++ b/openpype/hosts/harmony/api/TB_sceneOpened.js @@ -142,10 +142,10 @@ function Client() { }; /** - * Process recieved request. This will eval recieved function and produce + * Process received request. This will eval received function and produce * results. * @function - * @param {object} request - recieved request JSON + * @param {object} request - received request JSON * @return {object} result of evaled function. */ self.processRequest = function(request) { @@ -245,7 +245,7 @@ function Client() { var request = JSON.parse(to_parse); var mid = request.message_id; // self.logDebug('[' + mid + '] - Request: ' + '\n' + JSON.stringify(request)); - self.logDebug('[' + mid + '] Recieved.'); + self.logDebug('[' + mid + '] Received.'); request.result = self.processRequest(request); self.logDebug('[' + mid + '] Processing done.'); @@ -286,8 +286,8 @@ function Client() { /** Harmony 21.1 doesn't have QDataStream anymore. This means we aren't able to write bytes into QByteArray so we had - modify how content lenght is sent do the server. - Content lenght is sent as string of 8 char convertible into integer + modify how content length is sent do the server. + Content length is sent as string of 8 char convertible into integer (instead of 0x00000001[4 bytes] > "000000001"[8 bytes]) */ var codec_name = new QByteArray().append("UTF-8"); @@ -476,6 +476,25 @@ function start() { action.triggered.connect(self.onSubsetManage); } + /** + * Set scene settings from DB to the scene + */ + self.onSetSceneSettings = function() { + app.avalonClient.send( + { + "module": "openpype.hosts.harmony.api", + "method": "ensure_scene_settings", + "args": [] + }, + false + ); + }; + // add Set Scene Settings + if (app.avalonMenu == null) { + action = menu.addAction('Set Scene Settings...'); + action.triggered.connect(self.onSetSceneSettings); + } + /** * Show Experimental dialog */ diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index e1e77bfbee..8048705dc8 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -394,7 +394,7 @@ def get_scene_data(): "function": "AvalonHarmony.getSceneData" })["result"] except json.decoder.JSONDecodeError: - # Means no sceen metadata has been made before. + # Means no scene metadata has been made before. return {} except KeyError: # Means no existing scene metadata has been made. @@ -465,7 +465,7 @@ def imprint(node_id, data, remove=False): Example: >>> from openpype.hosts.harmony.api import lib >>> node = "Top/Display" - >>> data = {"str": "someting", "int": 1, "float": 0.32, "bool": True} + >>> data = {"str": "something", "int": 1, "float": 0.32, "bool": True} >>> lib.imprint(layer, data) """ scene_data = get_scene_data() @@ -550,7 +550,7 @@ def save_scene(): method prevents this double request and safely saves the scene. """ - # Need to turn off the backgound watcher else the communication with + # Need to turn off the background watcher else the communication with # the server gets spammed with two requests at the same time. scene_path = send( {"function": "AvalonHarmony.saveScene"})["result"] diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index 686770b64e..285ee806a1 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -142,7 +142,7 @@ def application_launch(event): harmony.send({"script": script}) inject_avalon_js() - ensure_scene_settings() + # ensure_scene_settings() check_inventory() diff --git a/openpype/hosts/harmony/api/server.py b/openpype/hosts/harmony/api/server.py index ecf339d91b..04048e5c84 100644 --- a/openpype/hosts/harmony/api/server.py +++ b/openpype/hosts/harmony/api/server.py @@ -61,7 +61,7 @@ class Server(threading.Thread): "module": (str), # Module of method. "method" (str), # Name of method in module. "args" (list), # Arguments to pass to method. - "kwargs" (dict), # Keywork arguments to pass to method. + "kwargs" (dict), # Keyword arguments to pass to method. "reply" (bool), # Optional wait for method completion. } """ diff --git a/openpype/hosts/harmony/plugins/publish/extract_render.py b/openpype/hosts/harmony/plugins/publish/extract_render.py index c29864bb28..38b09902c1 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_render.py +++ b/openpype/hosts/harmony/plugins/publish/extract_render.py @@ -25,8 +25,9 @@ class ExtractRender(pyblish.api.InstancePlugin): application_path = instance.context.data.get("applicationPath") scene_path = instance.context.data.get("scenePath") frame_rate = instance.context.data.get("frameRate") - frame_start = instance.context.data.get("frameStart") - frame_end = instance.context.data.get("frameEnd") + # real value from timeline + frame_start = instance.context.data.get("frameStartHandle") + frame_end = instance.context.data.get("frameEndHandle") audio_path = instance.context.data.get("audioPath") if audio_path and os.path.exists(audio_path): @@ -55,9 +56,13 @@ class ExtractRender(pyblish.api.InstancePlugin): # Execute rendering. Ignoring error cause Harmony returns error code # always. - self.log.info(f"running [ {application_path} -batch {scene_path}") + + args = [application_path, "-batch", + "-frames", str(frame_start), str(frame_end), + "-scene", scene_path] + self.log.info(f"running [ {application_path} {' '.join(args)}") proc = subprocess.Popen( - [application_path, "-batch", scene_path], + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE diff --git a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py index 936533abd6..6e4c6955e4 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py @@ -60,7 +60,8 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): # which is available on 'context.data["assetEntity"]' # - the same approach can be used in 'ValidateSceneSettingsRepair' expected_settings = harmony.get_asset_settings() - self.log.info("scene settings from DB:".format(expected_settings)) + self.log.info("scene settings from DB:{}".format(expected_settings)) + expected_settings.pop("entityType") # not useful for the validation expected_settings = _update_frames(dict.copy(expected_settings)) expected_settings["frameEndHandle"] = expected_settings["frameEnd"] +\ @@ -68,21 +69,32 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): if (any(re.search(pattern, os.getenv('AVALON_TASK')) for pattern in self.skip_resolution_check)): + self.log.info("Skipping resolution check because of " + "task name and pattern {}".format( + self.skip_resolution_check)) expected_settings.pop("resolutionWidth") expected_settings.pop("resolutionHeight") - entity_type = expected_settings.get("entityType") - if (any(re.search(pattern, entity_type) + if (any(re.search(pattern, os.getenv('AVALON_TASK')) for pattern in self.skip_timelines_check)): + self.log.info("Skipping frames check because of " + "task name and pattern {}".format( + self.skip_timelines_check)) expected_settings.pop('frameStart', None) expected_settings.pop('frameEnd', None) - - expected_settings.pop("entityType") # not useful after the check + expected_settings.pop('frameStartHandle', None) + expected_settings.pop('frameEndHandle', None) asset_name = instance.context.data['anatomyData']['asset'] if any(re.search(pattern, asset_name) for pattern in self.frame_check_filter): - expected_settings.pop("frameEnd") + self.log.info("Skipping frames check because of " + "task name and pattern {}".format( + self.frame_check_filter)) + expected_settings.pop('frameStart', None) + expected_settings.pop('frameEnd', None) + expected_settings.pop('frameStartHandle', None) + expected_settings.pop('frameEndHandle', None) # handle case where ftrack uses only two decimal places # 23.976023976023978 vs. 23.98 @@ -99,6 +111,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): "frameEnd": instance.context.data["frameEnd"], "handleStart": instance.context.data.get("handleStart"), "handleEnd": instance.context.data.get("handleEnd"), + "frameStartHandle": instance.context.data.get("frameStartHandle"), "frameEndHandle": instance.context.data.get("frameEndHandle"), "resolutionWidth": instance.context.data.get("resolutionWidth"), "resolutionHeight": instance.context.data.get("resolutionHeight"), diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/README.md b/openpype/hosts/harmony/vendor/OpenHarmony/README.md index 7c77fbfcfa..064afca86c 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/README.md +++ b/openpype/hosts/harmony/vendor/OpenHarmony/README.md @@ -6,7 +6,7 @@ Ever tried to make a simple script for toonboom Harmony, then got stumped by the Toonboom Harmony is a very powerful software, with hundreds of functions and tools, and it unlocks a great amount of possibilities for animation studios around the globe. And... being the produce of the hard work of a small team forced to prioritise, it can also be a bit rustic at times! -We are users at heart, animators and riggers, who just want to interact with the software as simply as possible. Simplicity is at the heart of the design of openHarmony. But we also are developpers, and we made the library for people like us who can't resist tweaking the software and bend it in all possible ways, and are looking for powerful functions to help them do it. +We are users at heart, animators and riggers, who just want to interact with the software as simply as possible. Simplicity is at the heart of the design of openHarmony. But we also are developers, and we made the library for people like us who can't resist tweaking the software and bend it in all possible ways, and are looking for powerful functions to help them do it. This library's aim is to create a more direct way to interact with Toonboom through scripts, by providing a more intuitive way to access its elements, and help with the cumbersome and repetitive tasks as well as help unlock untapped potential in its many available systems. So we can go from having to do things like this: @@ -78,7 +78,7 @@ All you have to do is call : ```javascript include("openHarmony.js"); ``` -at the beggining of your script. +at the beginning of your script. You can ask your users to download their copy of the library and store it alongside, or bundle it as you wish as long as you include the license file provided on this repository. @@ -129,7 +129,7 @@ Check that the environment variable `LIB_OPENHARMONY_PATH` is set correctly to t ## How to add openHarmony to vscode intellisense for autocompletion Although not fully supported, you can get most of the autocompletion features to work by adding the following lines to a `jsconfig.json` file placed at the root of your working folder. -The paths need to be relative which means the openHarmony source code must be placed directly in your developping environnement. +The paths need to be relative which means the openHarmony source code must be placed directly in your developping environment. For example, if your working folder contains the openHarmony source in a folder called `OpenHarmony` and your working scripts in a folder called `myScripts`, place the `jsconfig.json` file at the root of the folder and add these lines to the file: diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony.js index 530c0902c5..ae65d32a2b 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -78,7 +78,7 @@ * $.log("hello"); // prints out a message to the MessageLog. * var myPoint = new $.oPoint(0,0,0); // create a new class instance from an openHarmony class. * - * // function members of the $ objects get published to the global scope, which means $ can be ommited + * // function members of the $ objects get published to the global scope, which means $ can be omitted * * log("hello"); * var myPoint = new oPoint(0,0,0); // This is all valid @@ -118,7 +118,7 @@ Object.defineProperty( $, "directory", { /** - * Wether Harmony is run with the interface or simply from command line + * Whether Harmony is run with the interface or simply from command line */ Object.defineProperty( $, "batchMode", { get: function(){ diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_actions.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_actions.js index ad1efc91be..a54f74e147 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_actions.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_actions.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -67,7 +67,7 @@ * @hideconstructor * @namespace * @example - * // To check wether an action is available, call the synthax: + * // To check whether an action is available, call the synthax: * Action.validate (, ); * * // To launch an action, call the synthax: diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_application.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_application.js index 9e9acb766c..5809cee694 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_application.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_application.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -409,7 +409,7 @@ $.oApp.prototype.getToolByName = function(toolName){ /** - * returns the list of stencils useable by the specified tool + * returns the list of stencils usable by the specified tool * @param {$.oTool} tool the tool object we want valid stencils for * @return {$.oStencil[]} the list of stencils compatible with the specified tool */ diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_attribute.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_attribute.js index d4d2d791ae..fa044d5b74 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_attribute.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_attribute.js @@ -4,7 +4,7 @@ // openHarmony Library v0.01 // // -// Developped by Mathieu Chaptel, Chris Fourney... +// Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -338,7 +338,7 @@ Object.defineProperty($.oAttribute.prototype, "useSeparate", { * Returns the default value of the attribute for most keywords * @name $.oAttribute#defaultValue * @type {bool} - * @todo switch the implentation to types? + * @todo switch the implementation to types? * @example * // to reset an attribute to its default value: * // (mostly used for position/angle/skew parameters of pegs and drawing nodes) @@ -449,7 +449,7 @@ $.oAttribute.prototype.getLinkedColumns = function(){ /** * Recursively sets an attribute to the same value as another. Both must have the same keyword. - * @param {bool} [duplicateColumns=false] In the case that the attribute has a column, wether to duplicate the column before linking + * @param {bool} [duplicateColumns=false] In the case that the attribute has a column, whether to duplicate the column before linking * @private */ $.oAttribute.prototype.setToAttributeValue = function(attributeToCopy, duplicateColumns){ diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_backdrop.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_backdrop.js index c98e194539..1d359f93c4 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_backdrop.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_backdrop.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_color.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_color.js index 7726be6cd6..ff06688e66 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_color.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_color.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -158,7 +158,7 @@ $.oColorValue.prototype.fromColorString = function (hexString){ /** - * Uses a color integer (used in backdrops) and parses the INT; applies the RGBA components of the INT to thos oColorValue + * Uses a color integer (used in backdrops) and parses the INT; applies the RGBA components of the INT to the oColorValue * @param { int } colorInt 24 bit-shifted integer containing RGBA values */ $.oColorValue.prototype.parseColorFromInt = function(colorInt){ diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_column.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_column.js index 1b73c7943e..f73309049e 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_column.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_column.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_database.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_database.js index 73964c5c38..5440b92875 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_database.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_database.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_dialog.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_dialog.js index a6e16ecb78..3ab78b87d6 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_dialog.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_dialog.js @@ -5,7 +5,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -17,7 +17,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -250,7 +250,7 @@ $.oDialog.prototype.prompt = function( labelText, title, prefilledText){ /** * Prompts with a file selector window * @param {string} [text="Select a file:"] The title of the confirmation dialog. - * @param {string} [filter="*"] The filter for the file type and/or file name that can be selected. Accepts wildcard charater "*". + * @param {string} [filter="*"] The filter for the file type and/or file name that can be selected. Accepts wildcard character "*". * @param {string} [getExisting=true] Whether to select an existing file or a save location * @param {string} [acceptMultiple=false] Whether or not selecting more than one file is ok. Is ignored if getExisting is falses. * @param {string} [startDirectory] The directory showed at the opening of the dialog. @@ -327,14 +327,14 @@ $.oDialog.prototype.browseForFolder = function(text, startDirectory){ * @constructor * @classdesc An simple progress dialog to display the progress of a task. * To react to the user clicking the cancel button, connect a function to $.oProgressDialog.canceled() signal. - * When $.batchmode is true, the progress will be outputed as a "Progress : value/range" string to the Harmony stdout. + * When $.batchmode is true, the progress will be outputted as a "Progress : value/range" string to the Harmony stdout. * @param {string} [labelText] The text displayed above the progress bar. * @param {string} [range=100] The maximum value that represents a full progress bar. * @param {string} [title] The title of the dialog * @param {bool} [show=false] Whether to immediately show the dialog. * * @property {bool} wasCanceled Whether the progress bar was cancelled. - * @property {$.oSignal} canceled A Signal emited when the dialog is canceled. Can be connected to a callback. + * @property {$.oSignal} canceled A Signal emitted when the dialog is canceled. Can be connected to a callback. */ $.oProgressDialog = function( labelText, range, title, show ){ if (typeof title === 'undefined') var title = "Progress"; @@ -608,7 +608,7 @@ $.oPieMenu = function( name, widgets, show, minAngle, maxAngle, radius, position this.maxAngle = maxAngle; this.globalCenter = position; - // how wide outisde the icons is the slice drawn + // how wide outside the icons is the slice drawn this._circleMargin = 30; // set these values before calling show() to customize the menu appearance @@ -974,7 +974,7 @@ $.oPieMenu.prototype.getMenuRadius = function(){ var _minRadius = UiLoader.dpiScale(30); var _speed = 10; // the higher the value, the slower the progression - // hyperbolic tangent function to determin the radius + // hyperbolic tangent function to determine the radius var exp = Math.exp(2*itemsNumber/_speed); var _radius = ((exp-1)/(exp+1))*_maxRadius+_minRadius; @@ -1383,7 +1383,7 @@ $.oActionButton.prototype.activate = function(){ * This class is a subclass of QPushButton and all the methods from that class are available to modify this button. * @param {string} paletteName The name of the palette that contains the color * @param {string} colorName The name of the color (if more than one is present, will pick the first match) - * @param {bool} showName Wether to display the name of the color on the button + * @param {bool} showName Whether to display the name of the color on the button * @param {QWidget} parent The parent QWidget for the button. Automatically set during initialisation of the menu. * */ @@ -1437,7 +1437,7 @@ $.oColorButton.prototype.activate = function(){ * @name $.oScriptButton * @constructor * @classdescription This subclass of QPushButton provides an easy way to create a button for a widget that will launch a function from another script file.
- * The buttons created this way automatically load the icon named after the script if it finds one named like the funtion in a script-icons folder next to the script file.
+ * The buttons created this way automatically load the icon named after the script if it finds one named like the function in a script-icons folder next to the script file.
* It will also automatically set the callback to lanch the function from the script.
* This class is a subclass of QPushButton and all the methods from that class are available to modify this button. * @param {string} scriptFile The path to the script file that will be launched diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_drawing.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_drawing.js index bad735f237..6f2bc19c0c 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_drawing.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_drawing.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -426,7 +426,7 @@ Object.defineProperty($.oDrawing.prototype, 'drawingData', { /** * Import a given file into an existing drawing. * @param {$.oFile} file The path to the file - * @param {bool} [convertToTvg=false] Wether to convert the bitmap to the tvg format (this doesn't vectorise the drawing) + * @param {bool} [convertToTvg=false] Whether to convert the bitmap to the tvg format (this doesn't vectorise the drawing) * * @return { $.oFile } the oFile object pointing to the drawing file after being it has been imported into the element folder. */ @@ -878,8 +878,8 @@ $.oArtLayer.prototype.drawCircle = function(center, radius, lineStyle, fillStyle * @param {$.oVertex[]} path an array of $.oVertex objects that describe a path. * @param {$.oLineStyle} [lineStyle] the line style to draw with. (By default, will use the current stencil selection) * @param {$.oFillStyle} [fillStyle] the fill information for the path. (By default, will use the current palette selection) - * @param {bool} [polygon] Wether bezier handles should be created for the points in the path (ignores "onCurve" properties of oVertex from path) - * @param {bool} [createUnderneath] Wether the new shape will appear on top or underneath the contents of the layer. (not working yet) + * @param {bool} [polygon] Whether bezier handles should be created for the points in the path (ignores "onCurve" properties of oVertex from path) + * @param {bool} [createUnderneath] Whether the new shape will appear on top or underneath the contents of the layer. (not working yet) */ $.oArtLayer.prototype.drawShape = function(path, lineStyle, fillStyle, polygon, createUnderneath){ if (typeof fillStyle === 'undefined') var fillStyle = new this.$.oFillStyle(); @@ -959,7 +959,7 @@ $.oArtLayer.prototype.drawContour = function(path, fillStyle){ * @param {float} width the width of the rectangle. * @param {float} height the height of the rectangle. * @param {$.oLineStyle} lineStyle a line style to use for the rectangle stroke. - * @param {$.oFillStyle} fillStyle a fill style to use for the rectange fill. + * @param {$.oFillStyle} fillStyle a fill style to use for the rectangle fill. * @returns {$.oShape} the shape containing the added stroke. */ $.oArtLayer.prototype.drawRectangle = function(x, y, width, height, lineStyle, fillStyle){ @@ -1514,7 +1514,7 @@ Object.defineProperty($.oStroke.prototype, "path", { /** - * The oVertex that are on the stroke (Bezier handles exluded.) + * The oVertex that are on the stroke (Bezier handles excluded.) * The first is repeated at the last position when the stroke is closed. * @name $.oStroke#points * @type {$.oVertex[]} @@ -1583,7 +1583,7 @@ Object.defineProperty($.oStroke.prototype, "style", { /** - * wether the stroke is a closed shape. + * whether the stroke is a closed shape. * @name $.oStroke#closed * @type {bool} */ @@ -1919,7 +1919,7 @@ $.oContour.prototype.toString = function(){ * @constructor * @classdesc * The $.oVertex class represents a single control point on a stroke. This class is used to get the index of the point in the stroke path sequence, as well as its position as a float along the stroke's length. - * The onCurve property describes wether this control point is a bezier handle or a point on the curve. + * The onCurve property describes whether this control point is a bezier handle or a point on the curve. * * @param {$.oStroke} stroke the stroke that this vertex belongs to * @param {float} x the x coordinate of the vertex, in drawing space diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_element.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_element.js index ed50d6e50b..b64c8169ec 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_element.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_element.js @@ -4,7 +4,7 @@ // openHarmony Library v0.01 // // -// Developped by Mathieu Chaptel, Chris Fourney... +// Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_file.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_file.js index 14dafa3b63..50e4b0d475 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_file.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_file.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -509,7 +509,7 @@ Object.defineProperty($.oFile.prototype, 'fullName', { /** - * The name of the file without extenstion. + * The name of the file without extension. * @name $.oFile#name * @type {string} */ diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_frame.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_frame.js index 37bdede02a..e1d1dd7fad 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_frame.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_frame.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -263,7 +263,7 @@ Object.defineProperty($.oFrame.prototype, 'duration', { return _sceneLength; } - // walk up the frames of the scene to the next keyFrame to determin duration + // walk up the frames of the scene to the next keyFrame to determine duration var _frames = this.column.frames for (var i=this.frameNumber+1; i<_sceneLength; i++){ if (_frames[i].isKeyframe) return _frames[i].frameNumber - _startFrame; @@ -426,7 +426,7 @@ Object.defineProperty($.oFrame.prototype, 'velocity', { * easeIn : a $.oPoint object representing the left handle for bezier columns, or a {point, ease} object for ease columns. * easeOut : a $.oPoint object representing the left handle for bezier columns, or a {point, ease} object for ease columns. * continuity : the type of bezier used by the point. - * constant : wether the frame is interpolated or a held value. + * constant : whether the frame is interpolated or a held value. * @name $.oFrame#ease * @type {oPoint/object} */ @@ -520,7 +520,7 @@ Object.defineProperty($.oFrame.prototype, 'easeOut', { /** - * Determines the frame's continuity setting. Can take the values "CORNER", (two independant bezier handles on each side), "SMOOTH"(handles are aligned) or "STRAIGHT" (no handles and in straight lines). + * Determines the frame's continuity setting. Can take the values "CORNER", (two independent bezier handles on each side), "SMOOTH"(handles are aligned) or "STRAIGHT" (no handles and in straight lines). * @name $.oFrame#continuity * @type {string} */ diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_list.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_list.js index 9d02b1c2aa..63a5c0eeb8 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_list.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_list.js @@ -4,7 +4,7 @@ // openHarmony Library v0.01 // // -// Developped by Mathieu Chaptel, Chris Fourney... +// Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -516,5 +516,5 @@ Object.defineProperty($.oList.prototype, 'toString', { -//Needs all filtering, limiting. mapping, pop, concat, join, ect +//Needs all filtering, limiting. mapping, pop, concat, join, etc //Speed up by finessing the way it extends and tracks the enumerable properties. \ No newline at end of file diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_math.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_math.js index c0d4ca99a7..06bfb51f30 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_math.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_math.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -193,7 +193,7 @@ $.oPoint.prototype.pointSubtract = function( sub_pt ){ /** * Subtracts the point to the coordinates of the current oPoint and returns a new oPoint with the result. * @param {$.oPoint} point The point to subtract to this point. - * @returns {$.oPoint} a new independant oPoint. + * @returns {$.oPoint} a new independent oPoint. */ $.oPoint.prototype.subtractPoint = function( point ){ var x = this.x - point.x; @@ -298,9 +298,9 @@ $.oPoint.prototype.convertToWorldspace = function(){ /** - * Linearily Interpolate between this (0.0) and the provided point (1.0) + * Linearly Interpolate between this (0.0) and the provided point (1.0) * @param {$.oPoint} point The target point at 100% - * @param {double} perc 0-1.0 value to linearily interp + * @param {double} perc 0-1.0 value to linearly interp * * @return: { $.oPoint } The interpolated value. */ @@ -410,9 +410,9 @@ $.oBox.prototype.include = function(box){ /** - * Checks wether the box contains another $.oBox. + * Checks whether the box contains another $.oBox. * @param {$.oBox} box The $.oBox to check for. - * @param {bool} [partial=false] wether to accept partially contained boxes. + * @param {bool} [partial=false] whether to accept partially contained boxes. */ $.oBox.prototype.contains = function(box, partial){ if (typeof partial === 'undefined') var partial = false; @@ -537,7 +537,7 @@ $.oMatrix.prototype.toString = function(){ * @classdesc The $.oVector is a replacement for the Vector3d objects of Harmony. * @param {float} x a x coordinate for this vector. * @param {float} y a y coordinate for this vector. - * @param {float} [z=0] a z coordinate for this vector. If ommited, will be set to 0 and vector will be 2D. + * @param {float} [z=0] a z coordinate for this vector. If omitted, will be set to 0 and vector will be 2D. */ $.oVector = function(x, y, z){ if (typeof z === "undefined" || isNaN(z)) var z = 0; diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_metadata.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_metadata.js index c19e6d12f4..29afeb522c 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_metadata.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_metadata.js @@ -4,7 +4,7 @@ // openHarmony Library v0.01 // // -// Developped by Mathieu Chaptel, Chris Fourney... +// Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_misc.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_misc.js index fec5d32816..6ef75f5560 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_misc.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_misc.js @@ -4,7 +4,7 @@ // openHarmony Library v0.01 // // -// Developped by Mathieu Chaptel, Chris Fourney... +// Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -54,7 +54,7 @@ /** - * The $.oUtils helper class -- providing generic utilities. Doesn't need instanciation. + * The $.oUtils helper class -- providing generic utilities. Doesn't need instantiation. * @classdesc $.oUtils utility Class */ $.oUtils = function(){ diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_network.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_network.js index a4476d7591..2a6aa3519a 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_network.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_network.js @@ -4,7 +4,7 @@ // openHarmony Library v0.01 // // -// Developped by Mathieu Chaptel, Chris Fourney... +// Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -87,7 +87,7 @@ $.oNetwork = function( ){ * @param {function} callback_func Providing a callback function prevents blocking, and will respond on this function. The callback function is in form func( results ){} * @param {bool} use_json In the event of a JSON api, this will return an object converted from the returned JSON. * - * @return: {string/object} The resulting object/string from the query -- otherwise a bool as false when an error occured.. + * @return: {string/object} The resulting object/string from the query -- otherwise a bool as false when an error occurred.. */ $.oNetwork.prototype.webQuery = function ( address, callback_func, use_json ){ if (typeof callback_func === 'undefined') var callback_func = false; @@ -272,7 +272,7 @@ $.oNetwork.prototype.webQuery = function ( address, callback_func, use_json ){ * @param {function} path The local file path to save the download. * @param {bool} replace Replace the file if it exists. * - * @return: {string/object} The resulting object/string from the query -- otherwise a bool as false when an error occured.. + * @return: {string/object} The resulting object/string from the query -- otherwise a bool as false when an error occurred.. */ $.oNetwork.prototype.downloadSingle = function ( address, path, replace ){ if (typeof replace === 'undefined') var replace = false; diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_node.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_node.js index 5590d7b7e9..deb1854357 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_node.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_node.js @@ -4,7 +4,7 @@ // openHarmony Library // // -// Developped by Mathieu Chaptel, Chris Fourney +// Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -562,7 +562,7 @@ Object.defineProperty($.oNode.prototype, 'height', { /** - * The list of oNodeLinks objects descibing the connections to the inport of this node, in order of inport. + * The list of oNodeLinks objects describing the connections to the inport of this node, in order of inport. * @name $.oNode#inLinks * @readonly * @deprecated returns $.oNodeLink instances but $.oLink is preferred. Use oNode.getInLinks() instead. @@ -658,7 +658,7 @@ Object.defineProperty($.oNode.prototype, 'outPorts', { /** - * The list of oNodeLinks objects descibing the connections to the outports of this node, in order of outport. + * The list of oNodeLinks objects describing the connections to the outports of this node, in order of outport. * @name $.oNode#outLinks * @readonly * @type {$.oNodeLink[]} @@ -1666,7 +1666,7 @@ $.oNode.prototype.refreshAttributes = function( ){ * It represents peg nodes in the scene. * @constructor * @augments $.oNode - * @classdesc Peg Moudle Class + * @classdesc Peg Module Class * @param {string} path Path to the node in the network. * @param {oScene} oSceneObject Access to the oScene object of the DOM. */ @@ -1886,7 +1886,7 @@ $.oDrawingNode.prototype.getDrawingAtFrame = function(frameNumber){ /** - * Gets the list of palettes containing colors used by a drawing node. This only gets palettes with the first occurence of the colors. + * Gets the list of palettes containing colors used by a drawing node. This only gets palettes with the first occurrence of the colors. * @return {$.oPalette[]} The palettes that contain the color IDs used by the drawings of the node. */ $.oDrawingNode.prototype.getUsedPalettes = function(){ @@ -1968,7 +1968,7 @@ $.oDrawingNode.prototype.unlinkPalette = function(oPaletteObject){ * Duplicates a node by creating an independent copy. * @param {string} [newName] The new name for the duplicated node. * @param {oPoint} [newPosition] The new position for the duplicated node. - * @param {bool} [duplicateElement] Wether to also duplicate the element. + * @param {bool} [duplicateElement] Whether to also duplicate the element. */ $.oDrawingNode.prototype.duplicate = function(newName, newPosition, duplicateElement){ if (typeof newPosition === 'undefined') var newPosition = this.nodePosition; @@ -2464,7 +2464,7 @@ $.oGroupNode.prototype.getNodeByName = function(name){ * Returns all the nodes of a certain type in the group. * Pass a value to recurse to look into the groups as well. * @param {string} typeName The type of the nodes. - * @param {bool} recurse Wether to look inside the groups. + * @param {bool} recurse Whether to look inside the groups. * * @return {$.oNode[]} The nodes found. */ @@ -2626,7 +2626,7 @@ $.oGroupNode.prototype.orderNodeView = function(recurse){ * * peg.linkOutNode(drawingNode); * - * //through all this we didn't specify nodePosition parameters so we'll sort evertything at once + * //through all this we didn't specify nodePosition parameters so we'll sort everything at once * * sceneRoot.orderNodeView(); * @@ -3333,7 +3333,7 @@ $.oGroupNode.prototype.importImageAsTVG = function(path, alignment, nodePosition * imports an image sequence as a node into the current group. * @param {$.oFile[]} imagePaths a list of paths to the images to import (can pass a list of strings or $.oFile) * @param {number} [exposureLength=1] the number of frames each drawing should be exposed at. If set to 0/false, each drawing will use the numbering suffix of the file to set its frame. - * @param {boolean} [convertToTvg=false] wether to convert the files to tvg during import + * @param {boolean} [convertToTvg=false] whether to convert the files to tvg during import * @param {string} [alignment="ASIS"] the alignment to apply to the node * @param {$.oPoint} [nodePosition] the position of the node in the nodeview * @@ -3346,7 +3346,7 @@ $.oGroupNode.prototype.importImageSequence = function(imagePaths, exposureLength if (typeof extendScene === 'undefined') var extendScene = false; - // match anything but capture trailing numbers and separates punctuation preceeding it + // match anything but capture trailing numbers and separates punctuation preceding it var numberingRe = /(.*?)([\W_]+)?(\d*)$/i; // sanitize imagePaths diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_nodeLink.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_nodeLink.js index 279a871691..07a4d147da 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_nodeLink.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_nodeLink.js @@ -4,7 +4,7 @@ // openHarmony Library v0.01 // // -// Developped by Mathieu Chaptel, Chris Fourney... +// Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -174,7 +174,7 @@ Object.defineProperty($.oNodeLink.prototype, 'outNode', { return; } - this.apply(); // do we really want to apply everytime we set? + this.apply(); // do we really want to apply every time we set? } }); @@ -198,7 +198,7 @@ Object.defineProperty($.oNodeLink.prototype, 'inNode', { return; } - this.apply(); // do we really want to apply everytime we set? + this.apply(); // do we really want to apply every time we set? } }); @@ -222,7 +222,7 @@ Object.defineProperty($.oNodeLink.prototype, 'outPort', { return; } - this.apply(); // do we really want to apply everytime we set? + this.apply(); // do we really want to apply every time we set? } }); @@ -256,7 +256,7 @@ Object.defineProperty($.oNodeLink.prototype, 'inPort', { return; } - this.apply(); // do we really want to apply everytime we set? + this.apply(); // do we really want to apply every time we set? } }); @@ -983,7 +983,7 @@ $.oNodeLink.prototype.validate = function ( ) { * @return {bool} Whether the connection is a valid connection that exists currently in the node system. */ $.oNodeLink.prototype.validateUpwards = function( inport, outportProvided ) { - //IN THE EVENT OUTNODE WASNT PROVIDED. + //IN THE EVENT OUTNODE WASN'T PROVIDED. this.path = this.findInputPath( this._inNode, inport, [] ); if( !this.path || this.path.length == 0 ){ return false; @@ -1173,7 +1173,7 @@ Object.defineProperty($.oLink.prototype, 'outPort', { /** - * The index of the link comming out of the out-port. + * The index of the link coming out of the out-port. *
In the event this value wasn't known by the link object but the link is actually connected, the correct value will be found. * @name $.oLink#outLink * @readonly @@ -1323,7 +1323,7 @@ $.oLink.prototype.getValidLink = function(createOutPorts, createInPorts){ /** - * Attemps to connect a link. Will guess the ports if not provided. + * Attempts to connect a link. Will guess the ports if not provided. * @return {bool} */ $.oLink.prototype.connect = function(){ @@ -1623,11 +1623,11 @@ $.oLinkPath.prototype.findExistingPath = function(){ /** - * Gets a link object from two nodes that can be succesfully connected. Provide port numbers if there are specific requirements to match. If a link already exists, it will be returned. + * Gets a link object from two nodes that can be successfully connected. Provide port numbers if there are specific requirements to match. If a link already exists, it will be returned. * @param {$.oNode} start The node from which the link originates. * @param {$.oNode} end The node at which the link ends. - * @param {int} [outPort] A prefered out-port for the link to use. - * @param {int} [inPort] A prefered in-port for the link to use. + * @param {int} [outPort] A preferred out-port for the link to use. + * @param {int} [inPort] A preferred in-port for the link to use. * * @return {$.oLink} the valid $.oLink object. Returns null if no such link could be created (for example if the node's in-port is already linked) */ diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony_tools.js b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony_tools.js index 57d4a63e96..9014929fc4 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony_tools.js +++ b/openpype/hosts/harmony/vendor/OpenHarmony/openHarmony_tools.js @@ -4,7 +4,7 @@ // openHarmony Library v0.01 // // -// Developped by Mathieu Chaptel, ... +// Developed by Mathieu Chaptel, ... // // // This library is an open source implementation of a Document Object Model @@ -16,7 +16,7 @@ // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every -// function has been implemented or is garanteed to work. Feel free to contribute +// function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // @@ -212,7 +212,7 @@ function openHarmony_toolInstaller(){ //---------------------------------------------- - //-- GET THE FILE CONTENTS IN A DIRCTORY ON GIT + //-- GET THE FILE CONTENTS IN A DIRECTORY ON GIT this.recurse_files = function( contents, arr_files ){ with( context.$.global ){ try{ @@ -501,7 +501,7 @@ function openHarmony_toolInstaller(){ var download_item = item["download_url"]; var query = $.network.webQuery( download_item, false, false ); if( query ){ - //INSTALL TYPES ARE script, package, ect. + //INSTALL TYPES ARE script, package, etc. if( install_types[ m.install_cache[ item["url"] ] ] ){ m.installLabel.text = install_types[ m.install_cache[ item["url"] ] ]; diff --git a/openpype/hosts/harmony/vendor/OpenHarmony/package.json b/openpype/hosts/harmony/vendor/OpenHarmony/package.json index c62ecbc9d8..7a535cdcf6 100644 --- a/openpype/hosts/harmony/vendor/OpenHarmony/package.json +++ b/openpype/hosts/harmony/vendor/OpenHarmony/package.json @@ -1,7 +1,7 @@ { "name": "openharmony", "version": "0.0.1", - "description": "An Open Source Imlementation of a Document Object Model for the Toonboom Harmony scripting interface", + "description": "An Open Source Implementation of a Document Object Model for the Toonboom Harmony scripting interface", "main": "openHarmony.js", "scripts": { "test": "$", diff --git a/openpype/hosts/hiero/api/__init__.py b/openpype/hosts/hiero/api/__init__.py index 1fa40c9f74..b95c0fe1d7 100644 --- a/openpype/hosts/hiero/api/__init__.py +++ b/openpype/hosts/hiero/api/__init__.py @@ -108,7 +108,7 @@ __all__ = [ "apply_colorspace_project", "apply_colorspace_clips", "get_sequence_pattern_and_padding", - # depricated + # deprecated "get_track_item_pype_tag", "set_track_item_pype_tag", "get_track_item_pype_data", diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index bbd1edc14a..0d4368529f 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -1221,7 +1221,7 @@ def set_track_color(track_item, color): def check_inventory_versions(track_items=None): """ - Actual version color idetifier of Loaded containers + Actual version color identifier of Loaded containers Check all track items and filter only Loader nodes for its version. It will get all versions from database @@ -1249,10 +1249,10 @@ def check_inventory_versions(track_items=None): project_name = legacy_io.active_project() filter_result = filter_containers(containers, project_name) for container in filter_result.latest: - set_track_color(container["_item"], clip_color) + set_track_color(container["_item"], clip_color_last) for container in filter_result.outdated: - set_track_color(container["_item"], clip_color_last) + set_track_color(container["_item"], clip_color) def selection_changed_timeline(event): diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index 4ab73e7d19..d88aeac810 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -193,8 +193,8 @@ def parse_container(item, validate=True): return # convert the data to list and validate them for _, obj_data in _data.items(): - cotnainer = data_to_container(item, obj_data) - return_list.append(cotnainer) + container = data_to_container(item, obj_data) + return_list.append(container) return return_list else: _data = lib.get_trackitem_openpype_data(item) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 07457db1a4..a3f8a6c524 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -146,6 +146,8 @@ class CreatorWidget(QtWidgets.QDialog): return " ".join([str(m.group(0)).capitalize() for m in matches]) def create_row(self, layout, type, text, **kwargs): + value_keys = ["setText", "setCheckState", "setValue", "setChecked"] + # get type attribute from qwidgets attr = getattr(QtWidgets, type) @@ -167,14 +169,27 @@ class CreatorWidget(QtWidgets.QDialog): # assign the created attribute to variable item = getattr(self, attr_name) + + # set attributes to item which are not values for func, val in kwargs.items(): + if func in value_keys: + continue + if getattr(item, func): + log.debug("Setting {} to {}".format(func, val)) func_attr = getattr(item, func) if isinstance(val, tuple): func_attr(*val) else: func_attr(val) + # set values to item + for value_item in value_keys: + if value_item not in kwargs: + continue + if getattr(item, value_item): + getattr(item, value_item)(kwargs[value_item]) + # add to layout layout.addRow(label, item) @@ -276,8 +291,11 @@ class CreatorWidget(QtWidgets.QDialog): elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], - setValue=v["value"], setMinimum=0, + setValue=v["value"], + setDisplayIntegerBase=10000, + setRange=(0, 99999), setMinimum=0, setMaximum=100000, setToolTip=tool_tip) + return data @@ -393,7 +411,7 @@ class ClipLoader: self.with_handles = options.get("handles") or bool( options.get("handles") is True) # try to get value from options or evaluate key value for `load_how` - self.sequencial_load = options.get("sequencially") or bool( + self.sequencial_load = options.get("sequentially") or bool( "Sequentially in order" in options.get("load_how", "")) # try to get value from options or evaluate key value for `load_to` self.new_sequence = options.get("newSequence") or bool( @@ -818,7 +836,7 @@ class PublishClip: # increasing steps by index of rename iteration self.count_steps *= self.rename_index - hierarchy_formating_data = {} + hierarchy_formatting_data = {} hierarchy_data = deepcopy(self.hierarchy_data) _data = self.track_item_default_data.copy() if self.ui_inputs: @@ -853,13 +871,13 @@ class PublishClip: # fill up pythonic expresisons in hierarchy data for k, _v in hierarchy_data.items(): - hierarchy_formating_data[k] = _v["value"].format(**_data) + hierarchy_formatting_data[k] = _v["value"].format(**_data) else: # if no gui mode then just pass default data - hierarchy_formating_data = hierarchy_data + hierarchy_formatting_data = hierarchy_data tag_hierarchy_data = self._solve_tag_hierarchy_data( - hierarchy_formating_data + hierarchy_formatting_data ) tag_hierarchy_data.update({"heroTrack": True}) @@ -887,20 +905,20 @@ class PublishClip: # add data to return data dict self.tag_data.update(tag_hierarchy_data) - def _solve_tag_hierarchy_data(self, hierarchy_formating_data): + def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve tag data from hierarchy data and templates. """ # fill up clip name and hierarchy keys - hierarchy_filled = self.hierarchy.format(**hierarchy_formating_data) - clip_name_filled = self.clip_name.format(**hierarchy_formating_data) + hierarchy_filled = self.hierarchy.format(**hierarchy_formatting_data) + clip_name_filled = self.clip_name.format(**hierarchy_formatting_data) # remove shot from hierarchy data: is not needed anymore - hierarchy_formating_data.pop("shot") + hierarchy_formatting_data.pop("shot") return { "newClipName": clip_name_filled, "hierarchy": hierarchy_filled, "parents": self.parents, - "hierarchyData": hierarchy_formating_data, + "hierarchyData": hierarchy_formatting_data, "subset": self.subset, "family": self.subset_family, "families": [self.data["family"]] @@ -916,16 +934,16 @@ class PublishClip: ) # first collect formatting data to use for formatting template - formating_data = {} + formatting_data = {} for _k, _v in self.hierarchy_data.items(): value = _v["value"].format( **self.track_item_default_data) - formating_data[_k] = value + formatting_data[_k] = value return { "entity_type": entity_type, "entity_name": template.format( - **formating_data + **formatting_data ) } diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index f0985973a6..340a7f0770 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -60,7 +60,7 @@ class Creator(LegacyCreator): def process(self): instance = super(CreateEpicNode, self, process() - # Set paramaters for Alembic node + # Set parameters for Alembic node instance.setParms( {"sop_path": "$HIP/%s.abc" % self.nodes[0]} ) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index ebd668e9e4..6e0f367f62 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -69,7 +69,7 @@ def generate_shelves(): mandatory_attributes = {'label', 'script'} for tool_definition in shelf_definition.get('tools_list'): - # We verify that the name and script attibutes of the tool + # We verify that the name and script attributes of the tool # are set if not all( tool_definition[key] for key in mandatory_attributes diff --git a/openpype/hosts/houdini/plugins/create/convert_legacy.py b/openpype/hosts/houdini/plugins/create/convert_legacy.py index 4b8041b4f5..e549c9dc26 100644 --- a/openpype/hosts/houdini/plugins/create/convert_legacy.py +++ b/openpype/hosts/houdini/plugins/create/convert_legacy.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Convertor for legacy Houdini subsets.""" +"""Converter for legacy Houdini subsets.""" from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin from openpype.hosts.houdini.api.lib import imprint @@ -7,7 +7,7 @@ from openpype.hosts.houdini.api.lib import imprint class HoudiniLegacyConvertor(SubsetConvertorPlugin): """Find and convert any legacy subsets in the scene. - This Convertor will find all legacy subsets in the scene and will + This Converter will find all legacy subsets in the scene and will transform them to the current system. Since the old subsets doesn't retain any information about their original creators, the only mapping we can do is based on their families. diff --git a/openpype/hosts/houdini/plugins/publish/collect_current_file.py b/openpype/hosts/houdini/plugins/publish/collect_current_file.py index 9cca07fdc7..caf679f98b 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_current_file.py +++ b/openpype/hosts/houdini/plugins/publish/collect_current_file.py @@ -1,7 +1,6 @@ import os import hou -from openpype.pipeline import legacy_io import pyblish.api @@ -11,7 +10,7 @@ class CollectHoudiniCurrentFile(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder - 0.01 label = "Houdini Current File" hosts = ["houdini"] - family = ["workfile"] + families = ["workfile"] def process(self, instance): """Inject the current working file""" @@ -21,7 +20,7 @@ class CollectHoudiniCurrentFile(pyblish.api.InstancePlugin): # By default, Houdini will even point a new scene to a path. # However if the file is not saved at all and does not exist, # we assume the user never set it. - filepath = "" + current_file = "" elif os.path.basename(current_file) == "untitled.hip": # Due to even a new file being called 'untitled.hip' we are unable diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 8e24ce3b99..47a4653d5d 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -66,8 +66,8 @@ host_tools.show_workfiles(parent) ]]> - - + + dict: + """Get the current assets frame range and handles. + + Returns: + dict: with frame start, frame end, handle start, handle end. + """ + # Set frame start/end + asset = get_current_project_asset() + frame_start = asset["data"].get("frameStart") + frame_end = asset["data"].get("frameEnd") + # Backwards compatibility + if frame_start is None or frame_end is None: + frame_start = asset["data"].get("edit_in") + frame_end = asset["data"].get("edit_out") + if frame_start is None or frame_end is None: + return + handles = asset["data"].get("handles") or 0 + handle_start = asset["data"].get("handleStart") + if handle_start is None: + handle_start = handles + handle_end = asset["data"].get("handleEnd") + if handle_end is None: + handle_end = handles + return { + "frameStart": frame_start, + "frameEnd": frame_end, + "handleStart": handle_start, + "handleEnd": handle_end + } + + +def reset_frame_range(fps: bool = True): + """Set frame range to current asset. + This is part of 3dsmax documentation: + + animationRange: A System Global variable which lets you get and + set an Interval value that defines the start and end frames + of the Active Time Segment. + frameRate: A System Global variable which lets you get + and set an Integer value that defines the current + scene frame rate in frames-per-second. + """ + if fps: + data_fps = get_current_project(fields=["data.fps"]) + 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}" + rt.execute(frange_cmd) + + +def set_context_setting(): + """Apply the project settings from the project definition + + Settings can be overwritten by an asset if the asset.data contains + any information regarding those settings. + + Examples of settings: + frame range + resolution + + Returns: + None + """ + reset_scene_resolution() + + def get_max_version(): """ Args: diff --git a/openpype/hosts/max/api/lib_renderproducts.py b/openpype/hosts/max/api/lib_renderproducts.py index a74a6a7426..350eb97661 100644 --- a/openpype/hosts/max/api/lib_renderproducts.py +++ b/openpype/hosts/max/api/lib_renderproducts.py @@ -8,6 +8,7 @@ from openpype.hosts.max.api.lib import ( get_current_renderer, get_default_render_folder ) +from openpype.pipeline.context_tools import get_current_project_asset from openpype.settings import get_project_settings from openpype.pipeline import legacy_io @@ -34,14 +35,20 @@ class RenderProducts(object): filename, container) + context = get_current_project_asset() + startFrame = context["data"].get("frameStart") + endFrame = context["data"].get("frameEnd") + 1 + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa - full_render_list = [] - beauty = self.beauty_render_product(output_file, img_fmt) - full_render_list.append(beauty) + full_render_list = self.beauty_render_product(output_file, + startFrame, + endFrame, + img_fmt) renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] + if renderer == "VUE_File_Renderer": return full_render_list @@ -54,6 +61,8 @@ class RenderProducts(object): "Quicksilver_Hardware_Renderer", ]: render_elem_list = self.render_elements_product(output_file, + startFrame, + endFrame, img_fmt) if render_elem_list: full_render_list.extend(iter(render_elem_list)) @@ -61,18 +70,24 @@ class RenderProducts(object): if renderer == "Arnold": aov_list = self.arnold_render_product(output_file, + startFrame, + endFrame, img_fmt) if aov_list: full_render_list.extend(iter(aov_list)) return full_render_list - def beauty_render_product(self, folder, fmt): - beauty_output = f"{folder}.####.{fmt}" - beauty_output = beauty_output.replace("\\", "/") - return beauty_output + def beauty_render_product(self, folder, startFrame, endFrame, fmt): + beauty_frame_range = [] + for f in range(startFrame, endFrame): + beauty_output = f"{folder}.{f}.{fmt}" + beauty_output = beauty_output.replace("\\", "/") + beauty_frame_range.append(beauty_output) + + return beauty_frame_range # TODO: Get the arnold render product - def arnold_render_product(self, folder, fmt): + def arnold_render_product(self, folder, startFrame, endFrame, fmt): """Get all the Arnold AOVs""" aovs = [] @@ -85,15 +100,17 @@ class RenderProducts(object): for i in range(aov_group_num): # get the specific AOV group for aov in aov_mgr.drivers[i].aov_list: - render_element = f"{folder}_{aov.name}.####.{fmt}" - render_element = render_element.replace("\\", "/") - aovs.append(render_element) + for f in range(startFrame, endFrame): + render_element = f"{folder}_{aov.name}.{f}.{fmt}" + render_element = render_element.replace("\\", "/") + aovs.append(render_element) + # close the AOVs manager window amw.close() return aovs - def render_elements_product(self, folder, fmt): + def render_elements_product(self, folder, startFrame, endFrame, fmt): """Get all the render element output files. """ render_dirname = [] @@ -104,9 +121,10 @@ class RenderProducts(object): renderlayer_name = render_elem.GetRenderElement(i) target, renderpass = str(renderlayer_name).split(":") if renderlayer_name.enabled: - render_element = f"{folder}_{renderpass}.####.{fmt}" - render_element = render_element.replace("\\", "/") - render_dirname.append(render_element) + for f in range(startFrame, endFrame): + render_element = f"{folder}_{renderpass}.{f}.{fmt}" + render_element = render_element.replace("\\", "/") + render_dirname.append(render_element) return render_dirname diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index 5c273b49b4..066cc90039 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -4,6 +4,7 @@ from qtpy import QtWidgets, QtCore from pymxs import runtime as rt from openpype.tools.utils import host_tools +from openpype.hosts.max.api import lib class OpenPypeMenu(object): @@ -107,6 +108,17 @@ class OpenPypeMenu(object): workfiles_action = QtWidgets.QAction("Work Files...", openpype_menu) workfiles_action.triggered.connect(self.workfiles_callback) openpype_menu.addAction(workfiles_action) + + openpype_menu.addSeparator() + + res_action = QtWidgets.QAction("Set Resolution", openpype_menu) + res_action.triggered.connect(self.resolution_callback) + openpype_menu.addAction(res_action) + + frame_action = QtWidgets.QAction("Set Frame Range", openpype_menu) + frame_action.triggered.connect(self.frame_range_callback) + openpype_menu.addAction(frame_action) + return openpype_menu def load_callback(self): @@ -128,3 +140,11 @@ class OpenPypeMenu(object): def workfiles_callback(self): """Callback to show Workfiles tool.""" host_tools.show_workfiles(parent=self.main_widget) + + def resolution_callback(self): + """Callback to reset scene resolution""" + return lib.reset_scene_resolution() + + def frame_range_callback(self): + """Callback to reset frame range""" + return lib.reset_frame_range() diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index f8a7b8ea5c..dacc402318 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -50,6 +50,11 @@ class MaxHost(HostBase, IWorkfileHost, ILoadHost, INewPublisher): self._has_been_setup = True + def context_setting(): + return lib.set_context_setting() + rt.callbacks.addScript(rt.Name('systemPostNew'), + context_setting) + def has_unsaved_changes(self): # TODO: how to get it from 3dsmax? return True diff --git a/openpype/hosts/max/plugins/create/create_maxScene.py b/openpype/hosts/max/plugins/create/create_maxScene.py new file mode 100644 index 0000000000..7900336f32 --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_maxScene.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating raw max scene.""" +from openpype.hosts.max.api import plugin +from openpype.pipeline import CreatedInstance + + +class CreateMaxScene(plugin.MaxCreator): + identifier = "io.openpype.creators.max.maxScene" + label = "Max Scene" + family = "maxScene" + icon = "gear" + + def create(self, subset_name, instance_data, pre_create_data): + from pymxs import runtime as rt + sel_obj = list(rt.selection) + instance = super(CreateMaxScene, 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 + for obj in sel_obj: + obj.parent = container + # for additional work on the node: + # instance_node = rt.getNodeByName(instance.get("instance_node")) diff --git a/openpype/hosts/max/plugins/create/create_pointcloud.py b/openpype/hosts/max/plugins/create/create_pointcloud.py new file mode 100644 index 0000000000..c83acac3df --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_pointcloud.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating point cloud.""" +from openpype.hosts.max.api import plugin +from openpype.pipeline import CreatedInstance + + +class CreatePointCloud(plugin.MaxCreator): + identifier = "io.openpype.creators.max.pointcloud" + label = "Point Cloud" + family = "pointcloud" + icon = "gear" + + def create(self, subset_name, instance_data, pre_create_data): + from pymxs import runtime as rt + sel_obj = list(rt.selection) + instance = super(CreatePointCloud, 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 + for obj in sel_obj: + obj.parent = container + # for additional work on the node: + # instance_node = rt.getNodeByName(instance.get("instance_node")) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index b863b9363f..460f4822a6 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -9,7 +9,8 @@ from openpype.hosts.max.api import lib class MaxSceneLoader(load.LoaderPlugin): """Max Scene Loader""" - families = ["camera"] + families = ["camera", + "maxScene"] representations = ["max"] order = -8 icon = "code-fork" @@ -46,8 +47,7 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) - - max_objects = self.get_container_children(node) + max_objects = node.Children for max_object in max_objects: max_object.source = path diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py new file mode 100644 index 0000000000..27bc88b4f3 --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -0,0 +1,51 @@ +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 + + +class PointCloudLoader(load.LoaderPlugin): + """Point Cloud Loader""" + + families = ["pointcloud"] + representations = ["prt"] + order = -8 + icon = "code-fork" + color = "green" + + def load(self, context, name=None, namespace=None, data=None): + """load point cloud by tyCache""" + from pymxs import runtime as rt + + filepath = os.path.normpath(self.fname) + obj = rt.tyCache() + obj.filename = filepath + + prt_container = rt.getNodeByName(f"{obj.name}") + + return containerise( + name, [prt_container], context, loader=self.__class__.__name__) + + def update(self, container, representation): + """update the container""" + from pymxs import runtime as rt + + path = get_representation_path(representation) + node = rt.getNodeByName(container["instance_node"]) + + prt_objects = self.get_container_children(node) + for prt_object in prt_objects: + prt_object.source = path + + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) + + def remove(self, container): + """remove the container""" + from pymxs import runtime as rt + + node = rt.getNodeByName(container["instance_node"]) + rt.delete(node) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 7c9e311c2f..63e4108c84 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -61,7 +61,7 @@ class CollectRender(pyblish.api.InstancePlugin): "plugin": "3dsmax", "frameStart": context.data['frameStart'], "frameEnd": context.data['frameEnd'], - "version": version_int + "version": version_int, } self.log.info("data: {0}".format(data)) instance.data.update(data) diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py index cacc84c591..969f87be48 100644 --- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py +++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py @@ -20,7 +20,8 @@ class ExtractMaxSceneRaw(publish.Extractor, order = pyblish.api.ExtractorOrder - 0.2 label = "Extract Max Scene (Raw)" hosts = ["max"] - families = ["camera"] + families = ["camera", + "maxScene"] optional = True def process(self, instance): diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py new file mode 100644 index 0000000000..e8d58ab713 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -0,0 +1,207 @@ +import os +import pyblish.api +from openpype.pipeline import publish +from pymxs import runtime as rt +from openpype.hosts.max.api import ( + maintained_selection +) +from openpype.settings import get_project_settings +from openpype.pipeline import legacy_io + + +def get_setting(project_setting=None): + project_setting = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + return (project_setting["max"]["PointCloud"]) + + +class ExtractPointCloud(publish.Extractor): + """ + Extract PRT format with tyFlow operators + + Notes: + Currently only works for the default partition setting + + Args: + export_particle(): sets up all job arguments for attributes + to be exported in MAXscript + + get_operators(): get the export_particle operator + + get_custom_attr(): get all custom channel attributes from Openpype + setting and sets it as job arguments before exporting + + get_files(): get the files with tyFlow naming convention + before publishing + + partition_output_name(): get the naming with partition settings. + get_partition(): get partition value + + """ + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Extract Point Cloud" + hosts = ["max"] + families = ["pointcloud"] + + def process(self, instance): + start = int(instance.context.data.get("frameStart")) + end = int(instance.context.data.get("frameEnd")) + container = instance.data["instance_node"] + self.log.info("Extracting PRT...") + + stagingdir = self.staging_dir(instance) + filename = "{name}.prt".format(**instance.data) + path = os.path.join(stagingdir, filename) + + with maintained_selection(): + job_args = self.export_particle(container, + start, + end, + path) + for job in job_args: + rt.execute(job) + + self.log.info("Performing Extraction ...") + if "representations" not in instance.data: + instance.data["representations"] = [] + + self.log.info("Writing PRT with TyFlow Plugin...") + filenames = self.get_files(container, path, start, end) + self.log.debug("filenames: {0}".format(filenames)) + + partition = self.partition_output_name(container) + + representation = { + 'name': 'prt', + 'ext': 'prt', + 'files': filenames if len(filenames) > 1 else filenames[0], + "stagingDir": stagingdir, + "outputName": partition # partition value + } + instance.data["representations"].append(representation) + self.log.info("Extracted instance '%s' to: %s" % (instance.name, + path)) + + def export_particle(self, + container, + start, + end, + filepath): + job_args = [] + opt_list = self.get_operators(container) + for operator in opt_list: + start_frame = "{0}.frameStart={1}".format(operator, + start) + job_args.append(start_frame) + end_frame = "{0}.frameEnd={1}".format(operator, + end) + job_args.append(end_frame) + filepath = filepath.replace("\\", "/") + prt_filename = '{0}.PRTFilename="{1}"'.format(operator, + filepath) + + job_args.append(prt_filename) + # Partition + mode = "{0}.PRTPartitionsMode=2".format(operator) + job_args.append(mode) + + additional_args = self.get_custom_attr(operator) + for args in additional_args: + job_args.append(args) + + prt_export = "{0}.exportPRT()".format(operator) + job_args.append(prt_export) + + return job_args + + def get_operators(self, container): + """Get Export Particles Operator""" + + opt_list = [] + node = rt.getNodebyName(container) + selection_list = list(node.Children) + for sel in selection_list: + obj = sel.baseobject + # TODO: to see if it can be used maxscript instead + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + sub_anim = rt.getsubanim(obj, anim_name) + boolean = rt.isProperty(sub_anim, "Export_Particles") + event_name = sub_anim.name + if boolean: + opt = "${0}.{1}.export_particles".format(sel.name, + event_name) + opt_list.append(opt) + + return opt_list + + def get_custom_attr(self, operator): + """Get Custom Attributes""" + + custom_attr_list = [] + attr_settings = get_setting()["attribute"] + for key, value in attr_settings.items(): + custom_attr = "{0}.PRTChannels_{1}=True".format(operator, + value) + self.log.debug( + "{0} will be added as custom attribute".format(key) + ) + custom_attr_list.append(custom_attr) + + return custom_attr_list + + def get_files(self, + container, + path, + start_frame, + end_frame): + """ + Note: + Set the filenames accordingly to the tyFlow file + naming extension for the publishing purpose + + Actual File Output from tyFlow: + __partof..prt + e.g. tyFlow_cloth_CCCS_blobbyFill_001__part1of1_00004.prt + """ + filenames = [] + filename = os.path.basename(path) + orig_name, ext = os.path.splitext(filename) + partition_count, partition_start = self.get_partition(container) + for frame in range(int(start_frame), int(end_frame) + 1): + actual_name = "{}__part{:03}of{}_{:05}".format(orig_name, + partition_start, + partition_count, + frame) + actual_filename = path.replace(orig_name, actual_name) + filenames.append(os.path.basename(actual_filename)) + + return filenames + + def partition_output_name(self, container): + """ + Notes: + Partition output name set for mapping + the published file output + + todo: + Customizes the setting for the output + """ + partition_count, partition_start = self.get_partition(container) + partition = "_part{:03}of{}".format(partition_start, + partition_count) + + return partition + + def get_partition(self, container): + """ + Get Partition Value + """ + opt_list = self.get_operators(container) + for operator in opt_list: + count = rt.execute(f'{operator}.PRTPartitionsCount') + start = rt.execute(f'{operator}.PRTPartitionsFrom') + + return count, start diff --git a/openpype/hosts/max/plugins/publish/increment_workfile_version.py b/openpype/hosts/max/plugins/publish/increment_workfile_version.py new file mode 100644 index 0000000000..3dec214f77 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/increment_workfile_version.py @@ -0,0 +1,19 @@ +import pyblish.api +from openpype.lib import version_up +from pymxs import runtime as rt + + +class IncrementWorkfileVersion(pyblish.api.ContextPlugin): + """Increment current workfile version.""" + + order = pyblish.api.IntegratorOrder + 0.9 + label = "Increment Workfile Version" + hosts = ["max"] + families = ["workfile"] + + def process(self, context): + path = context.data["currentFile"] + filepath = version_up(path) + + rt.saveMaxFile(filepath) + self.log.info("Incrementing file version") diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_no_max_content.py new file mode 100644 index 0000000000..c20a1968ed --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_no_max_content.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +from pymxs import runtime as rt + + +class ValidateMaxContents(pyblish.api.InstancePlugin): + """Validates Max contents. + + Check if MaxScene container includes any contents underneath. + """ + + order = pyblish.api.ValidatorOrder + families = ["camera", + "maxScene", + "maxrender"] + hosts = ["max"] + label = "Max Scene Contents" + + def process(self, instance): + container = rt.getNodeByName(instance.data["instance_node"]) + if not list(container.Children): + raise PublishValidationError("No content found in the container") diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py new file mode 100644 index 0000000000..f654058648 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -0,0 +1,191 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError +from pymxs import runtime as rt +from openpype.settings import get_project_settings +from openpype.pipeline import legacy_io + + +def get_setting(project_setting=None): + project_setting = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + return (project_setting["max"]["PointCloud"]) + + +class ValidatePointCloud(pyblish.api.InstancePlugin): + """Validate that workfile was saved.""" + + order = pyblish.api.ValidatorOrder + families = ["pointcloud"] + hosts = ["max"] + label = "Validate Point Cloud" + + def process(self, instance): + """ + Notes: + + 1. Validate the container only include tyFlow objects + 2. Validate if tyFlow operator Export Particle exists + 3. Validate if the export mode of Export Particle is at PRT format + 4. Validate the partition count and range set as default value + Partition Count : 100 + Partition Range : 1 to 1 + 5. Validate if the custom attribute(s) exist as parameter(s) + of export_particle operator + + """ + invalid = self.get_tyFlow_object(instance) + if invalid: + raise PublishValidationError("Non tyFlow object " + "found: {}".format(invalid)) + invalid = self.get_tyFlow_operator(instance) + if invalid: + raise PublishValidationError("tyFlow ExportParticle operator " + "not found: {}".format(invalid)) + + invalid = self.validate_export_mode(instance) + if invalid: + raise PublishValidationError("The export mode is not at PRT") + + invalid = self.validate_partition_value(instance) + if invalid: + raise PublishValidationError("tyFlow Partition setting is " + "not at the default value") + invalid = self.validate_custom_attribute(instance) + if invalid: + raise PublishValidationError("Custom Attribute not found " + ":{}".format(invalid)) + + def get_tyFlow_object(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow container " + "for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + sel_tmp = str(sel) + if rt.classOf(sel) in [rt.tyFlow, + rt.Editable_Mesh]: + if "tyFlow" not in sel_tmp: + invalid.append(sel) + else: + invalid.append(sel) + + return invalid + + def get_tyFlow_operator(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow object " + "for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + bool_list = [] + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.getsubanim(obj, anim_name) + # check if there is export particle operator + boolean = rt.isProperty(sub_anim, "Export_Particles") + bool_list.append(str(boolean)) + # if the export_particles property is not there + # it means there is not a "Export Particle" operator + if "True" not in bool_list: + self.log.error("Operator 'Export Particles' not found!") + invalid.append(sel) + + return invalid + + def validate_custom_attribute(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow custom " + "attributes for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.getsubanim(obj, anim_name) + # check if there is export particle operator + boolean = rt.isProperty(sub_anim, "Export_Particles") + event_name = sub_anim.name + if boolean: + opt = "${0}.{1}.export_particles".format(sel.name, + event_name) + attributes = get_setting()["attribute"] + for key, value in attributes.items(): + custom_attr = "{0}.PRTChannels_{1}".format(opt, + value) + try: + rt.execute(custom_attr) + except RuntimeError: + invalid.add(key) + + return invalid + + def validate_partition_value(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow partition " + "value for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.getsubanim(obj, anim_name) + # check if there is export particle operator + boolean = rt.isProperty(sub_anim, "Export_Particles") + event_name = sub_anim.name + if boolean: + opt = "${0}.{1}.export_particles".format(sel.name, + event_name) + count = rt.execute(f'{opt}.PRTPartitionsCount') + if count != 100: + invalid.append(count) + start = rt.execute(f'{opt}.PRTPartitionsFrom') + if start != 1: + invalid.append(start) + end = rt.execute(f'{opt}.PRTPartitionsTo') + if end != 1: + invalid.append(end) + + return invalid + + def validate_export_mode(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow export " + "mode for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.getsubanim(obj, anim_name) + # check if there is export particle operator + boolean = rt.isProperty(sub_anim, "Export_Particles") + event_name = sub_anim.name + if boolean: + opt = "${0}.{1}.export_particles".format(sel.name, + event_name) + export_mode = rt.execute(f'{opt}.exportMode') + if export_mode != 1: + invalid.append(export_mode) + + return invalid diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index 018340d86c..3e31875fd8 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -69,7 +69,7 @@ def _resolution_from_document(doc): resolution_width = doc["data"].get("resolution_width") resolution_height = doc["data"].get("resolution_height") - # Make sure both width and heigh are set + # Make sure both width and height are set if resolution_width is None or resolution_height is None: cmds.warning( "No resolution information found for \"{}\"".format(doc["name"]) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index a670737b66..1a62e7dbc3 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2099,29 +2099,40 @@ def get_frame_range(): } -def reset_frame_range(): - """Set frame range to current asset""" +def reset_frame_range(playback=True, render=True, fps=True): + """Set frame range to current asset - fps = convert_to_maya_fps( - float(legacy_io.Session.get("AVALON_FPS", 25)) - ) - set_scene_fps(fps) + Args: + playback (bool, Optional): Whether to set the maya timeline playback + frame range. Defaults to True. + render (bool, Optional): Whether to set the maya render frame range. + Defaults to True. + fps (bool, Optional): Whether to set scene FPS. Defaults to True. + """ + + if fps: + fps = convert_to_maya_fps( + float(legacy_io.Session.get("AVALON_FPS", 25)) + ) + set_scene_fps(fps) frame_range = get_frame_range() frame_start = frame_range["frameStart"] - int(frame_range["handleStart"]) frame_end = frame_range["frameEnd"] + int(frame_range["handleEnd"]) - cmds.playbackOptions(minTime=frame_start) - cmds.playbackOptions(maxTime=frame_end) - cmds.playbackOptions(animationStartTime=frame_start) - cmds.playbackOptions(animationEndTime=frame_end) - cmds.playbackOptions(minTime=frame_start) - cmds.playbackOptions(maxTime=frame_end) - cmds.currentTime(frame_start) + if playback: + cmds.playbackOptions(minTime=frame_start) + cmds.playbackOptions(maxTime=frame_end) + cmds.playbackOptions(animationStartTime=frame_start) + cmds.playbackOptions(animationEndTime=frame_end) + cmds.playbackOptions(minTime=frame_start) + cmds.playbackOptions(maxTime=frame_end) + cmds.currentTime(frame_start) - cmds.setAttr("defaultRenderGlobals.startFrame", frame_start) - cmds.setAttr("defaultRenderGlobals.endFrame", frame_end) + if render: + cmds.setAttr("defaultRenderGlobals.startFrame", frame_start) + cmds.setAttr("defaultRenderGlobals.endFrame", frame_end) def reset_scene_resolution(): @@ -2467,8 +2478,8 @@ def load_capture_preset(data=None): float(value[2]) / 255 ] disp_options[key] = value - else: - disp_options['displayGradient'] = True + elif key == "displayGradient": + disp_options[key] = value options['display_options'] = disp_options diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index a54256c59a..324496c964 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -339,7 +339,7 @@ class ARenderProducts: aov_tokens = ["", ""] def match_last(tokens, text): - """regex match the last occurence from a list of tokens""" + """regex match the last occurrence from a list of tokens""" pattern = "(?:.*)({})".format("|".join(tokens)) return re.search(pattern, text, re.IGNORECASE) @@ -1051,7 +1051,7 @@ class RenderProductsRedshift(ARenderProducts): def get_files(self, product): # When outputting AOVs we need to replace Redshift specific AOV tokens # with Maya render tokens for generating file sequences. We validate to - # a specific AOV fileprefix so we only need to accout for one + # a specific AOV fileprefix so we only need to account for one # replacement. if not product.multipart and product.driver: file_prefix = self._get_attr(product.driver + ".filePrefix") diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py index 7d44d4eaa6..eaa728a2f6 100644 --- a/openpype/hosts/maya/api/lib_rendersettings.py +++ b/openpype/hosts/maya/api/lib_rendersettings.py @@ -158,7 +158,7 @@ class RenderSettings(object): cmds.setAttr( "defaultArnoldDriver.mergeAOVs", multi_exr) self._additional_attribs_setter(additional_options) - reset_frame_range() + reset_frame_range(playback=False, fps=False, render=True) def _set_redshift_settings(self, width, height): """Sets settings for Redshift.""" diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index efd9d414b6..5284c0249d 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -112,12 +112,12 @@ def install(): ) cmds.menuItem( - "Reset Frame Range", + "Set Frame Range", command=lambda *args: lib.reset_frame_range() ) cmds.menuItem( - "Reset Resolution", + "Set Resolution", command=lambda *args: lib.reset_scene_resolution() ) diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py index 90ab6e21e0..4bee0664ef 100644 --- a/openpype/hosts/maya/api/workfile_template_builder.py +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -33,7 +33,7 @@ class MayaTemplateBuilder(AbstractTemplateBuilder): get_template_preset implementation) Returns: - bool: Wether the template was succesfully imported or not + bool: Whether the template was successfully imported or not """ if cmds.objExists(PLACEHOLDER_SET): @@ -116,7 +116,7 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): placeholder_name_parts = placeholder_data["builder_type"].split("_") pos = 1 - # add famlily in any + # add family in any placeholder_family = placeholder_data["family"] if placeholder_family: placeholder_name_parts.insert(pos, placeholder_family) diff --git a/openpype/hosts/maya/plugins/create/create_animation.py b/openpype/hosts/maya/plugins/create/create_animation.py index a4b6e86598..f992ff2c1a 100644 --- a/openpype/hosts/maya/plugins/create/create_animation.py +++ b/openpype/hosts/maya/plugins/create/create_animation.py @@ -13,6 +13,7 @@ class CreateAnimation(plugin.Creator): icon = "male" write_color_sets = False write_face_sets = False + include_parent_hierarchy = False include_user_defined_attributes = False def __init__(self, *args, **kwargs): @@ -37,7 +38,7 @@ class CreateAnimation(plugin.Creator): self.data["visibleOnly"] = False # Include the groups above the out_SET content - self.data["includeParentHierarchy"] = False # Include parent groups + self.data["includeParentHierarchy"] = self.include_parent_hierarchy # Default to exporting world-space self.data["worldSpace"] = True diff --git a/openpype/hosts/maya/plugins/create/create_look.py b/openpype/hosts/maya/plugins/create/create_look.py index 44e439fe1f..51b0b8819a 100644 --- a/openpype/hosts/maya/plugins/create/create_look.py +++ b/openpype/hosts/maya/plugins/create/create_look.py @@ -12,6 +12,7 @@ class CreateLook(plugin.Creator): family = "look" icon = "paint-brush" make_tx = True + rs_tex = False def __init__(self, *args, **kwargs): super(CreateLook, self).__init__(*args, **kwargs) @@ -20,7 +21,8 @@ class CreateLook(plugin.Creator): # Whether to automatically convert the textures to .tx upon publish. self.data["maketx"] = self.make_tx - + # Whether to automatically convert the textures to .rstex upon publish. + self.data["rstex"] = self.rs_tex # Enable users to force a copy. # - on Windows is "forceCopy" always changed to `True` because of # windows implementation of hardlinks diff --git a/openpype/hosts/maya/plugins/create/create_review.py b/openpype/hosts/maya/plugins/create/create_review.py index f1b626c06b..e709239ae7 100644 --- a/openpype/hosts/maya/plugins/create/create_review.py +++ b/openpype/hosts/maya/plugins/create/create_review.py @@ -26,6 +26,7 @@ class CreateReview(plugin.Creator): "alpha cut" ] useMayaTimeline = True + panZoom = False def __init__(self, *args, **kwargs): super(CreateReview, self).__init__(*args, **kwargs) @@ -45,5 +46,6 @@ class CreateReview(plugin.Creator): data["keepImages"] = self.keepImages data["imagePlane"] = self.imagePlane data["transparency"] = self.transparency + data["panZoom"] = self.panZoom self.data = data diff --git a/openpype/hosts/maya/plugins/inventory/connect_yeti_rig.py b/openpype/hosts/maya/plugins/inventory/connect_yeti_rig.py new file mode 100644 index 0000000000..924a1a4627 --- /dev/null +++ b/openpype/hosts/maya/plugins/inventory/connect_yeti_rig.py @@ -0,0 +1,178 @@ +import os +import json +from collections import defaultdict + +from maya import cmds + +from openpype.pipeline import ( + InventoryAction, get_representation_context, get_representation_path +) +from openpype.hosts.maya.api.lib import get_container_members, get_id + + +class ConnectYetiRig(InventoryAction): + """Connect Yeti Rig with an animation or pointcache.""" + + label = "Connect Yeti Rig" + icon = "link" + color = "white" + + def process(self, containers): + # Validate selection is more than 1. + message = ( + "Only 1 container selected. 2+ containers needed for this action." + ) + if len(containers) == 1: + self.display_warning(message) + return + + # Categorize containers by family. + containers_by_family = defaultdict(list) + for container in containers: + family = get_representation_context( + container["representation"] + )["subset"]["data"]["family"] + containers_by_family[family].append(container) + + # Validate to only 1 source container. + source_containers = containers_by_family.get("animation", []) + source_containers += containers_by_family.get("pointcache", []) + source_container_namespaces = [ + x["namespace"] for x in source_containers + ] + message = ( + "{} animation containers selected:\n\n{}\n\nOnly select 1 of type " + "\"animation\" or \"pointcache\".".format( + len(source_containers), source_container_namespaces + ) + ) + if len(source_containers) != 1: + self.display_warning(message) + return + + source_container = source_containers[0] + source_ids = self.nodes_by_id(source_container) + + # Target containers. + target_ids = {} + inputs = [] + + yeti_rig_containers = containers_by_family.get("yetiRig") + if not yeti_rig_containers: + self.display_warning( + "Select at least one yetiRig container" + ) + return + + for container in yeti_rig_containers: + target_ids.update(self.nodes_by_id(container)) + + maya_file = get_representation_path( + get_representation_context( + container["representation"] + )["representation"] + ) + _, ext = os.path.splitext(maya_file) + settings_file = maya_file.replace(ext, ".rigsettings") + if not os.path.exists(settings_file): + continue + + with open(settings_file) as f: + inputs.extend(json.load(f)["inputs"]) + + # Compare loaded connections to scene. + for input in inputs: + source_node = source_ids.get(input["sourceID"]) + target_node = target_ids.get(input["destinationID"]) + + if not source_node or not target_node: + self.log.debug( + "Could not find nodes for input:\n" + + json.dumps(input, indent=4, sort_keys=True) + ) + continue + source_attr, target_attr = input["connections"] + + if not cmds.attributeQuery( + source_attr, node=source_node, exists=True + ): + self.log.debug( + "Could not find attribute {} on node {} for " + "input:\n{}".format( + source_attr, + source_node, + json.dumps(input, indent=4, sort_keys=True) + ) + ) + continue + + if not cmds.attributeQuery( + target_attr, node=target_node, exists=True + ): + self.log.debug( + "Could not find attribute {} on node {} for " + "input:\n{}".format( + target_attr, + target_node, + json.dumps(input, indent=4, sort_keys=True) + ) + ) + continue + + source_plug = "{}.{}".format( + source_node, source_attr + ) + target_plug = "{}.{}".format( + target_node, target_attr + ) + if cmds.isConnected( + source_plug, target_plug, ignoreUnitConversion=True + ): + self.log.debug( + "Connection already exists: {} -> {}".format( + source_plug, target_plug + ) + ) + continue + + cmds.connectAttr(source_plug, target_plug, force=True) + self.log.debug( + "Connected attributes: {} -> {}".format( + source_plug, target_plug + ) + ) + + def nodes_by_id(self, container): + ids = {} + for member in get_container_members(container): + id = get_id(member) + if not id: + continue + ids[id] = member + + return ids + + def display_warning(self, message, show_cancel=False): + """Show feedback to user. + + Returns: + bool + """ + + from qtpy import QtWidgets + + accept = QtWidgets.QMessageBox.Ok + if show_cancel: + buttons = accept | QtWidgets.QMessageBox.Cancel + else: + buttons = accept + + state = QtWidgets.QMessageBox.warning( + None, + "", + message, + buttons=buttons, + defaultButton=accept + ) + + return state == accept diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 2574624dbb..ba69debc40 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -118,7 +118,7 @@ class ImportMayaLoader(load.LoaderPlugin): "clean_import", label="Clean import", default=False, - help="Should all occurences of cbId be purged?" + help="Should all occurrences of cbId be purged?" ) ] diff --git a/openpype/hosts/maya/plugins/load/load_arnold_standin.py b/openpype/hosts/maya/plugins/load/load_arnold_standin.py index 11a2bd1966..21b2246f6c 100644 --- a/openpype/hosts/maya/plugins/load/load_arnold_standin.py +++ b/openpype/hosts/maya/plugins/load/load_arnold_standin.py @@ -180,7 +180,7 @@ class ArnoldStandinLoader(load.LoaderPlugin): proxy_basename, proxy_path = self._get_proxy_path(path) # Whether there is proxy or so, we still update the string operator. - # If no proxy exists, the string operator wont replace anything. + # If no proxy exists, the string operator won't replace anything. cmds.setAttr( string_replace_operator + ".match", "resources/" + proxy_basename, diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py new file mode 100644 index 0000000000..b464c268fc --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -0,0 +1,332 @@ +import os +import copy + +from openpype.lib import EnumDef +from openpype.pipeline import ( + load, + get_representation_context +) +from openpype.pipeline.load.utils import get_representation_path_from_context +from openpype.pipeline.colorspace import ( + get_imageio_colorspace_from_filepath, + get_imageio_config, + get_imageio_file_rules +) +from openpype.settings import get_project_settings + +from openpype.hosts.maya.api.pipeline import containerise +from openpype.hosts.maya.api.lib import ( + unique_namespace, + namespaced +) + +from maya import cmds + + +def create_texture(): + """Create place2dTexture with file node with uv connections + + Mimics Maya "file [Texture]" creation. + """ + + place = cmds.shadingNode("place2dTexture", asUtility=True, name="place2d") + file = cmds.shadingNode("file", asTexture=True, name="file") + + connections = ["coverage", "translateFrame", "rotateFrame", "rotateUV", + "mirrorU", "mirrorV", "stagger", "wrapV", "wrapU", + "repeatUV", "offset", "noiseUV", "vertexUvThree", + "vertexUvTwo", "vertexUvOne", "vertexCameraOne"] + for attr in connections: + src = "{}.{}".format(place, attr) + dest = "{}.{}".format(file, attr) + cmds.connectAttr(src, dest) + + cmds.connectAttr(place + '.outUV', file + '.uvCoord') + cmds.connectAttr(place + '.outUvFilterSize', file + '.uvFilterSize') + + return file, place + + +def create_projection(): + """Create texture with place3dTexture and projection + + Mimics Maya "file [Projection]" creation. + """ + + file, place = create_texture() + projection = cmds.shadingNode("projection", asTexture=True, + name="projection") + place3d = cmds.shadingNode("place3dTexture", asUtility=True, + name="place3d") + + cmds.connectAttr(place3d + '.worldInverseMatrix[0]', + projection + ".placementMatrix") + cmds.connectAttr(file + '.outColor', projection + ".image") + + return file, place, projection, place3d + + +def create_stencil(): + """Create texture with extra place2dTexture offset and stencil + + Mimics Maya "file [Stencil]" creation. + """ + + file, place = create_texture() + + place_stencil = cmds.shadingNode("place2dTexture", asUtility=True, + name="place2d_stencil") + stencil = cmds.shadingNode("stencil", asTexture=True, name="stencil") + + for src_attr, dest_attr in [ + ("outUV", "uvCoord"), + ("outUvFilterSize", "uvFilterSize") + ]: + src_plug = "{}.{}".format(place_stencil, src_attr) + cmds.connectAttr(src_plug, "{}.{}".format(place, dest_attr)) + cmds.connectAttr(src_plug, "{}.{}".format(stencil, dest_attr)) + + return file, place, stencil, place_stencil + + +class FileNodeLoader(load.LoaderPlugin): + """File node loader.""" + + families = ["image", "plate", "render"] + label = "Load file node" + representations = ["exr", "tif", "png", "jpg"] + icon = "image" + color = "orange" + order = 2 + + options = [ + EnumDef( + "mode", + items={ + "texture": "Texture", + "projection": "Projection", + "stencil": "Stencil" + }, + default="texture", + label="Texture Mode" + ) + ] + + def load(self, context, name, namespace, data): + + asset = context['asset']['name'] + namespace = namespace or unique_namespace( + asset + "_", + prefix="_" if asset[0].isdigit() else "", + suffix="_", + ) + + with namespaced(namespace, new=True) as namespace: + # Create the nodes within the namespace + nodes = { + "texture": create_texture, + "projection": create_projection, + "stencil": create_stencil + }[data.get("mode", "texture")]() + + file_node = cmds.ls(nodes, type="file")[0] + + self._apply_representation_context(context, file_node) + + # For ease of access for the user select all the nodes and select + # the file node last so that UI shows its attributes by default + cmds.select(list(nodes) + [file_node], replace=True) + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__ + ) + + def update(self, container, representation): + + members = cmds.sets(container['objectName'], query=True) + file_node = cmds.ls(members, type="file")[0] + + context = get_representation_context(representation) + self._apply_representation_context(context, file_node) + + # Update representation + cmds.setAttr( + container["objectName"] + ".representation", + str(representation["_id"]), + type="string" + ) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + members = cmds.sets(container['objectName'], query=True) + cmds.lockNode(members, lock=False) + cmds.delete([container['objectName']] + members) + + # Clean up the namespace + try: + cmds.namespace(removeNamespace=container['namespace'], + deleteNamespaceContent=True) + except RuntimeError: + pass + + def _apply_representation_context(self, context, file_node): + """Update the file node to match the context. + + This sets the file node's attributes for: + - file path + - udim tiling mode (if it is an udim tile) + - use frame extension (if it is a sequence) + - colorspace + + """ + + repre_context = context["representation"]["context"] + has_frames = repre_context.get("frame") is not None + has_udim = repre_context.get("udim") is not None + + # Set UV tiling mode if UDIM tiles + if has_udim: + cmds.setAttr(file_node + ".uvTilingMode", 3) # UDIM-tiles + else: + cmds.setAttr(file_node + ".uvTilingMode", 0) # off + + # Enable sequence if publish has `startFrame` and `endFrame` and + # `startFrame != endFrame` + if has_frames and self._is_sequence(context): + # When enabling useFrameExtension maya automatically + # connects an expression to .frameExtension to set + # the current frame. However, this expression is generated + # with some delay and thus it'll show a warning if frame 0 + # doesn't exist because we're explicitly setting the + # token. + cmds.setAttr(file_node + ".useFrameExtension", True) + else: + cmds.setAttr(file_node + ".useFrameExtension", False) + + # Set the file node path attribute + path = self._format_path(context) + cmds.setAttr(file_node + ".fileTextureName", path, type="string") + + # Set colorspace + colorspace = self._get_colorspace(context) + if colorspace: + cmds.setAttr(file_node + ".colorSpace", colorspace, type="string") + else: + self.log.debug("Unknown colorspace - setting colorspace skipped.") + + def _is_sequence(self, context): + """Check whether frameStart and frameEnd are not the same.""" + version = context.get("version", {}) + representation = context.get("representation", {}) + + for doc in [representation, version]: + # Frame range can be set on version or representation. + # When set on representation it overrides version data. + data = doc.get("data", {}) + start = data.get("frameStartHandle", data.get("frameStart", None)) + end = data.get("frameEndHandle", data.get("frameEnd", None)) + + if start is None or end is None: + continue + + if start != end: + return True + else: + return False + + return False + + def _get_colorspace(self, context): + """Return colorspace of the file to load. + + Retrieves the explicit colorspace from the publish. If no colorspace + data is stored with published content then project imageio settings + are used to make an assumption of the colorspace based on the file + rules. If no file rules match then None is returned. + + Returns: + str or None: The colorspace of the file or None if not detected. + + """ + + # We can't apply color spaces if management is not enabled + if not cmds.colorManagementPrefs(query=True, cmEnabled=True): + return + + representation = context["representation"] + colorspace_data = representation.get("data", {}).get("colorspaceData") + if colorspace_data: + return colorspace_data["colorspace"] + + # Assume colorspace from filepath based on project settings + project_name = context["project"]["name"] + host_name = os.environ.get("AVALON_APP") + project_settings = get_project_settings(project_name) + + config_data = get_imageio_config( + project_name, host_name, + project_settings=project_settings + ) + file_rules = get_imageio_file_rules( + project_name, host_name, + project_settings=project_settings + ) + + path = get_representation_path_from_context(context) + colorspace = get_imageio_colorspace_from_filepath( + path=path, + host_name=host_name, + project_name=project_name, + config_data=config_data, + file_rules=file_rules, + project_settings=project_settings + ) + + return colorspace + + def _format_path(self, context): + """Format the path with correct tokens for frames and udim tiles.""" + + context = copy.deepcopy(context) + representation = context["representation"] + template = representation.get("data", {}).get("template") + if not template: + # No template to find token locations for + return get_representation_path_from_context(context) + + def _placeholder(key): + # Substitute with a long placeholder value so that potential + # custom formatting with padding doesn't find its way into + # our formatting, so that wouldn't be padded as 0 + return "___{}___".format(key) + + # We format UDIM and Frame numbers with their specific tokens. To do so + # we in-place change the representation context data to format the path + # with our own data + tokens = { + "frame": "", + "udim": "" + } + has_tokens = False + repre_context = representation["context"] + for key, _token in tokens.items(): + if key in repre_context: + repre_context[key] = _placeholder(key) + has_tokens = True + + # Replace with our custom template that has the tokens set + representation["data"]["template"] = template + path = get_representation_path_from_context(context) + + if has_tokens: + for key, token in tokens.items(): + if key in repre_context: + path = path.replace(_placeholder(key), token) + + return path diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index d93702a16d..82c15ab899 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -1,4 +1,6 @@ import os +import difflib +import contextlib from maya import cmds from openpype.settings import get_project_settings @@ -8,7 +10,82 @@ from openpype.pipeline.create import ( get_legacy_creator_by_name, ) import openpype.hosts.maya.api.plugin -from openpype.hosts.maya.api.lib import maintained_selection +from openpype.hosts.maya.api.lib import ( + maintained_selection, + get_container_members +) + + +@contextlib.contextmanager +def preserve_modelpanel_cameras(container, log=None): + """Preserve camera members of container in the modelPanels. + + This is used to ensure a camera remains in the modelPanels after updating + to a new version. + + """ + + # Get the modelPanels that used the old camera + members = get_container_members(container) + old_cameras = set(cmds.ls(members, type="camera", long=True)) + if not old_cameras: + # No need to manage anything + yield + return + + panel_cameras = {} + for panel in cmds.getPanel(type="modelPanel"): + cam = cmds.ls(cmds.modelPanel(panel, query=True, camera=True), + long=True) + + # Often but not always maya returns the transform from the + # modelPanel as opposed to the camera shape, so we convert it + # to explicitly be the camera shape + if cmds.nodeType(cam) != "camera": + cam = cmds.listRelatives(cam, + children=True, + fullPath=True, + type="camera")[0] + if cam in old_cameras: + panel_cameras[panel] = cam + + if not panel_cameras: + # No need to manage anything + yield + return + + try: + yield + finally: + new_members = get_container_members(container) + new_cameras = set(cmds.ls(new_members, type="camera", long=True)) + if not new_cameras: + return + + for panel, cam_name in panel_cameras.items(): + new_camera = None + if cam_name in new_cameras: + new_camera = cam_name + elif len(new_cameras) == 1: + new_camera = next(iter(new_cameras)) + else: + # Multiple cameras in the updated container but not an exact + # match detected by name. Find the closest match + matches = difflib.get_close_matches(word=cam_name, + possibilities=new_cameras, + n=1) + if matches: + new_camera = matches[0] # best match + if log: + log.info("Camera in '{}' restored with " + "closest match camera: {} (before: {})" + .format(panel, new_camera, cam_name)) + + if not new_camera: + # Unable to find the camera to re-apply in the modelpanel + continue + + cmds.modelPanel(panel, edit=True, camera=new_camera) class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): @@ -68,6 +145,9 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): new_nodes = (list(set(nodes) - set(shapes))) + # if there are cameras, try to lock their transforms + self._lock_camera_transforms(new_nodes) + current_namespace = pm.namespaceInfo(currentNamespace=True) if current_namespace != ":": @@ -136,6 +216,15 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def switch(self, container, representation): self.update(container, representation) + def update(self, container, representation): + with preserve_modelpanel_cameras(container, log=self.log): + super(ReferenceLoader, self).update(container, representation) + + # We also want to lock camera transforms on any new cameras in the + # reference or for a camera which might have changed names. + members = get_container_members(container) + self._lock_camera_transforms(members) + def _post_process_rig(self, name, namespace, context, options): output = next((node for node in self if @@ -168,3 +257,18 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): options={"useSelection": True}, data={"dependencies": dependency} ) + + def _lock_camera_transforms(self, nodes): + cameras = cmds.ls(nodes, type="camera") + if not cameras: + return + + # Check the Maya version, lockTransform has been introduced since + # Maya 2016.5 Ext 2 + version = int(cmds.about(version=True)) + if version >= 2016: + for camera in cameras: + cmds.camera(camera, edit=True, lockTransform=True) + else: + self.log.warning("This version of Maya does not support locking of" + " transforms of cameras.") diff --git a/openpype/hosts/maya/plugins/load/load_yeti_rig.py b/openpype/hosts/maya/plugins/load/load_yeti_rig.py index 651607de8a..6a13d2e145 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_rig.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_rig.py @@ -1,17 +1,12 @@ -import os -from collections import defaultdict +import maya.cmds as cmds -from openpype.settings import get_project_settings +from openpype.settings import get_current_project_settings import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api import lib class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """ - This loader will load Yeti rig. You can select something in scene and if it - has same ID as mesh published with rig, their shapes will be linked - together. - """ + """This loader will load Yeti rig.""" families = ["yetiRig"] representations = ["ma"] @@ -22,72 +17,31 @@ class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): color = "orange" def process_reference( - self, context, name=None, namespace=None, options=None): + self, context, name=None, namespace=None, options=None + ): - import maya.cmds as cmds - - # get roots of selected hierarchies - selected_roots = [] - for sel in cmds.ls(sl=True, long=True): - selected_roots.append(sel.split("|")[1]) - - # get all objects under those roots - selected_hierarchy = [] - for root in selected_roots: - selected_hierarchy.append(cmds.listRelatives( - root, - allDescendents=True) or []) - - # flatten the list and filter only shapes - shapes_flat = [] - for root in selected_hierarchy: - shapes = cmds.ls(root, long=True, type="mesh") or [] - for shape in shapes: - shapes_flat.append(shape) - - # create dictionary of cbId and shape nodes - scene_lookup = defaultdict(list) - for node in shapes_flat: - cb_id = lib.get_id(node) - scene_lookup[cb_id] = node - - # load rig + group_name = "{}:{}".format(namespace, name) with lib.maintained_selection(): - file_url = self.prepare_root_value(self.fname, - context["project"]["name"]) - nodes = cmds.file(file_url, - namespace=namespace, - reference=True, - returnNewNodes=True, - groupReference=True, - groupName="{}:{}".format(namespace, name)) + file_url = self.prepare_root_value( + self.fname, context["project"]["name"] + ) + nodes = cmds.file( + file_url, + namespace=namespace, + reference=True, + returnNewNodes=True, + groupReference=True, + groupName=group_name + ) - # for every shape node we've just loaded find matching shape by its - # cbId in selection. If found outMesh of scene shape will connect to - # inMesh of loaded shape. - for destination_node in nodes: - source_node = scene_lookup[lib.get_id(destination_node)] - if source_node: - self.log.info("found: {}".format(source_node)) - self.log.info( - "creating connection to {}".format(destination_node)) - - cmds.connectAttr("{}.outMesh".format(source_node), - "{}.inMesh".format(destination_node), - force=True) - - groupName = "{}:{}".format(namespace, name) - - settings = get_project_settings(os.environ['AVALON_PROJECT']) - colors = settings['maya']['load']['colors'] - - c = colors.get('yetiRig') + settings = get_current_project_settings() + colors = settings["maya"]["load"]["colors"] + c = colors.get("yetiRig") if c is not None: - cmds.setAttr(groupName + ".useOutlinerColor", 1) - cmds.setAttr(groupName + ".outlinerColor", - (float(c[0])/255), - (float(c[1])/255), - (float(c[2])/255) + cmds.setAttr(group_name + ".useOutlinerColor", 1) + cmds.setAttr( + group_name + ".outlinerColor", + (float(c[0]) / 255), (float(c[1]) / 255), (float(c[2]) / 255) ) self[:] = nodes diff --git a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py index a7cb14855b..33fc7a025f 100644 --- a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py @@ -255,7 +255,7 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): Searches through the overrides finding all material overrides. From there it extracts the shading group and then finds all texture files in the shading group network. It also checks for mipmap versions of texture files - and adds them to the resouces to get published. + and adds them to the resources to get published. """ diff --git a/openpype/hosts/maya/plugins/publish/collect_review.py b/openpype/hosts/maya/plugins/publish/collect_review.py index 65ff7cf0fe..00565c5819 100644 --- a/openpype/hosts/maya/plugins/publish/collect_review.py +++ b/openpype/hosts/maya/plugins/publish/collect_review.py @@ -5,6 +5,7 @@ import pyblish.api from openpype.client import get_subset_by_name from openpype.pipeline import legacy_io +from openpype.hosts.maya.api.lib import get_attribute_input class CollectReview(pyblish.api.InstancePlugin): @@ -24,7 +25,9 @@ class CollectReview(pyblish.api.InstancePlugin): task = legacy_io.Session["AVALON_TASK"] # Get panel. - instance.data["panel"] = cmds.playblast(activeEditor=True) + instance.data["panel"] = cmds.playblast( + activeEditor=True + ).split("|")[-1] # get cameras members = instance.data['setMembers'] @@ -77,6 +80,8 @@ class CollectReview(pyblish.api.InstancePlugin): data['review_width'] = instance.data['review_width'] data['review_height'] = instance.data['review_height'] data["isolate"] = instance.data["isolate"] + data["panZoom"] = instance.data.get("panZoom", False) + data["panel"] = instance.data["panel"] cmds.setAttr(str(instance) + '.active', 1) self.log.debug('data {}'.format(instance.context[i].data)) instance.context[i].data.update(data) @@ -142,3 +147,21 @@ class CollectReview(pyblish.api.InstancePlugin): "filename": node.filename.get() } ) + + # Collect focal length. + attr = camera + ".focalLength" + focal_length = None + if get_attribute_input(attr): + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + 1 + focal_length = [ + cmds.getAttr(attr, time=t) for t in range(int(start), int(end)) + ] + else: + focal_length = cmds.getAttr(attr) + + key = "focalLength" + try: + instance.data["burninDataMembers"][key] = focal_length + except KeyError: + instance.data["burninDataMembers"] = {key: focal_length} diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index efeddcfbe4..93054e5fbb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -1,56 +1,42 @@ # -*- coding: utf-8 -*- """Maya look extractor.""" -import os -import sys -import json -import tempfile -import platform -import contextlib -import subprocess +from abc import ABCMeta, abstractmethod from collections import OrderedDict - -from maya import cmds # noqa +import contextlib +import json +import logging +import os +import platform +import tempfile +import six +import attr import pyblish.api -from openpype.lib import source_hash, run_subprocess -from openpype.pipeline import legacy_io, publish +from maya import cmds # noqa + +from openpype.lib.vendor_bin_utils import find_executable +from openpype.lib import source_hash, run_subprocess, get_oiio_tools_path +from openpype.pipeline import legacy_io, publish, KnownPublishError from openpype.hosts.maya.api import lib -from openpype.hosts.maya.api.lib import image_info, guess_colorspace # Modes for transfer COPY = 1 HARDLINK = 2 -def escape_space(path): - """Ensure path is enclosed by quotes to allow paths with spaces""" - return '"{}"'.format(path) if " " in path else path - - -def get_ocio_config_path(profile_folder): - """Path to OpenPype vendorized OCIO. - - Vendorized OCIO config file path is grabbed from the specific path - hierarchy specified below. - - "{OPENPYPE_ROOT}/vendor/OpenColorIO-Configs/{profile_folder}/config.ocio" - Args: - profile_folder (str): Name of folder to grab config file from. - - Returns: - str: Path to vendorized config file. - """ - - return os.path.join( - os.environ["OPENPYPE_ROOT"], - "vendor", - "bin", - "ocioconfig", - "OpenColorIOConfigs", - profile_folder, - "config.ocio" - ) +@attr.s +class TextureResult: + """The resulting texture of a processed file for a resource""" + # Path to the file + path = attr.ib() + # Colorspace of the resulting texture. This might not be the input + # colorspace of the texture if a TextureProcessor has processed the file. + colorspace = attr.ib() + # Hash generated for the texture using openpype.lib.source_hash + file_hash = attr.ib() + # The transfer mode, e.g. COPY or HARDLINK + transfer_mode = attr.ib() def find_paths_by_hash(texture_hash): @@ -69,61 +55,6 @@ def find_paths_by_hash(texture_hash): return legacy_io.distinct(key, {"type": "version"}) -def maketx(source, destination, args, logger): - """Make `.tx` using `maketx` with some default settings. - - The settings are based on default as used in Arnold's - txManager in the scene. - This function requires the `maketx` executable to be - on the `PATH`. - - Args: - source (str): Path to source file. - destination (str): Writing destination path. - args (list): Additional arguments for `maketx`. - logger (logging.Logger): Logger to log messages to. - - Returns: - str: Output of `maketx` command. - - """ - from openpype.lib import get_oiio_tools_path - - maketx_path = get_oiio_tools_path("maketx") - - if not maketx_path: - print( - "OIIO tool not found in {}".format(maketx_path)) - raise AssertionError("OIIO tool not found") - - subprocess_args = [ - maketx_path, - "-v", # verbose - "-u", # update mode - # unpremultiply before conversion (recommended when alpha present) - "--unpremult", - "--checknan", - # use oiio-optimized settings for tile-size, planarconfig, metadata - "--oiio", - "--filter", "lanczos3", - source - ] - - subprocess_args.extend(args) - subprocess_args.extend(["-o", destination]) - - cmd = " ".join(subprocess_args) - logger.debug(cmd) - - try: - out = run_subprocess(subprocess_args) - except Exception: - logger.error("Maketx converion failed", exc_info=True) - raise - - return out - - @contextlib.contextmanager def no_workspace_dir(): """Force maya to a fake temporary workspace directory. @@ -156,6 +87,303 @@ def no_workspace_dir(): os.rmdir(fake_workspace_dir) +@six.add_metaclass(ABCMeta) +class TextureProcessor: + + extension = None + + def __init__(self, log=None): + if log is None: + log = logging.getLogger(self.__class__.__name__) + self.log = log + + def apply_settings(self, system_settings, project_settings): + """Apply OpenPype system/project settings to the TextureProcessor + + Args: + system_settings (dict): OpenPype system settings + project_settings (dict): OpenPype project settings + + Returns: + None + + """ + pass + + @abstractmethod + def process(self, + source, + colorspace, + color_management, + staging_dir): + """Process the `source` texture. + + Must be implemented on inherited class. + + This must always return a TextureResult even when it does not generate + a texture. If it doesn't generate a texture then it should return a + TextureResult using the input path and colorspace. + + Args: + source (str): Path to source file. + colorspace (str): Colorspace of the source file. + color_management (dict): Maya Color management data from + `lib.get_color_management_preferences` + staging_dir (str): Output directory to write to. + + Returns: + TextureResult: The resulting texture information. + + """ + pass + + def __repr__(self): + # Log instance as class name + return self.__class__.__name__ + + +class MakeRSTexBin(TextureProcessor): + """Make `.rstexbin` using `redshiftTextureProcessor`""" + + extension = ".rstexbin" + + def process(self, + source, + colorspace, + color_management, + staging_dir): + + texture_processor_path = self.get_redshift_tool( + "redshiftTextureProcessor" + ) + if not texture_processor_path: + raise KnownPublishError("Must have Redshift available.") + + subprocess_args = [ + texture_processor_path, + source + ] + + hash_args = ["rstex"] + texture_hash = source_hash(source, *hash_args) + + # Redshift stores the output texture next to the input but with + # the extension replaced to `.rstexbin` + basename, ext = os.path.splitext(source) + destination = "{}{}".format(basename, self.extension) + + self.log.debug(" ".join(subprocess_args)) + try: + run_subprocess(subprocess_args) + except Exception: + self.log.error("Texture .rstexbin conversion failed", + exc_info=True) + raise + + return TextureResult( + path=destination, + file_hash=texture_hash, + colorspace=colorspace, + transfer_mode=COPY + ) + + @staticmethod + def get_redshift_tool(tool_name): + """Path to redshift texture processor. + + On Windows it adds .exe extension if missing from tool argument. + + Args: + tool_name (string): Tool name. + + Returns: + str: Full path to redshift texture processor executable. + """ + if "REDSHIFT_COREDATAPATH" not in os.environ: + raise RuntimeError("Must have Redshift available.") + + redshift_tool_path = os.path.join( + os.environ["REDSHIFT_COREDATAPATH"], + "bin", + tool_name + ) + + return find_executable(redshift_tool_path) + + +class MakeTX(TextureProcessor): + """Make `.tx` using `maketx` with some default settings. + + Some hardcoded arguments passed to `maketx` are based on the defaults used + in Arnold's txManager tool. + + """ + + extension = ".tx" + + def __init__(self, log=None): + super(MakeTX, self).__init__(log=log) + self.extra_args = [] + + def apply_settings(self, system_settings, project_settings): + # Allow extra maketx arguments from project settings + args_settings = ( + project_settings["maya"]["publish"] + .get("ExtractLook", {}).get("maketx_arguments", []) + ) + extra_args = [] + for arg_data in args_settings: + argument = arg_data["argument"] + parameters = arg_data["parameters"] + if not argument: + self.log.debug("Ignoring empty parameter from " + "`maketx_arguments` setting..") + continue + + extra_args.append(argument) + extra_args.extend(parameters) + + self.extra_args = extra_args + + def process(self, + source, + colorspace, + color_management, + staging_dir): + """Process the texture. + + This function requires the `maketx` executable to be available in an + OpenImageIO toolset detectable by OpenPype. + + Args: + source (str): Path to source file. + colorspace (str): Colorspace of the source file. + color_management (dict): Maya Color management data from + `lib.get_color_management_preferences` + staging_dir (str): Output directory to write to. + + Returns: + TextureResult: The resulting texture information. + + """ + + maketx_path = get_oiio_tools_path("maketx") + + if not maketx_path: + raise AssertionError( + "OIIO 'maketx' tool not found. Result: {}".format(maketx_path) + ) + + # Define .tx filepath in staging if source file is not .tx + fname, ext = os.path.splitext(os.path.basename(source)) + if ext == ".tx": + # Do nothing if the source file is already a .tx file. + return TextureResult( + path=source, + file_hash=None, # todo: unknown texture hash? + colorspace=colorspace, + transfer_mode=COPY + ) + + # Hardcoded default arguments for maketx conversion based on Arnold's + # txManager in Maya + args = [ + # unpremultiply before conversion (recommended when alpha present) + "--unpremult", + # use oiio-optimized settings for tile-size, planarconfig, metadata + "--oiio", + "--filter", "lanczos3", + ] + if color_management["enabled"]: + config_path = color_management["config"] + if not os.path.exists(config_path): + raise RuntimeError("OCIO config not found at: " + "{}".format(config_path)) + + render_colorspace = color_management["rendering_space"] + + self.log.info("tx: converting colorspace {0} " + "-> {1}".format(colorspace, + render_colorspace)) + args.extend(["--colorconvert", colorspace, render_colorspace]) + args.extend(["--colorconfig", config_path]) + + else: + # Maya Color management is disabled. We cannot rely on an OCIO + self.log.debug("tx: Maya color management is disabled. No color " + "conversion will be applied to .tx conversion for: " + "{}".format(source)) + # Assume linear + render_colorspace = "linear" + + # Note: The texture hash is only reliable if we include any potential + # conversion arguments provide to e.g. `maketx` + hash_args = ["maketx"] + args + self.extra_args + texture_hash = source_hash(source, *hash_args) + + # Ensure folder exists + resources_dir = os.path.join(staging_dir, "resources") + if not os.path.exists(resources_dir): + os.makedirs(resources_dir) + + self.log.info("Generating .tx file for %s .." % source) + + subprocess_args = [ + maketx_path, + "-v", # verbose + "-u", # update mode + # --checknan doesn't influence the output file but aborts the + # conversion if it finds any. So we can avoid it for the file hash + "--checknan", + source + ] + + subprocess_args.extend(args) + if self.extra_args: + subprocess_args.extend(self.extra_args) + + # Add source hash attribute after other arguments for log readability + # Note: argument is excluded from the hash since it is the hash itself + subprocess_args.extend([ + "--sattrib", + "sourceHash", + texture_hash + ]) + + destination = os.path.join(resources_dir, fname + ".tx") + subprocess_args.extend(["-o", destination]) + + # We want to make sure we are explicit about what OCIO config gets + # used. So when we supply no --colorconfig flag that no fallback to + # an OCIO env var occurs. + env = os.environ.copy() + env.pop("OCIO", None) + + self.log.debug(" ".join(subprocess_args)) + try: + run_subprocess(subprocess_args, env=env) + except Exception: + self.log.error("Texture maketx conversion failed", + exc_info=True) + raise + + return TextureResult( + path=destination, + file_hash=texture_hash, + colorspace=render_colorspace, + transfer_mode=COPY + ) + + @staticmethod + def _has_arnold(): + """Return whether the arnold package is available and importable.""" + try: + import arnold # noqa: F401 + return True + except (ImportError, ModuleNotFoundError): + return False + + class ExtractLook(publish.Extractor): """Extract Look (Maya Scene + JSON) @@ -172,22 +400,6 @@ class ExtractLook(publish.Extractor): scene_type = "ma" look_data_type = "json" - @staticmethod - def get_renderer_name(): - """Get renderer name from Maya. - - Returns: - str: Renderer name. - - """ - renderer = cmds.getAttr( - "defaultRenderGlobals.currentRenderer" - ).lower() - # handle various renderman names - if renderer.startswith("renderman"): - renderer = "renderman" - return renderer - def get_maya_scene_type(self, instance): """Get Maya scene type from settings. @@ -227,16 +439,12 @@ class ExtractLook(publish.Extractor): dir_path = self.staging_dir(instance) maya_fname = "{0}.{1}".format(instance.name, self.scene_type) json_fname = "{0}.{1}".format(instance.name, self.look_data_type) - - # Make texture dump folder maya_path = os.path.join(dir_path, maya_fname) json_path = os.path.join(dir_path, json_fname) - self.log.info("Performing extraction..") - # Remove all members of the sets so they are not included in the # exported file by accident - self.log.info("Extract sets (%s) ..." % _scene_type) + self.log.info("Processing sets..") lookdata = instance.data["lookData"] relationships = lookdata["relationships"] sets = list(relationships.keys()) @@ -244,13 +452,36 @@ class ExtractLook(publish.Extractor): self.log.info("No sets found") return - results = self.process_resources(instance, staging_dir=dir_path) + # Specify texture processing executables to activate + # TODO: Load these more dynamically once we support more processors + processors = [] + context = instance.context + for key, Processor in { + # Instance data key to texture processor mapping + "maketx": MakeTX, + "rstex": MakeRSTexBin + }.items(): + if instance.data.get(key, False): + processor = Processor() + processor.apply_settings(context.data["system_settings"], + context.data["project_settings"]) + processors.append(processor) + + if processors: + self.log.debug("Collected texture processors: " + "{}".format(processors)) + + self.log.debug("Processing resources..") + results = self.process_resources(instance, + staging_dir=dir_path, + processors=processors) transfers = results["fileTransfers"] hardlinks = results["fileHardlinks"] hashes = results["fileHashes"] remap = results["attrRemap"] # Extract in correct render layer + self.log.info("Extracting look maya scene file: {}".format(maya_path)) layer = instance.data.get("renderlayer", "defaultRenderLayer") with lib.renderlayer(layer): # TODO: Ensure membership edits don't become renderlayer overrides @@ -258,7 +489,7 @@ class ExtractLook(publish.Extractor): # To avoid Maya trying to automatically remap the file # textures relative to the `workspace -directory` we force # it to a fake temporary workspace. This fixes textures - # getting incorrectly remapped. (LKD-17, PLN-101) + # getting incorrectly remapped. with no_workspace_dir(): with lib.attribute_values(remap): with lib.maintained_selection(): @@ -322,40 +553,38 @@ class ExtractLook(publish.Extractor): # Source hash for the textures instance.data["sourceHashes"] = hashes - """ - self.log.info("Returning colorspaces to their original values ...") - for attr, value in remap.items(): - self.log.info(" - {}: {}".format(attr, value)) - cmds.setAttr(attr, value, type="string") - """ self.log.info("Extracted instance '%s' to: %s" % (instance.name, maya_path)) - def process_resources(self, instance, staging_dir): + def _set_resource_result_colorspace(self, resource, colorspace): + """Update resource resulting colorspace after texture processing""" + if "result_color_space" in resource: + if resource["result_color_space"] == colorspace: + return + + self.log.warning( + "Resource already has a resulting colorspace but is now " + "being overridden to a new one: {} -> {}".format( + resource["result_color_space"], colorspace + ) + ) + resource["result_color_space"] = colorspace + + def process_resources(self, instance, staging_dir, processors): + """Process all resources in the instance. + + It is assumed that all resources are nodes using file textures. + + Extract the textures to transfer, possibly convert with maketx and + remap the node paths to the destination path. Note that a source + might be included more than once amongst the resources as they could + be the input file to multiple nodes. + + """ - # Extract the textures to transfer, possibly convert with maketx and - # remap the node paths to the destination path. Note that a source - # might be included more than once amongst the resources as they could - # be the input file to multiple nodes. resources = instance.data["resources"] - do_maketx = instance.data.get("maketx", False) + color_management = lib.get_color_management_preferences() - # Collect all unique files used in the resources - files_metadata = {} - for resource in resources: - # Preserve color space values (force value after filepath change) - # This will also trigger in the same order at end of context to - # ensure after context it's still the original value. - color_space = resource.get("color_space") - - for f in resource["files"]: - files_metadata[os.path.normpath(f)] = { - "color_space": color_space} - - # Process the resource files - transfers = [] - hardlinks = [] - hashes = {} # Temporary fix to NOT create hardlinks on windows machines if platform.system().lower() == "windows": self.log.info( @@ -365,95 +594,114 @@ class ExtractLook(publish.Extractor): else: force_copy = instance.data.get("forceCopy", False) - for filepath in files_metadata: + destinations_cache = {} - linearize = False - # if OCIO color management enabled - # it won't take the condition of the files_metadata + def get_resource_destination_cached(path): + """Get resource destination with cached result per filepath""" + if path not in destinations_cache: + destination = self.get_resource_destination( + path, instance.data["resourcesDir"], processors) + destinations_cache[path] = destination + return destinations_cache[path] - ocio_maya = cmds.colorManagementPrefs(q=True, - cmConfigFileEnabled=True, - cmEnabled=True) - - if do_maketx and not ocio_maya: - if files_metadata[filepath]["color_space"].lower() == "srgb": # noqa: E501 - linearize = True - # set its file node to 'raw' as tx will be linearized - files_metadata[filepath]["color_space"] = "Raw" - - # if do_maketx: - # color_space = "Raw" - - source, mode, texture_hash = self._process_texture( - filepath, - resource, - do_maketx, - staging=staging_dir, - linearize=linearize, - force=force_copy - ) - destination = self.resource_destination(instance, - source, - do_maketx) - - # Force copy is specified. - if force_copy: - mode = COPY - - if mode == COPY: - transfers.append((source, destination)) - self.log.info('file will be copied {} -> {}'.format( - source, destination)) - elif mode == HARDLINK: - hardlinks.append((source, destination)) - self.log.info('file will be hardlinked {} -> {}'.format( - source, destination)) - - # Store the hashes from hash to destination to include in the - # database - hashes[texture_hash] = destination - - # Remap the resources to the destination path (change node attributes) - destinations = {} - remap = OrderedDict() # needs to be ordered, see color space values + # Process all resource's individual files + processed_files = {} + transfers = [] + hardlinks = [] + hashes = {} + remap = OrderedDict() for resource in resources: - source = os.path.normpath(resource["source"]) - if source not in destinations: - # Cache destination as source resource might be included - # multiple times - destinations[source] = self.resource_destination( - instance, source, do_maketx + colorspace = resource["color_space"] + + for filepath in resource["files"]: + filepath = os.path.normpath(filepath) + + if filepath in processed_files: + # The file was already processed, likely due to usage by + # another resource in the scene. We confirm here it + # didn't do color spaces different than the current + # resource. + processed_file = processed_files[filepath] + self.log.debug( + "File was already processed. Likely used by another " + "resource too: {}".format(filepath) + ) + + if colorspace != processed_file["color_space"]: + self.log.warning( + "File '{}' was already processed using colorspace " + "'{}' instead of the current resource's " + "colorspace '{}'. The already processed texture " + "result's colorspace '{}' will be used." + "".format(filepath, + colorspace, + processed_file["color_space"], + processed_file["result_color_space"])) + + self._set_resource_result_colorspace( + resource, + colorspace=processed_file["result_color_space"] + ) + continue + + texture_result = self._process_texture( + filepath, + processors=processors, + staging_dir=staging_dir, + force_copy=force_copy, + color_management=color_management, + colorspace=colorspace ) + # Set the resulting color space on the resource + self._set_resource_result_colorspace( + resource, colorspace=texture_result.colorspace + ) + + processed_files[filepath] = { + "color_space": colorspace, + "result_color_space": texture_result.colorspace, + } + + source = texture_result.path + destination = get_resource_destination_cached(source) + if force_copy or texture_result.transfer_mode == COPY: + transfers.append((source, destination)) + self.log.info('file will be copied {} -> {}'.format( + source, destination)) + elif texture_result.transfer_mode == HARDLINK: + hardlinks.append((source, destination)) + self.log.info('file will be hardlinked {} -> {}'.format( + source, destination)) + + # Store the hashes from hash to destination to include in the + # database + hashes[texture_result.file_hash] = destination + + # Set up remapping attributes for the node during the publish + # The order of these can be important if one attribute directly + # affects another, e.g. we set colorspace after filepath because + # maya sometimes tries to guess the colorspace when changing + # filepaths (which is avoidable, but we don't want to have those + # attributes changed in the resulting publish) + # Remap filepath to publish destination + # TODO It would be much better if we could use the destination path + # from the actual processed texture results, but since the + # attribute will need to preserve tokens like , etc for + # now we will define the output path from the attribute value + # including the tokens to persist them. + filepath_attr = resource["attribute"] + remap[filepath_attr] = get_resource_destination_cached( + resource["source"] + ) + # Preserve color space values (force value after filepath change) # This will also trigger in the same order at end of context to # ensure after context it's still the original value. - color_space_attr = resource["node"] + ".colorSpace" - try: - color_space = cmds.getAttr(color_space_attr) - except ValueError: - # node doesn't have color space attribute - color_space = "Raw" - else: - # get the resolved files - metadata = files_metadata.get(source) - # if the files are unresolved from `source` - # assume color space from the first file of - # the resource - if not metadata: - first_file = next(iter(resource.get( - "files", [])), None) - if not first_file: - continue - first_filepath = os.path.normpath(first_file) - metadata = files_metadata[first_filepath] - if metadata["color_space"] == "Raw": - # set color space to raw if we linearized it - color_space = "Raw" - # Remap file node filename to destination - remap[color_space_attr] = color_space - attr = resource["attribute"] - remap[attr] = destinations[source] + node = resource["node"] + if cmds.attributeQuery("colorSpace", node=node, exists=True): + color_space_attr = "{}.colorSpace".format(node) + remap[color_space_attr] = resource["result_color_space"] self.log.info("Finished remapping destinations ...") @@ -464,134 +712,131 @@ class ExtractLook(publish.Extractor): "attrRemap": remap, } - def resource_destination(self, instance, filepath, do_maketx): + def get_resource_destination(self, filepath, resources_dir, processors): """Get resource destination path. This is utility function to change path if resource file name is changed by some external tool like `maketx`. Args: - instance: Current Instance. - filepath (str): Resource path - do_maketx (bool): Flag if resource is processed by `maketx`. + filepath (str): Resource source path + resources_dir (str): Destination dir for resources in publish. + processors (list): Texture processors converting resource. Returns: str: Path to resource file """ - resources_dir = instance.data["resourcesDir"] - # Compute destination location basename, ext = os.path.splitext(os.path.basename(filepath)) - # If `maketx` then the texture will always end with .tx - if do_maketx: - ext = ".tx" + # Get extension from the last processor + for processor in reversed(processors): + processor_ext = processor.extension + if processor_ext and ext != processor_ext: + self.log.debug("Processor {} overrides extension to '{}' " + "for path: {}".format(processor, + processor_ext, + filepath)) + ext = processor_ext + break return os.path.join( resources_dir, basename + ext ) - def _process_texture(self, filepath, resource, - do_maketx, staging, linearize, force): - """Process a single texture file on disk for publishing. - This will: - 1. Check whether it's already published, if so it will do hardlink - 2. If not published and maketx is enabled, generate a new .tx file. - 3. Compute the destination path for the source file. - Args: - filepath (str): The source file path to process. - do_maketx (bool): Whether to produce a .tx file - Returns: - """ - - fname, ext = os.path.splitext(os.path.basename(filepath)) - - args = [] - if do_maketx: - args.append("maketx") - texture_hash = source_hash(filepath, *args) + def _get_existing_hashed_texture(self, texture_hash): + """Return the first found filepath from a texture hash""" # If source has been published before with the same settings, # then don't reprocess but hardlink from the original existing = find_paths_by_hash(texture_hash) - if existing and not force: - self.log.info("Found hash in database, preparing hardlink..") + if existing: source = next((p for p in existing if os.path.exists(p)), None) if source: - return source, HARDLINK, texture_hash + return source else: self.log.warning( - ("Paths not found on disk, " - "skipping hardlink: %s") % (existing,) + "Paths not found on disk, " + "skipping hardlink: {}".format(existing) ) - if do_maketx and ext != ".tx": - # Produce .tx file in staging if source file is not .tx - converted = os.path.join(staging, "resources", fname + ".tx") - additional_args = [ - "--sattrib", - "sourceHash", - texture_hash - ] - if linearize: - if cmds.colorManagementPrefs(query=True, cmEnabled=True): - render_colorspace = cmds.colorManagementPrefs(query=True, - renderingSpaceName=True) # noqa - config_path = cmds.colorManagementPrefs(query=True, - configFilePath=True) # noqa - if not os.path.exists(config_path): - raise RuntimeError("No OCIO config path found!") + def _process_texture(self, + filepath, + processors, + staging_dir, + force_copy, + color_management, + colorspace): + """Process a single texture file on disk for publishing. - color_space_attr = resource["node"] + ".colorSpace" - try: - color_space = cmds.getAttr(color_space_attr) - except ValueError: - # node doesn't have color space attribute - if cmds.loadPlugin("mtoa", quiet=True): - img_info = image_info(filepath) - color_space = guess_colorspace(img_info) - else: - color_space = "Raw" - self.log.info("tx: converting {0} -> {1}".format(color_space, render_colorspace)) # noqa + This will: + 1. Check whether it's already published, if so it will do hardlink + (if the texture hash is found and force copy is not enabled) + 2. It will process the texture using the supplied texture + processors like MakeTX and MakeRSTexBin if enabled. + 3. Compute the destination path for the source file. - additional_args.extend(["--colorconvert", - color_space, - render_colorspace]) - else: + Args: + filepath (str): The source file path to process. + processors (list): List of TextureProcessor processing the texture + staging_dir (str): The staging directory to write to. + force_copy (bool): Whether to force a copy even if a file hash + might have existed already in the project, otherwise + hardlinking the existing file is allowed. + color_management (dict): Maya's Color Management settings from + `lib.get_color_management_preferences` + colorspace (str): The source colorspace of the resources this + texture belongs to. - if cmds.loadPlugin("mtoa", quiet=True): - img_info = image_info(filepath) - color_space = guess_colorspace(img_info) - if color_space == "sRGB": - self.log.info("tx: converting sRGB -> linear") - additional_args.extend(["--colorconvert", - "sRGB", - "Raw"]) - else: - self.log.info("tx: texture's colorspace " - "is already linear") - else: - self.log.warning("cannot guess the colorspace" - "color conversion won't be available!") # noqa + Returns: + TextureResult: The texture result information. + """ - - additional_args.extend(["--colorconfig", config_path]) - # Ensure folder exists - if not os.path.exists(os.path.dirname(converted)): - os.makedirs(os.path.dirname(converted)) - - self.log.info("Generating .tx file for %s .." % filepath) - maketx( - filepath, - converted, - additional_args, - self.log + if len(processors) > 1: + raise KnownPublishError( + "More than one texture processor not supported. " + "Current processors enabled: {}".format(processors) ) - return converted, COPY, texture_hash + for processor in processors: + self.log.debug("Processing texture {} with processor {}".format( + filepath, processor + )) - return filepath, COPY, texture_hash + processed_result = processor.process(filepath, + colorspace, + color_management, + staging_dir) + if not processed_result: + raise RuntimeError("Texture Processor {} returned " + "no result.".format(processor)) + self.log.info("Generated processed " + "texture: {}".format(processed_result.path)) + + # TODO: Currently all processors force copy instead of allowing + # hardlinks using source hashes. This should be refactored + return processed_result + + # No texture processing for this file + texture_hash = source_hash(filepath) + if not force_copy: + existing = self._get_existing_hashed_texture(filepath) + if existing: + self.log.info("Found hash in database, preparing hardlink..") + return TextureResult( + path=filepath, + file_hash=texture_hash, + colorspace=colorspace, + transfer_mode=HARDLINK + ) + + return TextureResult( + path=filepath, + file_hash=texture_hash, + colorspace=colorspace, + transfer_mode=COPY + ) class ExtractModelRenderSets(ExtractLook): diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index 0628623e88..cf610ac6b4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -102,7 +102,7 @@ class ExtractMultiverseUsdOverride(publish.Extractor): long=True) self.log.info("Collected object {}".format(members)) - # TODO: Deal with asset, composition, overide with options. + # TODO: Deal with asset, composition, override with options. import multiverse time_opts = None diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 94571ff731..27bd7dc8ea 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -1,5 +1,6 @@ import os import json +import contextlib import clique import capture @@ -11,6 +12,16 @@ from maya import cmds import pymel.core as pm +@contextlib.contextmanager +def panel_camera(panel, camera): + original_camera = cmds.modelPanel(panel, query=True, camera=True) + try: + cmds.modelPanel(panel, edit=True, camera=camera) + yield + finally: + cmds.modelPanel(panel, edit=True, camera=original_camera) + + class ExtractPlayblast(publish.Extractor): """Extract viewport playblast. @@ -25,6 +36,16 @@ class ExtractPlayblast(publish.Extractor): optional = True capture_preset = {} + def _capture(self, preset): + self.log.info( + "Using preset:\n{}".format( + json.dumps(preset, sort_keys=True, indent=4) + ) + ) + + path = capture.capture(log=self.log, **preset) + self.log.debug("playblast path {}".format(path)) + def process(self, instance): self.log.info("Extracting capture..") @@ -43,7 +64,7 @@ class ExtractPlayblast(publish.Extractor): self.log.info("start: {}, end: {}".format(start, end)) # get cameras - camera = instance.data['review_camera'] + camera = instance.data["review_camera"] preset = lib.load_capture_preset(data=self.capture_preset) # Grab capture presets from the project settings @@ -57,23 +78,23 @@ class ExtractPlayblast(publish.Extractor): asset_height = asset_data.get("resolutionHeight") review_instance_width = instance.data.get("review_width") review_instance_height = instance.data.get("review_height") - preset['camera'] = camera + preset["camera"] = camera # Tests if project resolution is set, # if it is a value other than zero, that value is # used, if not then the asset resolution is # used if review_instance_width and review_instance_height: - preset['width'] = review_instance_width - preset['height'] = review_instance_height + preset["width"] = review_instance_width + preset["height"] = review_instance_height elif width_preset and height_preset: - preset['width'] = width_preset - preset['height'] = height_preset + preset["width"] = width_preset + preset["height"] = height_preset elif asset_width and asset_height: - preset['width'] = asset_width - preset['height'] = asset_height - preset['start_frame'] = start - preset['end_frame'] = end + preset["width"] = asset_width + preset["height"] = asset_height + preset["start_frame"] = start + preset["end_frame"] = end # Enforce persisting camera depth of field camera_options = preset.setdefault("camera_options", {}) @@ -86,8 +107,8 @@ class ExtractPlayblast(publish.Extractor): self.log.info("Outputting images to %s" % path) - preset['filename'] = path - preset['overwrite'] = True + preset["filename"] = path + preset["overwrite"] = True pm.refresh(f=True) @@ -114,7 +135,8 @@ class ExtractPlayblast(publish.Extractor): # Disable Pan/Zoom. pan_zoom = cmds.getAttr("{}.panZoomEnabled".format(preset["camera"])) - cmds.setAttr("{}.panZoomEnabled".format(preset["camera"]), False) + preset.pop("pan_zoom", None) + preset["camera_options"]["panZoomEnabled"] = instance.data["panZoom"] # Need to explicitly enable some viewport changes so the viewport is # refreshed ahead of playblasting. @@ -136,30 +158,39 @@ class ExtractPlayblast(publish.Extractor): ) override_viewport_options = ( - capture_presets['Viewport Options']['override_viewport_options'] + capture_presets["Viewport Options"]["override_viewport_options"] ) - with lib.maintained_time(): - filename = preset.get("filename", "%TEMP%") - # Force viewer to False in call to capture because we have our own - # viewer opening call to allow a signal to trigger between - # playblast and viewer - preset['viewer'] = False + # Force viewer to False in call to capture because we have our own + # viewer opening call to allow a signal to trigger between + # playblast and viewer + preset["viewer"] = False - # Update preset with current panel setting - # if override_viewport_options is turned off - if not override_viewport_options: - panel_preset = capture.parse_view(instance.data["panel"]) - panel_preset.pop("camera") - preset.update(panel_preset) + # Update preset with current panel setting + # if override_viewport_options is turned off + if not override_viewport_options: + panel_preset = capture.parse_view(instance.data["panel"]) + panel_preset.pop("camera") + preset.update(panel_preset) - self.log.info( - "Using preset:\n{}".format( - json.dumps(preset, sort_keys=True, indent=4) + # Need to ensure Python 2 compatibility. + # TODO: Remove once dropping Python 2. + if getattr(contextlib, "nested", None): + # Python 3 compatibility. + with contextlib.nested( + lib.maintained_time(), + panel_camera(instance.data["panel"], preset["camera"]) + ): + self._capture(preset) + else: + # Python 2 compatibility. + with contextlib.ExitStack() as stack: + stack.enter_context(lib.maintained_time()) + stack.enter_context( + panel_camera(instance.data["panel"], preset["camera"]) ) - ) - path = capture.capture(log=self.log, **preset) + self._capture(preset) # Restoring viewport options. if viewport_defaults: @@ -169,18 +200,17 @@ class ExtractPlayblast(publish.Extractor): cmds.setAttr("{}.panZoomEnabled".format(preset["camera"]), pan_zoom) - self.log.debug("playblast path {}".format(path)) - collected_files = os.listdir(stagingdir) patterns = [clique.PATTERNS["frames"]] collections, remainder = clique.assemble(collected_files, minimum_items=1, patterns=patterns) + filename = preset.get("filename", "%TEMP%") self.log.debug("filename {}".format(filename)) frame_collection = None for collection in collections: - filebase = collection.format('{head}').rstrip(".") + filebase = collection.format("{head}").rstrip(".") self.log.debug("collection head {}".format(filebase)) if filebase in filename: frame_collection = collection @@ -204,15 +234,14 @@ class ExtractPlayblast(publish.Extractor): collected_files = collected_files[0] representation = { - 'name': 'png', - 'ext': 'png', - 'files': collected_files, + "name": self.capture_preset["Codec"]["compression"], + "ext": self.capture_preset["Codec"]["compression"], + "files": collected_files, "stagingDir": stagingdir, "frameStart": start, "frameEnd": end, - 'fps': fps, - 'preview': True, - 'tags': tags, - 'camera_name': camera_node_name + "fps": fps, + "tags": tags, + "camera_name": camera_node_name } instance.data["representations"].append(representation) diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 1d94bd58c5..f2d084b828 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -26,28 +26,28 @@ class ExtractThumbnail(publish.Extractor): def process(self, instance): self.log.info("Extracting capture..") - camera = instance.data['review_camera'] + camera = instance.data["review_camera"] - capture_preset = ( - instance.context.data["project_settings"]['maya']['publish']['ExtractPlayblast']['capture_preset'] - ) + maya_setting = instance.context.data["project_settings"]["maya"] + plugin_setting = maya_setting["publish"]["ExtractPlayblast"] + capture_preset = plugin_setting["capture_preset"] override_viewport_options = ( - capture_preset['Viewport Options']['override_viewport_options'] + capture_preset["Viewport Options"]["override_viewport_options"] ) try: preset = lib.load_capture_preset(data=capture_preset) except KeyError as ke: - self.log.error('Error loading capture presets: {}'.format(str(ke))) + self.log.error("Error loading capture presets: {}".format(str(ke))) preset = {} - self.log.info('Using viewport preset: {}'.format(preset)) + self.log.info("Using viewport preset: {}".format(preset)) # preset["off_screen"] = False - preset['camera'] = camera - preset['start_frame'] = instance.data["frameStart"] - preset['end_frame'] = instance.data["frameStart"] - preset['camera_options'] = { + preset["camera"] = camera + preset["start_frame"] = instance.data["frameStart"] + preset["end_frame"] = instance.data["frameStart"] + preset["camera_options"] = { "displayGateMask": False, "displayResolution": False, "displayFilmGate": False, @@ -74,14 +74,14 @@ class ExtractThumbnail(publish.Extractor): # used, if not then the asset resolution is # used if review_instance_width and review_instance_height: - preset['width'] = review_instance_width - preset['height'] = review_instance_height + preset["width"] = review_instance_width + preset["height"] = review_instance_height elif width_preset and height_preset: - preset['width'] = width_preset - preset['height'] = height_preset + preset["width"] = width_preset + preset["height"] = height_preset elif asset_width and asset_height: - preset['width'] = asset_width - preset['height'] = asset_height + preset["width"] = asset_width + preset["height"] = asset_height # Create temp directory for thumbnail # - this is to avoid "override" of source file @@ -96,8 +96,8 @@ class ExtractThumbnail(publish.Extractor): self.log.info("Outputting images to %s" % path) - preset['filename'] = path - preset['overwrite'] = True + preset["filename"] = path + preset["overwrite"] = True pm.refresh(f=True) @@ -123,14 +123,14 @@ class ExtractThumbnail(publish.Extractor): preset["viewport_options"] = {"imagePlane": image_plane} # Disable Pan/Zoom. - pan_zoom = cmds.getAttr("{}.panZoomEnabled".format(preset["camera"])) - cmds.setAttr("{}.panZoomEnabled".format(preset["camera"]), False) + preset.pop("pan_zoom", None) + preset["camera_options"]["panZoomEnabled"] = instance.data["panZoom"] with lib.maintained_time(): # Force viewer to False in call to capture because we have our own # viewer opening call to allow a signal to trigger between # playblast and viewer - preset['viewer'] = False + preset["viewer"] = False # Update preset with current panel setting # if override_viewport_options is turned off @@ -145,17 +145,15 @@ class ExtractThumbnail(publish.Extractor): _, thumbnail = os.path.split(playblast) - cmds.setAttr("{}.panZoomEnabled".format(preset["camera"]), pan_zoom) - self.log.info("file list {}".format(thumbnail)) if "representations" not in instance.data: instance.data["representations"] = [] representation = { - 'name': 'thumbnail', - 'ext': 'jpg', - 'files': thumbnail, + "name": "thumbnail", + "ext": "jpg", + "files": thumbnail, "stagingDir": dst_staging, "thumbnail": True } diff --git a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py index b90885663c..d8e8554b68 100644 --- a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py +++ b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py @@ -30,7 +30,7 @@ class ResetXgenAttributes(pyblish.api.InstancePlugin): cmds.setAttr(palette + ".xgExportAsDelta", True) # Need to save the scene, cause the attribute changes above does not - # mark the scene as modified so user can exit without commiting the + # mark the scene as modified so user can exit without committing the # changes. self.log.info("Saving changes.") cmds.file(save=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_camera_attributes.py b/openpype/hosts/maya/plugins/publish/validate_camera_attributes.py index bd1529e252..13ea53a357 100644 --- a/openpype/hosts/maya/plugins/publish/validate_camera_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_camera_attributes.py @@ -8,7 +8,7 @@ from openpype.pipeline.publish import ValidateContentsOrder class ValidateCameraAttributes(pyblish.api.InstancePlugin): """Validates Camera has no invalid attribute keys or values. - The Alembic file format does not a specifc subset of attributes as such + The Alembic file format does not a specific subset of attributes as such we validate that no values are set there as the output will not match the current scene. For example the preScale, film offsets and film roll. diff --git a/openpype/hosts/maya/plugins/publish/validate_look_color_space.py b/openpype/hosts/maya/plugins/publish/validate_look_color_space.py deleted file mode 100644 index b1bdeb7541..0000000000 --- a/openpype/hosts/maya/plugins/publish/validate_look_color_space.py +++ /dev/null @@ -1,26 +0,0 @@ -from maya import cmds - -import pyblish.api -from openpype.pipeline.publish import ValidateContentsOrder -from openpype.pipeline import PublishValidationError - - -class ValidateMayaColorSpace(pyblish.api.InstancePlugin): - """ - Check if the OCIO Color Management and maketx options - enabled at the same time - """ - - order = ValidateContentsOrder - families = ['look'] - hosts = ['maya'] - label = 'Color Management with maketx' - - def process(self, instance): - ocio_maya = cmds.colorManagementPrefs(q=True, - cmConfigFileEnabled=True, - cmEnabled=True) - maketx = instance.data["maketx"] - - if ocio_maya and maketx: - raise PublishValidationError("Maya is color managed and maketx option is on. OpenPype doesn't support this combination yet.") # noqa diff --git a/openpype/hosts/maya/plugins/publish/validate_look_contents.py b/openpype/hosts/maya/plugins/publish/validate_look_contents.py index 53501d11e5..2d38099f0f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_contents.py @@ -1,6 +1,7 @@ import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder +from maya import cmds # noqa class ValidateLookContents(pyblish.api.InstancePlugin): @@ -85,6 +86,7 @@ class ValidateLookContents(pyblish.api.InstancePlugin): invalid.add(instance.name) return list(invalid) + @classmethod def validate_looks(cls, instance): @@ -112,3 +114,23 @@ class ValidateLookContents(pyblish.api.InstancePlugin): invalid.append(node) return invalid + + @classmethod + def validate_renderer(cls, instance): + # TODO: Rewrite this to be more specific and configurable + renderer = cmds.getAttr( + 'defaultRenderGlobals.currentRenderer').lower() + do_maketx = instance.data.get("maketx", False) + do_rstex = instance.data.get("rstex", False) + processors = [] + + if do_maketx: + processors.append('arnold') + if do_rstex: + processors.append('redshift') + + for processor in processors: + if processor == renderer: + continue + else: + cls.log.error("Converted texture does not match current renderer.") # noqa diff --git a/openpype/hosts/maya/plugins/publish/validate_maya_units.py b/openpype/hosts/maya/plugins/publish/validate_maya_units.py index 357dde692c..011df0846c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_maya_units.py +++ b/openpype/hosts/maya/plugins/publish/validate_maya_units.py @@ -34,7 +34,7 @@ class ValidateMayaUnits(pyblish.api.ContextPlugin): fps = context.data.get('fps') - # TODO repace query with using 'context.data["assetEntity"]' + # TODO replace query with using 'context.data["assetEntity"]' asset_doc = get_current_project_asset() asset_fps = mayalib.convert_to_maya_fps(asset_doc["data"]["fps"]) @@ -86,7 +86,7 @@ class ValidateMayaUnits(pyblish.api.ContextPlugin): cls.log.debug(current_linear) cls.log.info("Setting time unit to match project") - # TODO repace query with using 'context.data["assetEntity"]' + # TODO replace query with using 'context.data["assetEntity"]' asset_doc = get_current_project_asset() asset_fps = asset_doc["data"]["fps"] mayalib.set_scene_fps(asset_fps) diff --git a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py index e583c1edba..04db5a061b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py @@ -42,7 +42,8 @@ class ValidateMvLookContents(pyblish.api.InstancePlugin): resources = instance.data.get("resources", []) for resource in resources: files = resource["files"] - self.log.debug("Resouce '{}', files: [{}]".format(resource, files)) + self.log.debug( + "Resource '{}', files: [{}]".format(resource, files)) node = resource["node"] if len(files) == 0: self.log.error("File node '{}' uses no or non-existing " diff --git a/openpype/hosts/maya/plugins/publish/validate_renderlayer_aovs.py b/openpype/hosts/maya/plugins/publish/validate_renderlayer_aovs.py index 6b6fb03eec..7919a6eaa1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_renderlayer_aovs.py +++ b/openpype/hosts/maya/plugins/publish/validate_renderlayer_aovs.py @@ -37,8 +37,8 @@ class ValidateRenderLayerAOVs(pyblish.api.InstancePlugin): project_name = legacy_io.active_project() asset_doc = instance.data["assetEntity"] - render_passses = instance.data.get("renderPasses", []) - for render_pass in render_passses: + render_passes = instance.data.get("renderPasses", []) + for render_pass in render_passes: is_valid = self.validate_subset_registered( project_name, asset_doc, render_pass ) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 94e2633593..53f340cd2c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -13,6 +13,22 @@ from openpype.pipeline.publish import ( from openpype.hosts.maya.api import lib +def convert_to_int_or_float(string_value): + # Order of types are important here since float can convert string + # representation of integer. + types = [int, float] + for t in types: + try: + result = t(string_value) + except ValueError: + continue + else: + return result + + # Neither integer or float. + return string_value + + def get_redshift_image_format_labels(): """Return nice labels for Redshift image formats.""" var = "$g_redshiftImageFormatLabels" @@ -242,10 +258,6 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.DEFAULT_PADDING, "0" * cls.DEFAULT_PADDING)) # load validation definitions from settings - validation_settings = ( - instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( # noqa: E501 - "{}_render_attributes".format(renderer)) or [] - ) settings_lights_flag = instance.context.data["project_settings"].get( "maya", {}).get( "RenderSettings", {}).get( @@ -253,17 +265,67 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): instance_lights_flag = instance.data.get("renderSetupIncludeLights") if settings_lights_flag != instance_lights_flag: - cls.log.warning('Instance flag for "Render Setup Include Lights" is set to {0} and Settings flag is set to {1}'.format(instance_lights_flag, settings_lights_flag)) # noqa + cls.log.warning( + "Instance flag for \"Render Setup Include Lights\" is set to " + "{} and Settings flag is set to {}".format( + instance_lights_flag, settings_lights_flag + ) + ) # go through definitions and test if such node.attribute exists. # if so, compare its value from the one required. - for attr, value in OrderedDict(validation_settings).items(): - cls.log.debug("{}: {}".format(attr, value)) - if "." not in attr: - cls.log.warning("Skipping invalid attribute defined in " - "validation settings: '{}'".format(attr)) + for attribute, data in cls.get_nodes(instance, renderer).items(): + # Validate the settings has values. + if not data["values"]: + cls.log.error( + "Settings for {}.{} is missing values.".format( + node, attribute + ) + ) continue + for node in data["nodes"]: + try: + render_value = cmds.getAttr( + "{}.{}".format(node, attribute) + ) + except RuntimeError: + invalid = True + cls.log.error( + "Cannot get value of {}.{}".format(node, attribute) + ) + else: + if render_value not in data["values"]: + invalid = True + cls.log.error( + "Invalid value {} set on {}.{}. Expecting " + "{}".format( + render_value, node, attribute, data["values"] + ) + ) + + return invalid + + @classmethod + def get_nodes(cls, instance, renderer): + maya_settings = instance.context.data["project_settings"]["maya"] + validation_settings = ( + maya_settings["publish"]["ValidateRenderSettings"].get( + "{}_render_attributes".format(renderer) + ) or [] + ) + result = {} + for attr, values in OrderedDict(validation_settings).items(): + cls.log.debug("{}: {}".format(attr, values)) + if "." not in attr: + cls.log.warning( + "Skipping invalid attribute defined in validation " + "settings: \"{}\"".format(attr) + ) + continue + + values = [convert_to_int_or_float(v) for v in values] + node_type, attribute_name = attr.split(".", 1) # first get node of that type @@ -271,28 +333,13 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): if not nodes: cls.log.warning( - "No nodes of type '{}' found.".format(node_type)) + "No nodes of type \"{}\" found.".format(node_type) + ) continue - for node in nodes: - try: - render_value = cmds.getAttr( - "{}.{}".format(node, attribute_name)) - except RuntimeError: - invalid = True - cls.log.error( - "Cannot get value of {}.{}".format( - node, attribute_name)) - else: - if str(value) != str(render_value): - invalid = True - cls.log.error( - ("Invalid value {} set on {}.{}. " - "Expecting {}").format( - render_value, node, attribute_name, value) - ) + result[attribute_name] = {"nodes": nodes, "values": values} - return invalid + return result @classmethod def repair(cls, instance): @@ -305,6 +352,12 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): "{aov_separator}", instance.data.get("aovSeparator", "_") ) + for attribute, data in cls.get_nodes(instance, renderer).items(): + if not data["values"]: + continue + for node in data["nodes"]: + lib.set_attribute(attribute, data["values"][0], node) + with lib.renderlayer(layer_node): default = lib.RENDER_ATTRS['default'] render_attrs = lib.RENDER_ATTRS.get(renderer, default) diff --git a/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py b/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py index 0147aa8a52..b2a83a80fb 100644 --- a/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py +++ b/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py @@ -21,7 +21,7 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): - nurbsSurface: _NRB - locator: _LOC - null/group: _GRP - Suffices can also be overriden by project settings. + Suffices can also be overridden by project settings. .. warning:: This grabs the first child shape as a reference and doesn't use the diff --git a/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py b/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py index a864a18cee..06250f5779 100644 --- a/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py +++ b/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py @@ -48,6 +48,18 @@ class ValidateYetiRenderScriptCallbacks(pyblish.api.InstancePlugin): yeti_loaded = cmds.pluginInfo("pgYetiMaya", query=True, loaded=True) + if not yeti_loaded and not cmds.ls(type="pgYetiMaya"): + # The yeti plug-in is available and loaded so at + # this point we don't really care whether the scene + # has any yeti callback set or not since if the callback + # is there it wouldn't error and if it weren't then + # nothing happens because there are no yeti nodes. + cls.log.info( + "Yeti is loaded but no yeti nodes were found. " + "Callback validation skipped.." + ) + return False + renderer = instance.data["renderer"] if renderer == "redshift": cls.log.info("Redshift ignores any pre and post render callbacks") diff --git a/openpype/hosts/maya/tools/mayalookassigner/commands.py b/openpype/hosts/maya/tools/mayalookassigner/commands.py index 2e7a51efde..3d9746511d 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/commands.py +++ b/openpype/hosts/maya/tools/mayalookassigner/commands.py @@ -80,21 +80,7 @@ def get_all_asset_nodes(): Returns: list: list of dictionaries """ - - host = registered_host() - - nodes = [] - for container in host.ls(): - # We are not interested in looks but assets! - if container["loader"] == "LookLoader": - continue - - # Gather all information - container_name = container["objectName"] - nodes += lib.get_container_members(container_name) - - nodes = list(set(nodes)) - return nodes + return cmds.ls(dag=True, noIntermediate=True, long=True) def create_asset_id_hash(nodes): diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 2a14096f0e..157a02b9aa 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -148,7 +148,7 @@ def get_main_window(): def set_node_data(node, knobname, data): """Write data to node invisible knob - Will create new in case it doesnt exists + Will create new in case it doesn't exists or update the one already created. Args: @@ -506,7 +506,7 @@ def get_avalon_knob_data(node, prefix="avalon:", create=True): try: # check if data available on the node test = node[AVALON_DATA_GROUP].value() - log.debug("Only testing if data avalable: `{}`".format(test)) + log.debug("Only testing if data available: `{}`".format(test)) except NameError as e: # if it doesn't then create it log.debug("Creating avalon knob: `{}`".format(e)) @@ -908,11 +908,11 @@ def get_view_process_node(): continue if not ipn_node: - # in case a Viewer node is transfered from + # in case a Viewer node is transferred from # different workfile with old values raise NameError(( "Input process node name '{}' set in " - "Viewer '{}' is does't exists in nodes" + "Viewer '{}' is doesn't exists in nodes" ).format(ipn, v_.name())) ipn_node.setSelected(True) @@ -1662,7 +1662,7 @@ def create_write_node_legacy( tile_color = _data.get("tile_color", "0xff0000ff") GN["tile_color"].setValue(tile_color) - # overrie knob values from settings + # override knob values from settings for knob in knob_overrides: knob_type = knob["type"] knob_name = knob["name"] @@ -2117,7 +2117,7 @@ class WorkfileSettings(object): write_node[knob["name"]].setValue(value) except TypeError: log.warning( - "Legacy workflow didnt work, switching to current") + "Legacy workflow didn't work, switching to current") set_node_knobs_from_settings( write_node, nuke_imageio_writes["knobs"]) @@ -2543,7 +2543,7 @@ def reset_selection(): def select_nodes(nodes): - """Selects all inputed nodes + """Selects all inputted nodes Arguments: nodes (list): nuke nodes to be selected @@ -2560,7 +2560,7 @@ def launch_workfiles_app(): Trigger to show workfiles tool on application launch. Can be executed only once all other calls are ignored. - Workfiles tool show is deffered after application initialization using + Workfiles tool show is deferred after application initialization using QTimer. """ @@ -2581,7 +2581,7 @@ def launch_workfiles_app(): # Show workfiles tool using timer # - this will be probably triggered during initialization in that case # the application is not be able to show uis so it must be - # deffered using timer + # deferred using timer # - timer should be processed when initialization ends # When applications starts to process events. timer = QtCore.QTimer() diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index aec87be5ab..cc3af2a38f 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -594,7 +594,7 @@ class ExporterReview(object): Defaults to None. range (bool, optional): flag for adding ranges. Defaults to False. - custom_tags (list[str], optional): user inputed custom tags. + custom_tags (list[str], optional): user inputted custom tags. Defaults to None. """ add_tags = tags or [] @@ -1110,7 +1110,7 @@ class AbstractWriteRender(OpenPypeCreator): def is_legacy(self): """Check if it needs to run legacy code - In case where `type` key is missing in singe + In case where `type` key is missing in single knob it is legacy project anatomy. Returns: diff --git a/openpype/hosts/nuke/api/utils.py b/openpype/hosts/nuke/api/utils.py index 6bcb752dd1..2b3c35c23a 100644 --- a/openpype/hosts/nuke/api/utils.py +++ b/openpype/hosts/nuke/api/utils.py @@ -87,7 +87,7 @@ def bake_gizmos_recursively(in_group=None): def colorspace_exists_on_node(node, colorspace_name): """ Check if colorspace exists on node - Look through all options in the colorpsace knob, and see if we have an + Look through all options in the colorspace knob, and see if we have an exact match to one of the items. Args: diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index fb0afb3d55..cf85a5ea05 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -42,7 +42,7 @@ class NukeTemplateBuilder(AbstractTemplateBuilder): get_template_preset implementation) Returns: - bool: Wether the template was successfully imported or not + bool: Whether the template was successfully imported or not """ # TODO check if the template is already imported @@ -222,7 +222,7 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): self._imprint_siblings(placeholder) if placeholder.data["nb_children"] == 0: - # save initial nodes postions and dimensions, update them + # save initial nodes positions and dimensions, update them # and set inputs and outputs of loaded nodes self._imprint_inits() @@ -231,7 +231,7 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): elif placeholder.data["siblings"]: # create copies of placeholder siblings for the new loaded nodes, - # set their inputs and outpus and update all nodes positions and + # set their inputs and outputs and update all nodes positions and # dimensions and siblings names siblings = get_nodes_by_names(placeholder.data["siblings"]) @@ -632,7 +632,7 @@ class NukePlaceholderCreatePlugin( self._imprint_siblings(placeholder) if placeholder.data["nb_children"] == 0: - # save initial nodes postions and dimensions, update them + # save initial nodes positions and dimensions, update them # and set inputs and outputs of created nodes self._imprint_inits() @@ -641,7 +641,7 @@ class NukePlaceholderCreatePlugin( elif placeholder.data["siblings"]: # create copies of placeholder siblings for the new created nodes, - # set their inputs and outpus and update all nodes positions and + # set their inputs and outputs and update all nodes positions and # dimensions and siblings names siblings = get_nodes_by_names(placeholder.data["siblings"]) diff --git a/openpype/hosts/nuke/plugins/create/convert_legacy.py b/openpype/hosts/nuke/plugins/create/convert_legacy.py index d7341c625f..c143e4cb27 100644 --- a/openpype/hosts/nuke/plugins/create/convert_legacy.py +++ b/openpype/hosts/nuke/plugins/create/convert_legacy.py @@ -39,7 +39,7 @@ class LegacyConverted(SubsetConvertorPlugin): break if legacy_found: - # if not item do not add legacy instance convertor + # if not item do not add legacy instance converter self.add_convertor_item("Convert legacy instances") def convert(self): diff --git a/openpype/hosts/nuke/plugins/create/create_source.py b/openpype/hosts/nuke/plugins/create/create_source.py index 06cf4e6cbf..57504b5d53 100644 --- a/openpype/hosts/nuke/plugins/create/create_source.py +++ b/openpype/hosts/nuke/plugins/create/create_source.py @@ -85,4 +85,4 @@ class CreateSource(NukeCreator): raise NukeCreatorError("Creator error: No active selection") else: NukeCreatorError( - "Creator error: only supprted with active selection") + "Creator error: only supported with active selection") diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index f227aa161a..67c7877e60 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -54,22 +54,19 @@ class LoadBackdropNodes(load.LoaderPlugin): version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) - first = version_data.get("frameStart", None) - last = version_data.get("frameEnd", None) namespace = namespace or context['asset']['name'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob - add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", - "source", "author", "fps"] + add_keys = ["source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "version": vname, + "colorspaceInput": colorspace, + "objectName": object_name + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -204,18 +201,13 @@ class LoadBackdropNodes(load.LoaderPlugin): name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) - first = version_data.get("frameStart", None) - last = version_data.get("frameEnd", None) namespace = container['namespace'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) - add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", - "source", "author", "fps"] + add_keys = ["source", "author", "fps"] data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, "version": vname, "colorspaceInput": colorspace, "objectName": object_name} diff --git a/openpype/hosts/nuke/plugins/publish/collect_backdrop.py b/openpype/hosts/nuke/plugins/publish/collect_backdrop.py index 8eaefa6854..7d51af7e9e 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/collect_backdrop.py @@ -51,38 +51,10 @@ class CollectBackdrops(pyblish.api.InstancePlugin): instance.data["label"] = "{0} ({1} nodes)".format( bckn.name(), len(instance.data["transientData"]["childNodes"])) - instance.data["families"].append(instance.data["family"]) - - # Get frame range - handle_start = instance.context.data["handleStart"] - handle_end = instance.context.data["handleEnd"] - first_frame = int(nuke.root()["first_frame"].getValue()) - last_frame = int(nuke.root()["last_frame"].getValue()) - # get version version = instance.context.data.get('version') - if not version: - raise RuntimeError("Script name has no version in the name.") + if version: + instance.data['version'] = version - instance.data['version'] = version - - # Add version data to instance - version_data = { - "handles": handle_start, - "handleStart": handle_start, - "handleEnd": handle_end, - "frameStart": first_frame + handle_start, - "frameEnd": last_frame - handle_end, - "version": int(version), - "families": [instance.data["family"]] + instance.data["families"], - "subset": instance.data["subset"], - "fps": instance.context.data["fps"] - } - - instance.data.update({ - "versionData": version_data, - "frameStart": first_frame, - "frameEnd": last_frame - }) self.log.info("Backdrop instance collected: `{}`".format(instance)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index 0008a756bc..536a0698f3 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -189,7 +189,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, }) # make sure rendered sequence on farm will - # be used for exctract review + # be used for extract review if not instance.data["review"]: instance.data["useSequenceForReview"] = False diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data.py b/openpype/hosts/nuke/plugins/publish/extract_review_data.py index dee8248295..c221af40fb 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data.py @@ -25,7 +25,7 @@ class ExtractReviewData(publish.Extractor): # review can be removed since `ProcessSubmittedJobOnFarm` will create # reviewable representation if needed if ( - "render.farm" in instance.data["families"] + instance.data.get("farm") and "review" in instance.data["families"] ): instance.data["families"].remove("review") diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index 67779e9599..e4b7b155cd 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -49,7 +49,12 @@ class ExtractReviewDataLut(publish.Extractor): exporter.stagingDir, exporter.file).replace("\\", "/") instance.data["representations"] += data["representations"] - if "render.farm" in families: + # review can be removed since `ProcessSubmittedJobOnFarm` will create + # reviewable representation if needed + if ( + instance.data.get("farm") + and "review" in instance.data["families"] + ): instance.data["families"].remove("review") self.log.debug( diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 3fcfc2a4b5..956d1a54a3 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -105,10 +105,7 @@ class ExtractReviewDataMov(publish.Extractor): self, instance, o_name, o_data["extension"], multiple_presets) - if ( - "render.farm" in families or - "prerender.farm" in families - ): + if instance.data.get("farm"): if "review" in instance.data["families"]: instance.data["families"].remove("review") diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index a1a0e241c0..f391ca1e7c 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -31,7 +31,7 @@ class ExtractThumbnail(publish.Extractor): def process(self, instance): - if "render.farm" in instance.data["families"]: + if instance.data.get("farm"): return with napi.maintained_selection(): diff --git a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py index 208d4a2498..5f4a5c3ab0 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py @@ -48,7 +48,7 @@ class SelectCenterInNodeGraph(pyblish.api.Action): class ValidateBackdrop(pyblish.api.InstancePlugin): """ Validate amount of nodes on backdrop node in case user - forgoten to add nodes above the publishing backdrop node. + forgotten to add nodes above the publishing backdrop node. """ order = pyblish.api.ValidatorOrder diff --git a/openpype/hosts/photoshop/api/extension/host/index.jsx b/openpype/hosts/photoshop/api/extension/host/index.jsx index 2acec1ebc1..e2711fb960 100644 --- a/openpype/hosts/photoshop/api/extension/host/index.jsx +++ b/openpype/hosts/photoshop/api/extension/host/index.jsx @@ -199,7 +199,7 @@ function getActiveDocumentName(){ function getActiveDocumentFullName(){ /** * Returns file name of active document with file path. - * activeDocument.fullName returns path in URI (eg /c/.. insted of c:/) + * activeDocument.fullName returns path in URI (eg /c/.. instead of c:/) * */ if (documents.length == 0){ return null; @@ -225,7 +225,7 @@ function getSelectedLayers(doc) { * Returns json representation of currently selected layers. * Works in three steps - 1) creates new group with selected layers * 2) traverses this group - * 3) deletes newly created group, not neede + * 3) deletes newly created group, not needed * Bit weird, but Adobe.. **/ if (doc == null){ @@ -284,7 +284,7 @@ function selectLayers(selectedLayers){ existing_ids.push(existing_layers[y]["id"]); } for (var i = 0; i < selectedLayers.length; i++) { - // a check to see if the id stil exists + // a check to see if the id still exists var id = selectedLayers[i]; if(existing_ids.toString().indexOf(id)>=0){ layers[i] = charIDToTypeID( "Lyr " ); diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 89ba6ad4e6..25732446b5 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -66,11 +66,11 @@ class MainThreadItem: return self._result def execute(self): - """Execute callback and store it's result. + """Execute callback and store its result. Method must be called from main thread. Item is marked as `done` when callback execution finished. Store output of callback of exception - information when callback raise one. + information when callback raises one. """ log.debug("Executing process in main thread") if self.done: diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 01022ce0b2..9d7eff0211 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -129,7 +129,6 @@ class ExtractReview(publish.Extractor): "frameStart": 1, "frameEnd": no_of_frames, "fps": fps, - "preview": True, "tags": self.mov_options['tags'] }) diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index f41eb36caf..b3ad20df39 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -250,7 +250,7 @@ def create_timeline_item(media_pool_item: object, media_pool_item, timeline) assert output_timeline_item, AssertionError( - "Track Item with name `{}` doesnt exist on the timeline: `{}`".format( + "Track Item with name `{}` doesn't exist on the timeline: `{}`".format( clip_name, timeline.GetName() )) return output_timeline_item @@ -571,7 +571,7 @@ def create_compound_clip(clip_data, name, folder): # Set current folder to input media_pool_folder: mp.SetCurrentFolder(folder) - # check if clip doesnt exist already: + # check if clip doesn't exist already: clips = folder.GetClipList() cct = next((c for c in clips if c.GetName() in name), None) @@ -582,7 +582,7 @@ def create_compound_clip(clip_data, name, folder): # Create empty timeline in current folder and give name: cct = mp.CreateEmptyTimeline(name) - # check if clip doesnt exist already: + # check if clip doesn't exist already: clips = folder.GetClipList() cct = next((c for c in clips if c.GetName() in name), None) diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index eeb9e65dec..b3717e01ea 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -69,7 +69,7 @@ class OpenPypeMenu(QtWidgets.QWidget): # "Set colorspace from presets", self # ) # reset_resolution_btn = QtWidgets.QPushButton( - # "Reset Resolution from peresets", self + # "Set Resolution from presets", self # ) layout = QtWidgets.QVBoxLayout(self) @@ -108,7 +108,7 @@ class OpenPypeMenu(QtWidgets.QWidget): libload_btn.clicked.connect(self.on_libload_clicked) # rename_btn.clicked.connect(self.on_rename_clicked) # set_colorspace_btn.clicked.connect(self.on_set_colorspace_clicked) - # reset_resolution_btn.clicked.connect(self.on_reset_resolution_clicked) + # reset_resolution_btn.clicked.connect(self.on_set_resolution_clicked) experimental_btn.clicked.connect(self.on_experimental_clicked) def on_workfile_clicked(self): @@ -145,8 +145,8 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_set_colorspace_clicked(self): print("Clicked Set Colorspace") - def on_reset_resolution_clicked(self): - print("Clicked Reset Resolution") + def on_set_resolution_clicked(self): + print("Clicked Set Resolution") def on_experimental_clicked(self): host_tools.show_experimental_tools_dialog() diff --git a/openpype/hosts/resolve/api/menu_style.qss b/openpype/hosts/resolve/api/menu_style.qss index d2d3d1ed37..3d51c7139f 100644 --- a/openpype/hosts/resolve/api/menu_style.qss +++ b/openpype/hosts/resolve/api/menu_style.qss @@ -61,7 +61,7 @@ QVBoxLayout { background-color: #282828; } -#Devider { +#Divider { border: 1px solid #090909; background-color: #585858; } diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 77e30149fd..609cff60f7 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -715,7 +715,7 @@ class PublishClip: # increasing steps by index of rename iteration self.count_steps *= self.rename_index - hierarchy_formating_data = dict() + hierarchy_formatting_data = dict() _data = self.timeline_item_default_data.copy() if self.ui_inputs: # adding tag metadata from ui @@ -749,13 +749,13 @@ class PublishClip: # fill up pythonic expresisons in hierarchy data for k, _v in self.hierarchy_data.items(): - hierarchy_formating_data[k] = _v["value"].format(**_data) + hierarchy_formatting_data[k] = _v["value"].format(**_data) else: # if no gui mode then just pass default data - hierarchy_formating_data = self.hierarchy_data + hierarchy_formatting_data = self.hierarchy_data tag_hierarchy_data = self._solve_tag_hierarchy_data( - hierarchy_formating_data + hierarchy_formatting_data ) tag_hierarchy_data.update({"heroTrack": True}) @@ -792,18 +792,17 @@ class PublishClip: else: self.tag_data.update({"reviewTrack": None}) - - def _solve_tag_hierarchy_data(self, hierarchy_formating_data): + def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve tag data from hierarchy data and templates. """ # fill up clip name and hierarchy keys - hierarchy_filled = self.hierarchy.format(**hierarchy_formating_data) - clip_name_filled = self.clip_name.format(**hierarchy_formating_data) + hierarchy_filled = self.hierarchy.format(**hierarchy_formatting_data) + clip_name_filled = self.clip_name.format(**hierarchy_formatting_data) return { "newClipName": clip_name_filled, "hierarchy": hierarchy_filled, "parents": self.parents, - "hierarchyData": hierarchy_formating_data, + "hierarchyData": hierarchy_formatting_data, "subset": self.subset, "family": self.subset_family, "families": ["clip"] diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py index 7925b0ecf3..6c3b0c3efd 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py @@ -83,9 +83,9 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin): self.log.info(f"Created new instance: {instance_name}") - def convertor(value): + def converter(value): return str(value) self.log.debug("Instance data: {}".format( - json.dumps(new_instance.data, indent=4, default=convertor) + json.dumps(new_instance.data, indent=4, default=converter) )) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py index 2bf3917e2f..96aaae23dc 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py @@ -104,7 +104,7 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): if repr.get(k): repr.pop(k) - # convert files to list if it isnt + # convert files to list if it isn't if not isinstance(files, (tuple, list)): files = [files] @@ -174,7 +174,7 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): continue files = repre["files"] - # Convert files to list if it isnt + # Convert files to list if it isn't if not isinstance(files, (tuple, list)): files = [files] @@ -255,7 +255,9 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): if ext.startswith("."): component["ext"] = ext[1:] - if component["preview"]: + # Remove 'preview' key from representation data + preview = component.pop("preview") + if preview: instance.data["families"].append("review") component["tags"] = ["review"] self.log.debug("Adding review family") diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial.py index 8633d4bf9d..391cace761 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_editorial.py @@ -116,7 +116,7 @@ class CollectEditorial(pyblish.api.InstancePlugin): kwargs = {} if extension == ".edl": # EDL has no frame rate embedded so needs explicit - # frame rate else 24 is asssumed. + # frame rate else 24 is assumed. kwargs["rate"] = get_current_project_asset()["data"]["fps"] instance.data["otio_timeline"] = otio.adapters.read_from_file( diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py index 074c62ea0e..e46fbe6098 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py @@ -29,7 +29,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): for pattern in self.skip_timelines_check): self.log.info("Skipping for {} task".format(instance.data["task"])) - # TODO repace query with using 'instance.data["assetEntity"]' + # TODO replace query with using 'instance.data["assetEntity"]' asset_data = get_current_project_asset(instance.data["asset"])["data"] frame_start = asset_data["frameStart"] frame_end = asset_data["frameEnd"] diff --git a/openpype/hosts/traypublisher/api/editorial.py b/openpype/hosts/traypublisher/api/editorial.py index 293db542a9..e8f76bd314 100644 --- a/openpype/hosts/traypublisher/api/editorial.py +++ b/openpype/hosts/traypublisher/api/editorial.py @@ -8,10 +8,10 @@ from openpype.pipeline.create import CreatorError class ShotMetadataSolver: """ Solving hierarchical metadata - Used during editorial publishing. Works with imput + Used during editorial publishing. Works with input clip name and settings defining python formatable template. Settings also define searching patterns - and its token keys used for formating in templates. + and its token keys used for formatting in templates. """ NO_DECOR_PATERN = re.compile(r"\{([a-z]*?)\}") @@ -40,13 +40,13 @@ class ShotMetadataSolver: """Shot renaming function Args: - data (dict): formating data + data (dict): formatting data Raises: CreatorError: If missing keys Returns: - str: formated new name + str: formatted new name """ shot_rename_template = self.shot_rename[ "shot_rename_template"] @@ -58,7 +58,7 @@ class ShotMetadataSolver: "Make sure all keys in settings are correct:: \n\n" f"From template string {shot_rename_template} > " f"`{_E}` has no equivalent in \n" - f"{list(data.keys())} input formating keys!" + f"{list(data.keys())} input formatting keys!" )) def _generate_tokens(self, clip_name, source_data): @@ -68,7 +68,7 @@ class ShotMetadataSolver: Args: clip_name (str): name of clip in editorial - source_data (dict): data for formating + source_data (dict): data for formatting Raises: CreatorError: if missing key @@ -106,14 +106,14 @@ class ShotMetadataSolver: return output_data def _create_parents_from_settings(self, parents, data): - """Formating parent components. + """formatting parent components. Args: parents (list): list of dict parent components - data (dict): formating data + data (dict): formatting data Raises: - CreatorError: missing formating key + CreatorError: missing formatting key CreatorError: missing token key KeyError: missing parent token @@ -126,7 +126,7 @@ class ShotMetadataSolver: # fill parent keys data template from anatomy data try: - _parent_tokens_formating_data = { + _parent_tokens_formatting_data = { parent_token["name"]: parent_token["value"].format(**data) for parent_token in hierarchy_parents } @@ -143,17 +143,17 @@ class ShotMetadataSolver: for _index, _parent in enumerate( shot_hierarchy["parents_path"].split("/") ): - # format parent token with value which is formated + # format parent token with value which is formatted try: parent_name = _parent.format( - **_parent_tokens_formating_data) + **_parent_tokens_formatting_data) except KeyError as _E: raise CreatorError(( "Make sure all keys in settings are correct : \n\n" f"`{_E}` from template string " f"{shot_hierarchy['parents_path']}, " f" has no equivalent in \n" - f"{list(_parent_tokens_formating_data.keys())} parents" + f"{list(_parent_tokens_formatting_data.keys())} parents" )) parent_token_name = ( @@ -225,7 +225,7 @@ class ShotMetadataSolver: visual_hierarchy = [asset_doc] current_doc = asset_doc - # looping trought all available visual parents + # looping through all available visual parents # if they are not available anymore than it breaks while True: visual_parent_id = current_doc["data"]["visualParent"] @@ -288,7 +288,7 @@ class ShotMetadataSolver: Args: clip_name (str): clip name - source_data (dict): formating data + source_data (dict): formatting data Returns: (str, dict): shot name and hierarchy data @@ -301,19 +301,19 @@ class ShotMetadataSolver: # match clip to shot name at start shot_name = clip_name - # parse all tokens and generate formating data - formating_data = self._generate_tokens(shot_name, source_data) + # parse all tokens and generate formatting data + formatting_data = self._generate_tokens(shot_name, source_data) # generate parents from selected asset parents = self._get_parents_from_selected_asset(asset_doc, project_doc) if self.shot_rename["enabled"]: - shot_name = self._rename_template(formating_data) + shot_name = self._rename_template(formatting_data) self.log.info(f"Renamed shot name: {shot_name}") if self.shot_hierarchy["enabled"]: parents = self._create_parents_from_settings( - parents, formating_data) + parents, formatting_data) if self.shot_add_tasks: tasks = self._generate_tasks_from_settings( diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 73be43444e..0630dfb3da 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -260,7 +260,7 @@ or updating already created. Publishing will create OTIO file. ) if not first_otio_timeline: - # assing otio timeline for multi file to layer + # assign otio timeline for multi file to layer first_otio_timeline = otio_timeline # create otio editorial instance @@ -283,7 +283,7 @@ or updating already created. Publishing will create OTIO file. Args: subset_name (str): name of subset - data (dict): instnance data + data (dict): instance data sequence_path (str): path to sequence file media_path (str): path to media file otio_timeline (otio.Timeline): otio timeline object @@ -315,7 +315,7 @@ or updating already created. Publishing will create OTIO file. kwargs = {} if extension == ".edl": # EDL has no frame rate embedded so needs explicit - # frame rate else 24 is asssumed. + # frame rate else 24 is assumed. kwargs["rate"] = fps kwargs["ignore_timecode_mismatch"] = True @@ -358,7 +358,7 @@ or updating already created. Publishing will create OTIO file. sequence_file_name, first_otio_timeline=None ): - """Helping function fro creating clip instance + """Helping function for creating clip instance Args: otio_timeline (otio.Timeline): otio timeline object @@ -527,7 +527,7 @@ or updating already created. Publishing will create OTIO file. Args: otio_clip (otio.Clip): otio clip object - preset (dict): sigle family preset + preset (dict): single family preset instance_data (dict): instance data parenting_data (dict): shot instance parent data @@ -767,7 +767,7 @@ or updating already created. Publishing will create OTIO file. ] def _validate_clip_for_processing(self, otio_clip): - """Validate otio clip attribues + """Validate otio clip attributes Args: otio_clip (otio.Clip): otio clip object @@ -843,7 +843,7 @@ or updating already created. Publishing will create OTIO file. single_item=False, label="Media files", ), - # TODO: perhpas better would be timecode and fps input + # TODO: perhaps better would be timecode and fps input NumberDef( "timeline_offset", default=0, diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py index 183195a515..c081216481 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py @@ -14,7 +14,7 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): There is also possibility to have reviewable representation which can be stored under 'reviewable' attribute stored on instance data. If there was - already created representation with the same files as 'revieable' containes + already created representation with the same files as 'reviewable' contains Representations can be marked for review and in that case is also added 'review' family to instance families. For review can be marked only one diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index e94e64e04a..6f76c25e0c 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -389,11 +389,11 @@ class MainThreadItem: self.kwargs = kwargs def execute(self): - """Execute callback and store it's result. + """Execute callback and store its result. Method must be called from main thread. Item is marked as `done` when callback execution finished. Store output of callback of exception - information when callback raise one. + information when callback raises one. """ log.debug("Executing process in main thread") if self.done: diff --git a/openpype/hosts/tvpaint/plugins/create/convert_legacy.py b/openpype/hosts/tvpaint/plugins/create/convert_legacy.py index 538c6e4c5e..5cfa1faa50 100644 --- a/openpype/hosts/tvpaint/plugins/create/convert_legacy.py +++ b/openpype/hosts/tvpaint/plugins/create/convert_legacy.py @@ -55,7 +55,7 @@ class TVPaintLegacyConverted(SubsetConvertorPlugin): self._convert_render_layers( to_convert["renderLayer"], current_instances) self._convert_render_passes( - to_convert["renderpass"], current_instances) + to_convert["renderPass"], current_instances) self._convert_render_scenes( to_convert["renderScene"], current_instances) self._convert_workfiles( @@ -116,7 +116,7 @@ class TVPaintLegacyConverted(SubsetConvertorPlugin): render_layers_by_group_id = {} for instance in current_instances: if instance.get("creator_identifier") == "render.layer": - group_id = instance["creator_identifier"]["group_id"] + group_id = instance["creator_attributes"]["group_id"] render_layers_by_group_id[group_id] = instance for render_pass in render_passes: diff --git a/openpype/hosts/tvpaint/plugins/create/create_render.py b/openpype/hosts/tvpaint/plugins/create/create_render.py index 9711024c79..2369c7329f 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render.py @@ -415,11 +415,11 @@ class CreateRenderPass(TVPaintCreator): .get("creator_attributes", {}) .get("render_layer_instance_id") ) - render_layer_info = render_layers.get(render_layer_instance_id) + render_layer_info = render_layers.get(render_layer_instance_id, {}) self.update_instance_labels( instance_data, - render_layer_info["variant"], - render_layer_info["template_data"] + render_layer_info.get("variant"), + render_layer_info.get("template_data") ) instance = CreatedInstance.from_existing(instance_data, self) self._add_instance_to_context(instance) @@ -607,11 +607,11 @@ class CreateRenderPass(TVPaintCreator): current_instances = self.host.list_instances() render_layers = [ { - "value": instance["instance_id"], - "label": instance["subset"] + "value": inst["instance_id"], + "label": inst["subset"] } - for instance in current_instances - if instance["creator_identifier"] == CreateRenderlayer.identifier + for inst in current_instances + if inst.get("creator_identifier") == CreateRenderlayer.identifier ] if not render_layers: render_layers.append({"value": None, "label": "N/A"}) @@ -697,6 +697,7 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): ["create"] ["auto_detect_render"] ) + self.enabled = plugin_settings.get("enabled", False) self.allow_group_rename = plugin_settings["allow_group_rename"] self.group_name_template = plugin_settings["group_name_template"] self.group_idx_offset = plugin_settings["group_idx_offset"] diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py b/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py index 5eb702a1da..63f04cf3ce 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py @@ -22,9 +22,11 @@ class CollectOutputFrameRange(pyblish.api.InstancePlugin): context = instance.context frame_start = asset_doc["data"]["frameStart"] + fps = asset_doc["data"]["fps"] frame_end = frame_start + ( context.data["sceneMarkOut"] - context.data["sceneMarkIn"] ) + instance.data["fps"] = fps instance.data["frameStart"] = frame_start instance.data["frameEnd"] = frame_end self.log.info( diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml index e7be735888..5832c74350 100644 --- a/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml @@ -1,7 +1,7 @@ -Layers visiblity +Layers visibility ## All layers are not visible Layers visibility was changed during publishing which caused that all layers for subset "{instance_name}" are hidden. diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml index 7397f6ef0b..0fc03c2948 100644 --- a/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml @@ -11,7 +11,7 @@ Your scene does not contain metadata about {missing_metadata}. Resave the scene using Workfiles tool or hit the "Repair" button on the right. -### How this could happend? +### How this could happen? You're using scene file that was not created using Workfiles tool. diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml index c4ffafc8b5..bb57e93bf2 100644 --- a/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml @@ -13,7 +13,7 @@ If the workfile belongs to project "{env_project_name}" then use Workfiles tool Otherwise close TVPaint and launch it again from project you want to publish in. -### How this could happend? +### How this could happen? You've opened workfile from different project. You've opened TVPaint on a task from "{env_project_name}" then you've opened TVPaint again on task from "{workfile_project_name}" without closing the TVPaint. Because TVPaint can run only once the project didn't change. diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py index 7e35726030..9347960d3f 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py @@ -1,5 +1,8 @@ import pyblish.api -from openpype.pipeline import PublishXmlValidationError +from openpype.pipeline import ( + PublishXmlValidationError, + OptionalPyblishPluginMixin, +) from openpype.hosts.tvpaint.api.pipeline import ( list_instances, write_instances, @@ -31,8 +34,11 @@ class FixAssetNames(pyblish.api.Action): write_instances(new_instance_items) -class ValidateAssetNames(pyblish.api.ContextPlugin): - """Validate assset name present on instance. +class ValidateAssetName( + OptionalPyblishPluginMixin, + pyblish.api.ContextPlugin +): + """Validate asset name present on instance. Asset name on instance should be the same as context's. """ @@ -43,6 +49,8 @@ class ValidateAssetNames(pyblish.api.ContextPlugin): actions = [FixAssetNames] def process(self, context): + if not self.is_active(context.data): + return context_asset_name = context.data["asset"] for instance in context: asset_name = instance.data.get("asset") diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py b/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py index 6a496a2e49..8e52a636f4 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py @@ -11,7 +11,7 @@ class ValidateLayersVisiblity(pyblish.api.InstancePlugin): families = ["review", "render"] def process(self, instance): - layers = instance.data["layers"] + layers = instance.data.get("layers") # Instance have empty layers # - it is not job of this validator to check that if not layers: diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py index 0030b0fd1c..7b2cc62bb5 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py @@ -1,7 +1,10 @@ import json import pyblish.api -from openpype.pipeline import PublishXmlValidationError +from openpype.pipeline import ( + PublishXmlValidationError, + OptionalPyblishPluginMixin, +) from openpype.hosts.tvpaint.api.lib import execute_george @@ -23,7 +26,10 @@ class ValidateMarksRepair(pyblish.api.Action): ) -class ValidateMarks(pyblish.api.ContextPlugin): +class ValidateMarks( + OptionalPyblishPluginMixin, + pyblish.api.ContextPlugin +): """Validate mark in and out are enabled and it's duration. Mark In/Out does not have to match frameStart and frameEnd but duration is @@ -59,6 +65,9 @@ class ValidateMarks(pyblish.api.ContextPlugin): } def process(self, context): + if not self.is_active(context.data): + return + current_data = { "markIn": context.data["sceneMarkIn"], "markInState": context.data["sceneMarkInState"], diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py b/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py index 4473e4b1b7..0ab8e811f5 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py @@ -1,11 +1,17 @@ import json import pyblish.api -from openpype.pipeline import PublishXmlValidationError +from openpype.pipeline import ( + PublishXmlValidationError, + OptionalPyblishPluginMixin, +) # TODO @iLliCiTiT add fix action for fps -class ValidateProjectSettings(pyblish.api.ContextPlugin): +class ValidateProjectSettings( + OptionalPyblishPluginMixin, + pyblish.api.ContextPlugin +): """Validate scene settings against database.""" label = "Validate Scene Settings" @@ -13,6 +19,9 @@ class ValidateProjectSettings(pyblish.api.ContextPlugin): optional = True def process(self, context): + if not self.is_active(context.data): + return + expected_data = context.data["assetEntity"]["data"] scene_data = { "fps": context.data.get("sceneFps"), diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py index 066e54c670..229ccfcd18 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py @@ -1,5 +1,8 @@ import pyblish.api -from openpype.pipeline import PublishXmlValidationError +from openpype.pipeline import ( + PublishXmlValidationError, + OptionalPyblishPluginMixin, +) from openpype.hosts.tvpaint.api.lib import execute_george @@ -14,7 +17,10 @@ class RepairStartFrame(pyblish.api.Action): execute_george("tv_startframe 0") -class ValidateStartFrame(pyblish.api.ContextPlugin): +class ValidateStartFrame( + OptionalPyblishPluginMixin, + pyblish.api.ContextPlugin +): """Validate start frame being at frame 0.""" label = "Validate Start Frame" @@ -24,6 +30,9 @@ class ValidateStartFrame(pyblish.api.ContextPlugin): optional = True def process(self, context): + if not self.is_active(context.data): + return + start_frame = execute_george("tv_startframe") if start_frame == 0: return diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 8a5a459194..1a7c626984 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -306,7 +306,7 @@ def imprint(node, data): def show_tools_popup(): """Show popup with tools. - Popup will disappear on click or loosing focus. + Popup will disappear on click or losing focus. """ from openpype.hosts.unreal.api import tools_ui diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp index 008025e816..34faba1f49 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp @@ -31,7 +31,7 @@ bool UOpenPypeLib::SetFolderColor(const FString& FolderPath, const FLinearColor& } /** - * Returns all poperties on given object + * Returns all properties on given object * @param cls - class * @return TArray of properties */ diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h index c960bbf190..322a23a3e8 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h @@ -16,7 +16,7 @@ /** * @brief This enum values are humanly readable mapping of error codes. * Here should be all error codes to be possible find what went wrong. -* TODO: In the future should exists an web document where is mapped error code & what problem occured & how to repair it... +* TODO: In the future a web document should exists with the mapped error code & what problem occurred & how to repair it... */ UENUM() namespace EOP_ActionResult @@ -27,11 +27,11 @@ namespace EOP_ActionResult ProjectNotCreated, ProjectNotLoaded, ProjectNotSaved, - //....Here insert another values + //....Here insert another values //Do not remove! //Usable for looping through enum values - __Last UMETA(Hidden) + __Last UMETA(Hidden) }; } @@ -63,10 +63,10 @@ public: private: /** @brief Action status */ - EOP_ActionResult::Type Status; + EOP_ActionResult::Type Status; /** @brief Optional reason of fail */ - FText Reason; + FText Reason; public: /** @@ -77,7 +77,7 @@ public: EOP_ActionResult::Type& GetStatus(); FText& GetReason(); -private: +private: void TryLog() const; }; diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp index 008025e816..34faba1f49 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp @@ -31,7 +31,7 @@ bool UOpenPypeLib::SetFolderColor(const FString& FolderPath, const FLinearColor& } /** - * Returns all poperties on given object + * Returns all properties on given object * @param cls - class * @return TArray of properties */ diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h index c960bbf190..322a23a3e8 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h @@ -16,7 +16,7 @@ /** * @brief This enum values are humanly readable mapping of error codes. * Here should be all error codes to be possible find what went wrong. -* TODO: In the future should exists an web document where is mapped error code & what problem occured & how to repair it... +* TODO: In the future a web document should exists with the mapped error code & what problem occurred & how to repair it... */ UENUM() namespace EOP_ActionResult @@ -27,11 +27,11 @@ namespace EOP_ActionResult ProjectNotCreated, ProjectNotLoaded, ProjectNotSaved, - //....Here insert another values + //....Here insert another values //Do not remove! //Usable for looping through enum values - __Last UMETA(Hidden) + __Last UMETA(Hidden) }; } @@ -63,10 +63,10 @@ public: private: /** @brief Action status */ - EOP_ActionResult::Type Status; + EOP_ActionResult::Type Status; /** @brief Optional reason of fail */ - FText Reason; + FText Reason; public: /** @@ -77,7 +77,7 @@ public: EOP_ActionResult::Type& GetStatus(); FText& GetReason(); -private: +private: void TryLog() const; }; diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index ca6b0ce736..2496440e5f 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -171,7 +171,7 @@ class CameraLoader(plugin.Loader): project_name = legacy_io.active_project() # TODO refactor - # - Creationg of hierarchy should be a function in unreal integration + # - Creating of hierarchy should be a function in unreal integration # - it's used in multiple loaders but must not be loader's logic # - hard to say what is purpose of the loop # - variables does not match their meaning diff --git a/openpype/hosts/unreal/ue_workers.py b/openpype/hosts/unreal/ue_workers.py index 00f83a7d7a..d1740124a8 100644 --- a/openpype/hosts/unreal/ue_workers.py +++ b/openpype/hosts/unreal/ue_workers.py @@ -5,29 +5,38 @@ import re import subprocess from distutils import dir_util from pathlib import Path -from typing import List +from typing import List, Union import openpype.hosts.unreal.lib as ue_lib from qtpy import QtCore -def parse_comp_progress(line: str, progress_signal: QtCore.Signal(int)) -> int: - match = re.search('\[[1-9]+/[0-9]+\]', line) +def parse_comp_progress(line: str, progress_signal: QtCore.Signal(int)): + match = re.search(r"\[[1-9]+/[0-9]+]", line) if match is not None: - split: list[str] = match.group().split('/') + split: list[str] = match.group().split("/") curr: float = float(split[0][1:]) total: float = float(split[1][:-1]) progress_signal.emit(int((curr / total) * 100.0)) -def parse_prj_progress(line: str, progress_signal: QtCore.Signal(int)) -> int: - match = re.search('@progress', line) +def parse_prj_progress(line: str, progress_signal: QtCore.Signal(int)): + match = re.search("@progress", line) if match is not None: - percent_match = re.search('\d{1,3}', line) + percent_match = re.search(r"\d{1,3}", line) progress_signal.emit(int(percent_match.group())) +def retrieve_exit_code(line: str): + match = re.search(r"ExitCode=\d+", line) + if match is not None: + split: list[str] = match.group().split("=") + return int(split[1]) + + return None + + class UEProjectGenerationWorker(QtCore.QObject): finished = QtCore.Signal(str) failed = QtCore.Signal(str) @@ -77,16 +86,19 @@ class UEProjectGenerationWorker(QtCore.QObject): if self.dev_mode: stage_count = 4 - self.stage_begin.emit(f'Generating a new UE project ... 1 out of ' - f'{stage_count}') + self.stage_begin.emit( + ("Generating a new UE project ... 1 out of " + f"{stage_count}")) - commandlet_cmd = [f'{ue_editor_exe.as_posix()}', - f'{cmdlet_project.as_posix()}', - f'-run=OPGenerateProject', - f'{project_file.resolve().as_posix()}'] + commandlet_cmd = [ + f"{ue_editor_exe.as_posix()}", + f"{cmdlet_project.as_posix()}", + "-run=OPGenerateProject", + f"{project_file.resolve().as_posix()}", + ] if self.dev_mode: - commandlet_cmd.append('-GenerateCode') + commandlet_cmd.append("-GenerateCode") gen_process = subprocess.Popen(commandlet_cmd, stdout=subprocess.PIPE, @@ -94,24 +106,27 @@ class UEProjectGenerationWorker(QtCore.QObject): for line in gen_process.stdout: decoded_line = line.decode(errors="replace") - print(decoded_line, end='') + print(decoded_line, end="") self.log.emit(decoded_line) gen_process.stdout.close() return_code = gen_process.wait() if return_code and return_code != 0: - msg = 'Failed to generate ' + self.project_name \ - + f' project! Exited with return code {return_code}' + msg = ( + f"Failed to generate {self.project_name} " + f"project! Exited with return code {return_code}" + ) self.failed.emit(msg, return_code) raise RuntimeError(msg) print("--- Project has been generated successfully.") - self.stage_begin.emit(f'Writing the Engine ID of the build UE ... 1' - f' out of {stage_count}') + self.stage_begin.emit( + (f"Writing the Engine ID of the build UE ... 1" + f" out of {stage_count}")) if not project_file.is_file(): - msg = "Failed to write the Engine ID into .uproject file! Can " \ - "not read!" + msg = ("Failed to write the Engine ID into .uproject file! Can " + "not read!") self.failed.emit(msg) raise RuntimeError(msg) @@ -125,13 +140,14 @@ class UEProjectGenerationWorker(QtCore.QObject): pf.seek(0) json.dump(pf_json, pf, indent=4) pf.truncate() - print(f'--- Engine ID has been written into the project file') + print("--- Engine ID has been written into the project file") self.progress.emit(90) if self.dev_mode: # 2nd stage - self.stage_begin.emit(f'Generating project files ... 2 out of ' - f'{stage_count}') + self.stage_begin.emit( + (f"Generating project files ... 2 out of " + f"{stage_count}")) self.progress.emit(0) ubt_path = ue_lib.get_path_to_ubt(self.engine_path, @@ -154,8 +170,8 @@ class UEProjectGenerationWorker(QtCore.QObject): stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in gen_proc.stdout: - decoded_line: str = line.decode(errors='replace') - print(decoded_line, end='') + decoded_line: str = line.decode(errors="replace") + print(decoded_line, end="") self.log.emit(decoded_line) parse_prj_progress(decoded_line, self.progress) @@ -163,13 +179,13 @@ class UEProjectGenerationWorker(QtCore.QObject): return_code = gen_proc.wait() if return_code and return_code != 0: - msg = 'Failed to generate project files! ' \ - f'Exited with return code {return_code}' + msg = ("Failed to generate project files! " + f"Exited with return code {return_code}") self.failed.emit(msg, return_code) raise RuntimeError(msg) - self.stage_begin.emit(f'Building the project ... 3 out of ' - f'{stage_count}') + self.stage_begin.emit( + f"Building the project ... 3 out of {stage_count}") self.progress.emit(0) # 3rd stage build_prj_cmd = [ubt_path.as_posix(), @@ -177,16 +193,16 @@ class UEProjectGenerationWorker(QtCore.QObject): arch, "Development", "-TargetType=Editor", - f'-Project={project_file}', - f'{project_file}', + f"-Project={project_file}", + f"{project_file}", "-IgnoreJunk"] build_prj_proc = subprocess.Popen(build_prj_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in build_prj_proc.stdout: - decoded_line: str = line.decode(errors='replace') - print(decoded_line, end='') + decoded_line: str = line.decode(errors="replace") + print(decoded_line, end="") self.log.emit(decoded_line) parse_comp_progress(decoded_line, self.progress) @@ -194,16 +210,17 @@ class UEProjectGenerationWorker(QtCore.QObject): return_code = build_prj_proc.wait() if return_code and return_code != 0: - msg = 'Failed to build project! ' \ - f'Exited with return code {return_code}' + msg = ("Failed to build project! " + f"Exited with return code {return_code}") self.failed.emit(msg, return_code) raise RuntimeError(msg) # ensure we have PySide2 installed in engine self.progress.emit(0) - self.stage_begin.emit(f'Checking PySide2 installation... {stage_count}' - f' out of {stage_count}') + self.stage_begin.emit( + (f"Checking PySide2 installation... {stage_count} " + f" out of {stage_count}")) python_path = None if platform.system().lower() == "windows": python_path = self.engine_path / ("Engine/Binaries/ThirdParty/" @@ -225,9 +242,30 @@ class UEProjectGenerationWorker(QtCore.QObject): msg = f"Unreal Python not found at {python_path}" self.failed.emit(msg, 1) raise RuntimeError(msg) - subprocess.check_call( - [python_path.as_posix(), "-m", "pip", "install", "pyside2"] - ) + pyside_cmd = [python_path.as_posix(), + "-m", + "pip", + "install", + "pyside2"] + + pyside_install = subprocess.Popen(pyside_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + for line in pyside_install.stdout: + decoded_line: str = line.decode(errors="replace") + print(decoded_line, end="") + self.log.emit(decoded_line) + + pyside_install.stdout.close() + return_code = pyside_install.wait() + + if return_code and return_code != 0: + msg = ("Failed to create the project! " + "The installation of PySide2 has failed!") + self.failed.emit(msg, return_code) + raise RuntimeError(msg) + self.progress.emit(100) self.finished.emit("Project successfully built!") @@ -266,26 +304,30 @@ class UEPluginInstallWorker(QtCore.QObject): # in order to successfully build the plugin, # It must be built outside the Engine directory and then moved - build_plugin_cmd: List[str] = [f'{uat_path.as_posix()}', - 'BuildPlugin', - f'-Plugin={uplugin_path.as_posix()}', - f'-Package={temp_dir.as_posix()}'] + build_plugin_cmd: List[str] = [f"{uat_path.as_posix()}", + "BuildPlugin", + f"-Plugin={uplugin_path.as_posix()}", + f"-Package={temp_dir.as_posix()}"] build_proc = subprocess.Popen(build_plugin_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return_code: Union[None, int] = None for line in build_proc.stdout: - decoded_line: str = line.decode(errors='replace') - print(decoded_line, end='') + decoded_line: str = line.decode(errors="replace") + print(decoded_line, end="") self.log.emit(decoded_line) + if return_code is None: + return_code = retrieve_exit_code(decoded_line) parse_comp_progress(decoded_line, self.progress) build_proc.stdout.close() - return_code = build_proc.wait() + build_proc.wait() if return_code and return_code != 0: - msg = 'Failed to build plugin' \ - f' project! Exited with return code {return_code}' + msg = ("Failed to build plugin" + f" project! Exited with return code {return_code}") + dir_util.remove_tree(temp_dir.as_posix()) self.failed.emit(msg, return_code) raise RuntimeError(msg) diff --git a/openpype/hosts/webpublisher/lib.py b/openpype/hosts/webpublisher/lib.py index 4bc3f1db80..b207f85b46 100644 --- a/openpype/hosts/webpublisher/lib.py +++ b/openpype/hosts/webpublisher/lib.py @@ -30,7 +30,7 @@ def parse_json(path): Returns: (dict) or None if unparsable Raises: - AsssertionError if 'path' doesn't exist + AssertionError if 'path' doesn't exist """ path = path.strip('\"') assert os.path.isfile(path), ( diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 7cc296f47b..8adae34827 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -889,7 +889,8 @@ class ApplicationLaunchContext: self.modules_manager = ModulesManager() # Logger - logger_name = "{}-{}".format(self.__class__.__name__, self.app_name) + logger_name = "{}-{}".format(self.__class__.__name__, + self.application.full_name) self.log = Logger.get_logger(logger_name) self.executable = executable @@ -968,7 +969,7 @@ class ApplicationLaunchContext: """Helper to collect application launch hooks from addons. Module have to have implemented 'get_launch_hook_paths' method which - can expect appliction as argument or nothing. + can expect application as argument or nothing. Returns: List[str]: Paths to launch hook directories. @@ -1246,7 +1247,7 @@ class ApplicationLaunchContext: args_len_str = " ({})".format(len(args)) self.log.info( "Launching \"{}\" with args{}: {}".format( - self.app_name, args_len_str, args + self.application.full_name, args_len_str, args ) ) self.launch_args = args @@ -1271,7 +1272,9 @@ class ApplicationLaunchContext: exc_info=True ) - self.log.debug("Launch of {} finished.".format(self.app_name)) + self.log.debug("Launch of {} finished.".format( + self.application.full_name + )) return self.process @@ -1508,8 +1511,8 @@ def prepare_app_environments( if key in source_env: source_env[key] = value - # `added_env_keys` has debug purpose - added_env_keys = {app.group.name, app.name} + # `app_and_tool_labels` has debug purpose + app_and_tool_labels = [app.full_name] # Environments for application environments = [ app.group.environment, @@ -1532,15 +1535,14 @@ def prepare_app_environments( for group_name in sorted(groups_by_name.keys()): group = groups_by_name[group_name] environments.append(group.environment) - added_env_keys.add(group_name) for tool_name in sorted(tool_by_group_name[group_name].keys()): tool = tool_by_group_name[group_name][tool_name] environments.append(tool.environment) - added_env_keys.add(tool.name) + app_and_tool_labels.append(tool.full_name) log.debug( "Will add environments for apps and tools: {}".format( - ", ".join(added_env_keys) + ", ".join(app_and_tool_labels) ) ) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index b5cd15f41a..6054d2a92a 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -9,7 +9,7 @@ from abc import ABCMeta, abstractmethod, abstractproperty import six import clique -# Global variable which store attribude definitions by type +# Global variable which store attribute definitions by type # - default types are registered on import _attr_defs_by_type = {} @@ -93,7 +93,7 @@ class AbstractAttrDefMeta(ABCMeta): @six.add_metaclass(AbstractAttrDefMeta) class AbstractAttrDef(object): - """Abstraction of attribute definiton. + """Abstraction of attribute definition. Each attribute definition must have implemented validation and conversion method. @@ -427,7 +427,7 @@ class EnumDef(AbstractAttrDef): """Enumeration of single item from items. Args: - items: Items definition that can be coverted using + items: Items definition that can be converted using 'prepare_enum_items'. default: Default value. Must be one key(value) from passed items. """ diff --git a/openpype/lib/events.py b/openpype/lib/events.py index 096201312f..bed00fe659 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -156,7 +156,7 @@ class EventCallback(object): self._enabled = enabled def deregister(self): - """Calling this funcion will cause that callback will be removed.""" + """Calling this function will cause that callback will be removed.""" # Fake reference self._ref_valid = False diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 759a4db0cb..834394b02f 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -8,6 +8,8 @@ import tempfile from .log import Logger from .vendor_bin_utils import find_executable +from .openpype_version import is_running_from_build + # MSDN process creation flag (Windows only) CREATE_NO_WINDOW = 0x08000000 @@ -102,6 +104,10 @@ def run_subprocess(*args, **kwargs): if ( platform.system().lower() == "windows" and "creationflags" not in kwargs + # shell=True already tries to hide the console window + # and passing these creationflags then shows the window again + # so we avoid it for shell=True cases + and kwargs.get("shell") is not True ): kwargs["creationflags"] = ( subprocess.CREATE_NEW_PROCESS_GROUP @@ -157,7 +163,7 @@ def run_subprocess(*args, **kwargs): def clean_envs_for_openpype_process(env=None): - """Modify environemnts that may affect OpenPype process. + """Modify environments that may affect OpenPype process. Main reason to implement this function is to pop PYTHONPATH which may be affected by in-host environments. @@ -196,6 +202,11 @@ def run_openpype_process(*args, **kwargs): # Skip envs that can affect OpenPype process # - fill more if you find more env = clean_envs_for_openpype_process(os.environ) + + # Only keep OpenPype version if we are running from build. + if not is_running_from_build(): + env.pop("OPENPYPE_VERSION", None) + return run_subprocess(args, env=env, **kwargs) diff --git a/openpype/lib/file_transaction.py b/openpype/lib/file_transaction.py index fe70b37cb1..80f4e81f2c 100644 --- a/openpype/lib/file_transaction.py +++ b/openpype/lib/file_transaction.py @@ -13,6 +13,16 @@ else: from shutil import copyfile +class DuplicateDestinationError(ValueError): + """Error raised when transfer destination already exists in queue. + + The error is only raised if `allow_queue_replacements` is False on the + FileTransaction instance and the added file to transfer is of a different + src file than the one already detected in the queue. + + """ + + class FileTransaction(object): """File transaction with rollback options. @@ -44,7 +54,7 @@ class FileTransaction(object): MODE_COPY = 0 MODE_HARDLINK = 1 - def __init__(self, log=None): + def __init__(self, log=None, allow_queue_replacements=False): if log is None: log = logging.getLogger("FileTransaction") @@ -60,6 +70,8 @@ class FileTransaction(object): # Backup file location mapping to original locations self._backup_to_original = {} + self._allow_queue_replacements = allow_queue_replacements + def add(self, src, dst, mode=MODE_COPY): """Add a new file to transfer queue. @@ -82,6 +94,14 @@ class FileTransaction(object): src, dst)) return else: + if not self._allow_queue_replacements: + raise DuplicateDestinationError( + "Transfer to destination is already in queue: " + "{} -> {}. It's not allowed to be replaced by " + "a new transfer from {}".format( + queued_src, dst, src + )) + self.log.warning("File transfer in queue replaced..") self.log.debug( "Removed from queue: {} -> {} replaced by {} -> {}".format( @@ -110,7 +130,7 @@ class FileTransaction(object): path_same = self._same_paths(src, dst) if path_same: self.log.debug( - "Source and destionation are same files {} -> {}".format( + "Source and destination are same files {} -> {}".format( src, dst)) continue diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 799693554f..57968b3700 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -540,7 +540,7 @@ def convert_for_ffmpeg( continue # Remove attributes that have string value longer than allowed length - # for ffmpeg or when containt unallowed symbols + # for ffmpeg or when contain unallowed symbols erase_reason = "Missing reason" erase_attribute = False if len(attr_value) > MAX_FFMPEG_STRING_LEN: @@ -680,7 +680,7 @@ def convert_input_paths_for_ffmpeg( continue # Remove attributes that have string value longer than allowed - # length for ffmpeg or when containt unallowed symbols + # length for ffmpeg or when containing unallowed symbols erase_reason = "Missing reason" erase_attribute = False if len(attr_value) > MAX_FFMPEG_STRING_LEN: @@ -968,7 +968,7 @@ def _ffmpeg_dnxhd_codec_args(stream_data, source_ffmpeg_cmd): if source_ffmpeg_cmd: # Define bitrate arguments bit_rate_args = ("-b:v", "-vb",) - # Seprate the two variables in case something else should be copied + # Separate the two variables in case something else should be copied # from source command copy_args = [] copy_args.extend(bit_rate_args) diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index b6797dbba0..f27c78d486 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -224,18 +224,26 @@ def find_tool_in_custom_paths(paths, tool, validation_func=None): def _check_args_returncode(args): try: - # Python 2 compatibility where DEVNULL is not available + kwargs = {} + if platform.system().lower() == "windows": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) + if hasattr(subprocess, "DEVNULL"): proc = subprocess.Popen( args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + **kwargs ) proc.wait() else: with open(os.devnull, "w") as devnull: proc = subprocess.Popen( - args, stdout=devnull, stderr=devnull, + args, stdout=devnull, stderr=devnull, **kwargs ) proc.wait() @@ -252,7 +260,7 @@ def _oiio_executable_validation(filepath): that it can be executed. For that is used '--help' argument which is fast and does not need any other inputs. - Any possible crash of missing libraries or invalid build should be catched. + Any possible crash of missing libraries or invalid build should be caught. Main reason is to validate if executable can be executed on OS just running which can be issue ob linux machines. @@ -321,7 +329,7 @@ def _ffmpeg_executable_validation(filepath): that it can be executed. For that is used '-version' argument which is fast and does not need any other inputs. - Any possible crash of missing libraries or invalid build should be catched. + Any possible crash of missing libraries or invalid build should be caught. Main reason is to validate if executable can be executed on OS just running which can be issue ob linux machines. @@ -375,7 +383,7 @@ def get_ffmpeg_tool_path(tool="ffmpeg"): # Look to PATH for the tool if not tool_executable_path: from_path = find_executable(tool) - if from_path and _oiio_executable_validation(from_path): + if from_path and _ffmpeg_executable_validation(from_path): tool_executable_path = from_path CachedToolPaths.cache_executable_path(tool, tool_executable_path) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 0fd21492e8..ed1eeb04cd 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -472,7 +472,7 @@ class OpenPypeModule: Args: application (Application): Application that is launched. - env (dict): Current environemnt variables. + env (dict): Current environment variables. """ pass @@ -622,7 +622,7 @@ class ModulesManager: # Check if class is abstract (Developing purpose) if inspect.isabstract(modules_item): - # Find missing implementations by convetion on `abc` module + # Find abstract attributes by convention on `abc` module not_implemented = [] for attr_name in dir(modules_item): attr = getattr(modules_item, attr_name, None) @@ -708,13 +708,13 @@ class ModulesManager: ] def collect_global_environments(self): - """Helper to collect global enviornment variabled from modules. + """Helper to collect global environment variabled from modules. Returns: dict: Global environment variables from enabled modules. Raises: - AssertionError: Gobal environment variables must be unique for + AssertionError: Global environment variables must be unique for all modules. """ module_envs = {} @@ -1174,7 +1174,7 @@ class TrayModulesManager(ModulesManager): def get_module_settings_defs(): - """Check loaded addons/modules for existence of thei settings definition. + """Check loaded addons/modules for existence of their settings definition. Check if OpenPype addon/module as python module has class that inherit from `ModuleSettingsDef` in python module variables (imported @@ -1204,7 +1204,7 @@ def get_module_settings_defs(): continue if inspect.isabstract(attr): - # Find missing implementations by convetion on `abc` module + # Find missing implementations by convention on `abc` module not_implemented = [] for attr_name in dir(attr): attr = getattr(attr, attr_name, None) @@ -1293,7 +1293,7 @@ class BaseModuleSettingsDef: class ModuleSettingsDef(BaseModuleSettingsDef): - """Settings definiton with separated system and procect settings parts. + """Settings definition with separated system and procect settings parts. Reduce conditions that must be checked and adds predefined methods for each case. diff --git a/openpype/modules/clockify/clockify_api.py b/openpype/modules/clockify/clockify_api.py index 6af911fffc..47af002f7a 100644 --- a/openpype/modules/clockify/clockify_api.py +++ b/openpype/modules/clockify/clockify_api.py @@ -6,34 +6,22 @@ import datetime import requests from .constants import ( CLOCKIFY_ENDPOINT, - ADMIN_PERMISSION_NAMES + ADMIN_PERMISSION_NAMES, ) from openpype.lib.local_settings import OpenPypeSecureRegistry - - -def time_check(obj): - if obj.request_counter < 10: - obj.request_counter += 1 - return - - wait_time = 1 - (time.time() - obj.request_time) - if wait_time > 0: - time.sleep(wait_time) - - obj.request_time = time.time() - obj.request_counter = 0 +from openpype.lib import Logger class ClockifyAPI: + log = Logger.get_logger(__name__) + def __init__(self, api_key=None, master_parent=None): self.workspace_name = None - self.workspace_id = None self.master_parent = master_parent self.api_key = api_key - self.request_counter = 0 - self.request_time = time.time() - + self._workspace_id = None + self._user_id = None self._secure_registry = None @property @@ -44,11 +32,19 @@ class ClockifyAPI: @property def headers(self): - return {"X-Api-Key": self.api_key} + return {"x-api-key": self.api_key} + + @property + def workspace_id(self): + return self._workspace_id + + @property + def user_id(self): + return self._user_id def verify_api(self): for key, value in self.headers.items(): - if value is None or value.strip() == '': + if value is None or value.strip() == "": return False return True @@ -59,65 +55,55 @@ class ClockifyAPI: if api_key is not None and self.validate_api_key(api_key) is True: self.api_key = api_key self.set_workspace() + self.set_user_id() if self.master_parent: self.master_parent.signed_in() return True return False def validate_api_key(self, api_key): - test_headers = {'X-Api-Key': api_key} - action_url = 'workspaces/' - time_check(self) + test_headers = {"x-api-key": api_key} + action_url = "user" response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=test_headers + CLOCKIFY_ENDPOINT + action_url, headers=test_headers ) if response.status_code != 200: return False return True - def validate_workspace_perm(self, workspace_id=None): - user_id = self.get_user_id() + def validate_workspace_permissions(self, workspace_id=None, user_id=None): if user_id is None: + self.log.info("No user_id found during validation") return False if workspace_id is None: workspace_id = self.workspace_id - action_url = "/workspaces/{}/users/{}/permissions".format( - workspace_id, user_id - ) - time_check(self) + action_url = f"workspaces/{workspace_id}/users?includeRoles=1" response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) - user_permissions = response.json() - for perm in user_permissions: - if perm['name'] in ADMIN_PERMISSION_NAMES: + data = response.json() + for user in data: + if user.get("id") == user_id: + roles_data = user.get("roles") + for entities in roles_data: + if entities.get("role") in ADMIN_PERMISSION_NAMES: return True return False def get_user_id(self): - action_url = 'v1/user/' - time_check(self) + action_url = "user" response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) - # this regex is neccessary: UNICODE strings are crashing - # during json serialization - id_regex = '\"{1}id\"{1}\:{1}\"{1}\w+\"{1}' - result = re.findall(id_regex, str(response.content)) - if len(result) != 1: - # replace with log and better message? - print('User ID was not found (this is a BUG!!!)') - return None - return json.loads('{'+result[0]+'}')['id'] + result = response.json() + user_id = result.get("id", None) + + return user_id def set_workspace(self, name=None): if name is None: - name = os.environ.get('CLOCKIFY_WORKSPACE', None) + name = os.environ.get("CLOCKIFY_WORKSPACE", None) self.workspace_name = name - self.workspace_id = None if self.workspace_name is None: return try: @@ -125,7 +111,7 @@ class ClockifyAPI: except Exception: result = False if result is not False: - self.workspace_id = result + self._workspace_id = result if self.master_parent is not None: self.master_parent.start_timer_check() return True @@ -139,6 +125,14 @@ class ClockifyAPI: return all_workspaces[name] return False + def set_user_id(self): + try: + user_id = self.get_user_id() + except Exception: + user_id = None + if user_id is not None: + self._user_id = user_id + def get_api_key(self): return self.secure_registry.get_item("api_key", None) @@ -146,11 +140,9 @@ class ClockifyAPI: self.secure_registry.set_item("api_key", api_key) def get_workspaces(self): - action_url = 'workspaces/' - time_check(self) + action_url = "workspaces/" response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return { workspace["name"]: workspace["id"] for workspace in response.json() @@ -159,27 +151,22 @@ class ClockifyAPI: def get_projects(self, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/projects/'.format(workspace_id) - time_check(self) + action_url = f"workspaces/{workspace_id}/projects" response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) - - return { - project["name"]: project["id"] for project in response.json() - } + if response.status_code != 403: + result = response.json() + return {project["name"]: project["id"] for project in result} def get_project_by_id(self, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/projects/{}/'.format( + action_url = "workspaces/{}/projects/{}".format( workspace_id, project_id ) - time_check(self) response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return response.json() @@ -187,32 +174,24 @@ class ClockifyAPI: def get_tags(self, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/tags/'.format(workspace_id) - time_check(self) + action_url = "workspaces/{}/tags".format(workspace_id) response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) - return { - tag["name"]: tag["id"] for tag in response.json() - } + return {tag["name"]: tag["id"] for tag in response.json()} def get_tasks(self, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/projects/{}/tasks/'.format( + action_url = "workspaces/{}/projects/{}/tasks".format( workspace_id, project_id ) - time_check(self) response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) - return { - task["name"]: task["id"] for task in response.json() - } + return {task["name"]: task["id"] for task in response.json()} def get_workspace_id(self, workspace_name): all_workspaces = self.get_workspaces() @@ -236,48 +215,64 @@ class ClockifyAPI: return None return all_tasks[tag_name] - def get_task_id( - self, task_name, project_id, workspace_id=None - ): + def get_task_id(self, task_name, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - all_tasks = self.get_tasks( - project_id, workspace_id - ) + all_tasks = self.get_tasks(project_id, workspace_id) if task_name not in all_tasks: return None return all_tasks[task_name] def get_current_time(self): - return str(datetime.datetime.utcnow().isoformat())+'Z' + return str(datetime.datetime.utcnow().isoformat()) + "Z" def start_time_entry( - self, description, project_id, task_id=None, tag_ids=[], - workspace_id=None, billable=True + self, + description, + project_id, + task_id=None, + tag_ids=None, + workspace_id=None, + user_id=None, + billable=True, ): # Workspace if workspace_id is None: workspace_id = self.workspace_id + # User ID + if user_id is None: + user_id = self._user_id + + # get running timer to check if we need to start it + current_timer = self.get_in_progress() # Check if is currently run another times and has same values - current = self.get_in_progress(workspace_id) - if current is not None: + # DO not restart the timer, if it is already running for current task + if current_timer: + current_timer_hierarchy = current_timer.get("description") + current_project_id = current_timer.get("projectId") + current_task_id = current_timer.get("taskId") if ( - current.get("description", None) == description and - current.get("projectId", None) == project_id and - current.get("taskId", None) == task_id + description == current_timer_hierarchy + and project_id == current_project_id + and task_id == current_task_id ): + self.log.info( + "Timer for the current project is already running" + ) self.bool_timer_run = True return self.bool_timer_run - self.finish_time_entry(workspace_id) + self.finish_time_entry() # Convert billable to strings if billable: - billable = 'true' + billable = "true" else: - billable = 'false' + billable = "false" # Rest API Action - action_url = 'workspaces/{}/timeEntries/'.format(workspace_id) + action_url = "workspaces/{}/user/{}/time-entries".format( + workspace_id, user_id + ) start = self.get_current_time() body = { "start": start, @@ -285,169 +280,135 @@ class ClockifyAPI: "description": description, "projectId": project_id, "taskId": task_id, - "tagIds": tag_ids + "tagIds": tag_ids, } - time_check(self) response = requests.post( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers, - json=body + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) - - success = False if response.status_code < 300: - success = True - return success + return True + return False - def get_in_progress(self, workspace_id=None): - if workspace_id is None: - workspace_id = self.workspace_id - action_url = 'workspaces/{}/timeEntries/inProgress'.format( - workspace_id - ) - time_check(self) - response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers - ) + def _get_current_timer_values(self, response): + if response is None: + return try: output = response.json() except json.decoder.JSONDecodeError: - output = None - return output + return None + if output and isinstance(output, list): + return output[0] + return None - def finish_time_entry(self, workspace_id=None): + def get_in_progress(self, user_id=None, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - current = self.get_in_progress(workspace_id) - if current is None: - return + if user_id is None: + user_id = self.user_id - current_id = current["id"] - action_url = 'workspaces/{}/timeEntries/{}'.format( - workspace_id, current_id + action_url = ( + f"workspaces/{workspace_id}/user/" + f"{user_id}/time-entries?in-progress=1" ) - body = { - "start": current["timeInterval"]["start"], - "billable": current["billable"], - "description": current["description"], - "projectId": current["projectId"], - "taskId": current["taskId"], - "tagIds": current["tagIds"], - "end": self.get_current_time() - } - time_check(self) - response = requests.put( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers, - json=body + response = requests.get( + CLOCKIFY_ENDPOINT + action_url, headers=self.headers + ) + return self._get_current_timer_values(response) + + def finish_time_entry(self, workspace_id=None, user_id=None): + if workspace_id is None: + workspace_id = self.workspace_id + if user_id is None: + user_id = self.user_id + current_timer = self.get_in_progress() + if not current_timer: + return + action_url = "workspaces/{}/user/{}/time-entries".format( + workspace_id, user_id + ) + body = {"end": self.get_current_time()} + response = requests.patch( + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() - def get_time_entries( - self, workspace_id=None, quantity=10 - ): + def get_time_entries(self, workspace_id=None, user_id=None, quantity=10): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/timeEntries/'.format(workspace_id) - time_check(self) + if user_id is None: + user_id = self.user_id + action_url = "workspaces/{}/user/{}/time-entries".format( + workspace_id, user_id + ) response = requests.get( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return response.json()[:quantity] - def remove_time_entry(self, tid, workspace_id=None): + def remove_time_entry(self, tid, workspace_id=None, user_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/timeEntries/{}'.format( - workspace_id, tid + action_url = "workspaces/{}/user/{}/time-entries/{}".format( + workspace_id, user_id, tid ) - time_check(self) response = requests.delete( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return response.json() def add_project(self, name, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/projects/'.format(workspace_id) + action_url = "workspaces/{}/projects".format(workspace_id) body = { "name": name, "clientId": "", "isPublic": "false", - "estimate": { - "estimate": 0, - "type": "AUTO" - }, + "estimate": {"estimate": 0, "type": "AUTO"}, "color": "#f44336", - "billable": "true" + "billable": "true", } - time_check(self) response = requests.post( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers, - json=body + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() def add_workspace(self, name): - action_url = 'workspaces/' + action_url = "workspaces/" body = {"name": name} - time_check(self) response = requests.post( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers, - json=body + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() - def add_task( - self, name, project_id, workspace_id=None - ): + def add_task(self, name, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/projects/{}/tasks/'.format( + action_url = "workspaces/{}/projects/{}/tasks".format( workspace_id, project_id ) - body = { - "name": name, - "projectId": project_id - } - time_check(self) + body = {"name": name, "projectId": project_id} response = requests.post( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers, - json=body + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() def add_tag(self, name, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = 'workspaces/{}/tags'.format(workspace_id) - body = { - "name": name - } - time_check(self) + action_url = "workspaces/{}/tags".format(workspace_id) + body = {"name": name} response = requests.post( - CLOCKIFY_ENDPOINT + action_url, - headers=self.headers, - json=body + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() - def delete_project( - self, project_id, workspace_id=None - ): + def delete_project(self, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id - action_url = '/workspaces/{}/projects/{}'.format( + action_url = "/workspaces/{}/projects/{}".format( workspace_id, project_id ) - time_check(self) response = requests.delete( CLOCKIFY_ENDPOINT + action_url, headers=self.headers, @@ -455,12 +416,12 @@ class ClockifyAPI: return response.json() def convert_input( - self, entity_id, entity_name, mode='Workspace', project_id=None + self, entity_id, entity_name, mode="Workspace", project_id=None ): if entity_id is None: error = False error_msg = 'Missing information "{}"' - if mode.lower() == 'workspace': + if mode.lower() == "workspace": if entity_id is None and entity_name is None: if self.workspace_id is not None: entity_id = self.workspace_id @@ -471,14 +432,14 @@ class ClockifyAPI: else: if entity_id is None and entity_name is None: error = True - elif mode.lower() == 'project': + elif mode.lower() == "project": entity_id = self.get_project_id(entity_name) - elif mode.lower() == 'task': + elif mode.lower() == "task": entity_id = self.get_task_id( task_name=entity_name, project_id=project_id ) else: - raise TypeError('Unknown type') + raise TypeError("Unknown type") # Raise error if error: raise ValueError(error_msg.format(mode)) diff --git a/openpype/modules/clockify/clockify_module.py b/openpype/modules/clockify/clockify_module.py index 300d5576e2..b6efec7907 100644 --- a/openpype/modules/clockify/clockify_module.py +++ b/openpype/modules/clockify/clockify_module.py @@ -2,24 +2,13 @@ import os import threading import time -from openpype.modules import ( - OpenPypeModule, - ITrayModule, - IPluginPaths -) +from openpype.modules import OpenPypeModule, ITrayModule, IPluginPaths +from openpype.client import get_asset_by_name -from .clockify_api import ClockifyAPI -from .constants import ( - CLOCKIFY_FTRACK_USER_PATH, - CLOCKIFY_FTRACK_SERVER_PATH -) +from .constants import CLOCKIFY_FTRACK_USER_PATH, CLOCKIFY_FTRACK_SERVER_PATH -class ClockifyModule( - OpenPypeModule, - ITrayModule, - IPluginPaths -): +class ClockifyModule(OpenPypeModule, ITrayModule, IPluginPaths): name = "clockify" def initialize(self, modules_settings): @@ -33,18 +22,23 @@ class ClockifyModule( self.timer_manager = None self.MessageWidgetClass = None self.message_widget = None - - self.clockapi = ClockifyAPI(master_parent=self) + self._clockify_api = None # TimersManager attributes # - set `timers_manager_connector` only in `tray_init` self.timers_manager_connector = None self._timers_manager_module = None + @property + def clockify_api(self): + if self._clockify_api is None: + from .clockify_api import ClockifyAPI + + self._clockify_api = ClockifyAPI(master_parent=self) + return self._clockify_api + def get_global_environments(self): - return { - "CLOCKIFY_WORKSPACE": self.workspace_name - } + return {"CLOCKIFY_WORKSPACE": self.workspace_name} def tray_init(self): from .widgets import ClockifySettings, MessageWidget @@ -52,7 +46,7 @@ class ClockifyModule( self.MessageWidgetClass = MessageWidget self.message_widget = None - self.widget_settings = ClockifySettings(self.clockapi) + self.widget_settings = ClockifySettings(self.clockify_api) self.widget_settings_required = None self.thread_timer_check = None @@ -61,7 +55,7 @@ class ClockifyModule( self.bool_api_key_set = False self.bool_workspace_set = False self.bool_timer_run = False - self.bool_api_key_set = self.clockapi.set_api() + self.bool_api_key_set = self.clockify_api.set_api() # Define itself as TimersManager connector self.timers_manager_connector = self @@ -71,37 +65,32 @@ class ClockifyModule( self.show_settings() return - self.bool_workspace_set = self.clockapi.workspace_id is not None + self.bool_workspace_set = self.clockify_api.workspace_id is not None if self.bool_workspace_set is False: return self.start_timer_check() - self.set_menu_visibility() def tray_exit(self, *_a, **_kw): return def get_plugin_paths(self): - """Implementaton of IPluginPaths to get plugin paths.""" + """Implementation of IPluginPaths to get plugin paths.""" actions_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "launcher_actions" + os.path.dirname(os.path.abspath(__file__)), "launcher_actions" ) - return { - "actions": [actions_path] - } + return {"actions": [actions_path]} def get_ftrack_event_handler_paths(self): """Function for Ftrack module to add ftrack event handler paths.""" return { "user": [CLOCKIFY_FTRACK_USER_PATH], - "server": [CLOCKIFY_FTRACK_SERVER_PATH] + "server": [CLOCKIFY_FTRACK_SERVER_PATH], } def clockify_timer_stopped(self): self.bool_timer_run = False - # Call `ITimersManager` method self.timer_stopped() def start_timer_check(self): @@ -122,45 +111,44 @@ class ClockifyModule( def check_running(self): while self.bool_thread_check_running is True: bool_timer_run = False - if self.clockapi.get_in_progress() is not None: + if self.clockify_api.get_in_progress() is not None: bool_timer_run = True if self.bool_timer_run != bool_timer_run: if self.bool_timer_run is True: self.clockify_timer_stopped() elif self.bool_timer_run is False: - actual_timer = self.clockapi.get_in_progress() - if not actual_timer: + current_timer = self.clockify_api.get_in_progress() + if current_timer is None: + continue + current_proj_id = current_timer.get("projectId") + if not current_proj_id: continue - actual_proj_id = actual_timer["projectId"] - if not actual_proj_id: - continue - - project = self.clockapi.get_project_by_id(actual_proj_id) + project = self.clockify_api.get_project_by_id( + current_proj_id + ) if project and project.get("code") == 501: continue - project_name = project["name"] + project_name = project.get("name") - actual_timer_hierarchy = actual_timer["description"] - hierarchy_items = actual_timer_hierarchy.split("/") + current_timer_hierarchy = current_timer.get("description") + if not current_timer_hierarchy: + continue + hierarchy_items = current_timer_hierarchy.split("/") # Each pype timer must have at least 2 items! if len(hierarchy_items) < 2: continue + task_name = hierarchy_items[-1] hierarchy = hierarchy_items[:-1] - task_type = None - if len(actual_timer.get("tags", [])) > 0: - task_type = actual_timer["tags"][0].get("name") data = { "task_name": task_name, "hierarchy": hierarchy, "project_name": project_name, - "task_type": task_type } - # Call `ITimersManager` method self.timer_started(data) self.bool_timer_run = bool_timer_run @@ -184,6 +172,7 @@ class ClockifyModule( def tray_menu(self, parent_menu): # Menu for Tray App from qtpy import QtWidgets + menu = QtWidgets.QMenu("Clockify", parent_menu) menu.setProperty("submenu", "on") @@ -204,7 +193,9 @@ class ClockifyModule( parent_menu.addMenu(menu) def show_settings(self): - self.widget_settings.input_api_key.setText(self.clockapi.get_api_key()) + self.widget_settings.input_api_key.setText( + self.clockify_api.get_api_key() + ) self.widget_settings.show() def set_menu_visibility(self): @@ -218,72 +209,82 @@ class ClockifyModule( def timer_started(self, data): """Tell TimersManager that timer started.""" if self._timers_manager_module is not None: - self._timers_manager_module.timer_started(self._module.id, data) + self._timers_manager_module.timer_started(self.id, data) def timer_stopped(self): """Tell TimersManager that timer stopped.""" if self._timers_manager_module is not None: - self._timers_manager_module.timer_stopped(self._module.id) + self._timers_manager_module.timer_stopped(self.id) def stop_timer(self): """Called from TimersManager to stop timer.""" - self.clockapi.finish_time_entry() + self.clockify_api.finish_time_entry() - def start_timer(self, input_data): - """Called from TimersManager to start timer.""" - # If not api key is not entered then skip - if not self.clockapi.get_api_key(): - return - - actual_timer = self.clockapi.get_in_progress() - actual_timer_hierarchy = None - actual_project_id = None - if actual_timer is not None: - actual_timer_hierarchy = actual_timer.get("description") - actual_project_id = actual_timer.get("projectId") - - # Concatenate hierarchy and task to get description - desc_items = [val for val in input_data.get("hierarchy", [])] - desc_items.append(input_data["task_name"]) - description = "/".join(desc_items) - - # Check project existence - project_name = input_data["project_name"] - project_id = self.clockapi.get_project_id(project_name) + def _verify_project_exists(self, project_name): + project_id = self.clockify_api.get_project_id(project_name) if not project_id: - self.log.warning(( - "Project \"{}\" was not found in Clockify. Timer won't start." - ).format(project_name)) + self.log.warning( + 'Project "{}" was not found in Clockify. Timer won\'t start.' + ).format(project_name) if not self.MessageWidgetClass: return msg = ( - "Project \"{}\" is not" - " in Clockify Workspace \"{}\"." + 'Project "{}" is not' + ' in Clockify Workspace "{}".' "

Please inform your Project Manager." - ).format(project_name, str(self.clockapi.workspace_name)) + ).format(project_name, str(self.clockify_api.workspace_name)) self.message_widget = self.MessageWidgetClass( msg, "Clockify - Info Message" ) self.message_widget.closed.connect(self.on_message_widget_close) self.message_widget.show() + return False + return project_id + def start_timer(self, input_data): + """Called from TimersManager to start timer.""" + # If not api key is not entered then skip + if not self.clockify_api.get_api_key(): return - if ( - actual_timer is not None and - description == actual_timer_hierarchy and - project_id == actual_project_id - ): + task_name = input_data.get("task_name") + + # Concatenate hierarchy and task to get description + description_items = list(input_data.get("hierarchy", [])) + description_items.append(task_name) + description = "/".join(description_items) + + # Check project existence + project_name = input_data.get("project_name") + project_id = self._verify_project_exists(project_name) + if not project_id: return + # Setup timer tags tag_ids = [] - task_tag_id = self.clockapi.get_tag_id(input_data["task_type"]) + tag_name = input_data.get("task_type") + if not tag_name: + # no task_type found in the input data + # if the timer is restarted by idle time (bug?) + asset_name = input_data["hierarchy"][-1] + asset_doc = get_asset_by_name(project_name, asset_name) + task_info = asset_doc["data"]["tasks"][task_name] + tag_name = task_info.get("type", "") + if not tag_name: + self.log.info("No tag information found for the timer") + + task_tag_id = self.clockify_api.get_tag_id(tag_name) if task_tag_id is not None: tag_ids.append(task_tag_id) - self.clockapi.start_time_entry( - description, project_id, tag_ids=tag_ids + # Start timer + self.clockify_api.start_time_entry( + description, + project_id, + tag_ids=tag_ids, + workspace_id=self.clockify_api.workspace_id, + user_id=self.clockify_api.user_id, ) diff --git a/openpype/modules/clockify/constants.py b/openpype/modules/clockify/constants.py index 66f6cb899a..4574f91be1 100644 --- a/openpype/modules/clockify/constants.py +++ b/openpype/modules/clockify/constants.py @@ -9,4 +9,4 @@ CLOCKIFY_FTRACK_USER_PATH = os.path.join( ) ADMIN_PERMISSION_NAMES = ["WORKSPACE_OWN", "WORKSPACE_ADMIN"] -CLOCKIFY_ENDPOINT = "https://api.clockify.me/api/" +CLOCKIFY_ENDPOINT = "https://api.clockify.me/api/v1/" diff --git a/openpype/modules/clockify/ftrack/server/action_clockify_sync_server.py b/openpype/modules/clockify/ftrack/server/action_clockify_sync_server.py index c6b55947da..985cf49b97 100644 --- a/openpype/modules/clockify/ftrack/server/action_clockify_sync_server.py +++ b/openpype/modules/clockify/ftrack/server/action_clockify_sync_server.py @@ -4,7 +4,7 @@ from openpype_modules.ftrack.lib import ServerAction from openpype_modules.clockify.clockify_api import ClockifyAPI -class SyncClocifyServer(ServerAction): +class SyncClockifyServer(ServerAction): '''Synchronise project names and task types.''' identifier = "clockify.sync.server" @@ -14,12 +14,12 @@ class SyncClocifyServer(ServerAction): role_list = ["Pypeclub", "Administrator", "project Manager"] def __init__(self, *args, **kwargs): - super(SyncClocifyServer, self).__init__(*args, **kwargs) + super(SyncClockifyServer, self).__init__(*args, **kwargs) workspace_name = os.environ.get("CLOCKIFY_WORKSPACE") api_key = os.environ.get("CLOCKIFY_API_KEY") - self.clockapi = ClockifyAPI(api_key) - self.clockapi.set_workspace(workspace_name) + self.clockify_api = ClockifyAPI(api_key) + self.clockify_api.set_workspace(workspace_name) if api_key is None: modified_key = "None" else: @@ -48,13 +48,16 @@ class SyncClocifyServer(ServerAction): return True def launch(self, session, entities, event): - if self.clockapi.workspace_id is None: + self.clockify_api.set_api() + if self.clockify_api.workspace_id is None: return { "success": False, "message": "Clockify Workspace or API key are not set!" } - if self.clockapi.validate_workspace_perm() is False: + if not self.clockify_api.validate_workspace_permissions( + self.clockify_api.workspace_id, self.clockify_api.user_id + ): return { "success": False, "message": "Missing permissions for this action!" @@ -88,9 +91,9 @@ class SyncClocifyServer(ServerAction): task_type["name"] for task_type in task_types ] try: - clockify_projects = self.clockapi.get_projects() + clockify_projects = self.clockify_api.get_projects() if project_name not in clockify_projects: - response = self.clockapi.add_project(project_name) + response = self.clockify_api.add_project(project_name) if "id" not in response: self.log.warning( "Project \"{}\" can't be created. Response: {}".format( @@ -105,7 +108,7 @@ class SyncClocifyServer(ServerAction): ).format(project_name) } - clockify_workspace_tags = self.clockapi.get_tags() + clockify_workspace_tags = self.clockify_api.get_tags() for task_type_name in task_type_names: if task_type_name in clockify_workspace_tags: self.log.debug( @@ -113,7 +116,7 @@ class SyncClocifyServer(ServerAction): ) continue - response = self.clockapi.add_tag(task_type_name) + response = self.clockify_api.add_tag(task_type_name) if "id" not in response: self.log.warning( "Task \"{}\" can't be created. Response: {}".format( @@ -138,4 +141,4 @@ class SyncClocifyServer(ServerAction): def register(session, **kw): - SyncClocifyServer(session).register() + SyncClockifyServer(session).register() diff --git a/openpype/modules/clockify/ftrack/user/action_clockify_sync_local.py b/openpype/modules/clockify/ftrack/user/action_clockify_sync_local.py index a430791906..0e8cf6bd37 100644 --- a/openpype/modules/clockify/ftrack/user/action_clockify_sync_local.py +++ b/openpype/modules/clockify/ftrack/user/action_clockify_sync_local.py @@ -3,7 +3,7 @@ from openpype_modules.ftrack.lib import BaseAction, statics_icon from openpype_modules.clockify.clockify_api import ClockifyAPI -class SyncClocifyLocal(BaseAction): +class SyncClockifyLocal(BaseAction): '''Synchronise project names and task types.''' #: Action identifier. @@ -18,9 +18,9 @@ class SyncClocifyLocal(BaseAction): icon = statics_icon("app_icons", "clockify-white.png") def __init__(self, *args, **kwargs): - super(SyncClocifyLocal, self).__init__(*args, **kwargs) + super(SyncClockifyLocal, self).__init__(*args, **kwargs) #: CLockifyApi - self.clockapi = ClockifyAPI() + self.clockify_api = ClockifyAPI() def discover(self, session, entities, event): if ( @@ -31,14 +31,18 @@ class SyncClocifyLocal(BaseAction): return False def launch(self, session, entities, event): - self.clockapi.set_api() - if self.clockapi.workspace_id is None: + self.clockify_api.set_api() + if self.clockify_api.workspace_id is None: return { "success": False, "message": "Clockify Workspace or API key are not set!" } - if self.clockapi.validate_workspace_perm() is False: + if ( + self.clockify_api.validate_workspace_permissions( + self.clockify_api.workspace_id, self.clockify_api.user_id) + is False + ): return { "success": False, "message": "Missing permissions for this action!" @@ -74,9 +78,9 @@ class SyncClocifyLocal(BaseAction): task_type["name"] for task_type in task_types ] try: - clockify_projects = self.clockapi.get_projects() + clockify_projects = self.clockify_api.get_projects() if project_name not in clockify_projects: - response = self.clockapi.add_project(project_name) + response = self.clockify_api.add_project(project_name) if "id" not in response: self.log.warning( "Project \"{}\" can't be created. Response: {}".format( @@ -91,7 +95,7 @@ class SyncClocifyLocal(BaseAction): ).format(project_name) } - clockify_workspace_tags = self.clockapi.get_tags() + clockify_workspace_tags = self.clockify_api.get_tags() for task_type_name in task_type_names: if task_type_name in clockify_workspace_tags: self.log.debug( @@ -99,7 +103,7 @@ class SyncClocifyLocal(BaseAction): ) continue - response = self.clockapi.add_tag(task_type_name) + response = self.clockify_api.add_tag(task_type_name) if "id" not in response: self.log.warning( "Task \"{}\" can't be created. Response: {}".format( @@ -121,4 +125,4 @@ class SyncClocifyLocal(BaseAction): def register(session, **kw): - SyncClocifyLocal(session).register() + SyncClockifyLocal(session).register() diff --git a/openpype/modules/clockify/launcher_actions/ClockifyStart.py b/openpype/modules/clockify/launcher_actions/ClockifyStart.py index 7663aecc31..4a653c1b8d 100644 --- a/openpype/modules/clockify/launcher_actions/ClockifyStart.py +++ b/openpype/modules/clockify/launcher_actions/ClockifyStart.py @@ -6,9 +6,9 @@ from openpype_modules.clockify.clockify_api import ClockifyAPI class ClockifyStart(LauncherAction): name = "clockify_start_timer" label = "Clockify - Start Timer" - icon = "clockify_icon" + icon = "app_icons/clockify.png" order = 500 - clockapi = ClockifyAPI() + clockify_api = ClockifyAPI() def is_compatible(self, session): """Return whether the action is compatible with the session""" @@ -17,23 +17,39 @@ class ClockifyStart(LauncherAction): return False def process(self, session, **kwargs): + self.clockify_api.set_api() + user_id = self.clockify_api.user_id + workspace_id = self.clockify_api.workspace_id project_name = session["AVALON_PROJECT"] asset_name = session["AVALON_ASSET"] task_name = session["AVALON_TASK"] - description = asset_name - asset_doc = get_asset_by_name( - project_name, asset_name, fields=["data.parents"] - ) - if asset_doc is not None: - desc_items = asset_doc.get("data", {}).get("parents", []) - desc_items.append(asset_name) - desc_items.append(task_name) - description = "/".join(desc_items) - project_id = self.clockapi.get_project_id(project_name) - tag_ids = [] - tag_ids.append(self.clockapi.get_tag_id(task_name)) - self.clockapi.start_time_entry( - description, project_id, tag_ids=tag_ids + # fetch asset docs + asset_doc = get_asset_by_name(project_name, asset_name) + + # get task type to fill the timer tag + task_info = asset_doc["data"]["tasks"][task_name] + task_type = task_info["type"] + + # check if the task has hierarchy and fill the + parents_data = asset_doc["data"] + if parents_data is not None: + description_items = parents_data.get("parents", []) + description_items.append(asset_name) + description_items.append(task_name) + description = "/".join(description_items) + + project_id = self.clockify_api.get_project_id( + project_name, workspace_id + ) + tag_ids = [] + tag_name = task_type + tag_ids.append(self.clockify_api.get_tag_id(tag_name, workspace_id)) + self.clockify_api.start_time_entry( + description, + project_id, + tag_ids=tag_ids, + workspace_id=workspace_id, + user_id=user_id, ) diff --git a/openpype/modules/clockify/launcher_actions/ClockifySync.py b/openpype/modules/clockify/launcher_actions/ClockifySync.py index c346a1b4f6..cbd2519a04 100644 --- a/openpype/modules/clockify/launcher_actions/ClockifySync.py +++ b/openpype/modules/clockify/launcher_actions/ClockifySync.py @@ -3,20 +3,39 @@ from openpype_modules.clockify.clockify_api import ClockifyAPI from openpype.pipeline import LauncherAction -class ClockifySync(LauncherAction): +class ClockifyPermissionsCheckFailed(Exception): + """Timer start failed due to user permissions check. + Message should be self explanatory as traceback won't be shown. + """ + pass + + +class ClockifySync(LauncherAction): name = "sync_to_clockify" label = "Sync to Clockify" - icon = "clockify_white_icon" + icon = "app_icons/clockify-white.png" order = 500 - clockapi = ClockifyAPI() - have_permissions = clockapi.validate_workspace_perm() + clockify_api = ClockifyAPI() def is_compatible(self, session): - """Return whether the action is compatible with the session""" - return self.have_permissions + """Check if there's some projects to sync""" + try: + next(get_projects()) + return True + except StopIteration: + return False def process(self, session, **kwargs): + self.clockify_api.set_api() + workspace_id = self.clockify_api.workspace_id + user_id = self.clockify_api.user_id + if not self.clockify_api.validate_workspace_permissions( + workspace_id, user_id + ): + raise ClockifyPermissionsCheckFailed( + "Current CLockify user is missing permissions for this action!" + ) project_name = session.get("AVALON_PROJECT") or "" projects_to_sync = [] @@ -30,24 +49,28 @@ class ClockifySync(LauncherAction): task_types = project["config"]["tasks"].keys() projects_info[project["name"]] = task_types - clockify_projects = self.clockapi.get_projects() + clockify_projects = self.clockify_api.get_projects(workspace_id) for project_name, task_types in projects_info.items(): if project_name in clockify_projects: continue - response = self.clockapi.add_project(project_name) + response = self.clockify_api.add_project( + project_name, workspace_id + ) if "id" not in response: - self.log.error("Project {} can't be created".format( - project_name - )) + self.log.error( + "Project {} can't be created".format(project_name) + ) continue - clockify_workspace_tags = self.clockapi.get_tags() + clockify_workspace_tags = self.clockify_api.get_tags(workspace_id) for task_type in task_types: if task_type not in clockify_workspace_tags: - response = self.clockapi.add_tag(task_type) + response = self.clockify_api.add_tag( + task_type, workspace_id + ) if "id" not in response: - self.log.error('Task {} can\'t be created'.format( - task_type - )) + self.log.error( + "Task {} can't be created".format(task_type) + ) continue diff --git a/openpype/modules/clockify/widgets.py b/openpype/modules/clockify/widgets.py index 122b6212c0..86e67569f2 100644 --- a/openpype/modules/clockify/widgets.py +++ b/openpype/modules/clockify/widgets.py @@ -34,7 +34,7 @@ class MessageWidget(QtWidgets.QWidget): def _ui_layout(self, messages): if not messages: - messages = ["*Misssing messages (This is a bug)*", ] + messages = ["*Missing messages (This is a bug)*", ] elif not isinstance(messages, (tuple, list)): messages = [messages, ] @@ -77,15 +77,15 @@ class MessageWidget(QtWidgets.QWidget): class ClockifySettings(QtWidgets.QWidget): - SIZE_W = 300 + SIZE_W = 500 SIZE_H = 130 loginSignal = QtCore.Signal(object, object, object) - def __init__(self, clockapi, optional=True): + def __init__(self, clockify_api, optional=True): super(ClockifySettings, self).__init__() - self.clockapi = clockapi + self.clockify_api = clockify_api self.optional = optional self.validated = False @@ -162,17 +162,17 @@ class ClockifySettings(QtWidgets.QWidget): def click_ok(self): api_key = self.input_api_key.text().strip() if self.optional is True and api_key == '': - self.clockapi.save_api_key(None) - self.clockapi.set_api(api_key) + self.clockify_api.save_api_key(None) + self.clockify_api.set_api(api_key) self.validated = False self._close_widget() return - validation = self.clockapi.validate_api_key(api_key) + validation = self.clockify_api.validate_api_key(api_key) if validation: - self.clockapi.save_api_key(api_key) - self.clockapi.set_api(api_key) + self.clockify_api.save_api_key(api_key) + self.clockify_api.set_api(api_key) self.validated = True self._close_widget() else: diff --git a/openpype/modules/deadline/plugins/publish/collect_pools.py b/openpype/modules/deadline/plugins/publish/collect_pools.py index 48130848d5..e221eb00ea 100644 --- a/openpype/modules/deadline/plugins/publish/collect_pools.py +++ b/openpype/modules/deadline/plugins/publish/collect_pools.py @@ -3,21 +3,60 @@ """ import pyblish.api +from openpype.lib import TextDef +from openpype.pipeline.publish import OpenPypePyblishPluginMixin -class CollectDeadlinePools(pyblish.api.InstancePlugin): +class CollectDeadlinePools(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): """Collect pools from instance if present, from Setting otherwise.""" order = pyblish.api.CollectorOrder + 0.420 label = "Collect Deadline Pools" - families = ["rendering", "render.farm", "renderFarm", "renderlayer"] + families = ["rendering", + "render.farm", + "renderFarm", + "renderlayer", + "maxrender"] primary_pool = None secondary_pool = None + @classmethod + def apply_settings(cls, project_settings, system_settings): + # deadline.publish.CollectDeadlinePools + settings = project_settings["deadline"]["publish"]["CollectDeadlinePools"] # noqa + cls.primary_pool = settings.get("primary_pool", None) + cls.secondary_pool = settings.get("secondary_pool", None) + def process(self, instance): + + attr_values = self.get_attr_values_from_data(instance.data) if not instance.data.get("primaryPool"): - instance.data["primaryPool"] = self.primary_pool or "none" + instance.data["primaryPool"] = ( + attr_values.get("primaryPool") or self.primary_pool or "none" + ) if not instance.data.get("secondaryPool"): - instance.data["secondaryPool"] = self.secondary_pool or "none" + instance.data["secondaryPool"] = ( + attr_values.get("secondaryPool") or self.secondary_pool or "none" # noqa + ) + + @classmethod + def get_attribute_defs(cls): + # TODO: Preferably this would be an enum for the user + # but the Deadline server URL can be dynamic and + # can be set per render instance. Since get_attribute_defs + # can't be dynamic unfortunately EnumDef isn't possible (yet?) + # pool_names = self.deadline_module.get_deadline_pools(deadline_url, + # self.log) + # secondary_pool_names = ["-"] + pool_names + + return [ + TextDef("primaryPool", + label="Primary Pool", + default=cls.primary_pool), + TextDef("secondaryPool", + label="Secondary Pool", + default=cls.secondary_pool) + ] diff --git a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py index 038ee4fc03..bcf0850768 100644 --- a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py @@ -106,7 +106,7 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): # define chunk and priority chunk_size = instance.context.data.get("chunk") - if chunk_size == 0: + if not chunk_size: chunk_size = self.deadline_chunk_size # search for %02d pattern in name, and padding number diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 417a03de74..c728b6b9c7 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -3,7 +3,15 @@ import getpass import copy import attr -from openpype.pipeline import legacy_io +from openpype.lib import ( + TextDef, + BoolDef, + NumberDef, +) +from openpype.pipeline import ( + legacy_io, + OpenPypePyblishPluginMixin +) from openpype.settings import get_project_settings from openpype.hosts.max.api.lib import ( get_current_renderer, @@ -22,7 +30,8 @@ class MaxPluginInfo(object): IgnoreInputs = attr.ib(default=True) -class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): +class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, + OpenPypePyblishPluginMixin): label = "Submit Render to Deadline" hosts = ["max"] @@ -31,14 +40,22 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): use_published = True priority = 50 - tile_priority = 50 chunk_size = 1 jobInfo = {} pluginInfo = {} group = None - deadline_pool = None - deadline_pool_secondary = None - framePerTask = 1 + + @classmethod + def apply_settings(cls, project_settings, system_settings): + settings = project_settings["deadline"]["publish"]["MaxSubmitDeadline"] # noqa + + # Take some defaults from settings + cls.use_published = settings.get("use_published", + cls.use_published) + cls.priority = settings.get("priority", + cls.priority) + cls.chuck_size = settings.get("chunk_size", cls.chunk_size) + cls.group = settings.get("group", cls.group) def get_job_info(self): job_info = DeadlineJobInfo(Plugin="3dsmax") @@ -49,11 +66,11 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): instance = self._instance context = instance.context - # Always use the original work file name for the Job name even when # rendering is done from the published Work File. The original work # file name is clearer because it can also have subversion strings, # etc. which are stripped for the published file. + src_filepath = context.data["currentFile"] src_filename = os.path.basename(src_filepath) @@ -71,13 +88,13 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): job_info.Pool = instance.data.get("primaryPool") job_info.SecondaryPool = instance.data.get("secondaryPool") - job_info.ChunkSize = instance.data.get("chunkSize", 1) - job_info.Comment = context.data.get("comment") - job_info.Priority = instance.data.get("priority", self.priority) - job_info.FramesPerTask = instance.data.get("framesPerTask", 1) - if self.group: - job_info.Group = self.group + attr_values = self.get_attr_values_from_data(instance.data) + + job_info.ChunkSize = attr_values.get("chunkSize", 1) + job_info.Comment = context.data.get("comment") + job_info.Priority = attr_values.get("priority", self.priority) + job_info.Group = attr_values.get("group", self.group) # Add options from RenderGlobals render_globals = instance.data.get("renderGlobals", {}) @@ -216,3 +233,32 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): plugin_info.update(plugin_data) return job_info, plugin_info + + @classmethod + def get_attribute_defs(cls): + defs = super(MaxSubmitDeadline, cls).get_attribute_defs() + defs.extend([ + BoolDef("use_published", + default=cls.use_published, + label="Use Published Scene"), + + NumberDef("priority", + minimum=1, + maximum=250, + decimals=0, + default=cls.priority, + label="Priority"), + + NumberDef("chunkSize", + minimum=1, + maximum=50, + decimals=0, + default=cls.chunk_size, + label="Frame Per Task"), + + TextDef("group", + default=cls.group, + label="Group Name"), + ]) + + return defs diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 15025e47f2..062732c059 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -422,6 +422,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): assembly_job_info.Priority = instance.data.get( "tile_priority", self.tile_priority ) + assembly_job_info.TileJob = False pool = instance.context.data["project_settings"]["deadline"] pool = pool["publish"]["ProcessSubmittedJobOnFarm"]["deadline_pool"] @@ -450,15 +451,14 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): frame_assembly_job_info.ExtraInfo[0] = file_hash frame_assembly_job_info.ExtraInfo[1] = file frame_assembly_job_info.JobDependencies = tile_job_id + frame_assembly_job_info.Frames = frame # write assembly job config files - now = datetime.now() - config_file = os.path.join( output_dir, "{}_config_{}.txt".format( os.path.splitext(file)[0], - now.strftime("%Y_%m_%d_%H_%M_%S") + datetime.now().strftime("%Y_%m_%d_%H_%M_%S") ) ) try: @@ -469,6 +469,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): self.log.warning("Path is unreachable: " "`{}`".format(output_dir)) + assembly_plugin_info["ConfigFile"] = config_file + with open(config_file, "w") as cf: print("TileCount={}".format(tiles_count), file=cf) print("ImageFileName={}".format(file), file=cf) @@ -477,25 +479,30 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): print("ImageHeight={}".format( instance.data.get("resolutionHeight")), file=cf) + with open(config_file, "a") as cf: + # Need to reverse the order of the y tiles, because image + # coordinates are calculated from bottom left corner. tiles = _format_tiles( file, 0, instance.data.get("tilesX"), instance.data.get("tilesY"), instance.data.get("resolutionWidth"), instance.data.get("resolutionHeight"), - payload_plugin_info["OutputFilePrefix"] + payload_plugin_info["OutputFilePrefix"], + reversed_y=True )[1] for k, v in sorted(tiles.items()): print("{}={}".format(k, v), file=cf) - payload = self.assemble_payload( - job_info=frame_assembly_job_info, - plugin_info=assembly_plugin_info.copy(), - # todo: aux file transfers don't work with deadline webservice - # add config file as job auxFile - # aux_files=[config_file] + assembly_payloads.append( + self.assemble_payload( + job_info=frame_assembly_job_info, + plugin_info=assembly_plugin_info.copy(), + # This would fail if the client machine and webserice are + # using different storage paths. + aux_files=[config_file] + ) ) - assembly_payloads.append(payload) # Submit assembly jobs assembly_job_ids = [] @@ -505,6 +512,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): "submitting assembly job {} of {}".format(i + 1, num_assemblies) ) + self.log.info(payload) assembly_job_id = self.submit(payload) assembly_job_ids.append(assembly_job_id) @@ -764,8 +772,15 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): def _format_tiles( - filename, index, tiles_x, tiles_y, - width, height, prefix): + filename, + index, + tiles_x, + tiles_y, + width, + height, + prefix, + reversed_y=False +): """Generate tile entries for Deadline tile job. Returns two dictionaries - one that can be directly used in Deadline @@ -802,6 +817,7 @@ def _format_tiles( width (int): Width resolution of final image. height (int): Height resolution of final image. prefix (str): Image prefix. + reversed_y (bool): Reverses the order of the y tiles. Returns: (dict, dict): Tuple of two dictionaries - first can be used to @@ -824,12 +840,16 @@ def _format_tiles( cfg["TilesCropped"] = "False" tile = 0 + range_y = range(1, tiles_y + 1) + reversed_y_range = list(reversed(range_y)) for tile_x in range(1, tiles_x + 1): - for tile_y in reversed(range(1, tiles_y + 1)): + for i, tile_y in enumerate(range_y): + tile_y_index = tile_y + if reversed_y: + tile_y_index = reversed_y_range[i] + tile_prefix = "_tile_{}x{}_{}x{}_".format( - tile_x, tile_y, - tiles_x, - tiles_y + tile_x, tile_y_index, tiles_x, tiles_y ) new_filename = "{}/{}{}".format( @@ -844,11 +864,14 @@ def _format_tiles( right = (tile_x * w_space) - 1 # Job info - out["JobInfo"]["OutputFilename{}Tile{}".format(index, tile)] = new_filename # noqa: E501 + key = "OutputFilename{}".format(index) + out["JobInfo"][key] = new_filename # Plugin Info - out["PluginInfo"]["RegionPrefix{}".format(str(tile))] = \ - "/{}".format(tile_prefix).join(prefix.rsplit("/", 1)) + key = "RegionPrefix{}".format(str(tile)) + out["PluginInfo"][key] = "/{}".format( + tile_prefix + ).join(prefix.rsplit("/", 1)) out["PluginInfo"]["RegionTop{}".format(tile)] = top out["PluginInfo"]["RegionBottom{}".format(tile)] = bottom out["PluginInfo"]["RegionLeft{}".format(tile)] = left diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index aff34c7e4a..0c899a500c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -32,7 +32,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, label = "Submit Nuke to Deadline" order = pyblish.api.IntegratorOrder + 0.1 hosts = ["nuke"] - families = ["render", "prerender.farm"] + families = ["render", "prerender"] optional = True targets = ["local"] @@ -66,7 +66,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, ), NumberDef( "concurrency", - label="Concurency", + label="Concurrency", default=cls.concurrent_tasks, decimals=0, minimum=1, @@ -80,6 +80,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, ] def process(self, instance): + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + instance.data["attributeValues"] = self.get_attr_values_from_data( instance.data) @@ -168,10 +172,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, resp.json()["_id"]) # redefinition of families - if "render.farm" in families: + if "render" in instance.data["family"]: instance.data['family'] = 'write' families.insert(0, "render2d") - elif "prerender.farm" in families: + elif "prerender" in instance.data["family"]: instance.data['family'] = 'write' families.insert(0, "prerender") instance.data["families"] = families diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 53c09ad22f..41bc103d5c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -85,10 +85,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): These jobs are dependent on a deadline or muster job submission prior to this plug-in. - - In case of Deadline, it creates dependend job on farm publishing + - In case of Deadline, it creates dependent job on farm publishing rendered image sequence. - - In case of Muster, there is no need for such thing as dependend job, + - In case of Muster, there is no need for such thing as dependent job, post action will be executed and rendered sequence will be published. Options in instance.data: @@ -108,7 +108,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): - publishJobState (str, Optional): "Active" or "Suspended" This defaults to "Suspended" - - expectedFiles (list or dict): explained bellow + - expectedFiles (list or dict): explained below """ @@ -158,8 +158,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # regex for finding frame number in string R_FRAME_NUMBER = re.compile(r'.+\.(?P[0-9]+)\..+') - # mapping of instance properties to be transfered to new instance for every - # specified family + # mapping of instance properties to be transferred to new instance + # for every specified family instance_transfer = { "slate": ["slateFrames", "slate"], "review": ["lutPath"], @@ -398,7 +398,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): continue r_col.indexes.remove(frame) - # now we need to translate published names from represenation + # now we need to translate published names from representation # back. This is tricky, right now we'll just use same naming # and only switch frame numbers resource_files = [] @@ -535,7 +535,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if preview: new_instance["review"] = True - # create represenation + # create representation if isinstance(col, (list, tuple)): files = [os.path.basename(f) for f in col] else: @@ -748,7 +748,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # type: (pyblish.api.Instance) -> None """Process plugin. - Detect type of renderfarm submission and create and post dependend job + Detect type of renderfarm submission and create and post dependent job in case of Deadline. It creates json file with metadata needed for publishing in directory of render. @@ -756,6 +756,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): instance (pyblish.api.Instance): Instance data. """ + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + data = instance.data.copy() context = instance.context self.context = context @@ -982,7 +986,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): instances = [instance_skeleton_data] # if we are attaching to other subsets, create copy of existing - # instances, change data to match thats subset and replace + # instances, change data to match its subset and replace # existing instances with modified data if instance.data.get("attachTo"): self.log.info("Attaching render to subset:") diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index 78eed17c98..7c8ab62d4d 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -17,10 +17,18 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, label = "Validate Deadline Pools" order = pyblish.api.ValidatorOrder - families = ["rendering", "render.farm", "renderFarm", "renderlayer"] + families = ["rendering", + "render.farm", + "renderFarm", + "renderlayer", + "maxrender"] optional = True def process(self, instance): + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] self.log.info("deadline_url::{}".format(deadline_url)) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index f34f71d213..ff4be677e7 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -68,8 +68,15 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # files to be in the folder that we might not want to use. missing = expected_files - existing_files if missing: - raise RuntimeError("Missing expected files: {}".format( - sorted(missing))) + raise RuntimeError( + "Missing expected files: {}\n" + "Expected files: {}\n" + "Existing files: {}".format( + sorted(missing), + sorted(expected_files), + sorted(existing_files) + ) + ) def _get_frame_list(self, original_job_id): """Returns list of frame ranges from all render job. diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 20a58c9131..15226bb773 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -362,11 +362,11 @@ def inject_openpype_environment(deadlinePlugin): args_str = subprocess.list2cmdline(args) print(">>> Executing: {} {}".format(exe, args_str)) - process = ProcessUtils.SpawnProcess( - exe, args_str, os.path.dirname(exe) + process_exitcode = deadlinePlugin.RunProcess( + exe, args_str, os.path.dirname(exe), -1 ) - ProcessUtils.WaitForExit(process, -1) - if process.ExitCode != 0: + + if process_exitcode != 0: raise RuntimeError( "Failed to run OpenPype process to extract environments." ) diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index 861f16518c..b51daffbc8 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -16,6 +16,10 @@ from Deadline.Scripting import ( FileUtils, RepositoryUtils, SystemUtils) +version_major = 1 +version_minor = 0 +version_patch = 0 +version_string = "{}.{}.{}".format(version_major, version_minor, version_patch) STRING_TAGS = { "format" } @@ -264,6 +268,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): def initialize_process(self): """Initialization.""" + self.LogInfo("Plugin version: {}".format(version_string)) self.SingleFramesOnly = True self.StdoutHandling = True self.renderer = self.GetPluginInfoEntryWithDefault( @@ -320,12 +325,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): output_file = data["ImageFileName"] output_file = RepositoryUtils.CheckPathMapping(output_file) output_file = self.process_path(output_file) - """ - _, ext = os.path.splitext(output_file) - if "exr" not in ext: - self.FailRender( - "[{}] Only EXR format is supported for now.".format(ext)) - """ + tile_info = [] for tile in range(int(data["TileCount"])): tile_info.append({ @@ -336,11 +336,6 @@ class OpenPypeTileAssembler(DeadlinePlugin): "width": int(data["Tile{}Width".format(tile)]) }) - # FFMpeg doesn't support tile coordinates at the moment. - # arguments = self.tile_completer_ffmpeg_args( - # int(data["ImageWidth"]), int(data["ImageHeight"]), - # tile_info, output_file) - arguments = self.tile_oiio_args( int(data["ImageWidth"]), int(data["ImageHeight"]), tile_info, output_file) @@ -362,20 +357,20 @@ class OpenPypeTileAssembler(DeadlinePlugin): def pre_render_tasks(self): """Load config file and do remapping.""" self.LogInfo("OpenPype Tile Assembler starting...") - scene_filename = self.GetDataFilename() + config_file = self.GetPluginInfoEntry("ConfigFile") temp_scene_directory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber())) - temp_scene_filename = Path.GetFileName(scene_filename) + temp_scene_filename = Path.GetFileName(config_file) self.config_file = Path.Combine( temp_scene_directory, temp_scene_filename) if SystemUtils.IsRunningOnWindows(): RepositoryUtils.CheckPathMappingInFileAndReplaceSeparator( - scene_filename, self.config_file, "/", "\\") + config_file, self.config_file, "/", "\\") else: RepositoryUtils.CheckPathMappingInFileAndReplaceSeparator( - scene_filename, self.config_file, "\\", "/") + config_file, self.config_file, "\\", "/") os.chmod(self.config_file, os.stat(self.config_file).st_mode) def post_render_tasks(self): @@ -459,75 +454,3 @@ class OpenPypeTileAssembler(DeadlinePlugin): args.append(output_path) return args - - def tile_completer_ffmpeg_args( - self, output_width, output_height, tiles_info, output_path): - """Generate ffmpeg arguments for tile assembly. - - Expected inputs are tiled images. - - Args: - output_width (int): Width of output image. - output_height (int): Height of output image. - tiles_info (list): List of tile items, each item must be - dictionary with `filepath`, `pos_x` and `pos_y` keys - representing path to file and x, y coordinates on output - image where top-left point of tile item should start. - output_path (str): Path to file where should be output stored. - - Returns: - (list): ffmpeg arguments. - - """ - previous_name = "base" - ffmpeg_args = [] - filter_complex_strs = [] - - filter_complex_strs.append("nullsrc=size={}x{}[{}]".format( - output_width, output_height, previous_name - )) - - new_tiles_info = {} - for idx, tile_info in enumerate(tiles_info): - # Add input and store input index - filepath = tile_info["filepath"] - ffmpeg_args.append("-i \"{}\"".format(filepath.replace("\\", "/"))) - - # Prepare initial filter complex arguments - index_name = "input{}".format(idx) - filter_complex_strs.append( - "[{}]setpts=PTS-STARTPTS[{}]".format(idx, index_name) - ) - tile_info["index"] = idx - new_tiles_info[index_name] = tile_info - - # Set frames to 1 - ffmpeg_args.append("-frames 1") - - # Concatenation filter complex arguments - global_index = 1 - total_index = len(new_tiles_info) - for index_name, tile_info in new_tiles_info.items(): - item_str = ( - "[{previous_name}][{index_name}]overlay={pos_x}:{pos_y}" - ).format( - previous_name=previous_name, - index_name=index_name, - pos_x=tile_info["pos_x"], - pos_y=tile_info["pos_y"] - ) - new_previous = "tmp{}".format(global_index) - if global_index != total_index: - item_str += "[{}]".format(new_previous) - filter_complex_strs.append(item_str) - previous_name = new_previous - global_index += 1 - - joined_parts = ";".join(filter_complex_strs) - filter_complex_str = "-filter_complex \"{}\"".format(joined_parts) - - ffmpeg_args.append(filter_complex_str) - ffmpeg_args.append("-y") - ffmpeg_args.append("\"{}\"".format(output_path)) - - return ffmpeg_args diff --git a/openpype/modules/example_addons/example_addon/addon.py b/openpype/modules/example_addons/example_addon/addon.py index ead647b41d..be1d3ff920 100644 --- a/openpype/modules/example_addons/example_addon/addon.py +++ b/openpype/modules/example_addons/example_addon/addon.py @@ -44,7 +44,7 @@ class AddonSettingsDef(JsonFilesSettingsDef): class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction): - """This Addon has defined it's settings and interface. + """This Addon has defined its settings and interface. This example has system settings with an enabled option. And use few other interfaces: diff --git a/openpype/modules/ftrack/event_handlers_server/action_clone_review_session.py b/openpype/modules/ftrack/event_handlers_server/action_clone_review_session.py index 1ad7a17785..333228c699 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_clone_review_session.py +++ b/openpype/modules/ftrack/event_handlers_server/action_clone_review_session.py @@ -44,7 +44,7 @@ def clone_review_session(session, entity): class CloneReviewSession(ServerAction): '''Generate Client Review action - `label` a descriptive string identifing your action. + `label` a descriptive string identifying your action. `varaint` To group actions together, give them the same label and specify a unique variant per action. `identifier` a unique identifier for your action. diff --git a/openpype/modules/ftrack/event_handlers_server/action_create_review_session.py b/openpype/modules/ftrack/event_handlers_server/action_create_review_session.py index 21382007a0..42a279e333 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_create_review_session.py +++ b/openpype/modules/ftrack/event_handlers_server/action_create_review_session.py @@ -230,7 +230,7 @@ class CreateDailyReviewSessionServerAction(ServerAction): if not today_session_name: continue - # Find matchin review session + # Find matching review session project_review_sessions = review_sessions_by_project_id[project_id] todays_session = None yesterdays_session = None diff --git a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py index 332648cd02..02231cbe3c 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py +++ b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py @@ -124,7 +124,7 @@ class PrepareProjectServer(ServerAction): root_items.append({ "type": "label", "value": ( - "

NOTE: Roots are crutial for path filling" + "

NOTE: Roots are crucial for path filling" " (and creating folder structure).

" ) }) diff --git a/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py index 1209375f82..a698195c59 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py @@ -9,7 +9,7 @@ from openpype_modules.ftrack.lib import ( class PushHierValuesToNonHier(ServerAction): - """Action push hierarchical custom attribute values to non hierarchical. + """Action push hierarchical custom attribute values to non-hierarchical. Hierarchical value is also pushed to their task entities. @@ -119,17 +119,109 @@ class PushHierValuesToNonHier(ServerAction): self.join_query_keys(object_ids) )).all() - output = {} + attrs_by_obj_id = collections.defaultdict(list) hiearchical = [] for attr in attrs: if attr["is_hierarchical"]: hiearchical.append(attr) continue obj_id = attr["object_type_id"] - if obj_id not in output: - output[obj_id] = [] - output[obj_id].append(attr) - return output, hiearchical + attrs_by_obj_id[obj_id].append(attr) + return attrs_by_obj_id, hiearchical + + def query_attr_value( + self, + session, + hier_attrs, + attrs_by_obj_id, + dst_object_type_ids, + task_entity_ids, + non_task_entity_ids, + parent_id_by_entity_id + ): + all_non_task_ids_with_parents = set() + for entity_id in non_task_entity_ids: + all_non_task_ids_with_parents.add(entity_id) + _entity_id = entity_id + while True: + parent_id = parent_id_by_entity_id.get(_entity_id) + if ( + parent_id is None + or parent_id in all_non_task_ids_with_parents + ): + break + all_non_task_ids_with_parents.add(parent_id) + _entity_id = parent_id + + all_entity_ids = ( + set(all_non_task_ids_with_parents) + | set(task_entity_ids) + ) + attr_ids = {attr["id"] for attr in hier_attrs} + for obj_id in dst_object_type_ids: + attrs = attrs_by_obj_id.get(obj_id) + if attrs is not None: + for attr in attrs: + attr_ids.add(attr["id"]) + + real_values_by_entity_id = { + entity_id: {} + for entity_id in all_entity_ids + } + + attr_values = query_custom_attributes( + session, attr_ids, all_entity_ids, True + ) + for item in attr_values: + entity_id = item["entity_id"] + attr_id = item["configuration_id"] + real_values_by_entity_id[entity_id][attr_id] = item["value"] + + # Fill hierarchical values + hier_attrs_key_by_id = { + hier_attr["id"]: hier_attr + for hier_attr in hier_attrs + } + hier_values_per_entity_id = {} + for entity_id in all_non_task_ids_with_parents: + real_values = real_values_by_entity_id[entity_id] + hier_values_per_entity_id[entity_id] = {} + for attr_id, attr in hier_attrs_key_by_id.items(): + key = attr["key"] + hier_values_per_entity_id[entity_id][key] = ( + real_values.get(attr_id) + ) + + output = {} + for entity_id in non_task_entity_ids: + output[entity_id] = {} + for attr in hier_attrs_key_by_id.values(): + key = attr["key"] + value = hier_values_per_entity_id[entity_id][key] + tried_ids = set() + if value is None: + tried_ids.add(entity_id) + _entity_id = entity_id + while value is None: + parent_id = parent_id_by_entity_id.get(_entity_id) + if not parent_id: + break + value = hier_values_per_entity_id[parent_id][key] + if value is not None: + break + _entity_id = parent_id + tried_ids.add(parent_id) + + if value is None: + value = attr["default"] + + if value is not None: + for ent_id in tried_ids: + hier_values_per_entity_id[ent_id][key] = value + + output[entity_id][key] = value + + return real_values_by_entity_id, output def propagate_values(self, session, event, selected_entities): ftrack_settings = self.get_ftrack_settings( @@ -156,29 +248,24 @@ class PushHierValuesToNonHier(ServerAction): } task_object_type = object_types_by_low_name["task"] - destination_object_types = [task_object_type] + dst_object_type_ids = {task_object_type["id"]} for ent_type in interest_entity_types: obj_type = object_types_by_low_name.get(ent_type) - if obj_type and obj_type not in destination_object_types: - destination_object_types.append(obj_type) - - destination_object_type_ids = set( - obj_type["id"] - for obj_type in destination_object_types - ) + if obj_type: + dst_object_type_ids.add(obj_type["id"]) interest_attributes = action_settings["interest_attributes"] # Find custom attributes definitions attrs_by_obj_id, hier_attrs = self.attrs_configurations( - session, destination_object_type_ids, interest_attributes + session, dst_object_type_ids, interest_attributes ) # Filter destination object types if they have any object specific # custom attribute - for obj_id in tuple(destination_object_type_ids): + for obj_id in tuple(dst_object_type_ids): if obj_id not in attrs_by_obj_id: - destination_object_type_ids.remove(obj_id) + dst_object_type_ids.remove(obj_id) - if not destination_object_type_ids: + if not dst_object_type_ids: # TODO report that there are not matching custom attributes return { "success": True, @@ -192,14 +279,14 @@ class PushHierValuesToNonHier(ServerAction): session, selected_ids, project_entity, - destination_object_type_ids + dst_object_type_ids ) self.log.debug("Preparing whole project hierarchy by ids.") entities_by_obj_id = { obj_id: [] - for obj_id in destination_object_type_ids + for obj_id in dst_object_type_ids } self.log.debug("Filtering Task entities.") @@ -223,10 +310,16 @@ class PushHierValuesToNonHier(ServerAction): "message": "Nothing to do in your selection." } - self.log.debug("Getting Hierarchical custom attribute values parents.") - hier_values_by_entity_id = self.get_hier_values( + self.log.debug("Getting Custom attribute values.") + ( + real_values_by_entity_id, + hier_values_by_entity_id + ) = self.query_attr_value( session, hier_attrs, + attrs_by_obj_id, + dst_object_type_ids, + task_entity_ids, non_task_entity_ids, parent_id_by_entity_id ) @@ -237,7 +330,8 @@ class PushHierValuesToNonHier(ServerAction): hier_attrs, task_entity_ids, hier_values_by_entity_id, - parent_id_by_entity_id + parent_id_by_entity_id, + real_values_by_entity_id ) self.log.debug("Setting values to entities themselves.") @@ -245,7 +339,8 @@ class PushHierValuesToNonHier(ServerAction): session, entities_by_obj_id, attrs_by_obj_id, - hier_values_by_entity_id + hier_values_by_entity_id, + real_values_by_entity_id ) return True @@ -322,112 +417,64 @@ class PushHierValuesToNonHier(ServerAction): return parent_id_by_entity_id, filtered_entities - def get_hier_values( - self, - session, - hier_attrs, - focus_entity_ids, - parent_id_by_entity_id - ): - all_ids_with_parents = set() - for entity_id in focus_entity_ids: - all_ids_with_parents.add(entity_id) - _entity_id = entity_id - while True: - parent_id = parent_id_by_entity_id.get(_entity_id) - if ( - not parent_id - or parent_id in all_ids_with_parents - ): - break - all_ids_with_parents.add(parent_id) - _entity_id = parent_id - - hier_attr_ids = tuple(hier_attr["id"] for hier_attr in hier_attrs) - hier_attrs_key_by_id = { - hier_attr["id"]: hier_attr["key"] - for hier_attr in hier_attrs - } - - values_per_entity_id = {} - for entity_id in all_ids_with_parents: - values_per_entity_id[entity_id] = {} - for key in hier_attrs_key_by_id.values(): - values_per_entity_id[entity_id][key] = None - - values = query_custom_attributes( - session, hier_attr_ids, all_ids_with_parents, True - ) - for item in values: - entity_id = item["entity_id"] - key = hier_attrs_key_by_id[item["configuration_id"]] - - values_per_entity_id[entity_id][key] = item["value"] - - output = {} - for entity_id in focus_entity_ids: - output[entity_id] = {} - for key in hier_attrs_key_by_id.values(): - value = values_per_entity_id[entity_id][key] - tried_ids = set() - if value is None: - tried_ids.add(entity_id) - _entity_id = entity_id - while value is None: - parent_id = parent_id_by_entity_id.get(_entity_id) - if not parent_id: - break - value = values_per_entity_id[parent_id][key] - if value is not None: - break - _entity_id = parent_id - tried_ids.add(parent_id) - - if value is not None: - for ent_id in tried_ids: - values_per_entity_id[ent_id][key] = value - - output[entity_id][key] = value - return output - def set_task_attr_values( self, session, hier_attrs, task_entity_ids, hier_values_by_entity_id, - parent_id_by_entity_id + parent_id_by_entity_id, + real_values_by_entity_id ): hier_attr_id_by_key = { attr["key"]: attr["id"] for attr in hier_attrs } + filtered_task_ids = set() for task_id in task_entity_ids: - parent_id = parent_id_by_entity_id.get(task_id) or {} + parent_id = parent_id_by_entity_id.get(task_id) parent_values = hier_values_by_entity_id.get(parent_id) - if not parent_values: - continue + if parent_values: + filtered_task_ids.add(task_id) + if not filtered_task_ids: + return + + for task_id in filtered_task_ids: + parent_id = parent_id_by_entity_id[task_id] + parent_values = hier_values_by_entity_id[parent_id] hier_values_by_entity_id[task_id] = {} + real_task_attr_values = real_values_by_entity_id[task_id] for key, value in parent_values.items(): hier_values_by_entity_id[task_id][key] = value + if value is None: + continue + configuration_id = hier_attr_id_by_key[key] _entity_key = collections.OrderedDict([ ("configuration_id", configuration_id), ("entity_id", task_id) ]) - - session.recorded_operations.push( - ftrack_api.operation.UpdateEntityOperation( - "ContextCustomAttributeValue", + op = None + if configuration_id not in real_task_attr_values: + op = ftrack_api.operation.CreateEntityOperation( + "CustomAttributeValue", + _entity_key, + {"value": value} + ) + elif real_task_attr_values[configuration_id] != value: + op = ftrack_api.operation.UpdateEntityOperation( + "CustomAttributeValue", _entity_key, "value", - ftrack_api.symbol.NOT_SET, + real_task_attr_values[configuration_id], value ) - ) - if len(session.recorded_operations) > 100: - session.commit() + + if op is not None: + session.recorded_operations.push(op) + if len(session.recorded_operations) > 100: + session.commit() session.commit() @@ -436,39 +483,68 @@ class PushHierValuesToNonHier(ServerAction): session, entities_by_obj_id, attrs_by_obj_id, - hier_values_by_entity_id + hier_values_by_entity_id, + real_values_by_entity_id ): + """Push values from hierarchical custom attributes to non-hierarchical. + + Args: + session (ftrack_api.Sessison): Session which queried entities, + values and which is used for change propagation. + entities_by_obj_id (dict[str, list[str]]): TypedContext + ftrack entity ids where the attributes are propagated by their + object ids. + attrs_by_obj_id (dict[str, ftrack_api.Entity]): Objects of + 'CustomAttributeConfiguration' by their ids. + hier_values_by_entity_id (doc[str, dict[str, Any]]): Attribute + values by entity id and by their keys. + real_values_by_entity_id (doc[str, dict[str, Any]]): Real attribute + values of entities. + """ + for object_id, entity_ids in entities_by_obj_id.items(): attrs = attrs_by_obj_id.get(object_id) if not attrs or not entity_ids: continue - for attr in attrs: - for entity_id in entity_ids: - value = ( - hier_values_by_entity_id - .get(entity_id, {}) - .get(attr["key"]) - ) + for entity_id in entity_ids: + real_values = real_values_by_entity_id.get(entity_id) + hier_values = hier_values_by_entity_id.get(entity_id) + if hier_values is None: + continue + + for attr in attrs: + attr_id = attr["id"] + attr_key = attr["key"] + value = hier_values.get(attr_key) if value is None: continue _entity_key = collections.OrderedDict([ - ("configuration_id", attr["id"]), + ("configuration_id", attr_id), ("entity_id", entity_id) ]) - session.recorded_operations.push( - ftrack_api.operation.UpdateEntityOperation( - "ContextCustomAttributeValue", + op = None + if attr_id not in real_values: + op = ftrack_api.operation.CreateEntityOperation( + "CustomAttributeValue", + _entity_key, + {"value": value} + ) + elif real_values[attr_id] != value: + op = ftrack_api.operation.UpdateEntityOperation( + "CustomAttributeValue", _entity_key, "value", - ftrack_api.symbol.NOT_SET, + real_values[attr_id], value ) - ) - if len(session.recorded_operations) > 100: - session.commit() + + if op is not None: + session.recorded_operations.push(op) + if len(session.recorded_operations) > 100: + session.commit() session.commit() diff --git a/openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py b/openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py index d160b7200d..f6899843a3 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py +++ b/openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py @@ -12,7 +12,7 @@ from openpype_modules.ftrack.lib.avalon_sync import create_chunks class TransferHierarchicalValues(ServerAction): - """Transfer values across hierarhcical attributes. + """Transfer values across hierarchical attributes. Aalso gives ability to convert types meanwhile. That is limited to conversions between numbers and strings @@ -67,7 +67,7 @@ class TransferHierarchicalValues(ServerAction): "type": "label", "value": ( "Didn't found custom attributes" - " that can be transfered." + " that can be transferred." ) }] } diff --git a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py index a65ae46545..a100c34f67 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py +++ b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py @@ -279,7 +279,7 @@ class NextTaskUpdate(BaseEvent): except Exception: session.rollback() self.log.warning( - "\"{}\" status couldnt be set to \"{}\"".format( + "\"{}\" status couldn't be set to \"{}\"".format( ent_path, new_status["name"] ), exc_info=True diff --git a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py index dc76920a57..ed630ad59d 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py @@ -1,6 +1,6 @@ import collections -import datetime import copy +from typing import Any import ftrack_api from openpype_modules.ftrack.lib import ( @@ -9,13 +9,30 @@ from openpype_modules.ftrack.lib import ( ) -class PushFrameValuesToTaskEvent(BaseEvent): +class PushHierValuesToNonHierEvent(BaseEvent): + """Push value changes between hierarchical and non-hierarchical attributes. + + Changes of non-hierarchical attributes are pushed to hierarchical and back. + The attributes must have same definition of custom attribute. + + Handler does not handle changes of hierarchical parents. So if entity does + not have explicitly set value of hierarchical attribute and any parent + would change it the change would not be propagated. + + The handler also push the value to task entity on task creation + and movement. To push values between hierarchical & non-hierarchical + add 'Task' to entity types in settings. + + Todos: + Task attribute values push on create/move should be possible to + enabled by settings. + """ + # Ignore event handler by default cust_attrs_query = ( "select id, key, object_type_id, is_hierarchical, default" " from CustomAttributeConfiguration" - " where key in ({}) and" - " (object_type_id in ({}) or is_hierarchical is true)" + " where key in ({})" ) _cached_task_object_id = None @@ -26,35 +43,35 @@ class PushFrameValuesToTaskEvent(BaseEvent): settings_key = "sync_hier_entity_attributes" - def session_user_id(self, session): - if self._cached_user_id is None: - user = session.query( - "User where username is \"{}\"".format(session.api_user) - ).one() - self._cached_user_id = user["id"] - return self._cached_user_id + def filter_entities_info( + self, event: ftrack_api.event.base.Event + ) -> dict[str, list[dict[str, Any]]]: + """Basic entities filter info we care about. - def launch(self, session, event): - filtered_entities_info = self.filter_entities_info(event) - if not filtered_entities_info: - return + This filtering is first of many filters. This does not query anything + from ftrack nor use settings. - for project_id, entities_info in filtered_entities_info.items(): - self.process_by_project(session, event, project_id, entities_info) + Args: + event (ftrack_api.event.base.Event): Ftrack event with update + information. + + Returns: + dict[str, list[dict[str, Any]]]: Filtered entity changes by + project id. + """ - def filter_entities_info(self, event): # Filter if event contain relevant data entities_info = event["data"].get("entities") if not entities_info: return - entities_info_by_project_id = {} + entities_info_by_project_id = collections.defaultdict(list) for entity_info in entities_info: - # Care only about tasks - if entity_info.get("entityType") != "task": + # Ignore removed entities + if entity_info.get("action") == "remove": continue - # Care only about changes of status + # Care only about information with changes of entities changes = entity_info.get("changes") if not changes: continue @@ -69,367 +86,287 @@ class PushFrameValuesToTaskEvent(BaseEvent): if project_id is None: continue - # Skip `Task` entity type if parent didn't change - if entity_info["entity_type"].lower() == "task": - if ( - "parent_id" not in changes - or changes["parent_id"]["new"] is None - ): - continue - - if project_id not in entities_info_by_project_id: - entities_info_by_project_id[project_id] = [] entities_info_by_project_id[project_id].append(entity_info) return entities_info_by_project_id - def process_by_project(self, session, event, project_id, entities_info): - project_name = self.get_project_name_from_event( + def _get_attrs_configurations(self, session, interest_attributes): + """Get custom attribute configurations by name. + + Args: + session (ftrack_api.Session): Ftrack sesson. + interest_attributes (list[str]): Names of custom attributes + that should be synchronized. + + Returns: + tuple[dict[str, list], list]: Attributes by object id and + hierarchical attributes. + """ + + attrs = session.query(self.cust_attrs_query.format( + self.join_query_keys(interest_attributes) + )).all() + + attrs_by_obj_id = collections.defaultdict(list) + hier_attrs = [] + for attr in attrs: + if attr["is_hierarchical"]: + hier_attrs.append(attr) + continue + obj_id = attr["object_type_id"] + attrs_by_obj_id[obj_id].append(attr) + return attrs_by_obj_id, hier_attrs + + def _get_handler_project_settings( + self, + session: ftrack_api.Session, + event: ftrack_api.event.base.Event, + project_id: str + ) -> tuple[set[str], set[str]]: + """Get handler settings based on the project. + + Args: + session (ftrack_api.Session): Ftrack session. + event (ftrack_api.event.base.Event): Ftrack event which triggered + the changes. + project_id (str): Project id where the current changes are handled. + + Returns: + tuple[set[str], set[str]]: Attribute names we care about and + entity types we care about. + """ + + project_name: str = self.get_project_name_from_event( session, event, project_id ) # Load settings - project_settings = self.get_project_settings_from_event( - event, project_name + project_settings: dict[str, Any] = ( + self.get_project_settings_from_event(event, project_name) ) # Load status mapping from presets - event_settings = ( + event_settings: dict[str, Any] = ( project_settings ["ftrack"] ["events"] - ["sync_hier_entity_attributes"] + [self.settings_key] ) # Skip if event is not enabled if not event_settings["enabled"]: self.log.debug("Project \"{}\" has disabled {}".format( project_name, self.__class__.__name__ )) - return + return set(), set() - interest_attributes = event_settings["interest_attributes"] + interest_attributes: list[str] = event_settings["interest_attributes"] if not interest_attributes: self.log.info(( "Project \"{}\" does not have filled 'interest_attributes'," " skipping." )) - return - interest_entity_types = event_settings["interest_entity_types"] + + interest_entity_types: list[str] = ( + event_settings["interest_entity_types"]) if not interest_entity_types: self.log.info(( "Project \"{}\" does not have filled 'interest_entity_types'," " skipping." )) - return - interest_attributes = set(interest_attributes) - interest_entity_types = set(interest_entity_types) + # Unify possible issues from settings ('Asset Build' -> 'assetbuild') + interest_entity_types: set[str] = { + entity_type.replace(" ", "").lower() + for entity_type in interest_entity_types + } + return set(interest_attributes), interest_entity_types - # Separate value changes and task parent changes - _entities_info = [] - added_entities = [] - added_entity_ids = set() - task_parent_changes = [] + def _entities_filter_by_settings( + self, + entities_info: list[dict[str, Any]], + interest_attributes: set[str], + interest_entity_types: set[str] + ): + new_entities_info = [] for entity_info in entities_info: - if entity_info["entity_type"].lower() == "task": - task_parent_changes.append(entity_info) - elif entity_info.get("action") == "add": - added_entities.append(entity_info) - added_entity_ids.add(entity_info["entityId"]) - else: - _entities_info.append(entity_info) - entities_info = _entities_info + entity_type_low = entity_info["entity_type"].lower() - # Filter entities info with changes - interesting_data, changed_keys_by_object_id = self.filter_changes( - session, event, entities_info, interest_attributes - ) - self.interesting_data_for_added( - session, - added_entities, - interest_attributes, - interesting_data, - changed_keys_by_object_id - ) - if not interesting_data and not task_parent_changes: - return + changes = entity_info["changes"] + # SPECIAL CASE: Capture changes of task created/moved under + # interested entity type + if ( + entity_type_low == "task" + and "parent_id" in changes + ): + # Direct parent is always second item in 'parents' and 'Task' + # must have at least one parent + parent_info = entity_info["parents"][1] + parent_entity_type = ( + parent_info["entity_type"] + .replace(" ", "") + .lower() + ) + if parent_entity_type in interest_entity_types: + new_entities_info.append(entity_info) + continue - # Prepare object types - object_types = session.query("select id, name from ObjectType").all() - object_types_by_name = {} - for object_type in object_types: - name_low = object_type["name"].lower() - object_types_by_name[name_low] = object_type + # Skip if entity type is not enabled for attr value sync + if entity_type_low not in interest_entity_types: + continue - # NOTE it would be nice to check if `interesting_data` do not contain - # value changs of tasks that were created or moved - # - it is a complex way how to find out - if interesting_data: - self.process_attribute_changes( - session, - object_types_by_name, - interesting_data, - changed_keys_by_object_id, - interest_entity_types, - interest_attributes, - added_entity_ids - ) + valid_attr_change = entity_info.get("action") == "add" + for attr_key in interest_attributes: + if valid_attr_change: + break - if task_parent_changes: - self.process_task_parent_change( - session, object_types_by_name, task_parent_changes, - interest_entity_types, interest_attributes - ) + if attr_key not in changes: + continue - def process_task_parent_change( + if changes[attr_key]["new"] is not None: + valid_attr_change = True + + if not valid_attr_change: + continue + + new_entities_info.append(entity_info) + + return new_entities_info + + def propagate_attribute_changes( self, session, - object_types_by_name, - task_parent_changes, - interest_entity_types, - interest_attributes + interest_attributes, + entities_info, + attrs_by_obj_id, + hier_attrs, + real_values_by_entity_id, + hier_values_by_entity_id, ): - """Push custom attribute values if task parent has changed. + hier_attr_ids_by_key = { + attr["key"]: attr["id"] + for attr in hier_attrs + } + filtered_interest_attributes = { + attr_name + for attr_name in interest_attributes + if attr_name in hier_attr_ids_by_key + } + attrs_keys_by_obj_id = {} + for obj_id, attrs in attrs_by_obj_id.items(): + attrs_keys_by_obj_id[obj_id] = { + attr["key"]: attr["id"] + for attr in attrs + } - Parent is changed if task is created or if is moved under different - entity. We don't care about all task changes only about those that - have it's parent in interest types (from settings). + op_changes = [] + for entity_info in entities_info: + entity_id = entity_info["entityId"] + obj_id = entity_info["objectTypeId"] + # Skip attributes sync if does not have object specific custom + # attribute + if obj_id not in attrs_keys_by_obj_id: + continue + attr_keys = attrs_keys_by_obj_id[obj_id] + real_values = real_values_by_entity_id[entity_id] + hier_values = hier_values_by_entity_id[entity_id] - Tasks hierarchical value should be unset or set based on parents - real hierarchical value and non hierarchical custom attribute value - should be set to hierarchical value. - """ - - # Store task ids which were created or moved under parent with entity - # type defined in settings (interest_entity_types). - task_ids = set() - # Store parent ids of matching task ids - matching_parent_ids = set() - # Store all entity ids of all entities to be able query hierarchical - # values. - whole_hierarchy_ids = set() - # Store parent id of each entity id - parent_id_by_entity_id = {} - for entity_info in task_parent_changes: - # Ignore entities with less parents than 2 - # NOTE entity itself is also part of "parents" value - parents = entity_info.get("parents") or [] - if len(parents) < 2: + changes = copy.deepcopy(entity_info["changes"]) + obj_id_attr_keys = { + attr_key + for attr_key in filtered_interest_attributes + if attr_key in attr_keys + } + if not obj_id_attr_keys: continue - parent_info = parents[1] - # Check if parent has entity type we care about. - if parent_info["entity_type"] not in interest_entity_types: - continue + value_by_key = {} + is_new_entity = entity_info.get("action") == "add" + for attr_key in obj_id_attr_keys: + if ( + attr_key in changes + and changes[attr_key]["new"] is not None + ): + value_by_key[attr_key] = changes[attr_key]["new"] - task_ids.add(entity_info["entityId"]) - matching_parent_ids.add(parent_info["entityId"]) - - # Store whole hierarchi of task entity - prev_id = None - for item in parents: - item_id = item["entityId"] - whole_hierarchy_ids.add(item_id) - - if prev_id is None: - prev_id = item_id + if not is_new_entity: continue - parent_id_by_entity_id[prev_id] = item_id - if item["entityType"] == "show": - break - prev_id = item_id + hier_attr_id = hier_attr_ids_by_key[attr_key] + attr_id = attr_keys[attr_key] + if hier_attr_id in real_values or attr_id in real_values: + continue - # Just skip if nothing is interesting for our settings - if not matching_parent_ids: - return + value_by_key[attr_key] = hier_values[hier_attr_id] - # Query object type ids of parent ids for custom attribute - # definitions query - entities = session.query( - "select object_type_id from TypedContext where id in ({})".format( - self.join_query_keys(matching_parent_ids) - ) - ) + for key, new_value in value_by_key.items(): + if new_value is None: + continue - # Prepare task object id - task_object_id = object_types_by_name["task"]["id"] + hier_id = hier_attr_ids_by_key[key] + std_id = attr_keys[key] + real_hier_value = real_values.get(hier_id) + real_std_value = real_values.get(std_id) + hier_value = hier_values[hier_id] + # Get right type of value for conversion + # - values in event are strings + type_value = real_hier_value + if type_value is None: + type_value = real_std_value + if type_value is None: + type_value = hier_value + # Skip if current values are not set + if type_value is None: + continue - # All object ids for which we're querying custom attribute definitions - object_type_ids = set() - object_type_ids.add(task_object_id) - for entity in entities: - object_type_ids.add(entity["object_type_id"]) + try: + new_value = type(type_value)(new_value) + except Exception: + self.log.warning(( + "Couldn't convert from {} to {}." + " Skipping update values." + ).format(type(new_value), type(type_value))) + continue - attrs_by_obj_id, hier_attrs = self.attrs_configurations( - session, object_type_ids, interest_attributes - ) + real_std_value_is_same = new_value == real_std_value + real_hier_value_is_same = new_value == real_hier_value + # New value does not match anything in current entity values + if ( + not is_new_entity + and not real_std_value_is_same + and not real_hier_value_is_same + ): + continue - # Skip if all task attributes are not available - task_attrs = attrs_by_obj_id.get(task_object_id) - if not task_attrs: - return + if not real_std_value_is_same: + op_changes.append(( + std_id, + entity_id, + new_value, + real_values.get(std_id), + std_id in real_values + )) - # Skip attributes that is not in both hierarchical and nonhierarchical - # TODO be able to push values if hierarchical is available - for key in interest_attributes: - if key not in hier_attrs: - task_attrs.pop(key, None) + if not real_hier_value_is_same: + op_changes.append(( + hier_id, + entity_id, + new_value, + real_values.get(hier_id), + hier_id in real_values + )) - elif key not in task_attrs: - hier_attrs.pop(key) + for change in op_changes: + ( + attr_id, + entity_id, + new_value, + old_value, + do_update + ) = change - # Skip if nothing remained - if not task_attrs: - return - - # Do some preparations for custom attribute values query - attr_key_by_id = {} - nonhier_id_by_key = {} - hier_attr_ids = [] - for key, attr_id in hier_attrs.items(): - attr_key_by_id[attr_id] = key - hier_attr_ids.append(attr_id) - - conf_ids = list(hier_attr_ids) - task_conf_ids = [] - for key, attr_id in task_attrs.items(): - attr_key_by_id[attr_id] = key - nonhier_id_by_key[key] = attr_id - conf_ids.append(attr_id) - task_conf_ids.append(attr_id) - - # Query custom attribute values - # - result does not contain values for all entities only result of - # query callback to ftrack server - result = query_custom_attributes( - session, list(hier_attr_ids), whole_hierarchy_ids, True - ) - result.extend( - query_custom_attributes( - session, task_conf_ids, whole_hierarchy_ids, False - ) - ) - - # Prepare variables where result will be stored - # - hierachical values should not contain attribute with value by - # default - hier_values_by_entity_id = { - entity_id: {} - for entity_id in whole_hierarchy_ids - } - # - real values of custom attributes - values_by_entity_id = { - entity_id: { - attr_id: None - for attr_id in conf_ids - } - for entity_id in whole_hierarchy_ids - } - for item in result: - attr_id = item["configuration_id"] - entity_id = item["entity_id"] - value = item["value"] - - values_by_entity_id[entity_id][attr_id] = value - - if attr_id in hier_attr_ids and value is not None: - hier_values_by_entity_id[entity_id][attr_id] = value - - # Prepare values for all task entities - # - going through all parents and storing first value value - # - store None to those that are already known that do not have set - # value at all - for task_id in tuple(task_ids): - for attr_id in hier_attr_ids: - entity_ids = [] - value = None - entity_id = task_id - while value is None: - entity_value = hier_values_by_entity_id[entity_id] - if attr_id in entity_value: - value = entity_value[attr_id] - if value is None: - break - - if value is None: - entity_ids.append(entity_id) - - entity_id = parent_id_by_entity_id.get(entity_id) - if entity_id is None: - break - - for entity_id in entity_ids: - hier_values_by_entity_id[entity_id][attr_id] = value - - # Prepare changes to commit - changes = [] - for task_id in tuple(task_ids): - parent_id = parent_id_by_entity_id[task_id] - for attr_id in hier_attr_ids: - attr_key = attr_key_by_id[attr_id] - nonhier_id = nonhier_id_by_key[attr_key] - - # Real value of hierarchical attribute on parent - # - If is none then should be unset - real_parent_value = values_by_entity_id[parent_id][attr_id] - # Current hierarchical value of a task - # - Will be compared to real parent value - hier_value = hier_values_by_entity_id[task_id][attr_id] - - # Parent value that can be inherited from it's parent entity - parent_value = hier_values_by_entity_id[parent_id][attr_id] - # Task value of nonhierarchical custom attribute - nonhier_value = values_by_entity_id[task_id][nonhier_id] - - if real_parent_value != hier_value: - changes.append({ - "new_value": real_parent_value, - "attr_id": attr_id, - "entity_id": task_id, - "attr_key": attr_key - }) - - if parent_value != nonhier_value: - changes.append({ - "new_value": parent_value, - "attr_id": nonhier_id, - "entity_id": task_id, - "attr_key": attr_key - }) - - self._commit_changes(session, changes) - - def _commit_changes(self, session, changes): - uncommited_changes = False - for idx, item in enumerate(changes): - new_value = item["new_value"] - old_value = item["old_value"] - attr_id = item["attr_id"] - entity_id = item["entity_id"] - attr_key = item["attr_key"] - - entity_key = collections.OrderedDict(( + entity_key = collections.OrderedDict([ ("configuration_id", attr_id), ("entity_id", entity_id) - )) - self._cached_changes.append({ - "attr_key": attr_key, - "entity_id": entity_id, - "value": new_value, - "time": datetime.datetime.now() - }) - old_value_is_set = ( - old_value is not ftrack_api.symbol.NOT_SET - and old_value is not None - ) - if new_value is None: - if not old_value_is_set: - continue - op = ftrack_api.operation.DeleteEntityOperation( - "CustomAttributeValue", - entity_key - ) - - elif old_value_is_set: + ]) + if do_update: op = ftrack_api.operation.UpdateEntityOperation( "CustomAttributeValue", entity_key, @@ -446,449 +383,116 @@ class PushFrameValuesToTaskEvent(BaseEvent): ) session.recorded_operations.push(op) - self.log.info(( - "Changing Custom Attribute \"{}\" to value" - " \"{}\" on entity: {}" - ).format(attr_key, new_value, entity_id)) - - if (idx + 1) % 20 == 0: - uncommited_changes = False - try: - session.commit() - except Exception: - session.rollback() - self.log.warning( - "Changing of values failed.", exc_info=True - ) - else: - uncommited_changes = True - if uncommited_changes: - try: + if len(session.recorded_operations) > 100: session.commit() - except Exception: - session.rollback() - self.log.warning("Changing of values failed.", exc_info=True) + session.commit() - def process_attribute_changes( + def process_by_project( self, - session, - object_types_by_name, - interesting_data, - changed_keys_by_object_id, - interest_entity_types, - interest_attributes, - added_entity_ids + session: ftrack_api.Session, + event: ftrack_api.event.base.Event, + project_id: str, + entities_info: list[dict[str, Any]] ): - # Prepare task object id - task_object_id = object_types_by_name["task"]["id"] + """Process changes in single project. - # Collect object type ids based on settings - interest_object_ids = [] - for entity_type in interest_entity_types: - _entity_type = entity_type.lower() - object_type = object_types_by_name.get(_entity_type) - if not object_type: - self.log.warning("Couldn't find object type \"{}\"".format( - entity_type - )) + Args: + session (ftrack_api.Session): Ftrack session. + event (ftrack_api.event.base.Event): Event which has all changes + information. + project_id (str): Project id related to changes. + entities_info (list[dict[str, Any]]): Changes of entities. + """ - interest_object_ids.append(object_type["id"]) - - # Query entities by filtered data and object ids - entities = self.get_entities( - session, interesting_data, interest_object_ids - ) - if not entities: + ( + interest_attributes, + interest_entity_types + ) = self._get_handler_project_settings(session, event, project_id) + if not interest_attributes or not interest_entity_types: return - # Pop not found entities from interesting data - entity_ids = set( - entity["id"] - for entity in entities + entities_info: list[dict[str, Any]] = ( + self._entities_filter_by_settings( + entities_info, + interest_attributes, + interest_entity_types + ) ) - for entity_id in tuple(interesting_data.keys()): - if entity_id not in entity_ids: - interesting_data.pop(entity_id) - - # Add task object type to list - attr_obj_ids = list(interest_object_ids) - attr_obj_ids.append(task_object_id) - - attrs_by_obj_id, hier_attrs = self.attrs_configurations( - session, attr_obj_ids, interest_attributes - ) - - task_attrs = attrs_by_obj_id.get(task_object_id) - - changed_keys = set() - # Skip keys that are not both in hierachical and type specific - for object_id, keys in changed_keys_by_object_id.items(): - changed_keys |= set(keys) - object_id_attrs = attrs_by_obj_id.get(object_id) - for key in keys: - if key not in hier_attrs: - attrs_by_obj_id[object_id].pop(key) - continue - - if ( - (not object_id_attrs or key not in object_id_attrs) - and (not task_attrs or key not in task_attrs) - ): - hier_attrs.pop(key) - - # Clean up empty values - for key, value in tuple(attrs_by_obj_id.items()): - if not value: - attrs_by_obj_id.pop(key) - - if not attrs_by_obj_id: - self.log.warning(( - "There is not created Custom Attributes {} " - " for entity types: {}" - ).format( - self.join_query_keys(interest_attributes), - self.join_query_keys(interest_entity_types) - )) + if not entities_info: return - # Prepare task entities - task_entities = [] - # If task entity does not contain changed attribute then skip - if task_attrs: - task_entities = self.get_task_entities(session, interesting_data) - - task_entity_ids = set() - parent_id_by_task_id = {} - for task_entity in task_entities: - task_id = task_entity["id"] - task_entity_ids.add(task_id) - parent_id_by_task_id[task_id] = task_entity["parent_id"] - - self.finalize_attribute_changes( - session, - interesting_data, - changed_keys, - attrs_by_obj_id, - hier_attrs, - task_entity_ids, - parent_id_by_task_id, - added_entity_ids - ) - - def finalize_attribute_changes( - self, - session, - interesting_data, - changed_keys, - attrs_by_obj_id, - hier_attrs, - task_entity_ids, - parent_id_by_task_id, - added_entity_ids - ): - attr_id_to_key = {} - for attr_confs in attrs_by_obj_id.values(): - for key in changed_keys: - custom_attr_id = attr_confs.get(key) - if custom_attr_id: - attr_id_to_key[custom_attr_id] = key - - for key in changed_keys: - custom_attr_id = hier_attrs.get(key) - if custom_attr_id: - attr_id_to_key[custom_attr_id] = key - - entity_ids = ( - set(interesting_data.keys()) | task_entity_ids - ) - attr_ids = set(attr_id_to_key.keys()) - - current_values_by_id = self.get_current_values( - session, - attr_ids, - entity_ids, - task_entity_ids, - hier_attrs - ) - - changes = [] - for entity_id, current_values in current_values_by_id.items(): - parent_id = parent_id_by_task_id.get(entity_id) - if not parent_id: - parent_id = entity_id - values = interesting_data[parent_id] - - added_entity = entity_id in added_entity_ids - for attr_id, old_value in current_values.items(): - if added_entity and attr_id in hier_attrs: - continue - - attr_key = attr_id_to_key.get(attr_id) - if not attr_key: - continue - - # Convert new value from string - new_value = values.get(attr_key) - new_value_is_valid = ( - old_value is not ftrack_api.symbol.NOT_SET - and new_value is not None - ) - if added_entity and not new_value_is_valid: - continue - - if new_value is not None and new_value_is_valid: - try: - new_value = type(old_value)(new_value) - except Exception: - self.log.warning(( - "Couldn't convert from {} to {}." - " Skipping update values." - ).format(type(new_value), type(old_value))) - if new_value == old_value: - continue - - changes.append({ - "new_value": new_value, - "attr_id": attr_id, - "old_value": old_value, - "entity_id": entity_id, - "attr_key": attr_key - }) - self._commit_changes(session, changes) - - def filter_changes( - self, session, event, entities_info, interest_attributes - ): - session_user_id = self.session_user_id(session) - user_data = event["data"].get("user") - changed_by_session = False - if user_data and user_data.get("userid") == session_user_id: - changed_by_session = True - - current_time = datetime.datetime.now() - - interesting_data = {} - changed_keys_by_object_id = {} - - for entity_info in entities_info: - # Care only about changes if specific keys - entity_changes = {} - changes = entity_info["changes"] - for key in interest_attributes: - if key in changes: - entity_changes[key] = changes[key]["new"] - - entity_id = entity_info["entityId"] - if changed_by_session: - for key, new_value in tuple(entity_changes.items()): - for cached in tuple(self._cached_changes): - if ( - cached["entity_id"] != entity_id - or cached["attr_key"] != key - ): - continue - - cached_value = cached["value"] - try: - new_value = type(cached_value)(new_value) - except Exception: - pass - - if cached_value == new_value: - self._cached_changes.remove(cached) - entity_changes.pop(key) - break - - delta = (current_time - cached["time"]).seconds - if delta > self._max_delta: - self._cached_changes.remove(cached) - - if not entity_changes: - continue - - entity_id = entity_info["entityId"] - object_id = entity_info["objectTypeId"] - interesting_data[entity_id] = entity_changes - if object_id not in changed_keys_by_object_id: - changed_keys_by_object_id[object_id] = set() - changed_keys_by_object_id[object_id] |= set(entity_changes.keys()) - - return interesting_data, changed_keys_by_object_id - - def interesting_data_for_added( - self, - session, - added_entities, - interest_attributes, - interesting_data, - changed_keys_by_object_id - ): - if not added_entities or not interest_attributes: - return - - object_type_ids = set() - entity_ids = set() - all_entity_ids = set() - object_id_by_entity_id = {} - project_id = None - entity_ids_by_parent_id = collections.defaultdict(set) - for entity_info in added_entities: - object_id = entity_info["objectTypeId"] - entity_id = entity_info["entityId"] - object_type_ids.add(object_id) - entity_ids.add(entity_id) - object_id_by_entity_id[entity_id] = object_id - - for item in entity_info["parents"]: - entity_id = item["entityId"] - all_entity_ids.add(entity_id) - parent_id = item["parentId"] - if not parent_id: - project_id = entity_id - else: - entity_ids_by_parent_id[parent_id].add(entity_id) - - hier_attrs = self.get_hierarchical_configurations( + attrs_by_obj_id, hier_attrs = self._get_attrs_configurations( session, interest_attributes ) - if not hier_attrs: + # Skip if attributes are not available + # - there is nothing to sync + if not attrs_by_obj_id or not hier_attrs: return - hier_attrs_key_by_id = { - attr_conf["id"]: attr_conf["key"] - for attr_conf in hier_attrs - } - default_values_by_key = { - attr_conf["key"]: attr_conf["default"] - for attr_conf in hier_attrs - } + entity_ids_by_parent_id = collections.defaultdict(set) + all_entity_ids = set() + for entity_info in entities_info: + entity_id = None + for item in entity_info["parents"]: + item_id = item["entityId"] + all_entity_ids.add(item_id) + if entity_id is not None: + entity_ids_by_parent_id[item_id].add(entity_id) + entity_id = item_id - values = query_custom_attributes( - session, list(hier_attrs_key_by_id.keys()), all_entity_ids, True + attr_ids = {attr["id"] for attr in hier_attrs} + for attrs in attrs_by_obj_id.values(): + attr_ids |= {attr["id"] for attr in attrs} + + # Query real custom attribute values + # - we have to know what are the real values, if are set and to what + # value + value_items = query_custom_attributes( + session, attr_ids, all_entity_ids, True ) - values_per_entity_id = {} - for entity_id in all_entity_ids: - values_per_entity_id[entity_id] = {} - for attr_name in interest_attributes: - values_per_entity_id[entity_id][attr_name] = None - - for item in values: - entity_id = item["entity_id"] - key = hier_attrs_key_by_id[item["configuration_id"]] - values_per_entity_id[entity_id][key] = item["value"] - - fill_queue = collections.deque() - fill_queue.append((project_id, default_values_by_key)) - while fill_queue: - item = fill_queue.popleft() - entity_id, values_by_key = item - entity_values = values_per_entity_id[entity_id] - new_values_by_key = copy.deepcopy(values_by_key) - for key, value in values_by_key.items(): - current_value = entity_values[key] - if current_value is None: - entity_values[key] = value - else: - new_values_by_key[key] = current_value - - for child_id in entity_ids_by_parent_id[entity_id]: - fill_queue.append((child_id, new_values_by_key)) - - for entity_id in entity_ids: - entity_changes = {} - for key, value in values_per_entity_id[entity_id].items(): - if value is not None: - entity_changes[key] = value - - if not entity_changes: - continue - - interesting_data[entity_id] = entity_changes - object_id = object_id_by_entity_id[entity_id] - if object_id not in changed_keys_by_object_id: - changed_keys_by_object_id[object_id] = set() - changed_keys_by_object_id[object_id] |= set(entity_changes.keys()) - - def get_current_values( - self, - session, - attr_ids, - entity_ids, - task_entity_ids, - hier_attrs - ): - current_values_by_id = {} - if not attr_ids or not entity_ids: - return current_values_by_id - - for entity_id in entity_ids: - current_values_by_id[entity_id] = {} - for attr_id in attr_ids: - current_values_by_id[entity_id][attr_id] = ( - ftrack_api.symbol.NOT_SET - ) - - values = query_custom_attributes( - session, attr_ids, entity_ids, True - ) - - for item in values: + real_values_by_entity_id = collections.defaultdict(dict) + for item in value_items: entity_id = item["entity_id"] attr_id = item["configuration_id"] - if entity_id in task_entity_ids and attr_id in hier_attrs: - continue + real_values_by_entity_id[entity_id][attr_id] = item["value"] - if entity_id not in current_values_by_id: - current_values_by_id[entity_id] = {} - current_values_by_id[entity_id][attr_id] = item["value"] - return current_values_by_id + hier_values_by_entity_id = {} + default_values = { + attr["id"]: attr["default"] + for attr in hier_attrs + } + hier_queue = collections.deque() + hier_queue.append((default_values, [project_id])) + while hier_queue: + parent_values, entity_ids = hier_queue.popleft() + for entity_id in entity_ids: + entity_values = copy.deepcopy(parent_values) + real_values = real_values_by_entity_id[entity_id] + for attr_id, value in real_values.items(): + entity_values[attr_id] = value + hier_values_by_entity_id[entity_id] = entity_values + hier_queue.append( + (entity_values, entity_ids_by_parent_id[entity_id]) + ) - def get_entities(self, session, interesting_data, interest_object_ids): - return session.query(( - "select id from TypedContext" - " where id in ({}) and object_type_id in ({})" - ).format( - self.join_query_keys(interesting_data.keys()), - self.join_query_keys(interest_object_ids) - )).all() - - def get_task_entities(self, session, interesting_data): - return session.query( - "select id, parent_id from Task where parent_id in ({})".format( - self.join_query_keys(interesting_data.keys()) - ) - ).all() - - def attrs_configurations(self, session, object_ids, interest_attributes): - attrs = session.query(self.cust_attrs_query.format( - self.join_query_keys(interest_attributes), - self.join_query_keys(object_ids) - )).all() - - output = {} - hiearchical = {} - for attr in attrs: - if attr["is_hierarchical"]: - hiearchical[attr["key"]] = attr["id"] - continue - obj_id = attr["object_type_id"] - if obj_id not in output: - output[obj_id] = {} - output[obj_id][attr["key"]] = attr["id"] - return output, hiearchical - - def get_hierarchical_configurations(self, session, interest_attributes): - hier_attr_query = ( - "select id, key, object_type_id, is_hierarchical, default" - " from CustomAttributeConfiguration" - " where key in ({}) and is_hierarchical is true" + self.propagate_attribute_changes( + session, + interest_attributes, + entities_info, + attrs_by_obj_id, + hier_attrs, + real_values_by_entity_id, + hier_values_by_entity_id, ) - if not interest_attributes: - return [] - return list(session.query(hier_attr_query.format( - self.join_query_keys(interest_attributes), - )).all()) + + def launch(self, session, event): + filtered_entities_info = self.filter_entities_info(event) + if not filtered_entities_info: + return + + for project_id, entities_info in filtered_entities_info.items(): + self.process_by_project(session, event, project_id, entities_info) def register(session): - PushFrameValuesToTaskEvent(session).register() + PushHierValuesToNonHierEvent(session).register() diff --git a/openpype/modules/ftrack/event_handlers_server/event_radio_buttons.py b/openpype/modules/ftrack/event_handlers_server/event_radio_buttons.py index 99ad3aec37..358a8d2310 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_radio_buttons.py +++ b/openpype/modules/ftrack/event_handlers_server/event_radio_buttons.py @@ -7,7 +7,7 @@ class RadioButtons(BaseEvent): ignore_me = True def launch(self, session, event): - '''Provides a readio button behaviour to any bolean attribute in + '''Provides a radio button behaviour to any boolean attribute in radio_button group.''' # start of event procedure ---------------------------------- diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 0058a428e3..0aa0b9f9f5 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -787,7 +787,7 @@ class SyncToAvalonEvent(BaseEvent): # Filter updates where name is changing for ftrack_id, ent_info in updated.items(): ent_keys = ent_info["keys"] - # Seprate update info from rename + # Separate update info from rename if "name" not in ent_keys: continue @@ -827,7 +827,7 @@ class SyncToAvalonEvent(BaseEvent): # 5.) Process updated self.process_updated() time_6 = time.time() - # 6.) Process changes in hierarchy or hier custom attribues + # 6.) Process changes in hierarchy or hier custom attributes self.process_hier_cleanup() time_7 = time.time() self.process_task_updates() @@ -1094,7 +1094,7 @@ class SyncToAvalonEvent(BaseEvent): def check_names_synchronizable(self, names): """Check if entities with specific names are importable. - This check should happend after removing entity or renaming entity. + This check should happen after removing entity or renaming entity. When entity was removed or renamed then it's name is possible to sync. """ joined_passed_names = ", ".join( @@ -1743,7 +1743,7 @@ class SyncToAvalonEvent(BaseEvent): def process_moved(self): """ - Handles moved entities to different place in hiearchy. + Handles moved entities to different place in hierarchy. (Not tasks - handled separately.) """ if not self.ftrack_moved: @@ -1792,7 +1792,7 @@ class SyncToAvalonEvent(BaseEvent): self.log.warning("{} <{}>".format(error_msg, ent_path)) continue - # THIS MUST HAPPEND AFTER CREATING NEW ENTITIES !!!! + # THIS MUST HAPPEN AFTER CREATING NEW ENTITIES !!!! # - because may be moved to new created entity if "data" not in self.updates[mongo_id]: self.updates[mongo_id]["data"] = {} @@ -2323,7 +2323,7 @@ class SyncToAvalonEvent(BaseEvent): items.append("{} - \"{}\"".format(ent_path, value)) self.report_items["error"][fps_msg] = items - # Get dictionary with not None hierarchical values to pull to childs + # Get dictionary with not None hierarchical values to pull to children project_values = {} for key, value in ( entities_dict[ftrack_project_id]["hier_attrs"].items() @@ -2460,7 +2460,7 @@ class SyncToAvalonEvent(BaseEvent): def update_entities(self): """ Update Avalon entities by mongo bulk changes. - Expects self.updates which are transfered to $set part of update + Expects self.updates which are transferred to $set part of update command. Resets self.updates afterwards. """ diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py index a0e039926e..25fa3b0535 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py @@ -291,7 +291,7 @@ class TaskStatusToParent(BaseEvent): except Exception: session.rollback() self.log.warning( - "\"{}\" status couldnt be set to \"{}\"".format( + "\"{}\" status couldn't be set to \"{}\"".format( ent_path, new_status["name"] ), exc_info=True @@ -399,7 +399,7 @@ class TaskStatusToParent(BaseEvent): # For cases there are multiple tasks in changes # - task status which match any new status item by order in the - # list `single_match` is preffered + # list `single_match` is preferred best_order = len(single_match) best_order_status = None for task_entity in task_entities: diff --git a/openpype/modules/ftrack/event_handlers_server/event_user_assigment.py b/openpype/modules/ftrack/event_handlers_server/event_user_assigment.py index c4e48b92f0..9539a34f5e 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_user_assigment.py +++ b/openpype/modules/ftrack/event_handlers_server/event_user_assigment.py @@ -10,11 +10,11 @@ from openpype_modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY class UserAssigmentEvent(BaseEvent): """ - This script will intercept user assigment / de-assigment event and + This script will intercept user assignment / de-assignment event and run shell script, providing as much context as possible. It expects configuration file ``presets/ftrack/user_assigment_event.json``. - In it, you define paths to scripts to be run for user assigment event and + In it, you define paths to scripts to be run for user assignment event and for user-deassigment:: { "add": [ diff --git a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py index e36c3eecd9..fb40fd6417 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py +++ b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py @@ -102,7 +102,7 @@ class VersionToTaskStatus(BaseEvent): asset_version_entities.append(asset_version) task_ids.add(asset_version["task_id"]) - # Skipt if `task_ids` are empty + # Skip if `task_ids` are empty if not task_ids: return diff --git a/openpype/modules/ftrack/event_handlers_user/action_applications.py b/openpype/modules/ftrack/event_handlers_user/action_applications.py index 102f04c956..30399b463d 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_applications.py +++ b/openpype/modules/ftrack/event_handlers_user/action_applications.py @@ -124,6 +124,11 @@ class AppplicationsAction(BaseAction): if not avalon_project_apps: return False + settings = self.get_project_settings_from_event( + event, avalon_project_doc["name"]) + + only_available = settings["applications"]["only_available"] + items = [] for app_name in avalon_project_apps: app = self.application_manager.applications.get(app_name) @@ -133,6 +138,10 @@ class AppplicationsAction(BaseAction): if app.group.name in CUSTOM_LAUNCH_APP_GROUPS: continue + # Skip applications without valid executables + if only_available and not app.find_executable(): + continue + app_icon = app.icon if app_icon and self.icon_url: try: diff --git a/openpype/modules/ftrack/event_handlers_user/action_batch_task_creation.py b/openpype/modules/ftrack/event_handlers_user/action_batch_task_creation.py index c7fb1af98b..06d572601d 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_batch_task_creation.py +++ b/openpype/modules/ftrack/event_handlers_user/action_batch_task_creation.py @@ -7,7 +7,7 @@ from openpype_modules.ftrack.lib import BaseAction, statics_icon class BatchTasksAction(BaseAction): '''Batch Tasks action - `label` a descriptive string identifing your action. + `label` a descriptive string identifying your action. `varaint` To group actions together, give them the same label and specify a unique variant per action. `identifier` a unique identifier for your action. diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py index c19cfd1502..471a8c4182 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -82,9 +82,9 @@ config (dictionary) write_security_roles/read_security_roles (array of strings) - default: ["ALL"] - strings should be role names (e.g.: ["API", "Administrator"]) - - if set to ["ALL"] - all roles will be availabled + - if set to ["ALL"] - all roles will be available - if first is 'except' - roles will be set to all except roles in array - - Warning: Be carefull with except - roles can be different by company + - Warning: Be careful with except - roles can be different by company - example: write_security_roles = ["except", "User"] read_security_roles = ["ALL"] # (User is can only read) @@ -500,7 +500,7 @@ class CustomAttributes(BaseAction): data = {} # Get key, label, type data.update(self.get_required(cust_attr_data)) - # Get hierachical/ entity_type/ object_id + # Get hierarchical/ entity_type/ object_id data.update(self.get_entity_type(cust_attr_data)) # Get group, default, security roles data.update(self.get_optional(cust_attr_data)) diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_folders.py b/openpype/modules/ftrack/event_handlers_user/action_create_folders.py index 9806f83773..cbeff5343f 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_folders.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_folders.py @@ -51,7 +51,7 @@ class CreateFolders(BaseAction): }, { "type": "label", - "value": "With all chilren entities" + "value": "With all children entities" }, { "name": "children_included", diff --git a/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py b/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py index 03d029b0c1..72a5efbcfe 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delete_asset.py @@ -18,7 +18,7 @@ class DeleteAssetSubset(BaseAction): # Action label. label = "Delete Asset/Subsets" # Action description. - description = "Removes from Avalon with all childs and asset from Ftrack" + description = "Removes from Avalon with all children and asset from Ftrack" icon = statics_icon("ftrack", "action_icons", "DeleteAsset.svg") settings_key = "delete_asset_subset" diff --git a/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py index c543dc8834..ec14c6918b 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py @@ -27,7 +27,7 @@ class DeleteOldVersions(BaseAction): variant = "- Delete old versions" description = ( "Delete files from older publishes so project can be" - " archived with only lates versions." + " archived with only latest versions." ) icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") @@ -307,7 +307,7 @@ class DeleteOldVersions(BaseAction): file_path, seq_path = self.path_from_represenation(repre, anatomy) if file_path is None: self.log.warning(( - "Could not format path for represenation \"{}\"" + "Could not format path for representation \"{}\"" ).format(str(repre))) continue diff --git a/openpype/modules/ftrack/event_handlers_user/action_delivery.py b/openpype/modules/ftrack/event_handlers_user/action_delivery.py index a400c8f5f0..559de3a24d 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delivery.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delivery.py @@ -601,7 +601,7 @@ class Delivery(BaseAction): return self.report(report_items) def report(self, report_items): - """Returns dict with final status of delivery (succes, fail etc.).""" + """Returns dict with final status of delivery (success, fail etc.).""" items = [] for msg, _items in report_items.items(): diff --git a/openpype/modules/ftrack/event_handlers_user/action_fill_workfile_attr.py b/openpype/modules/ftrack/event_handlers_user/action_fill_workfile_attr.py index fb1cdf340e..36d29db96b 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_fill_workfile_attr.py +++ b/openpype/modules/ftrack/event_handlers_user/action_fill_workfile_attr.py @@ -246,7 +246,7 @@ class FillWorkfileAttributeAction(BaseAction): project_name = project_entity["full_name"] - # Find matchin asset documents and map them by ftrack task entities + # Find matching asset documents and map them by ftrack task entities # - result stored to 'asset_docs_with_task_entities' is list with # tuple `(asset document, [task entitis, ...])` # Quety all asset documents diff --git a/openpype/modules/ftrack/event_handlers_user/action_job_killer.py b/openpype/modules/ftrack/event_handlers_user/action_job_killer.py index f489c0c54c..dd68c75f84 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_job_killer.py +++ b/openpype/modules/ftrack/event_handlers_user/action_job_killer.py @@ -54,14 +54,14 @@ class JobKiller(BaseAction): for job in jobs: try: data = json.loads(job["data"]) - desctiption = data["description"] + description = data["description"] except Exception: - desctiption = "*No description*" + description = "*No description*" user_id = job["user_id"] username = usernames_by_id.get(user_id) or "Unknown user" created = job["created_at"].strftime('%d.%m.%Y %H:%M:%S') label = "{} - {} - {}".format( - username, desctiption, created + username, description, created ) item_label = { "type": "label", diff --git a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py index e825198180..19d5701e08 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py +++ b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py @@ -24,7 +24,7 @@ class PrepareProjectLocal(BaseAction): settings_key = "prepare_project" - # Key to store info about trigerring create folder structure + # Key to store info about triggering create folder structure create_project_structure_key = "create_folder_structure" create_project_structure_identifier = "create.project.structure" item_splitter = {"type": "label", "value": "---"} @@ -146,7 +146,7 @@ class PrepareProjectLocal(BaseAction): root_items.append({ "type": "label", "value": ( - "

NOTE: Roots are crutial for path filling" + "

NOTE: Roots are crucial for path filling" " (and creating folder structure).

" ) }) diff --git a/openpype/modules/ftrack/event_handlers_user/action_rv.py b/openpype/modules/ftrack/event_handlers_user/action_rv.py index d05f0c47f6..39cf33d605 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_rv.py +++ b/openpype/modules/ftrack/event_handlers_user/action_rv.py @@ -66,7 +66,7 @@ class RVAction(BaseAction): def get_components_from_entity(self, session, entity, components): """Get components from various entity types. - The components dictionary is modifid in place, so nothing is returned. + The components dictionary is modified in place, so nothing is returned. Args: entity (Ftrack entity) diff --git a/openpype/modules/ftrack/event_handlers_user/action_seed.py b/openpype/modules/ftrack/event_handlers_user/action_seed.py index 4021d70c0a..657cd07a9f 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_seed.py +++ b/openpype/modules/ftrack/event_handlers_user/action_seed.py @@ -325,8 +325,8 @@ class SeedDebugProject(BaseAction): ): index = 0 - self.log.debug("*** Commiting Assets") - self.log.debug("Commiting entities. {}/{}".format( + self.log.debug("*** Committing Assets") + self.log.debug("Committing entities. {}/{}".format( created_entities, to_create_length )) self.session.commit() @@ -414,8 +414,8 @@ class SeedDebugProject(BaseAction): ): index = 0 - self.log.debug("*** Commiting Shots") - self.log.debug("Commiting entities. {}/{}".format( + self.log.debug("*** Committing Shots") + self.log.debug("Committing entities. {}/{}".format( created_entities, to_create_length )) self.session.commit() @@ -423,7 +423,7 @@ class SeedDebugProject(BaseAction): def temp_commit(self, index, created_entities, to_create_length): if index < self.max_entities_created_at_one_commit: return False - self.log.debug("Commiting {} entities. {}/{}".format( + self.log.debug("Committing {} entities. {}/{}".format( index, created_entities, to_create_length )) self.session.commit() diff --git a/openpype/modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py b/openpype/modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py index 8748f426bd..c9e0901623 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py @@ -184,7 +184,7 @@ class StoreThumbnailsToAvalon(BaseAction): self.db_con.install() for entity in entities: - # Skip if entity is not AssetVersion (never should happend, but..) + # Skip if entity is not AssetVersion (should never happen, but..) if entity.entity_type.lower() != "assetversion": continue diff --git a/openpype/modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/ftrack/ftrack_server/event_server_cli.py index ad7ffd8e25..77f479ee20 100644 --- a/openpype/modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/ftrack/ftrack_server/event_server_cli.py @@ -33,7 +33,7 @@ class MongoPermissionsError(Exception): """Is used when is created multiple objects of same RestApi class.""" def __init__(self, message=None): if not message: - message = "Exiting because have issue with acces to MongoDB" + message = "Exiting because have issue with access to MongoDB" super().__init__(message) @@ -340,7 +340,7 @@ def main_loop(ftrack_url): return 1 # ====== STORER ======= - # Run backup thread which does not requeire mongo to work + # Run backup thread which does not require mongo to work if storer_thread is None: if storer_failed_count < max_fail_count: storer_thread = socket_thread.SocketThread( @@ -399,7 +399,7 @@ def main_loop(ftrack_url): elif not processor_thread.is_alive(): if processor_thread.mongo_error: raise Exception( - "Exiting because have issue with acces to MongoDB" + "Exiting because have issue with access to MongoDB" ) processor_thread.join() processor_thread = None diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 0341c25717..8b4c4619a1 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -891,7 +891,7 @@ class SyncEntitiesFactory: parent_dict = self.entities_dict.get(parent_id, {}) for child_id in parent_dict.get("children", []): - # keep original `remove` value for all childs + # keep original `remove` value for all children _remove = (remove is True) if not _remove: if self.entities_dict[child_id]["avalon_attrs"].get( @@ -1191,8 +1191,8 @@ class SyncEntitiesFactory: avalon_hier = [] for item in items: value = item["value"] - # WARNING It is not possible to propage enumerate hierachical - # attributes with multiselection 100% right. Unseting all values + # WARNING It is not possible to propagate enumerate hierarchical + # attributes with multiselection 100% right. Unsetting all values # will cause inheritance from parent. if ( value is None @@ -1231,7 +1231,7 @@ class SyncEntitiesFactory: items.append("{} - \"{}\"".format(ent_path, value)) self.report_items["error"][fps_msg] = items - # Get dictionary with not None hierarchical values to pull to childs + # Get dictionary with not None hierarchical values to pull to children top_id = self.ft_project_id project_values = {} for key, value in self.entities_dict[top_id]["hier_attrs"].items(): @@ -1749,7 +1749,7 @@ class SyncEntitiesFactory: # TODO logging ent_path = self.get_ent_path(ftrack_id) msg = ( - " It is not possible" + " It is not possible" " to change the hierarchy of an entity or it's parents," " if it already contained published data." ) @@ -2584,8 +2584,8 @@ class SyncEntitiesFactory: # # ent_dict = self.entities_dict[found_by_name_id] - # TODO report - CRITICAL entity with same name alread exists in - # different hierarchy - can't recreate entity + # TODO report - CRITICAL entity with same name already exists + # in different hierarchy - can't recreate entity continue _vis_parent = deleted_entity["data"]["visualParent"] diff --git a/openpype/modules/ftrack/lib/custom_attributes.py b/openpype/modules/ftrack/lib/custom_attributes.py index 2f53815368..3e40bb02f2 100644 --- a/openpype/modules/ftrack/lib/custom_attributes.py +++ b/openpype/modules/ftrack/lib/custom_attributes.py @@ -65,7 +65,7 @@ def get_openpype_attr(session, split_hierarchical=True, query_keys=None): cust_attrs_query = ( "select {}" " from CustomAttributeConfiguration" - # Kept `pype` for Backwards Compatiblity + # Kept `pype` for Backwards Compatibility " where group.name in (\"pype\", \"{}\")" ).format(", ".join(query_keys), CUST_ATTR_GROUP) all_avalon_attr = session.query(cust_attrs_query).all() diff --git a/openpype/modules/ftrack/lib/ftrack_action_handler.py b/openpype/modules/ftrack/lib/ftrack_action_handler.py index b24fe5f12a..07b3a780a2 100644 --- a/openpype/modules/ftrack/lib/ftrack_action_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_action_handler.py @@ -12,7 +12,7 @@ def statics_icon(*icon_statics_file_parts): class BaseAction(BaseHandler): '''Custom Action base class - `label` a descriptive string identifing your action. + `label` a descriptive string identifying your action. `varaint` To group actions together, give them the same label and specify a unique variant per action. diff --git a/openpype/modules/ftrack/lib/ftrack_base_handler.py b/openpype/modules/ftrack/lib/ftrack_base_handler.py index c0b03f8a41..55400c22ab 100644 --- a/openpype/modules/ftrack/lib/ftrack_base_handler.py +++ b/openpype/modules/ftrack/lib/ftrack_base_handler.py @@ -30,7 +30,7 @@ class PreregisterException(Exception): class BaseHandler(object): '''Custom Action base class -