diff --git a/.github/workflows/automate-projects.yml b/.github/workflows/automate-projects.yml deleted file mode 100644 index b605071c2d..0000000000 --- a/.github/workflows/automate-projects.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Automate Projects - -on: - issues: - types: [opened, labeled] -env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - -jobs: - assign_one_project: - runs-on: ubuntu-latest - name: Assign to One Project - steps: - - name: Assign NEW bugs to triage - uses: srggrs/assign-one-project-github-action@1.2.0 - if: contains(github.event.issue.labels.*.name, 'bug') - with: - project: 'https://github.com/pypeclub/pype/projects/2' - column_name: 'Needs triage' diff --git a/.github/workflows/milestone_assign.yml b/.github/workflows/milestone_assign.yml index 4b52dfc30d..3cbee51472 100644 --- a/.github/workflows/milestone_assign.yml +++ b/.github/workflows/milestone_assign.yml @@ -13,7 +13,7 @@ jobs: if: github.event.pull_request.milestone == null uses: zoispag/action-assign-milestone@v1 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" + repo-token: "${{ secrets.YNPUT_BOT_TOKEN }}" milestone: 'next-minor' run_if_develop: @@ -24,5 +24,5 @@ jobs: if: github.event.pull_request.milestone == null uses: zoispag/action-assign-milestone@v1 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - milestone: 'next-patch' \ No newline at end of file + repo-token: "${{ secrets.YNPUT_BOT_TOKEN }}" + milestone: 'next-patch' diff --git a/.github/workflows/milestone_create.yml b/.github/workflows/milestone_create.yml index b56ca81dc1..632704e64a 100644 --- a/.github/workflows/milestone_create.yml +++ b/.github/workflows/milestone_create.yml @@ -12,7 +12,7 @@ jobs: uses: "WyriHaximus/github-action-get-milestones@master" id: milestones env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" - run: printf "name=number::%s" $(printenv MILESTONES | jq --arg MILESTONE $(printenv MILESTONE) '.[] | select(.title == $MILESTONE) | .number') id: querymilestone @@ -31,7 +31,7 @@ jobs: with: title: 'next-patch' env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" generate-next-minor: runs-on: ubuntu-latest @@ -40,7 +40,7 @@ jobs: uses: "WyriHaximus/github-action-get-milestones@master" id: milestones env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" - run: printf "name=number::%s" $(printenv MILESTONES | jq --arg MILESTONE $(printenv MILESTONE) '.[] | select(.title == $MILESTONE) | .number') id: querymilestone @@ -59,4 +59,4 @@ jobs: with: title: 'next-minor' env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" \ No newline at end of file + GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" diff --git a/.github/workflows/miletone_release_trigger.yml b/.github/workflows/miletone_release_trigger.yml new file mode 100644 index 0000000000..b5b8aab1dc --- /dev/null +++ b/.github/workflows/miletone_release_trigger.yml @@ -0,0 +1,47 @@ +name: Milestone Release [trigger] + +on: + workflow_dispatch: + inputs: + milestone: + required: true + release-type: + type: choice + description: What release should be created + options: + - release + - pre-release + milestone: + types: closed + + +jobs: + milestone-title: + runs-on: ubuntu-latest + outputs: + milestone: ${{ steps.milestoneTitle.outputs.value }} + steps: + - name: Switch input milestone + uses: haya14busa/action-cond@v1 + id: milestoneTitle + with: + cond: ${{ inputs.milestone == '' }} + if_true: ${{ github.event.milestone.title }} + if_false: ${{ inputs.milestone }} + - name: Print resulted milestone + run: | + echo "${{ steps.milestoneTitle.outputs.value }}" + + call-ci-tools-milestone-release: + needs: milestone-title + uses: ynput/ci-tools/.github/workflows/milestone_release_ref.yml@main + with: + milestone: ${{ needs.milestone-title.outputs.milestone }} + repo-owner: ${{ github.event.repository.owner.login }} + repo-name: ${{ github.event.repository.name }} + version-py-path: "./openpype/version.py" + pyproject-path: "./pyproject.toml" + secrets: + token: ${{ secrets.YNPUT_BOT_TOKEN }} + user_email: ${{ secrets.CI_EMAIL }} + user_name: ${{ secrets.CI_USER }} diff --git a/.github/workflows/nightly_merge.yml b/.github/workflows/nightly_merge.yml index 1d36c89cc7..1776d7a464 100644 --- a/.github/workflows/nightly_merge.yml +++ b/.github/workflows/nightly_merge.yml @@ -14,10 +14,10 @@ jobs: - name: ๐Ÿš› Checkout Code uses: actions/checkout@v2 - - name: ๐Ÿ”จ Merge develop to main + - name: ๐Ÿ”จ Merge develop to main uses: everlytic/branch-merge@1.1.0 with: - github_token: ${{ secrets.ADMIN_TOKEN }} + github_token: ${{ secrets.YNPUT_BOT_TOKEN }} source_ref: 'develop' target_branch: 'main' commit_message_template: '[Automated] Merged {source_ref} into {target_branch}' @@ -26,4 +26,4 @@ jobs: uses: benc-uk/workflow-dispatch@v1 with: workflow: Nightly Prerelease - token: ${{ secrets.ADMIN_TOKEN }} \ No newline at end of file + token: ${{ secrets.YNPUT_BOT_TOKEN }} diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 94bbe48156..571b0339e1 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -25,43 +25,15 @@ jobs: - name: ๐Ÿ”Ž Determine next version type id: version_type run: | - TYPE=$(python ./tools/ci_tools.py --bump --github_token ${{ secrets.GITHUB_TOKEN }}) - - echo ::set-output name=type::$TYPE + TYPE=$(python ./tools/ci_tools.py --bump --github_token ${{ secrets.YNPUT_BOT_TOKEN }}) + echo "type=${TYPE}" >> $GITHUB_OUTPUT - name: ๐Ÿ’‰ Inject new version into files id: version if: steps.version_type.outputs.type != 'skip' run: | - RESULT=$(python ./tools/ci_tools.py --nightly --github_token ${{ secrets.GITHUB_TOKEN }}) - - echo ::set-output name=next_tag::$RESULT - - # - name: "โœ๏ธ Generate full changelog" - # if: steps.version_type.outputs.type != 'skip' - # id: generate-full-changelog - # uses: heinrichreimer/github-changelog-generator-action@v2.3 - # with: - # token: ${{ secrets.ADMIN_TOKEN }} - # addSections: '{"documentation":{"prefix":"### ๐Ÿ“– Documentation","labels":["type: documentation"]},"tests":{"prefix":"### โœ… Testing","labels":["tests"]},"feature":{"prefix":"**๐Ÿ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**๐Ÿ’ฅ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**๐Ÿš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**๐Ÿ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**โš ๏ธ Deprecations**", "labels":["depreciated"]}, "refactor":{"prefix":"**๐Ÿ”€ Refactored code**", "labels":["refactor"]}}' - # issues: false - # issuesWoLabels: false - # sinceTag: "3.12.0" - # maxIssues: 100 - # pullRequests: true - # prWoLabels: false - # author: false - # unreleased: true - # compareLink: true - # stripGeneratorNotice: true - # verbose: true - # unreleasedLabel: ${{ steps.version.outputs.next_tag }} - # excludeTagsRegex: "CI/.+" - # releaseBranch: "main" - - - name: "๐Ÿ–จ๏ธ Print changelog to console" - if: steps.version_type.outputs.type != 'skip' - run: cat CHANGELOG.md + NEW_VERSION_TAG=$(python ./tools/ci_tools.py --nightly --github_token ${{ secrets.YNPUT_BOT_TOKEN }}) + echo "next_tag=${NEW_VERSION_TAG}" >> $GITHUB_OUTPUT - name: ๐Ÿ’พ Commit and Tag id: git_commit @@ -80,7 +52,7 @@ jobs: - name: Push to protected main branch uses: CasperWA/push-protected@v2.10.0 with: - token: ${{ secrets.ADMIN_TOKEN }} + token: ${{ secrets.YNPUT_BOT_TOKEN }} branch: main tags: true unprotect_reviews: true @@ -89,7 +61,7 @@ jobs: uses: everlytic/branch-merge@1.1.0 if: steps.version_type.outputs.type != 'skip' with: - github_token: ${{ secrets.ADMIN_TOKEN }} + github_token: ${{ secrets.YNPUT_BOT_TOKEN }} source_ref: 'main' target_branch: 'develop' commit_message_template: '[Automated] Merged {source_ref} into {target_branch}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7e3b6eb05c..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,124 +0,0 @@ -name: Stable Release - -on: - release: - types: - - prereleased - -jobs: - create_release: - runs-on: ubuntu-latest - if: github.actor != 'pypebot' - - steps: - - name: ๐Ÿš› Checkout Code - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - name: Install Python requirements - run: pip install gitpython semver PyGithub - - - name: ๐Ÿ’‰ Inject new version into files - id: version - run: | - echo ::set-output name=current_version::${GITHUB_REF#refs/*/} - RESULT=$(python ./tools/ci_tools.py --finalize ${GITHUB_REF#refs/*/}) - LASTRELEASE=$(python ./tools/ci_tools.py --lastversion release) - - echo ::set-output name=last_release::$LASTRELEASE - echo ::set-output name=release_tag::$RESULT - - # - name: "โœ๏ธ Generate full changelog" - # if: steps.version.outputs.release_tag != 'skip' - # id: generate-full-changelog - # uses: heinrichreimer/github-changelog-generator-action@v2.3 - # with: - # token: ${{ secrets.ADMIN_TOKEN }} - # addSections: '{"documentation":{"prefix":"### ๐Ÿ“– Documentation","labels":["type: documentation"]},"tests":{"prefix":"### โœ… Testing","labels":["tests"]},"feature":{"prefix":"**๐Ÿ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**๐Ÿ’ฅ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**๐Ÿš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**๐Ÿ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**โš ๏ธ Deprecations**", "labels":["depreciated"]}, "refactor":{"prefix":"**๐Ÿ”€ Refactored code**", "labels":["refactor"]}}' - # issues: false - # issuesWoLabels: false - # sinceTag: "3.12.0" - # maxIssues: 100 - # pullRequests: true - # prWoLabels: false - # author: false - # unreleased: true - # compareLink: true - # stripGeneratorNotice: true - # verbose: true - # futureRelease: ${{ steps.version.outputs.release_tag }} - # excludeTagsRegex: "CI/.+" - # releaseBranch: "main" - - - name: ๐Ÿ’พ Commit and Tag - id: git_commit - if: steps.version.outputs.release_tag != 'skip' - run: | - git config user.email ${{ secrets.CI_EMAIL }} - git config user.name ${{ secrets.CI_USER }} - git add . - git commit -m "[Automated] Release" - tag_name="${{ steps.version.outputs.release_tag }}" - git tag -a $tag_name -m "stable release" - - - name: ๐Ÿ” Push to protected main branch - if: steps.version.outputs.release_tag != 'skip' - uses: CasperWA/push-protected@v2.10.0 - with: - token: ${{ secrets.ADMIN_TOKEN }} - branch: main - tags: true - unprotect_reviews: true - - - name: "โœ๏ธ Generate last changelog" - if: steps.version.outputs.release_tag != 'skip' - id: generate-last-changelog - uses: heinrichreimer/github-changelog-generator-action@v2.2 - with: - token: ${{ secrets.ADMIN_TOKEN }} - addSections: '{"documentation":{"prefix":"### ๐Ÿ“– Documentation","labels":["type: documentation"]},"tests":{"prefix":"### โœ… Testing","labels":["tests"]},"feature":{"prefix":"**๐Ÿ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**๐Ÿ’ฅ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**๐Ÿš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**๐Ÿ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**โš ๏ธ Deprecations**", "labels":["depreciated"]}, "refactor":{"prefix":"**๐Ÿ”€ Refactored code**", "labels":["refactor"]}}' - issues: false - issuesWoLabels: false - sinceTag: ${{ steps.version.outputs.last_release }} - maxIssues: 100 - pullRequests: true - prWoLabels: false - author: false - unreleased: true - compareLink: true - stripGeneratorNotice: true - verbose: true - futureRelease: ${{ steps.version.outputs.release_tag }} - excludeTagsRegex: "CI/.+" - releaseBranch: "main" - stripHeaders: true - base: 'none' - - - - name: ๐Ÿš€ Github Release - if: steps.version.outputs.release_tag != 'skip' - uses: ncipollo/release-action@v1 - with: - body: ${{ steps.generate-last-changelog.outputs.changelog }} - tag: ${{ steps.version.outputs.release_tag }} - token: ${{ secrets.ADMIN_TOKEN }} - - - name: โ˜  Delete Pre-release - if: steps.version.outputs.release_tag != 'skip' - uses: cb80/delrel@latest - with: - tag: "${{ steps.version.outputs.current_version }}" - - - name: ๐Ÿ” Merge main back to develop - if: steps.version.outputs.release_tag != 'skip' - uses: everlytic/branch-merge@1.1.0 - with: - github_token: ${{ secrets.ADMIN_TOKEN }} - source_ref: 'main' - target_branch: 'develop' - commit_message_template: '[Automated] Merged release {source_ref} into {target_branch}' diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml index 0e6c242bd6..064a4d47e0 100644 --- a/.github/workflows/test_build.yml +++ b/.github/workflows/test_build.yml @@ -28,7 +28,7 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - + - name: ๐Ÿงต Install Requirements shell: pwsh run: | @@ -64,27 +64,3 @@ jobs: run: | export SKIP_THIRD_PARTY_VALIDATION="1" ./tools/build.sh - - # MacOS-latest: - - # runs-on: macos-latest - # strategy: - # matrix: - # python-version: [3.9] - - # steps: - # - name: ๐Ÿš› Checkout Code - # uses: actions/checkout@v2 - - # - name: Set up Python - # uses: actions/setup-python@v2 - # with: - # python-version: ${{ matrix.python-version }} - - # - name: ๐Ÿงต Install Requirements - # run: | - # ./tools/create_env.sh - - # - name: ๐Ÿ”จ Build - # run: | - # ./tools/build.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bbd5d145fe..eec388924e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,4 +9,4 @@ repos: - id: check-yaml - id: check-added-large-files - id: no-commit-to-branch - args: [ '--pattern', '^(?!((enhancement|feature|bugfix|documentation|tests|local|chore)\/[a-zA-Z0-9\-]+)$).*' ] + args: [ '--pattern', '^(?!((release|enhancement|feature|bugfix|documentation|tests|local|chore)\/[a-zA-Z0-9\-_]+)$).*' ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da167763b..c7ecbc83bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,1187 @@ # Changelog -## [3.15.0](https://github.com/ynput/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/ynput/OpenPype/compare/3.14.10...HEAD) +## [3.15.1](https://github.com/ynput/OpenPype/tree/3.15.1) + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.0...3.15.1) + +### **๐Ÿ†• New features** + + + + +
+Maya: Xgen (3d / maya ) - #4256 + +___ + +#### Brief description + +Initial Xgen implementation. + + + +#### Description + +Client request of Xgen pipeline. + + + + +___ + +
+ + + +
+Data exchange cameras for 3d Studio Max (3d / 3dsmax ) - #4376 + +___ + +#### Brief description + +Add Camera Family into the 3d Studio Max + + + +#### Description + +Adding Camera Extractors(extract abc camera and extract fbx camera) and validators(for camera contents) into 3dMaxAlso add the extractor for exporting 3d max raw scene (which is also related to 3dMax Scene Family) for camera family + + + + +___ + +
+ + +### **๐Ÿš€ Enhancements** + + + + +
+Adding path validator for non-maya nodes (3d / maya ) - #4271 + +___ + +#### Brief description + +Adding a path validator for filepaths from non-maya nodes, which are created by plugins such as Renderman, Yeti and abcImport. + + + +#### Description + +As File Path Editor cannot catch the wrong filenpaths from non-maya nodes such as AlembicNodes, It is neccessary to have a new validator to ensure the existence of the filepaths from the nodes. + + + + +___ + +
+ + + +
+Deadline: Allow disabling strict error check in Maya submissions (3d / maya / deadline ) - #4420 + +___ + +#### Brief description + +DL by default has Strict error checking, but some errors are not fatal. + + + +#### Description + +This allows to set profile based on Task and Subset values to temporarily disable Strict Error Checks.Subset and task names should support regular expressions. (not wildcard notation though). + + + + +___ + +
+ + + +
+Houdini: New publisher code tweak (3d / houdini ) - #4374 + +___ + +#### Brief description + +This is cosmetics only - the previous code to me felt quite unreadable due to the lengthy strings being used. + + + +#### Description + +Code should do roughly the same, but just be reformatted. + + + + +___ + +
+ + + +
+3dsmax: enhance alembic loader update function (3d / 3dsmax ) - #4387 + +___ + +## Enhancement + + + +This PR is adding update/switch ability to pointcache/alembic loader in 3dsmax and fixing wrong tool shown when clicking on "Manage" item on OpenPype menu, that is now correctly Scene Inventory (but was Subset Manager). + + + +Alembic update has still one caveat - it doesn't cope with changed number of object inside alembic, since loading alembic in max involves creating all those objects as first class nodes. So it will keep the objects in scene, just update path to alembic file on them. +___ + +
+ + + +
+Global: supporting `OPENPYPE_TMPDIR` in staging dir maker (editorial / hiero ) - #4398 + +___ + +#### Brief description + +Productions can use OPENPYPE_TMPDIR for staging temp publishing directory + + + +#### Description + +Studios were demanding to be able to configure their own shared storages as temporary staging directories. Template formatting is also supported with optional keys formatting and following anatomy keys: - root[work | ] - project[name | code] + + + + +___ + +
+ + + +
+General: Functions for current context (other ) - #4324 + +___ + +#### Brief description + +Defined more functions to receive current context information and added the methods to host integration so host can affect the result. + + + +#### Description + +This is one of steps to reduce usage of `legacy_io.Session`. This change define how to receive current context information -> call functions instead of accessing `legacy_io.Session` or `os.environ` directly. Plus, direct access on session or environments is unfortunatelly not enough for some DCCs where multiple workfiles can be opened at one time which can heavily affect the context but host integration sometimes can't affect that at all.`HostBase` already had implemented `get_current_context`, that was enhanced by adding more specific methods `get_current_project_name`, `get_current_asset_name` and `get_current_task_name`. The same functions were added to `~/openpype/pipeline/cotext_tools.py`. The functions in context tools are calling host integration methods (if are available) otherwise are using environent variables as default implementation does. Also was added `get_current_host_name` to receive host name from registered host if is available or from environment variable. + + + + +___ + +
+ + + +
+Houdini: Do not visualize the hidden OpenPypeContext node (other / houdini ) - #4382 + +___ + +#### Brief description + +Using the new publisher UI would generate a visible 'null' locator at the origin. It's confusing to the user since it's supposed to be 'hidden'. + + + +#### Description + +Before this PR the user would see a locator/null at the origin which was the 'hidden' `/obj/OpenPypeContext` node. This null would suddenly appear if the user would've ever opened the Publisher UI once.After this PR it will not show:Nice and tidy. + + + + +___ + +
+ + + +
+Maya + Blender: Pyblish plugins removed unused `version` and `category` attributes (other ) - #4402 + +___ + +#### Brief description + +Once upon a time in a land far far away there lived a few plug-ins who felt like they didn't belong in generic boxes and felt they needed to be versioned well above others. They tried, but with no success. + + + +#### Description + +Even though they now lived in a universe with elaborate `version` and `category` attributes embedded into their tiny little plug-in DNA this particular deviation has been greatly unused. There is nothing special about the version, nothing special about the category.It does nothing. + + + + +___ + +
+ + + +
+General: Fix original basename frame issues (other ) - #4452 + +___ + +#### Brief description + +Treat `{originalBasename}` in different way then standard files processing. In case template should use `{originalBasename}` the transfers will use them as they are without any changes or handling of frames. + + + +#### Description + +Frames handling is problematic with original basename because their padding can't be defined to match padding in source filenames. Also it limits the usage of functionality to "must have frame at end of fiename". This is proposal how that could be solved by simply ignoring frame handling and using filenames as are on representation. First frame is still stored to representation context but is not used in formatting part. This way we don't have to care about padding of frames at all. + + + + +___ + +
+ + + +
+Publisher: Report also crashed creators and convertors (other ) - #4473 + +___ + +#### Brief description + +Added crashes of creators and convertos discovery (lazy solution). + + + +#### Description + +Report in Publisher also contains information about crashed files caused during creator plugin discovery and convertor plugin discovery. They're not separated into categroies and there is no other information in the report about them, but this helps a lot during development. This change does not need to change format/schema of the report nor UI logic. + + + + +___ + +
+ + +### **๐Ÿ› Bug fixes** + + + + +
+Maya: Fix Validate Attributes plugin (3d / maya ) - #4401 + +___ + +#### Brief description + +Code was broken. So either plug-in was unused or it had gone unnoticed. + + + +#### Description + +Looking at the commit history of the plug-in itself it seems this might have been broken somewhere between two to three years. I think it's broken since two years since this commit.Should this plug-in be removed completely?@tokejepsen Is there still a use case where we should have this plug-in? (You created the original one) + + + + +___ + +
+ + + +
+Maya: Ignore workfile lock in Untitled scene (3d / maya ) - #4414 + +___ + +#### Brief description + +Skip workfile lock check if current scene is 'Untitled'. + + + + +___ + +
+ + + +
+Maya: fps rounding - OP-2549 (3d / maya ) - #4424 + +___ + +#### Brief description + +When FPS is registered in for example Ftrack and round either down or up (floor/ceil), comparing to Maya FPS can fail. Example:23.97 (Ftrack/Mongo) != 23.976023976023978 (Maya) + + + +#### Description + +Since Maya only has a select number of supported framerates, I've taken the approach of converting any fps to supported framerates in Maya. We validate the input fps to make sure they are supported in Maya in two ways:Whole Numbers - are validated straight against the supported framerates in Maya.Demical Numbers - we find the closest supported framerate in Maya. If the difference to the closest supported framerate, is more than 0.5 we'll throw an error.If Maya ever supports arbitrary framerates, then we might have a problem but I'm not holding my breath... + + + + +___ + +
+ + + +
+Strict Error Checking Default (3d / maya ) - #4457 + +___ + +#### Brief description + +Provide default of strict error checking for instances created prior to PR. + + + + +___ + +
+ + + +
+Create: Enhance instance & context changes (3d / houdini,after effects,3dsmax ) - #4375 + +___ + +#### Brief description + +Changes of instances and context have complex, hard to get structure. The structure did not change but instead of complex dictionaries are used objected data. + + + +#### Description + +This is poposal of changes data improvement for creators. Implemented `TrackChangesItem` which handles the changes for us. The item is creating changes based on old and new value and can provide information about changed keys or access to full old or new value. Can give the values on any "sub-dictionary".Used this new approach to fix change in houdini and 3ds max and also modified one aftereffects plugin using changes. + + + + +___ + +
+ + + +
+Houdini: hotfix condition (3d / houdini ) - #4391 + +___ + +## Hotfix + + + +This is fixing bug introduced int #4374 +___ + +
+ + + +
+Houdini: Houdini shelf tools fixes (3d / houdini ) - #4428 + +___ + +#### Brief description + +Fix Houdini shelf tools. + + + +#### Description + +Use `label` as mandatory key instead of `name`. Changed how shelves are created. If the script is empty it is gracefully skipping it instead of crashing. + + + + +___ + +
+ + + +
+3dsmax: startup fixes (3d / 3dsmax ) - #4412 + +___ + +#### Brief description + +This is fixing various issues that can occur on some of the 3dsmax versions. + + + +#### Description + +On displays with +4K resolution UI was broken, some 3dsmax versions couldn't process `PYTHONPATH` correctly. This PR is forcing `sys.path` and disabling `QT_AUTO_SCREEN_SCALE_FACTOR` + + + + +___ + +
+ + + +
+Fix features for gizmo menu (2d / nuke ) - #4280 + +___ + +#### Brief description + +Fix features for the Gizmo Menu project settings (shortcut for python type of usage and file type of usage functionality) + + + + +___ + +
+ + + +
+Photoshop: fix missing legacy io for legacy instances (2d / photoshop,after effects ) - #4467 + +___ + +#### Brief description + +`legacy_io` import was removed, but usage stayed. + + + +#### Description + +Usage of `legacy_io` should be eradicated, in creators it should be replaced by `self.create_context.get_current_project_name/asset_name/task_name`. + + + + +___ + +
+ + + +
+Fix - addSite loader handles hero version (other / sitesync ) - #4359 + +___ + +#### Brief description + +If adding site to representation presence of hero version is checked, if found hero version is marked to be donwloaded too.Replacing https://github.com/ynput/OpenPype/pull/4191 + + + + +___ + +
+ + + +
+Remove OIIO build for macos (other ) - #4381 + +___ + +## Fix + + + +Since we are not able to provide OpenImageIO tools binaries for macos, we should remove the item from th `pyproject.toml`. This PR is taking care of it. + + + +It is also changing the way `fetch_thirdparty_libs` script works in that it doesn't crash when lib cannot be processed, it only issue warning. + + + + + +Resolves #3858 +___ + +
+ + + +
+General: Attribute definitions fixes (other ) - #4392 + +___ + +#### Brief description + +Fix possible issues with attribute definitions in publisher if there is unknown attribute on an instance. + + + +#### Description + +Source of the issue is that attribute definitions from creator plugin could be "expanded" during `CreatedInstance` initialization. Which would affect all other instances using the same list of attributes -> literally object of list. If the same list object is used in "BaseClass" for other creators it would affect all instances (because of 1 instance). There had to be implemented other changes to fix the issue and keep behavior the same.Object of `CreatedInstance` can be created without reference to creator object. `CreatedInstance` is responsible to give UI attribute definitions (technically is prepared for cases when each instance may have different attribute definitions -> not yet).Attribute definition has added more conditions for `__eq__` method and have implemented `__ne__` method (which is required for Py 2 compatibility). Renamed `AbtractAttrDef` to `AbstractAttrDef` (fix typo). + + + + +___ + +
+ + + +
+Ftrack: Don't force ftrackapp endpoint (other / ftrack ) - #4411 + +___ + +#### Brief description + +Auto-fill of ftrack url don't break custom urls. Custom urls couldn't be used as `ftrackapp.com` is added if is not in the url. + + + +#### Description + +The code was changed in a way that auto-fill is still supported but before `ftrackapp` is added it will try to use url as is. If the connection works as is it is used. + + + + +___ + +
+ + + +
+Fix: DL on MacOS (other ) - #4418 + +___ + +#### Brief description + +This works if DL Openpype plugin Installation Directories is set to level of app bundle (eg. '/Applications/OpenPype 3.15.0.app') + + + + +___ + +
+ + + +
+Photoshop: make usage of layer name in subset name more controllable (other ) - #4432 + +___ + +#### Brief description + +Layer name was previously used in subset name only if multiple instances were being created in single step. This adds explicit toggle. + + + +#### Description + +Toggling this button allows to use layer name in created subset name even if single instance is being created.This follows more closely implementation if AE. + + + + +___ + +
+ + + +
+SiteSync: fix dirmap (other ) - #4436 + +___ + +#### Brief description + +Fixed issue in dirmap in Maya and Nuke + + + +#### Description + +Loads of error were thrown in Nuke console about dictionary value.`AttributeError: 'dict' object has no attribute 'lower'` + + + + +___ + +
+ + + +
+General: Ignore decode error of stdout/stderr in run_subprocess (other ) - #4446 + +___ + +#### Brief description + +Ignore decode errors and replace invalid character (byte) with escaped byte character. + + + +#### Description + +Calling of `run_subprocess` may cause crashes if output contains some unicode character which (for example Polish name of encoder handler). + + + + +___ + +
+ + + +
+Publisher: Fix reopen bug (other ) - #4463 + +___ + +#### Brief description + +Use right name of constant 'ActiveWindow' -> 'WindowActive'. + + + + +___ + +
+ + + +
+Publisher: Fix compatibility of QAction in Publisher (other ) - #4474 + +___ + +#### Brief description + +Fix `QAction` for older version of Qt bindings where QAction requires a parent on initialization. + + + +#### Description + +This bug was discovered in Nuke 11. Fixed by creating QAction when QMenu is already available and can be used as parent. + + + + +___ + +
+ + +### **๐Ÿ”€ Refactored code** + + + + +
+General: Remove 'openpype.api' (other ) - #4413 + +___ + +#### Brief description + +PR is removing `openpype/api.py` file which is causing a lot of troubles and cross-imports. + + + +#### Description + +I wanted to remove the file slowly function by function but it always reappear somewhere in codebase even if most of the functionality imported from there is triggering deprecation warnings. This is small change which may have huge impact.There shouldn't be anything in openpype codebase which is using `openpype.api` anymore so only possible issues are in customized repositories or custom addons. + + + + +___ + +
+ + +### **๐Ÿ“ƒ Documentation** + + + + +
+docs-user-Getting Started adjustments (other ) - #4365 + +___ + +#### Brief description + +Small typo fixes here and there, additional info on install/ running OP. + + + + +___ + +
+ + +### **Merged pull requests** + + + + +
+Renderman support for sample and display filters (3d / maya ) - #4003 + +___ + +#### Brief description + +User can set up both sample and display filters in Openpype settings if they are using Renderman as renderer. + + + +#### Description + +You can preset which sample and display filters for renderman , including the cryptomatte renderpass, in Openpype settings. Once you select which filters to be included in openpype settings and then create render instance for your camera in maya, it would automatically tell the system to generate your selected filters in render settings.The place you can find for setting up the filters: _Maya > Render Settings > Renderman Renderer > Display Filters/ Sample Filters_ + + + + +___ + +
+ + + +
+Maya: Create Arnold options on repair. (3d / maya ) - #4448 + +___ + +#### Brief description + +When validating/repairing we previously required users to open render settings to create the Arnold options. This is done through code now. + + + + +___ + +
+ + + +
+Update Asset field of creator Instances in Maya Template Builder (3d / maya ) - #4470 + +___ + +#### Brief description + +When we build a template with Maya Template Builder, it will update the asset field of the sets (creator instances) that are imported from the template. + + + +#### Description + +When building a template, we also want to define the publishable content in advance: create an instance of a model, or look, etc., to speed up the workflow and reduce the number of questions we are asked. After building a work file from a saved template that contains pre-created instances, the template builder should update the asset field to the current asset. + + + + +___ + +
+ + + +
+Blender: fix import workfile all families (3d / blender ) - #4405 + +___ + +#### Brief description + +Having this feature related to workfile available for any family is absurd. + + + + +___ + +
+ + + +
+Nuke: update rendered frames in latest version (2d / nuke ) - #4362 + +___ + +#### Brief description + +Introduced new field to insert frame(s) to rerender only. + + + +#### Description + +Rendering is expensive, sometimes it is helpful only to re-render changed frames and reuse existing.Artists can in Publisher fill which frame(s) should be re-rendered.If there is already published version of currently publishing subset, all representation files are collected (currently for `render` family only) and then when Nuke is rendering (locally only for now), old published files are copied into into temporary render folder where will be rewritten only by frames explicitly set in new field.That way review/burnin process could also reuse old files and recreate reviews/burnins.New version is produced during this process! + + + + +___ + +
+ + + +
+Feature: Keep synced hero representations up-to-date. (other ) - #4343 + +___ + +#### Brief description + +Keep previously synchronized sites up-to-date by comparing old and new sites and adding old sites if missing in new ones.Fix #4331 + + + + +___ + +
+ + + +
+Maya: Fix template builder bug where assets are not put in the right hierarchy (other ) - #4367 + +___ + +#### Brief description + +When buiding scene from template, the assets loaded from the placeholders are not put in the hierarchy. Plus, the assets are loaded in double. + + + + +___ + +
+ + + +
+Bump ua-parser-js from 0.7.31 to 0.7.33 in /website (other ) - #4371 + +___ + +Bumps [ua-parser-js](https://github.com/faisalman/ua-parser-js) from 0.7.31 to 0.7.33. +
+Changelog +

Sourced from ua-parser-js's changelog.

+
+

Version 0.7.31 / 1.0.2

+
    +
  • Fix OPPO Reno A5 incorrect detection
  • +
  • Fix TypeError Bug
  • +
  • Use AST to extract regexes and verify them with safe-regex
  • +
+

Version 0.7.32 / 1.0.32

+
    +
  • Add new browser : DuckDuckGo, Huawei Browser, LinkedIn
  • +
  • Add new OS : HarmonyOS
  • +
  • Add some Huawei models
  • +
  • Add Sharp Aquos TV
  • +
  • Improve detection Xiaomi Mi CC9
  • +
  • Fix Sony Xperia 1 III misidentified as Acer tablet
  • +
  • Fix Detect Sony BRAVIA as SmartTV
  • +
  • Fix Detect Xiaomi Mi TV as SmartTV
  • +
  • Fix Detect Galaxy Tab S8 as tablet
  • +
  • Fix WeGame mistakenly identified as WeChat
  • +
  • Fix included commas in Safari / Mobile Safari version
  • +
  • Increase UA_MAX_LENGTH to 350
  • +
+

Version 0.7.33 / 1.0.33

+
    +
  • Add new browser : Cobalt
  • +
  • Identify Macintosh as an Apple device
  • +
  • Fix ReDoS vulnerability
  • +
+

Version 0.8

+

Version 0.8 was created by accident. This version is now deprecated and no longer maintained, please update to version 0.7 / 1.0.

+
+
+
+Commits + +
+
+ + +[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ua-parser-js&package-manager=npm_and_yarn&previous-version=0.7.31&new-version=0.7.33)](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). + +
+___ + +
+ + + +
+Docs: Question about renaming in Kitsu (other ) - #4384 + +___ + +#### Brief description + +To keep memory of this discussion: https://discord.com/channels/517362899170230292/563751989075378201/1068112668491255818 + + + + +___ + +
+ + + +
+New Publisher: Fix Creator error typo (other ) - #4396 + +___ + +#### Brief description + +Fixes typo in error message. + + + + +___ + +
+ + + +
+Chore: pyproject.toml version because of Poetry (other ) - #4408 + +___ + +#### Brief description + +Automatization injects wrong format + + + + +___ + +
+ + + +
+Fix - remove minor part in toml (other ) - #4437 + +___ + +#### Brief description + +Causes issue in create_env and new Poetry + + + + +___ + +
+ + + +
+General: Add project code to anatomy (other ) - #4445 + +___ + +#### Brief description + +Added attribute `project_code` to `Anatomy` object. + + + +#### Description + +Anatomy already have access to almost all attributes from project anatomy except project code. This PR changing it. Technically `Anatomy` is everything what would be needed to get fill data of project. + +``` + +{ + + "project": { + + "name": anatomy.project_name, + + "code": anatomy.project_code + + } + +} + +``` + + +___ + +
+ + + +
+Maya: Arnold Scene Source overhaul - OP-4865 (other / maya ) - #4449 + +___ + +#### Brief description + +General overhaul of the Arnold Scene Source (ASS) workflow. + + + +#### Description + +This originally was to support static files (non-sequencial) ASS publishing, but digging deeper whole workflow needed an update to get ready for further issues. During this overhaul the following changes were made: + +- Generalized Arnold Standin workflow to a single loader. + +- Support multiple nodes as proxies. + +- Support proxies for `pointcache` family. + +- Generalized approach to proxies as resources, so they can be the same file format as the original.This workflow should allow further expansion to utilize operators and eventually USD. + + + + +___ + +
+ + + + +## [3.15.0](https://github.com/ynput/OpenPype/tree/3.15.0) + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.14.10...3.15.0) **Deprecated:** diff --git a/README.md b/README.md index 485ae7f4ee..514ffb62c0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ OpenPype [![documentation](https://github.com/pypeclub/pype/actions/workflows/documentation.yml/badge.svg)](https://github.com/pypeclub/pype/actions/workflows/documentation.yml) ![GitHub VFX Platform](https://img.shields.io/badge/vfx%20platform-2022-lightgrey?labelColor=303846) -this Introduction ------------ diff --git a/openpype/api.py b/openpype/api.py deleted file mode 100644 index b60cd21d2b..0000000000 --- a/openpype/api.py +++ /dev/null @@ -1,112 +0,0 @@ -from .settings import ( - get_system_settings, - get_project_settings, - get_current_project_settings, - get_anatomy_settings, - - SystemSettings, - ProjectSettings -) -from .lib import ( - PypeLogger, - Logger, - Anatomy, - execute, - run_subprocess, - version_up, - get_asset, - get_workdir_data, - get_version_from_path, - get_last_version_from_path, - get_app_environments_for_context, - source_hash, - get_latest_version, - get_local_site_id, - change_openpype_mongo_url, - create_project_folders, - get_project_basic_paths -) - -from .lib.mongo import ( - get_default_components -) - -from .lib.applications import ( - ApplicationManager -) - -from .lib.avalon_context import ( - BuildWorkfile -) - -from . import resources - -from .plugin import ( - Extractor, - - ValidatePipelineOrder, - ValidateContentsOrder, - ValidateSceneOrder, - ValidateMeshOrder, -) - -# temporary fix, might -from .action import ( - get_errored_instances_from_context, - RepairAction, - RepairContextAction -) - - -__all__ = [ - "get_system_settings", - "get_project_settings", - "get_current_project_settings", - "get_anatomy_settings", - "get_project_basic_paths", - - "SystemSettings", - "ProjectSettings", - - "PypeLogger", - "Logger", - "Anatomy", - "execute", - "get_default_components", - "ApplicationManager", - "BuildWorkfile", - - # Resources - "resources", - - # plugin classes - "Extractor", - # ordering - "ValidatePipelineOrder", - "ValidateContentsOrder", - "ValidateSceneOrder", - "ValidateMeshOrder", - # action - "get_errored_instances_from_context", - "RepairAction", - "RepairContextAction", - - # get contextual data - "version_up", - "get_asset", - "get_workdir_data", - "get_version_from_path", - "get_last_version_from_path", - "get_app_environments_for_context", - "source_hash", - - "run_subprocess", - "get_latest_version", - - "get_local_site_id", - "change_openpype_mongo_url", - - "get_project_basic_paths", - "create_project_folders" - -] diff --git a/openpype/client/entity_links.py b/openpype/client/entity_links.py index e42ac58aff..b74b4ce7f6 100644 --- a/openpype/client/entity_links.py +++ b/openpype/client/entity_links.py @@ -164,7 +164,6 @@ def get_linked_representation_id( # Recursive graph lookup for inputs {"$graphLookup": graph_lookup} ] - conn = get_project_connection(project_name) result = conn.aggregate(query_pipeline) referenced_version_ids = _process_referenced_pipeline_result( @@ -213,7 +212,7 @@ def _process_referenced_pipeline_result(result, link_type): for output in sorted(outputs_recursive, key=lambda o: o["depth"]): output_links = output.get("data", {}).get("inputLinks") - if not output_links: + if not output_links and output["type"] != "hero_version": continue # Leaf @@ -232,6 +231,9 @@ def _process_referenced_pipeline_result(result, link_type): def _filter_input_links(input_links, link_type, correctly_linked_ids): + if not input_links: # to handle hero versions + return + for input_link in input_links: if link_type and input_link["type"] != link_type: continue diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 49fb54d263..054052a908 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -1,4 +1,5 @@ import os + from openpype.lib import PreLaunchHook @@ -41,5 +42,13 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): self.log.info("Current context does not have any workfile yet.") return + # Determine whether to open workfile post initialization. + if self.host_name == "maya": + key = "open_workfile_post_initialization" + if self.data["project_settings"]["maya"][key]: + self.log.debug("Opening workfile post initialization.") + self.data["env"]["OPENPYPE_" + key.upper()] = "1" + return + # Add path to workfile to arguments self.launch_context.launch_args.append(last_workfile) diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py index 85f68c6b60..2092d5025d 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_foundry_apps.py @@ -13,7 +13,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = ["nuke", "nukex", "hiero", "nukestudio"] + app_groups = ["nuke", "nukeassist", "nukex", "hiero", "nukestudio"] platforms = ["windows"] def execute(self): diff --git a/openpype/host/dirmap.py b/openpype/host/dirmap.py index 88d68f27bf..347c5fbf85 100644 --- a/openpype/host/dirmap.py +++ b/openpype/host/dirmap.py @@ -8,6 +8,7 @@ exists is used. import os from abc import ABCMeta, abstractmethod +import platform import six @@ -187,11 +188,19 @@ class HostDirmap(object): self.log.debug("local overrides {}".format(active_overrides)) self.log.debug("remote overrides {}".format(remote_overrides)) + current_platform = platform.system().lower() for root_name, active_site_dir in active_overrides.items(): remote_site_dir = ( remote_overrides.get(root_name) or sync_settings["sites"][remote_site]["root"][root_name] ) + + if isinstance(remote_site_dir, dict): + remote_site_dir = remote_site_dir.get(current_platform) + + if not remote_site_dir: + continue + if os.path.isdir(active_site_dir): if "destination-path" not in mapping: mapping["destination-path"] = [] diff --git a/openpype/host/host.py b/openpype/host/host.py index 94416bb39a..d2335c0062 100644 --- a/openpype/host/host.py +++ b/openpype/host/host.py @@ -1,3 +1,4 @@ +import os import logging import contextlib from abc import ABCMeta, abstractproperty @@ -100,6 +101,30 @@ class HostBase(object): pass + def get_current_project_name(self): + """ + Returns: + Union[str, None]: Current project name. + """ + + return os.environ.get("AVALON_PROJECT") + + def get_current_asset_name(self): + """ + Returns: + Union[str, None]: Current asset name. + """ + + return os.environ.get("AVALON_ASSET") + + def get_current_task_name(self): + """ + Returns: + Union[str, None]: Current task name. + """ + + return os.environ.get("AVALON_TASK") + def get_current_context(self): """Get current context information. @@ -111,19 +136,14 @@ class HostBase(object): Default implementation returns values from 'legacy_io.Session'. Returns: - dict: Context with 3 keys 'project_name', 'asset_name' and - 'task_name'. All of them can be 'None'. + Dict[str, Union[str, None]]: Context with 3 keys 'project_name', + 'asset_name' and 'task_name'. All of them can be 'None'. """ - from openpype.pipeline import legacy_io - - if legacy_io.is_installed(): - legacy_io.install() - return { - "project_name": legacy_io.Session["AVALON_PROJECT"], - "asset_name": legacy_io.Session["AVALON_ASSET"], - "task_name": legacy_io.Session["AVALON_TASK"] + "project_name": self.get_current_project_name(), + "asset_name": self.get_current_asset_name(), + "task_name": self.get_current_task_name() } def get_context_title(self): diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 8d38288257..c20b0ec51b 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -6,14 +6,19 @@ from openpype.hosts.aftereffects import api from openpype.pipeline import ( Creator, CreatedInstance, - CreatorError, - legacy_io, + CreatorError ) from openpype.hosts.aftereffects.api.pipeline import cache_and_get_instances from openpype.lib import prepare_template_data +from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS class RenderCreator(Creator): + """Creates 'render' instance for publishing. + + Result of 'render' instance is video or sequence of images for particular + composition based of configuration in its RenderQueue. + """ identifier = "render" label = "Render" family = "render" @@ -28,45 +33,6 @@ class RenderCreator(Creator): ["RenderCreator"] ["defaults"]) - def get_icon(self): - return resources.get_openpype_splash_filepath() - - def collect_instances(self): - for instance_data in cache_and_get_instances(self): - # legacy instances have family=='render' or 'renderLocal', use them - creator_id = (instance_data.get("creator_identifier") or - instance_data.get("family", '').replace("Local", '')) - if creator_id == self.identifier: - instance_data = self._handle_legacy(instance_data) - instance = CreatedInstance.from_existing( - instance_data, self - ) - self._add_instance_to_context(instance) - - def update_instances(self, update_list): - for created_inst, _changes in update_list: - api.get_stub().imprint(created_inst.get("instance_id"), - created_inst.data_to_store()) - subset_change = _changes.get("subset") - if subset_change: - api.get_stub().rename_item(created_inst.data["members"][0], - subset_change[1]) - - def remove_instances(self, instances): - for instance in instances: - self._remove_instance_from_context(instance) - self.host.remove_instance(instance) - - subset = instance.data["subset"] - comp_id = instance.data["members"][0] - comp = api.get_stub().get_item(comp_id) - if comp: - new_comp_name = comp.name.replace(subset, '') - if not new_comp_name: - new_comp_name = "dummyCompName" - api.get_stub().rename_item(comp_id, - new_comp_name) - def create(self, subset_name_from_ui, data, pre_create_data): stub = api.get_stub() # only after After Effects is up if pre_create_data.get("use_selection"): @@ -82,10 +48,19 @@ class RenderCreator(Creator): "if 'useSelection' or create at least " "one composition." ) - + use_composition_name = (pre_create_data.get("use_composition_name") or + len(comps) > 1) for comp in comps: - if pre_create_data.get("use_composition_name"): - composition_name = comp.name + if use_composition_name: + if "{composition}" not in subset_name_from_ui.lower(): + subset_name_from_ui += "{Composition}" + + composition_name = re.sub( + "[^{}]+".format(SUBSET_NAME_ALLOWED_SYMBOLS), + "", + comp.name + ) + dynamic_fill = prepare_template_data({"composition": composition_name}) subset_name = subset_name_from_ui.format(**dynamic_fill) @@ -129,8 +104,72 @@ class RenderCreator(Creator): ] return output + def get_icon(self): + return resources.get_openpype_splash_filepath() + + def collect_instances(self): + for instance_data in cache_and_get_instances(self): + # legacy instances have family=='render' or 'renderLocal', use them + creator_id = (instance_data.get("creator_identifier") or + instance_data.get("family", '').replace("Local", '')) + if creator_id == self.identifier: + instance_data = self._handle_legacy(instance_data) + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + for created_inst, _changes in update_list: + api.get_stub().imprint(created_inst.get("instance_id"), + created_inst.data_to_store()) + subset_change = _changes.get("subset") + if subset_change: + api.get_stub().rename_item(created_inst.data["members"][0], + subset_change.new_value) + + def remove_instances(self, instances): + for instance in instances: + self._remove_instance_from_context(instance) + self.host.remove_instance(instance) + + subset = instance.data["subset"] + comp_id = instance.data["members"][0] + comp = api.get_stub().get_item(comp_id) + if comp: + new_comp_name = comp.name.replace(subset, '') + if not new_comp_name: + new_comp_name = "dummyCompName" + api.get_stub().rename_item(comp_id, + new_comp_name) + def get_detail_description(self): - return """Creator for Render instances""" + return """Creator for Render instances + + Main publishable item in AfterEffects will be of `render` family. + Result of this item (instance) is picture sequence or video that could + be a final delivery product or loaded and used in another DCCs. + + Select single composition and create instance of 'render' family or + turn off 'Use selection' to create instance for all compositions. + + 'Use composition name in subset' allows to explicitly add composition + name into created subset name. + + Position of composition name could be set in + `project_settings/global/tools/creator/subset_name_profiles` with some + form of '{composition}' placeholder. + + Composition name will be used implicitly if multiple composition should + be handled at same time. + + If {composition} placeholder is not us 'subset_name_profiles' + composition name will be capitalized and set at the end of subset name + if necessary. + + If composition name should be used, it will be cleaned up of characters + that would cause an issue in published file names. + """ def get_dynamic_data(self, variant, task_name, asset_doc, project_name, host_name, instance): @@ -155,7 +194,7 @@ class RenderCreator(Creator): instance_data.pop("uuid") if not instance_data.get("task"): - instance_data["task"] = legacy_io.Session.get("AVALON_TASK") + instance_data["task"] = self.create_context.get_current_task_name() if not instance_data.get("creator_attributes"): is_old_farm = instance_data["family"] != "renderLocal" diff --git a/openpype/hosts/aftereffects/plugins/create/workfile_creator.py b/openpype/hosts/aftereffects/plugins/create/workfile_creator.py index c698af896b..2e7b9d4a7e 100644 --- a/openpype/hosts/aftereffects/plugins/create/workfile_creator.py +++ b/openpype/hosts/aftereffects/plugins/create/workfile_creator.py @@ -2,8 +2,7 @@ import openpype.hosts.aftereffects.api as api from openpype.client import get_asset_by_name from openpype.pipeline import ( AutoCreator, - CreatedInstance, - legacy_io, + CreatedInstance ) from openpype.hosts.aftereffects.api.pipeline import cache_and_get_instances @@ -38,10 +37,11 @@ class AEWorkfileCreator(AutoCreator): existing_instance = instance break - project_name = legacy_io.Session["AVALON_PROJECT"] - asset_name = legacy_io.Session["AVALON_ASSET"] - task_name = legacy_io.Session["AVALON_TASK"] - host_name = legacy_io.Session["AVALON_APP"] + context = self.create_context + project_name = context.get_current_project_name() + asset_name = context.get_current_asset_name() + task_name = context.get_current_task_name() + host_name = context.host_name if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) diff --git a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py index 03ec184524..85a42830a4 100644 --- a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py @@ -1,6 +1,6 @@ import json import pyblish.api -from openpype.hosts.aftereffects.api import list_instances +from openpype.hosts.aftereffects.api import AfterEffectsHost class PreCollectRender(pyblish.api.ContextPlugin): @@ -25,7 +25,7 @@ class PreCollectRender(pyblish.api.ContextPlugin): self.log.debug("Not applicable for New Publisher, skip") return - for inst in list_instances(): + for inst in AfterEffectsHost().list_instances(): if inst.get("creator_attributes"): raise ValueError("Instance created in New publisher, " "cannot be published in Pyblish.\n" diff --git a/openpype/hosts/blender/plugins/load/import_workfile.py b/openpype/hosts/blender/plugins/load/import_workfile.py index 618fb83e31..bbdf1c7ea0 100644 --- a/openpype/hosts/blender/plugins/load/import_workfile.py +++ b/openpype/hosts/blender/plugins/load/import_workfile.py @@ -44,7 +44,7 @@ class AppendBlendLoader(plugin.AssetLoader): """ representations = ["blend"] - families = ["*"] + families = ["workfile"] label = "Append Workfile" order = 9 @@ -68,7 +68,7 @@ class ImportBlendLoader(plugin.AssetLoader): """ representations = ["blend"] - families = ["*"] + families = ["workfile"] label = "Import Workfile" order = 9 diff --git a/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py index 84b9dd1a6e..48c267fd18 100644 --- a/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py +++ b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py @@ -19,7 +19,6 @@ class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ["blender"] families = ["camera"] - version = (0, 1, 0) label = "Zero Keyframe" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] diff --git a/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py b/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py index cee855671d..edf47193be 100644 --- a/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py +++ b/openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py @@ -14,7 +14,6 @@ class ValidateMeshHasUvs(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ["blender"] families = ["model"] - category = "geometry" label = "Mesh Has UV's" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] optional = True diff --git a/openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py b/openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py index 45ac08811d..618feb95c1 100644 --- a/openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py +++ b/openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py @@ -14,7 +14,6 @@ class ValidateMeshNoNegativeScale(pyblish.api.Validator): order = ValidateContentsOrder hosts = ["blender"] families = ["model"] - category = "geometry" label = "Mesh No Negative Scale" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] diff --git a/openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py b/openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py index f5dc9fdd5c..1a98ec4c1d 100644 --- a/openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py +++ b/openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py @@ -19,7 +19,6 @@ class ValidateNoColonsInName(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ["blender"] families = ["model", "rig"] - version = (0, 1, 0) label = "No Colons in names" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] diff --git a/openpype/hosts/blender/plugins/publish/validate_transform_zero.py b/openpype/hosts/blender/plugins/publish/validate_transform_zero.py index 742826d3d9..66ef731e6e 100644 --- a/openpype/hosts/blender/plugins/publish/validate_transform_zero.py +++ b/openpype/hosts/blender/plugins/publish/validate_transform_zero.py @@ -21,7 +21,6 @@ class ValidateTransformZero(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ["blender"] families = ["model"] - version = (0, 1, 0) label = "Transform Zero" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] 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 bf97dd744b..43b81b83e7 100644 --- a/openpype/hosts/celaction/plugins/publish/collect_celaction_cli_kwargs.py +++ b/openpype/hosts/celaction/plugins/publish/collect_celaction_cli_kwargs.py @@ -1,5 +1,4 @@ import pyblish.api -import argparse import sys from pprint import pformat @@ -11,20 +10,40 @@ class CollectCelactionCliKwargs(pyblish.api.Collector): order = pyblish.api.Collector.order - 0.1 def process(self, context): - parser = argparse.ArgumentParser(prog="celaction") - parser.add_argument("--currentFile", - help="Pass file to Context as `currentFile`") - parser.add_argument("--chunk", - help=("Render chanks on farm")) - parser.add_argument("--frameStart", - help=("Start of frame range")) - parser.add_argument("--frameEnd", - help=("End of frame range")) - parser.add_argument("--resolutionWidth", - help=("Width of resolution")) - parser.add_argument("--resolutionHeight", - help=("Height of resolution")) - passing_kwargs = parser.parse_args(sys.argv[1:]).__dict__ + args = list(sys.argv[1:]) + self.log.info(str(args)) + missing_kwargs = [] + passing_kwargs = {} + for key in ( + "chunk", + "frameStart", + "frameEnd", + "resolutionWidth", + "resolutionHeight", + "currentFile", + ): + arg_key = f"--{key}" + if arg_key not in args: + missing_kwargs.append(key) + continue + arg_idx = args.index(arg_key) + args.pop(arg_idx) + if key != "currentFile": + value = args.pop(arg_idx) + else: + path_parts = [] + while arg_idx < len(args): + path_parts.append(args.pop(arg_idx)) + value = " ".join(path_parts).strip('"') + + passing_kwargs[key] = value + + if missing_kwargs: + raise RuntimeError("Missing arguments {}".format( + ", ".join( + [f'"{key}"' for key in missing_kwargs] + ) + )) self.log.info("Storing kwargs ...") self.log.debug("_ passing_kwargs: {}".format(pformat(passing_kwargs))) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index b1db612671..983d7486b3 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -702,6 +702,37 @@ class ClipLoader(LoaderPlugin): _mapping = None + def apply_settings(cls, project_settings, system_settings): + + plugin_type_settings = ( + project_settings + .get("flame", {}) + .get("load", {}) + ) + + if not plugin_type_settings: + return + + plugin_name = cls.__name__ + + plugin_settings = None + # Look for plugin settings in host specific settings + if plugin_name in plugin_type_settings: + plugin_settings = plugin_type_settings[plugin_name] + + if not plugin_settings: + return + + print(">>> We have preset for {}".format(plugin_name)) + for option, value in plugin_settings.items(): + if option == "enabled" and value is False: + print(" - is disabled by preset") + elif option == "representations": + continue + else: + print(" - setting `{}`: `{}`".format(option, value)) + setattr(cls, option, value) + def get_colorspace(self, context): """Get colorspace name diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index 6f47c23d57..25b31c94a3 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -4,6 +4,10 @@ import flame from pprint import pformat import openpype.hosts.flame.api as opfapi from openpype.lib import StringTemplate +from openpype.lib.transcoding import ( + VIDEO_EXTENSIONS, + IMAGE_EXTENSIONS +) class LoadClip(opfapi.ClipLoader): @@ -14,7 +18,10 @@ class LoadClip(opfapi.ClipLoader): """ families = ["render2d", "source", "plate", "render", "review"] - representations = ["exr", "dpx", "jpg", "jpeg", "png", "h264"] + representations = ["*"] + extensions = set( + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + ) label = "Load as clip" order = -10 diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index 5975c6e42f..86bc0f8f1e 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -4,7 +4,10 @@ import flame from pprint import pformat import openpype.hosts.flame.api as opfapi from openpype.lib import StringTemplate - +from openpype.lib.transcoding import ( + VIDEO_EXTENSIONS, + IMAGE_EXTENSIONS +) class LoadClipBatch(opfapi.ClipLoader): """Load a subset to timeline as clip @@ -14,7 +17,10 @@ class LoadClipBatch(opfapi.ClipLoader): """ families = ["render2d", "source", "plate", "render", "review"] - representations = ["exr", "dpx", "jpg", "jpeg", "png", "h264"] + representations = ["*"] + extensions = set( + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + ) label = "Load as clip to current batch" order = -10 diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index d5294d61c2..5082217db0 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -143,6 +143,9 @@ class ExtractSubsetResources(publish.Extractor): # create staging dir path staging_dir = self.staging_dir(instance) + # append staging dir for later cleanup + instance.context.data["cleanupFullPaths"].append(staging_dir) + # add default preset type for thumbnail and reviewable video # update them with settings and override in case the same # are found in there @@ -548,30 +551,3 @@ class ExtractSubsetResources(publish.Extractor): "Path `{}` is containing more that one clip".format(path) ) return clips[0] - - def staging_dir(self, instance): - """Provide a temporary directory in which to store extracted files - - Upon calling this method the staging directory is stored inside - the instance.data['stagingDir'] - """ - staging_dir = instance.data.get('stagingDir', None) - openpype_temp_dir = os.getenv("OPENPYPE_TEMP_DIR") - - if not staging_dir: - if openpype_temp_dir and os.path.exists(openpype_temp_dir): - staging_dir = os.path.normpath( - tempfile.mkdtemp( - prefix="pyblish_tmp_", - dir=openpype_temp_dir - ) - ) - else: - staging_dir = os.path.normpath( - tempfile.mkdtemp(prefix="pyblish_tmp_") - ) - instance.data['stagingDir'] = staging_dir - - instance.context.data["cleanupFullPaths"].append(staging_dir) - - return staging_dir diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index a33e5cf289..88a3f0b49b 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -210,7 +210,8 @@ def switch_item(container, if any(not x for x in [asset_name, subset_name, representation_name]): repre_id = container["representation"] representation = get_representation_by_id(project_name, repre_id) - repre_parent_docs = get_representation_parents(representation) + repre_parent_docs = get_representation_parents( + project_name, representation) if repre_parent_docs: version, subset, asset, _ = repre_parent_docs else: diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index d043d54322..323b8b0029 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -36,7 +36,7 @@ class FusionPrelaunch(PreLaunchHook): "Make sure the environment in fusion settings has " "'FUSION_PYTHON3_HOME' set correctly and make sure " "Python 3 is installed in the given path." - f"\n\nPYTHON36: {fusion_python3_home}" + f"\n\nPYTHON PATH: {fusion_python3_home}" ) self.log.info(f"Setting {py3_var}: '{py3_dir}'...") diff --git a/openpype/hosts/fusion/plugins/load/actions.py b/openpype/hosts/fusion/plugins/load/actions.py index 819c9272fd..3b14f022e5 100644 --- a/openpype/hosts/fusion/plugins/load/actions.py +++ b/openpype/hosts/fusion/plugins/load/actions.py @@ -15,6 +15,7 @@ class FusionSetFrameRangeLoader(load.LoaderPlugin): "pointcache", "render"] representations = ["*"] + extensions = {"*"} label = "Set frame range" order = 11 diff --git a/openpype/hosts/fusion/plugins/load/load_alembic.py b/openpype/hosts/fusion/plugins/load/load_alembic.py index f8b8c2cb0a..11bf59af12 100644 --- a/openpype/hosts/fusion/plugins/load/load_alembic.py +++ b/openpype/hosts/fusion/plugins/load/load_alembic.py @@ -13,7 +13,8 @@ class FusionLoadAlembicMesh(load.LoaderPlugin): """Load Alembic mesh into Fusion""" families = ["pointcache", "model"] - representations = ["abc"] + representations = ["*"] + extensions = {"abc"} label = "Load alembic mesh" order = -10 diff --git a/openpype/hosts/fusion/plugins/load/load_fbx.py b/openpype/hosts/fusion/plugins/load/load_fbx.py index 70fe82ffef..b8f501ae7e 100644 --- a/openpype/hosts/fusion/plugins/load/load_fbx.py +++ b/openpype/hosts/fusion/plugins/load/load_fbx.py @@ -14,7 +14,8 @@ class FusionLoadFBXMesh(load.LoaderPlugin): """Load FBX mesh into Fusion""" families = ["*"] - representations = ["fbx"] + representations = ["*"] + extensions = {"fbx"} label = "Load FBX mesh" order = -10 diff --git a/openpype/hosts/fusion/plugins/load/load_sequence.py b/openpype/hosts/fusion/plugins/load/load_sequence.py index 6f44c61d1b..542c3c1a51 100644 --- a/openpype/hosts/fusion/plugins/load/load_sequence.py +++ b/openpype/hosts/fusion/plugins/load/load_sequence.py @@ -12,6 +12,10 @@ from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk ) +from openpype.lib.transcoding import ( + IMAGE_EXTENSIONS, + VIDEO_EXTENSIONS +) comp = get_current_comp() @@ -129,6 +133,9 @@ class FusionLoadSequence(load.LoaderPlugin): families = ["imagesequence", "review", "render", "plate"] representations = ["*"] + extensions = set( + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + ) label = "Load sequence" order = -10 diff --git a/openpype/hosts/fusion/plugins/publish/collect_instances.py b/openpype/hosts/fusion/plugins/publish/collect_instances.py index fe60b83827..7b0a1b6369 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_instances.py +++ b/openpype/hosts/fusion/plugins/publish/collect_instances.py @@ -80,6 +80,7 @@ class CollectInstances(pyblish.api.ContextPlugin): "outputDir": os.path.dirname(path), "ext": ext, # todo: should be redundant "label": label, + "task": context.data["task"], "frameStart": context.data["frameStart"], "frameEnd": context.data["frameEnd"], "frameStartHandle": context.data["frameStartHandle"], diff --git a/openpype/hosts/fusion/plugins/publish/render_local.py b/openpype/hosts/fusion/plugins/publish/render_local.py index 79e458b40a..53d8eb64e1 100644 --- a/openpype/hosts/fusion/plugins/publish/render_local.py +++ b/openpype/hosts/fusion/plugins/publish/render_local.py @@ -1,6 +1,4 @@ import os -from pprint import pformat - import pyblish.api from openpype.hosts.fusion.api import comp_lock_and_undo_chunk @@ -23,23 +21,53 @@ class Fusionlocal(pyblish.api.InstancePlugin): # This plug-in runs only once and thus assumes all instances # currently will render the same frame range context = instance.context - key = "__hasRun{}".format(self.__class__.__name__) + key = f"__hasRun{self.__class__.__name__}" if context.data.get(key, False): return - else: - context.data[key] = True - current_comp = context.data["currentComp"] + context.data[key] = True + + self.render_once(context) + frame_start = context.data["frameStartHandle"] frame_end = context.data["frameEndHandle"] path = instance.data["path"] output_dir = instance.data["outputDir"] - ext = os.path.splitext(os.path.basename(path))[-1] + 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, + } + + if "representations" not in instance.data: + instance.data["representations"] = [] + instance.data["representations"].append(repre) + + # review representation + repre_preview = repre.copy() + repre_preview["name"] = repre_preview["ext"] = "mp4" + repre_preview["tags"] = ["review", "ftrackreview", "delete"] + instance.data["representations"].append(repre_preview) + + def render_once(self, context): + """Render context comp only once, even with more render instances""" + + current_comp = context.data["currentComp"] + frame_start = context.data["frameStartHandle"] + frame_end = context.data["frameEndHandle"] self.log.info("Starting render") - self.log.info("Start frame: {}".format(frame_start)) - self.log.info("End frame: {}".format(frame_end)) + 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({ @@ -48,26 +76,5 @@ class Fusionlocal(pyblish.api.InstancePlugin): "Wait": True }) - if "representations" not in instance.data: - instance.data["representations"] = [] - - collected_frames = os.listdir(output_dir) - repre = { - 'name': ext[1:], - 'ext': ext[1:], - 'frameStart': "%0{}d".format(len(str(frame_end))) % frame_start, - 'files': collected_frames, - "stagingDir": output_dir, - } - instance.data["representations"].append(repre) - - # review representation - repre_preview = repre.copy() - repre_preview["name"] = repre_preview["ext"] = "mp4" - repre_preview["tags"] = ["review", "preview", "ftrackreview", "delete"] - instance.data["representations"].append(repre_preview) - - self.log.debug(f"_ instance.data: {pformat(instance.data)}") - if not result: raise RuntimeError("Comp render failed") diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index 4b9849c190..686770b64e 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -126,10 +126,6 @@ def check_inventory(): def application_launch(event): """Event that is executed after Harmony is launched.""" - # FIXME: This is breaking server <-> client communication. - # It is now moved so it it manually called. - # ensure_scene_settings() - # check_inventory() # fills OPENPYPE_HARMONY_JS pype_harmony_path = Path(__file__).parent.parent / "js" / "PypeHarmony.js" pype_harmony_js = pype_harmony_path.read_text() @@ -146,6 +142,9 @@ def application_launch(event): harmony.send({"script": script}) inject_avalon_js() + ensure_scene_settings() + check_inventory() + def export_template(backdrops, nodes, filepath): """Export Template to file. diff --git a/openpype/hosts/harmony/plugins/load/load_imagesequence.py b/openpype/hosts/harmony/plugins/load/load_imagesequence.py index 1b64aff595..b95d25f507 100644 --- a/openpype/hosts/harmony/plugins/load/load_imagesequence.py +++ b/openpype/hosts/harmony/plugins/load/load_imagesequence.py @@ -20,8 +20,9 @@ class ImageSequenceLoader(load.LoaderPlugin): Stores the imported asset in a container named after the asset. """ - families = ["shot", "render", "image", "plate", "reference"] - representations = ["jpeg", "png", "jpg"] + families = ["shot", "render", "image", "plate", "reference", "review"] + representations = ["*"] + extensions = {"jpeg", "png", "jpg"} def load(self, context, name=None, namespace=None, data=None): """Plugin entry point. diff --git a/openpype/hosts/harmony/plugins/publish/extract_render.py b/openpype/hosts/harmony/plugins/publish/extract_render.py index 2f8169248e..c29864bb28 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_render.py +++ b/openpype/hosts/harmony/plugins/publish/extract_render.py @@ -108,9 +108,9 @@ class ExtractRender(pyblish.api.InstancePlugin): output = process.communicate()[0] if process.returncode != 0: - raise ValueError(output.decode("utf-8")) + raise ValueError(output.decode("utf-8", errors="backslashreplace")) - self.log.debug(output.decode("utf-8")) + self.log.debug(output.decode("utf-8", errors="backslashreplace")) # Generate representations. extension = collection.tail[1:] diff --git a/openpype/hosts/hiero/plugins/load/load_clip.py b/openpype/hosts/hiero/plugins/load/load_clip.py index 2a7d1af41e..77844d2448 100644 --- a/openpype/hosts/hiero/plugins/load/load_clip.py +++ b/openpype/hosts/hiero/plugins/load/load_clip.py @@ -6,6 +6,10 @@ from openpype.pipeline import ( legacy_io, get_representation_path, ) +from openpype.lib.transcoding import ( + VIDEO_EXTENSIONS, + IMAGE_EXTENSIONS +) import openpype.hosts.hiero.api as phiero @@ -17,7 +21,10 @@ class LoadClip(phiero.SequenceLoader): """ families = ["render2d", "source", "plate", "render", "review"] - representations = ["exr", "dpx", "jpg", "jpeg", "png", "h264"] + representations = ["*"] + extensions = set( + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + ) label = "Load as clip" order = -10 @@ -34,6 +41,38 @@ class LoadClip(phiero.SequenceLoader): clip_name_template = "{asset}_{subset}_{representation}" + def apply_settings(cls, project_settings, system_settings): + + plugin_type_settings = ( + project_settings + .get("hiero", {}) + .get("load", {}) + ) + + if not plugin_type_settings: + return + + plugin_name = cls.__name__ + + plugin_settings = None + # Look for plugin settings in host specific settings + if plugin_name in plugin_type_settings: + plugin_settings = plugin_type_settings[plugin_name] + + if not plugin_settings: + return + + print(">>> We have preset for {}".format(plugin_name)) + for option, value in plugin_settings.items(): + if option == "enabled" and value is False: + print(" - is disabled by preset") + elif option == "representations": + continue + else: + print(" - setting `{}`: `{}`".format(option, value)) + setattr(cls, option, value) + + def load(self, context, name, namespace, options): # add clip name template to options options.update({ diff --git a/openpype/hosts/hiero/plugins/load/load_effects.py b/openpype/hosts/hiero/plugins/load/load_effects.py index a3fcd63b5b..b61cca9731 100644 --- a/openpype/hosts/hiero/plugins/load/load_effects.py +++ b/openpype/hosts/hiero/plugins/load/load_effects.py @@ -19,8 +19,9 @@ from openpype.lib import Logger class LoadEffects(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" - representations = ["effectJson"] families = ["effect"] + representations = ["*"] + extension = {"json"} label = "Load Effects" order = 0 diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index f8e2c16d21..9793679b45 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -144,13 +144,20 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): """ obj_network = hou.node("/obj") - op_ctx = obj_network.createNode( - "null", node_name="OpenPypeContext") + op_ctx = obj_network.createNode("null", node_name="OpenPypeContext") + + # A null in houdini by default comes with content inside to visualize + # the null. However since we explicitly want to hide the node lets + # remove the content and disable the display flag of the node + for node in op_ctx.children(): + node.destroy() + op_ctx.moveToGoodPosition() op_ctx.setBuiltExplicitly(False) op_ctx.setCreatorState("OpenPype") op_ctx.setComment("OpenPype node to hold context metadata") op_ctx.setColor(hou.Color((0.081, 0.798, 0.810))) + op_ctx.setDisplayFlag(False) op_ctx.hide(True) return op_ctx diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index e15e27c83f..f0985973a6 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -103,9 +103,8 @@ class HoudiniCreatorBase(object): fill it with all collected instances from the scene under its respective creator identifiers. - If legacy instances are detected in the scene, create - `houdini_cached_legacy_subsets` there and fill it with - all legacy subsets under family as a key. + Create `houdini_cached_legacy_subsets` key for any legacy instances + detected in the scene as instances per family. Args: Dict[str, Any]: Shared data. @@ -115,29 +114,30 @@ class HoudiniCreatorBase(object): """ if shared_data.get("houdini_cached_subsets") is None: - shared_data["houdini_cached_subsets"] = {} - if shared_data.get("houdini_cached_legacy_subsets") is None: - shared_data["houdini_cached_legacy_subsets"] = {} - cached_instances = lsattr("id", "pyblish.avalon.instance") - for i in cached_instances: - if not i.parm("creator_identifier"): - # we have legacy instance - family = i.parm("family").eval() - if family not in shared_data[ - "houdini_cached_legacy_subsets"]: - shared_data["houdini_cached_legacy_subsets"][ - family] = [i] - else: - shared_data[ - "houdini_cached_legacy_subsets"][family].append(i) - continue + cache = dict() + cache_legacy = dict() + + for node in lsattr("id", "pyblish.avalon.instance"): + + creator_identifier_parm = node.parm("creator_identifier") + if creator_identifier_parm: + # creator instance + creator_id = creator_identifier_parm.eval() + cache.setdefault(creator_id, []).append(node) - creator_id = i.parm("creator_identifier").eval() - if creator_id not in shared_data["houdini_cached_subsets"]: - shared_data["houdini_cached_subsets"][creator_id] = [i] else: - shared_data[ - "houdini_cached_subsets"][creator_id].append(i) # noqa + # legacy instance + family_parm = node.parm("family") + if not family_parm: + # must be a broken instance + continue + + family = family_parm.eval() + cache_legacy.setdefault(family, []).append(node) + + shared_data["houdini_cached_subsets"] = cache + shared_data["houdini_cached_legacy_subsets"] = cache_legacy + return shared_data @staticmethod @@ -225,12 +225,12 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self._add_instance_to_context(created_instance) def update_instances(self, update_list): - for created_inst, _changes in update_list: + for created_inst, changes in update_list: instance_node = hou.node(created_inst.get("instance_node")) new_values = { - key: new_value - for key, (_old_value, new_value) in _changes.items() + key: changes[key].new_value + for key in changes.changed_keys } imprint( instance_node, diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 3ccab964cd..ebd668e9e4 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -1,4 +1,5 @@ import os +import re import logging import platform @@ -66,7 +67,7 @@ def generate_shelves(): ) continue - mandatory_attributes = {'name', 'script'} + mandatory_attributes = {'label', 'script'} for tool_definition in shelf_definition.get('tools_list'): # We verify that the name and script attibutes of the tool # are set @@ -152,31 +153,32 @@ def get_or_create_tool(tool_definition, shelf): Returns: hou.Tool: The tool updated or the new one """ - existing_tools = shelf.tools() - tool_label = tool_definition.get('label') + tool_label = tool_definition.get("label") + if not tool_label: + log.warning("Skipped shelf without label") + return + + script_path = tool_definition["script"] + if not script_path or not os.path.exists(script_path): + log.warning("This path doesn't exist - {}".format(script_path)) + return + + existing_tools = shelf.tools() existing_tool = next( (tool for tool in existing_tools if tool.label() == tool_label), None ) + + with open(script_path) as stream: + script = stream.read() + + tool_definition["script"] = script + if existing_tool: - tool_definition.pop('name', None) - tool_definition.pop('label', None) + tool_definition.pop("label", None) existing_tool.setData(**tool_definition) return existing_tool - tool_name = tool_label.replace(' ', '_').lower() - - if not os.path.exists(tool_definition['script']): - log.warning( - "This path doesn't exist - {}".format(tool_definition['script']) - ) - return - - with open(tool_definition['script']) as f: - script = f.read() - tool_definition.update({'script': script}) - - new_tool = hou.shelves.newTool(name=tool_name, **tool_definition) - - return new_tool + tool_name = re.sub(r"[^\w\d]+", "_", tool_label).lower() + return hou.shelves.newTool(name=tool_name, **tool_definition) diff --git a/openpype/hosts/max/addon.py b/openpype/hosts/max/addon.py index 734b87dd21..9d6ab5a8b3 100644 --- a/openpype/hosts/max/addon.py +++ b/openpype/hosts/max/addon.py @@ -12,5 +12,17 @@ class MaxAddon(OpenPypeModule, IHostAddon): def initialize(self, module_settings): self.enabled = True + def add_implementation_envs(self, env, _app): + # Remove auto screen scale factor for Qt + # - let 3dsmax decide it's value + env.pop("QT_AUTO_SCREEN_SCALE_FACTOR", None) + def get_workfile_extensions(self): return [".max"] + + def get_launch_hook_paths(self, app): + if app.host_name != self.host_name: + return [] + return [ + os.path.join(MAX_HOST_DIR, "hooks") + ] diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 9256ca9ac1..4fb750d91b 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -120,3 +120,51 @@ def get_all_children(parent, node_type=None): return ([x for x in child_list if rt.superClassOf(x) == node_type] if node_type else child_list) + + +def get_current_renderer(): + """get current renderer""" + return rt.renderers.production + + +def get_default_render_folder(project_setting=None): + return (project_setting["max"] + ["RenderSettings"] + ["default_render_image_folder"]) + + +def set_framerange(start_frame, end_frame): + """ + Note: + Frame range can be specified in different types. Possible values are: + * `1` - Single frame. + * `2` - Active time segment ( animationRange ). + * `3` - User specified Range. + * `4` - User specified Frame pickup string (for example `1,3,5-12`). + + Todo: + Current type is hard-coded, there should be a custom setting for this. + """ + rt.rendTimeType = 4 + if start_frame is not None and end_frame is not None: + frame_range = "{0}-{1}".format(start_frame, end_frame) + rt.rendPickupFrames = frame_range + + +def get_multipass_setting(project_setting=None): + return (project_setting["max"] + ["RenderSettings"] + ["multipass"]) + + +def get_max_version(): + """ + Args: + get max version date for deadline + + Returns: + #(25000, 62, 0, 25, 0, 0, 997, 2023, "") + max_info[7] = max version date + """ + max_info = rt.maxversion() + return max_info[7] diff --git a/openpype/hosts/max/api/lib_renderproducts.py b/openpype/hosts/max/api/lib_renderproducts.py new file mode 100644 index 0000000000..a74a6a7426 --- /dev/null +++ b/openpype/hosts/max/api/lib_renderproducts.py @@ -0,0 +1,114 @@ +# Render Element Example : For scanline render, VRay +# https://help.autodesk.com/view/MAXDEV/2022/ENU/?guid=GUID-E8F75D47-B998-4800-A3A5-610E22913CFC +# arnold +# https://help.autodesk.com/view/ARNOL/ENU/?guid=arnold_for_3ds_max_ax_maxscript_commands_ax_renderview_commands_html +import os +from pymxs import runtime as rt +from openpype.hosts.max.api.lib import ( + get_current_renderer, + get_default_render_folder +) +from openpype.settings import get_project_settings +from openpype.pipeline import legacy_io + + +class RenderProducts(object): + + def __init__(self, project_settings=None): + self._project_settings = project_settings + if not self._project_settings: + self._project_settings = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + + def render_product(self, container): + folder = rt.maxFilePath + file = rt.maxFileName + folder = folder.replace("\\", "/") + setting = self._project_settings + render_folder = get_default_render_folder(setting) + filename, ext = os.path.splitext(file) + + output_file = os.path.join(folder, + render_folder, + filename, + container) + + 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) + + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + + if renderer == "VUE_File_Renderer": + return full_render_list + + if renderer in [ + "ART_Renderer", + "Redshift_Renderer", + "V_Ray_6_Hotfix_3", + "V_Ray_GPU_6_Hotfix_3", + "Default_Scanline_Renderer", + "Quicksilver_Hardware_Renderer", + ]: + render_elem_list = self.render_elements_product(output_file, + img_fmt) + if render_elem_list: + full_render_list.extend(iter(render_elem_list)) + return full_render_list + + if renderer == "Arnold": + aov_list = self.arnold_render_product(output_file, + 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 + + # TODO: Get the arnold render product + def arnold_render_product(self, folder, fmt): + """Get all the Arnold AOVs""" + aovs = [] + + amw = rt.MaxtoAOps.AOVsManagerWindow() + aov_mgr = rt.renderers.current.AOVManager + # Check if there is any aov group set in AOV manager + aov_group_num = len(aov_mgr.drivers) + if aov_group_num < 1: + return + 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) + # close the AOVs manager window + amw.close() + + return aovs + + def render_elements_product(self, folder, fmt): + """Get all the render element output files. """ + render_dirname = [] + + render_elem = rt.maxOps.GetCurRenderElementMgr() + render_elem_num = render_elem.NumRenderElements() + # get render elements from the renders + for i in range(render_elem_num): + 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) + + return render_dirname + + def image_format(self): + return self._project_settings["max"]["RenderSettings"]["image_format"] # noqa diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py new file mode 100644 index 0000000000..4940265a23 --- /dev/null +++ b/openpype/hosts/max/api/lib_rendersettings.py @@ -0,0 +1,168 @@ +import os +from pymxs import runtime as rt +from openpype.lib import Logger +from openpype.settings import get_project_settings +from openpype.pipeline import legacy_io +from openpype.pipeline.context_tools import get_current_project_asset + +from openpype.hosts.max.api.lib import ( + set_framerange, + get_current_renderer, + get_default_render_folder +) + + +class RenderSettings(object): + + log = Logger.get_logger("RenderSettings") + + _aov_chars = { + "dot": ".", + "dash": "-", + "underscore": "_" + } + + def __init__(self, project_settings=None): + """ + Set up the naming convention for the render + elements for the deadline submission + """ + + self._project_settings = project_settings + if not self._project_settings: + self._project_settings = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + + def set_render_camera(self, selection): + for sel in selection: + # to avoid Attribute Error from pymxs wrapper + found = False + if rt.classOf(sel) in rt.Camera.classes: + found = True + rt.viewport.setCamera(sel) + break + if not found: + raise RuntimeError("Camera not found") + + def render_output(self, container): + folder = rt.maxFilePath + # hard-coded, should be customized in the setting + file = rt.maxFileName + folder = folder.replace("\\", "/") + # hard-coded, set the renderoutput path + setting = self._project_settings + render_folder = get_default_render_folder(setting) + filename, ext = os.path.splitext(file) + output_dir = os.path.join(folder, + render_folder, + filename) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + # hard-coded, should be customized in the setting + context = get_current_project_asset() + + # get project resolution + width = context["data"].get("resolutionWidth") + height = context["data"].get("resolutionHeight") + # Set Frame Range + frame_start = context["data"].get("frame_start") + frame_end = context["data"].get("frame_end") + set_framerange(frame_start, frame_end) + # get the production render + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa + output = os.path.join(output_dir, container) + try: + aov_separator = self._aov_chars[( + self._project_settings["maya"] + ["RenderSettings"] + ["aov_separator"] + )] + except KeyError: + aov_separator = "." + output_filename = "{0}..{1}".format(output, img_fmt) + output_filename = output_filename.replace("{aov_separator}", + aov_separator) + rt.rendOutputFilename = output_filename + if renderer == "VUE_File_Renderer": + return + # TODO: Finish the arnold render setup + if renderer == "Arnold": + self.arnold_setup() + + if renderer in [ + "ART_Renderer", + "Redshift_Renderer", + "V_Ray_6_Hotfix_3", + "V_Ray_GPU_6_Hotfix_3", + "Default_Scanline_Renderer", + "Quicksilver_Hardware_Renderer", + ]: + self.render_element_layer(output, width, height, img_fmt) + + rt.rendSaveFile = True + + def arnold_setup(self): + # get Arnold RenderView run in the background + # for setting up renderable camera + arv = rt.MAXToAOps.ArnoldRenderView() + render_camera = rt.viewport.GetCamera() + arv.setOption("Camera", str(render_camera)) + + # TODO: add AOVs and extension + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa + setup_cmd = ( + f""" + amw = MaxtoAOps.AOVsManagerWindow() + amw.close() + aovmgr = renderers.current.AOVManager + aovmgr.drivers = #() + img_fmt = "{img_fmt}" + if img_fmt == "png" then driver = ArnoldPNGDriver() + if img_fmt == "jpg" then driver = ArnoldJPEGDriver() + if img_fmt == "exr" then driver = ArnoldEXRDriver() + if img_fmt == "tif" then driver = ArnoldTIFFDriver() + if img_fmt == "tiff" then driver = ArnoldTIFFDriver() + append aovmgr.drivers driver + aovmgr.drivers[1].aov_list = #() + """) + + rt.execute(setup_cmd) + arv.close() + + def render_element_layer(self, dir, width, height, ext): + """For Renderers with render elements""" + rt.renderWidth = width + rt.renderHeight = height + render_elem = rt.maxOps.GetCurRenderElementMgr() + render_elem_num = render_elem.NumRenderElements() + if render_elem_num < 0: + return + + for i in range(render_elem_num): + renderlayer_name = render_elem.GetRenderElement(i) + target, renderpass = str(renderlayer_name).split(":") + aov_name = "{0}_{1}..{2}".format(dir, renderpass, ext) + render_elem.SetRenderElementFileName(i, aov_name) + + def get_render_output(self, container, output_dir): + output = os.path.join(output_dir, container) + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa + output_filename = "{0}..{1}".format(output, img_fmt) + return output_filename + + def get_render_element(self): + orig_render_elem = [] + render_elem = rt.maxOps.GetCurRenderElementMgr() + render_elem_num = render_elem.NumRenderElements() + if render_elem_num < 0: + return + + for i in range(render_elem_num): + render_element = render_elem.GetRenderElementFilename(i) + orig_render_elem.append(render_element) + + return orig_render_elem diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index 02d8315af6..5c273b49b4 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -119,7 +119,7 @@ class OpenPypeMenu(object): def manage_callback(self): """Callback to show Scene Manager/Inventory tool.""" - host_tools.show_subset_manager(parent=self.main_widget) + host_tools.show_scene_inventory(parent=self.main_widget) def library_callback(self): """Callback to show Library Loader tool.""" diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index f3cdf245fb..f8a7b8ea5c 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -2,6 +2,7 @@ """Pipeline tools for OpenPype Houdini integration.""" import os import logging +from operator import attrgetter import json @@ -141,5 +142,25 @@ def ls() -> list: if rt.getUserProp(obj, "id") == AVALON_CONTAINER_ID ] - for container in sorted(containers, key=lambda name: container.name): + for container in sorted(containers, key=attrgetter("name")): yield lib.read(container) + + +def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": "", + "loader": loader, + "representation": context["representation"]["_id"], + } + + container_name = f"{name}{suffix}" + container = rt.container(name=container_name) + for node in nodes: + node.Parent = container + + if not lib.imprint(container_name, data): + print(f"imprinting of {container_name} failed.") + return container diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 4788bfd383..c16d9e61ec 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -78,12 +78,12 @@ class MaxCreator(Creator, MaxCreatorBase): self._add_instance_to_context(created_instance) def update_instances(self, update_list): - for created_inst, _changes in update_list: + for created_inst, changes in update_list: instance_node = created_inst.get("instance_node") new_values = { - key: new_value - for key, (_old_value, new_value) in _changes.items() + key: changes[key].new_value + for key in changes.changed_keys } imprint( instance_node, diff --git a/openpype/hosts/max/hooks/inject_python.py b/openpype/hosts/max/hooks/inject_python.py new file mode 100644 index 0000000000..d9753ccbd8 --- /dev/null +++ b/openpype/hosts/max/hooks/inject_python.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +"""Pre-launch hook to inject python environment.""" +from openpype.lib import PreLaunchHook +import os + + +class InjectPythonPath(PreLaunchHook): + """Inject OpenPype environment to 3dsmax. + + Note that this works in combination whit 3dsmax startup script that + is translating it back to PYTHONPATH for cases when 3dsmax drops PYTHONPATH + environment. + + Hook `GlobalHostDataHook` must be executed before this hook. + """ + app_groups = ["3dsmax"] + + def execute(self): + self.launch_context.env["MAX_PYTHONPATH"] = os.environ["PYTHONPATH"] diff --git a/openpype/hosts/max/plugins/create/create_camera.py b/openpype/hosts/max/plugins/create/create_camera.py new file mode 100644 index 0000000000..91d0d4d3dc --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_camera.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating camera.""" +from openpype.hosts.max.api import plugin +from openpype.pipeline import CreatedInstance + + +class CreateCamera(plugin.MaxCreator): + identifier = "io.openpype.creators.max.camera" + label = "Camera" + family = "camera" + 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(CreateCamera, 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_render.py b/openpype/hosts/max/plugins/create/create_render.py new file mode 100644 index 0000000000..269fff2e32 --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating camera.""" +from openpype.hosts.max.api import plugin +from openpype.pipeline import CreatedInstance +from openpype.hosts.max.api.lib_rendersettings import RenderSettings + + +class CreateRender(plugin.MaxCreator): + identifier = "io.openpype.creators.max.render" + label = "Render" + family = "maxrender" + 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(CreateRender, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + container_name = instance.data.get("instance_node") + container = rt.getNodeByName(container_name) + # 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")) + + # set viewport camera for rendering(mandatory for deadline) + RenderSettings().set_render_camera(sel_obj) + # set output paths for rendering(mandatory for deadline) + RenderSettings().render_output(container_name) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py new file mode 100644 index 0000000000..3a6947798e --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -0,0 +1,64 @@ +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 FbxLoader(load.LoaderPlugin): + """Fbx Loader""" + + families = ["camera"] + representations = ["fbx"] + order = -9 + icon = "code-fork" + color = "white" + + def load(self, context, name=None, namespace=None, data=None): + from pymxs import runtime as rt + + filepath = os.path.normpath(self.fname) + + fbx_import_cmd = ( + f""" + +FBXImporterSetParam "Animation" true +FBXImporterSetParam "Cameras" true +FBXImporterSetParam "AxisConversionMethod" true +FbxExporterSetParam "UpAxis" "Y" +FbxExporterSetParam "Preserveinstances" true + +importFile @"{filepath}" #noPrompt using:FBXIMP + """) + + self.log.debug(f"Executing command: {fbx_import_cmd}") + rt.execute(fbx_import_cmd) + + container_name = f"{name}_CON" + + asset = rt.getNodeByName(f"{name}") + + return containerise( + name, [asset], context, loader=self.__class__.__name__) + + def update(self, container, representation): + from pymxs import runtime as rt + + path = get_representation_path(representation) + node = rt.getNodeByName(container["instance_node"]) + + fbx_objects = self.get_container_children(node) + for fbx_object in fbx_objects: + fbx_object.source = path + + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) + + def remove(self, container): + from pymxs import runtime as rt + + node = rt.getNodeByName(container["instance_node"]) + rt.delete(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py new file mode 100644 index 0000000000..b863b9363f --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -0,0 +1,62 @@ +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 MaxSceneLoader(load.LoaderPlugin): + """Max Scene Loader""" + + families = ["camera"] + representations = ["max"] + order = -8 + icon = "code-fork" + color = "green" + + def load(self, context, name=None, namespace=None, data=None): + from pymxs import runtime as rt + path = os.path.normpath(self.fname) + # import the max scene by using "merge file" + path = path.replace('\\', '/') + + merge_before = { + c for c in rt.rootNode.Children + if rt.classOf(c) == rt.Container + } + rt.mergeMaxFile(path) + + merge_after = { + c for c in rt.rootNode.Children + if rt.classOf(c) == rt.Container + } + max_containers = merge_after.difference(merge_before) + + if len(max_containers) != 1: + self.log.error("Something failed when loading.") + + max_container = max_containers.pop() + + return containerise( + name, [max_container], context, loader=self.__class__.__name__) + + def update(self, container, representation): + from pymxs import runtime as rt + + path = get_representation_path(representation) + node = rt.getNodeByName(container["instance_node"]) + + max_objects = self.get_container_children(node) + for max_object in max_objects: + max_object.source = path + + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) + + def remove(self, container): + from pymxs import runtime as rt + + node = rt.getNodeByName(container["instance_node"]) + rt.delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 285d84b7b6..f7a72ece25 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -6,14 +6,19 @@ Because of limited api, alembics can be only loaded, but not easily updated. """ import os from openpype.pipeline import ( - load + load, get_representation_path ) +from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api import lib class AbcLoader(load.LoaderPlugin): """Alembic loader.""" - families = ["model", "animation", "pointcache"] + families = ["model", + "camera", + "animation", + "pointcache"] label = "Load Alembic" representations = ["abc"] order = -10 @@ -52,14 +57,47 @@ importFile @"{file_path}" #noPrompt abc_container = abc_containers.pop() - container_name = f"{name}_CON" - container = rt.container(name=container_name) - abc_container.Parent = container + return containerise( + name, [abc_container], context, loader=self.__class__.__name__) - return container + def update(self, container, representation): + from pymxs import runtime as rt + + path = get_representation_path(representation) + node = rt.getNodeByName(container["instance_node"]) + + alembic_objects = self.get_container_children(node, "AlembicObject") + for alembic_object in alembic_objects: + alembic_object.source = path + + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) + + def switch(self, container, representation): + self.update(container, representation) def remove(self, container): from pymxs import runtime as rt - node = container["node"] + node = rt.getNodeByName(container["instance_node"]) rt.delete(node) + + @staticmethod + def get_container_children(parent, type_name): + from pymxs import runtime as rt + + def list_children(node): + children = [] + for c in node.Children: + children.append(c) + children += list_children(c) + return children + + filtered = [] + for child in list_children(parent): + class_type = str(rt.classOf(child.baseObject)) + if class_type == type_name: + filtered.append(child) + + return filtered diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py new file mode 100644 index 0000000000..7c9e311c2f --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""Collect Render""" +import os +import pyblish.api + +from pymxs import runtime as rt +from openpype.pipeline import get_current_asset_name +from openpype.hosts.max.api.lib import get_max_version +from openpype.hosts.max.api.lib_renderproducts import RenderProducts +from openpype.client import get_last_version_by_subset_name + + +class CollectRender(pyblish.api.InstancePlugin): + """Collect Render for Deadline""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Collect 3dsmax Render Layers" + hosts = ['max'] + families = ["maxrender"] + + def process(self, instance): + context = instance.context + folder = rt.maxFilePath + file = rt.maxFileName + current_file = os.path.join(folder, file) + filepath = current_file.replace("\\", "/") + + context.data['currentFile'] = current_file + asset = get_current_asset_name() + + render_layer_files = RenderProducts().render_product(instance.name) + folder = folder.replace("\\", "/") + + img_format = RenderProducts().image_format() + project_name = context.data["projectName"] + asset_doc = context.data["assetEntity"] + asset_id = asset_doc["_id"] + version_doc = get_last_version_by_subset_name(project_name, + instance.name, + asset_id) + + self.log.debug("version_doc: {0}".format(version_doc)) + version_int = 1 + if version_doc: + version_int += int(version_doc["name"]) + + self.log.debug(f"Setting {version_int} to context.") + context.data["version"] = version_int + + # setup the plugin as 3dsmax for the internal renderer + data = { + "subset": instance.name, + "asset": asset, + "publish": True, + "maxversion": str(get_max_version()), + "imageFormat": img_format, + "family": 'maxrender', + "families": ['maxrender'], + "source": filepath, + "expectedFiles": render_layer_files, + "plugin": "3dsmax", + "frameStart": context.data['frameStart'], + "frameEnd": context.data['frameEnd'], + "version": version_int + } + self.log.info("data: {0}".format(data)) + instance.data.update(data) diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py new file mode 100644 index 0000000000..8c23ff9878 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -0,0 +1,75 @@ +import os +import pyblish.api +from openpype.pipeline import ( + publish, + OptionalPyblishPluginMixin +) +from pymxs import runtime as rt +from openpype.hosts.max.api import ( + maintained_selection, + get_all_children +) + + +class ExtractCameraAlembic(publish.Extractor, + OptionalPyblishPluginMixin): + """ + Extract Camera with AlembicExport + """ + + order = pyblish.api.ExtractorOrder - 0.1 + label = "Extract Alembic Camera" + hosts = ["max"] + families = ["camera"] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + start = float(instance.data.get("frameStartHandle", 1)) + end = float(instance.data.get("frameEndHandle", 1)) + + container = instance.data["instance_node"] + + self.log.info("Extracting Camera ...") + + stagingdir = self.staging_dir(instance) + filename = "{name}.abc".format(**instance.data) + path = os.path.join(stagingdir, filename) + + # We run the render + self.log.info("Writing alembic '%s' to '%s'" % (filename, + stagingdir)) + + export_cmd = ( + f""" +AlembicExport.ArchiveType = #ogawa +AlembicExport.CoordinateSystem = #maya +AlembicExport.StartFrame = {start} +AlembicExport.EndFrame = {end} +AlembicExport.CustomAttributes = true + +exportFile @"{path}" #noPrompt selectedOnly:on using:AlembicExport + + """) + + self.log.debug(f"Executing command: {export_cmd}") + + with maintained_selection(): + # select and export + rt.select(get_all_children(rt.getNodeByName(container))) + rt.execute(export_cmd) + + self.log.info("Performing Extraction ...") + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'abc', + 'ext': 'abc', + 'files': filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + self.log.info("Extracted instance '%s' to: %s" % (instance.name, + path)) diff --git a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py new file mode 100644 index 0000000000..7e92f355ed --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py @@ -0,0 +1,75 @@ +import os +import pyblish.api +from openpype.pipeline import ( + publish, + OptionalPyblishPluginMixin +) +from pymxs import runtime as rt +from openpype.hosts.max.api import ( + maintained_selection, + get_all_children +) + + +class ExtractCameraFbx(publish.Extractor, + OptionalPyblishPluginMixin): + """ + Extract Camera with FbxExporter + """ + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Extract Fbx Camera" + hosts = ["max"] + families = ["camera"] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + container = instance.data["instance_node"] + + self.log.info("Extracting Camera ...") + stagingdir = self.staging_dir(instance) + filename = "{name}.fbx".format(**instance.data) + + filepath = os.path.join(stagingdir, filename) + self.log.info("Writing fbx file '%s' to '%s'" % (filename, + filepath)) + + # Need to export: + # Animation = True + # Cameras = True + # AxisConversionMethod + fbx_export_cmd = ( + f""" + +FBXExporterSetParam "Animation" true +FBXExporterSetParam "Cameras" true +FBXExporterSetParam "AxisConversionMethod" "Animation" +FbxExporterSetParam "UpAxis" "Y" +FbxExporterSetParam "Preserveinstances" true + +exportFile @"{filepath}" #noPrompt selectedOnly:true using:FBXEXP + + """) + + self.log.debug(f"Executing command: {fbx_export_cmd}") + + with maintained_selection(): + # select and export + rt.select(get_all_children(rt.getNodeByName(container))) + rt.execute(fbx_export_cmd) + + self.log.info("Performing Extraction ...") + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + self.log.info("Extracted instance '%s' to: %s" % (instance.name, + filepath)) diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py new file mode 100644 index 0000000000..cacc84c591 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py @@ -0,0 +1,60 @@ +import os +import pyblish.api +from openpype.pipeline import ( + publish, + OptionalPyblishPluginMixin +) +from pymxs import runtime as rt +from openpype.hosts.max.api import ( + maintained_selection, + get_all_children +) + + +class ExtractMaxSceneRaw(publish.Extractor, + OptionalPyblishPluginMixin): + """ + Extract Raw Max Scene with SaveSelected + """ + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Extract Max Scene (Raw)" + hosts = ["max"] + families = ["camera"] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + container = instance.data["instance_node"] + + # publish the raw scene for camera + self.log.info("Extracting Raw Max Scene ...") + + stagingdir = self.staging_dir(instance) + filename = "{name}.max".format(**instance.data) + + max_path = os.path.join(stagingdir, filename) + self.log.info("Writing max file '%s' to '%s'" % (filename, + max_path)) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + # saving max scene + with maintained_selection(): + # need to figure out how to select the camera + rt.select(get_all_children(rt.getNodeByName(container))) + rt.execute(f'saveNodes selection "{max_path}" quiet:true') + + self.log.info("Performing Extraction ...") + + representation = { + 'name': 'max', + 'ext': 'max', + 'files': filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + self.log.info("Extracted instance '%s' to: %s" % (instance.name, + max_path)) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index 904c1656da..75d8a7972c 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -51,7 +51,7 @@ class ExtractAlembic(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Pointcache" hosts = ["max"] - families = ["pointcache", "camera"] + families = ["pointcache"] def process(self, instance): start = float(instance.data.get("frameStartHandle", 1)) diff --git a/openpype/hosts/max/plugins/publish/validate_camera_contents.py b/openpype/hosts/max/plugins/publish/validate_camera_contents.py new file mode 100644 index 0000000000..c81e28a61f --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_camera_contents.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +from pymxs import runtime as rt + + +class ValidateCameraContent(pyblish.api.InstancePlugin): + """Validates Camera instance contents. + + A Camera instance may only hold a SINGLE camera's transform + """ + + order = pyblish.api.ValidatorOrder + families = ["camera"] + hosts = ["max"] + label = "Camera Contents" + camera_type = ["$Free_Camera", "$Target_Camera", + "$Physical_Camera", "$Target"] + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError("Camera instance must only include" + "camera (and camera target)") + + def get_invalid(self, instance): + """ + Get invalid nodes if the instance is not camera + """ + invalid = list() + container = instance.data["instance_node"] + self.log.info("Validating look content for " + "{}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + # to avoid Attribute Error from pymxs wrapper + sel_tmp = str(sel) + found = False + for cam in self.camera_type: + if sel_tmp.startswith(cam): + found = True + break + if not found: + self.log.error("Camera not found") + invalid.append(sel) + return invalid diff --git a/openpype/hosts/max/startup/startup.ms b/openpype/hosts/max/startup/startup.ms index aee40eb6bc..b80ead4b74 100644 --- a/openpype/hosts/max/startup/startup.ms +++ b/openpype/hosts/max/startup/startup.ms @@ -2,8 +2,11 @@ ( local sysPath = dotNetClass "System.IO.Path" local sysDir = dotNetClass "System.IO.Directory" - local localScript = getThisScriptFilename() + local localScript = getThisScriptFilename() local startup = sysPath.Combine (sysPath.GetDirectoryName localScript) "startup.py" + local pythonpath = systemTools.getEnvVariable "MAX_PYTHONPATH" + systemTools.setEnvVariable "PYTHONPATH" pythonpath + python.ExecuteFile startup ) \ No newline at end of file diff --git a/openpype/hosts/max/startup/startup.py b/openpype/hosts/max/startup/startup.py index 37bcef5db1..0d3135a16f 100644 --- a/openpype/hosts/max/startup/startup.py +++ b/openpype/hosts/max/startup/startup.py @@ -1,4 +1,13 @@ # -*- coding: utf-8 -*- +import os +import sys + +# this might happen in some 3dsmax version where PYTHONPATH isn't added +# to sys.path automatically +for path in os.environ["PYTHONPATH"].split(os.pathsep): + if path and path not in sys.path: + sys.path.append(path) + from openpype.hosts.max.api import MaxHost from openpype.pipeline import install_host diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index 4a36406632..018340d86c 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -57,68 +57,6 @@ def edit_shader_definitions(): window.show() -def reset_frame_range(): - """Set frame range to current asset""" - # Set FPS first - fps = {15: 'game', - 24: 'film', - 25: 'pal', - 30: 'ntsc', - 48: 'show', - 50: 'palf', - 60: 'ntscf', - 23.98: '23.976fps', - 23.976: '23.976fps', - 29.97: '29.97fps', - 47.952: '47.952fps', - 47.95: '47.952fps', - 59.94: '59.94fps', - 44100: '44100fps', - 48000: '48000fps' - }.get(float(legacy_io.Session.get("AVALON_FPS", 25)), "pal") - - cmds.currentUnit(time=fps) - - # Set frame start/end - project_name = legacy_io.active_project() - asset_name = legacy_io.Session["AVALON_ASSET"] - asset = get_asset_by_name(project_name, asset_name) - - 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: - cmds.warning("No edit information found for %s" % asset_name) - 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 - - frame_start -= int(handle_start) - frame_end += int(handle_end) - - 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) - - def _resolution_from_document(doc): if not doc or "data" not in doc: print("Entered document is not valid. \"{}\"".format(str(doc))) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 25842a4776..954576f02e 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -4,7 +4,7 @@ import os import sys import platform import uuid -import math +import re import json import logging @@ -14,7 +14,7 @@ from math import ceil from six import string_types from maya import cmds, mel -import maya.api.OpenMaya as om +from maya.api import OpenMaya from openpype.client import ( get_project, @@ -33,7 +33,6 @@ from openpype.pipeline import ( registered_host, ) from openpype.pipeline.context_tools import get_current_project_asset -from .commands import reset_frame_range self = sys.modules[__name__] @@ -254,11 +253,6 @@ def read(node): return data -def _get_mel_global(name): - """Return the value of a mel global variable""" - return mel.eval("$%s = $%s;" % (name, name)) - - def matrix_equals(a, b, tolerance=1e-10): """ Compares two matrices with an imperfection tolerance @@ -408,9 +402,9 @@ def lsattrs(attrs): """ - dep_fn = om.MFnDependencyNode() - dag_fn = om.MFnDagNode() - selection_list = om.MSelectionList() + dep_fn = OpenMaya.MFnDependencyNode() + dag_fn = OpenMaya.MFnDagNode() + selection_list = OpenMaya.MSelectionList() first_attr = next(iter(attrs)) @@ -424,7 +418,7 @@ def lsattrs(attrs): matches = set() for i in range(selection_list.length()): node = selection_list.getDependNode(i) - if node.hasFn(om.MFn.kDagNode): + if node.hasFn(OpenMaya.MFn.kDagNode): fn_node = dag_fn.setObject(node) full_path_names = [path.fullPathName() for path in fn_node.getAllPaths()] @@ -624,15 +618,15 @@ class delete_after(object): cmds.delete(self._nodes) +def get_current_renderlayer(): + return cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) + + def get_renderer(layer): with renderlayer(layer): return cmds.getAttr("defaultRenderGlobals.currentRenderer") -def get_current_renderlayer(): - return cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) - - @contextlib.contextmanager def no_undo(flush=False): """Disable the undo queue during the context @@ -873,11 +867,11 @@ def maintained_selection_api(): Warning: This is *not* added to the undo stack. """ - original = om.MGlobal.getActiveSelectionList() + original = OpenMaya.MGlobal.getActiveSelectionList() try: yield finally: - om.MGlobal.setActiveSelectionList(original) + OpenMaya.MGlobal.setActiveSelectionList(original) @contextlib.contextmanager @@ -1287,11 +1281,11 @@ def get_id(node): if node is None: return - sel = om.MSelectionList() + sel = OpenMaya.MSelectionList() sel.add(node) api_node = sel.getDependNode(0) - fn = om.MFnDependencyNode(api_node) + fn = OpenMaya.MFnDependencyNode(api_node) if not fn.hasAttribute("cbId"): return @@ -1373,27 +1367,6 @@ def set_id(node, unique_id, overwrite=False): cmds.setAttr(attr, unique_id, type="string") -# endregion ID -def get_reference_node(path): - """ - Get the reference node when the path is found being used in a reference - Args: - path (str): the file path to check - - Returns: - node (str): name of the reference node in question - """ - try: - node = cmds.file(path, query=True, referenceNode=True) - except RuntimeError: - log.debug('File is not referenced : "{}"'.format(path)) - return - - reference_path = cmds.referenceQuery(path, filename=True) - if os.path.normpath(path) == os.path.normpath(reference_path): - return node - - def set_attribute(attribute, value, node): """Adjust attributes based on the value from the attribute data @@ -1995,8 +1968,6 @@ def get_id_from_sibling(node, history_only=True): return first_id - -# Project settings def set_scene_fps(fps, update=True): """Set FPS from project configuration @@ -2009,30 +1980,23 @@ def set_scene_fps(fps, update=True): """ - fps_mapping = {'15': 'game', - '24': 'film', - '25': 'pal', - '30': 'ntsc', - '48': 'show', - '50': 'palf', - '60': 'ntscf', - '23.98': '23.976fps', - '23.976': '23.976fps', - '29.97': '29.97fps', - '47.952': '47.952fps', - '47.95': '47.952fps', - '59.94': '59.94fps', - '44100': '44100fps', - '48000': '48000fps'} + fps_mapping = { + '15': 'game', + '24': 'film', + '25': 'pal', + '30': 'ntsc', + '48': 'show', + '50': 'palf', + '60': 'ntscf', + '23.976023976023978': '23.976fps', + '29.97002997002997': '29.97fps', + '47.952047952047955': '47.952fps', + '59.94005994005994': '59.94fps', + '44100': '44100fps', + '48000': '48000fps' + } - # pull from mapping - # this should convert float string to float and int to int - # so 25.0 is converted to 25, but 23.98 will be still float. - dec, ipart = math.modf(fps) - if dec == 0.0: - fps = int(ipart) - - unit = fps_mapping.get(str(fps), None) + unit = fps_mapping.get(str(convert_to_maya_fps(fps)), None) if unit is None: raise ValueError("Unsupported FPS value: `%s`" % fps) @@ -2099,6 +2063,67 @@ def set_scene_resolution(width, height, pixelAspect): cmds.setAttr("%s.pixelAspect" % control_node, pixelAspect) +def get_frame_range(): + """Get the current assets frame range and handles.""" + + # Set frame start/end + project_name = legacy_io.active_project() + asset_name = legacy_io.Session["AVALON_ASSET"] + asset = get_asset_by_name(project_name, asset_name) + + 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: + cmds.warning("No edit information found for %s" % asset_name) + 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(): + """Set frame range to current asset""" + + 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) + + cmds.setAttr("defaultRenderGlobals.startFrame", frame_start) + cmds.setAttr("defaultRenderGlobals.endFrame", frame_end) + + def reset_scene_resolution(): """Apply the scene resolution from the project definition @@ -2150,7 +2175,9 @@ def set_context_settings(): asset_data = asset_doc.get("data", {}) # Set project fps - fps = asset_data.get("fps", project_data.get("fps", 25)) + fps = convert_to_maya_fps( + asset_data.get("fps", project_data.get("fps", 25)) + ) legacy_io.Session["AVALON_FPS"] = str(fps) set_scene_fps(fps) @@ -2172,15 +2199,12 @@ def validate_fps(): """ - fps = get_current_project_asset(fields=["data.fps"])["data"]["fps"] - # TODO(antirotor): This is hack as for framerates having multiple - # decimal places. FTrack is ceiling decimal values on - # fps to two decimal places but Maya 2019+ is reporting those fps - # with much higher resolution. As we currently cannot fix Ftrack - # rounding, we have to round those numbers coming from Maya. - current_fps = float_round(mel.eval('currentTimeUnitToFPS()'), 2) + expected_fps = convert_to_maya_fps( + get_current_project_asset(fields=["data.fps"])["data"]["fps"] + ) + current_fps = mel.eval('currentTimeUnitToFPS()') - fps_match = current_fps == fps + fps_match = current_fps == expected_fps if not fps_match and not IS_HEADLESS: from openpype.widgets import popup @@ -2189,14 +2213,19 @@ def validate_fps(): dialog = popup.PopupUpdateKeys(parent=parent) dialog.setModal(True) dialog.setWindowTitle("Maya scene does not match project FPS") - dialog.setMessage("Scene %i FPS does not match project %i FPS" % - (current_fps, fps)) + dialog.setMessage( + "Scene {} FPS does not match project {} FPS".format( + current_fps, expected_fps + ) + ) dialog.setButtonText("Fix") # Set new text for button (add optional argument for the popup?) toggle = dialog.widgets["toggle"] update = toggle.isChecked() - dialog.on_clicked_state.connect(lambda: set_scene_fps(fps, update)) + dialog.on_clicked_state.connect( + lambda: set_scene_fps(expected_fps, update) + ) dialog.show() @@ -3324,15 +3353,15 @@ def iter_visible_nodes_in_range(nodes, start, end): @memodict def get_visibility_mplug(node): """Return api 2.0 MPlug with cached memoize decorator""" - sel = om.MSelectionList() + sel = OpenMaya.MSelectionList() sel.add(node) dag = sel.getDagPath(0) - return om.MFnDagNode(dag).findPlug("visibility", True) + return OpenMaya.MFnDagNode(dag).findPlug("visibility", True) @contextlib.contextmanager def dgcontext(mtime): """MDGContext context manager""" - context = om.MDGContext(mtime) + context = OpenMaya.MDGContext(mtime) try: previous = context.makeCurrent() yield context @@ -3341,9 +3370,9 @@ def iter_visible_nodes_in_range(nodes, start, end): # We skip the first frame as we already used that frame to check for # overall visibilities. And end+1 to include the end frame. - scene_units = om.MTime.uiUnit() + scene_units = OpenMaya.MTime.uiUnit() for frame in range(start + 1, end + 1): - mtime = om.MTime(frame, unit=scene_units) + mtime = OpenMaya.MTime(frame, unit=scene_units) # Build little cache so we don't query the same MPlug's value # again if it was checked on this frame and also is a dependency @@ -3379,3 +3408,200 @@ def iter_visible_nodes_in_range(nodes, start, end): def get_attribute_input(attr): connections = cmds.listConnections(attr, plugs=True, destination=False) return connections[0] if connections else None + + +def convert_to_maya_fps(fps): + """Convert any fps to supported Maya framerates.""" + float_framerates = [ + 23.976023976023978, + # WTF is 29.97 df vs fps? + 29.97002997002997, + 47.952047952047955, + 59.94005994005994 + ] + # 44100 fps evaluates as 41000.0. Why? Omitting for now. + int_framerates = [ + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 12, + 15, + 16, + 20, + 24, + 25, + 30, + 40, + 48, + 50, + 60, + 75, + 80, + 90, + 100, + 120, + 125, + 150, + 200, + 240, + 250, + 300, + 375, + 400, + 500, + 600, + 750, + 1200, + 1500, + 2000, + 3000, + 6000, + 48000 + ] + + # If input fps is a whole number we'll return. + if float(fps).is_integer(): + # Validate fps is part of Maya's fps selection. + if int(fps) not in int_framerates: + raise ValueError( + "Framerate \"{}\" is not supported in Maya".format(fps) + ) + return int(fps) + else: + # Differences to supported float frame rates. + differences = [] + for i in float_framerates: + differences.append(abs(i - fps)) + + # Validate difference does not stray too far from supported framerates. + min_difference = min(differences) + min_index = differences.index(min_difference) + supported_framerate = float_framerates[min_index] + if min_difference > 0.1: + raise ValueError( + "Framerate \"{}\" strays too far from any supported framerate" + " in Maya. Closest supported framerate is \"{}\"".format( + fps, supported_framerate + ) + ) + + return supported_framerate + + +def write_xgen_file(data, filepath): + """Overwrites data in .xgen files. + + Quite naive approach to mainly overwrite "xgDataPath" and "xgProjectPath". + + Args: + data (dict): Dictionary of key, value. Key matches with xgen file. + For example: + {"xgDataPath": "some/path"} + filepath (string): Absolute path of .xgen file. + """ + # Generate regex lookup for line to key basically + # match any of the keys in `\t{key}\t\t` + keys = "|".join(re.escape(key) for key in data.keys()) + re_keys = re.compile("^\t({})\t\t".format(keys)) + + lines = [] + with open(filepath, "r") as f: + for line in f: + match = re_keys.match(line) + if match: + key = match.group(1) + value = data[key] + line = "\t{}\t\t{}\n".format(key, value) + + lines.append(line) + + with open(filepath, "w") as f: + f.writelines(lines) + + +def get_color_management_preferences(): + """Get and resolve OCIO preferences.""" + data = { + # Is color management enabled. + "enabled": cmds.colorManagementPrefs( + query=True, cmEnabled=True + ), + "rendering_space": cmds.colorManagementPrefs( + query=True, renderingSpaceName=True + ), + "output_transform": cmds.colorManagementPrefs( + query=True, outputTransformName=True + ), + "output_transform_enabled": cmds.colorManagementPrefs( + query=True, outputTransformEnabled=True + ), + "view_transform": cmds.colorManagementPrefs( + query=True, viewTransformName=True + ) + } + + # Split view and display from view_transform. view_transform comes in + # format of "{view} ({display})". + regex = re.compile(r"^(?P.+) \((?P.+)\)$") + match = regex.match(data["view_transform"]) + data.update({ + "display": match.group("display"), + "view": match.group("view") + }) + + # Get config absolute path. + path = cmds.colorManagementPrefs( + query=True, configFilePath=True + ) + + # The OCIO config supports a custom token. + maya_resources_token = "" + maya_resources_path = OpenMaya.MGlobal.getAbsolutePathToResources() + path = path.replace(maya_resources_token, maya_resources_path) + + data["config"] = path + + return data + + +def get_color_management_output_transform(): + preferences = get_color_management_preferences() + colorspace = preferences["rendering_space"] + if preferences["output_transform_enabled"]: + colorspace = preferences["output_transform"] + return colorspace + + +def len_flattened(components): + """Return the length of the list as if it was flattened. + + Maya will return consecutive components as a single entry + when requesting with `maya.cmds.ls` without the `flatten` + flag. Though enabling `flatten` on a large list (e.g. millions) + will result in a slow result. This command will return the amount + of entries in a non-flattened list by parsing the result with + regex. + + Args: + components (list): The non-flattened components. + + Returns: + int: The amount of entries. + + """ + assert isinstance(components, (list, tuple)) + n = 0 + + pattern = re.compile(r"\[(\d+):(\d+)\]") + for c in components: + match = pattern.search(c) + if match: + start, end = match.groups() + n += int(end) - int(start) + 1 + else: + n += 1 + return n diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index c54e3ab3e0..a54256c59a 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -46,6 +46,7 @@ import attr from . import lib from . import lib_rendersetup +from openpype.pipeline.colorspace import get_ocio_config_views from maya import cmds, mel @@ -127,6 +128,7 @@ class RenderProduct(object): """ productName = attr.ib() ext = attr.ib() # extension + colorspace = attr.ib() # colorspace aov = attr.ib(default=None) # source aov driver = attr.ib(default=None) # source driver multipart = attr.ib(default=False) # multichannel file @@ -196,12 +198,18 @@ class ARenderProducts: """Constructor.""" self.layer = layer self.render_instance = render_instance - self.multipart = False + self.multipart = self.get_multipart() # Initialize self.layer_data = self._get_layer_data() self.layer_data.products = self.get_render_products() + def get_multipart(self): + raise NotImplementedError( + "The render product implementation does not have a " + "\"get_multipart\" method." + ) + def has_camera_token(self): # type: () -> bool """Check if camera token is in image prefix. @@ -344,7 +352,6 @@ class ARenderProducts: separator = file_prefix[matches[0].end(1):matches[1].start(1)] return separator - def _get_layer_data(self): # type: () -> LayerMetadata # ______________________________________________ @@ -531,16 +538,20 @@ class RenderProductsArnold(ARenderProducts): return prefix - def _get_aov_render_products(self, aov, cameras=None): - """Return all render products for the AOV""" - - products = [] - aov_name = self._get_attr(aov, "name") + def get_multipart(self): multipart = False multilayer = bool(self._get_attr("defaultArnoldDriver.multipart")) merge_AOVs = bool(self._get_attr("defaultArnoldDriver.mergeAOVs")) if multilayer or merge_AOVs: multipart = True + + return multipart + + def _get_aov_render_products(self, aov, cameras=None): + """Return all render products for the AOV""" + + products = [] + aov_name = self._get_attr(aov, "name") ai_drivers = cmds.listConnections("{}.outputs".format(aov), source=True, destination=False, @@ -553,6 +564,9 @@ class RenderProductsArnold(ARenderProducts): ] for ai_driver in ai_drivers: + colorspace = self._get_colorspace( + ai_driver + ".colorManagement" + ) # todo: check aiAOVDriver.prefix as it could have # a custom path prefix set for this driver @@ -590,12 +604,15 @@ class RenderProductsArnold(ARenderProducts): global_aov = self._get_attr(aov, "globalAov") if global_aov: for camera in cameras: - product = RenderProduct(productName=name, - ext=ext, - aov=aov_name, - driver=ai_driver, - multipart=multipart, - camera=camera) + product = RenderProduct( + productName=name, + ext=ext, + aov=aov_name, + driver=ai_driver, + multipart=self.multipart, + camera=camera, + colorspace=colorspace + ) products.append(product) all_light_groups = self._get_attr(aov, "lightGroups") @@ -603,13 +620,16 @@ class RenderProductsArnold(ARenderProducts): # All light groups is enabled. A single multipart # Render Product for camera in cameras: - product = RenderProduct(productName=name + "_lgroups", - ext=ext, - aov=aov_name, - driver=ai_driver, - # Always multichannel output - multipart=True, - camera=camera) + product = RenderProduct( + productName=name + "_lgroups", + ext=ext, + aov=aov_name, + driver=ai_driver, + # Always multichannel output + multipart=True, + camera=camera, + colorspace=colorspace + ) products.append(product) else: value = self._get_attr(aov, "lightGroupsList") @@ -625,12 +645,36 @@ class RenderProductsArnold(ARenderProducts): aov=aov_name, driver=ai_driver, ext=ext, - camera=camera + camera=camera, + colorspace=colorspace ) products.append(product) return products + def _get_colorspace(self, attribute): + """Resolve colorspace from Arnold settings.""" + + def _view_transform(): + preferences = lib.get_color_management_preferences() + views_data = get_ocio_config_views(preferences["config"]) + view_data = views_data[ + "{}/{}".format(preferences["display"], preferences["view"]) + ] + return view_data["colorspace"] + + def _raw(): + preferences = lib.get_color_management_preferences() + return preferences["rendering_space"] + + resolved_values = { + "Raw": _raw, + "Use View Transform": _view_transform, + # Default. Same as Maya Preferences. + "Use Output Transform": lib.get_color_management_output_transform + } + return resolved_values[self._get_attr(attribute)]() + def get_render_products(self): """Get all AOVs. @@ -659,11 +703,19 @@ class RenderProductsArnold(ARenderProducts): ] default_ext = self._get_attr("defaultRenderGlobals.imfPluginKey") - beauty_products = [RenderProduct( - productName="beauty", - ext=default_ext, - driver="defaultArnoldDriver", - camera=camera) for camera in cameras] + colorspace = self._get_colorspace( + "defaultArnoldDriver.colorManagement" + ) + beauty_products = [ + RenderProduct( + productName="beauty", + ext=default_ext, + driver="defaultArnoldDriver", + camera=camera, + colorspace=colorspace + ) for camera in cameras + ] + # AOVs > Legacy > Maya Render View > Mode aovs_enabled = bool( self._get_attr("defaultArnoldRenderOptions.aovMode") @@ -731,6 +783,14 @@ class RenderProductsVray(ARenderProducts): renderer = "vray" + def get_multipart(self): + multipart = False + image_format = self._get_attr("vraySettings.imageFormatStr") + if image_format == "exr (multichannel)": + multipart = True + + return multipart + def get_renderer_prefix(self): # type: () -> str """Get image prefix for V-Ray. @@ -804,23 +864,29 @@ class RenderProductsVray(ARenderProducts): if not dont_save_rgb: for camera in cameras: products.append( - RenderProduct(productName="", - ext=default_ext, - camera=camera)) + RenderProduct( + productName="", + ext=default_ext, + camera=camera, + colorspace=lib.get_color_management_output_transform(), + multipart=self.multipart + ) + ) # separate alpha file separate_alpha = self._get_attr("vraySettings.separateAlpha") if separate_alpha: for camera in cameras: products.append( - RenderProduct(productName="Alpha", - ext=default_ext, - camera=camera) + RenderProduct( + productName="Alpha", + ext=default_ext, + camera=camera, + multipart=self.multipart + ) ) - - if image_format_str == "exr (multichannel)": + if self.multipart: # AOVs are merged in m-channel file, only main layer is rendered - self.multipart = True return products # handle aovs from references @@ -858,10 +924,13 @@ class RenderProductsVray(ARenderProducts): aov_name = self._get_vray_aov_name(aov) for camera in cameras: - product = RenderProduct(productName=aov_name, - ext=default_ext, - aov=aov, - camera=camera) + product = RenderProduct( + productName=aov_name, + ext=default_ext, + aov=aov, + camera=camera, + colorspace=lib.get_color_management_output_transform() + ) products.append(product) return products @@ -979,6 +1048,34 @@ class RenderProductsRedshift(ARenderProducts): renderer = "redshift" unmerged_aovs = {"Cryptomatte"} + 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 + # replacement. + if not product.multipart and product.driver: + file_prefix = self._get_attr(product.driver + ".filePrefix") + self.layer_data.filePrefix = file_prefix.replace( + "/", + "//" + ) + + return super(RenderProductsRedshift, self).get_files(product) + + def get_multipart(self): + # For Redshift we don't directly return upon forcing multilayer + # due to some AOVs still being written into separate files, + # like Cryptomatte. + # AOVs are merged in multi-channel file + multipart = False + force_layer = bool( + self._get_attr("redshiftOptions.exrForceMultilayer") + ) + if force_layer: + multipart = True + + return multipart + def get_renderer_prefix(self): """Get image prefix for Redshift. @@ -1018,16 +1115,6 @@ class RenderProductsRedshift(ARenderProducts): for c in self.get_renderable_cameras() ] - # For Redshift we don't directly return upon forcing multilayer - # due to some AOVs still being written into separate files, - # like Cryptomatte. - # AOVs are merged in multi-channel file - multipart = False - force_layer = bool(self._get_attr("redshiftOptions.exrForceMultilayer")) # noqa - exMultipart = bool(self._get_attr("redshiftOptions.exrMultipart")) - if exMultipart or force_layer: - multipart = True - # Get Redshift Extension from image format image_format = self._get_attr("redshiftOptions.imageFormat") # integer ext = mel.eval("redshiftGetImageExtension(%i)" % image_format) @@ -1049,7 +1136,7 @@ class RenderProductsRedshift(ARenderProducts): continue aov_type = self._get_attr(aov, "aovType") - if multipart and aov_type not in self.unmerged_aovs: + if self.multipart and aov_type not in self.unmerged_aovs: continue # Any AOVs that still get processed, like Cryptomatte @@ -1084,8 +1171,9 @@ class RenderProductsRedshift(ARenderProducts): productName=aov_light_group_name, aov=aov_name, ext=ext, - multipart=multipart, - camera=camera) + multipart=False, + camera=camera, + driver=aov) products.append(product) if light_groups: @@ -1098,8 +1186,9 @@ class RenderProductsRedshift(ARenderProducts): product = RenderProduct(productName=aov_name, aov=aov_name, ext=ext, - multipart=multipart, - camera=camera) + multipart=False, + camera=camera, + driver=aov) products.append(product) # When a Beauty AOV is added manually, it will be rendered as @@ -1114,7 +1203,7 @@ class RenderProductsRedshift(ARenderProducts): products.insert(0, RenderProduct(productName=beauty_name, ext=ext, - multipart=multipart, + multipart=self.multipart, camera=camera)) return products @@ -1132,6 +1221,11 @@ class RenderProductsRenderman(ARenderProducts): """ renderer = "renderman" + unmerged_aovs = {"PxrCryptomatte"} + + def get_multipart(self): + # Implemented as display specific in "get_render_products". + return False def get_render_products(self): """Get all AOVs. @@ -1181,6 +1275,17 @@ class RenderProductsRenderman(ARenderProducts): if not display_types.get(display["driverNode"]["type"]): continue + has_cryptomatte = cmds.ls(type=self.unmerged_aovs) + matte_enabled = False + if has_cryptomatte: + for cryptomatte in has_cryptomatte: + cryptomatte_aov = cryptomatte + matte_name = "cryptomatte" + rman_globals = cmds.listConnections(cryptomatte + + ".message") + if rman_globals: + matte_enabled = True + aov_name = name if aov_name == "rmanDefaultDisplay": aov_name = "beauty" @@ -1199,6 +1304,15 @@ class RenderProductsRenderman(ARenderProducts): camera=camera, multipart=True ) + + if has_cryptomatte and matte_enabled: + cryptomatte = RenderProduct( + productName=matte_name, + aov=cryptomatte_aov, + ext=extensions, + camera=camera, + multipart=True + ) else: # this code should handle the case where no multipart # capable format is selected. But since it involves @@ -1218,6 +1332,9 @@ class RenderProductsRenderman(ARenderProducts): products.append(product) + if has_cryptomatte and matte_enabled: + products.append(cryptomatte) + return products def get_files(self, product): @@ -1249,6 +1366,10 @@ class RenderProductsMayaHardware(ARenderProducts): {"label": "EXR(exr)", "index": 40, "extension": "exr"} ] + def get_multipart(self): + # MayaHardware does not support multipart EXRs. + return False + def _get_extension(self, value): result = None if isinstance(value, int): @@ -1293,7 +1414,12 @@ class RenderProductsMayaHardware(ARenderProducts): products = [] for cam in self.get_renderable_cameras(): - product = RenderProduct(productName="beauty", ext=ext, camera=cam) + product = RenderProduct( + productName="beauty", + ext=ext, + camera=cam, + colorspace=lib.get_color_management_output_transform() + ) products.append(product) return products diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py index 5161141ef9..f19deb0351 100644 --- a/openpype/hosts/maya/api/lib_rendersettings.py +++ b/openpype/hosts/maya/api/lib_rendersettings.py @@ -14,7 +14,7 @@ from openpype.settings import ( from openpype.pipeline import legacy_io from openpype.pipeline import CreatorError from openpype.pipeline.context_tools import get_current_project_asset -from openpype.hosts.maya.api.commands import reset_frame_range +from openpype.hosts.maya.api.lib import reset_frame_range class RenderSettings(object): @@ -22,17 +22,26 @@ class RenderSettings(object): _image_prefix_nodes = { 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', - 'renderman': 'defaultRenderGlobals.imageFilePrefix', - 'redshift': 'defaultRenderGlobals.imageFilePrefix' + 'renderman': 'rmanGlobals.imageFileFormat', + 'redshift': 'defaultRenderGlobals.imageFilePrefix', + 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix' } _image_prefixes = { 'vray': get_current_project_settings()["maya"]["RenderSettings"]["vray_renderer"]["image_prefix"], # noqa 'arnold': get_current_project_settings()["maya"]["RenderSettings"]["arnold_renderer"]["image_prefix"], # noqa - 'renderman': '//{aov_separator}', + 'renderman': get_current_project_settings()["maya"]["RenderSettings"]["renderman_renderer"]["image_prefix"], # noqa 'redshift': get_current_project_settings()["maya"]["RenderSettings"]["redshift_renderer"]["image_prefix"] # noqa } + # Renderman only + _image_dir = { + 'renderman': get_current_project_settings()["maya"]["RenderSettings"]["renderman_renderer"]["image_dir"], # noqa + 'cryptomatte': get_current_project_settings()["maya"]["RenderSettings"]["renderman_renderer"]["cryptomatte_dir"], # noqa + 'imageDisplay': get_current_project_settings()["maya"]["RenderSettings"]["renderman_renderer"]["imageDisplay_dir"], # noqa + "watermark": get_current_project_settings()["maya"]["RenderSettings"]["renderman_renderer"]["watermark_dir"] # noqa + } + _aov_chars = { "dot": ".", "dash": "-", @@ -81,7 +90,6 @@ class RenderSettings(object): prefix, type="string") # noqa else: print("{0} isn't a supported renderer to autoset settings.".format(renderer)) # noqa - # TODO: handle not having res values in the doc width = asset_doc["data"].get("resolutionWidth") height = asset_doc["data"].get("resolutionHeight") @@ -97,6 +105,13 @@ class RenderSettings(object): self._set_redshift_settings(width, height) mel.eval("redshiftUpdateActiveAovList") + if renderer == "renderman": + image_dir = self._image_dir["renderman"] + cmds.setAttr("rmanGlobals.imageOutputDir", + image_dir, type="string") + self._set_renderman_settings(width, height, + aov_separator) + def _set_arnold_settings(self, width, height): """Sets settings for Arnold.""" from mtoa.core import createOptions # noqa @@ -202,6 +217,66 @@ class RenderSettings(object): cmds.setAttr("defaultResolution.height", height) self._additional_attribs_setter(additional_options) + def _set_renderman_settings(self, width, height, aov_separator): + """Sets settings for Renderman""" + rman_render_presets = ( + self._project_settings + ["maya"] + ["RenderSettings"] + ["renderman_renderer"] + ) + display_filters = rman_render_presets["display_filters"] + d_filters_number = len(display_filters) + for i in range(d_filters_number): + d_node = cmds.ls(typ=display_filters[i]) + if len(d_node) > 0: + filter_nodes = d_node[0] + else: + filter_nodes = cmds.createNode(display_filters[i]) + + cmds.connectAttr(filter_nodes + ".message", + "rmanGlobals.displayFilters[%i]" % i, + force=True) + if filter_nodes.startswith("PxrImageDisplayFilter"): + imageDisplay_dir = self._image_dir["imageDisplay"] + imageDisplay_dir = imageDisplay_dir.replace("{aov_separator}", + aov_separator) + cmds.setAttr(filter_nodes + ".filename", + imageDisplay_dir, type="string") + + sample_filters = rman_render_presets["sample_filters"] + s_filters_number = len(sample_filters) + for n in range(s_filters_number): + s_node = cmds.ls(typ=sample_filters[n]) + if len(s_node) > 0: + filter_nodes = s_node[0] + else: + filter_nodes = cmds.createNode(sample_filters[n]) + + cmds.connectAttr(filter_nodes + ".message", + "rmanGlobals.sampleFilters[%i]" % n, + force=True) + + if filter_nodes.startswith("PxrCryptomatte"): + matte_dir = self._image_dir["cryptomatte"] + matte_dir = matte_dir.replace("{aov_separator}", + aov_separator) + cmds.setAttr(filter_nodes + ".filename", + matte_dir, type="string") + elif filter_nodes.startswith("PxrWatermarkFilter"): + watermark_dir = self._image_dir["watermark"] + watermark_dir = watermark_dir.replace("{aov_separator}", + aov_separator) + cmds.setAttr(filter_nodes + ".filename", + watermark_dir, type="string") + + additional_options = rman_render_presets["additional_options"] + + self._set_global_output_settings() + cmds.setAttr("defaultResolution.width", width) + cmds.setAttr("defaultResolution.height", height) + self._additional_attribs_setter(additional_options) + def _set_vray_settings(self, aov_separator, width, height): # type: (str, int, int) -> None """Sets important settings for Vray.""" diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 67109e9958..0f48a133a6 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -12,7 +12,6 @@ from openpype.pipeline.workfile import BuildWorkfile from openpype.tools.utils import host_tools from openpype.hosts.maya.api import lib, lib_rendersettings from .lib import get_main_window, IS_HEADLESS -from .commands import reset_frame_range from .workfile_template_builder import ( create_placeholder, @@ -50,7 +49,6 @@ def install(): parent="MayaWindow" ) - renderer = cmds.getAttr('defaultRenderGlobals.currentRenderer').lower() # Create context menu context_label = "{}, {}".format( legacy_io.Session["AVALON_ASSET"], @@ -114,7 +112,7 @@ def install(): cmds.menuItem( "Reset Frame Range", - command=lambda *args: reset_frame_range() + command=lambda *args: lib.reset_frame_range() ) cmds.menuItem( diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 3798170671..5323717fa7 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -514,6 +514,9 @@ def check_lock_on_current_file(): # add the lock file when opening the file filepath = current_file() + # Skip if current file is 'untitled' + if not filepath: + return if is_workfile_locked(filepath): # add lockfile dialog @@ -680,10 +683,12 @@ def before_workfile_save(event): def after_workfile_save(event): workfile_name = event["filename"] - if handle_workfile_locks(): - if workfile_name: - if not is_workfile_locked(workfile_name): - create_workfile_lock(workfile_name) + if ( + handle_workfile_locks() + and workfile_name + and not is_workfile_locked(workfile_name) + ): + create_workfile_lock(workfile_name) class MayaDirmap(HostDirmap): diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 82df85a8be..916fddd923 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -300,6 +300,39 @@ class ReferenceLoader(Loader): str(representation["_id"]), type="string") + # When an animation or pointcache gets connected to an Xgen container, + # the compound attribute "xgenContainers" gets created. When animation + # containers gets updated we also need to update the cacheFileName on + # the Xgen collection. + compound_name = "xgenContainers" + if cmds.objExists("{}.{}".format(node, compound_name)): + import xgenm + container_amount = cmds.getAttr( + "{}.{}".format(node, compound_name), size=True + ) + # loop through all compound children + for i in range(container_amount): + attr = "{}.{}[{}].container".format(node, compound_name, i) + objectset = cmds.listConnections(attr)[0] + reference_node = cmds.sets(objectset, query=True)[0] + palettes = cmds.ls( + cmds.referenceQuery(reference_node, nodes=True), + type="xgmPalette" + ) + for palette in palettes: + for description in xgenm.descriptions(palette): + xgenm.setAttr( + "cacheFileName", + path.replace("\\", "/"), + palette, + description, + "SplinePrimitive" + ) + + # Refresh UI and viewport. + de = xgenm.xgGlobal.DescriptionEditor + de.refresh("Full") + def remove(self, container): """Remove an existing `container` from Maya scene diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py index 3416c98793..2f550e787a 100644 --- a/openpype/hosts/maya/api/workfile_template_builder.py +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -2,7 +2,7 @@ import json from maya import cmds -from openpype.pipeline import registered_host +from openpype.pipeline import registered_host, get_current_asset_name from openpype.pipeline.workfile.workfile_template_builder import ( TemplateAlreadyImported, AbstractTemplateBuilder, @@ -41,10 +41,27 @@ class MayaTemplateBuilder(AbstractTemplateBuilder): )) cmds.sets(name=PLACEHOLDER_SET, empty=True) - cmds.file(path, i=True, returnNewNodes=True) + new_nodes = cmds.file(path, i=True, returnNewNodes=True) cmds.setAttr(PLACEHOLDER_SET + ".hiddenInOutliner", True) + imported_sets = cmds.ls(new_nodes, set=True) + if not imported_sets: + return True + + # update imported sets information + asset_name = get_current_asset_name() + for node in imported_sets: + if not cmds.attributeQuery("id", node=node, exists=True): + continue + if cmds.getAttr("{}.id".format(node)) != "pyblish.avalon.instance": + continue + if not cmds.attributeQuery("asset", node=node, exists=True): + continue + + cmds.setAttr( + "{}.asset".format(node), asset_name, type="string") + return True diff --git a/openpype/hosts/maya/plugins/create/create_animation.py b/openpype/hosts/maya/plugins/create/create_animation.py index e54c12315c..a4b6e86598 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_user_defined_attributes = False def __init__(self, *args, **kwargs): super(CreateAnimation, self).__init__(*args, **kwargs) @@ -47,3 +48,6 @@ class CreateAnimation(plugin.Creator): # Default to write normals. self.data["writeNormals"] = True + + value = self.include_user_defined_attributes + self.data["includeUserDefinedAttributes"] = value diff --git a/openpype/hosts/maya/plugins/create/create_ass.py b/openpype/hosts/maya/plugins/create/create_arnold_scene_source.py similarity index 84% rename from openpype/hosts/maya/plugins/create/create_ass.py rename to openpype/hosts/maya/plugins/create/create_arnold_scene_source.py index 935a068ca5..2afb897e94 100644 --- a/openpype/hosts/maya/plugins/create/create_ass.py +++ b/openpype/hosts/maya/plugins/create/create_arnold_scene_source.py @@ -6,7 +6,7 @@ from openpype.hosts.maya.api import ( from maya import cmds -class CreateAss(plugin.Creator): +class CreateArnoldSceneSource(plugin.Creator): """Arnold Scene Source""" name = "ass" @@ -29,7 +29,7 @@ class CreateAss(plugin.Creator): maskOperator = False def __init__(self, *args, **kwargs): - super(CreateAss, self).__init__(*args, **kwargs) + super(CreateArnoldSceneSource, self).__init__(*args, **kwargs) # Add animation data self.data.update(lib.collect_animation_data()) @@ -52,7 +52,7 @@ class CreateAss(plugin.Creator): self.data["maskOperator"] = self.maskOperator def process(self): - instance = super(CreateAss, self).process() + instance = super(CreateArnoldSceneSource, self).process() nodes = [] @@ -61,6 +61,6 @@ class CreateAss(plugin.Creator): cmds.sets(nodes, rm=instance) - assContent = cmds.sets(name="content_SET") - assProxy = cmds.sets(name="proxy_SET", empty=True) + assContent = cmds.sets(name=instance + "_content_SET") + assProxy = cmds.sets(name=instance + "_proxy_SET", empty=True) cmds.sets([assContent, assProxy], forceElement=instance) diff --git a/openpype/hosts/maya/plugins/create/create_pointcache.py b/openpype/hosts/maya/plugins/create/create_pointcache.py index cdec140ea8..1b8d5e6850 100644 --- a/openpype/hosts/maya/plugins/create/create_pointcache.py +++ b/openpype/hosts/maya/plugins/create/create_pointcache.py @@ -1,3 +1,5 @@ +from maya import cmds + from openpype.hosts.maya.api import ( lib, plugin @@ -13,6 +15,7 @@ class CreatePointCache(plugin.Creator): icon = "gears" write_color_sets = False write_face_sets = False + include_user_defined_attributes = False def __init__(self, *args, **kwargs): super(CreatePointCache, self).__init__(*args, **kwargs) @@ -31,9 +34,17 @@ class CreatePointCache(plugin.Creator): self.data["refresh"] = False # Default to suspend refresh. # Add options for custom attributes + value = self.include_user_defined_attributes + self.data["includeUserDefinedAttributes"] = value self.data["attr"] = "" self.data["attrPrefix"] = "" # Default to not send to farm. self.data["farm"] = False self.data["priority"] = 50 + + def process(self): + instance = super(CreatePointCache, self).process() + + assProxy = cmds.sets(name=instance + "_proxy_SET", empty=True) + cmds.sets(assProxy, forceElement=instance) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 8375149442..387b7321b9 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -54,6 +54,7 @@ class CreateRender(plugin.Creator): tileRendering (bool): Instance is set to tile rendering mode. We won't submit actual render, but we'll make publish job to wait for Tile Assembly job done and then publish. + strict_error_checking (bool): Enable/disable error checking on DL See Also: https://pype.club/docs/artist_hosts_maya#creating-basic-render-setup @@ -271,6 +272,9 @@ class CreateRender(plugin.Creator): secondary_pool = pool_setting["secondary_pool"] self.data["secondaryPool"] = self._set_default_pool(pool_names, secondary_pool) + strict_error_checking = maya_submit_dl.get("strict_error_checking", + True) + self.data["strict_error_checking"] = strict_error_checking if muster_enabled: self.log.info(">>> Loading Muster credentials ...") diff --git a/openpype/hosts/maya/plugins/create/create_review.py b/openpype/hosts/maya/plugins/create/create_review.py index ba51ffa009..f1b626c06b 100644 --- a/openpype/hosts/maya/plugins/create/create_review.py +++ b/openpype/hosts/maya/plugins/create/create_review.py @@ -25,16 +25,20 @@ class CreateReview(plugin.Creator): "depth peeling", "alpha cut" ] + useMayaTimeline = True def __init__(self, *args, **kwargs): super(CreateReview, self).__init__(*args, **kwargs) - - # get basic animation data : start / end / handles / steps data = OrderedDict(**self.data) - animation_data = lib.collect_animation_data(fps=True) - for key, value in animation_data.items(): + + # Option for using Maya or asset frame range in settings. + frame_range = lib.get_frame_range() + if self.useMayaTimeline: + frame_range = lib.collect_animation_data(fps=True) + for key, value in frame_range.items(): data[key] = value + data["fps"] = lib.collect_animation_data(fps=True)["fps"] data["review_width"] = self.Width data["review_height"] = self.Height data["isolate"] = self.isolate diff --git a/openpype/hosts/maya/plugins/create/create_vrayproxy.py b/openpype/hosts/maya/plugins/create/create_vrayproxy.py index 5c0365b495..d135073e82 100644 --- a/openpype/hosts/maya/plugins/create/create_vrayproxy.py +++ b/openpype/hosts/maya/plugins/create/create_vrayproxy.py @@ -9,6 +9,9 @@ class CreateVrayProxy(plugin.Creator): family = "vrayproxy" icon = "gears" + vrmesh = True + alembic = True + def __init__(self, *args, **kwargs): super(CreateVrayProxy, self).__init__(*args, **kwargs) @@ -18,3 +21,6 @@ class CreateVrayProxy(plugin.Creator): # Write vertex colors self.data["vertexColors"] = False + + self.data["vrmesh"] = self.vrmesh + self.data["alembic"] = self.alembic diff --git a/openpype/hosts/maya/plugins/create/create_xgen.py b/openpype/hosts/maya/plugins/create/create_xgen.py index 8672c06a1e..70e23cf47b 100644 --- a/openpype/hosts/maya/plugins/create/create_xgen.py +++ b/openpype/hosts/maya/plugins/create/create_xgen.py @@ -2,9 +2,9 @@ from openpype.hosts.maya.api import plugin class CreateXgen(plugin.Creator): - """Xgen interactive export""" + """Xgen""" name = "xgen" - label = "Xgen Interactive" + label = "Xgen" family = "xgen" icon = "pagelines" diff --git a/openpype/hosts/maya/plugins/inventory/connect_geometry.py b/openpype/hosts/maya/plugins/inventory/connect_geometry.py new file mode 100644 index 0000000000..a12487cf7e --- /dev/null +++ b/openpype/hosts/maya/plugins/inventory/connect_geometry.py @@ -0,0 +1,153 @@ +from maya import cmds + +from openpype.pipeline import InventoryAction, get_representation_context +from openpype.hosts.maya.api.lib import get_id + + +class ConnectGeometry(InventoryAction): + """Connect geometries within containers. + + Source container will connect to the target containers, by searching for + matching geometry IDs (cbid). + Source containers are of family; "animation" and "pointcache". + The connection with be done with a live world space blendshape. + """ + + label = "Connect Geometry" + 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 = {} + for container in containers: + family = get_representation_context( + container["representation"] + )["subset"]["data"]["family"] + try: + containers_by_family[family].append(container) + except KeyError: + containers_by_family[family] = [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_object = source_containers[0]["objectName"] + + # Collect matching geometry transforms based cbId attribute. + target_containers = [] + for family, containers in containers_by_family.items(): + if family in ["animation", "pointcache"]: + continue + + target_containers.extend(containers) + + source_data = self.get_container_data(source_object) + matches = [] + node_types = set() + for target_container in target_containers: + target_data = self.get_container_data( + target_container["objectName"] + ) + node_types.update(target_data["node_types"]) + for id, transform in target_data["ids"].items(): + source_match = source_data["ids"].get(id) + if source_match: + matches.append([source_match, transform]) + + # Message user about what is about to happen. + if not matches: + self.display_warning("No matching geometries found.") + return + + message = "Connecting geometries:\n\n" + for match in matches: + message += "{} > {}\n".format(match[0], match[1]) + + choice = self.display_warning(message, show_cancel=True) + if choice is False: + return + + # Setup live worldspace blendshape connection. + for source, target in matches: + blendshape = cmds.blendShape(source, target)[0] + cmds.setAttr(blendshape + ".origin", 0) + cmds.setAttr(blendshape + "." + target.split(":")[-1], 1) + + # Update Xgen if in any of the containers. + if "xgmPalette" in node_types: + cmds.xgmPreview() + + def get_container_data(self, container): + """Collects data about the container nodes. + + Args: + container (dict): Container instance. + + Returns: + data (dict): + "node_types": All node types in container nodes. + "ids": If the node is a mesh, we collect its parent transform + id. + """ + data = {"node_types": set(), "ids": {}} + ref_node = cmds.sets(container, query=True, nodesOnly=True)[0] + for node in cmds.referenceQuery(ref_node, nodes=True): + node_type = cmds.nodeType(node) + data["node_types"].add(node_type) + + # Only interested in mesh transforms for connecting geometry with + # blendshape. + if node_type != "mesh": + continue + + transform = cmds.listRelatives(node, parent=True)[0] + data["ids"][get_id(transform)] = transform + + return data + + def display_warning(self, message, show_cancel=False): + """Show feedback to user. + + Returns: + bool + """ + + from Qt 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/inventory/connect_xgen.py b/openpype/hosts/maya/plugins/inventory/connect_xgen.py new file mode 100644 index 0000000000..933a1b4025 --- /dev/null +++ b/openpype/hosts/maya/plugins/inventory/connect_xgen.py @@ -0,0 +1,168 @@ +from maya import cmds +import xgenm + +from openpype.pipeline import ( + InventoryAction, get_representation_context, get_representation_path +) + + +class ConnectXgen(InventoryAction): + """Connect Xgen with an animation or pointcache. + """ + + label = "Connect Xgen" + 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 = {} + for container in containers: + family = get_representation_context( + container["representation"] + )["subset"]["data"]["family"] + try: + containers_by_family[family].append(container) + except KeyError: + containers_by_family[family] = [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_object = source_container["objectName"] + + # Validate source representation is an alembic. + source_path = get_representation_path( + get_representation_context( + source_container["representation"] + )["representation"] + ).replace("\\", "/") + message = "Animation container \"{}\" is not an alembic:\n{}".format( + source_container["namespace"], source_path + ) + if not source_path.endswith(".abc"): + self.display_warning(message) + return + + # Target containers. + target_containers = [] + for family, containers in containers_by_family.items(): + if family in ["animation", "pointcache"]: + continue + + target_containers.extend(containers) + + # Inform user of connections from source representation to target + # descriptions. + descriptions_data = [] + connections_msg = "" + for target_container in target_containers: + reference_node = cmds.sets( + target_container["objectName"], query=True + )[0] + palettes = cmds.ls( + cmds.referenceQuery(reference_node, nodes=True), + type="xgmPalette" + ) + for palette in palettes: + for description in xgenm.descriptions(palette): + descriptions_data.append([palette, description]) + connections_msg += "\n{}/{}".format(palette, description) + + message = "Connecting \"{}\" to:\n".format( + source_container["namespace"] + ) + message += connections_msg + choice = self.display_warning(message, show_cancel=True) + if choice is False: + return + + # Recreate "xgenContainers" attribute to reset. + compound_name = "xgenContainers" + attr = "{}.{}".format(source_object, compound_name) + if cmds.objExists(attr): + cmds.deleteAttr(attr) + + cmds.addAttr( + source_object, + longName=compound_name, + attributeType="compound", + numberOfChildren=1, + multi=True + ) + + # Connect target containers. + for target_container in target_containers: + cmds.addAttr( + source_object, + longName="container", + attributeType="message", + parent=compound_name + ) + index = target_containers.index(target_container) + cmds.connectAttr( + target_container["objectName"] + ".message", + source_object + ".{}[{}].container".format( + compound_name, index + ) + ) + + # Setup cache on Xgen + object = "SplinePrimitive" + for palette, description in descriptions_data: + xgenm.setAttr("useCache", "true", palette, description, object) + xgenm.setAttr("liveMode", "false", palette, description, object) + xgenm.setAttr( + "cacheFileName", source_path, palette, description, object + ) + + # Refresh UI and viewport. + de = xgenm.xgGlobal.DescriptionEditor + de.refresh("Full") + + def display_warning(self, message, show_cancel=False): + """Show feedback to user. + + Returns: + bool + """ + + from Qt 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 98c8192294..2574624dbb 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -93,7 +93,20 @@ class ImportMayaLoader(load.LoaderPlugin): """ representations = ["ma", "mb", "obj"] - families = ["*"] + families = [ + "model", + "pointcache", + "proxyAbc", + "animation", + "mayaAscii", + "mayaScene", + "setdress", + "layout", + "camera", + "rig", + "camerarig", + "staticMesh" + ] label = "Import" order = 10 diff --git a/openpype/hosts/maya/plugins/load/load_abc_to_standin.py b/openpype/hosts/maya/plugins/load/load_abc_to_standin.py deleted file mode 100644 index 70866a3ba6..0000000000 --- a/openpype/hosts/maya/plugins/load/load_abc_to_standin.py +++ /dev/null @@ -1,132 +0,0 @@ -import os - -from openpype.pipeline import ( - legacy_io, - load, - get_representation_path -) -from openpype.settings import get_project_settings - - -class AlembicStandinLoader(load.LoaderPlugin): - """Load Alembic as Arnold Standin""" - - families = ["animation", "model", "proxyAbc", "pointcache"] - representations = ["abc"] - - label = "Import Alembic as Arnold Standin" - order = -5 - icon = "code-fork" - color = "orange" - - def load(self, context, name, namespace, options): - - import maya.cmds as cmds - import mtoa.ui.arnoldmenu - from openpype.hosts.maya.api.pipeline import containerise - from openpype.hosts.maya.api.lib import unique_namespace - - version = context["version"] - version_data = version.get("data", {}) - family = version["data"]["families"] - self.log.info("version_data: {}\n".format(version_data)) - self.log.info("family: {}\n".format(family)) - frameStart = version_data.get("frameStart", None) - - asset = context["asset"]["name"] - namespace = namespace or unique_namespace( - asset + "_", - prefix="_" if asset[0].isdigit() else "", - suffix="_", - ) - - # Root group - label = "{}:{}".format(namespace, name) - root = cmds.group(name=label, empty=True) - - settings = get_project_settings(os.environ['AVALON_PROJECT']) - colors = settings["maya"]["load"]["colors"] - fps = legacy_io.Session["AVALON_FPS"] - c = colors.get(family[0]) - if c is not None: - r = (float(c[0]) / 255) - g = (float(c[1]) / 255) - b = (float(c[2]) / 255) - cmds.setAttr(root + ".useOutlinerColor", 1) - cmds.setAttr(root + ".outlinerColor", - r, g, b) - - transform_name = label + "_ABC" - - standinShape = cmds.ls(mtoa.ui.arnoldmenu.createStandIn())[0] - standin = cmds.listRelatives(standinShape, parent=True, - typ="transform") - standin = cmds.rename(standin, transform_name) - standinShape = cmds.listRelatives(standin, children=True)[0] - - cmds.parent(standin, root) - - # Set the standin filepath - cmds.setAttr(standinShape + ".dso", self.fname, type="string") - cmds.setAttr(standinShape + ".abcFPS", float(fps)) - - if frameStart is None: - cmds.setAttr(standinShape + ".useFrameExtension", 0) - - elif "model" in family: - cmds.setAttr(standinShape + ".useFrameExtension", 0) - - else: - cmds.setAttr(standinShape + ".useFrameExtension", 1) - - nodes = [root, standin] - self[:] = nodes - - return containerise( - name=name, - namespace=namespace, - nodes=nodes, - context=context, - loader=self.__class__.__name__) - - def update(self, container, representation): - - import pymel.core as pm - - path = get_representation_path(representation) - fps = legacy_io.Session["AVALON_FPS"] - # Update the standin - standins = list() - members = pm.sets(container['objectName'], query=True) - self.log.info("container:{}".format(container)) - for member in members: - shape = member.getShape() - if (shape and shape.type() == "aiStandIn"): - standins.append(shape) - - for standin in standins: - standin.dso.set(path) - standin.abcFPS.set(float(fps)) - if "modelMain" in container['objectName']: - standin.useFrameExtension.set(0) - else: - standin.useFrameExtension.set(1) - - container = pm.PyNode(container["objectName"]) - container.representation.set(str(representation["_id"])) - - def switch(self, container, representation): - self.update(container, representation) - - def remove(self, container): - import maya.cmds as cmds - 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 diff --git a/openpype/hosts/maya/plugins/load/load_arnold_standin.py b/openpype/hosts/maya/plugins/load/load_arnold_standin.py new file mode 100644 index 0000000000..ab69d62ef5 --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_arnold_standin.py @@ -0,0 +1,218 @@ +import os +import clique + +import maya.cmds as cmds +import mtoa.ui.arnoldmenu + +from openpype.settings import get_project_settings +from openpype.pipeline import ( + load, + get_representation_path +) +from openpype.hosts.maya.api.lib import ( + unique_namespace, get_attribute_input, maintained_selection +) +from openpype.hosts.maya.api.pipeline import containerise + + +def is_sequence(files): + sequence = False + collections, remainder = clique.assemble(files) + if collections: + sequence = True + + return sequence + + +class ArnoldStandinLoader(load.LoaderPlugin): + """Load as Arnold standin""" + + families = ["ass", "animation", "model", "proxyAbc", "pointcache"] + representations = ["ass", "abc"] + + label = "Load as Arnold standin" + order = -5 + icon = "code-fork" + color = "orange" + + def load(self, context, name, namespace, options): + version = context['version'] + version_data = version.get("data", {}) + + self.log.info("version_data: {}\n".format(version_data)) + + asset = context['asset']['name'] + namespace = namespace or unique_namespace( + asset + "_", + prefix="_" if asset[0].isdigit() else "", + suffix="_", + ) + + # Root group + label = "{}:{}".format(namespace, name) + root = cmds.group(name=label, empty=True) + + # Set color. + settings = get_project_settings(context["project"]["name"]) + color = settings['maya']['load']['colors'].get('ass') + if color is not None: + cmds.setAttr(root + ".useOutlinerColor", True) + cmds.setAttr( + root + ".outlinerColor", color[0], color[1], color[2] + ) + + with maintained_selection(): + # Create transform with shape + transform_name = label + "_standin" + + standin_shape = mtoa.ui.arnoldmenu.createStandIn() + standin = cmds.listRelatives(standin_shape, parent=True)[0] + standin = cmds.rename(standin, transform_name) + standin_shape = cmds.listRelatives(standin, shapes=True)[0] + + cmds.parent(standin, root) + + # Set the standin filepath + path, operator = self._setup_proxy( + standin_shape, self.fname, namespace + ) + cmds.setAttr(standin_shape + ".dso", path, type="string") + sequence = is_sequence(os.listdir(os.path.dirname(self.fname))) + cmds.setAttr(standin_shape + ".useFrameExtension", sequence) + + nodes = [root, standin] + if operator is not None: + nodes.append(operator) + self[:] = nodes + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__) + + def get_next_free_multi_index(self, attr_name): + """Find the next unconnected multi index at the input attribute.""" + for index in range(10000000): + connection_info = cmds.connectionInfo( + "{}[{}]".format(attr_name, index), + sourceFromDestination=True + ) + if len(connection_info or []) == 0: + return index + + def _get_proxy_path(self, path): + basename_split = os.path.basename(path).split(".") + proxy_basename = ( + basename_split[0] + "_proxy." + ".".join(basename_split[1:]) + ) + proxy_path = "/".join([os.path.dirname(path), proxy_basename]) + return proxy_basename, proxy_path + + def _setup_proxy(self, shape, path, namespace): + proxy_basename, proxy_path = self._get_proxy_path(path) + + options_node = "defaultArnoldRenderOptions" + merge_operator = get_attribute_input(options_node + ".operator") + if merge_operator is None: + merge_operator = cmds.createNode("aiMerge") + cmds.connectAttr( + merge_operator + ".message", options_node + ".operator" + ) + + merge_operator = merge_operator.split(".")[0] + + string_replace_operator = cmds.createNode( + "aiStringReplace", name=namespace + ":string_replace_operator" + ) + node_type = "alembic" if path.endswith(".abc") else "procedural" + cmds.setAttr( + string_replace_operator + ".selection", + "*.(@node=='{}')".format(node_type), + type="string" + ) + cmds.setAttr( + string_replace_operator + ".match", + proxy_basename, + type="string" + ) + cmds.setAttr( + string_replace_operator + ".replace", + os.path.basename(path), + type="string" + ) + + cmds.connectAttr( + string_replace_operator + ".out", + "{}.inputs[{}]".format( + merge_operator, + self.get_next_free_multi_index(merge_operator + ".inputs") + ) + ) + + # We setup the string operator no matter whether there is a proxy or + # not. This makes it easier to update since the string operator will + # always be created. Return original path to use for standin. + if not os.path.exists(proxy_path): + return path, string_replace_operator + + return proxy_path, string_replace_operator + + def update(self, container, representation): + # Update the standin + members = cmds.sets(container['objectName'], query=True) + for member in members: + if cmds.nodeType(member) == "aiStringReplace": + string_replace_operator = member + + shapes = cmds.listRelatives(member, shapes=True) + if not shapes: + continue + if cmds.nodeType(shapes[0]) == "aiStandIn": + standin = shapes[0] + + path = get_representation_path(representation) + 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. + cmds.setAttr( + string_replace_operator + ".match", + "resources/" + proxy_basename, + type="string" + ) + cmds.setAttr( + string_replace_operator + ".replace", + os.path.basename(path), + type="string" + ) + + dso_path = path + if os.path.exists(proxy_path): + dso_path = proxy_path + cmds.setAttr(standin + ".dso", dso_path, type="string") + + sequence = is_sequence(os.listdir(os.path.dirname(path))) + cmds.setAttr(standin + ".useFrameExtension", sequence) + + 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 diff --git a/openpype/hosts/maya/plugins/load/load_ass.py b/openpype/hosts/maya/plugins/load/load_ass.py deleted file mode 100644 index 5db6fc3dfa..0000000000 --- a/openpype/hosts/maya/plugins/load/load_ass.py +++ /dev/null @@ -1,290 +0,0 @@ -import os -import clique - -from openpype.settings import get_project_settings -from openpype.pipeline import ( - load, - get_representation_path -) -import openpype.hosts.maya.api.plugin -from openpype.hosts.maya.api.plugin import get_reference_node -from openpype.hosts.maya.api.lib import ( - maintained_selection, - unique_namespace -) -from openpype.hosts.maya.api.pipeline import containerise - - -class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): - """Load Arnold Proxy as reference""" - - families = ["ass"] - representations = ["ass"] - - label = "Reference .ASS standin with Proxy" - order = -10 - icon = "code-fork" - color = "orange" - - def process_reference(self, context, name, namespace, options): - - import maya.cmds as cmds - import pymel.core as pm - - version = context['version'] - version_data = version.get("data", {}) - - self.log.info("version_data: {}\n".format(version_data)) - - frameStart = version_data.get("frameStart", None) - - try: - family = context["representation"]["context"]["family"] - except ValueError: - family = "ass" - - with maintained_selection(): - - groupName = "{}:{}".format(namespace, name) - path = self.fname - proxyPath_base = os.path.splitext(path)[0] - - if frameStart is not None: - proxyPath_base = os.path.splitext(proxyPath_base)[0] - - publish_folder = os.path.split(path)[0] - files_in_folder = os.listdir(publish_folder) - collections, remainder = clique.assemble(files_in_folder) - - if collections: - hashes = collections[0].padding * '#' - coll = collections[0].format('{head}[index]{tail}') - filename = coll.replace('[index]', hashes) - - path = os.path.join(publish_folder, filename) - - proxyPath = proxyPath_base + ".ma" - - project_name = context["project"]["name"] - file_url = self.prepare_root_value(proxyPath, - project_name) - - nodes = cmds.file(file_url, - namespace=namespace, - reference=True, - returnNewNodes=True, - groupReference=True, - groupName=groupName) - - cmds.makeIdentity(groupName, apply=False, rotate=True, - translate=True, scale=True) - - # Set attributes - proxyShape = pm.ls(nodes, type="mesh")[0] - - proxyShape.aiTranslator.set('procedural') - proxyShape.dso.set(path) - proxyShape.aiOverrideShaders.set(0) - - settings = get_project_settings(project_name) - colors = settings['maya']['load']['colors'] - - c = colors.get(family) - 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) - ) - - self[:] = nodes - - return nodes - - def switch(self, container, representation): - self.update(container, representation) - - def update(self, container, representation): - from maya import cmds - import pymel.core as pm - - node = container["objectName"] - - representation["context"].pop("frame", None) - path = get_representation_path(representation) - print(path) - # path = self.fname - print(self.fname) - proxyPath = os.path.splitext(path)[0] + ".ma" - print(proxyPath) - - # Get reference node from container members - members = cmds.sets(node, query=True, nodesOnly=True) - reference_node = get_reference_node(members) - - assert os.path.exists(proxyPath), "%s does not exist." % proxyPath - - try: - file_url = self.prepare_root_value(proxyPath, - representation["context"] - ["project"] - ["name"]) - content = cmds.file(file_url, - loadReference=reference_node, - type="mayaAscii", - returnNewNodes=True) - - # Set attributes - proxyShape = pm.ls(content, type="mesh")[0] - - proxyShape.aiTranslator.set('procedural') - proxyShape.dso.set(path) - proxyShape.aiOverrideShaders.set(0) - - except RuntimeError as exc: - # When changing a reference to a file that has load errors the - # command will raise an error even if the file is still loaded - # correctly (e.g. when raising errors on Arnold attributes) - # When the file is loaded and has content, we consider it's fine. - if not cmds.referenceQuery(reference_node, isLoaded=True): - raise - - content = cmds.referenceQuery(reference_node, - nodes=True, - dagPath=True) - if not content: - raise - - self.log.warning("Ignoring file read error:\n%s", exc) - - # Add new nodes of the reference to the container - cmds.sets(content, forceElement=node) - - # Remove any placeHolderList attribute entries from the set that - # are remaining from nodes being removed from the referenced file. - members = cmds.sets(node, query=True) - invalid = [x for x in members if ".placeHolderList" in x] - if invalid: - cmds.sets(invalid, remove=node) - - # Update metadata - cmds.setAttr("{}.representation".format(node), - str(representation["_id"]), - type="string") - - -class AssStandinLoader(load.LoaderPlugin): - """Load .ASS file as standin""" - - families = ["ass"] - representations = ["ass"] - - label = "Load .ASS file as standin" - order = -5 - icon = "code-fork" - color = "orange" - - def load(self, context, name, namespace, options): - - import maya.cmds as cmds - import mtoa.ui.arnoldmenu - import pymel.core as pm - - version = context['version'] - version_data = version.get("data", {}) - - self.log.info("version_data: {}\n".format(version_data)) - - frameStart = version_data.get("frameStart", None) - - asset = context['asset']['name'] - namespace = namespace or unique_namespace( - asset + "_", - prefix="_" if asset[0].isdigit() else "", - suffix="_", - ) - - # cmds.loadPlugin("gpuCache", quiet=True) - - # Root group - label = "{}:{}".format(namespace, name) - root = pm.group(name=label, empty=True) - - settings = get_project_settings(os.environ['AVALON_PROJECT']) - colors = settings['maya']['load']['colors'] - - c = colors.get('ass') - if c is not None: - cmds.setAttr(root + ".useOutlinerColor", 1) - cmds.setAttr(root + ".outlinerColor", - c[0], c[1], c[2]) - - # Create transform with shape - transform_name = label + "_ASS" - # transform = pm.createNode("transform", name=transform_name, - # parent=root) - - standinShape = pm.PyNode(mtoa.ui.arnoldmenu.createStandIn()) - standin = standinShape.getParent() - standin.rename(transform_name) - - pm.parent(standin, root) - - # Set the standin filepath - standinShape.dso.set(self.fname) - if frameStart is not None: - standinShape.useFrameExtension.set(1) - - nodes = [root, standin] - self[:] = nodes - - return containerise( - name=name, - namespace=namespace, - nodes=nodes, - context=context, - loader=self.__class__.__name__) - - def update(self, container, representation): - - import pymel.core as pm - - path = get_representation_path(representation) - - files_in_path = os.listdir(os.path.split(path)[0]) - sequence = 0 - collections, remainder = clique.assemble(files_in_path) - if collections: - sequence = 1 - - # Update the standin - standins = list() - members = pm.sets(container['objectName'], query=True) - for member in members: - shape = member.getShape() - if (shape and shape.type() == "aiStandIn"): - standins.append(shape) - - for standin in standins: - standin.dso.set(path) - standin.useFrameExtension.set(sequence) - - container = pm.PyNode(container["objectName"]) - container.representation.set(str(representation["_id"])) - - def switch(self, container, representation): - self.update(container, representation) - - def remove(self, container): - import maya.cmds as cmds - 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 diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 96d7d5d3b2..858c9b709e 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -25,9 +25,9 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): "camera", "rig", "camerarig", - "xgen", "staticMesh", "mvLook"] + representations = ["ma", "abc", "fbx", "mb"] label = "Reference" diff --git a/openpype/hosts/maya/plugins/load/load_vrayproxy.py b/openpype/hosts/maya/plugins/load/load_vrayproxy.py index 720a132aa7..64184f9e7b 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayproxy.py +++ b/openpype/hosts/maya/plugins/load/load_vrayproxy.py @@ -81,10 +81,11 @@ class VRayProxyLoader(load.LoaderPlugin): c = colors.get(family) if c is not None: cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) - cmds.setAttr("{0}.outlinerColor".format(group_node), - (float(c[0])/255), - (float(c[1])/255), - (float(c[2])/255) + cmds.setAttr( + "{0}.outlinerColor".format(group_node), + (float(c[0]) / 255), + (float(c[1]) / 255), + (float(c[2]) / 255) ) return containerise( @@ -101,7 +102,7 @@ class VRayProxyLoader(load.LoaderPlugin): assert cmds.objExists(node), "Missing container" members = cmds.sets(node, query=True) or [] - vraymeshes = cmds.ls(members, type="VRayMesh") + vraymeshes = cmds.ls(members, type="VRayProxy") assert vraymeshes, "Cannot find VRayMesh in container" # get all representations for this version diff --git a/openpype/hosts/maya/plugins/load/load_xgen.py b/openpype/hosts/maya/plugins/load/load_xgen.py new file mode 100644 index 0000000000..1600cd49bd --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_xgen.py @@ -0,0 +1,173 @@ +import os + +import maya.cmds as cmds +import xgenm + +from Qt import QtWidgets + +import openpype.hosts.maya.api.plugin +from openpype.hosts.maya.api.lib import ( + maintained_selection, + get_container_members, + attribute_values, + write_xgen_file +) +from openpype.hosts.maya.api import current_file +from openpype.pipeline import get_representation_path + + +class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): + """Load Xgen as reference""" + + families = ["xgen"] + representations = ["ma", "mb"] + + label = "Reference Xgen" + icon = "code-fork" + color = "orange" + + def get_xgen_xgd_paths(self, palette): + _, maya_extension = os.path.splitext(current_file()) + xgen_file = current_file().replace( + maya_extension, + "__{}.xgen".format(palette.replace("|", "").replace(":", "__")) + ) + xgd_file = xgen_file.replace(".xgen", ".xgd") + return xgen_file, xgd_file + + def process_reference(self, context, name, namespace, options): + # Validate workfile has a path. + if current_file() is None: + QtWidgets.QMessageBox.warning( + None, + "", + "Current workfile has not been saved. Please save the workfile" + " before loading an Xgen." + ) + return + + maya_filepath = self.prepare_root_value( + self.fname, context["project"]["name"] + ) + + # Reference xgen. Xgen does not like being referenced in under a group. + new_nodes = [] + + with maintained_selection(): + nodes = cmds.file( + maya_filepath, + namespace=namespace, + sharedReferenceFile=False, + reference=True, + returnNewNodes=True + ) + + xgen_palette = cmds.ls( + nodes, type="xgmPalette", long=True + )[0].replace("|", "") + + xgen_file, xgd_file = self.get_xgen_xgd_paths(xgen_palette) + self.set_palette_attributes(xgen_palette, xgen_file, xgd_file) + + # Change the cache and disk values of xgDataPath and xgProjectPath + # to ensure paths are setup correctly. + project_path = os.path.dirname(current_file()).replace("\\", "/") + xgenm.setAttr("xgProjectPath", project_path, xgen_palette) + data_path = "${{PROJECT}}xgen/collections/{};{}".format( + xgen_palette.replace(":", "__ns__"), + xgenm.getAttr("xgDataPath", xgen_palette) + ) + xgenm.setAttr("xgDataPath", data_path, xgen_palette) + + data = {"xgProjectPath": project_path, "xgDataPath": data_path} + write_xgen_file(data, xgen_file) + + # This create an expression attribute of float. If we did not add + # any changes to collection, then Xgen does not create an xgd file + # on save. This gives errors when launching the workfile again due + # to trying to find the xgd file. + name = "custom_float_ignore" + if name not in xgenm.customAttrs(xgen_palette): + xgenm.addCustomAttr( + "custom_float_ignore", xgen_palette + ) + + shapes = cmds.ls(nodes, shapes=True, long=True) + + new_nodes = (list(set(nodes) - set(shapes))) + + self[:] = new_nodes + + return new_nodes + + def set_palette_attributes(self, xgen_palette, xgen_file, xgd_file): + cmds.setAttr( + "{}.xgBaseFile".format(xgen_palette), + os.path.basename(xgen_file), + type="string" + ) + cmds.setAttr( + "{}.xgFileName".format(xgen_palette), + os.path.basename(xgd_file), + type="string" + ) + cmds.setAttr("{}.xgExportAsDelta".format(xgen_palette), True) + + def update(self, container, representation): + """Workflow for updating Xgen. + + - Copy and potentially overwrite the workspace .xgen file. + - Export changes to delta file. + - Set collection attributes to not include delta files. + - Update xgen maya file reference. + - Apply the delta file changes. + - Reset collection attributes to include delta files. + + We have to do this workflow because when using referencing of the xgen + collection, Maya implicitly imports the Xgen data from the xgen file so + we dont have any control over when adding the delta file changes. + + There is an implicit increment of the xgen and delta files, due to + using the workfile basename. + """ + + container_node = container["objectName"] + members = get_container_members(container_node) + xgen_palette = cmds.ls( + members, type="xgmPalette", long=True + )[0].replace("|", "") + xgen_file, xgd_file = self.get_xgen_xgd_paths(xgen_palette) + + # Export current changes to apply later. + xgenm.createDelta(xgen_palette.replace("|", ""), xgd_file) + + self.set_palette_attributes(xgen_palette, xgen_file, xgd_file) + + maya_file = get_representation_path(representation) + _, extension = os.path.splitext(maya_file) + new_xgen_file = maya_file.replace(extension, ".xgen") + data_path = "" + with open(new_xgen_file, "r") as f: + for line in f: + if line.startswith("\txgDataPath"): + line = line.rstrip() + data_path = line.split("\t")[-1] + break + + project_path = os.path.dirname(current_file()).replace("\\", "/") + data_path = "${{PROJECT}}xgen/collections/{};{}".format( + xgen_palette.replace(":", "__ns__"), + data_path + ) + data = {"xgProjectPath": project_path, "xgDataPath": data_path} + write_xgen_file(data, xgen_file) + + attribute_data = { + "{}.xgFileName".format(xgen_palette): os.path.basename(xgen_file), + "{}.xgBaseFile".format(xgen_palette): "", + "{}.xgExportAsDelta".format(xgen_palette): False + } + with attribute_values(attribute_data): + super().update(container, representation) + + xgenm.applyDelta(xgen_palette.replace("|", ""), xgd_file) diff --git a/openpype/hosts/maya/plugins/publish/collect_animation.py b/openpype/hosts/maya/plugins/publish/collect_animation.py index 549098863f..8f523f770b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_animation.py +++ b/openpype/hosts/maya/plugins/publish/collect_animation.py @@ -46,7 +46,6 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): hierarchy = members + descendants - # Ignore certain node types (e.g. constraints) ignore = cmds.ls(hierarchy, type=self.ignore_type, long=True) if ignore: @@ -58,3 +57,18 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): if instance.data.get("farm"): instance.data["families"].append("publish.farm") + + # Collect user defined attributes. + if not instance.data.get("includeUserDefinedAttributes", False): + return + + user_defined_attributes = set() + for node in hierarchy: + attrs = cmds.listAttr(node, userDefined=True) or list() + shapes = cmds.listRelatives(node, shapes=True) or list() + for shape in shapes: + attrs.extend(cmds.listAttr(shape, userDefined=True) or list()) + + user_defined_attributes.update(attrs) + + instance.data["userDefinedAttributes"] = list(user_defined_attributes) diff --git a/openpype/hosts/maya/plugins/publish/collect_ass.py b/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py similarity index 60% rename from openpype/hosts/maya/plugins/publish/collect_ass.py rename to openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py index b5e05d6665..0415808b7a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_ass.py +++ b/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py @@ -1,19 +1,18 @@ from maya import cmds -from openpype.pipeline.publish import KnownPublishError import pyblish.api -class CollectAssData(pyblish.api.InstancePlugin): - """Collect Ass data.""" +class CollectArnoldSceneSource(pyblish.api.InstancePlugin): + """Collect Arnold Scene Source data.""" # Offset to be after renderable camera collection. order = pyblish.api.CollectorOrder + 0.2 - label = 'Collect Ass' + label = "Collect Arnold Scene Source" families = ["ass"] def process(self, instance): - objsets = instance.data['setMembers'] + objsets = instance.data["setMembers"] for objset in objsets: objset = str(objset) @@ -21,15 +20,12 @@ class CollectAssData(pyblish.api.InstancePlugin): if members is None: self.log.warning("Skipped empty instance: \"%s\" " % objset) continue - if "content_SET" in objset: - instance.data['setMembers'] = members - self.log.debug('content members: {}'.format(members)) - elif objset.startswith("proxy_SET"): - if len(members) != 1: - msg = "You have multiple proxy meshes, please only use one" - raise KnownPublishError(msg) - instance.data['proxy'] = members - self.log.debug('proxy members: {}'.format(members)) + if objset.endswith("content_SET"): + instance.data["setMembers"] = cmds.ls(members, long=True) + self.log.debug("content members: {}".format(members)) + elif objset.endswith("proxy_SET"): + instance.data["proxy"] = cmds.ls(members, long=True) + self.log.debug("proxy members: {}".format(members)) # Use camera in object set if present else default to render globals # camera. diff --git a/openpype/hosts/maya/plugins/publish/collect_instances.py b/openpype/hosts/maya/plugins/publish/collect_instances.py index 6c6819f0a2..c594626569 100644 --- a/openpype/hosts/maya/plugins/publish/collect_instances.py +++ b/openpype/hosts/maya/plugins/publish/collect_instances.py @@ -137,6 +137,7 @@ class CollectInstances(pyblish.api.ContextPlugin): # Create the instance instance = context.create_instance(objset) instance[:] = members_hierarchy + instance.data["objset"] = objset # Store the exact members of the object set instance.data["setMembers"] = members diff --git a/openpype/hosts/maya/plugins/publish/collect_maya_workspace.py b/openpype/hosts/maya/plugins/publish/collect_maya_workspace.py index 1250ea438f..122fabe8a1 100644 --- a/openpype/hosts/maya/plugins/publish/collect_maya_workspace.py +++ b/openpype/hosts/maya/plugins/publish/collect_maya_workspace.py @@ -12,7 +12,6 @@ class CollectMayaWorkspace(pyblish.api.ContextPlugin): label = "Maya Workspace" hosts = ['maya'] - version = (0, 1, 0) def process(self, context): workspace = cmds.workspace(rootDirectory=True, query=True) diff --git a/openpype/hosts/maya/plugins/publish/collect_pointcache.py b/openpype/hosts/maya/plugins/publish/collect_pointcache.py index a841341f72..d0430c5612 100644 --- a/openpype/hosts/maya/plugins/publish/collect_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/collect_pointcache.py @@ -1,3 +1,5 @@ +from maya import cmds + import pyblish.api @@ -12,3 +14,46 @@ class CollectPointcache(pyblish.api.InstancePlugin): def process(self, instance): if instance.data.get("farm"): instance.data["families"].append("publish.farm") + + proxy_set = None + for node in instance.data["setMembers"]: + if cmds.nodeType(node) != "objectSet": + continue + members = cmds.sets(node, query=True) + if members is None: + self.log.warning("Skipped empty objectset: \"%s\" " % node) + continue + if node.endswith("proxy_SET"): + proxy_set = node + instance.data["proxy"] = [] + instance.data["proxyRoots"] = [] + for member in members: + instance.data["proxy"].extend(cmds.ls(member, long=True)) + instance.data["proxyRoots"].extend( + cmds.ls(member, long=True) + ) + instance.data["proxy"].extend( + cmds.listRelatives(member, shapes=True, fullPath=True) + ) + self.log.debug( + "proxy members: {}".format(instance.data["proxy"]) + ) + + if proxy_set: + instance.remove(proxy_set) + instance.data["setMembers"].remove(proxy_set) + + # Collect user defined attributes. + if not instance.data.get("includeUserDefinedAttributes", False): + return + + user_defined_attributes = set() + for node in instance: + attrs = cmds.listAttr(node, userDefined=True) or list() + shapes = cmds.listRelatives(node, shapes=True) or list() + for shape in shapes: + attrs.extend(cmds.listAttr(shape, userDefined=True) or list()) + + user_defined_attributes.update(attrs) + + instance.data["userDefinedAttributes"] = list(user_defined_attributes) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index b1ad3ca58e..7c47f17acb 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -184,7 +184,11 @@ class CollectMayaRender(pyblish.api.ContextPlugin): self.log.info("multipart: {}".format( multipart)) assert exp_files, "no file names were generated, this is bug" - self.log.info(exp_files) + self.log.info( + "expected files: {}".format( + json.dumps(exp_files, indent=4, sort_keys=True) + ) + ) # if we want to attach render to subset, check if we have AOV's # in expectedFiles. If so, raise error as we cannot attach AOV @@ -265,7 +269,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): self.log.info(full_exp_files) self.log.info("collecting layer: {}".format(layer_name)) # Get layer specific settings, might be overrides - + colorspace_data = lib.get_color_management_preferences() data = { "subset": expected_layer_name, "attachTo": attach_to, @@ -318,6 +322,12 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "aovSeparator": layer_render_products.layer_data.aov_separator, # noqa: E501 "renderSetupIncludeLights": render_instance.data.get( "renderSetupIncludeLights" + ), + "colorspaceConfig": colorspace_data["config"], + "colorspaceDisplay": colorspace_data["display"], + "colorspaceView": colorspace_data["view"], + "strict_error_checking": render_instance.data.get( + "strict_error_checking", True ) } diff --git a/openpype/hosts/maya/plugins/publish/collect_vrayproxy.py b/openpype/hosts/maya/plugins/publish/collect_vrayproxy.py index 236797ca3c..24521a2f09 100644 --- a/openpype/hosts/maya/plugins/publish/collect_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/collect_vrayproxy.py @@ -9,10 +9,16 @@ class CollectVrayProxy(pyblish.api.InstancePlugin): Add `pointcache` family for it. """ order = pyblish.api.CollectorOrder + 0.01 - label = 'Collect Vray Proxy' + label = "Collect Vray Proxy" families = ["vrayproxy"] def process(self, instance): """Collector entry point.""" if not instance.data.get('families'): instance.data["families"] = [] + + if instance.data.get("vrmesh"): + instance.data["families"].append("vrayproxy.vrmesh") + + if instance.data.get("alembic"): + instance.data["families"].append("vrayproxy.alembic") diff --git a/openpype/hosts/maya/plugins/publish/collect_xgen.py b/openpype/hosts/maya/plugins/publish/collect_xgen.py new file mode 100644 index 0000000000..da0549b2d8 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_xgen.py @@ -0,0 +1,71 @@ +import os + +from maya import cmds + +import pyblish.api +from openpype.hosts.maya.api.lib import get_attribute_input + + +class CollectXgen(pyblish.api.InstancePlugin): + """Collect Xgen""" + + order = pyblish.api.CollectorOrder + 0.499999 + label = "Collect Xgen" + families = ["xgen"] + + def process(self, instance): + data = { + "xgmPalettes": cmds.ls(instance, type="xgmPalette", long=True), + "xgmDescriptions": cmds.ls( + instance, type="xgmDescription", long=True + ), + "xgmSubdPatches": cmds.ls(instance, type="xgmSubdPatch", long=True) + } + data["xgenNodes"] = ( + data["xgmPalettes"] + + data["xgmDescriptions"] + + data["xgmSubdPatches"] + ) + + if data["xgmPalettes"]: + data["xgmPalette"] = data["xgmPalettes"][0] + + data["xgenConnections"] = {} + for node in data["xgmSubdPatches"]: + data["xgenConnections"][node] = {} + for attr in ["transform", "geometry"]: + input = get_attribute_input("{}.{}".format(node, attr)) + data["xgenConnections"][node][attr] = input + + # Collect all files under palette root as resources. + import xgenm + + data_path = xgenm.getAttr( + "xgDataPath", data["xgmPalette"].replace("|", "") + ).split(os.pathsep)[0] + data_path = data_path.replace( + "${PROJECT}", + xgenm.getAttr("xgProjectPath", data["xgmPalette"].replace("|", "")) + ) + transfers = [] + + # Since we are duplicating this palette when extracting we predict that + # the name will be the basename without namespaces. + predicted_palette_name = data["xgmPalette"].split(":")[-1] + predicted_palette_name = predicted_palette_name.replace("|", "") + + for root, _, files in os.walk(data_path): + for file in files: + source = os.path.join(root, file).replace("\\", "/") + destination = os.path.join( + instance.data["resourcesDir"], + "collections", + predicted_palette_name, + source.replace(data_path, "")[1:] + ) + transfers.append((source, destination.replace("\\", "/"))) + + data["transfers"] = transfers + + self.log.info(data) + instance.data.update(data) diff --git a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py new file mode 100644 index 0000000000..924ac58c40 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py @@ -0,0 +1,160 @@ +import os + +from maya import cmds +import arnold + +from openpype.pipeline import publish +from openpype.hosts.maya.api.lib import ( + maintained_selection, attribute_values, delete_after +) + + +class ExtractArnoldSceneSource(publish.Extractor): + """Extract the content of the instance to an Arnold Scene Source file.""" + + label = "Extract Arnold Scene Source" + hosts = ["maya"] + families = ["ass"] + asciiAss = False + + def process(self, instance): + staging_dir = self.staging_dir(instance) + filename = "{}.ass".format(instance.name) + file_path = os.path.join(staging_dir, filename) + + # Mask + mask = arnold.AI_NODE_ALL + + node_types = { + "options": arnold.AI_NODE_OPTIONS, + "camera": arnold.AI_NODE_CAMERA, + "light": arnold.AI_NODE_LIGHT, + "shape": arnold.AI_NODE_SHAPE, + "shader": arnold.AI_NODE_SHADER, + "override": arnold.AI_NODE_OVERRIDE, + "driver": arnold.AI_NODE_DRIVER, + "filter": arnold.AI_NODE_FILTER, + "color_manager": arnold.AI_NODE_COLOR_MANAGER, + "operator": arnold.AI_NODE_OPERATOR + } + + for key in node_types.keys(): + if instance.data.get("mask" + key.title()): + mask = mask ^ node_types[key] + + # Motion blur + attribute_data = { + "defaultArnoldRenderOptions.motion_blur_enable": instance.data.get( + "motionBlur", True + ), + "defaultArnoldRenderOptions.motion_steps": instance.data.get( + "motionBlurKeys", 2 + ), + "defaultArnoldRenderOptions.motion_frames": instance.data.get( + "motionBlurLength", 0.5 + ) + } + + # Write out .ass file + kwargs = { + "filename": file_path, + "startFrame": instance.data.get("frameStartHandle", 1), + "endFrame": instance.data.get("frameEndHandle", 1), + "frameStep": instance.data.get("step", 1), + "selected": True, + "asciiAss": self.asciiAss, + "shadowLinks": True, + "lightLinks": True, + "boundingBox": True, + "expandProcedurals": instance.data.get("expandProcedurals", False), + "camera": instance.data["camera"], + "mask": mask + } + + filenames = self._extract( + instance.data["setMembers"], attribute_data, kwargs + ) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + "name": "ass", + "ext": "ass", + "files": filenames if len(filenames) > 1 else filenames[0], + "stagingDir": staging_dir, + "frameStart": kwargs["startFrame"] + } + + instance.data["representations"].append(representation) + + self.log.info( + "Extracted instance {} to: {}".format(instance.name, staging_dir) + ) + + # Extract proxy. + if not instance.data.get("proxy", []): + return + + kwargs["filename"] = file_path.replace(".ass", "_proxy.ass") + filenames = self._extract( + instance.data["proxy"], attribute_data, kwargs + ) + + representation = { + "name": "proxy", + "ext": "ass", + "files": filenames if len(filenames) > 1 else filenames[0], + "stagingDir": staging_dir, + "frameStart": kwargs["startFrame"], + "outputName": "proxy" + } + + instance.data["representations"].append(representation) + + def _extract(self, nodes, attribute_data, kwargs): + self.log.info("Writing: " + kwargs["filename"]) + filenames = [] + # Duplicating nodes so they are direct children of the world. This + # makes the hierarchy of any exported ass file the same. + with delete_after() as delete_bin: + duplicate_nodes = [] + for node in nodes: + duplicate_transform = cmds.duplicate(node)[0] + + # Discard the children. + shapes = cmds.listRelatives(duplicate_transform, shapes=True) + children = cmds.listRelatives( + duplicate_transform, children=True + ) + cmds.delete(set(children) - set(shapes)) + + duplicate_transform = cmds.parent( + duplicate_transform, world=True + )[0] + + cmds.rename(duplicate_transform, node.split("|")[-1]) + duplicate_transform = "|" + node.split("|")[-1] + + duplicate_nodes.append(duplicate_transform) + delete_bin.append(duplicate_transform) + + with attribute_values(attribute_data): + with maintained_selection(): + self.log.info( + "Writing: {}".format(duplicate_nodes) + ) + cmds.select(duplicate_nodes, noExpand=True) + + self.log.info( + "Extracting ass sequence with: {}".format(kwargs) + ) + + exported_files = cmds.arnoldExportAss(**kwargs) + + for file in exported_files: + filenames.append(os.path.split(file)[1]) + + self.log.info("Exported: {}".format(filenames)) + + return filenames diff --git a/openpype/hosts/maya/plugins/publish/extract_ass.py b/openpype/hosts/maya/plugins/publish/extract_ass.py deleted file mode 100644 index 049f256a7a..0000000000 --- a/openpype/hosts/maya/plugins/publish/extract_ass.py +++ /dev/null @@ -1,106 +0,0 @@ -import os - -from maya import cmds -import arnold - -from openpype.pipeline import publish -from openpype.hosts.maya.api.lib import maintained_selection, attribute_values - - -class ExtractAssStandin(publish.Extractor): - """Extract the content of the instance to a ass file""" - - label = "Arnold Scene Source (.ass)" - hosts = ["maya"] - families = ["ass"] - asciiAss = False - - def process(self, instance): - staging_dir = self.staging_dir(instance) - filename = "{}.ass".format(instance.name) - filenames = [] - file_path = os.path.join(staging_dir, filename) - - # Mask - mask = arnold.AI_NODE_ALL - - node_types = { - "options": arnold.AI_NODE_OPTIONS, - "camera": arnold.AI_NODE_CAMERA, - "light": arnold.AI_NODE_LIGHT, - "shape": arnold.AI_NODE_SHAPE, - "shader": arnold.AI_NODE_SHADER, - "override": arnold.AI_NODE_OVERRIDE, - "driver": arnold.AI_NODE_DRIVER, - "filter": arnold.AI_NODE_FILTER, - "color_manager": arnold.AI_NODE_COLOR_MANAGER, - "operator": arnold.AI_NODE_OPERATOR - } - - for key in node_types.keys(): - if instance.data.get("mask" + key.title()): - mask = mask ^ node_types[key] - - # Motion blur - values = { - "defaultArnoldRenderOptions.motion_blur_enable": instance.data.get( - "motionBlur", True - ), - "defaultArnoldRenderOptions.motion_steps": instance.data.get( - "motionBlurKeys", 2 - ), - "defaultArnoldRenderOptions.motion_frames": instance.data.get( - "motionBlurLength", 0.5 - ) - } - - # Write out .ass file - kwargs = { - "filename": file_path, - "startFrame": instance.data.get("frameStartHandle", 1), - "endFrame": instance.data.get("frameEndHandle", 1), - "frameStep": instance.data.get("step", 1), - "selected": True, - "asciiAss": self.asciiAss, - "shadowLinks": True, - "lightLinks": True, - "boundingBox": True, - "expandProcedurals": instance.data.get("expandProcedurals", False), - "camera": instance.data["camera"], - "mask": mask - } - - self.log.info("Writing: '%s'" % file_path) - with attribute_values(values): - with maintained_selection(): - self.log.info( - "Writing: {}".format(instance.data["setMembers"]) - ) - cmds.select(instance.data["setMembers"], noExpand=True) - - self.log.info( - "Extracting ass sequence with: {}".format(kwargs) - ) - - exported_files = cmds.arnoldExportAss(**kwargs) - - for file in exported_files: - filenames.append(os.path.split(file)[1]) - - self.log.info("Exported: {}".format(filenames)) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - 'name': 'ass', - 'ext': 'ass', - 'files': filenames if len(filenames) > 1 else filenames[0], - "stagingDir": staging_dir, - 'frameStart': kwargs["startFrame"] - } - - instance.data["representations"].append(representation) - - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) diff --git a/openpype/hosts/maya/plugins/publish/extract_assproxy.py b/openpype/hosts/maya/plugins/publish/extract_assproxy.py deleted file mode 100644 index 4937a28a9e..0000000000 --- a/openpype/hosts/maya/plugins/publish/extract_assproxy.py +++ /dev/null @@ -1,81 +0,0 @@ -import os -import contextlib - -from maya import cmds - -from openpype.pipeline import publish -from openpype.hosts.maya.api.lib import maintained_selection - - -class ExtractAssProxy(publish.Extractor): - """Extract proxy model as Maya Ascii to use as arnold standin - - - """ - - order = publish.Extractor.order + 0.2 - label = "Ass Proxy (Maya ASCII)" - hosts = ["maya"] - families = ["ass"] - - def process(self, instance): - - @contextlib.contextmanager - def unparent(root): - """Temporarily unparent `root`""" - parent = cmds.listRelatives(root, parent=True) - if parent: - cmds.parent(root, world=True) - yield - self.log.info("{} - {}".format(root, parent)) - cmds.parent(root, parent) - else: - yield - - # Define extract output file path - stagingdir = self.staging_dir(instance) - filename = "{0}.ma".format(instance.name) - path = os.path.join(stagingdir, filename) - - # Perform extraction - self.log.info("Performing extraction..") - - # Get only the shape contents we need in such a way that we avoid - # taking along intermediateObjects - proxy = instance.data.get('proxy', None) - - if not proxy: - self.log.info("no proxy mesh") - return - - members = cmds.ls(proxy, - dag=True, - transforms=True, - noIntermediate=True) - self.log.info(members) - - with maintained_selection(): - with unparent(members[0]): - cmds.select(members, noExpand=True) - cmds.file(path, - force=True, - typ="mayaAscii", - exportSelected=True, - preserveReferences=False, - channels=False, - constraints=False, - expressions=False, - constructionHistory=False) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - 'name': 'ma', - 'ext': 'ma', - 'files': filename, - "stagingDir": stagingdir - } - instance.data["representations"].append(representation) - - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_gltf.py b/openpype/hosts/maya/plugins/publish/extract_gltf.py index f5ceed5f33..ac258ffb3d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_gltf.py +++ b/openpype/hosts/maya/plugins/publish/extract_gltf.py @@ -22,6 +22,8 @@ class ExtractGLB(publish.Extractor): self.log.info("Extracting GLB to: {}".format(path)) + cmds.loadPlugin("maya2glTF", quiet=True) + nodes = instance[:] self.log.info("Instance: {0}".format(nodes)) @@ -45,6 +47,7 @@ class ExtractGLB(publish.Extractor): "glb": True, "vno": True # visibleNodeOnly } + with lib.maintained_selection(): cmds.select(nodes, hi=True, noExpand=True) extract_gltf(staging_dir, diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 3769ec3605..c2411ca651 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -20,8 +20,7 @@ class ExtractMayaSceneRaw(publish.Extractor): "mayaScene", "setdress", "layout", - "camerarig", - "xgen"] + "camerarig"] scene_type = "ma" def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 1f9f9db99a..1966ad7b66 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -1,4 +1,5 @@ import os +import json import clique import capture @@ -44,10 +45,6 @@ class ExtractPlayblast(publish.Extractor): # get cameras camera = instance.data['review_camera'] - override_viewport_options = ( - self.capture_preset['Viewport Options'] - ['override_viewport_options'] - ) preset = lib.load_capture_preset(data=self.capture_preset) # Grab capture presets from the project settings capture_presets = self.capture_preset @@ -119,6 +116,27 @@ class ExtractPlayblast(publish.Extractor): pan_zoom = cmds.getAttr("{}.panZoomEnabled".format(preset["camera"])) cmds.setAttr("{}.panZoomEnabled".format(preset["camera"]), False) + # Need to explicitly enable some viewport changes so the viewport is + # refreshed ahead of playblasting. + panel = cmds.getPanel(withFocus=True) + keys = [ + "useDefaultMaterial", + "wireframeOnShaded", + "xray", + "jointXray", + "backfaceCulling" + ] + viewport_defaults = {} + for key in keys: + viewport_defaults[key] = cmds.modelEditor( + panel, query=True, **{key: True} + ) + if preset["viewport_options"][key]: + cmds.modelEditor(panel, edit=True, **{key: True}) + + override_viewport_options = ( + capture_presets['Viewport Options']['override_viewport_options'] + ) with lib.maintained_time(): filename = preset.get("filename", "%TEMP%") @@ -127,18 +145,26 @@ class ExtractPlayblast(publish.Extractor): # playblast and viewer preset['viewer'] = False - self.log.info('using viewport preset: {}'.format(preset)) - # Update preset with current panel setting # if override_viewport_options is turned off - if not override_viewport_options: - panel = cmds.getPanel(withFocus=True) + panel = cmds.getPanel(withFocus=True) or "" + if not override_viewport_options and "modelPanel" in panel: panel_preset = capture.parse_active_view() + panel_preset.pop("camera") preset.update(panel_preset) cmds.setFocus(panel) + self.log.info( + "Using preset:\n{}".format( + json.dumps(preset, sort_keys=True, indent=4) + ) + ) + path = capture.capture(log=self.log, **preset) + # Restoring viewport options. + cmds.modelEditor(panel, edit=True, **viewport_defaults) + cmds.setAttr("{}.panZoomEnabled".format(preset["camera"]), pan_zoom) self.log.debug("playblast path {}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index 7ed73fd5b0..f44c13767c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -23,9 +23,7 @@ class ExtractAlembic(publish.Extractor): label = "Extract Pointcache (Alembic)" hosts = ["maya"] - families = ["pointcache", - "model", - "vrayproxy"] + families = ["pointcache", "model", "vrayproxy.alembic"] targets = ["local", "remote"] def process(self, instance): @@ -41,6 +39,7 @@ class ExtractAlembic(publish.Extractor): attrs = instance.data.get("attr", "").split(";") attrs = [value for value in attrs if value.strip()] + attrs += instance.data.get("userDefinedAttributes", []) attrs += ["cbId"] attr_prefixes = instance.data.get("attrPrefix", "").split(";") @@ -87,6 +86,7 @@ class ExtractAlembic(publish.Extractor): end=end)) suspend = not instance.data.get("refresh", False) + self.log.info(nodes) with suspended_refresh(suspend=suspend): with maintained_selection(): cmds.select(nodes, noExpand=True) @@ -101,9 +101,9 @@ class ExtractAlembic(publish.Extractor): instance.data["representations"] = [] representation = { - 'name': 'abc', - 'ext': 'abc', - 'files': filename, + "name": "abc", + "ext": "abc", + "files": filename, "stagingDir": dirname } instance.data["representations"].append(representation) @@ -112,6 +112,37 @@ class ExtractAlembic(publish.Extractor): self.log.info("Extracted {} to {}".format(instance, dirname)) + # Extract proxy. + if not instance.data.get("proxy"): + self.log.info("No proxy nodes found. Skipping proxy extraction.") + return + + path = path.replace(".abc", "_proxy.abc") + if not instance.data.get("includeParentHierarchy", True): + # Set the root nodes if we don't want to include parents + # The roots are to be considered the ones that are the actual + # direct members of the set + options["root"] = instance.data["proxyRoots"] + + with suspended_refresh(suspend=suspend): + with maintained_selection(): + cmds.select(instance.data["proxy"]) + extract_alembic( + file=path, + startFrame=start, + endFrame=end, + **options + ) + + representation = { + "name": "proxy", + "ext": "abc", + "files": os.path.basename(path), + "stagingDir": dirname, + "outputName": "proxy" + } + instance.data["representations"].append(representation) + def get_members_and_roots(self, instance): return instance[:], instance.data.get("setMembers") diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 1edafeb926..1d94bd58c5 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -134,8 +134,8 @@ class ExtractThumbnail(publish.Extractor): # Update preset with current panel setting # if override_viewport_options is turned off - if not override_viewport_options: - panel = cmds.getPanel(withFocus=True) + panel = cmds.getPanel(withFocus=True) or "" + if not override_viewport_options and "modelPanel" in panel: panel_preset = capture.parse_active_view() preset.update(panel_preset) cmds.setFocus(panel) diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py index 38bf02245a..9b10d2737d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py @@ -16,7 +16,7 @@ class ExtractVRayProxy(publish.Extractor): label = "VRay Proxy (.vrmesh)" hosts = ["maya"] - families = ["vrayproxy"] + families = ["vrayproxy.vrmesh"] def process(self, instance): diff --git a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py new file mode 100644 index 0000000000..20e1bd37d8 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py @@ -0,0 +1,250 @@ +import os +import shutil +import copy + +from maya import cmds + +import pyblish.api +from openpype.hosts.maya.api.lib import extract_alembic +from openpype.pipeline import publish +from openpype.lib import StringTemplate + + +class ExtractWorkfileXgen(publish.Extractor): + """Extract Workfile Xgen. + + When submitting a render, we need to prep Xgen side car files. + """ + + # Offset to run before workfile scene save. + order = pyblish.api.ExtractorOrder - 0.499 + label = "Extract Workfile Xgen" + families = ["workfile"] + hosts = ["maya"] + + def get_render_max_frame_range(self, context): + """Return start to end frame range including all renderlayers in + context. + + This will return the full frame range which includes all frames of the + renderlayer instances to be published/submitted. + + Args: + context (pyblish.api.Context): Current publishing context. + + Returns: + tuple or None: Start frame, end frame tuple if any renderlayers + found. Otherwise None is returned. + + """ + + def _is_active_renderlayer(i): + """Return whether instance is active renderlayer""" + if not i.data.get("publish", True): + return False + + is_renderlayer = ( + "renderlayer" in i.data.get("families", []) or + i.data["family"] == "renderlayer" + ) + return is_renderlayer + + start_frame = None + end_frame = None + for instance in context: + if not _is_active_renderlayer(instance): + # Only consider renderlyare instances + continue + + render_start_frame = instance.data["frameStart"] + render_end_frame = instance.data["frameStart"] + + if start_frame is None: + start_frame = render_start_frame + else: + start_frame = min(start_frame, render_start_frame) + + if end_frame is None: + end_frame = render_end_frame + else: + end_frame = max(end_frame, render_end_frame) + + if start_frame is None or end_frame is None: + return + + return start_frame, end_frame + + def process(self, instance): + transfers = [] + + # Validate there is any palettes in the scene. + if not cmds.ls(type="xgmPalette"): + self.log.debug( + "No collections found in the scene. Skipping Xgen extraction." + ) + return + + import xgenm + + # Validate to extract only when we are publishing a renderlayer as + # well. + render_range = self.get_render_max_frame_range(instance.context) + if not render_range: + self.log.debug( + "No publishable renderlayers found in context. Skipping Xgen" + " extraction." + ) + return + + start_frame, end_frame = render_range + + # We decrement start frame and increment end frame so motion blur will + # render correctly. + start_frame -= 1 + end_frame += 1 + + # Extract patches alembic. + path_no_ext, _ = os.path.splitext(instance.context.data["currentFile"]) + kwargs = {"attrPrefix": ["xgen"], "stripNamespaces": True} + alembic_files = [] + for palette in cmds.ls(type="xgmPalette"): + patch_names = [] + for description in xgenm.descriptions(palette): + for name in xgenm.boundGeometry(palette, description): + patch_names.append(name) + + alembic_file = "{}__{}.abc".format( + path_no_ext, palette.replace(":", "__ns__") + ) + extract_alembic( + alembic_file, + root=patch_names, + selection=False, + startFrame=float(start_frame), + endFrame=float(end_frame), + verbose=True, + **kwargs + ) + alembic_files.append(alembic_file) + + template_data = copy.deepcopy(instance.data["anatomyData"]) + published_maya_path = StringTemplate( + instance.context.data["anatomy"].templates["publish"]["file"] + ).format(template_data) + published_basename, _ = os.path.splitext(published_maya_path) + + for source in alembic_files: + destination = os.path.join( + os.path.dirname(instance.data["resourcesDir"]), + os.path.basename( + source.replace(path_no_ext, published_basename) + ) + ) + transfers.append((source, destination)) + + # Validate that we are using the published workfile. + deadline_settings = instance.context.get("deadline") + if deadline_settings: + publish_settings = deadline_settings["publish"] + if not publish_settings["MayaSubmitDeadline"]["use_published"]: + self.log.debug( + "Not using the published workfile. Abort Xgen extraction." + ) + return + + # Collect Xgen and Delta files. + xgen_files = [] + sources = [] + current_dir = os.path.dirname(instance.context.data["currentFile"]) + attrs = ["xgFileName", "xgBaseFile"] + for palette in cmds.ls(type="xgmPalette"): + for attr in attrs: + source = os.path.join( + current_dir, cmds.getAttr(palette + "." + attr) + ) + if not os.path.exists(source): + continue + + ext = os.path.splitext(source)[1] + if ext == ".xgen": + xgen_files.append(source) + if ext == ".xgd": + sources.append(source) + + # Copy .xgen file to temporary location and modify. + staging_dir = self.staging_dir(instance) + for source in xgen_files: + destination = os.path.join(staging_dir, os.path.basename(source)) + shutil.copy(source, destination) + + lines = [] + with open(destination, "r") as f: + for line in [line.rstrip() for line in f]: + if line.startswith("\txgProjectPath"): + path = os.path.dirname(instance.data["resourcesDir"]) + line = "\txgProjectPath\t\t{}/".format( + path.replace("\\", "/") + ) + + lines.append(line) + + with open(destination, "w") as f: + f.write("\n".join(lines)) + + sources.append(destination) + + # Add resource files to workfile instance. + for source in sources: + basename = os.path.basename(source) + destination = os.path.join( + os.path.dirname(instance.data["resourcesDir"]), basename + ) + transfers.append((source, destination)) + + destination_dir = os.path.join( + instance.data["resourcesDir"], "collections" + ) + for palette in cmds.ls(type="xgmPalette"): + project_path = xgenm.getAttr("xgProjectPath", palette) + data_path = xgenm.getAttr("xgDataPath", palette) + data_path = data_path.replace("${PROJECT}", project_path) + for path in data_path.split(";"): + for root, _, files in os.walk(path): + for f in files: + source = os.path.join(root, f) + destination = "{}/{}{}".format( + destination_dir, + palette.replace(":", "__ns__"), + source.replace(path, "") + ) + transfers.append((source, destination)) + + for source, destination in transfers: + self.log.debug("Transfer: {} > {}".format(source, destination)) + + instance.data["transfers"] = transfers + + # Set palette attributes in preparation for workfile publish. + attrs = {"xgFileName": None, "xgBaseFile": ""} + data = {} + for palette in cmds.ls(type="xgmPalette"): + attrs["xgFileName"] = "resources/{}.xgen".format( + palette.replace(":", "__ns__") + ) + for attr, value in attrs.items(): + node_attr = palette + "." + attr + + old_value = cmds.getAttr(node_attr) + try: + data[palette][attr] = old_value + except KeyError: + data[palette] = {attr: old_value} + + cmds.setAttr(node_attr, value, type="string") + self.log.info( + "Setting \"{}\" on \"{}\"".format(value, node_attr) + ) + + cmds.setAttr(palette + "." + "xgExportAsDelta", False) + + instance.data["xgenAttributes"] = data diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen.py b/openpype/hosts/maya/plugins/publish/extract_xgen.py new file mode 100644 index 0000000000..0cc842b4ec --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_xgen.py @@ -0,0 +1,142 @@ +import os +import copy +import tempfile + +from maya import cmds +import xgenm + +from openpype.pipeline import publish +from openpype.hosts.maya.api.lib import ( + maintained_selection, attribute_values, write_xgen_file, delete_after +) +from openpype.lib import StringTemplate + + +class ExtractXgen(publish.Extractor): + """Extract Xgen + + Workflow: + - Duplicate nodes used for patches. + - Export palette and import onto duplicate nodes. + - Export/Publish duplicate nodes and palette. + - Export duplicate palette to .xgen file and add to publish. + - Publish all xgen files as resources. + """ + + label = "Extract Xgen" + hosts = ["maya"] + families = ["xgen"] + scene_type = "ma" + + def process(self, instance): + if "representations" not in instance.data: + instance.data["representations"] = [] + + staging_dir = self.staging_dir(instance) + maya_filename = "{}.{}".format(instance.data["name"], self.scene_type) + maya_filepath = os.path.join(staging_dir, maya_filename) + + # Get published xgen file name. + template_data = copy.deepcopy(instance.data["anatomyData"]) + template_data.update({"ext": "xgen"}) + templates = instance.context.data["anatomy"].templates["publish"] + xgen_filename = StringTemplate(templates["file"]).format(template_data) + + xgen_path = os.path.join( + self.staging_dir(instance), xgen_filename + ).replace("\\", "/") + type = "mayaAscii" if self.scene_type == "ma" else "mayaBinary" + + # Duplicate xgen setup. + with delete_after() as delete_bin: + duplicate_nodes = [] + # Collect nodes to export. + for _, connections in instance.data["xgenConnections"].items(): + transform_name = connections["transform"].split(".")[0] + + # Duplicate_transform subd patch geometry. + duplicate_transform = cmds.duplicate(transform_name)[0] + delete_bin.append(duplicate_transform) + + # Discard the children. + shapes = cmds.listRelatives(duplicate_transform, shapes=True) + children = cmds.listRelatives( + duplicate_transform, children=True + ) + cmds.delete(set(children) - set(shapes)) + + duplicate_transform = cmds.parent( + duplicate_transform, world=True + )[0] + + duplicate_nodes.append(duplicate_transform) + + # Export temp xgen palette files. + temp_xgen_path = os.path.join( + tempfile.gettempdir(), "temp.xgen" + ).replace("\\", "/") + xgenm.exportPalette( + instance.data["xgmPalette"].replace("|", ""), temp_xgen_path + ) + self.log.info("Extracted to {}".format(temp_xgen_path)) + + # Import xgen onto the duplicate. + with maintained_selection(): + cmds.select(duplicate_nodes) + palette = xgenm.importPalette(temp_xgen_path, []) + + delete_bin.append(palette) + + # Export duplicated palettes. + xgenm.exportPalette(palette, xgen_path) + + # Export Maya file. + attribute_data = {"{}.xgFileName".format(palette): xgen_filename} + with attribute_values(attribute_data): + with maintained_selection(): + cmds.select(duplicate_nodes + [palette]) + cmds.file( + maya_filepath, + force=True, + type=type, + exportSelected=True, + preserveReferences=False, + constructionHistory=True, + shader=True, + constraints=True, + expressions=True + ) + + self.log.info("Extracted to {}".format(maya_filepath)) + + if os.path.exists(temp_xgen_path): + os.remove(temp_xgen_path) + + data = { + "xgDataPath": os.path.join( + instance.data["resourcesDir"], + "collections", + palette.replace(":", "__ns__") + ).replace("\\", "/"), + "xgProjectPath": os.path.dirname( + instance.data["resourcesDir"] + ).replace("\\", "/") + } + write_xgen_file(data, xgen_path) + + # Adding representations. + representation = { + "name": "xgen", + "ext": "xgen", + "files": xgen_filename, + "stagingDir": staging_dir, + } + instance.data["representations"].append(representation) + + representation = { + "name": self.scene_type, + "ext": self.scene_type, + "files": maya_filename, + "stagingDir": staging_dir + } + instance.data["representations"].append(representation) diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py b/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py deleted file mode 100644 index 77350f343e..0000000000 --- a/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py +++ /dev/null @@ -1,64 +0,0 @@ -import os - -from maya import cmds - -from openpype.pipeline import publish -from openpype.hosts.maya.api.lib import ( - suspended_refresh, - maintained_selection -) - - -class ExtractXgenCache(publish.Extractor): - """Produce an alembic of just xgen interactive groom - - """ - - label = "Extract Xgen ABC Cache" - hosts = ["maya"] - families = ["xgen"] - optional = True - - def process(self, instance): - - # Collect the out set nodes - out_descriptions = [node for node in instance - if cmds.nodeType(node) == "xgmSplineDescription"] - - start = 1 - end = 1 - - self.log.info("Extracting Xgen Cache..") - dirname = self.staging_dir(instance) - - parent_dir = self.staging_dir(instance) - filename = "{name}.abc".format(**instance.data) - path = os.path.join(parent_dir, filename) - - with suspended_refresh(): - with maintained_selection(): - command = ( - '-file ' - + path - + ' -df "ogawa" -fr ' - + str(start) - + ' ' - + str(end) - + ' -step 1 -mxf -wfw' - ) - for desc in out_descriptions: - command += (" -obj " + desc) - cmds.xgmSplineCache(export=True, j=command) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - 'name': 'abc', - 'ext': 'abc', - 'files': filename, - "stagingDir": dirname, - } - instance.data["representations"].append(representation) - - self.log.info("Extracted {} to {}".format(instance, dirname)) diff --git a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py new file mode 100644 index 0000000000..b90885663c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py @@ -0,0 +1,36 @@ +from maya import cmds + +import pyblish.api + + +class ResetXgenAttributes(pyblish.api.InstancePlugin): + """Reset Xgen attributes. + + When the incremental save of the workfile triggers, the Xgen attributes + changes so this plugin will change it back to the values before publishing. + """ + + label = "Reset Xgen Attributes." + # Offset to run after workfile increment plugin. + order = pyblish.api.IntegratorOrder + 10.0 + families = ["workfile"] + + def process(self, instance): + xgen_attributes = instance.data.get("xgenAttributes", {}) + if not xgen_attributes: + return + + for palette, data in xgen_attributes.items(): + for attr, value in data.items(): + node_attr = "{}.{}".format(palette, attr) + self.log.info( + "Setting \"{}\" on \"{}\"".format(value, node_attr) + ) + cmds.setAttr(node_attr, value, type="string") + 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 + # changes. + self.log.info("Saving changes.") + cmds.file(save=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/validate_arnold_scene_source.py new file mode 100644 index 0000000000..3b0ffd52d7 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_arnold_scene_source.py @@ -0,0 +1,106 @@ +import maya.cmds as cmds + +import pyblish.api +from openpype.pipeline.publish import ( + ValidateContentsOrder, PublishValidationError +) + + +class ValidateArnoldSceneSource(pyblish.api.InstancePlugin): + """Validate Arnold Scene Source. + + We require at least 1 root node/parent for the meshes. This is to ensure we + can duplicate the nodes and preserve the names. + + If using proxies we need the nodes to share the same names and not be + parent to the world. This ends up needing at least two groups with content + nodes and proxy nodes in another. + """ + + order = ValidateContentsOrder + hosts = ["maya"] + families = ["ass"] + label = "Validate Arnold Scene Source" + + def _get_nodes_data(self, nodes): + ungrouped_nodes = [] + nodes_by_name = {} + parents = [] + for node in nodes: + node_split = node.split("|") + if len(node_split) == 2: + ungrouped_nodes.append(node) + + parent = "|".join(node_split[:-1]) + if parent: + parents.append(parent) + + nodes_by_name[node_split[-1]] = node + for shape in cmds.listRelatives(node, shapes=True): + nodes_by_name[shape.split("|")[-1]] = shape + + return ungrouped_nodes, nodes_by_name, parents + + def process(self, instance): + ungrouped_nodes = [] + + nodes, content_nodes_by_name, content_parents = self._get_nodes_data( + instance.data["setMembers"] + ) + ungrouped_nodes.extend(nodes) + + nodes, proxy_nodes_by_name, proxy_parents = self._get_nodes_data( + instance.data.get("proxy", []) + ) + ungrouped_nodes.extend(nodes) + + # Validate against nodes directly parented to world. + if ungrouped_nodes: + raise PublishValidationError( + "Found nodes parented to the world: {}\n" + "All nodes need to be grouped.".format(ungrouped_nodes) + ) + + # Proxy validation. + if not instance.data.get("proxy", []): + return + + # Validate for content and proxy nodes amount being the same. + if len(instance.data["setMembers"]) != len(instance.data["proxy"]): + raise PublishValidationError( + "Amount of content nodes ({}) and proxy nodes ({}) needs to " + "be the same.".format( + len(instance.data["setMembers"]), + len(instance.data["proxy"]) + ) + ) + + # Validate against content and proxy nodes sharing same parent. + if list(set(content_parents) & set(proxy_parents)): + raise PublishValidationError( + "Content and proxy nodes cannot share the same parent." + ) + + # Validate for content and proxy nodes sharing same names. + sorted_content_names = sorted(content_nodes_by_name.keys()) + sorted_proxy_names = sorted(proxy_nodes_by_name.keys()) + odd_content_names = list( + set(sorted_content_names) - set(sorted_proxy_names) + ) + odd_content_nodes = [ + content_nodes_by_name[x] for x in odd_content_names + ] + odd_proxy_names = list( + set(sorted_proxy_names) - set(sorted_content_names) + ) + odd_proxy_nodes = [ + proxy_nodes_by_name[x] for x in odd_proxy_names + ] + if not sorted_content_names == sorted_proxy_names: + raise PublishValidationError( + "Content and proxy nodes need to share the same names.\n" + "Content nodes not matching: {}\n" + "Proxy nodes not matching: {}".format( + odd_content_nodes, odd_proxy_nodes + ) + ) diff --git a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py index ac6ce4d22d..6975d583bb 100644 --- a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py +++ b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py @@ -2,11 +2,13 @@ import os import types import maya.cmds as cmds +from mtoa.core import createOptions import pyblish.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, + PublishValidationError ) @@ -34,8 +36,9 @@ class ValidateAssRelativePaths(pyblish.api.InstancePlugin): "defaultArnoldRenderOptions.pspath" ) except ValueError: - assert False, ("Can not validate, render setting were not opened " - "yet so Arnold setting cannot be validate") + raise PublishValidationError( + "Default Arnold options has not been created yet." + ) scene_dir, scene_basename = os.path.split(cmds.file(q=True, loc=True)) scene_name, _ = os.path.splitext(scene_basename) @@ -66,6 +69,8 @@ class ValidateAssRelativePaths(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): + createOptions() + texture_path = cmds.getAttr("defaultArnoldRenderOptions.tspath") procedural_path = cmds.getAttr("defaultArnoldRenderOptions.pspath") diff --git a/openpype/hosts/maya/plugins/publish/validate_attributes.py b/openpype/hosts/maya/plugins/publish/validate_attributes.py index 136c38bc1d..7a1f0cf086 100644 --- a/openpype/hosts/maya/plugins/publish/validate_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_attributes.py @@ -58,23 +58,23 @@ class ValidateAttributes(pyblish.api.ContextPlugin): # Filter families. families = [instance.data["family"]] families += instance.data.get("families", []) - families = list(set(families) & set(self.attributes.keys())) + families = list(set(families) & set(cls.attributes.keys())) if not families: continue # Get all attributes to validate. attributes = {} for family in families: - for preset in self.attributes[family]: + for preset in cls.attributes[family]: [node_name, attribute_name] = preset.split(".") try: attributes[node_name].update( - {attribute_name: self.attributes[family][preset]} + {attribute_name: cls.attributes[family][preset]} ) except KeyError: attributes.update({ node_name: { - attribute_name: self.attributes[family][preset] + attribute_name: cls.attributes[family][preset] } }) diff --git a/openpype/hosts/maya/plugins/publish/validate_color_sets.py b/openpype/hosts/maya/plugins/publish/validate_color_sets.py index 905417bafa..7ce3cca61a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_color_sets.py +++ b/openpype/hosts/maya/plugins/publish/validate_color_sets.py @@ -19,7 +19,6 @@ class ValidateColorSets(pyblish.api.Validator): order = ValidateMeshOrder hosts = ['maya'] families = ['model'] - category = 'geometry' label = 'Mesh ColorSets' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_frame_range.py b/openpype/hosts/maya/plugins/publish/validate_frame_range.py index d86925184e..59b06874b3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/maya/plugins/publish/validate_frame_range.py @@ -57,6 +57,10 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): inst_start = int(instance.data.get("frameStartHandle")) inst_end = int(instance.data.get("frameEndHandle")) + inst_frame_start = int(instance.data.get("frameStart")) + inst_frame_end = int(instance.data.get("frameEnd")) + inst_handle_start = int(instance.data.get("handleStart")) + inst_handle_end = int(instance.data.get("handleEnd")) # basic sanity checks assert frame_start_handle <= frame_end_handle, ( @@ -69,24 +73,37 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): if [ef for ef in self.exclude_families if instance.data["family"] in ef]: return - if(inst_start != frame_start_handle): + if (inst_start != frame_start_handle): errors.append("Instance start frame [ {} ] doesn't " - "match the one set on instance [ {} ]: " + "match the one set on asset [ {} ]: " "{}/{}/{}/{} (handle/start/end/handle)".format( inst_start, frame_start_handle, handle_start, frame_start, frame_end, handle_end )) - if(inst_end != frame_end_handle): + if (inst_end != frame_end_handle): errors.append("Instance end frame [ {} ] doesn't " - "match the one set on instance [ {} ]: " + "match the one set on asset [ {} ]: " "{}/{}/{}/{} (handle/start/end/handle)".format( inst_end, frame_end_handle, handle_start, frame_start, frame_end, handle_end )) + checks = { + "frame start": (frame_start, inst_frame_start), + "frame end": (frame_end, inst_frame_end), + "handle start": (handle_start, inst_handle_start), + "handle end": (handle_end, inst_handle_end) + } + for label, values in checks.items(): + if values[0] != values[1]: + errors.append( + "{} on instance ({}) does not match with the asset " + "({}).".format(label.title(), values[1], values[0]) + ) + for e in errors: self.log.error(e) diff --git a/openpype/hosts/maya/plugins/publish/validate_glsl_material.py b/openpype/hosts/maya/plugins/publish/validate_glsl_material.py new file mode 100644 index 0000000000..10c48da404 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_glsl_material.py @@ -0,0 +1,207 @@ +import os +from maya import cmds + +import pyblish.api +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder +) +from openpype.pipeline import PublishValidationError + + +class ValidateGLSLMaterial(pyblish.api.InstancePlugin): + """ + Validate if the asset uses GLSL Shader + """ + + order = ValidateContentsOrder + 0.1 + families = ['gltf'] + hosts = ['maya'] + label = 'GLSL Shader for GLTF' + actions = [RepairAction] + optional = True + active = True + + def process(self, instance): + shading_grp = self.get_material_from_shapes(instance) + if not shading_grp: + raise PublishValidationError("No shading group found") + invalid = self.get_texture_shader_invalid(instance) + if invalid: + raise PublishValidationError("Non GLSL Shader found: " + "{0}".format(invalid)) + + def get_material_from_shapes(self, instance): + shapes = cmds.ls(instance, type="mesh", long=True) + for shape in shapes: + shading_grp = cmds.listConnections(shape, + destination=True, + type="shadingEngine") + + return shading_grp or [] + + def get_texture_shader_invalid(self, instance): + + invalid = set() + shading_grp = self.get_material_from_shapes(instance) + for shading_group in shading_grp: + material_name = "{}.surfaceShader".format(shading_group) + material = cmds.listConnections(material_name, + source=True, + destination=False, + type="GLSLShader") + + if not material: + # add material name + material = cmds.listConnections(material_name)[0] + invalid.add(material) + + return list(invalid) + + @classmethod + def repair(cls, instance): + """ + Repair instance by assigning GLSL Shader + to the material + """ + cls.assign_glsl_shader(instance) + return + + @classmethod + def assign_glsl_shader(cls, instance): + """ + Converting StingrayPBS material to GLSL Shaders + for the glb export through Maya2GLTF plugin + """ + + meshes = cmds.ls(instance, type="mesh", long=True) + cls.log.info("meshes: {}".format(meshes)) + # load the glsl shader plugin + cmds.loadPlugin("glslShader", quiet=True) + + for mesh in meshes: + # create glsl shader + glsl = cmds.createNode('GLSLShader') + glsl_shading_grp = cmds.sets(name=glsl + "SG", empty=True, + renderable=True, noSurfaceShader=True) + cmds.connectAttr(glsl + ".outColor", + glsl_shading_grp + ".surfaceShader") + + # load the maya2gltf shader + ogsfx_path = instance.context.data["project_settings"]["maya"]["publish"]["ExtractGLB"]["ogsfx_path"] # noqa + if not os.path.exists(ogsfx_path): + if ogsfx_path: + # if custom ogsfx path is not specified + # the log below is the warning for the user + cls.log.warning("ogsfx shader file " + "not found in {}".format(ogsfx_path)) + + cls.log.info("Find the ogsfx shader file in " + "default maya directory...") + # re-direct to search the ogsfx path in maya_dir + ogsfx_path = os.getenv("MAYA_APP_DIR") + ogsfx_path + if not os.path.exists(ogsfx_path): + raise PublishValidationError("The ogsfx shader file does not " # noqa + "exist: {}".format(ogsfx_path)) # noqa + + cmds.setAttr(glsl + ".shader", ogsfx_path, typ="string") + # list the materials used for the assets + shading_grp = cmds.listConnections(mesh, + destination=True, + type="shadingEngine") + + # get the materials related to the selected assets + for material in shading_grp: + pbs_shader = cmds.listConnections(material, + destination=True, + type="StingrayPBS") + if pbs_shader: + cls.pbs_shader_conversion(pbs_shader, glsl) + # setting up to relink the texture if + # the mesh is with aiStandardSurface + arnold_shader = cmds.listConnections(material, + destination=True, + type="aiStandardSurface") + if arnold_shader: + cls.arnold_shader_conversion(arnold_shader, glsl) + + cmds.sets(mesh, forceElement=str(glsl_shading_grp)) + + @classmethod + def pbs_shader_conversion(cls, main_shader, glsl): + + cls.log.info("StringrayPBS detected " + "-> Can do texture conversion") + + for shader in main_shader: + # get the file textures related to the PBS Shader + albedo = cmds.listConnections(shader + + ".TEX_color_map") + if albedo: + dif_output = albedo[0] + ".outColor" + # get the glsl_shader input + # reconnect the file nodes to maya2gltf shader + glsl_dif = glsl + ".u_BaseColorTexture" + cmds.connectAttr(dif_output, glsl_dif) + + # connect orm map if there is one + orm_packed = cmds.listConnections(shader + + ".TEX_ao_map") + if orm_packed: + orm_output = orm_packed[0] + ".outColor" + + mtl = glsl + ".u_MetallicTexture" + ao = glsl + ".u_OcclusionTexture" + rough = glsl + ".u_RoughnessTexture" + + cmds.connectAttr(orm_output, mtl) + cmds.connectAttr(orm_output, ao) + cmds.connectAttr(orm_output, rough) + + # connect nrm map if there is one + nrm = cmds.listConnections(shader + + ".TEX_normal_map") + if nrm: + nrm_output = nrm[0] + ".outColor" + glsl_nrm = glsl + ".u_NormalTexture" + cmds.connectAttr(nrm_output, glsl_nrm) + + @classmethod + def arnold_shader_conversion(cls, main_shader, glsl): + cls.log.info("aiStandardSurface detected " + "-> Can do texture conversion") + + for shader in main_shader: + # get the file textures related to the PBS Shader + albedo = cmds.listConnections(shader + ".baseColor") + if albedo: + dif_output = albedo[0] + ".outColor" + # get the glsl_shader input + # reconnect the file nodes to maya2gltf shader + glsl_dif = glsl + ".u_BaseColorTexture" + cmds.connectAttr(dif_output, glsl_dif) + + orm_packed = cmds.listConnections(shader + + ".specularRoughness") + if orm_packed: + orm_output = orm_packed[0] + ".outColor" + + mtl = glsl + ".u_MetallicTexture" + ao = glsl + ".u_OcclusionTexture" + rough = glsl + ".u_RoughnessTexture" + + cmds.connectAttr(orm_output, mtl) + cmds.connectAttr(orm_output, ao) + cmds.connectAttr(orm_output, rough) + + # connect nrm map if there is one + bump_node = cmds.listConnections(shader + + ".normalCamera") + if bump_node: + for bump in bump_node: + nrm = cmds.listConnections(bump + + ".bumpValue") + if nrm: + nrm_output = nrm[0] + ".outColor" + glsl_nrm = glsl + ".u_NormalTexture" + cmds.connectAttr(nrm_output, glsl_nrm) diff --git a/openpype/hosts/maya/plugins/publish/validate_glsl_plugin.py b/openpype/hosts/maya/plugins/publish/validate_glsl_plugin.py new file mode 100644 index 0000000000..53c2cf548a --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_glsl_plugin.py @@ -0,0 +1,31 @@ + +from maya import cmds + +import pyblish.api +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder +) + + +class ValidateGLSLPlugin(pyblish.api.InstancePlugin): + """ + Validate if the asset uses GLSL Shader + """ + + order = ValidateContentsOrder + 0.15 + families = ['gltf'] + hosts = ['maya'] + label = 'maya2glTF plugin' + actions = [RepairAction] + + def process(self, instance): + if not cmds.pluginInfo("maya2glTF", query=True, loaded=True): + raise RuntimeError("maya2glTF is not loaded") + + @classmethod + def repair(cls, instance): + """ + Repair instance by enabling the plugin + """ + return cmds.loadPlugin("maya2glTF", quiet=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py b/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py new file mode 100644 index 0000000000..f870c9f8c4 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py @@ -0,0 +1,60 @@ +from maya import cmds + +import pyblish.api +from openpype.pipeline.publish import ( + ValidateContentsOrder, PublishValidationError, RepairAction +) +from openpype.pipeline import discover_legacy_creator_plugins +from openpype.hosts.maya.api.lib import imprint + + +class ValidateInstanceAttributes(pyblish.api.InstancePlugin): + """Validate Instance Attributes. + + New attributes can be introduced as new features come in. Old instances + will need to be updated with these attributes for the documentation to make + sense, and users do not have to recreate the instances. + """ + + order = ValidateContentsOrder + hosts = ["maya"] + families = ["*"] + label = "Instance Attributes" + plugins_by_family = { + p.family: p for p in discover_legacy_creator_plugins() + } + actions = [RepairAction] + + @classmethod + def get_missing_attributes(self, instance): + plugin = self.plugins_by_family[instance.data["family"]] + subset = instance.data["subset"] + asset = instance.data["asset"] + objset = instance.data["objset"] + + missing_attributes = {} + for key, value in plugin(subset, asset).data.items(): + if not cmds.objExists("{}.{}".format(objset, key)): + missing_attributes[key] = value + + return missing_attributes + + def process(self, instance): + objset = instance.data.get("objset") + if objset is None: + self.log.debug( + "Skipping {} because no objectset found.".format(instance) + ) + return + + missing_attributes = self.get_missing_attributes(instance) + if missing_attributes: + raise PublishValidationError( + "Missing attributes on {}:\n{}".format( + objset, missing_attributes + ) + ) + + @classmethod + def repair(cls, instance): + imprint(instance.data["objset"], cls.get_missing_attributes(instance)) diff --git a/openpype/hosts/maya/plugins/publish/validate_maya_units.py b/openpype/hosts/maya/plugins/publish/validate_maya_units.py index 5698d795ff..357dde692c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_maya_units.py +++ b/openpype/hosts/maya/plugins/publish/validate_maya_units.py @@ -4,17 +4,12 @@ import pyblish.api import openpype.hosts.maya.api.lib as mayalib from openpype.pipeline.context_tools import get_current_project_asset -from math import ceil from openpype.pipeline.publish import ( RepairContextAction, ValidateSceneOrder, ) -def float_round(num, places=0, direction=ceil): - return direction(num * (10**places)) / float(10**places) - - class ValidateMayaUnits(pyblish.api.ContextPlugin): """Check if the Maya units are set correct""" @@ -36,18 +31,12 @@ class ValidateMayaUnits(pyblish.api.ContextPlugin): # Collected units linearunits = context.data.get('linearUnits') angularunits = context.data.get('angularUnits') - # TODO(antirotor): This is hack as for framerates having multiple - # decimal places. FTrack is ceiling decimal values on - # fps to two decimal places but Maya 2019+ is reporting those fps - # with much higher resolution. As we currently cannot fix Ftrack - # rounding, we have to round those numbers coming from Maya. - # NOTE: this must be revisited yet again as it seems that Ftrack is - # now flooring the value? - fps = float_round(context.data.get('fps'), 2, ceil) + + fps = context.data.get('fps') # TODO repace query with using 'context.data["assetEntity"]' asset_doc = get_current_project_asset() - asset_fps = asset_doc["data"]["fps"] + asset_fps = mayalib.convert_to_maya_fps(asset_doc["data"]["fps"]) self.log.info('Units (linear): {0}'.format(linearunits)) self.log.info('Units (angular): {0}'.format(angularunits)) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index c1c0636b9e..fa4c66952c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -19,7 +19,6 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin): order = ValidateMeshOrder hosts = ["maya"] families = ["model"] - category = "geometry" label = "Mesh Arnold Attributes" actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_empty.py b/openpype/hosts/maya/plugins/publish/validate_mesh_empty.py new file mode 100644 index 0000000000..848d66c4ae --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_empty.py @@ -0,0 +1,54 @@ +from maya import cmds + +import pyblish.api +import openpype.hosts.maya.api.action +from openpype.pipeline.publish import ( + RepairAction, + ValidateMeshOrder +) + + +class ValidateMeshEmpty(pyblish.api.InstancePlugin): + """Validate meshes have some vertices. + + Its possible to have meshes without any vertices. To replicate + this issue, delete all faces/polygons then all edges. + """ + + order = ValidateMeshOrder + hosts = ["maya"] + families = ["model"] + label = "Mesh Empty" + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction + ] + + @classmethod + def repair(cls, instance): + invalid = cls.get_invalid(instance) + for node in invalid: + cmds.delete(node) + + @classmethod + def get_invalid(cls, instance): + invalid = [] + + meshes = cmds.ls(instance, type="mesh", long=True) + for mesh in meshes: + num_vertices = cmds.polyEvaluate(mesh, vertex=True) + + if num_vertices == 0: + cls.log.warning( + "\"{}\" does not have any vertices.".format(mesh) + ) + invalid.append(mesh) + + return invalid + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError( + "Meshes found in instance without any vertices: %s" % invalid + ) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py b/openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py index 36a0da7a59..b7836b3e92 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py @@ -1,39 +1,9 @@ -import re - from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateMeshOrder - - -def len_flattened(components): - """Return the length of the list as if it was flattened. - - Maya will return consecutive components as a single entry - when requesting with `maya.cmds.ls` without the `flatten` - flag. Though enabling `flatten` on a large list (e.g. millions) - will result in a slow result. This command will return the amount - of entries in a non-flattened list by parsing the result with - regex. - - Args: - components (list): The non-flattened components. - - Returns: - int: The amount of entries. - - """ - assert isinstance(components, (list, tuple)) - n = 0 - for c in components: - match = re.search("\[([0-9]+):([0-9]+)\]", c) - if match: - start, end = match.groups() - n += int(end) - int(start) + 1 - else: - n += 1 - return n +from openpype.hosts.maya.api.lib import len_flattened class ValidateMeshHasUVs(pyblish.api.InstancePlugin): @@ -48,7 +18,6 @@ class ValidateMeshHasUVs(pyblish.api.InstancePlugin): order = ValidateMeshOrder hosts = ['maya'] families = ['model'] - category = 'geometry' label = 'Mesh Has UVs' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True @@ -58,6 +27,15 @@ class ValidateMeshHasUVs(pyblish.api.InstancePlugin): invalid = [] for node in cmds.ls(instance, type='mesh'): + num_vertices = cmds.polyEvaluate(node, vertex=True) + + if num_vertices == 0: + cls.log.warning( + "Skipping \"{}\", cause it does not have any " + "vertices.".format(node) + ) + continue + uv = cmds.polyEvaluate(node, uv=True) if uv == 0: diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py b/openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py index 4427c6eece..f120361583 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py @@ -15,8 +15,6 @@ class ValidateMeshLaminaFaces(pyblish.api.InstancePlugin): order = ValidateMeshOrder hosts = ['maya'] families = ['model'] - category = 'geometry' - version = (0, 1, 0) label = 'Mesh Lamina Faces' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py b/openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py index 0ef2716559..b49ba85648 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py @@ -19,8 +19,6 @@ class ValidateMeshNonZeroEdgeLength(pyblish.api.InstancePlugin): order = ValidateMeshOrder families = ['model'] hosts = ['maya'] - category = 'geometry' - version = (0, 1, 0) label = 'Mesh Edge Length Non Zero' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True @@ -30,7 +28,10 @@ class ValidateMeshNonZeroEdgeLength(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): """Return the invalid edges. - Also see: http://help.autodesk.com/view/MAYAUL/2015/ENU/?guid=Mesh__Cleanup + + Also see: + + http://help.autodesk.com/view/MAYAUL/2015/ENU/?guid=Mesh__Cleanup """ @@ -38,8 +39,21 @@ class ValidateMeshNonZeroEdgeLength(pyblish.api.InstancePlugin): if not meshes: return list() + valid_meshes = [] + for mesh in meshes: + num_vertices = cmds.polyEvaluate(mesh, vertex=True) + + if num_vertices == 0: + cls.log.warning( + "Skipping \"{}\", cause it does not have any " + "vertices.".format(mesh) + ) + continue + + valid_meshes.append(mesh) + # Get all edges - edges = ['{0}.e[*]'.format(node) for node in meshes] + edges = ['{0}.e[*]'.format(node) for node in valid_meshes] # Filter by constraint on edge length invalid = lib.polyConstraint(edges, diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py b/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py index c8892a8e59..1b754a9829 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py @@ -20,8 +20,6 @@ class ValidateMeshNormalsUnlocked(pyblish.api.Validator): order = ValidateMeshOrder hosts = ['maya'] families = ['model'] - category = 'geometry' - version = (0, 1, 0) label = 'Mesh Normals Unlocked' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index be7324a68f..be23f61ec5 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -235,7 +235,6 @@ class ValidateMeshHasOverlappingUVs(pyblish.api.InstancePlugin): order = ValidateMeshOrder hosts = ['maya'] families = ['model'] - category = 'geometry' label = 'Mesh Has Overlapping UVs' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py b/openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py index 6ca8c06ba5..faa360380e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py @@ -21,9 +21,7 @@ class ValidateMeshSingleUVSet(pyblish.api.InstancePlugin): order = ValidateMeshOrder hosts = ['maya'] families = ['model', 'pointcache'] - category = 'uv' optional = True - version = (0, 1, 0) label = "Mesh Single UV Set" actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py b/openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py index 1e6d290ae7..d885158004 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py @@ -1,5 +1,3 @@ -import re - from maya import cmds import pyblish.api @@ -8,37 +6,7 @@ from openpype.pipeline.publish import ( RepairAction, ValidateMeshOrder, ) - - -def len_flattened(components): - """Return the length of the list as if it was flattened. - - Maya will return consecutive components as a single entry - when requesting with `maya.cmds.ls` without the `flatten` - flag. Though enabling `flatten` on a large list (e.g. millions) - will result in a slow result. This command will return the amount - of entries in a non-flattened list by parsing the result with - regex. - - Args: - components (list): The non-flattened components. - - Returns: - int: The amount of entries. - - """ - assert isinstance(components, (list, tuple)) - n = 0 - - pattern = re.compile(r"\[(\d+):(\d+)\]") - for c in components: - match = pattern.search(c) - if match: - start, end = match.groups() - n += int(end) - int(start) + 1 - else: - n += 1 - return n +from openpype.hosts.maya.api.lib import len_flattened class ValidateMeshVerticesHaveEdges(pyblish.api.InstancePlugin): @@ -63,7 +31,6 @@ class ValidateMeshVerticesHaveEdges(pyblish.api.InstancePlugin): order = ValidateMeshOrder hosts = ['maya'] families = ['model'] - category = 'geometry' label = 'Mesh Vertices Have Edges' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] @@ -88,6 +55,13 @@ class ValidateMeshVerticesHaveEdges(pyblish.api.InstancePlugin): for mesh in meshes: num_vertices = cmds.polyEvaluate(mesh, vertex=True) + if num_vertices == 0: + cls.log.warning( + "Skipping \"{}\", cause it does not have any " + "vertices.".format(mesh) + ) + continue + # Vertices from all edges edges = "%s.e[*]" % mesh vertices = cmds.polyListComponentConversion(edges, toVertex=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_no_default_camera.py b/openpype/hosts/maya/plugins/publish/validate_no_default_camera.py index 1a5773e6a7..a4fb938d43 100644 --- a/openpype/hosts/maya/plugins/publish/validate_no_default_camera.py +++ b/openpype/hosts/maya/plugins/publish/validate_no_default_camera.py @@ -16,7 +16,6 @@ class ValidateNoDefaultCameras(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ['maya'] families = ['camera'] - version = (0, 1, 0) label = "No Default Cameras" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_no_namespace.py b/openpype/hosts/maya/plugins/publish/validate_no_namespace.py index 01c77e5b2e..e91b99359d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_no_namespace.py +++ b/openpype/hosts/maya/plugins/publish/validate_no_namespace.py @@ -23,8 +23,6 @@ class ValidateNoNamespace(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ['maya'] families = ['model'] - category = 'cleanup' - version = (0, 1, 0) label = 'No Namespaces' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py b/openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py index b430c2b63c..f77fc81dc1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py +++ b/openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py @@ -43,8 +43,6 @@ class ValidateNoNullTransforms(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ['maya'] families = ['model'] - category = 'cleanup' - version = (0, 1, 0) label = 'No Empty/Null Transforms' actions = [RepairAction, openpype.hosts.maya.api.action.SelectInvalidAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py index 7e3c656a1e..6135c9c695 100644 --- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py @@ -24,7 +24,7 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): validate_path = ( instance.context.data["project_settings"]["maya"]["publish"] ) - file_attr = validate_path["ValidatePathForPlugin"]["attribute"] + file_attr = validate_path["ValidatePluginPathAttributes"]["attribute"] if not file_attr: return invalid @@ -39,7 +39,7 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): filepath = cmds.getAttr(file_attr) if filepath and not os.path.exists(filepath): - self.log.error("File {0} not exists".format(filepath)) # noqa + self.log.error("File {0} not exists".format(filepath)) # noqa invalid.append(target) return invalid diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py b/openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py index d5bf7fd1cf..30d95128a2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py @@ -24,7 +24,6 @@ class ValidateRigJointsHidden(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ['maya'] families = ['rig'] - version = (0, 1, 0) label = "Joints Hidden" actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py b/openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py index ec2bea220d..f1fa4d3c4c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py +++ b/openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py @@ -31,8 +31,6 @@ class ValidateSceneSetWorkspace(pyblish.api.ContextPlugin): order = ValidatePipelineOrder hosts = ['maya'] - category = 'scene' - version = (0, 1, 0) label = 'Maya Workspace Set' def process(self, context): diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_default_names.py b/openpype/hosts/maya/plugins/publish/validate_shape_default_names.py index 651c6bcec9..4ab669f46b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_default_names.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_default_names.py @@ -38,9 +38,7 @@ class ValidateShapeDefaultNames(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ['maya'] families = ['model'] - category = 'cleanup' optional = True - version = (0, 1, 0) label = "Shape Default Naming" actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] 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 65551c8d5e..0147aa8a52 100644 --- a/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py +++ b/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py @@ -32,9 +32,7 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): order = ValidateContentsOrder hosts = ['maya'] families = ['model'] - category = 'cleanup' optional = True - version = (0, 1, 0) label = 'Suffix Naming Conventions' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] SUFFIX_NAMING_TABLE = {"mesh": ["_GEO", "_GES", "_GEP", "_OSD"], diff --git a/openpype/hosts/maya/plugins/publish/validate_transform_zero.py b/openpype/hosts/maya/plugins/publish/validate_transform_zero.py index da569195e8..abd9e00af1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_transform_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_transform_zero.py @@ -18,8 +18,6 @@ class ValidateTransformZero(pyblish.api.Validator): order = ValidateContentsOrder hosts = ["maya"] families = ["model"] - category = "geometry" - version = (0, 1, 0) label = "Transform Zero (Freeze)" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py index 4211e76a73..e78962bf97 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py @@ -13,7 +13,6 @@ class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin): order = ValidateMeshOrder hosts = ["maya"] families = ["staticMesh"] - category = "geometry" label = "Mesh is Triangulated" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] active = False diff --git a/openpype/hosts/maya/plugins/publish/validate_vray.py b/openpype/hosts/maya/plugins/publish/validate_vray.py new file mode 100644 index 0000000000..045ac258a1 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_vray.py @@ -0,0 +1,18 @@ +from maya import cmds + +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateVray(pyblish.api.InstancePlugin): + """Validate general Vray setup.""" + + order = pyblish.api.ValidatorOrder + label = 'VRay' + hosts = ["maya"] + families = ["vrayproxy"] + + def process(self, instance): + # Validate vray plugin is loaded. + if not cmds.pluginInfo("vrayformaya", query=True, loaded=True): + raise PublishValidationError("Vray plugin is not loaded.") diff --git a/openpype/hosts/maya/plugins/publish/validate_vrayproxy.py b/openpype/hosts/maya/plugins/publish/validate_vrayproxy.py index 3eceace76d..a106b970b4 100644 --- a/openpype/hosts/maya/plugins/publish/validate_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/validate_vrayproxy.py @@ -1,27 +1,31 @@ import pyblish.api +from openpype.pipeline import KnownPublishError + class ValidateVrayProxy(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder - label = 'VRay Proxy Settings' - hosts = ['maya'] - families = ['studio.vrayproxy'] + label = "VRay Proxy Settings" + hosts = ["maya"] + families = ["vrayproxy"] def process(self, instance): - - invalid = self.get_invalid(instance) - if invalid: - raise RuntimeError("'%s' has invalid settings for VRay Proxy " - "export!" % instance.name) - - @classmethod - def get_invalid(cls, instance): data = instance.data if not data["setMembers"]: - cls.log.error("'%s' is empty! This is a bug" % instance.name) + raise KnownPublishError( + "'%s' is empty! This is a bug" % instance.name + ) if data["animation"]: if data["frameEnd"] < data["frameStart"]: - cls.log.error("End frame is smaller than start frame") + raise KnownPublishError( + "End frame is smaller than start frame" + ) + + if not data["vrmesh"] and not data["alembic"]: + raise KnownPublishError( + "Both vrmesh and alembic are off. Needs at least one to" + " publish." + ) diff --git a/openpype/hosts/maya/plugins/publish/validate_xgen.py b/openpype/hosts/maya/plugins/publish/validate_xgen.py new file mode 100644 index 0000000000..2870909974 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_xgen.py @@ -0,0 +1,59 @@ +import json + +import maya.cmds as cmds +import xgenm + +import pyblish.api +from openpype.pipeline.publish import PublishValidationError + + +class ValidateXgen(pyblish.api.InstancePlugin): + """Validate Xgen data.""" + + label = "Validate Xgen" + order = pyblish.api.ValidatorOrder + host = ["maya"] + families = ["xgen"] + + def process(self, instance): + set_members = instance.data.get("setMembers") + + # Only 1 collection/node per instance. + if len(set_members) != 1: + raise PublishValidationError( + "Only one collection per instance is allowed." + " Found:\n{}".format(set_members) + ) + + # Only xgen palette node is allowed. + node_type = cmds.nodeType(set_members[0]) + if node_type != "xgmPalette": + raise PublishValidationError( + "Only node of type \"xgmPalette\" are allowed. Referred to as" + " \"collection\" in the Maya UI." + " Node type found: {}".format(node_type) + ) + + # Cant have inactive modifiers in collection cause Xgen will try and + # look for them when loading. + palette = instance.data["xgmPalette"].replace("|", "") + inactive_modifiers = {} + for description in instance.data["xgmDescriptions"]: + description = description.split("|")[-2] + modifier_names = xgenm.fxModules(palette, description) + for name in modifier_names: + attr = xgenm.getAttr("active", palette, description, name) + # Attribute value are lowercase strings of false/true. + if attr == "false": + try: + inactive_modifiers[description].append(name) + except KeyError: + inactive_modifiers[description] = [name] + + if inactive_modifiers: + raise PublishValidationError( + "There are inactive modifiers on the collection. " + "Please delete these:\n{}".format( + json.dumps(inactive_modifiers, indent=4, sort_keys=True) + ) + ) diff --git a/openpype/hosts/maya/startup/userSetup.py b/openpype/hosts/maya/startup/userSetup.py index 40cd51f2d8..c77ecb829e 100644 --- a/openpype/hosts/maya/startup/userSetup.py +++ b/openpype/hosts/maya/startup/userSetup.py @@ -1,16 +1,34 @@ import os +from functools import partial + from openpype.settings import get_project_settings from openpype.pipeline import install_host from openpype.hosts.maya.api import MayaHost + from maya import cmds + host = MayaHost() install_host(host) +print("Starting OpenPype usersetup...") -print("starting OpenPype usersetup") -# build a shelf +# Open Workfile Post Initialization. +key = "OPENPYPE_OPEN_WORKFILE_POST_INITIALIZATION" +if bool(int(os.environ.get(key, "0"))): + cmds.evalDeferred( + partial( + cmds.file, + os.environ["AVALON_LAST_WORKFILE"], + open=True, + force=True + ), + lowestPriority=True + ) + + +# Build a shelf. settings = get_project_settings(os.environ['AVALON_PROJECT']) shelf_preset = settings['maya'].get('project_shelf') @@ -26,7 +44,10 @@ if shelf_preset: print(import_string) exec(import_string) - cmds.evalDeferred("mlib.shelf(name=shelf_preset['name'], iconPath=icon_path, preset=shelf_preset)") + cmds.evalDeferred( + "mlib.shelf(name=shelf_preset['name'], iconPath=icon_path," + " preset=shelf_preset)" + ) -print("finished OpenPype usersetup") +print("Finished OpenPype usersetup.") diff --git a/openpype/hosts/nuke/addon.py b/openpype/hosts/nuke/addon.py index 9d25afe2b6..6a4b91a76d 100644 --- a/openpype/hosts/nuke/addon.py +++ b/openpype/hosts/nuke/addon.py @@ -63,5 +63,12 @@ class NukeAddon(OpenPypeModule, IHostAddon): path_paths.append(quick_time_path) env["PATH"] = os.pathsep.join(path_paths) + def get_launch_hook_paths(self, app): + if app.host_name != self.host_name: + return [] + return [ + os.path.join(NUKE_ROOT_DIR, "hooks") + ] + def get_workfile_extensions(self): return [".nk"] diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 3b00ca9f6f..1af5ff365d 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -30,7 +30,6 @@ from .pipeline import ( parse_container, update_container, - get_workfile_build_placeholder_plugins, ) from .lib import ( INSTANCE_DATA_KNOB, @@ -79,8 +78,6 @@ __all__ = ( "parse_container", "update_container", - "get_workfile_build_placeholder_plugins", - "INSTANCE_DATA_KNOB", "ROOT_DATA_KNOB", "maintained_selection", diff --git a/openpype/hosts/nuke/api/constants.py b/openpype/hosts/nuke/api/constants.py new file mode 100644 index 0000000000..110199720f --- /dev/null +++ b/openpype/hosts/nuke/api/constants.py @@ -0,0 +1,4 @@ +import os + + +ASSIST = bool(os.getenv("NUKEASSIST")) diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py index 9edfc62e3b..5838ee8a8a 100644 --- a/openpype/hosts/nuke/api/gizmo_menu.py +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -53,12 +53,18 @@ class GizmoMenu(): item_type = item.get("sourcetype") - if item_type == ("python" or "file"): + if item_type == "python": parent.addCommand( item["title"], command=str(item["command"]), icon=item.get("icon"), - shortcut=item.get("hotkey") + shortcut=item.get("shortcut") + ) + elif item_type == "file": + parent.addCommand( + item['title'], + "nuke.createNode('{}')".format(item.get('file_name')), + shortcut=item.get('shortcut') ) # add separator diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 0325838e78..c08db978d3 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -50,6 +50,7 @@ from openpype.pipeline.colorspace import ( from openpype.pipeline.workfile import BuildWorkfile from . import gizmo_menu +from .constants import ASSIST from .workio import ( save_file, @@ -215,7 +216,7 @@ def update_node_data(node, knobname, data): class Knobby(object): - """[DEPRICATED] For creating knob which it's type isn't + """[DEPRECATED] For creating knob which it's type isn't mapped in `create_knobs` Args: @@ -249,7 +250,7 @@ class Knobby(object): def create_knobs(data, tab=None): - """[DEPRICATED] Create knobs by data + """Create knobs by data Depending on the type of each dict value and creates the correct Knob. @@ -343,7 +344,7 @@ def create_knobs(data, tab=None): def imprint(node, data, tab=None): - """[DEPRICATED] Store attributes with value on node + """Store attributes with value on node Parse user data into Node knobs. Use `collections.OrderedDict` to ensure knob order. @@ -398,8 +399,9 @@ def imprint(node, data, tab=None): node.addKnob(knob) +@deprecated def add_publish_knob(node): - """[DEPRICATED] Add Publish knob to node + """[DEPRECATED] Add Publish knob to node Arguments: node (nuke.Node): nuke node to be processed @@ -416,8 +418,9 @@ def add_publish_knob(node): return node +@deprecated def set_avalon_knob_data(node, data=None, prefix="avalon:"): - """[DEPRICATED] Sets data into nodes's avalon knob + """[DEPRECATED] Sets data into nodes's avalon knob Arguments: node (nuke.Node): Nuke node to imprint with data, @@ -478,8 +481,9 @@ def set_avalon_knob_data(node, data=None, prefix="avalon:"): return node +@deprecated def get_avalon_knob_data(node, prefix="avalon:", create=True): - """[DEPRICATED] Gets a data from nodes's avalon knob + """[DEPRECATED] Gets a data from nodes's avalon knob Arguments: node (obj): Nuke node to search for data, @@ -521,8 +525,9 @@ def get_avalon_knob_data(node, prefix="avalon:", create=True): return data +@deprecated def fix_data_for_node_create(data): - """[DEPRICATED] Fixing data to be used for nuke knobs + """[DEPRECATED] Fixing data to be used for nuke knobs """ for k, v in data.items(): if isinstance(v, six.text_type): @@ -532,8 +537,9 @@ def fix_data_for_node_create(data): return data +@deprecated def add_write_node_legacy(name, **kwarg): - """[DEPRICATED] Adding nuke write node + """[DEPRECATED] Adding nuke write node Arguments: name (str): nuke node name kwarg (attrs): data for nuke knobs @@ -697,7 +703,7 @@ def get_nuke_imageio_settings(): @deprecated("openpype.hosts.nuke.api.lib.get_nuke_imageio_settings") def get_created_node_imageio_setting_legacy(nodeclass, creator, subset): - '''[DEPRICATED] Get preset data for dataflow (fileType, compression, bitDepth) + '''[DEPRECATED] Get preset data for dataflow (fileType, compression, bitDepth) ''' assert any([creator, nodeclass]), nuke.message( @@ -1241,7 +1247,7 @@ def create_write_node( nodes to be created before write with dependency review (bool)[optional]: adding review knob farm (bool)[optional]: rendering workflow target - kwargs (dict)[optional]: additional key arguments for formating + kwargs (dict)[optional]: additional key arguments for formatting Example: prenodes = { @@ -2258,14 +2264,20 @@ class WorkfileSettings(object): node['frame_range'].setValue(range) node['frame_range_lock'].setValue(True) - set_node_data( - self._root_node, - INSTANCE_DATA_KNOB, - { - "handleStart": int(handle_start), - "handleEnd": int(handle_end) - } - ) + if not ASSIST: + set_node_data( + self._root_node, + INSTANCE_DATA_KNOB, + { + "handleStart": int(handle_start), + "handleEnd": int(handle_end) + } + ) + else: + log.warning( + "NukeAssist mode is not allowing " + "updating custom knobs..." + ) def reset_resolution(self): """Set resolution to project resolution.""" diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 6dec60d81a..2496d66c1d 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -60,6 +60,7 @@ from .workio import ( work_root, current_file ) +from .constants import ASSIST log = Logger.get_logger(__name__) @@ -72,7 +73,6 @@ INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") MENU_LABEL = os.environ["AVALON_LABEL"] - # registering pyblish gui regarding settings in presets if os.getenv("PYBLISH_GUI", None): pyblish.api.register_gui(os.getenv("PYBLISH_GUI", None)) @@ -101,6 +101,12 @@ class NukeHost( def get_workfile_extensions(self): return file_extensions() + def get_workfile_build_placeholder_plugins(self): + return [ + NukePlaceholderLoadPlugin, + NukePlaceholderCreatePlugin + ] + def get_containers(self): return ls() @@ -200,45 +206,45 @@ def _show_workfiles(): host_tools.show_workfiles(parent=None, on_top=False) -def get_workfile_build_placeholder_plugins(): - return [ - NukePlaceholderLoadPlugin, - NukePlaceholderCreatePlugin - ] - - def _install_menu(): + """Install Avalon menu into Nuke's main menu bar.""" + # uninstall original avalon menu main_window = get_main_window() menubar = nuke.menu("Nuke") menu = menubar.addMenu(MENU_LABEL) - label = "{0}, {1}".format( - os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] - ) - Context.context_label = label - context_action = menu.addCommand(label) - context_action.setEnabled(False) + if not ASSIST: + label = "{0}, {1}".format( + os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] + ) + Context.context_label = label + context_action = menu.addCommand(label) + context_action.setEnabled(False) + + # add separator after context label + menu.addSeparator() - menu.addSeparator() menu.addCommand( "Work Files...", _show_workfiles ) menu.addSeparator() - menu.addCommand( - "Create...", - lambda: host_tools.show_publisher( - tab="create" + if not ASSIST: + menu.addCommand( + "Create...", + lambda: host_tools.show_publisher( + tab="create" + ) ) - ) - menu.addCommand( - "Publish...", - lambda: host_tools.show_publisher( - tab="publish" + menu.addCommand( + "Publish...", + lambda: host_tools.show_publisher( + tab="publish" + ) ) - ) + menu.addCommand( "Load...", lambda: host_tools.show_loader( @@ -286,15 +292,18 @@ def _install_menu(): "Build Workfile from template", lambda: build_workfile_template() ) - menu_template.addSeparator() - menu_template.addCommand( - "Create Place Holder", - lambda: create_placeholder() - ) - menu_template.addCommand( - "Update Place Holder", - lambda: update_placeholder() - ) + + if not ASSIST: + menu_template.addSeparator() + menu_template.addCommand( + "Create Place Holder", + lambda: create_placeholder() + ) + menu_template.addCommand( + "Update Place Holder", + lambda: update_placeholder() + ) + menu.addSeparator() menu.addCommand( "Experimental tools...", diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index d3f8357f7d..160ca820a4 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -558,9 +558,7 @@ class ExporterReview(object): self.path_in = self.instance.data.get("path", None) self.staging_dir = self.instance.data["stagingDir"] self.collection = self.instance.data.get("collection", None) - self.data = dict({ - "representations": list() - }) + self.data = {"representations": []} def get_file_info(self): if self.collection: @@ -626,7 +624,7 @@ class ExporterReview(object): nuke_imageio = opnlib.get_nuke_imageio_settings() # TODO: this is only securing backward compatibility lets remove - # this once all projects's anotomy are updated to newer config + # this once all projects's anatomy are updated to newer config if "baking" in nuke_imageio.keys(): return nuke_imageio["baking"]["viewerProcess"] else: @@ -823,8 +821,41 @@ class ExporterReviewMov(ExporterReview): add_tags = [] self.publish_on_farm = farm read_raw = kwargs["read_raw"] + + # TODO: remove this when `reformat_nodes_config` + # is changed in settings reformat_node_add = kwargs["reformat_node_add"] reformat_node_config = kwargs["reformat_node_config"] + + # TODO: make this required in future + reformat_nodes_config = kwargs.get("reformat_nodes_config", {}) + + # TODO: remove this once deprecated is removed + # make sure only reformat_nodes_config is used in future + if reformat_node_add and reformat_nodes_config.get("enabled"): + self.log.warning( + "`reformat_node_add` is deprecated. " + "Please use only `reformat_nodes_config` instead.") + reformat_nodes_config = None + + # TODO: reformat code when backward compatibility is not needed + # warning if reformat_nodes_config is not set + if not reformat_nodes_config: + self.log.warning( + "Please set `reformat_nodes_config` in settings. " + "Using `reformat_node_config` instead." + ) + reformat_nodes_config = { + "enabled": reformat_node_add, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": reformat_node_config + } + ] + } + + bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ "bake_viewer_input_process"] @@ -846,7 +877,6 @@ class ExporterReviewMov(ExporterReview): subset = self.instance.data["subset"] self._temp_nodes[subset] = [] - # ---------- start nodes creation # Read node r_node = nuke.createNode("Read") @@ -860,44 +890,39 @@ class ExporterReviewMov(ExporterReview): if read_raw: r_node["raw"].setValue(1) - # connect - self._temp_nodes[subset].append(r_node) - self.previous_node = r_node - self.log.debug("Read... `{}`".format(self._temp_nodes[subset])) + # connect to Read node + self._shift_to_previous_node_and_temp(subset, r_node, "Read... `{}`") # add reformat node - if reformat_node_add: + if reformat_nodes_config["enabled"]: + reposition_nodes = reformat_nodes_config["reposition_nodes"] + for reposition_node in reposition_nodes: + node_class = reposition_node["node_class"] + knobs = reposition_node["knobs"] + node = nuke.createNode(node_class) + set_node_knobs_from_settings(node, knobs) + + # connect in order + self._connect_to_above_nodes( + node, subset, "Reposition node... `{}`" + ) # append reformated tag add_tags.append("reformated") - rf_node = nuke.createNode("Reformat") - set_node_knobs_from_settings(rf_node, reformat_node_config) - - # connect - rf_node.setInput(0, self.previous_node) - self._temp_nodes[subset].append(rf_node) - self.previous_node = rf_node - self.log.debug( - "Reformat... `{}`".format(self._temp_nodes[subset])) - # only create colorspace baking if toggled on if bake_viewer_process: if bake_viewer_input_process_node: # View Process node ipn = get_view_process_node() if ipn is not None: - # connect - ipn.setInput(0, self.previous_node) - self._temp_nodes[subset].append(ipn) - self.previous_node = ipn - self.log.debug( - "ViewProcess... `{}`".format( - self._temp_nodes[subset])) + # connect to ViewProcess node + self._connect_to_above_nodes(ipn, subset, "ViewProcess... `{}`") if not self.viewer_lut_raw: # OCIODisplay dag_node = nuke.createNode("OCIODisplay") + # assign display display, viewer = get_viewer_config_from_string( str(baking_view_profile) ) @@ -907,13 +932,7 @@ class ExporterReviewMov(ExporterReview): # assign viewer dag_node["view"].setValue(viewer) - # connect - dag_node.setInput(0, self.previous_node) - self._temp_nodes[subset].append(dag_node) - self.previous_node = dag_node - self.log.debug("OCIODisplay... `{}`".format( - self._temp_nodes[subset])) - + self._connect_to_above_nodes(dag_node, subset, "OCIODisplay... `{}`") # Write node write_node = nuke.createNode("Write") self.log.debug("Path: {}".format(self.path)) @@ -967,6 +986,15 @@ class ExporterReviewMov(ExporterReview): return self.data + def _shift_to_previous_node_and_temp(self, subset, node, message): + self._temp_nodes[subset].append(node) + self.previous_node = node + self.log.debug(message.format(self._temp_nodes[subset])) + + def _connect_to_above_nodes(self, node, subset, message): + node.setInput(0, self.previous_node) + self._shift_to_previous_node_and_temp(subset, node, message) + @deprecated("openpype.hosts.nuke.api.plugin.NukeWriteCreator") class AbstractWriteRender(OpenPypeCreator): diff --git a/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py b/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py new file mode 100644 index 0000000000..3948a665c6 --- /dev/null +++ b/openpype/hosts/nuke/hooks/pre_nukeassist_setup.py @@ -0,0 +1,11 @@ +from openpype.lib import PreLaunchHook + + +class PrelaunchNukeAssistHook(PreLaunchHook): + """ + Adding flag when nukeassist + """ + app_groups = ["nukeassist"] + + def execute(self): + self.launch_context.env["NUKEASSIST"] = "1" diff --git a/openpype/hosts/nuke/plugins/load/actions.py b/openpype/hosts/nuke/plugins/load/actions.py index 69f56c7305..e562c74c58 100644 --- a/openpype/hosts/nuke/plugins/load/actions.py +++ b/openpype/hosts/nuke/plugins/load/actions.py @@ -17,6 +17,7 @@ class SetFrameRangeLoader(load.LoaderPlugin): "yeticache", "pointcache"] representations = ["*"] + extension = {"*"} label = "Set frame range" order = 11 diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index d1fb763500..f227aa161a 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -25,8 +25,9 @@ from openpype.hosts.nuke.api import containerise, update_container class LoadBackdropNodes(load.LoaderPlugin): """Loading Published Backdrop nodes (workfile, nukenodes)""" - representations = ["nk"] families = ["workfile", "nukenodes"] + representations = ["*"] + extension = {"nk"} label = "Import Nuke Nodes" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index 9fef7424c8..11cc63d25c 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -25,7 +25,8 @@ class AlembicCameraLoader(load.LoaderPlugin): """ families = ["camera"] - representations = ["abc"] + representations = ["*"] + extension = {"abc"} label = "Load Alembic Camera" icon = "camera" diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 565d777811..d170276add 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -21,6 +21,10 @@ from openpype.hosts.nuke.api import ( viewer_update_and_undo_stop, colorspace_exists_on_node ) +from openpype.lib.transcoding import ( + VIDEO_EXTENSIONS, + IMAGE_EXTENSIONS +) from openpype.hosts.nuke.api import plugin @@ -38,13 +42,10 @@ class LoadClip(plugin.NukeLoader): "prerender", "review" ] - representations = [ - "exr", - "dpx", - "mov", - "review", - "mp4" - ] + representations = ["*"] + extensions = set( + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + ) label = "Load Clip" order = -20 @@ -81,17 +82,17 @@ class LoadClip(plugin.NukeLoader): @classmethod def get_representations(cls): - return ( - cls.representations - + cls._representations - + plugin.get_review_presets_config() - ) + return cls._representations or cls.representations def load(self, context, name, namespace, options): + """Load asset via database + """ representation = context["representation"] - # reste container id so it is always unique for each instance + # reset container id so it is always unique for each instance self.reset_container_id() + self.log.warning(self.extensions) + is_sequence = len(representation["files"]) > 1 if is_sequence: @@ -220,8 +221,20 @@ class LoadClip(plugin.NukeLoader): dict: altered representation data """ representation = deepcopy(representation) - frame = representation["context"]["frame"] - representation["context"]["frame"] = "#" * len(str(frame)) + context = representation["context"] + template = representation["data"]["template"] + if ( + "{originalBasename}" in template + and "frame" in context + ): + frame = context["frame"] + hashed_frame = "#" * len(str(frame)) + origin_basename = context["originalBasename"] + context["originalBasename"] = origin_basename.replace( + frame, hashed_frame + ) + + representation["context"]["frame"] = hashed_frame return representation def update(self, container, representation): diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index cef4b0a5fc..d49f87a094 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -22,8 +22,9 @@ from openpype.hosts.nuke.api import ( class LoadEffects(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" - representations = ["effectJson"] families = ["effect"] + representations = ["*"] + extension = {"json"} label = "Load Effects - nodes" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index 9bd40be816..bfe32c1ed9 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -23,8 +23,9 @@ from openpype.hosts.nuke.api import ( class LoadEffectsInputProcess(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" - representations = ["effectJson"] families = ["effect"] + representations = ["*"] + extension = {"json"} label = "Load Effects - Input Process" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 9a18eeef5c..2aa7c49723 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -24,8 +24,9 @@ from openpype.hosts.nuke.api import ( class LoadGizmo(load.LoaderPlugin): """Loading nuke Gizmo""" - representations = ["gizmo"] families = ["gizmo"] + representations = ["*"] + extension = {"gizmo"} label = "Load Gizmo" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index 2890dbfd2c..2514a28299 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -26,8 +26,9 @@ from openpype.hosts.nuke.api import ( class LoadGizmoInputProcess(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" - representations = ["gizmo"] families = ["gizmo"] + representations = ["*"] + extension = {"gizmo"} label = "Load Gizmo - Input Process" order = 0 diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 49dc12f588..f82ee4db88 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -19,6 +19,9 @@ from openpype.hosts.nuke.api import ( update_container, viewer_update_and_undo_stop ) +from openpype.lib.transcoding import ( + IMAGE_EXTENSIONS +) class LoadImage(load.LoaderPlugin): @@ -33,7 +36,10 @@ class LoadImage(load.LoaderPlugin): "review", "image" ] - representations = ["exr", "dpx", "jpg", "jpeg", "png", "psd", "tiff"] + representations = ["*"] + extensions = set( + ext.lstrip(".") for ext in IMAGE_EXTENSIONS + ) label = "Load Image" order = -10 @@ -58,7 +64,7 @@ class LoadImage(load.LoaderPlugin): @classmethod def get_representations(cls): - return cls.representations + cls._representations + return cls._representations or cls.representations def load(self, context, name, namespace, options): self.log.info("__ options: `{}`".format(options)) diff --git a/openpype/hosts/nuke/plugins/load/load_matchmove.py b/openpype/hosts/nuke/plugins/load/load_matchmove.py index f5a90706c7..a7d124d472 100644 --- a/openpype/hosts/nuke/plugins/load/load_matchmove.py +++ b/openpype/hosts/nuke/plugins/load/load_matchmove.py @@ -8,7 +8,9 @@ class MatchmoveLoader(load.LoaderPlugin): """ families = ["matchmove"] - representations = ["py"] + representations = ["*"] + extension = {"py"} + defaults = ["Camera", "Object"] label = "Run matchmove script" diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index ad985e83c6..f968da8475 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -23,7 +23,8 @@ class AlembicModelLoader(load.LoaderPlugin): """ families = ["model", "pointcache", "animation"] - representations = ["abc"] + representations = ["*"] + extension = {"abc"} label = "Load Alembic" icon = "cube" diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index f0972f85d2..90581c2f22 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -20,8 +20,9 @@ from openpype.hosts.nuke.api import ( class LinkAsGroup(load.LoaderPlugin): """Copy the published file to be pasted at the desired location""" - representations = ["nk"] families = ["workfile", "nukenodes"] + representations = ["*"] + extension = {"nk"} label = "Load Precomp" order = 0 diff --git a/openpype/hosts/nuke/plugins/publish/collect_context_data.py b/openpype/hosts/nuke/plugins/publish/collect_context_data.py index 5a1cdcf49e..b487c946f0 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_context_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_context_data.py @@ -1,7 +1,7 @@ import os import nuke import pyblish.api -import openpype.api as api +from openpype.lib import get_version_from_path import openpype.hosts.nuke.api as napi from openpype.pipeline import KnownPublishError @@ -57,7 +57,7 @@ class CollectContextData(pyblish.api.ContextPlugin): "fps": root_node['fps'].value(), "currentFile": current_file, - "version": int(api.get_version_from_path(current_file)), + "version": int(get_version_from_path(current_file)), "host": pyblish.api.current_host(), "hostVersion": nuke.NUKE_VERSION_STRING diff --git a/openpype/hosts/nuke/plugins/publish/extract_render_local.py b/openpype/hosts/nuke/plugins/publish/extract_render_local.py index 2433364f85..b99a7a9548 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_render_local.py +++ b/openpype/hosts/nuke/plugins/publish/extract_render_local.py @@ -1,10 +1,12 @@ import os +import shutil import pyblish.api import clique import nuke from openpype.pipeline import publish +from openpype.lib import collect_frames class NukeRenderLocal(publish.ExtractorColormanaged): @@ -13,6 +15,8 @@ class NukeRenderLocal(publish.ExtractorColormanaged): Extract the result of savers by starting a comp render This will run the local render of Fusion. + Allows to use last published frames and overwrite only specific ones + (set in instance.data.get("frames_to_fix")) """ order = pyblish.api.ExtractorOrder @@ -21,7 +25,6 @@ class NukeRenderLocal(publish.ExtractorColormanaged): families = ["render.local", "prerender.local", "still.local"] def process(self, instance): - families = instance.data["families"] child_nodes = ( instance.data.get("transientData", {}).get("childNodes") or instance @@ -32,17 +35,16 @@ class NukeRenderLocal(publish.ExtractorColormanaged): if x.Class() == "Write": node = x + self.log.debug("instance collected: {}".format(instance.data)) + + node_subset_name = instance.data.get("name", None) + first_frame = instance.data.get("frameStartHandle", None) - last_frame = instance.data.get("frameEndHandle", None) - node_subset_name = instance.data["subset"] - - self.log.info("Starting render") - self.log.info("Start frame: {}".format(first_frame)) - self.log.info("End frame: {}".format(last_frame)) + filenames = [] node_file = node["file"] - # Collecte expected filepaths for each frame + # Collect expected filepaths for each frame # - for cases that output is still image is first created set of # paths which is then sorted and converted to list expected_paths = list(sorted({ @@ -50,22 +52,37 @@ class NukeRenderLocal(publish.ExtractorColormanaged): for frame in range(first_frame, last_frame + 1) })) # Extract only filenames for representation - filenames = [ + filenames.extend([ os.path.basename(filepath) for filepath in expected_paths - ] + ]) # Ensure output directory exists. out_dir = os.path.dirname(expected_paths[0]) if not os.path.exists(out_dir): os.makedirs(out_dir) - # Render frames - nuke.execute( - str(node_subset_name), - int(first_frame), - int(last_frame) - ) + frames_to_render = [(first_frame, last_frame)] + + frames_to_fix = instance.data.get("frames_to_fix") + if instance.data.get("last_version_published_files") and frames_to_fix: + frames_to_render = self._get_frames_to_render(frames_to_fix) + anatomy = instance.context.data["anatomy"] + self._copy_last_published(anatomy, instance, out_dir, + filenames) + + for render_first_frame, render_last_frame in frames_to_render: + + self.log.info("Starting render") + self.log.info("Start frame: {}".format(render_first_frame)) + self.log.info("End frame: {}".format(render_last_frame)) + + # Render frames + nuke.execute( + str(node_subset_name), + int(render_first_frame), + int(render_last_frame) + ) ext = node["file_type"].value() colorspace = node["colorspace"].value() @@ -106,6 +123,7 @@ class NukeRenderLocal(publish.ExtractorColormanaged): out_dir )) + families = instance.data["families"] # redefinition of families if "render.local" in families: instance.data['family'] = 'render' @@ -133,3 +151,58 @@ class NukeRenderLocal(publish.ExtractorColormanaged): self.log.info('Finished render') self.log.debug("_ instance.data: {}".format(instance.data)) + + def _copy_last_published(self, anatomy, instance, out_dir, + expected_filenames): + """Copies last published files to temporary out_dir. + + These are base of files which will be extended/fixed for specific + frames. + Renames published file to expected file name based on frame, eg. + test_project_test_asset_subset_v005.1001.exr > new_render.1001.exr + """ + last_published = instance.data["last_version_published_files"] + last_published_and_frames = collect_frames(last_published) + + expected_and_frames = collect_frames(expected_filenames) + frames_and_expected = {v: k for k, v in expected_and_frames.items()} + for file_path, frame in last_published_and_frames.items(): + file_path = anatomy.fill_root(file_path) + if not os.path.exists(file_path): + continue + target_file_name = frames_and_expected.get(frame) + if not target_file_name: + continue + + out_path = os.path.join(out_dir, target_file_name) + self.log.debug("Copying '{}' -> '{}'".format(file_path, out_path)) + shutil.copy(file_path, out_path) + + # TODO shouldn't this be uncommented + # instance.context.data["cleanupFullPaths"].append(out_path) + + def _get_frames_to_render(self, frames_to_fix): + """Return list of frame range tuples to render + + Args: + frames_to_fix (str): specific or range of frames to be rerendered + (1005, 1009-1010) + Returns: + (list): [(1005, 1005), (1009-1010)] + """ + frames_to_render = [] + + for frame_range in frames_to_fix.split(","): + if frame_range.isdigit(): + render_first_frame = frame_range + render_last_frame = frame_range + elif '-' in frame_range: + frames = frame_range.split('-') + render_first_frame = int(frames[0]) + render_last_frame = int(frames[1]) + else: + raise ValueError("Wrong format of frames to fix {}" + .format(frames_to_fix)) + frames_to_render.append((render_first_frame, + render_last_frame)) + return frames_to_render diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data.py b/openpype/hosts/nuke/plugins/publish/extract_review_data.py index 3c85b21b08..dee8248295 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data.py @@ -23,7 +23,7 @@ class ExtractReviewData(publish.Extractor): representations = instance.data.get("representations", []) # review can be removed since `ProcessSubmittedJobOnFarm` will create - # reviable representation if needed + # reviewable representation if needed if ( "render.farm" in instance.data["families"] and "review" in instance.data["families"] diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index ca3bbfd27c..3d82d6b6f0 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -5,7 +5,7 @@ from openpype.lib import BoolDef from openpype.pipeline import ( Creator, CreatedInstance, - legacy_io + CreatorError ) from openpype.lib import prepare_template_data from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS @@ -13,27 +13,16 @@ from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances class ImageCreator(Creator): - """Creates image instance for publishing.""" + """Creates image instance for publishing. + + Result of 'image' instance is image of all visible layers, or image(s) of + selected layers. + """ identifier = "image" label = "Image" family = "image" description = "Image creator" - def collect_instances(self): - for instance_data in cache_and_get_instances(self): - # legacy instances have family=='image' - creator_id = (instance_data.get("creator_identifier") or - instance_data.get("family")) - - if creator_id == self.identifier: - instance_data = self._handle_legacy(instance_data) - layer = api.stub().get_layer(instance_data["members"][0]) - instance_data["layer"] = layer - instance = CreatedInstance.from_existing( - instance_data, self - ) - self._add_instance_to_context(instance) - def create(self, subset_name_from_ui, data, pre_create_data): groups_to_create = [] top_layers_to_wrap = [] @@ -59,9 +48,10 @@ class ImageCreator(Creator): try: group = stub.group_selected_layers(subset_name_from_ui) except: - raise ValueError("Cannot group locked Bakcground layer!") + raise CreatorError("Cannot group locked Background layer!") groups_to_create.append(group) + # create empty group if nothing selected if not groups_to_create and not top_layers_to_wrap: group = stub.create_group(subset_name_from_ui) groups_to_create.append(group) @@ -73,13 +63,16 @@ class ImageCreator(Creator): groups_to_create.append(group) layer_name = '' - creating_multiple_groups = len(groups_to_create) > 1 + # use artist chosen option OR force layer if more subsets are created + # to differentiate them + use_layer_name = (pre_create_data.get("use_layer_name") or + len(groups_to_create) > 1) for group in groups_to_create: subset_name = subset_name_from_ui # reset to name from creator UI layer_names_in_hierarchy = [] created_group_name = self._clean_highlights(stub, group.name) - if creating_multiple_groups: + if use_layer_name: layer_name = re.sub( "[^{}]+".format(SUBSET_NAME_ALLOWED_SYMBOLS), "", @@ -112,6 +105,21 @@ class ImageCreator(Creator): stub.rename_layer(group.id, stub.PUBLISH_ICON + created_group_name) + def collect_instances(self): + for instance_data in cache_and_get_instances(self): + # legacy instances have family=='image' + creator_id = (instance_data.get("creator_identifier") or + instance_data.get("family")) + + if creator_id == self.identifier: + instance_data = self._handle_legacy(instance_data) + layer = api.stub().get_layer(instance_data["members"][0]) + instance_data["layer"] = layer + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + def update_instances(self, update_list): self.log.debug("update_list:: {}".format(update_list)) for created_inst, _changes in update_list: @@ -137,12 +145,42 @@ class ImageCreator(Creator): label="Create only for selected"), BoolDef("create_multiple", default=True, - label="Create separate instance for each selected") + label="Create separate instance for each selected"), + BoolDef("use_layer_name", + default=False, + label="Use layer name in subset") ] return output def get_detail_description(self): - return """Creator for Image instances""" + return """Creator for Image instances + + Main publishable item in Photoshop will be of `image` family. Result of + this item (instance) is picture that could be loaded and used + in another DCCs (for example as single layer in composition in + AfterEffects, reference in Maya etc). + + There are couple of options what to publish: + - separate image per selected layer (or group of layers) + - one image for all selected layers + - all visible layers (groups) flattened into single image + + In most cases you would like to keep `Create only for selected` + toggled on and select what you would like to publish. + Toggling this option off will allow you to create instance for all + visible layers without a need to select them explicitly. + + Use 'Create separate instance for each selected' to create separate + images per selected layer (group of layers). + + 'Use layer name in subset' will explicitly add layer name into subset + name. Position of this name is configurable in + `project_settings/global/tools/creator/subset_name_profiles`. + If layer placeholder ({layer}) is not used in `subset_name_profiles` + but layer name should be used (set explicitly in UI or implicitly if + multiple images should be created), it is added in capitalized form + as a suffix to subset name. + """ def _handle_legacy(self, instance_data): """Converts old instances to new format.""" @@ -155,7 +193,7 @@ class ImageCreator(Creator): instance_data.pop("uuid") if not instance_data.get("task"): - instance_data["task"] = legacy_io.Session.get("AVALON_TASK") + instance_data["task"] = self.create_context.get_current_task_name() if not instance_data.get("variant"): instance_data["variant"] = '' diff --git a/openpype/hosts/photoshop/plugins/create/workfile_creator.py b/openpype/hosts/photoshop/plugins/create/workfile_creator.py index 8ee9a0d832..f5d56adcbc 100644 --- a/openpype/hosts/photoshop/plugins/create/workfile_creator.py +++ b/openpype/hosts/photoshop/plugins/create/workfile_creator.py @@ -2,8 +2,7 @@ import openpype.hosts.photoshop.api as api from openpype.client import get_asset_by_name from openpype.pipeline import ( AutoCreator, - CreatedInstance, - legacy_io + CreatedInstance ) from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances @@ -38,10 +37,11 @@ class PSWorkfileCreator(AutoCreator): existing_instance = instance break - project_name = legacy_io.Session["AVALON_PROJECT"] - asset_name = legacy_io.Session["AVALON_ASSET"] - task_name = legacy_io.Session["AVALON_TASK"] - host_name = legacy_io.Session["AVALON_APP"] + context = self.create_context + project_name = context.get_current_project_name() + asset_name = context.get_current_asset_name() + task_name = context.get_current_task_name() + host_name = context.host_name if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( diff --git a/openpype/hosts/resolve/plugins/load/load_clip.py b/openpype/hosts/resolve/plugins/load/load_clip.py index a0c78c182f..d30a7ea272 100644 --- a/openpype/hosts/resolve/plugins/load/load_clip.py +++ b/openpype/hosts/resolve/plugins/load/load_clip.py @@ -14,7 +14,10 @@ from openpype.hosts.resolve.api.pipeline import ( containerise, update_container, ) - +from openpype.lib.transcoding import ( + VIDEO_EXTENSIONS, + IMAGE_EXTENSIONS +) class LoadClip(plugin.TimelineItemLoader): """Load a subset to timeline as clip @@ -24,7 +27,11 @@ class LoadClip(plugin.TimelineItemLoader): """ families = ["render2d", "source", "plate", "render", "review"] - representations = ["exr", "dpx", "jpg", "jpeg", "png", "h264", "mov"] + + representations = ["*"] + extensions = set( + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + ) label = "Load as clip" order = -10 diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py index 0a8ddaa343..3264f52b0f 100644 --- a/openpype/hosts/traypublisher/api/pipeline.py +++ b/openpype/hosts/traypublisher/api/pipeline.py @@ -37,7 +37,7 @@ class TrayPublisherHost(HostBase, IPublishHost): return HostContext.get_context_data() def update_context_data(self, data, changes): - HostContext.save_context_data(data, changes) + HostContext.save_context_data(data) def set_project_name(self, project_name): # TODO Deregister project specific plugins and register new project diff --git a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py index 1dc4bad9b3..d077131e4c 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py +++ b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py @@ -33,6 +33,8 @@ class BatchMovieCreator(TrayPublishCreator): create_allow_context_change = False version_regex = re.compile(r"^(.+)_v([0-9]+)$") + # Position batch creator after simple creators + order = 110 def __init__(self, project_settings, *args, **kwargs): super(BatchMovieCreator, self).__init__(project_settings, diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index 6ac3e6324c..e94e64e04a 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -309,8 +309,6 @@ class QtTVPaintRpc(BaseTVPaintRpc): self.add_methods( (route_name, self.workfiles_tool), (route_name, self.loader_tool), - (route_name, self.creator_tool), - (route_name, self.subset_manager_tool), (route_name, self.publish_tool), (route_name, self.scene_inventory_tool), (route_name, self.library_loader_tool), @@ -330,21 +328,9 @@ class QtTVPaintRpc(BaseTVPaintRpc): self._execute_in_main_thread(item) return - async def creator_tool(self): - log.info("Triggering Creator tool") - item = MainThreadItem(self.tools_helper.show_creator) - await self._async_execute_in_main_thread(item, wait=False) - - async def subset_manager_tool(self): - log.info("Triggering Subset Manager tool") - item = MainThreadItem(self.tools_helper.show_subset_manager) - # Do not wait for result of callback - self._execute_in_main_thread(item, wait=False) - return - async def publish_tool(self): log.info("Triggering Publish tool") - item = MainThreadItem(self.tools_helper.show_publish) + item = MainThreadItem(self.tools_helper.show_publisher_tool) self._execute_in_main_thread(item) return @@ -859,10 +845,6 @@ class QtCommunicator(BaseCommunicator): "callback": "loader_tool", "label": "Load", "help": "Open loader tool" - }, { - "callback": "creator_tool", - "label": "Create", - "help": "Open creator tool" }, { "callback": "scene_inventory_tool", "label": "Scene inventory", @@ -875,10 +857,6 @@ class QtCommunicator(BaseCommunicator): "callback": "library_loader_tool", "label": "Library", "help": "Open library loader tool" - }, { - "callback": "subset_manager_tool", - "label": "Subset Manager", - "help": "Open subset manager tool" }, { "callback": "experimental_tools", "label": "Experimental tools", diff --git a/openpype/hosts/tvpaint/api/lib.py b/openpype/hosts/tvpaint/api/lib.py index 5e64773b8e..49846d7f29 100644 --- a/openpype/hosts/tvpaint/api/lib.py +++ b/openpype/hosts/tvpaint/api/lib.py @@ -43,14 +43,15 @@ def parse_layers_data(data): layer_id, group_id, visible, position, opacity, name, layer_type, frame_start, frame_end, prelighttable, postlighttable, - selected, editable, sencil_state + selected, editable, sencil_state, is_current ) = layer_raw.split("|") layer = { "layer_id": int(layer_id), "group_id": int(group_id), "visible": visible == "ON", "position": int(position), - "opacity": int(opacity), + # Opacity from 'tv_layerinfo' is always set to '0' so it's unusable + # "opacity": int(opacity), "name": name, "type": layer_type, "frame_start": int(frame_start), @@ -59,7 +60,8 @@ def parse_layers_data(data): "postlighttable": postlighttable == "1", "selected": selected == "1", "editable": editable == "1", - "sencil_state": sencil_state + "sencil_state": sencil_state, + "is_current": is_current == "1" } layers.append(layer) return layers @@ -87,15 +89,17 @@ def get_layers_data_george_script(output_filepath, layer_ids=None): " selected editable sencilState" ), # Check if layer ID match `tv_LayerCurrentID` + "is_current=0", "IF CMP(current_layer_id, layer_id)==1", # - mark layer as selected if layer id match to current layer id + "is_current=1", "selected=1", "END", # Prepare line with data separated by "|" ( "line = layer_id'|'group_id'|'visible'|'position'|'opacity'|'" "name'|'type'|'startFrame'|'endFrame'|'prelighttable'|'" - "postlighttable'|'selected'|'editable'|'sencilState" + "postlighttable'|'selected'|'editable'|'sencilState'|'is_current" ), # Write data to output file "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line", @@ -202,8 +206,9 @@ def get_groups_data(communicator=None): # Variable containing full path to output file "output_path = \"{}\"".format(output_filepath), "empty = 0", - # Loop over 100 groups - "FOR idx = 1 TO 100", + # Loop over 26 groups which is ATM maximum possible (in 11.7) + # - ref: https://www.tvpaint.com/forum/viewtopic.php?t=13880 + "FOR idx = 1 TO 26", # Receive information about groups "tv_layercolor \"getcolor\" 0 idx", "PARSE result clip_id group_index c_red c_green c_blue group_name", diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 249326791b..575e6aa755 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -8,7 +8,7 @@ import requests import pyblish.api from openpype.client import get_project, get_asset_by_name -from openpype.host import HostBase, IWorkfileHost, ILoadHost +from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost from openpype.hosts.tvpaint import TVPAINT_ROOT_DIR from openpype.settings import get_current_project_settings from openpype.lib import register_event_callback @@ -18,6 +18,7 @@ from openpype.pipeline import ( register_creator_plugin_path, AVALON_CONTAINER_ID, ) +from openpype.pipeline.context_tools import get_global_context from .lib import ( execute_george, @@ -29,6 +30,7 @@ log = logging.getLogger(__name__) METADATA_SECTION = "avalon" SECTION_NAME_CONTEXT = "context" +SECTION_NAME_CREATE_CONTEXT = "create_context" SECTION_NAME_INSTANCES = "instances" SECTION_NAME_CONTAINERS = "containers" # Maximum length of metadata chunk string @@ -58,7 +60,7 @@ instances=2 """ -class TVPaintHost(HostBase, IWorkfileHost, ILoadHost): +class TVPaintHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "tvpaint" def install(self): @@ -85,14 +87,63 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost): registered_callbacks = ( pyblish.api.registered_callbacks().get("instanceToggled") or [] ) - if self.on_instance_toggle not in registered_callbacks: - pyblish.api.register_callback( - "instanceToggled", self.on_instance_toggle - ) register_event_callback("application.launched", self.initial_launch) register_event_callback("application.exit", self.application_exit) + def get_current_project_name(self): + """ + Returns: + Union[str, None]: Current project name. + """ + + return self.get_current_context().get("project_name") + + def get_current_asset_name(self): + """ + Returns: + Union[str, None]: Current asset name. + """ + + return self.get_current_context().get("asset_name") + + def get_current_task_name(self): + """ + Returns: + Union[str, None]: Current task name. + """ + + return self.get_current_context().get("task_name") + + def get_current_context(self): + context = get_current_workfile_context() + if not context: + return get_global_context() + + if "project_name" in context: + return context + # This is legacy way how context was stored + return { + "project_name": context.get("project"), + "asset_name": context.get("asset"), + "task_name": context.get("task") + } + + # --- Create --- + def get_context_data(self): + return get_workfile_metadata(SECTION_NAME_CREATE_CONTEXT, {}) + + def update_context_data(self, data, changes): + return write_workfile_metadata(SECTION_NAME_CREATE_CONTEXT, data) + + def list_instances(self): + """List all created instances from current workfile.""" + return list_instances() + + def write_instances(self, data): + return write_instances(data) + + # --- Workfile --- def open_workfile(self, filepath): george_script = "tv_LoadProject '\"'\"{}\"'\"'".format( filepath.replace("\\", "/") @@ -102,11 +153,7 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost): def save_workfile(self, filepath=None): if not filepath: filepath = self.get_current_workfile() - context = { - "project": legacy_io.Session["AVALON_PROJECT"], - "asset": legacy_io.Session["AVALON_ASSET"], - "task": legacy_io.Session["AVALON_TASK"] - } + context = get_global_context() save_current_workfile_context(context) # Execute george script to save workfile. @@ -125,6 +172,7 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost): def get_workfile_extensions(self): return [".tvpp"] + # --- Load --- def get_containers(self): return get_containers() @@ -137,27 +185,15 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost): return log.info("Setting up project...") - set_context_settings() - - def remove_instance(self, instance): - """Remove instance from current workfile metadata. - - Implementation for Subset manager tool. - """ - - current_instances = get_workfile_metadata(SECTION_NAME_INSTANCES) - instance_id = instance.get("uuid") - found_idx = None - if instance_id: - for idx, _inst in enumerate(current_instances): - if _inst["uuid"] == instance_id: - found_idx = idx - break - - if found_idx is None: + global_context = get_global_context() + project_name = global_context.get("project_name") + asset_name = global_context.get("aset_name") + if not project_name or not asset_name: return - current_instances.pop(found_idx) - write_instances(current_instances) + + asset_doc = get_asset_by_name(project_name, asset_name) + + set_context_settings(project_name, asset_doc) def application_exit(self): """Logic related to TimerManager. @@ -177,34 +213,6 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost): rest_api_url = "{}/timers_manager/stop_timer".format(webserver_url) requests.post(rest_api_url) - def on_instance_toggle(self, instance, old_value, new_value): - """Update instance data in workfile on publish toggle.""" - # Review may not have real instance in wokrfile metadata - if not instance.data.get("uuid"): - return - - instance_id = instance.data["uuid"] - found_idx = None - current_instances = list_instances() - for idx, workfile_instance in enumerate(current_instances): - if workfile_instance["uuid"] == instance_id: - found_idx = idx - break - - if found_idx is None: - return - - if "active" in current_instances[found_idx]: - current_instances[found_idx]["active"] = new_value - self.write_instances(current_instances) - - def list_instances(self): - """List all created instances from current workfile.""" - return list_instances() - - def write_instances(self, data): - return write_instances(data) - def containerise( name, namespace, members, context, loader, current_containers=None @@ -462,40 +470,17 @@ def get_containers(): return output -def set_context_settings(asset_doc=None): +def set_context_settings(project_name, asset_doc): """Set workfile settings by asset document data. Change fps, resolution and frame start/end. """ - project_name = legacy_io.active_project() - if asset_doc is None: - asset_name = legacy_io.Session["AVALON_ASSET"] - # Use current session asset if not passed - asset_doc = get_asset_by_name(project_name, asset_name) - - project_doc = get_project(project_name) - - framerate = asset_doc["data"].get("fps") - if framerate is None: - framerate = project_doc["data"].get("fps") - - if framerate is not None: - execute_george( - "tv_framerate {} \"timestretch\"".format(framerate) - ) - else: - print("Framerate was not found!") - width_key = "resolutionWidth" height_key = "resolutionHeight" width = asset_doc["data"].get(width_key) height = asset_doc["data"].get(height_key) - if width is None or height is None: - width = project_doc["data"].get(width_key) - height = project_doc["data"].get(height_key) - if width is None or height is None: print("Resolution was not found!") else: @@ -503,6 +488,15 @@ def set_context_settings(asset_doc=None): "tv_resizepage {} {} 0".format(width, height) ) + framerate = asset_doc["data"].get("fps") + + if framerate is not None: + execute_george( + "tv_framerate {} \"timestretch\"".format(framerate) + ) + else: + print("Framerate was not found!") + frame_start = asset_doc["data"].get("frameStart") frame_end = asset_doc["data"].get("frameEnd") diff --git a/openpype/hosts/tvpaint/api/plugin.py b/openpype/hosts/tvpaint/api/plugin.py index da456e7067..96b99199f2 100644 --- a/openpype/hosts/tvpaint/api/plugin.py +++ b/openpype/hosts/tvpaint/api/plugin.py @@ -1,80 +1,142 @@ import re -import uuid -from openpype.pipeline import ( - LegacyCreator, - LoaderPlugin, - registered_host, +from openpype.pipeline import LoaderPlugin +from openpype.pipeline.create import ( + CreatedInstance, + get_subset_name, + AutoCreator, + Creator, ) +from openpype.pipeline.create.creator_plugins import cache_and_get_instances from .lib import get_layers_data -from .pipeline import get_current_workfile_context -class Creator(LegacyCreator): - def __init__(self, *args, **kwargs): - super(Creator, self).__init__(*args, **kwargs) - # Add unified identifier created with `uuid` module - self.data["uuid"] = str(uuid.uuid4()) +SHARED_DATA_KEY = "openpype.tvpaint.instances" - @classmethod - def get_dynamic_data(cls, *args, **kwargs): - dynamic_data = super(Creator, cls).get_dynamic_data(*args, **kwargs) - # Change asset and name by current workfile context - workfile_context = get_current_workfile_context() - asset_name = workfile_context.get("asset") - task_name = workfile_context.get("task") - if "asset" not in dynamic_data and asset_name: - dynamic_data["asset"] = asset_name +class TVPaintCreatorCommon: + @property + def subset_template_family_filter(self): + return self.family - if "task" not in dynamic_data and task_name: - dynamic_data["task"] = task_name - return dynamic_data - - @staticmethod - def are_instances_same(instance_1, instance_2): - """Compare instances but skip keys with unique values. - - During compare are skipped keys that will be 100% sure - different on new instance, like "id". - - Returns: - bool: True if instances are same. - """ - if ( - not isinstance(instance_1, dict) - or not isinstance(instance_2, dict) - ): - return instance_1 == instance_2 - - checked_keys = set() - checked_keys.add("id") - for key, value in instance_1.items(): - if key not in checked_keys: - if key not in instance_2: - return False - if value != instance_2[key]: - return False - checked_keys.add(key) - - for key in instance_2.keys(): - if key not in checked_keys: - return False - return True - - def write_instances(self, data): - self.log.debug( - "Storing instance data to workfile. {}".format(str(data)) + def _cache_and_get_instances(self): + return cache_and_get_instances( + self, SHARED_DATA_KEY, self.host.list_instances ) - host = registered_host() - return host.write_instances(data) - def process(self): - host = registered_host() - data = host.list_instances() - data.append(self.data) - self.write_instances(data) + def _collect_create_instances(self): + instances_by_identifier = self._cache_and_get_instances() + for instance_data in instances_by_identifier[self.identifier]: + instance = CreatedInstance.from_existing(instance_data, self) + self._add_instance_to_context(instance) + + def _update_create_instances(self, update_list): + if not update_list: + return + + cur_instances = self.host.list_instances() + cur_instances_by_id = {} + for instance_data in cur_instances: + instance_id = instance_data.get("instance_id") + if instance_id: + cur_instances_by_id[instance_id] = instance_data + + for instance, changes in update_list: + instance_data = changes.new_value + cur_instance_data = cur_instances_by_id.get(instance.id) + if cur_instance_data is None: + cur_instances.append(instance_data) + continue + for key in set(cur_instance_data) - set(instance_data): + cur_instance_data.pop(key) + cur_instance_data.update(instance_data) + self.host.write_instances(cur_instances) + + def _custom_get_subset_name( + self, + variant, + task_name, + asset_doc, + project_name, + host_name=None, + instance=None + ): + dynamic_data = self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name, instance + ) + + return get_subset_name( + self.family, + variant, + task_name, + asset_doc, + project_name, + host_name, + dynamic_data=dynamic_data, + project_settings=self.project_settings, + family_filter=self.subset_template_family_filter + ) + + +class TVPaintCreator(Creator, TVPaintCreatorCommon): + def collect_instances(self): + self._collect_create_instances() + + def update_instances(self, update_list): + self._update_create_instances(update_list) + + def remove_instances(self, instances): + ids_to_remove = { + instance.id + for instance in instances + } + cur_instances = self.host.list_instances() + changed = False + new_instances = [] + for instance_data in cur_instances: + if instance_data.get("instance_id") in ids_to_remove: + changed = True + else: + new_instances.append(instance_data) + + if changed: + self.host.write_instances(new_instances) + + for instance in instances: + self._remove_instance_from_context(instance) + + def get_dynamic_data(self, *args, **kwargs): + # Change asset and name by current workfile context + create_context = self.create_context + asset_name = create_context.get_current_asset_name() + task_name = create_context.get_current_task_name() + output = {} + if asset_name: + output["asset"] = asset_name + if task_name: + output["task"] = task_name + return output + + def get_subset_name(self, *args, **kwargs): + return self._custom_get_subset_name(*args, **kwargs) + + def _store_new_instance(self, new_instance): + instances_data = self.host.list_instances() + instances_data.append(new_instance.data_to_store()) + self.host.write_instances(instances_data) + self._add_instance_to_context(new_instance) + + +class TVPaintAutoCreator(AutoCreator, TVPaintCreatorCommon): + def collect_instances(self): + self._collect_create_instances() + + def update_instances(self, update_list): + self._update_create_instances(update_list) + + def get_subset_name(self, *args, **kwargs): + return self._custom_get_subset_name(*args, **kwargs) class Loader(LoaderPlugin): diff --git a/openpype/hosts/tvpaint/plugins/create/convert_legacy.py b/openpype/hosts/tvpaint/plugins/create/convert_legacy.py new file mode 100644 index 0000000000..538c6e4c5e --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/create/convert_legacy.py @@ -0,0 +1,150 @@ +import collections + +from openpype.pipeline.create.creator_plugins import ( + SubsetConvertorPlugin, + cache_and_get_instances, +) +from openpype.hosts.tvpaint.api.plugin import SHARED_DATA_KEY +from openpype.hosts.tvpaint.api.lib import get_groups_data + + +class TVPaintLegacyConverted(SubsetConvertorPlugin): + """Conversion of legacy instances in scene to new creators. + + This convertor handles only instances created by core creators. + + All instances that would be created using auto-creators are removed as at + the moment of finding them would there already be existing instances. + """ + + identifier = "tvpaint.legacy.converter" + + def find_instances(self): + instances_by_identifier = cache_and_get_instances( + self, SHARED_DATA_KEY, self.host.list_instances + ) + if instances_by_identifier[None]: + self.add_convertor_item("Convert legacy instances") + + def convert(self): + current_instances = self.host.list_instances() + to_convert = collections.defaultdict(list) + converted = False + for instance in current_instances: + if instance.get("creator_identifier") is not None: + continue + converted = True + + family = instance.get("family") + if family in ( + "renderLayer", + "renderPass", + "renderScene", + "review", + "workfile", + ): + to_convert[family].append(instance) + else: + instance["keep"] = False + + # Skip if nothing was changed + if not converted: + self.remove_convertor_item() + return + + self._convert_render_layers( + to_convert["renderLayer"], current_instances) + self._convert_render_passes( + to_convert["renderpass"], current_instances) + self._convert_render_scenes( + to_convert["renderScene"], current_instances) + self._convert_workfiles( + to_convert["workfile"], current_instances) + self._convert_reviews( + to_convert["review"], current_instances) + + new_instances = [ + instance + for instance in current_instances + if instance.get("keep") is not False + ] + self.host.write_instances(new_instances) + # remove legacy item if all is fine + self.remove_convertor_item() + + def _convert_render_layers(self, render_layers, current_instances): + if not render_layers: + return + + # Look for possible existing render layers in scene + render_layers_by_group_id = {} + for instance in current_instances: + if instance.get("creator_identifier") == "render.layer": + group_id = instance["creator_identifier"]["group_id"] + render_layers_by_group_id[group_id] = instance + + groups_by_id = { + group["group_id"]: group + for group in get_groups_data() + } + for render_layer in render_layers: + group_id = render_layer.pop("group_id") + # Just remove legacy instance if group is already occupied + if group_id in render_layers_by_group_id: + render_layer["keep"] = False + continue + # Add identifier + render_layer["creator_identifier"] = "render.layer" + # Change 'uuid' to 'instance_id' + render_layer["instance_id"] = render_layer.pop("uuid") + # Fill creator attributes + render_layer["creator_attributes"] = { + "group_id": group_id + } + render_layer["family"] = "render" + group = groups_by_id[group_id] + # Use group name for variant + group["variant"] = group["name"] + + def _convert_render_passes(self, render_passes, current_instances): + if not render_passes: + return + + # Render passes must have available render layers so we look for render + # layers first + # - '_convert_render_layers' must be called before this method + render_layers_by_group_id = {} + for instance in current_instances: + if instance.get("creator_identifier") == "render.layer": + group_id = instance["creator_identifier"]["group_id"] + render_layers_by_group_id[group_id] = instance + + for render_pass in render_passes: + group_id = render_pass.pop("group_id") + render_layer = render_layers_by_group_id.get(group_id) + if not render_layer: + render_pass["keep"] = False + continue + + render_pass["creator_identifier"] = "render.pass" + render_pass["instance_id"] = render_pass.pop("uuid") + render_pass["family"] = "render" + + render_pass["creator_attributes"] = { + "render_layer_instance_id": render_layer["instance_id"] + } + render_pass["variant"] = render_pass.pop("pass") + render_pass.pop("renderlayer") + + # Rest of instances are just marked for deletion + def _convert_render_scenes(self, render_scenes, current_instances): + for render_scene in render_scenes: + render_scene["keep"] = False + + def _convert_workfiles(self, workfiles, current_instances): + for render_scene in workfiles: + render_scene["keep"] = False + + def _convert_reviews(self, reviews, current_instances): + for render_scene in reviews: + render_scene["keep"] = False diff --git a/openpype/hosts/tvpaint/plugins/create/create_render.py b/openpype/hosts/tvpaint/plugins/create/create_render.py new file mode 100644 index 0000000000..9711024c79 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/create/create_render.py @@ -0,0 +1,1151 @@ +"""Render Layer and Passes creators. + +Render layer is main part which is represented by group in TVPaint. All TVPaint +layers marked with that group color are part of the render layer. To be more +specific about some parts of layer it is possible to create sub-sets of layer +which are named passes. Render pass consist of layers in same color group as +render layer but define more specific part. + +For example render layer could be 'Bob' which consist of 5 TVPaint layers. +- Bob has 'head' which consist of 2 TVPaint layers -> Render pass 'head' +- Bob has 'body' which consist of 1 TVPaint layer -> Render pass 'body' +- Bob has 'arm' which consist of 1 TVPaint layer -> Render pass 'arm' +- Last layer does not belong to render pass at all + +Bob will be rendered as 'beauty' of bob (all visible layers in group). +His head will be rendered too but without any other parts. The same for body +and arm. + +What is this good for? Compositing has more power how the renders are used. +Can do transforms on each render pass without need to modify a re-render them +using TVPaint. + +The workflow may hit issues when there are used other blending modes than +default 'color' blend more. In that case it is not recommended to use this +workflow at all as other blend modes may affect all layers in clip which can't +be done. + +There is special case for simple publishing of scene which is called +'render.scene'. That will use all visible layers and render them as one big +sequence. + +Todos: + Add option to extract marked layers and passes as json output format for + AfterEffects. +""" + +import collections +from typing import Any, Optional, Union + +from openpype.client import get_asset_by_name +from openpype.lib import ( + prepare_template_data, + AbstractAttrDef, + UILabelDef, + UISeparatorDef, + EnumDef, + TextDef, + BoolDef, +) +from openpype.pipeline.create import ( + CreatedInstance, + CreatorError, +) +from openpype.hosts.tvpaint.api.plugin import ( + TVPaintCreator, + TVPaintAutoCreator, +) +from openpype.hosts.tvpaint.api.lib import ( + get_layers_data, + get_groups_data, + execute_george_through_file, +) + +RENDER_LAYER_DETAILED_DESCRIPTIONS = ( + """Render Layer is "a group of TVPaint layers" + +Be aware Render Layer is not TVPaint layer. + +All TVPaint layers in the scene with the color group id are rendered in the +beauty pass. To create sub passes use Render Pass creator which is +dependent on existence of render layer instance. + +The group can represent an asset (tree) or different part of scene that consist +of one or more TVPaint layers that can be used as single item during +compositing (for example). + +In some cases may be needed to have sub parts of the layer. For example 'Bob' +could be Render Layer which has 'Arm', 'Head' and 'Body' as Render Passes. +""" +) + + +RENDER_PASS_DETAILED_DESCRIPTIONS = ( + """Render Pass is sub part of Render Layer. + +Render Pass can consist of one or more TVPaint layers. Render Pass must +belong to a Render Layer. Marked TVPaint layers will change it's group color +to match group color of Render Layer. +""" +) + + +AUTODETECT_RENDER_DETAILED_DESCRIPTION = ( + """Semi-automated Render Layer and Render Pass creation. + +Based on information in TVPaint scene will be created Render Layers and Render +Passes. All color groups used in scene will be used for Render Layer creation. +Name of the group is used as a variant. + +All TVPaint layers under the color group will be created as Render Pass where +layer name is used as variant. + +The plugin will use all used color groups and layers, or can skip those that +are not visible. + +There is option to auto-rename color groups before Render Layer creation. That +is based on settings template where is filled index of used group from bottom +to top. +""" +) + +class CreateRenderlayer(TVPaintCreator): + """Mark layer group as Render layer instance. + + All TVPaint layers in the scene with the color group id are rendered in the + beauty pass. To create sub passes use Render Layer creator which is + dependent on existence of render layer instance. + """ + + label = "Render Layer" + family = "render" + subset_template_family_filter = "renderLayer" + identifier = "render.layer" + icon = "fa5.images" + + # George script to change color group + rename_script_template = ( + "tv_layercolor \"setcolor\"" + " {clip_id} {group_id} {r} {g} {b} \"{name}\"" + ) + # Order to be executed before Render Pass creator + order = 90 + description = "Mark TVPaint color group as one Render Layer." + detailed_description = RENDER_LAYER_DETAILED_DESCRIPTIONS + + # Settings + # - Default render pass name for beauty + default_pass_name = "beauty" + # - Mark by default instance for review + mark_for_review = True + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["tvpaint"]["create"]["create_render_layer"] + ) + self.default_variant = plugin_settings["default_variant"] + self.default_variants = plugin_settings["default_variants"] + self.default_pass_name = plugin_settings["default_pass_name"] + self.mark_for_review = plugin_settings["mark_for_review"] + + def get_dynamic_data( + self, variant, task_name, asset_doc, project_name, host_name, instance + ): + dynamic_data = super().get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name, instance + ) + dynamic_data["renderpass"] = self.default_pass_name + dynamic_data["renderlayer"] = variant + return dynamic_data + + def _get_selected_group_ids(self): + return { + layer["group_id"] + for layer in get_layers_data() + if layer["selected"] + } + + def create(self, subset_name, instance_data, pre_create_data): + self.log.debug("Query data from workfile.") + + group_name = instance_data["variant"] + group_id = pre_create_data.get("group_id") + # This creator should run only on one group + if group_id is None or group_id == -1: + selected_groups = self._get_selected_group_ids() + selected_groups.discard(0) + if len(selected_groups) > 1: + raise CreatorError("You have selected more than one group") + + if len(selected_groups) == 0: + raise CreatorError("You don't have selected any group") + group_id = tuple(selected_groups)[0] + + self.log.debug("Querying groups data from workfile.") + groups_data = get_groups_data() + group_item = None + for group_data in groups_data: + if group_data["group_id"] == group_id: + group_item = group_data + + for instance in self.create_context.instances: + if ( + instance.creator_identifier == self.identifier + and instance["creator_attributes"]["group_id"] == group_id + ): + raise CreatorError(( + f"Group \"{group_item.get('name')}\" is already used" + f" by another render layer \"{instance['subset']}\"" + )) + + self.log.debug(f"Selected group id is \"{group_id}\".") + if "creator_attributes" not in instance_data: + instance_data["creator_attributes"] = {} + creator_attributes = instance_data["creator_attributes"] + mark_for_review = pre_create_data.get("mark_for_review") + if mark_for_review is None: + mark_for_review = self.mark_for_review + creator_attributes["group_id"] = group_id + creator_attributes["mark_for_review"] = mark_for_review + + self.log.info(f"Subset name is {subset_name}") + new_instance = CreatedInstance( + self.family, + subset_name, + instance_data, + self + ) + self._store_new_instance(new_instance) + + if not group_id or group_item["name"] == group_name: + return new_instance + + self.log.debug("Changing name of the group.") + # Rename TVPaint group (keep color same) + # - groups can't contain spaces + rename_script = self.rename_script_template.format( + clip_id=group_item["clip_id"], + group_id=group_item["group_id"], + r=group_item["red"], + g=group_item["green"], + b=group_item["blue"], + name=group_name + ) + execute_george_through_file(rename_script) + + self.log.info(( + f"Name of group with index {group_id}" + f" was changed to \"{group_name}\"." + )) + return new_instance + + def _get_groups_enum(self): + groups_enum = [] + empty_groups = [] + for group in get_groups_data(): + group_name = group["name"] + item = { + "label": group_name, + "value": group["group_id"] + } + # TVPaint have defined how many color groups is available, but + # the count is not consistent across versions. It is not possible + # to know how many groups there is. + # + if group_name and group_name != "0": + if empty_groups: + groups_enum.extend(empty_groups) + empty_groups = [] + groups_enum.append(item) + else: + empty_groups.append(item) + return groups_enum + + def get_pre_create_attr_defs(self): + groups_enum = self._get_groups_enum() + groups_enum.insert(0, {"label": "", "value": -1}) + + return [ + EnumDef( + "group_id", + label="Group", + items=groups_enum + ), + BoolDef( + "mark_for_review", + label="Review", + default=self.mark_for_review + ) + ] + + def get_instance_attr_defs(self): + groups_enum = self._get_groups_enum() + return [ + EnumDef( + "group_id", + label="Group", + items=groups_enum + ), + BoolDef( + "mark_for_review", + label="Review", + default=self.mark_for_review + ) + ] + + def update_instances(self, update_list): + self._update_color_groups() + self._update_renderpass_groups() + + super().update_instances(update_list) + + def _update_color_groups(self): + render_layer_instances = [] + for instance in self.create_context.instances: + if instance.creator_identifier == self.identifier: + render_layer_instances.append(instance) + + if not render_layer_instances: + return + + groups_by_id = { + group["group_id"]: group + for group in get_groups_data() + } + grg_script_lines = [] + for instance in render_layer_instances: + group_id = instance["creator_attributes"]["group_id"] + variant = instance["variant"] + group = groups_by_id[group_id] + if group["name"] == variant: + continue + + grg_script_lines.append(self.rename_script_template.format( + clip_id=group["clip_id"], + group_id=group["group_id"], + r=group["red"], + g=group["green"], + b=group["blue"], + name=variant + )) + + if grg_script_lines: + execute_george_through_file("\n".join(grg_script_lines)) + + def _update_renderpass_groups(self): + render_layer_instances = {} + render_pass_instances = collections.defaultdict(list) + + for instance in self.create_context.instances: + if instance.creator_identifier == CreateRenderPass.identifier: + render_layer_id = ( + instance["creator_attributes"]["render_layer_instance_id"] + ) + render_pass_instances[render_layer_id].append(instance) + elif instance.creator_identifier == self.identifier: + render_layer_instances[instance.id] = instance + + if not render_pass_instances or not render_layer_instances: + return + + layers_data = get_layers_data() + layers_by_name = collections.defaultdict(list) + for layer in layers_data: + layers_by_name[layer["name"]].append(layer) + + george_lines = [] + for render_layer_id, instances in render_pass_instances.items(): + render_layer_inst = render_layer_instances.get(render_layer_id) + if render_layer_inst is None: + continue + group_id = render_layer_inst["creator_attributes"]["group_id"] + layer_names = set() + for instance in instances: + layer_names |= set(instance["layer_names"]) + + for layer_name in layer_names: + george_lines.extend( + f"tv_layercolor \"set\" {layer['layer_id']} {group_id}" + for layer in layers_by_name[layer_name] + if layer["group_id"] != group_id + ) + if george_lines: + execute_george_through_file("\n".join(george_lines)) + + +class CreateRenderPass(TVPaintCreator): + family = "render" + subset_template_family_filter = "renderPass" + identifier = "render.pass" + label = "Render Pass" + icon = "fa5.image" + description = "Mark selected TVPaint layers as pass of Render Layer." + detailed_description = RENDER_PASS_DETAILED_DESCRIPTIONS + + order = CreateRenderlayer.order + 10 + + # Settings + mark_for_review = True + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["tvpaint"]["create"]["create_render_pass"] + ) + self.default_variant = plugin_settings["default_variant"] + self.default_variants = plugin_settings["default_variants"] + self.mark_for_review = plugin_settings["mark_for_review"] + + def collect_instances(self): + instances_by_identifier = self._cache_and_get_instances() + render_layers = { + instance_data["instance_id"]: { + "variant": instance_data["variant"], + "template_data": prepare_template_data({ + "renderlayer": instance_data["variant"] + }) + } + for instance_data in ( + instances_by_identifier[CreateRenderlayer.identifier] + ) + } + + for instance_data in instances_by_identifier[self.identifier]: + render_layer_instance_id = ( + instance_data + .get("creator_attributes", {}) + .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"] + ) + instance = CreatedInstance.from_existing(instance_data, self) + self._add_instance_to_context(instance) + + def get_dynamic_data( + self, variant, task_name, asset_doc, project_name, host_name, instance + ): + dynamic_data = super().get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name, instance + ) + dynamic_data["renderpass"] = variant + dynamic_data["renderlayer"] = "{renderlayer}" + return dynamic_data + + def update_instance_labels( + self, instance, render_layer_variant, render_layer_data=None + ): + old_label = instance.get("label") + old_group = instance.get("group") + new_label = None + new_group = None + if render_layer_variant is not None: + if render_layer_data is None: + render_layer_data = prepare_template_data({ + "renderlayer": render_layer_variant + }) + try: + new_label = instance["subset"].format(**render_layer_data) + except (KeyError, ValueError): + pass + + new_group = f"{self.get_group_label()} ({render_layer_variant})" + + instance["label"] = new_label + instance["group"] = new_group + return old_group != new_group or old_label != new_label + + def create(self, subset_name, instance_data, pre_create_data): + render_layer_instance_id = pre_create_data.get( + "render_layer_instance_id" + ) + if not render_layer_instance_id: + raise CreatorError(( + "You cannot create a Render Pass without a Render Layer." + " Please select one first" + )) + + render_layer_instance = self.create_context.instances_by_id.get( + render_layer_instance_id + ) + if render_layer_instance is None: + raise CreatorError(( + "RenderLayer instance was not found" + f" by id \"{render_layer_instance_id}\"" + )) + + group_id = render_layer_instance["creator_attributes"]["group_id"] + self.log.debug("Query data from workfile.") + layers_data = get_layers_data() + + self.log.debug("Checking selection.") + # Get all selected layers and their group ids + marked_layer_names = pre_create_data.get("layer_names") + if marked_layer_names is not None: + layers_by_name = {layer["name"]: layer for layer in layers_data} + marked_layers = [] + for layer_name in marked_layer_names: + layer = layers_by_name.get(layer_name) + if layer is None: + raise CreatorError( + f"Layer with name \"{layer_name}\" was not found") + marked_layers.append(layer) + + else: + marked_layers = [ + layer + for layer in layers_data + if layer["selected"] + ] + + # Raise if nothing is selected + if not marked_layers: + raise CreatorError( + "Nothing is selected. Please select layers.") + + marked_layer_names = {layer["name"] for layer in marked_layers} + + marked_layer_names = set(marked_layer_names) + + instances_to_remove = [] + for instance in self.create_context.instances: + if instance.creator_identifier != self.identifier: + continue + cur_layer_names = set(instance["layer_names"]) + if not cur_layer_names.intersection(marked_layer_names): + continue + new_layer_names = cur_layer_names - marked_layer_names + if new_layer_names: + instance["layer_names"] = list(new_layer_names) + else: + instances_to_remove.append(instance) + + render_layer = render_layer_instance["variant"] + subset_name_fill_data = {"renderlayer": render_layer} + + # Format dynamic keys in subset name + label = subset_name + try: + label = label.format( + **prepare_template_data(subset_name_fill_data) + ) + except (KeyError, ValueError): + pass + + self.log.info(f"New subset name is \"{label}\".") + instance_data["label"] = label + instance_data["group"] = f"{self.get_group_label()} ({render_layer})" + instance_data["layer_names"] = list(marked_layer_names) + if "creator_attributes" not in instance_data: + instance_data["creator_attributes"] = {} + + creator_attributes = instance_data["creator_attributes"] + mark_for_review = pre_create_data.get("mark_for_review") + if mark_for_review is None: + mark_for_review = self.mark_for_review + creator_attributes["mark_for_review"] = mark_for_review + creator_attributes["render_layer_instance_id"] = ( + render_layer_instance_id + ) + + new_instance = CreatedInstance( + self.family, + subset_name, + instance_data, + self + ) + instances_data = self._remove_and_filter_instances( + instances_to_remove + ) + instances_data.append(new_instance.data_to_store()) + + self.host.write_instances(instances_data) + self._add_instance_to_context(new_instance) + self._change_layers_group(marked_layers, group_id) + + return new_instance + + def _change_layers_group(self, layers, group_id): + filtered_layers = [ + layer + for layer in layers + if layer["group_id"] != group_id + ] + if filtered_layers: + self.log.info(( + "Changing group of " + f"{','.join([l['name'] for l in filtered_layers])}" + f" to {group_id}" + )) + george_lines = [ + f"tv_layercolor \"set\" {layer['layer_id']} {group_id}" + for layer in filtered_layers + ] + execute_george_through_file("\n".join(george_lines)) + + def _remove_and_filter_instances(self, instances_to_remove): + instances_data = self.host.list_instances() + if not instances_to_remove: + return instances_data + + removed_ids = set() + for instance in instances_to_remove: + removed_ids.add(instance.id) + self._remove_instance_from_context(instance) + + return [ + instance_data + for instance_data in instances_data + if instance_data.get("instance_id") not in removed_ids + ] + + def get_pre_create_attr_defs(self): + # Find available Render Layers + # - instances are created after creators reset + current_instances = self.host.list_instances() + render_layers = [ + { + "value": instance["instance_id"], + "label": instance["subset"] + } + for instance in current_instances + if instance["creator_identifier"] == CreateRenderlayer.identifier + ] + if not render_layers: + render_layers.append({"value": None, "label": "N/A"}) + + return [ + EnumDef( + "render_layer_instance_id", + label="Render Layer", + items=render_layers + ), + UILabelDef( + "NOTE: Try to hit refresh if you don't see a Render Layer" + ), + BoolDef( + "mark_for_review", + label="Review", + default=self.mark_for_review + ) + ] + + def get_instance_attr_defs(self): + # Find available Render Layers + current_instances = self.create_context.instances + render_layers = [ + { + "value": instance.id, + "label": instance.label + } + for instance in current_instances + if instance.creator_identifier == CreateRenderlayer.identifier + ] + if not render_layers: + render_layers.append({"value": None, "label": "N/A"}) + + return [ + EnumDef( + "render_layer_instance_id", + label="Render Layer", + items=render_layers + ), + UILabelDef( + "NOTE: Try to hit refresh if you don't see a Render Layer" + ), + BoolDef( + "mark_for_review", + label="Review", + default=self.mark_for_review + ) + ] + + +class TVPaintAutoDetectRenderCreator(TVPaintCreator): + """Create Render Layer and Render Pass instances based on scene data. + + This is auto-detection creator which can be triggered by user to create + instances based on information in scene. Each used color group in scene + will be created as Render Layer where group name is used as variant and + each TVPaint layer as Render Pass where layer name is used as variant. + + Never will have any instances, all instances belong to different creators. + """ + + family = "render" + label = "Render Layer/Passes" + identifier = "render.auto.detect.creator" + order = CreateRenderPass.order + 10 + description = ( + "Create Render Layers and Render Passes based on scene setup" + ) + detailed_description = AUTODETECT_RENDER_DETAILED_DESCRIPTION + + # Settings + enabled = False + allow_group_rename = True + group_name_template = "L{group_index}" + group_idx_offset = 10 + group_idx_padding = 3 + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings + ["tvpaint"] + ["create"] + ["auto_detect_render"] + ) + 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"] + self.group_idx_padding = plugin_settings["group_idx_padding"] + + def _rename_groups( + self, + groups_order: list[int], + scene_groups: list[dict[str, Any]] + ): + new_group_name_by_id: dict[int, str] = {} + groups_by_id: dict[int, dict[str, Any]] = { + group["group_id"]: group + for group in scene_groups + } + # Count only renamed groups + for idx, group_id in enumerate(groups_order): + group_index_value: str = ( + "{{:0>{}}}" + .format(self.group_idx_padding) + .format((idx + 1) * self.group_idx_offset) + ) + group_name_fill_values: dict[str, str] = { + "groupIdx": group_index_value, + "groupidx": group_index_value, + "group_idx": group_index_value, + "group_index": group_index_value, + } + + group_name: str = self.group_name_template.format( + **group_name_fill_values + ) + group: dict[str, Any] = groups_by_id[group_id] + if group["name"] != group_name: + new_group_name_by_id[group_id] = group_name + + grg_lines: list[str] = [] + for group_id, group_name in new_group_name_by_id.items(): + group: dict[str, Any] = groups_by_id[group_id] + grg_line: str = "tv_layercolor \"setcolor\" {} {} {} {} {}".format( + group["clip_id"], + group_id, + group["red"], + group["green"], + group["blue"], + group_name + ) + grg_lines.append(grg_line) + group["name"] = group_name + + if grg_lines: + execute_george_through_file("\n".join(grg_lines)) + + def _prepare_render_layer( + self, + project_name: str, + asset_doc: dict[str, Any], + task_name: str, + group_id: int, + groups: list[dict[str, Any]], + mark_for_review: bool, + existing_instance: Optional[CreatedInstance] = None, + ) -> Union[CreatedInstance, None]: + match_group: Union[dict[str, Any], None] = next( + ( + group + for group in groups + if group["group_id"] == group_id + ), + None + ) + if not match_group: + return None + + variant: str = match_group["name"] + creator: CreateRenderlayer = ( + self.create_context.creators[CreateRenderlayer.identifier] + ) + + subset_name: str = creator.get_subset_name( + variant, + task_name, + asset_doc, + project_name, + host_name=self.create_context.host_name, + ) + if existing_instance is not None: + existing_instance["asset"] = asset_doc["name"] + existing_instance["task"] = task_name + existing_instance["subset"] = subset_name + return existing_instance + + instance_data: dict[str, str] = { + "asset": asset_doc["name"], + "task": task_name, + "family": creator.family, + "variant": variant + } + pre_create_data: dict[str, str] = { + "group_id": group_id, + "mark_for_review": mark_for_review + } + return creator.create(subset_name, instance_data, pre_create_data) + + def _prepare_render_passes( + self, + project_name: str, + asset_doc: dict[str, Any], + task_name: str, + render_layer_instance: CreatedInstance, + layers: list[dict[str, Any]], + mark_for_review: bool, + existing_render_passes: list[CreatedInstance] + ): + creator: CreateRenderPass = ( + self.create_context.creators[CreateRenderPass.identifier] + ) + render_pass_by_layer_name = {} + for render_pass in existing_render_passes: + for layer_name in render_pass["layer_names"]: + render_pass_by_layer_name[layer_name] = render_pass + + for layer in layers: + layer_name = layer["name"] + variant = layer_name + render_pass = render_pass_by_layer_name.get(layer_name) + if render_pass is not None: + if (render_pass["layer_names"]) > 1: + variant = render_pass["variant"] + + subset_name = creator.get_subset_name( + variant, + task_name, + asset_doc, + project_name, + host_name=self.create_context.host_name, + instance=render_pass + ) + + if render_pass is not None: + render_pass["asset"] = asset_doc["name"] + render_pass["task"] = task_name + render_pass["subset"] = subset_name + continue + + instance_data: dict[str, str] = { + "asset": asset_doc["name"], + "task": task_name, + "family": creator.family, + "variant": variant + } + pre_create_data: dict[str, Any] = { + "render_layer_instance_id": render_layer_instance.id, + "layer_names": [layer_name], + "mark_for_review": mark_for_review + } + creator.create(subset_name, instance_data, pre_create_data) + + def _filter_groups( + self, + layers_by_group_id, + groups_order, + only_visible_groups + ): + new_groups_order = [] + for group_id in groups_order: + layers: list[dict[str, Any]] = layers_by_group_id[group_id] + if not layers: + continue + + if ( + only_visible_groups + and not any( + layer + for layer in layers + if layer["visible"] + ) + ): + continue + new_groups_order.append(group_id) + return new_groups_order + + def create(self, subset_name, instance_data, pre_create_data): + project_name: str = self.create_context.get_current_project_name() + asset_name: str = instance_data["asset"] + task_name: str = instance_data["task"] + asset_doc: dict[str, Any] = get_asset_by_name(project_name, asset_name) + + render_layers_by_group_id: dict[int, CreatedInstance] = {} + render_passes_by_render_layer_id: dict[int, list[CreatedInstance]] = ( + collections.defaultdict(list) + ) + for instance in self.create_context.instances: + if instance.creator_identifier == CreateRenderlayer.identifier: + group_id = instance["creator_attributes"]["group_id"] + render_layers_by_group_id[group_id] = instance + elif instance.creator_identifier == CreateRenderPass.identifier: + render_layer_id = ( + instance + ["creator_attributes"] + ["render_layer_instance_id"] + ) + render_passes_by_render_layer_id[render_layer_id].append( + instance + ) + + layers_by_group_id: dict[int, list[dict[str, Any]]] = ( + collections.defaultdict(list) + ) + scene_layers: list[dict[str, Any]] = get_layers_data() + scene_groups: list[dict[str, Any]] = get_groups_data() + groups_order: list[int] = [] + for layer in scene_layers: + group_id: int = layer["group_id"] + # Skip 'default' group + if group_id == 0: + continue + + layers_by_group_id[group_id].append(layer) + if group_id not in groups_order: + groups_order.append(group_id) + + groups_order.reverse() + + mark_layers_for_review = pre_create_data.get( + "mark_layers_for_review", False + ) + mark_passes_for_review = pre_create_data.get( + "mark_passes_for_review", False + ) + rename_groups = pre_create_data.get("rename_groups", False) + only_visible_groups = pre_create_data.get("only_visible_groups", False) + groups_order = self._filter_groups( + layers_by_group_id, + groups_order, + only_visible_groups + ) + if not groups_order: + return + + if rename_groups: + self._rename_groups(groups_order, scene_groups) + + # Make sure all render layers are created + for group_id in groups_order: + instance: Union[CreatedInstance, None] = ( + self._prepare_render_layer( + project_name, + asset_doc, + task_name, + group_id, + scene_groups, + mark_layers_for_review, + render_layers_by_group_id.get(group_id), + ) + ) + if instance is not None: + render_layers_by_group_id[group_id] = instance + + for group_id in groups_order: + layers: list[dict[str, Any]] = layers_by_group_id[group_id] + render_layer_instance: Union[CreatedInstance, None] = ( + render_layers_by_group_id.get(group_id) + ) + if not layers or render_layer_instance is None: + continue + + self._prepare_render_passes( + project_name, + asset_doc, + task_name, + render_layer_instance, + layers, + mark_passes_for_review, + render_passes_by_render_layer_id[render_layer_instance.id] + ) + + def get_pre_create_attr_defs(self) -> list[AbstractAttrDef]: + render_layer_creator: CreateRenderlayer = ( + self.create_context.creators[CreateRenderlayer.identifier] + ) + render_pass_creator: CreateRenderPass = ( + self.create_context.creators[CreateRenderPass.identifier] + ) + output = [] + if self.allow_group_rename: + output.extend([ + BoolDef( + "rename_groups", + label="Rename color groups", + tooltip="Will rename color groups using studio template", + default=True + ), + BoolDef( + "only_visible_groups", + label="Only visible color groups", + tooltip=( + "Render Layers and rename will happen only on color" + " groups with visible layers." + ), + default=True + ), + UISeparatorDef() + ]) + output.extend([ + BoolDef( + "mark_layers_for_review", + label="Mark RenderLayers for review", + default=render_layer_creator.mark_for_review + ), + BoolDef( + "mark_passes_for_review", + label="Mark RenderPasses for review", + default=render_pass_creator.mark_for_review + ) + ]) + return output + + +class TVPaintSceneRenderCreator(TVPaintAutoCreator): + family = "render" + subset_template_family_filter = "renderScene" + identifier = "render.scene" + label = "Scene Render" + icon = "fa.file-image-o" + + # Settings + default_pass_name = "beauty" + mark_for_review = True + active_on_create = False + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["tvpaint"]["create"]["create_render_scene"] + ) + self.default_variant = plugin_settings["default_variant"] + self.default_variants = plugin_settings["default_variants"] + self.mark_for_review = plugin_settings["mark_for_review"] + self.active_on_create = plugin_settings["active_on_create"] + self.default_pass_name = plugin_settings["default_pass_name"] + + def get_dynamic_data(self, variant, *args, **kwargs): + dynamic_data = super().get_dynamic_data(variant, *args, **kwargs) + dynamic_data["renderpass"] = "{renderpass}" + dynamic_data["renderlayer"] = variant + return dynamic_data + + def _create_new_instance(self): + create_context = self.create_context + host_name = create_context.host_name + project_name = create_context.get_current_project_name() + asset_name = create_context.get_current_asset_name() + task_name = create_context.get_current_task_name() + + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + self.default_variant, + task_name, + asset_doc, + project_name, + host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": self.default_variant, + "creator_attributes": { + "render_pass_name": self.default_pass_name, + "mark_for_review": True + }, + "label": self._get_label( + subset_name, + self.default_pass_name + ) + } + if not self.active_on_create: + data["active"] = False + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + instances_data = self.host.list_instances() + instances_data.append(new_instance.data_to_store()) + self.host.write_instances(instances_data) + self._add_instance_to_context(new_instance) + return new_instance + + def create(self): + existing_instance = None + for instance in self.create_context.instances: + if instance.creator_identifier == self.identifier: + existing_instance = instance + break + + if existing_instance is None: + return self._create_new_instance() + + create_context = self.create_context + host_name = create_context.host_name + project_name = create_context.get_current_project_name() + asset_name = create_context.get_current_asset_name() + task_name = create_context.get_current_task_name() + + if ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + existing_instance["variant"], + task_name, + asset_doc, + project_name, + host_name, + existing_instance + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name + existing_instance["subset"] = subset_name + + existing_instance["label"] = self._get_label( + existing_instance["subset"], + existing_instance["creator_attributes"]["render_pass_name"] + ) + + def _get_label(self, subset_name, render_pass_name): + try: + subset_name = subset_name.format(**prepare_template_data({ + "renderpass": render_pass_name + })) + except (KeyError, ValueError): + pass + + return subset_name + + def get_instance_attr_defs(self): + return [ + TextDef( + "render_pass_name", + label="Pass Name", + default=self.default_pass_name, + tooltip=( + "Value is calculated during publishing and UI will update" + " label after refresh." + ) + ), + BoolDef( + "mark_for_review", + label="Review", + default=self.mark_for_review + ) + ] diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py deleted file mode 100644 index 009b69c4f1..0000000000 --- a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py +++ /dev/null @@ -1,231 +0,0 @@ -from openpype.lib import prepare_template_data -from openpype.pipeline import CreatorError -from openpype.hosts.tvpaint.api import ( - plugin, - CommunicationWrapper -) -from openpype.hosts.tvpaint.api.lib import ( - get_layers_data, - get_groups_data, - execute_george_through_file, -) -from openpype.hosts.tvpaint.api.pipeline import list_instances - - -class CreateRenderlayer(plugin.Creator): - """Mark layer group as one instance.""" - name = "render_layer" - label = "RenderLayer" - family = "renderLayer" - icon = "cube" - defaults = ["Main"] - - rename_group = True - render_pass = "beauty" - - rename_script_template = ( - "tv_layercolor \"setcolor\"" - " {clip_id} {group_id} {r} {g} {b} \"{name}\"" - ) - - dynamic_subset_keys = [ - "renderpass", "renderlayer", "render_pass", "render_layer", "group" - ] - - @classmethod - def get_dynamic_data( - cls, variant, task_name, asset_id, project_name, host_name - ): - dynamic_data = super(CreateRenderlayer, cls).get_dynamic_data( - variant, task_name, asset_id, project_name, host_name - ) - # Use render pass name from creator's plugin - dynamic_data["renderpass"] = cls.render_pass - # Add variant to render layer - dynamic_data["renderlayer"] = variant - # Change family for subset name fill - dynamic_data["family"] = "render" - - # TODO remove - Backwards compatibility for old subset name templates - # - added 2022/04/28 - dynamic_data["render_pass"] = dynamic_data["renderpass"] - dynamic_data["render_layer"] = dynamic_data["renderlayer"] - - return dynamic_data - - @classmethod - def get_default_variant(cls): - """Default value for variant in Creator tool. - - Method checks if TVPaint implementation is running and tries to find - selected layers from TVPaint. If only one is selected it's name is - returned. - - Returns: - str: Default variant name for Creator tool. - """ - # Validate that communication is initialized - if CommunicationWrapper.communicator: - # Get currently selected layers - layers_data = get_layers_data() - - selected_layers = [ - layer - for layer in layers_data - if layer["selected"] - ] - # Return layer name if only one is selected - if len(selected_layers) == 1: - return selected_layers[0]["name"] - - # Use defaults - if cls.defaults: - return cls.defaults[0] - return None - - def process(self): - self.log.debug("Query data from workfile.") - instances = list_instances() - layers_data = get_layers_data() - - self.log.debug("Checking for selection groups.") - # Collect group ids from selection - group_ids = set() - for layer in layers_data: - if layer["selected"]: - group_ids.add(layer["group_id"]) - - # Raise if there is no selection - if not group_ids: - raise CreatorError("Nothing is selected.") - - # This creator should run only on one group - if len(group_ids) > 1: - raise CreatorError("More than one group is in selection.") - - group_id = tuple(group_ids)[0] - # If group id is `0` it is `default` group which is invalid - if group_id == 0: - raise CreatorError( - "Selection is not in group. Can't mark selection as Beauty." - ) - - self.log.debug(f"Selected group id is \"{group_id}\".") - self.data["group_id"] = group_id - - group_data = get_groups_data() - group_name = None - for group in group_data: - if group["group_id"] == group_id: - group_name = group["name"] - break - - if group_name is None: - raise AssertionError( - "Couldn't find group by id \"{}\"".format(group_id) - ) - - subset_name_fill_data = { - "group": group_name - } - - family = self.family = self.data["family"] - - # Fill dynamic key 'group' - subset_name = self.data["subset"].format( - **prepare_template_data(subset_name_fill_data) - ) - self.data["subset"] = subset_name - - # Check for instances of same group - existing_instance = None - existing_instance_idx = None - # Check if subset name is not already taken - same_subset_instance = None - same_subset_instance_idx = None - for idx, instance in enumerate(instances): - if instance["family"] == family: - if instance["group_id"] == group_id: - existing_instance = instance - existing_instance_idx = idx - elif instance["subset"] == subset_name: - same_subset_instance = instance - same_subset_instance_idx = idx - - if ( - same_subset_instance_idx is not None - and existing_instance_idx is not None - ): - break - - if same_subset_instance_idx is not None: - if self._ask_user_subset_override(same_subset_instance): - instances.pop(same_subset_instance_idx) - else: - return - - if existing_instance is not None: - self.log.info( - f"Beauty instance for group id {group_id} already exists" - ", overriding" - ) - instances[existing_instance_idx] = self.data - else: - instances.append(self.data) - - self.write_instances(instances) - - if not self.rename_group: - self.log.info("Group rename function is turned off. Skipping") - return - - self.log.debug("Querying groups data from workfile.") - groups_data = get_groups_data() - - self.log.debug("Changing name of the group.") - selected_group = None - for group_data in groups_data: - if group_data["group_id"] == group_id: - selected_group = group_data - - # Rename TVPaint group (keep color same) - # - groups can't contain spaces - new_group_name = self.data["variant"].replace(" ", "_") - rename_script = self.rename_script_template.format( - clip_id=selected_group["clip_id"], - group_id=selected_group["group_id"], - r=selected_group["red"], - g=selected_group["green"], - b=selected_group["blue"], - name=new_group_name - ) - execute_george_through_file(rename_script) - - self.log.info( - f"Name of group with index {group_id}" - f" was changed to \"{new_group_name}\"." - ) - - def _ask_user_subset_override(self, instance): - from qtpy import QtCore - from qtpy.QtWidgets import QMessageBox - - title = "Subset \"{}\" already exist".format(instance["subset"]) - text = ( - "Instance with subset name \"{}\" already exists." - "\n\nDo you want to override existing?" - ).format(instance["subset"]) - - dialog = QMessageBox() - dialog.setWindowFlags( - dialog.windowFlags() - | QtCore.Qt.WindowStaysOnTopHint - ) - dialog.setWindowTitle(title) - dialog.setText(text) - dialog.setStandardButtons(QMessageBox.Yes | QMessageBox.No) - dialog.setDefaultButton(QMessageBox.Yes) - dialog.exec_() - if dialog.result() == QMessageBox.Yes: - return True - return False diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py deleted file mode 100644 index a44cb29f20..0000000000 --- a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py +++ /dev/null @@ -1,167 +0,0 @@ -from openpype.pipeline import CreatorError -from openpype.lib import prepare_template_data -from openpype.hosts.tvpaint.api import ( - plugin, - CommunicationWrapper -) -from openpype.hosts.tvpaint.api.lib import get_layers_data -from openpype.hosts.tvpaint.api.pipeline import list_instances - - -class CreateRenderPass(plugin.Creator): - """Render pass is combination of one or more layers from same group. - - Requirement to create Render Pass is to have already created beauty - instance. Beauty instance is used as base for subset name. - """ - name = "render_pass" - label = "RenderPass" - family = "renderPass" - icon = "cube" - defaults = ["Main"] - - dynamic_subset_keys = [ - "renderpass", "renderlayer", "render_pass", "render_layer" - ] - - @classmethod - def get_dynamic_data( - cls, variant, task_name, asset_id, project_name, host_name - ): - dynamic_data = super(CreateRenderPass, cls).get_dynamic_data( - variant, task_name, asset_id, project_name, host_name - ) - dynamic_data["renderpass"] = variant - dynamic_data["family"] = "render" - - # TODO remove - Backwards compatibility for old subset name templates - # - added 2022/04/28 - dynamic_data["render_pass"] = dynamic_data["renderpass"] - - return dynamic_data - - @classmethod - def get_default_variant(cls): - """Default value for variant in Creator tool. - - Method checks if TVPaint implementation is running and tries to find - selected layers from TVPaint. If only one is selected it's name is - returned. - - Returns: - str: Default variant name for Creator tool. - """ - # Validate that communication is initialized - if CommunicationWrapper.communicator: - # Get currently selected layers - layers_data = get_layers_data() - - selected_layers = [ - layer - for layer in layers_data - if layer["selected"] - ] - # Return layer name if only one is selected - if len(selected_layers) == 1: - return selected_layers[0]["name"] - - # Use defaults - if cls.defaults: - return cls.defaults[0] - return None - - def process(self): - self.log.debug("Query data from workfile.") - instances = list_instances() - layers_data = get_layers_data() - - self.log.debug("Checking selection.") - # Get all selected layers and their group ids - group_ids = set() - selected_layers = [] - for layer in layers_data: - if layer["selected"]: - selected_layers.append(layer) - group_ids.add(layer["group_id"]) - - # Raise if nothing is selected - if not selected_layers: - raise CreatorError("Nothing is selected.") - - # Raise if layers from multiple groups are selected - if len(group_ids) != 1: - raise CreatorError("More than one group is in selection.") - - group_id = tuple(group_ids)[0] - self.log.debug(f"Selected group id is \"{group_id}\".") - - # Find beauty instance for selected layers - beauty_instance = None - for instance in instances: - if ( - instance["family"] == "renderLayer" - and instance["group_id"] == group_id - ): - beauty_instance = instance - break - - # Beauty is required for this creator so raise if was not found - if beauty_instance is None: - raise CreatorError("Beauty pass does not exist yet.") - - subset_name = self.data["subset"] - - subset_name_fill_data = {} - - # Backwards compatibility - # - beauty may be created with older creator where variant was not - # stored - if "variant" not in beauty_instance: - render_layer = beauty_instance["name"] - else: - render_layer = beauty_instance["variant"] - - subset_name_fill_data["renderlayer"] = render_layer - subset_name_fill_data["render_layer"] = render_layer - - # Format dynamic keys in subset name - new_subset_name = subset_name.format( - **prepare_template_data(subset_name_fill_data) - ) - self.data["subset"] = new_subset_name - self.log.info(f"New subset name is \"{new_subset_name}\".") - - family = self.data["family"] - variant = self.data["variant"] - - self.data["group_id"] = group_id - self.data["pass"] = variant - self.data["renderlayer"] = render_layer - - # Collect selected layer ids to be stored into instance - layer_names = [layer["name"] for layer in selected_layers] - self.data["layer_names"] = layer_names - - # Check if same instance already exists - existing_instance = None - existing_instance_idx = None - for idx, instance in enumerate(instances): - if ( - instance["family"] == family - and instance["group_id"] == group_id - and instance["pass"] == variant - ): - existing_instance = instance - existing_instance_idx = idx - break - - if existing_instance is not None: - self.log.info( - f"Render pass instance for group id {group_id}" - f" and name \"{variant}\" already exists, overriding." - ) - instances[existing_instance_idx] = self.data - else: - instances.append(self.data) - - self.write_instances(instances) diff --git a/openpype/hosts/tvpaint/plugins/create/create_review.py b/openpype/hosts/tvpaint/plugins/create/create_review.py new file mode 100644 index 0000000000..886dae7c39 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/create/create_review.py @@ -0,0 +1,76 @@ +from openpype.client import get_asset_by_name +from openpype.pipeline import CreatedInstance +from openpype.hosts.tvpaint.api.plugin import TVPaintAutoCreator + + +class TVPaintReviewCreator(TVPaintAutoCreator): + family = "review" + identifier = "scene.review" + label = "Review" + icon = "ei.video" + + # Settings + active_on_create = True + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["tvpaint"]["create"]["create_review"] + ) + self.default_variant = plugin_settings["default_variant"] + self.default_variants = plugin_settings["default_variants"] + self.active_on_create = plugin_settings["active_on_create"] + + def create(self): + existing_instance = None + for instance in self.create_context.instances: + if instance.creator_identifier == self.identifier: + existing_instance = instance + break + + create_context = self.create_context + host_name = create_context.host_name + project_name = create_context.get_current_project_name() + asset_name = create_context.get_current_asset_name() + task_name = create_context.get_current_task_name() + + if existing_instance is None: + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + self.default_variant, + task_name, + asset_doc, + project_name, + host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": self.default_variant + } + if not self.active_on_create: + data["active"] = False + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + instances_data = self.host.list_instances() + instances_data.append(new_instance.data_to_store()) + self.host.write_instances(instances_data) + self._add_instance_to_context(new_instance) + + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + existing_instance["variant"], + task_name, + asset_doc, + project_name, + host_name, + existing_instance + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name + existing_instance["subset"] = subset_name diff --git a/openpype/hosts/tvpaint/plugins/create/create_workfile.py b/openpype/hosts/tvpaint/plugins/create/create_workfile.py new file mode 100644 index 0000000000..41347576d5 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/create/create_workfile.py @@ -0,0 +1,70 @@ +from openpype.client import get_asset_by_name +from openpype.pipeline import CreatedInstance +from openpype.hosts.tvpaint.api.plugin import TVPaintAutoCreator + + +class TVPaintWorkfileCreator(TVPaintAutoCreator): + family = "workfile" + identifier = "workfile" + label = "Workfile" + icon = "fa.file-o" + + def apply_settings(self, project_settings, system_settings): + plugin_settings = ( + project_settings["tvpaint"]["create"]["create_workfile"] + ) + self.default_variant = plugin_settings["default_variant"] + self.default_variants = plugin_settings["default_variants"] + + def create(self): + existing_instance = None + for instance in self.create_context.instances: + if instance.creator_identifier == self.identifier: + existing_instance = instance + break + + create_context = self.create_context + host_name = create_context.host_name + project_name = create_context.get_current_project_name() + asset_name = create_context.get_current_asset_name() + task_name = create_context.get_current_task_name() + + if existing_instance is None: + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + self.default_variant, + task_name, + asset_doc, + project_name, + host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": self.default_variant + } + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + instances_data = self.host.list_instances() + instances_data.append(new_instance.data_to_store()) + self.host.write_instances(instances_data) + self._add_instance_to_context(new_instance) + + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = get_asset_by_name(project_name, asset_name) + subset_name = self.get_subset_name( + existing_instance["variant"], + task_name, + asset_doc, + project_name, + host_name, + existing_instance + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name + existing_instance["subset"] = subset_name diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py b/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py index d5b79758ad..5eb702a1da 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py @@ -1,37 +1,34 @@ import pyblish.api -class CollectOutputFrameRange(pyblish.api.ContextPlugin): +class CollectOutputFrameRange(pyblish.api.InstancePlugin): """Collect frame start/end from context. When instances are collected context does not contain `frameStart` and `frameEnd` keys yet. They are collected in global plugin `CollectContextEntities`. """ + label = "Collect output frame range" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder + 0.4999 hosts = ["tvpaint"] + families = ["review", "render"] - def process(self, context): - for instance in context: - frame_start = instance.data.get("frameStart") - frame_end = instance.data.get("frameEnd") - if frame_start is not None and frame_end is not None: - self.log.debug( - "Instance {} already has set frames {}-{}".format( - str(instance), frame_start, frame_end - ) - ) - return + def process(self, instance): + asset_doc = instance.data.get("assetEntity") + if not asset_doc: + return - frame_start = context.data.get("frameStart") - frame_end = context.data.get("frameEnd") + context = instance.context - instance.data["frameStart"] = frame_start - instance.data["frameEnd"] = frame_end - - self.log.info( - "Set frames {}-{} on instance {} ".format( - frame_start, frame_end, str(instance) - ) + frame_start = asset_doc["data"]["frameStart"] + frame_end = frame_start + ( + context.data["sceneMarkOut"] - context.data["sceneMarkIn"] + ) + instance.data["frameStart"] = frame_start + instance.data["frameEnd"] = frame_end + self.log.info( + "Set frames {}-{} on instance {} ".format( + frame_start, frame_end, instance.data["subset"] ) + ) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py deleted file mode 100644 index ae1326a5bd..0000000000 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ /dev/null @@ -1,280 +0,0 @@ -import json -import copy -import pyblish.api - -from openpype.client import get_asset_by_name -from openpype.pipeline import legacy_io -from openpype.pipeline.create import get_subset_name - - -class CollectInstances(pyblish.api.ContextPlugin): - label = "Collect Instances" - order = pyblish.api.CollectorOrder - 0.4 - hosts = ["tvpaint"] - - def process(self, context): - workfile_instances = context.data["workfileInstances"] - - self.log.debug("Collected ({}) instances:\n{}".format( - len(workfile_instances), - json.dumps(workfile_instances, indent=4) - )) - - filtered_instance_data = [] - # Backwards compatibility for workfiles that already have review - # instance in metadata. - review_instance_exist = False - for instance_data in workfile_instances: - family = instance_data["family"] - if family == "review": - review_instance_exist = True - - elif family not in ("renderPass", "renderLayer"): - self.log.info("Unknown family \"{}\". Skipping {}".format( - family, json.dumps(instance_data, indent=4) - )) - continue - - filtered_instance_data.append(instance_data) - - # Fake review instance if review was not found in metadata families - if not review_instance_exist: - filtered_instance_data.append( - self._create_review_instance_data(context) - ) - - for instance_data in filtered_instance_data: - instance_data["fps"] = context.data["sceneFps"] - - # Conversion from older instances - # - change 'render_layer' to 'renderlayer' - render_layer = instance_data.get("instance_data") - if not render_layer: - # Render Layer has only variant - if instance_data["family"] == "renderLayer": - render_layer = instance_data.get("variant") - - # Backwards compatibility for renderPasses - elif "render_layer" in instance_data: - render_layer = instance_data["render_layer"] - - if render_layer: - instance_data["renderlayer"] = render_layer - - # Store workfile instance data to instance data - instance_data["originData"] = copy.deepcopy(instance_data) - # Global instance data modifications - # Fill families - family = instance_data["family"] - families = [family] - if family != "review": - families.append("review") - # Add `review` family for thumbnail integration - instance_data["families"] = families - - # Instance name - subset_name = instance_data["subset"] - name = instance_data.get("name", subset_name) - instance_data["name"] = name - instance_data["label"] = "{} [{}-{}]".format( - name, - context.data["sceneMarkIn"] + 1, - context.data["sceneMarkOut"] + 1 - ) - - active = instance_data.get("active", True) - instance_data["active"] = active - instance_data["publish"] = active - # Add representations key - instance_data["representations"] = [] - - # Different instance creation based on family - instance = None - if family == "review": - # Change subset name of review instance - - # Project name from workfile context - project_name = context.data["workfile_context"]["project"] - - # Collect asset doc to get asset id - # - not sure if it's good idea to require asset id in - # get_subset_name? - asset_name = context.data["workfile_context"]["asset"] - asset_doc = get_asset_by_name(project_name, asset_name) - - # Host name from environment variable - host_name = context.data["hostName"] - # Use empty variant value - variant = "" - task_name = legacy_io.Session["AVALON_TASK"] - new_subset_name = get_subset_name( - family, - variant, - task_name, - asset_doc, - project_name, - host_name, - project_settings=context.data["project_settings"] - ) - instance_data["subset"] = new_subset_name - - instance = context.create_instance(**instance_data) - - instance.data["layers"] = copy.deepcopy( - context.data["layersData"] - ) - - elif family == "renderLayer": - instance = self.create_render_layer_instance( - context, instance_data - ) - elif family == "renderPass": - instance = self.create_render_pass_instance( - context, instance_data - ) - - if instance is None: - continue - - any_visible = False - for layer in instance.data["layers"]: - if layer["visible"]: - any_visible = True - break - - instance.data["publish"] = any_visible - - self.log.debug("Created instance: {}\n{}".format( - instance, json.dumps(instance.data, indent=4) - )) - - def _create_review_instance_data(self, context): - """Fake review instance data.""" - - return { - "family": "review", - "asset": context.data["asset"], - # Dummy subset name - "subset": "reviewMain" - } - - def create_render_layer_instance(self, context, instance_data): - name = instance_data["name"] - # Change label - subset_name = instance_data["subset"] - - # Backwards compatibility - # - subset names were not stored as final subset names during creation - if "variant" not in instance_data: - instance_data["label"] = "{}_Beauty".format(name) - - # Change subset name - # Final family of an instance will be `render` - new_family = "render" - task_name = legacy_io.Session["AVALON_TASK"] - new_subset_name = "{}{}_{}_Beauty".format( - new_family, task_name.capitalize(), name - ) - instance_data["subset"] = new_subset_name - self.log.debug("Changed subset name \"{}\"->\"{}\"".format( - subset_name, new_subset_name - )) - - # Get all layers for the layer - layers_data = context.data["layersData"] - group_id = instance_data["group_id"] - group_layers = [] - for layer in layers_data: - if layer["group_id"] == group_id: - group_layers.append(layer) - - if not group_layers: - # Should be handled here? - self.log.warning(( - f"Group with id {group_id} does not contain any layers." - f" Instance \"{name}\" not created." - )) - return None - - instance_data["layers"] = group_layers - - return context.create_instance(**instance_data) - - def create_render_pass_instance(self, context, instance_data): - pass_name = instance_data["pass"] - self.log.info( - "Creating render pass instance. \"{}\"".format(pass_name) - ) - # Change label - render_layer = instance_data["renderlayer"] - - # Backwards compatibility - # - subset names were not stored as final subset names during creation - if "variant" not in instance_data: - instance_data["label"] = "{}_{}".format(render_layer, pass_name) - # Change subset name - # Final family of an instance will be `render` - new_family = "render" - old_subset_name = instance_data["subset"] - task_name = legacy_io.Session["AVALON_TASK"] - new_subset_name = "{}{}_{}_{}".format( - new_family, task_name.capitalize(), render_layer, pass_name - ) - instance_data["subset"] = new_subset_name - self.log.debug("Changed subset name \"{}\"->\"{}\"".format( - old_subset_name, new_subset_name - )) - - layers_data = context.data["layersData"] - layers_by_name = { - layer["name"]: layer - for layer in layers_data - } - - if "layer_names" in instance_data: - layer_names = instance_data["layer_names"] - else: - # Backwards compatibility - # - not 100% working as it was found out that layer ids can't be - # used as unified identifier across multiple workstations - layers_by_id = { - layer["layer_id"]: layer - for layer in layers_data - } - layer_ids = instance_data["layer_ids"] - layer_names = [] - for layer_id in layer_ids: - layer = layers_by_id.get(layer_id) - if layer: - layer_names.append(layer["name"]) - - if not layer_names: - raise ValueError(( - "Metadata contain old way of storing layers information." - " It is not possible to identify layers to publish with" - " these data. Please remove Render Pass instances with" - " Subset manager and use Creator tool to recreate them." - )) - - render_pass_layers = [] - for layer_name in layer_names: - layer = layers_by_name.get(layer_name) - # NOTE This is kind of validation before validators? - if not layer: - self.log.warning( - f"Layer with name {layer_name} was not found." - ) - continue - - render_pass_layers.append(layer) - - if not render_pass_layers: - name = instance_data["name"] - self.log.warning( - f"None of the layers from the RenderPass \"{name}\"" - " exist anymore. Instance not created." - ) - return None - - instance_data["layers"] = render_pass_layers - return context.create_instance(**instance_data) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_render_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_render_instances.py new file mode 100644 index 0000000000..e89fbf7882 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/collect_render_instances.py @@ -0,0 +1,114 @@ +import copy +import pyblish.api +from openpype.lib import prepare_template_data + + +class CollectRenderInstances(pyblish.api.InstancePlugin): + label = "Collect Render Instances" + order = pyblish.api.CollectorOrder - 0.4 + hosts = ["tvpaint"] + families = ["render", "review"] + + ignore_render_pass_transparency = False + + def process(self, instance): + context = instance.context + creator_identifier = instance.data["creator_identifier"] + if creator_identifier == "render.layer": + self._collect_data_for_render_layer(instance) + + elif creator_identifier == "render.pass": + self._collect_data_for_render_pass(instance) + + elif creator_identifier == "render.scene": + self._collect_data_for_render_scene(instance) + + else: + if creator_identifier == "scene.review": + self._collect_data_for_review(instance) + return + + subset_name = instance.data["subset"] + instance.data["name"] = subset_name + instance.data["label"] = "{} [{}-{}]".format( + subset_name, + context.data["sceneMarkIn"] + 1, + context.data["sceneMarkOut"] + 1 + ) + + def _collect_data_for_render_layer(self, instance): + instance.data["families"].append("renderLayer") + creator_attributes = instance.data["creator_attributes"] + group_id = creator_attributes["group_id"] + if creator_attributes["mark_for_review"]: + instance.data["families"].append("review") + + layers_data = instance.context.data["layersData"] + instance.data["layers"] = [ + copy.deepcopy(layer) + for layer in layers_data + if layer["group_id"] == group_id + ] + + def _collect_data_for_render_pass(self, instance): + instance.data["families"].append("renderPass") + + layer_names = set(instance.data["layer_names"]) + layers_data = instance.context.data["layersData"] + + creator_attributes = instance.data["creator_attributes"] + if creator_attributes["mark_for_review"]: + instance.data["families"].append("review") + + instance.data["layers"] = [ + copy.deepcopy(layer) + for layer in layers_data + if layer["name"] in layer_names + ] + instance.data["ignoreLayersTransparency"] = ( + self.ignore_render_pass_transparency + ) + + render_layer_data = None + render_layer_id = creator_attributes["render_layer_instance_id"] + for in_data in instance.context.data["workfileInstances"]: + if ( + in_data["creator_identifier"] == "render.layer" + and in_data["instance_id"] == render_layer_id + ): + render_layer_data = in_data + break + + instance.data["renderLayerData"] = copy.deepcopy(render_layer_data) + # Invalid state + if render_layer_data is None: + return + render_layer_name = render_layer_data["variant"] + subset_name = instance.data["subset"] + instance.data["subset"] = subset_name.format( + **prepare_template_data({"renderlayer": render_layer_name}) + ) + + def _collect_data_for_render_scene(self, instance): + instance.data["families"].append("renderScene") + + creator_attributes = instance.data["creator_attributes"] + if creator_attributes["mark_for_review"]: + instance.data["families"].append("review") + + instance.data["layers"] = copy.deepcopy( + instance.context.data["layersData"] + ) + + render_pass_name = ( + instance.data["creator_attributes"]["render_pass_name"] + ) + subset_name = instance.data["subset"] + instance.data["subset"] = subset_name.format( + **prepare_template_data({"renderpass": render_pass_name}) + ) + + def _collect_data_for_review(self, instance): + instance.data["layers"] = copy.deepcopy( + instance.context.data["layersData"] + ) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py deleted file mode 100644 index 92a2815ba0..0000000000 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ /dev/null @@ -1,114 +0,0 @@ -import json -import copy -import pyblish.api - -from openpype.client import get_asset_by_name -from openpype.pipeline.create import get_subset_name - - -class CollectRenderScene(pyblish.api.ContextPlugin): - """Collect instance which renders whole scene in PNG. - - Creates instance with family 'renderScene' which will have all layers - to render which will be composite into one result. The instance is not - collected from scene. - - Scene will be rendered with all visible layers similar way like review is. - - Instance is disabled if there are any created instances of 'renderLayer' - or 'renderPass'. That is because it is expected that this instance is - used as lazy publish of TVPaint file. - - Subset name is created similar way like 'renderLayer' family. It can use - `renderPass` and `renderLayer` keys which can be set using settings and - `variant` is filled using `renderPass` value. - """ - label = "Collect Render Scene" - order = pyblish.api.CollectorOrder - 0.39 - hosts = ["tvpaint"] - - # Value of 'render_pass' in subset name template - render_pass = "beauty" - - # Settings attributes - enabled = False - # Value of 'render_layer' and 'variant' in subset name template - render_layer = "Main" - - def process(self, context): - # Check if there are created instances of renderPass and renderLayer - # - that will define if renderScene instance is enabled after - # collection - any_created_instance = False - for instance in context: - family = instance.data["family"] - if family in ("renderPass", "renderLayer"): - any_created_instance = True - break - - # Global instance data modifications - # Fill families - family = "renderScene" - # Add `review` family for thumbnail integration - families = [family, "review"] - - # Collect asset doc to get asset id - # - not sure if it's good idea to require asset id in - # get_subset_name? - workfile_context = context.data["workfile_context"] - # Project name from workfile context - project_name = context.data["workfile_context"]["project"] - asset_name = workfile_context["asset"] - asset_doc = get_asset_by_name(project_name, asset_name) - - # Host name from environment variable - host_name = context.data["hostName"] - # Variant is using render pass name - variant = self.render_layer - dynamic_data = { - "renderlayer": self.render_layer, - "renderpass": self.render_pass, - } - # TODO remove - Backwards compatibility for old subset name templates - # - added 2022/04/28 - dynamic_data["render_layer"] = dynamic_data["renderlayer"] - dynamic_data["render_pass"] = dynamic_data["renderpass"] - - task_name = workfile_context["task"] - subset_name = get_subset_name( - "render", - variant, - task_name, - asset_doc, - project_name, - host_name, - dynamic_data=dynamic_data, - project_settings=context.data["project_settings"] - ) - - instance_data = { - "family": family, - "families": families, - "fps": context.data["sceneFps"], - "subset": subset_name, - "name": subset_name, - "label": "{} [{}-{}]".format( - subset_name, - context.data["sceneMarkIn"] + 1, - context.data["sceneMarkOut"] + 1 - ), - "active": not any_created_instance, - "publish": not any_created_instance, - "representations": [], - "layers": copy.deepcopy(context.data["layersData"]), - "asset": asset_name, - "task": task_name, - # Add render layer to instance data - "renderlayer": self.render_layer - } - - instance = context.create_instance(**instance_data) - - self.log.debug("Created instance: {}\n{}".format( - instance, json.dumps(instance.data, indent=4) - )) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py index 8c7c8c3899..a3449663f8 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py @@ -2,17 +2,15 @@ import os import json import pyblish.api -from openpype.client import get_asset_by_name -from openpype.pipeline import legacy_io -from openpype.pipeline.create import get_subset_name - -class CollectWorkfile(pyblish.api.ContextPlugin): +class CollectWorkfile(pyblish.api.InstancePlugin): label = "Collect Workfile" order = pyblish.api.CollectorOrder - 0.4 hosts = ["tvpaint"] + families = ["workfile"] - def process(self, context): + def process(self, instance): + context = instance.context current_file = context.data["currentFile"] self.log.info( @@ -21,49 +19,14 @@ class CollectWorkfile(pyblish.api.ContextPlugin): dirpath, filename = os.path.split(current_file) basename, ext = os.path.splitext(filename) - instance = context.create_instance(name=basename) - # Project name from workfile context - project_name = context.data["workfile_context"]["project"] - - # Get subset name of workfile instance - # Collect asset doc to get asset id - # - not sure if it's good idea to require asset id in - # get_subset_name? - family = "workfile" - asset_name = context.data["workfile_context"]["asset"] - asset_doc = get_asset_by_name(project_name, asset_name) - - # Host name from environment variable - host_name = os.environ["AVALON_APP"] - # Use empty variant value - variant = "" - task_name = legacy_io.Session["AVALON_TASK"] - subset_name = get_subset_name( - family, - variant, - task_name, - asset_doc, - project_name, - host_name, - project_settings=context.data["project_settings"] - ) - - # Create Workfile instance - instance.data.update({ - "subset": subset_name, - "asset": context.data["asset"], - "label": subset_name, - "publish": True, - "family": "workfile", - "families": ["workfile"], - "representations": [{ - "name": ext.lstrip("."), - "ext": ext.lstrip("."), - "files": filename, - "stagingDir": dirpath - }] + instance.data["representations"].append({ + "name": ext.lstrip("."), + "ext": ext.lstrip("."), + "files": filename, + "stagingDir": dirpath }) + self.log.info("Collected workfile instance: {}".format( json.dumps(instance.data, indent=4) )) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index 8fe71a4a46..95a5cd77bd 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -65,9 +65,9 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): # Collect and store current context to have reference current_context = { - "project": legacy_io.Session["AVALON_PROJECT"], - "asset": legacy_io.Session["AVALON_ASSET"], - "task": legacy_io.Session["AVALON_TASK"] + "project_name": context.data["projectName"], + "asset_name": context.data["asset"], + "task_name": context.data["task"] } context.data["previous_context"] = current_context self.log.debug("Current context is: {}".format(current_context)) @@ -76,25 +76,31 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): self.log.info("Collecting workfile context") workfile_context = get_current_workfile_context() + if "project" in workfile_context: + workfile_context = { + "project_name": workfile_context.get("project"), + "asset_name": workfile_context.get("asset"), + "task_name": workfile_context.get("task"), + } # Store workfile context to pyblish context context.data["workfile_context"] = workfile_context if workfile_context: # Change current context with context from workfile key_map = ( - ("AVALON_ASSET", "asset"), - ("AVALON_TASK", "task") + ("AVALON_ASSET", "asset_name"), + ("AVALON_TASK", "task_name") ) for env_key, key in key_map: legacy_io.Session[env_key] = workfile_context[key] os.environ[env_key] = workfile_context[key] self.log.info("Context changed to: {}".format(workfile_context)) - asset_name = workfile_context["asset"] - task_name = workfile_context["task"] + asset_name = workfile_context["asset_name"] + task_name = workfile_context["task_name"] else: - asset_name = current_context["asset"] - task_name = current_context["task"] + asset_name = current_context["asset_name"] + task_name = current_context["task_name"] # Handle older workfiles or workfiles without metadata self.log.warning(( "Workfile does not contain information about context." @@ -103,6 +109,7 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): # Store context asset name context.data["asset"] = asset_name + context.data["task"] = task_name self.log.info( "Context is set to Asset: \"{}\" and Task: \"{}\"".format( asset_name, task_name diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 78074f720c..1a21715aa2 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -6,6 +6,7 @@ from PIL import Image import pyblish.api +from openpype.pipeline.publish import KnownPublishError from openpype.hosts.tvpaint.api.lib import ( execute_george, execute_george_through_file, @@ -24,8 +25,7 @@ from openpype.hosts.tvpaint.lib import ( class ExtractSequence(pyblish.api.Extractor): label = "Extract Sequence" hosts = ["tvpaint"] - families = ["review", "renderPass", "renderLayer", "renderScene"] - families_to_review = ["review"] + families = ["review", "render"] # Modifiable with settings review_bg = [255, 255, 255, 255] @@ -59,6 +59,10 @@ class ExtractSequence(pyblish.api.Extractor): ) ) + ignore_layers_transparency = instance.data.get( + "ignoreLayersTransparency", False + ) + family_lowered = instance.data["family"].lower() mark_in = instance.context.data["sceneMarkIn"] mark_out = instance.context.data["sceneMarkOut"] @@ -114,7 +118,11 @@ class ExtractSequence(pyblish.api.Extractor): else: # Render output result = self.render( - output_dir, mark_in, mark_out, filtered_layers + output_dir, + mark_in, + mark_out, + filtered_layers, + ignore_layers_transparency ) output_filepaths_by_frame_idx, thumbnail_fullpath = result @@ -136,7 +144,7 @@ class ExtractSequence(pyblish.api.Extractor): # Fill tags and new families from project settings tags = [] - if family_lowered in self.families_to_review: + if family_lowered == "review": tags.append("review") # Sequence of one frame @@ -162,10 +170,6 @@ class ExtractSequence(pyblish.api.Extractor): instance.data["representations"].append(new_repre) - if family_lowered in ("renderpass", "renderlayer", "renderscene"): - # Change family to render - instance.data["family"] = "render" - if not thumbnail_fullpath: return @@ -259,7 +263,7 @@ class ExtractSequence(pyblish.api.Extractor): output_filepaths_by_frame_idx[frame_idx] = filepath if not os.path.exists(filepath): - raise AssertionError( + raise KnownPublishError( "Output was not rendered. File was not found {}".format( filepath ) @@ -278,7 +282,9 @@ class ExtractSequence(pyblish.api.Extractor): return output_filepaths_by_frame_idx, thumbnail_filepath - def render(self, output_dir, mark_in, mark_out, layers): + def render( + self, output_dir, mark_in, mark_out, layers, ignore_layer_opacity + ): """ Export images from TVPaint. Args: @@ -286,6 +292,7 @@ class ExtractSequence(pyblish.api.Extractor): mark_in (int): Starting frame index from which export will begin. mark_out (int): On which frame index export will end. layers (list): List of layers to be exported. + ignore_layer_opacity (bool): Layer's opacity will be ignored. Returns: tuple: With 2 items first is list of filenames second is path to @@ -327,7 +334,7 @@ class ExtractSequence(pyblish.api.Extractor): for layer_id, render_data in extraction_data_by_layer_id.items(): layer = layers_by_id[layer_id] filepaths_by_layer_id[layer_id] = self._render_layer( - render_data, layer, output_dir + render_data, layer, output_dir, ignore_layer_opacity ) # Prepare final filepaths where compositing should store result @@ -384,7 +391,9 @@ class ExtractSequence(pyblish.api.Extractor): red, green, blue = self.review_bg return (red, green, blue) - def _render_layer(self, render_data, layer, output_dir): + def _render_layer( + self, render_data, layer, output_dir, ignore_layer_opacity + ): frame_references = render_data["frame_references"] filenames_by_frame_index = render_data["filenames_by_frame_index"] @@ -393,6 +402,12 @@ class ExtractSequence(pyblish.api.Extractor): "tv_layerset {}".format(layer_id), "tv_SaveMode \"PNG\"" ] + # Set density to 100 and store previous opacity + if ignore_layer_opacity: + george_script_lines.extend([ + "tv_layerdensity 100", + "orig_opacity = result", + ]) filepaths_by_frame = {} frames_to_render = [] @@ -413,6 +428,10 @@ class ExtractSequence(pyblish.api.Extractor): # Store image to output george_script_lines.append("tv_saveimage \"{}\"".format(dst_path)) + # Set density back to origin opacity + if ignore_layer_opacity: + george_script_lines.append("tv_layerdensity orig_opacity") + self.log.debug("Rendering Exposure frames {} of layer {} ({})".format( ",".join(frames_to_render), layer_id, layer["name"] )) diff --git a/openpype/hosts/tvpaint/plugins/publish/help/validate_render_layer_group.xml b/openpype/hosts/tvpaint/plugins/publish/help/validate_render_layer_group.xml new file mode 100644 index 0000000000..a95387356f --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/help/validate_render_layer_group.xml @@ -0,0 +1,18 @@ + + + +Overused Color group +## One Color group is used by multiple Render Layers + +Single color group used by multiple Render Layers would cause clashes of rendered TVPaint layers. The same layers would be used for output files of both groups. + +### Missing layer names + +{groups_information} + +### How to repair? + +Refresh, go to 'Publish' tab and go through Render Layers and change their groups to not clash each other. If you reach limit of TVPaint color groups there is nothing you can do about it to fix the issue. + + + diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py b/openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py index 9f61bdbcd0..722d76b4d2 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py @@ -20,6 +20,9 @@ class ValidateLayersGroup(pyblish.api.InstancePlugin): duplicated_layer_names = [] for layer_name in layer_names: layers = layers_by_name.get(layer_name) + # It is not job of this validator to handle missing layers + if layers is None: + continue if len(layers) > 1: duplicated_layer_names.append(layer_name) diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py b/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py index d3a04cc69f..6a496a2e49 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py @@ -8,11 +8,16 @@ class ValidateLayersVisiblity(pyblish.api.InstancePlugin): label = "Validate Layers Visibility" order = pyblish.api.ValidatorOrder - families = ["review", "renderPass", "renderLayer", "renderScene"] + families = ["review", "render"] def process(self, instance): + layers = instance.data["layers"] + # Instance have empty layers + # - it is not job of this validator to check that + if not layers: + return layer_names = set() - for layer in instance.data["layers"]: + for layer in layers: layer_names.add(layer["name"]) if layer["visible"]: return diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_render_layer_group.py b/openpype/hosts/tvpaint/plugins/publish/validate_render_layer_group.py new file mode 100644 index 0000000000..bb0a9a4ffe --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/validate_render_layer_group.py @@ -0,0 +1,74 @@ +import collections +import pyblish.api +from openpype.pipeline import PublishXmlValidationError + + +class ValidateRenderLayerGroups(pyblish.api.ContextPlugin): + """Validate group ids of renderLayer subsets. + + Validate that there are not 2 render layers using the same group. + """ + + label = "Validate Render Layers Group" + order = pyblish.api.ValidatorOrder + 0.1 + + def process(self, context): + # Prepare layers + render_layers_by_group_id = collections.defaultdict(list) + for instance in context: + families = instance.data.get("families") + if not families or "renderLayer" not in families: + continue + + group_id = instance.data["creator_attributes"]["group_id"] + render_layers_by_group_id[group_id].append(instance) + + duplicated_instances = [] + for group_id, instances in render_layers_by_group_id.items(): + if len(instances) > 1: + duplicated_instances.append((group_id, instances)) + + if not duplicated_instances: + return + + # Exception message preparations + groups_data = context.data["groupsData"] + groups_by_id = { + group["group_id"]: group + for group in groups_data + } + + per_group_msgs = [] + groups_information_lines = [] + for group_id, instances in duplicated_instances: + group = groups_by_id[group_id] + group_label = "Group \"{}\" ({})".format( + group["name"], + group["group_id"], + ) + line_join_subset_names = "\n".join([ + f" - {instance['subset']}" + for instance in instances + ]) + joined_subset_names = ", ".join([ + f"\"{instance['subset']}\"" + for instance in instances + ]) + per_group_msgs.append( + "{} < {} >".format(group_label, joined_subset_names) + ) + groups_information_lines.append( + "{}\n{}".format(group_label, line_join_subset_names) + ) + + # Raise an error + raise PublishXmlValidationError( + self, + ( + "More than one Render Layer is using the same TVPaint" + " group color. {}" + ).format(" | ".join(per_group_msgs)), + formatting_data={ + "groups_information": "\n".join(groups_information_lines) + } + ) diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py b/openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py index 0fbfca6c56..2a3173c698 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py @@ -85,6 +85,5 @@ class ValidateLayersGroup(pyblish.api.InstancePlugin): ), "expected_group": correct_group["name"], "layer_names": ", ".join(invalid_layer_names) - } ) diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py b/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py index d235215ac9..4473e4b1b7 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py @@ -42,7 +42,7 @@ class ValidateProjectSettings(pyblish.api.ContextPlugin): "expected_width": expected_data["resolutionWidth"], "expected_height": expected_data["resolutionHeight"], "current_width": scene_data["resolutionWidth"], - "current_height": scene_data["resolutionWidth"], + "current_height": scene_data["resolutionHeight"], "expected_pixel_ratio": expected_data["pixelAspect"], "current_pixel_ratio": scene_data["pixelAspect"] } diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py index d66ae50c60..b38231e208 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py @@ -1,5 +1,9 @@ import pyblish.api -from openpype.pipeline import PublishXmlValidationError, registered_host +from openpype.pipeline import ( + PublishXmlValidationError, + PublishValidationError, + registered_host, +) class ValidateWorkfileMetadataRepair(pyblish.api.Action): @@ -27,13 +31,18 @@ class ValidateWorkfileMetadata(pyblish.api.ContextPlugin): actions = [ValidateWorkfileMetadataRepair] - required_keys = {"project", "asset", "task"} + required_keys = {"project_name", "asset_name", "task_name"} def process(self, context): workfile_context = context.data["workfile_context"] if not workfile_context: - raise AssertionError( - "Current workfile is missing whole metadata about context." + raise PublishValidationError( + "Current workfile is missing whole metadata about context.", + "Missing context", + ( + "Current workfile is missing metadata about task." + " To fix this issue save the file using Workfiles tool." + ) ) missing_keys = [] diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py index 0f25f2f7be..2ed5afa11c 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py @@ -1,4 +1,3 @@ -import os import pyblish.api from openpype.pipeline import PublishXmlValidationError @@ -16,15 +15,15 @@ class ValidateWorkfileProjectName(pyblish.api.ContextPlugin): def process(self, context): workfile_context = context.data.get("workfile_context") # If workfile context is missing than project is matching to - # `AVALON_PROJECT` value for 100% + # global project if not workfile_context: self.log.info( "Workfile context (\"workfile_context\") is not filled." ) return - workfile_project_name = workfile_context["project"] - env_project_name = os.environ["AVALON_PROJECT"] + workfile_project_name = workfile_context["project_name"] + env_project_name = context.data["projectName"] if workfile_project_name == env_project_name: self.log.info(( "Both workfile project and environment project are same. {}" diff --git a/openpype/hosts/unreal/addon.py b/openpype/hosts/unreal/addon.py index e2c8484651..24e2db975d 100644 --- a/openpype/hosts/unreal/addon.py +++ b/openpype/hosts/unreal/addon.py @@ -17,9 +17,10 @@ class UnrealAddon(OpenPypeModule, IHostAddon): ue_plugin = "UE_5.0" if app.name[:1] == "5" else "UE_4.7" unreal_plugin_path = os.path.join( - UNREAL_ROOT_DIR, "integration", ue_plugin + UNREAL_ROOT_DIR, "integration", ue_plugin, "OpenPype" ) - if not env.get("OPENPYPE_UNREAL_PLUGIN"): + if not env.get("OPENPYPE_UNREAL_PLUGIN") or \ + env.get("OPENPYPE_UNREAL_PLUGIN") != unreal_plugin_path: env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path # Set default environments if are not set via settings diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index ca9db259e6..2618a7677c 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- """Unreal Editor OpenPype host API.""" -from .plugin import Loader +from .plugin import ( + UnrealActorCreator, + UnrealAssetCreator, + Loader +) from .pipeline import ( install, diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 2081c8fd13..8a5a459194 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- import os +import json import logging from typing import List from contextlib import contextmanager import semver +import time import pyblish.api @@ -16,13 +18,14 @@ from openpype.pipeline import ( ) from openpype.tools.utils import host_tools import openpype.hosts.unreal -from openpype.host import HostBase, ILoadHost +from openpype.host import HostBase, ILoadHost, IPublishHost import unreal # noqa - logger = logging.getLogger("openpype.hosts.unreal") + OPENPYPE_CONTAINERS = "OpenPypeContainers" +CONTEXT_CONTAINER = "OpenPype/context.json" UNREAL_VERSION = semver.VersionInfo( *os.getenv("OPENPYPE_UNREAL_VERSION").split(".") ) @@ -35,7 +38,7 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") -class UnrealHost(HostBase, ILoadHost): +class UnrealHost(HostBase, ILoadHost, IPublishHost): """Unreal host implementation. For some time this class will re-use functions from module based @@ -60,6 +63,32 @@ class UnrealHost(HostBase, ILoadHost): show_tools_dialog() + def update_context_data(self, data, changes): + content_path = unreal.Paths.project_content_dir() + op_ctx = content_path + CONTEXT_CONTAINER + attempts = 3 + for i in range(attempts): + try: + with open(op_ctx, "w+") as f: + json.dump(data, f) + break + except IOError: + if i == attempts - 1: + raise Exception("Failed to write context data. Aborting.") + unreal.log_warning("Failed to write context data. Retrying...") + i += 1 + time.sleep(3) + continue + + def get_context_data(self): + content_path = unreal.Paths.project_content_dir() + op_ctx = content_path + CONTEXT_CONTAINER + if not os.path.isfile(op_ctx): + return {} + with open(op_ctx, "r") as fp: + data = json.load(fp) + return data + def install(): """Install Unreal configuration for OpenPype.""" @@ -133,6 +162,31 @@ def ls(): yield data +def ls_inst(): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + # UE 5.1 changed how class name is specified + class_name = [ + "/Script/OpenPype", + "OpenPypePublishInstance" + ] if ( + UNREAL_VERSION.major == 5 + and UNREAL_VERSION.minor > 0 + ) else "OpenPypePublishInstance" # noqa + instances = ar.get_assets_by_class(class_name, True) + + # get_asset_by_class returns AssetData. To get all metadata we need to + # load asset. get_tag_values() work only on metadata registered in + # Asset Registry Project settings (and there is no way to set it with + # python short of editing ini configuration file). + for asset_data in instances: + asset = asset_data.get_asset() + data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) + data["objectName"] = asset_data.asset_name + data = cast_map_to_str_dict(data) + + yield data + + def parse_container(container): """To get data from container, AssetContainer must be loaded. diff --git a/openpype/hosts/unreal/api/plugin.py b/openpype/hosts/unreal/api/plugin.py index 6fc00cb71c..d60050a696 100644 --- a/openpype/hosts/unreal/api/plugin.py +++ b/openpype/hosts/unreal/api/plugin.py @@ -1,7 +1,245 @@ # -*- coding: utf-8 -*- -from abc import ABC +import ast +import collections +import sys +import six +from abc import ( + ABC, + ABCMeta, +) -from openpype.pipeline import LoaderPlugin +import unreal + +from .pipeline import ( + create_publish_instance, + imprint, + ls_inst, + UNREAL_VERSION +) +from openpype.lib import ( + BoolDef, + UILabelDef +) +from openpype.pipeline import ( + Creator, + LoaderPlugin, + CreatorError, + CreatedInstance +) + + +@six.add_metaclass(ABCMeta) +class UnrealBaseCreator(Creator): + """Base class for Unreal creator plugins.""" + root = "/Game/OpenPype/PublishInstances" + suffix = "_INS" + + @staticmethod + def cache_subsets(shared_data): + """Cache instances for Creators to shared data. + + Create `unreal_cached_subsets` key when needed in shared data and + fill it with all collected instances from the scene under its + respective creator identifiers. + + If legacy instances are detected in the scene, create + `unreal_cached_legacy_subsets` there and fill it with + all legacy subsets under family as a key. + + Args: + Dict[str, Any]: Shared data. + + Return: + Dict[str, Any]: Shared data dictionary. + + """ + if shared_data.get("unreal_cached_subsets") is None: + unreal_cached_subsets = collections.defaultdict(list) + unreal_cached_legacy_subsets = collections.defaultdict(list) + for instance in ls_inst(): + creator_id = instance.get("creator_identifier") + if creator_id: + unreal_cached_subsets[creator_id].append(instance) + else: + family = instance.get("family") + unreal_cached_legacy_subsets[family].append(instance) + + shared_data["unreal_cached_subsets"] = unreal_cached_subsets + shared_data["unreal_cached_legacy_subsets"] = ( + unreal_cached_legacy_subsets + ) + return shared_data + + def create(self, subset_name, instance_data, pre_create_data): + try: + instance_name = f"{subset_name}{self.suffix}" + pub_instance = create_publish_instance(instance_name, self.root) + + instance_data["subset"] = subset_name + instance_data["instance_path"] = f"{self.root}/{instance_name}" + + instance = CreatedInstance( + self.family, + subset_name, + instance_data, + self) + self._add_instance_to_context(instance) + + pub_instance.set_editor_property('add_external_assets', True) + assets = pub_instance.get_editor_property('asset_data_external') + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + for member in pre_create_data.get("members", []): + obj = ar.get_asset_by_object_path(member).get_asset() + assets.add(obj) + + imprint(f"{self.root}/{instance_name}", instance.data_to_store()) + + return instance + + except Exception as er: + six.reraise( + CreatorError, + CreatorError(f"Creator error: {er}"), + sys.exc_info()[2]) + + def collect_instances(self): + # cache instances if missing + self.cache_subsets(self.collection_shared_data) + for instance in self.collection_shared_data[ + "unreal_cached_subsets"].get(self.identifier, []): + # Unreal saves metadata as string, so we need to convert it back + instance['creator_attributes'] = ast.literal_eval( + instance.get('creator_attributes', '{}')) + instance['publish_attributes'] = ast.literal_eval( + instance.get('publish_attributes', '{}')) + created_instance = CreatedInstance.from_existing(instance, self) + self._add_instance_to_context(created_instance) + + def update_instances(self, update_list): + for created_inst, changes in update_list: + instance_node = created_inst.get("instance_path", "") + + if not instance_node: + unreal.log_warning( + f"Instance node not found for {created_inst}") + continue + + new_values = { + key: changes[key].new_value + for key in changes.changed_keys + } + imprint( + instance_node, + new_values + ) + + def remove_instances(self, instances): + for instance in instances: + instance_node = instance.data.get("instance_path", "") + if instance_node: + unreal.EditorAssetLibrary.delete_asset(instance_node) + + self._remove_instance_from_context(instance) + + +@six.add_metaclass(ABCMeta) +class UnrealAssetCreator(UnrealBaseCreator): + """Base class for Unreal creator plugins based on assets.""" + + def create(self, subset_name, instance_data, pre_create_data): + """Create instance of the asset. + + Args: + subset_name (str): Name of the subset. + instance_data (dict): Data for the instance. + pre_create_data (dict): Data for the instance. + + Returns: + CreatedInstance: Created instance. + """ + try: + # Check if instance data has members, filled by the plugin. + # If not, use selection. + if not pre_create_data.get("members"): + pre_create_data["members"] = [] + + if pre_create_data.get("use_selection"): + utilib = unreal.EditorUtilityLibrary + sel_objects = utilib.get_selected_assets() + pre_create_data["members"] = [ + a.get_path_name() for a in sel_objects] + + super(UnrealAssetCreator, self).create( + subset_name, + instance_data, + pre_create_data) + + except Exception as er: + six.reraise( + CreatorError, + CreatorError(f"Creator error: {er}"), + sys.exc_info()[2]) + + def get_pre_create_attr_defs(self): + return [ + BoolDef("use_selection", label="Use selection", default=True) + ] + + +@six.add_metaclass(ABCMeta) +class UnrealActorCreator(UnrealBaseCreator): + """Base class for Unreal creator plugins based on actors.""" + + def create(self, subset_name, instance_data, pre_create_data): + """Create instance of the asset. + + Args: + subset_name (str): Name of the subset. + instance_data (dict): Data for the instance. + pre_create_data (dict): Data for the instance. + + Returns: + CreatedInstance: Created instance. + """ + try: + if UNREAL_VERSION.major == 5: + world = unreal.UnrealEditorSubsystem().get_editor_world() + else: + world = unreal.EditorLevelLibrary.get_editor_world() + + # Check if the level is saved + if world.get_path_name().startswith("/Temp/"): + raise CreatorError( + "Level must be saved before creating instances.") + + # Check if instance data has members, filled by the plugin. + # If not, use selection. + if not instance_data.get("members"): + actor_subsystem = unreal.EditorActorSubsystem() + sel_actors = actor_subsystem.get_selected_level_actors() + selection = [a.get_path_name() for a in sel_actors] + + instance_data["members"] = selection + + instance_data["level"] = world.get_path_name() + + super(UnrealActorCreator, self).create( + subset_name, + instance_data, + pre_create_data) + + except Exception as er: + six.reraise( + CreatorError, + CreatorError(f"Creator error: {er}"), + sys.exc_info()[2]) + + def get_pre_create_attr_defs(self): + return [ + UILabelDef("Select actors to create instance from them.") + ] class Loader(LoaderPlugin, ABC): diff --git a/openpype/hosts/unreal/api/tools_ui.py b/openpype/hosts/unreal/api/tools_ui.py index 708e167a65..8531472142 100644 --- a/openpype/hosts/unreal/api/tools_ui.py +++ b/openpype/hosts/unreal/api/tools_ui.py @@ -17,9 +17,8 @@ class ToolsBtnsWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(ToolsBtnsWidget, self).__init__(parent) - create_btn = QtWidgets.QPushButton("Create...", self) load_btn = QtWidgets.QPushButton("Load...", self) - publish_btn = QtWidgets.QPushButton("Publish...", self) + publish_btn = QtWidgets.QPushButton("Publisher...", self) manage_btn = QtWidgets.QPushButton("Manage...", self) render_btn = QtWidgets.QPushButton("Render...", self) experimental_tools_btn = QtWidgets.QPushButton( @@ -28,7 +27,6 @@ class ToolsBtnsWidget(QtWidgets.QWidget): layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(create_btn, 0) layout.addWidget(load_btn, 0) layout.addWidget(publish_btn, 0) layout.addWidget(manage_btn, 0) @@ -36,7 +34,6 @@ class ToolsBtnsWidget(QtWidgets.QWidget): layout.addWidget(experimental_tools_btn, 0) layout.addStretch(1) - create_btn.clicked.connect(self._on_create) load_btn.clicked.connect(self._on_load) publish_btn.clicked.connect(self._on_publish) manage_btn.clicked.connect(self._on_manage) @@ -50,7 +47,7 @@ class ToolsBtnsWidget(QtWidgets.QWidget): self.tool_required.emit("loader") def _on_publish(self): - self.tool_required.emit("publish") + self.tool_required.emit("publisher") def _on_manage(self): self.tool_required.emit("sceneinventory") diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 2dc6fb9f42..4c9f8258f5 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -79,9 +79,9 @@ class UnrealPrelaunchHook(PreLaunchHook): unreal_project_name = os.path.splitext(unreal_project_filename)[0] # Unreal is sensitive about project names longer then 20 chars if len(unreal_project_name) > 20: - self.log.warning(( - f"Project name exceed 20 characters ({unreal_project_name})!" - )) + raise ApplicationLaunchFailed( + f"Project name exceeds 20 characters ({unreal_project_name})!" + ) # Unreal doesn't accept non alphabet characters at the start # of the project name. This is because project name is then used @@ -119,29 +119,34 @@ class UnrealPrelaunchHook(PreLaunchHook): f"detected [ {engine_version} ]" )) - ue_path = unreal_lib.get_editor_executable_path( + ue_path = unreal_lib.get_editor_exe_path( Path(detected[engine_version]), engine_version) self.launch_context.launch_args = [ue_path.as_posix()] project_path.mkdir(parents=True, exist_ok=True) + # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for + # execution of `create_unreal_project` + + if self.launch_context.env.get("OPENPYPE_UNREAL_PLUGIN"): + self.log.info(( + f"{self.signature} using OpenPype plugin from " + f"{self.launch_context.env.get('OPENPYPE_UNREAL_PLUGIN')}" + )) + env_key = "OPENPYPE_UNREAL_PLUGIN" + if self.launch_context.env.get(env_key): + os.environ[env_key] = self.launch_context.env[env_key] + + engine_path = detected[engine_version] + + unreal_lib.try_installing_plugin(Path(engine_path), os.environ) + project_file = project_path / unreal_project_filename if not project_file.is_file(): - engine_path = detected[engine_version] self.log.info(( f"{self.signature} creating unreal " f"project [ {unreal_project_name} ]" )) - # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for - # execution of `create_unreal_project` - if self.launch_context.env.get("OPENPYPE_UNREAL_PLUGIN"): - self.log.info(( - f"{self.signature} using OpenPype plugin from " - f"{self.launch_context.env.get('OPENPYPE_UNREAL_PLUGIN')}" - )) - env_key = "OPENPYPE_UNREAL_PLUGIN" - if self.launch_context.env.get(env_key): - os.environ[env_key] = self.launch_context.env[env_key] unreal_lib.create_unreal_project( unreal_project_name, diff --git a/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/.gitignore b/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/.gitignore new file mode 100644 index 0000000000..e74e6886b7 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/.gitignore @@ -0,0 +1,8 @@ +/Saved +/DerivedDataCache +/Intermediate +/Content +/Config +/Binaries +/.idea +/.vs \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/CommandletProject.uproject b/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/CommandletProject.uproject new file mode 100644 index 0000000000..4d75e03bf3 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/CommandletProject/CommandletProject.uproject @@ -0,0 +1,12 @@ +{ + "FileVersion": 3, + "EngineAssociation": "4.27", + "Category": "", + "Description": "", + "Plugins": [ + { + "Name": "OpenPype", + "Enabled": true + } + ] +} \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_4.7/.gitignore b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/.gitignore similarity index 100% rename from openpype/hosts/unreal/integration/UE_4.7/.gitignore rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/.gitignore diff --git a/openpype/hosts/unreal/integration/UE_4.7/Config/DefaultOpenPypeSettings.ini b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/DefaultOpenPypeSettings.ini similarity index 100% rename from openpype/hosts/unreal/integration/UE_4.7/Config/DefaultOpenPypeSettings.ini rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/DefaultOpenPypeSettings.ini diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/FilterPlugin.ini b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/FilterPlugin.ini new file mode 100644 index 0000000000..ccebca2f32 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Config/FilterPlugin.ini @@ -0,0 +1,8 @@ +[FilterPlugin] +; This section lists additional files which will be packaged along with your plugin. Paths should be listed relative to the root plugin directory, and +; may include "...", "*", and "?" wildcards to match directories, files, and individual characters respectively. +; +; Examples: +; /README.txt +; /Extras/... +; /Binaries/ThirdParty/*.dll diff --git a/openpype/hosts/unreal/integration/UE_4.7/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Content/Python/init_unreal.py similarity index 100% rename from openpype/hosts/unreal/integration/UE_4.7/Content/Python/init_unreal.py rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Content/Python/init_unreal.py diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/OpenPype.uplugin similarity index 86% rename from openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/OpenPype.uplugin index 4c7a74403c..b2cbe3cff3 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/OpenPype.uplugin +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/OpenPype.uplugin @@ -10,10 +10,9 @@ "DocsURL": "https://openpype.io/docs/artist_hosts_unreal", "MarketplaceURL": "", "SupportURL": "https://pype.club/", + "EngineVersion": "4.27", "CanContainContent": true, - "IsBetaVersion": true, - "IsExperimentalVersion": false, - "Installed": false, + "Installed": true, "Modules": [ { "Name": "OpenPype", diff --git a/openpype/hosts/unreal/integration/UE_4.7/README.md b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/README.md similarity index 100% rename from openpype/hosts/unreal/integration/UE_4.7/README.md rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/README.md diff --git a/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype128.png b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype128.png similarity index 100% rename from openpype/hosts/unreal/integration/UE_4.7/Resources/openpype128.png rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype128.png diff --git a/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype40.png b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype40.png similarity index 100% rename from openpype/hosts/unreal/integration/UE_4.7/Resources/openpype40.png rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype40.png diff --git a/openpype/hosts/unreal/integration/UE_4.7/Resources/openpype512.png b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype512.png similarity index 100% rename from openpype/hosts/unreal/integration/UE_4.7/Resources/openpype512.png rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Resources/openpype512.png diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/OpenPype.Build.cs similarity index 88% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/OpenPype.Build.cs index 46e5dcb2df..f77c1383eb 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/OpenPype.Build.cs +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/OpenPype.Build.cs @@ -1,4 +1,4 @@ -// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. +// Copyright 2023, Ayon, All rights reserved. using UnrealBuildTool; @@ -6,8 +6,8 @@ public class OpenPype : ModuleRules { public OpenPype(ReadOnlyTargetRules Target) : base(Target) { - PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; - + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + PublicIncludePaths.AddRange( new string[] { // ... add public include paths required here ... @@ -34,6 +34,7 @@ public class OpenPype : ModuleRules PrivateDependencyModuleNames.AddRange( new string[] { + "GameProjectGeneration", "Projects", "InputCore", "UnrealEd", diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp new file mode 100644 index 0000000000..abb1975027 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp @@ -0,0 +1,141 @@ +// Copyright 2023, Ayon, All rights reserved. +#include "Commandlets/Implementations/OPGenerateProjectCommandlet.h" + +#include "Editor.h" +#include "GameProjectUtils.h" +#include "OPConstants.h" +#include "Commandlets/OPActionResult.h" +#include "ProjectDescriptor.h" + +int32 UOPGenerateProjectCommandlet::Main(const FString& CommandLineParams) +{ + //Parses command line parameters & creates structure FProjectInformation + const FOPGenerateProjectParams ParsedParams = FOPGenerateProjectParams(CommandLineParams); + ProjectInformation = ParsedParams.GenerateUEProjectInformation(); + + //Creates .uproject & other UE files + EVALUATE_OP_ACTION_RESULT(TryCreateProject()); + + //Loads created .uproject + EVALUATE_OP_ACTION_RESULT(TryLoadProjectDescriptor()); + + //Adds needed plugin to .uproject + AttachPluginsToProjectDescriptor(); + + //Saves .uproject + EVALUATE_OP_ACTION_RESULT(TrySave()); + + //When we are here, there should not be problems in generating Unreal Project for OpenPype + return 0; +} + + +FOPGenerateProjectParams::FOPGenerateProjectParams(): FOPGenerateProjectParams("") +{ +} + +FOPGenerateProjectParams::FOPGenerateProjectParams(const FString& CommandLineParams): CommandLineParams( + CommandLineParams) +{ + UCommandlet::ParseCommandLine(*CommandLineParams, Tokens, Switches); +} + +FProjectInformation FOPGenerateProjectParams::GenerateUEProjectInformation() const +{ + FProjectInformation ProjectInformation = FProjectInformation(); + ProjectInformation.ProjectFilename = GetProjectFileName(); + + ProjectInformation.bShouldGenerateCode = IsSwitchPresent("GenerateCode"); + + return ProjectInformation; +} + +FString FOPGenerateProjectParams::TryGetToken(const int32 Index) const +{ + return Tokens.IsValidIndex(Index) ? Tokens[Index] : ""; +} + +FString FOPGenerateProjectParams::GetProjectFileName() const +{ + return TryGetToken(0); +} + +bool FOPGenerateProjectParams::IsSwitchPresent(const FString& Switch) const +{ + return INDEX_NONE != Switches.IndexOfByPredicate([&Switch](const FString& Item) -> bool + { + return Item.Equals(Switch); + } + ); +} + + +UOPGenerateProjectCommandlet::UOPGenerateProjectCommandlet() +{ + LogToConsole = true; +} + +FOP_ActionResult UOPGenerateProjectCommandlet::TryCreateProject() const +{ + FText FailReason; + FText FailLog; + TArray OutCreatedFiles; + + if (!GameProjectUtils::CreateProject(ProjectInformation, FailReason, FailLog, &OutCreatedFiles)) + return FOP_ActionResult(EOP_ActionResult::ProjectNotCreated, FailReason); + return FOP_ActionResult(); +} + +FOP_ActionResult UOPGenerateProjectCommandlet::TryLoadProjectDescriptor() +{ + FText FailReason; + const bool bLoaded = ProjectDescriptor.Load(ProjectInformation.ProjectFilename, FailReason); + + return FOP_ActionResult(bLoaded ? EOP_ActionResult::Ok : EOP_ActionResult::ProjectNotLoaded, FailReason); +} + +void UOPGenerateProjectCommandlet::AttachPluginsToProjectDescriptor() +{ + FPluginReferenceDescriptor OPPluginDescriptor; + OPPluginDescriptor.bEnabled = true; + OPPluginDescriptor.Name = OPConstants::OP_PluginName; + ProjectDescriptor.Plugins.Add(OPPluginDescriptor); + + FPluginReferenceDescriptor PythonPluginDescriptor; + PythonPluginDescriptor.bEnabled = true; + PythonPluginDescriptor.Name = OPConstants::PythonScript_PluginName; + ProjectDescriptor.Plugins.Add(PythonPluginDescriptor); + + FPluginReferenceDescriptor SequencerScriptingPluginDescriptor; + SequencerScriptingPluginDescriptor.bEnabled = true; + SequencerScriptingPluginDescriptor.Name = OPConstants::SequencerScripting_PluginName; + ProjectDescriptor.Plugins.Add(SequencerScriptingPluginDescriptor); + + FPluginReferenceDescriptor MovieRenderPipelinePluginDescriptor; + MovieRenderPipelinePluginDescriptor.bEnabled = true; + MovieRenderPipelinePluginDescriptor.Name = OPConstants::MovieRenderPipeline_PluginName; + ProjectDescriptor.Plugins.Add(MovieRenderPipelinePluginDescriptor); + + FPluginReferenceDescriptor EditorScriptingPluginDescriptor; + EditorScriptingPluginDescriptor.bEnabled = true; + EditorScriptingPluginDescriptor.Name = OPConstants::EditorScriptingUtils_PluginName; + ProjectDescriptor.Plugins.Add(EditorScriptingPluginDescriptor); +} + +FOP_ActionResult UOPGenerateProjectCommandlet::TrySave() +{ + FText FailReason; + const bool bSaved = ProjectDescriptor.Save(ProjectInformation.ProjectFilename, FailReason); + + return FOP_ActionResult(bSaved ? EOP_ActionResult::Ok : EOP_ActionResult::ProjectNotSaved, FailReason); +} + +FOPGenerateProjectParams UOPGenerateProjectCommandlet::ParseParameters(const FString& Params) const +{ + FOPGenerateProjectParams ParamsResult; + + TArray Tokens, Switches; + ParseCommandLine(*Params, Tokens, Switches); + + return ParamsResult; +} diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp new file mode 100644 index 0000000000..6e50ef2221 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp @@ -0,0 +1,41 @@ +// Copyright 2023, Ayon, All rights reserved. + + +#include "Commandlets/OPActionResult.h" +#include "Logging/OP_Log.h" + +EOP_ActionResult::Type& FOP_ActionResult::GetStatus() +{ + return Status; +} + +FText& FOP_ActionResult::GetReason() +{ + return Reason; +} + +FOP_ActionResult::FOP_ActionResult():Status(EOP_ActionResult::Type::Ok) +{ + +} + +FOP_ActionResult::FOP_ActionResult(const EOP_ActionResult::Type& InEnum):Status(InEnum) +{ + TryLog(); +} + +FOP_ActionResult::FOP_ActionResult(const EOP_ActionResult::Type& InEnum, const FText& InReason):Status(InEnum), Reason(InReason) +{ + TryLog(); +}; + +bool FOP_ActionResult::IsProblem() const +{ + return Status != EOP_ActionResult::Ok; +} + +void FOP_ActionResult::TryLog() const +{ + if(IsProblem()) + UE_LOG(LogCommandletOPGenerateProject, Error, TEXT("%s"), *Reason.ToString()); +} diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp new file mode 100644 index 0000000000..29b1068c21 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp @@ -0,0 +1 @@ +#include "Logging/OP_Log.h" diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPype.cpp similarity index 78% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPype.cpp index d06a08eb43..9bf7b341c5 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPype.cpp +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPype.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPype.h" #include "ISettingsContainer.h" @@ -16,32 +17,34 @@ static const FName OpenPypeTabName("OpenPype"); // This function is triggered when the plugin is staring up void FOpenPypeModule::StartupModule() { - FOpenPypeStyle::Initialize(); - FOpenPypeStyle::SetIcon("Logo", "openpype40"); + if (!IsRunningCommandlet()) { + FOpenPypeStyle::Initialize(); + FOpenPypeStyle::SetIcon("Logo", "openpype40"); - // Create the Extender that will add content to the menu - FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); + // Create the Extender that will add content to the menu + FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); - TSharedPtr MenuExtender = MakeShareable(new FExtender()); - TSharedPtr ToolbarExtender = MakeShareable(new FExtender()); + TSharedPtr MenuExtender = MakeShareable(new FExtender()); + TSharedPtr ToolbarExtender = MakeShareable(new FExtender()); - MenuExtender->AddMenuExtension( - "LevelEditor", - EExtensionHook::After, - NULL, - FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry) - ); - ToolbarExtender->AddToolBarExtension( - "Settings", - EExtensionHook::After, - NULL, - FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry)); + MenuExtender->AddMenuExtension( + "LevelEditor", + EExtensionHook::After, + NULL, + FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry) + ); + ToolbarExtender->AddToolBarExtension( + "Settings", + EExtensionHook::After, + NULL, + FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry)); - LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); - LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender); + LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); + LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender); - RegisterSettings(); + RegisterSettings(); + } } void FOpenPypeModule::ShutdownModule() diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp similarity index 96% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp index a58e921288..008025e816 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeLib.cpp +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPypeLib.h" #include "AssetViewUtils.h" diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp similarity index 97% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstance.cpp rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp index 38740f1cbd..05638fbd0b 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstance.cpp +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp @@ -1,11 +1,12 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "OpenPypePublishInstance.h" #include "AssetRegistryModule.h" -#include "NotificationManager.h" #include "OpenPypeLib.h" #include "OpenPypeSettings.h" -#include "SNotificationList.h" +#include "Framework/Notifications/NotificationManager.h" +#include "Widgets/Notifications/SNotificationList.h" //Moves all the invalid pointers to the end to prepare them for the shrinking #define REMOVE_INVALID_ENTRIES(VAR) VAR.CompactStable(); \ diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp similarity index 94% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp index 9b26da7fa4..a32ebe32cb 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPypePublishInstanceFactory.h" #include "OpenPypePublishInstance.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp similarity index 90% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp index 8113231503..6ebfc528f0 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePythonBridge.cpp +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPypePythonBridge.h" UOpenPypePythonBridge* UOpenPypePythonBridge::Get() diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeSettings.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp similarity index 79% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeSettings.cpp rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp index 7134614d22..dd4228dfd0 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeSettings.cpp +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp @@ -1,9 +1,8 @@ -๏ปฟ// Fill out your copyright notice in the Description page of Project Settings. +๏ปฟ// Copyright 2023, Ayon, All rights reserved. #include "OpenPypeSettings.h" -#include "IPluginManager.h" -#include "UObjectGlobals.h" +#include "Interfaces/IPluginManager.h" /** * Mainly is used for initializing default values if the DefaultOpenPypeSettings.ini file does not exist in the saved config diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp similarity index 92% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp index a51c2d6aa5..0cc854c5ef 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeStyle.cpp +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPypeStyle.h" #include "Framework/Application/SlateApplication.h" #include "Styling/SlateStyle.h" @@ -43,7 +44,7 @@ const FVector2D Icon40x40(40.0f, 40.0f); TUniquePtr< FSlateStyleSet > FOpenPypeStyle::Create() { TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); - Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("OpenPype/Resources")); + Style->SetContentRoot(FPaths::EnginePluginsDir() / TEXT("Marketplace/OpenPype/Resources")); return Style; } @@ -66,5 +67,4 @@ const ISlateStyle& FOpenPypeStyle::Get() { check(OpenPypeStyleInstance); return *OpenPypeStyleInstance; - return *OpenPypeStyleInstance; } diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h new file mode 100644 index 0000000000..d1129aa070 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h @@ -0,0 +1,60 @@ +// Copyright 2023, Ayon, All rights reserved. +#pragma once + +#include "GameProjectUtils.h" +#include "Commandlets/OPActionResult.h" +#include "ProjectDescriptor.h" +#include "Commandlets/Commandlet.h" +#include "OPGenerateProjectCommandlet.generated.h" + +struct FProjectDescriptor; +struct FProjectInformation; + +/** +* @brief Structure which parses command line parameters and generates FProjectInformation +*/ +USTRUCT() +struct FOPGenerateProjectParams +{ + GENERATED_BODY() + +private: + FString CommandLineParams; + TArray Tokens; + TArray Switches; + +public: + FOPGenerateProjectParams(); + FOPGenerateProjectParams(const FString& CommandLineParams); + + FProjectInformation GenerateUEProjectInformation() const; + +private: + FString TryGetToken(const int32 Index) const; + FString GetProjectFileName() const; + + bool IsSwitchPresent(const FString& Switch) const; +}; + +UCLASS() +class OPENPYPE_API UOPGenerateProjectCommandlet : public UCommandlet +{ + GENERATED_BODY() + +private: + FProjectInformation ProjectInformation; + FProjectDescriptor ProjectDescriptor; + +public: + UOPGenerateProjectCommandlet(); + + virtual int32 Main(const FString& CommandLineParams) override; + +private: + FOPGenerateProjectParams ParseParameters(const FString& Params) const; + FOP_ActionResult TryCreateProject() const; + FOP_ActionResult TryLoadProjectDescriptor(); + void AttachPluginsToProjectDescriptor(); + FOP_ActionResult TrySave(); +}; + 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 new file mode 100644 index 0000000000..c960bbf190 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h @@ -0,0 +1,83 @@ +// Copyright 2023, Ayon, All rights reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "OPActionResult.generated.h" + +/** + * @brief This macro returns error code when is problem or does nothing when there is no problem. + * @param ActionResult FOP_ActionResult structure + */ +#define EVALUATE_OP_ACTION_RESULT(ActionResult) \ + if(ActionResult.IsProblem()) \ + return ActionResult.GetStatus(); + +/** +* @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... +*/ +UENUM() +namespace EOP_ActionResult +{ + enum Type + { + Ok, + ProjectNotCreated, + ProjectNotLoaded, + ProjectNotSaved, + //....Here insert another values + + //Do not remove! + //Usable for looping through enum values + __Last UMETA(Hidden) + }; +} + + +/** + * @brief This struct holds action result enum and optionally reason of fail + */ +USTRUCT() +struct FOP_ActionResult +{ + GENERATED_BODY() + +public: + /** @brief Default constructor usable when there is no problem */ + FOP_ActionResult(); + + /** + * @brief This constructor initializes variables & attempts to log when is error + * @param InEnum Status + */ + FOP_ActionResult(const EOP_ActionResult::Type& InEnum); + + /** + * @brief This constructor initializes variables & attempts to log when is error + * @param InEnum Status + * @param InReason Reason of potential fail + */ + FOP_ActionResult(const EOP_ActionResult::Type& InEnum, const FText& InReason); + +private: + /** @brief Action status */ + EOP_ActionResult::Type Status; + + /** @brief Optional reason of fail */ + FText Reason; + +public: + /** + * @brief Checks if there is problematic state + * @return true when status is not equal to EOP_ActionResult::Ok + */ + bool IsProblem() const; + EOP_ActionResult::Type& GetStatus(); + FText& GetReason(); + +private: + void TryLog() const; +}; + diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h new file mode 100644 index 0000000000..3740c5285a --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h @@ -0,0 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. +#pragma once + +DEFINE_LOG_CATEGORY_STATIC(LogCommandletOPGenerateProject, Log, All); \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OPConstants.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OPConstants.h new file mode 100644 index 0000000000..f4587f7a50 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OPConstants.h @@ -0,0 +1,13 @@ +// Copyright 2023, Ayon, All rights reserved. +#pragma once + +namespace OPConstants +{ + const FString OP_PluginName = "OpenPype"; + const FString PythonScript_PluginName = "PythonScriptPlugin"; + const FString SequencerScripting_PluginName = "SequencerScripting"; + const FString MovieRenderPipeline_PluginName = "MovieRenderPipeline"; + const FString EditorScriptingUtils_PluginName = "EditorScriptingUtilities"; +} + + diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPype.h similarity index 86% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPype.h index 9cfa60176c..2454344128 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPype.h +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPype.h @@ -1,4 +1,4 @@ -// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. +// Copyright 2023, Ayon, All rights reserved. #pragma once diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeLib.h similarity index 90% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeLib.h index 06425c7c7d..ef4d1027ea 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeLib.h +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeLib.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "Engine.h" diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h similarity index 92% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstance.h rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h index cd414fe2cc..8cfcd067c0 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstance.h +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "Engine.h" @@ -16,7 +17,7 @@ public: * * @return - Set of UObjects. Careful! They are returning raw pointers. Seems like an issue in UE5 */ - UFUNCTION(BlueprintCallable, BlueprintPure) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python") TSet GetInternalAssets() const { //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed. @@ -33,7 +34,7 @@ public: * * @return - TSet of assets (UObjects). Careful! They are returning raw pointers. Seems like an issue in UE5 */ - UFUNCTION(BlueprintCallable, BlueprintPure) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python") TSet GetExternalAssets() const { //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed. @@ -53,7 +54,7 @@ public: * * @attention If the bAddExternalAssets variable is false, external assets won't be included! */ - UFUNCTION(BlueprintCallable, BlueprintPure) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python") TSet GetAllAssets() const { const TSet>& IteratedSet = bAddExternalAssets diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h similarity index 92% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h index 7d2c77fe6e..3fdb984411 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "CoreMinimal.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h similarity index 90% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h index 692aab2e5e..827f76f56b 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePythonBridge.h +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "Engine.h" #include "OpenPypePythonBridge.generated.h" diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeSettings.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h similarity index 88% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeSettings.h rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h index 2df6c887cf..88defaa773 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeSettings.h +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h @@ -1,9 +1,8 @@ -๏ปฟ// Fill out your copyright notice in the Description page of Project Settings. +๏ปฟ// Copyright 2023, Ayon, All rights reserved. #pragma once #include "CoreMinimal.h" -#include "Object.h" #include "OpenPypeSettings.generated.h" #define OPENPYPE_SETTINGS_FILEPATH IPluginManager::Get().FindPlugin("OpenPype")->GetBaseDir() / TEXT("Config") / TEXT("DefaultOpenPypeSettings.ini") diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h similarity index 90% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h rename to openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h index fbc8bcdd5b..0e4af129d0 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeStyle.h +++ b/openpype/hosts/unreal/integration/UE_4.7/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "CoreMinimal.h" diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainer.cpp deleted file mode 100644 index c766f87a8e..0000000000 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainer.cpp +++ /dev/null @@ -1,115 +0,0 @@ -// Fill out your copyright notice in the Description page of Project Settings. - -#include "AssetContainer.h" -#include "AssetRegistryModule.h" -#include "Misc/PackageName.h" -#include "Engine.h" -#include "Containers/UnrealString.h" - -UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer) -: UAssetUserData(ObjectInitializer) -{ - FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); - FString path = UAssetContainer::GetPathName(); - UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path); - FARFilter Filter; - Filter.PackagePaths.Add(FName(*path)); - - AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded); - AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved); - AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed); -} - -void UAssetContainer::OnAssetAdded(const FAssetData& AssetData) -{ - TArray split; - - // get directory of current container - FString selfFullPath = UAssetContainer::GetPathName(); - FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); - - // get asset path and class - FString assetPath = AssetData.GetFullName(); - FString assetFName = AssetData.AssetClass.ToString(); - - // split path - assetPath.ParseIntoArray(split, TEXT(" "), true); - - FString assetDir = FPackageName::GetLongPackagePath(*split[1]); - - // take interest only in paths starting with path of current container - if (assetDir.StartsWith(*selfDir)) - { - // exclude self - if (assetFName != "AssetContainer") - { - assets.Add(assetPath); - assetsData.Add(AssetData); - UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); - } - } -} - -void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData) -{ - TArray split; - - // get directory of current container - FString selfFullPath = UAssetContainer::GetPathName(); - FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); - - // get asset path and class - FString assetPath = AssetData.GetFullName(); - FString assetFName = AssetData.AssetClass.ToString(); - - // split path - assetPath.ParseIntoArray(split, TEXT(" "), true); - - FString assetDir = FPackageName::GetLongPackagePath(*split[1]); - - // take interest only in paths starting with path of current container - FString path = UAssetContainer::GetPathName(); - FString lpp = FPackageName::GetLongPackagePath(*path); - - if (assetDir.StartsWith(*selfDir)) - { - // exclude self - if (assetFName != "AssetContainer") - { - // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); - assets.Remove(assetPath); - assetsData.Remove(AssetData); - } - } -} - -void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str) -{ - TArray split; - - // get directory of current container - FString selfFullPath = UAssetContainer::GetPathName(); - FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); - - // get asset path and class - FString assetPath = AssetData.GetFullName(); - FString assetFName = AssetData.AssetClass.ToString(); - - // split path - assetPath.ParseIntoArray(split, TEXT(" "), true); - - FString assetDir = FPackageName::GetLongPackagePath(*split[1]); - if (assetDir.StartsWith(*selfDir)) - { - // exclude self - if (assetFName != "AssetContainer") - { - - assets.Remove(str); - assets.Add(assetPath); - assetsData.Remove(AssetData); - // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); - } - } -} - diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainer.h deleted file mode 100644 index 3c2a360c78..0000000000 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainer.h +++ /dev/null @@ -1,39 +0,0 @@ -// Fill out your copyright notice in the Description page of Project Settings. - -#pragma once - -#include "CoreMinimal.h" -#include "UObject/NoExportTypes.h" -#include "Engine/AssetUserData.h" -#include "AssetData.h" -#include "AssetContainer.generated.h" - -/** - * - */ -UCLASS(Blueprintable) -class OPENPYPE_API UAssetContainer : public UAssetUserData -{ - GENERATED_BODY() - -public: - - UAssetContainer(const FObjectInitializer& ObjectInitalizer); - // ~UAssetContainer(); - - UPROPERTY(EditAnywhere, BlueprintReadOnly) - TArray assets; - - // There seems to be no reflection option to expose array of FAssetData - /* - UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data")) - TArray assetsData; - */ -private: - TArray assetsData; - void OnAssetAdded(const FAssetData& AssetData); - void OnAssetRemoved(const FAssetData& AssetData); - void OnAssetRenamed(const FAssetData& AssetData, const FString& str); -}; - - diff --git a/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/.gitignore b/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/.gitignore new file mode 100644 index 0000000000..80814ef0a6 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/.gitignore @@ -0,0 +1,41 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +/Saved +/DerivedDataCache +/Intermediate +/Binaries +/Content +/Config +/.idea +/.vs \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/CommandletProject.uproject b/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/CommandletProject.uproject new file mode 100644 index 0000000000..c8dc1c673e --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/CommandletProject/CommandletProject.uproject @@ -0,0 +1,20 @@ +{ + "FileVersion": 3, + "EngineAssociation": "5.0", + "Category": "", + "Description": "", + "Plugins": [ + { + "Name": "ModelingToolsEditorMode", + "Enabled": true, + "TargetAllowList": [ + "Editor" + ] + }, + { + "Name": "OpenPype", + "Enabled": true, + "Type": "Editor" + } + ] +} \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/.gitignore b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/.gitignore similarity index 100% rename from openpype/hosts/unreal/integration/UE_5.0/.gitignore rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/.gitignore diff --git a/openpype/hosts/unreal/integration/UE_5.0/Config/DefaultOpenPypeSettings.ini b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/DefaultOpenPypeSettings.ini similarity index 100% rename from openpype/hosts/unreal/integration/UE_5.0/Config/DefaultOpenPypeSettings.ini rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/DefaultOpenPypeSettings.ini diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/FilterPlugin.ini b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/FilterPlugin.ini new file mode 100644 index 0000000000..ccebca2f32 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Config/FilterPlugin.ini @@ -0,0 +1,8 @@ +[FilterPlugin] +; This section lists additional files which will be packaged along with your plugin. Paths should be listed relative to the root plugin directory, and +; may include "...", "*", and "?" wildcards to match directories, files, and individual characters respectively. +; +; Examples: +; /README.txt +; /Extras/... +; /Binaries/ThirdParty/*.dll diff --git a/openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Content/Python/init_unreal.py similarity index 100% rename from openpype/hosts/unreal/integration/UE_5.0/Content/Python/init_unreal.py rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Content/Python/init_unreal.py diff --git a/openpype/hosts/unreal/integration/UE_4.7/OpenPype.uplugin b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/OpenPype.uplugin similarity index 91% rename from openpype/hosts/unreal/integration/UE_4.7/OpenPype.uplugin rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/OpenPype.uplugin index 4c7a74403c..ff08edc13e 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/OpenPype.uplugin +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/OpenPype.uplugin @@ -11,9 +11,9 @@ "MarketplaceURL": "", "SupportURL": "https://pype.club/", "CanContainContent": true, - "IsBetaVersion": true, + "EngineVersion": "5.0", "IsExperimentalVersion": false, - "Installed": false, + "Installed": true, "Modules": [ { "Name": "OpenPype", diff --git a/openpype/hosts/unreal/integration/UE_5.0/README.md b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/README.md similarity index 100% rename from openpype/hosts/unreal/integration/UE_5.0/README.md rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/README.md diff --git a/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype128.png b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype128.png similarity index 100% rename from openpype/hosts/unreal/integration/UE_5.0/Resources/openpype128.png rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype128.png diff --git a/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype40.png b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype40.png similarity index 100% rename from openpype/hosts/unreal/integration/UE_5.0/Resources/openpype40.png rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype40.png diff --git a/openpype/hosts/unreal/integration/UE_5.0/Resources/openpype512.png b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype512.png similarity index 100% rename from openpype/hosts/unreal/integration/UE_5.0/Resources/openpype512.png rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Resources/openpype512.png diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/OpenPype.Build.cs similarity index 88% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/OpenPype.Build.cs rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/OpenPype.Build.cs index d853ec028f..e1087fd720 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/OpenPype.Build.cs +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/OpenPype.Build.cs @@ -1,4 +1,4 @@ -// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. +// Copyright 2023, Ayon, All rights reserved. using UnrealBuildTool; @@ -10,7 +10,7 @@ public class OpenPype : ModuleRules bLegacyPublicIncludePaths = false; ShadowVariableWarningLevel = WarningLevel.Error; PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; - IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_0; + //IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_0; PublicIncludePaths.AddRange( new string[] { @@ -30,14 +30,15 @@ public class OpenPype : ModuleRules new string[] { "Core", + "CoreUObject" // ... add other public dependencies that you statically link with here ... } ); - PrivateDependencyModuleNames.AddRange( new string[] { + "GameProjectGeneration", "Projects", "InputCore", "EditorFramework", diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainer.cpp similarity index 99% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainer.cpp index 61e563f729..0bea9e3d78 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainer.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainer.cpp @@ -14,7 +14,7 @@ UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer) UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path); FARFilter Filter; Filter.PackagePaths.Add(FName(*path)); - + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded); AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved); AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed); @@ -36,7 +36,7 @@ void UAssetContainer::OnAssetAdded(const FAssetData& AssetData) assetPath.ParseIntoArray(split, TEXT(" "), true); FString assetDir = FPackageName::GetLongPackagePath(*split[1]); - + // take interest only in paths starting with path of current container if (assetDir.StartsWith(*selfDir)) { @@ -112,4 +112,3 @@ void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& } } } - diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainerFactory.cpp similarity index 100% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/AssetContainerFactory.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/AssetContainerFactory.cpp diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp new file mode 100644 index 0000000000..abb1975027 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/Implementations/OPGenerateProjectCommandlet.cpp @@ -0,0 +1,141 @@ +// Copyright 2023, Ayon, All rights reserved. +#include "Commandlets/Implementations/OPGenerateProjectCommandlet.h" + +#include "Editor.h" +#include "GameProjectUtils.h" +#include "OPConstants.h" +#include "Commandlets/OPActionResult.h" +#include "ProjectDescriptor.h" + +int32 UOPGenerateProjectCommandlet::Main(const FString& CommandLineParams) +{ + //Parses command line parameters & creates structure FProjectInformation + const FOPGenerateProjectParams ParsedParams = FOPGenerateProjectParams(CommandLineParams); + ProjectInformation = ParsedParams.GenerateUEProjectInformation(); + + //Creates .uproject & other UE files + EVALUATE_OP_ACTION_RESULT(TryCreateProject()); + + //Loads created .uproject + EVALUATE_OP_ACTION_RESULT(TryLoadProjectDescriptor()); + + //Adds needed plugin to .uproject + AttachPluginsToProjectDescriptor(); + + //Saves .uproject + EVALUATE_OP_ACTION_RESULT(TrySave()); + + //When we are here, there should not be problems in generating Unreal Project for OpenPype + return 0; +} + + +FOPGenerateProjectParams::FOPGenerateProjectParams(): FOPGenerateProjectParams("") +{ +} + +FOPGenerateProjectParams::FOPGenerateProjectParams(const FString& CommandLineParams): CommandLineParams( + CommandLineParams) +{ + UCommandlet::ParseCommandLine(*CommandLineParams, Tokens, Switches); +} + +FProjectInformation FOPGenerateProjectParams::GenerateUEProjectInformation() const +{ + FProjectInformation ProjectInformation = FProjectInformation(); + ProjectInformation.ProjectFilename = GetProjectFileName(); + + ProjectInformation.bShouldGenerateCode = IsSwitchPresent("GenerateCode"); + + return ProjectInformation; +} + +FString FOPGenerateProjectParams::TryGetToken(const int32 Index) const +{ + return Tokens.IsValidIndex(Index) ? Tokens[Index] : ""; +} + +FString FOPGenerateProjectParams::GetProjectFileName() const +{ + return TryGetToken(0); +} + +bool FOPGenerateProjectParams::IsSwitchPresent(const FString& Switch) const +{ + return INDEX_NONE != Switches.IndexOfByPredicate([&Switch](const FString& Item) -> bool + { + return Item.Equals(Switch); + } + ); +} + + +UOPGenerateProjectCommandlet::UOPGenerateProjectCommandlet() +{ + LogToConsole = true; +} + +FOP_ActionResult UOPGenerateProjectCommandlet::TryCreateProject() const +{ + FText FailReason; + FText FailLog; + TArray OutCreatedFiles; + + if (!GameProjectUtils::CreateProject(ProjectInformation, FailReason, FailLog, &OutCreatedFiles)) + return FOP_ActionResult(EOP_ActionResult::ProjectNotCreated, FailReason); + return FOP_ActionResult(); +} + +FOP_ActionResult UOPGenerateProjectCommandlet::TryLoadProjectDescriptor() +{ + FText FailReason; + const bool bLoaded = ProjectDescriptor.Load(ProjectInformation.ProjectFilename, FailReason); + + return FOP_ActionResult(bLoaded ? EOP_ActionResult::Ok : EOP_ActionResult::ProjectNotLoaded, FailReason); +} + +void UOPGenerateProjectCommandlet::AttachPluginsToProjectDescriptor() +{ + FPluginReferenceDescriptor OPPluginDescriptor; + OPPluginDescriptor.bEnabled = true; + OPPluginDescriptor.Name = OPConstants::OP_PluginName; + ProjectDescriptor.Plugins.Add(OPPluginDescriptor); + + FPluginReferenceDescriptor PythonPluginDescriptor; + PythonPluginDescriptor.bEnabled = true; + PythonPluginDescriptor.Name = OPConstants::PythonScript_PluginName; + ProjectDescriptor.Plugins.Add(PythonPluginDescriptor); + + FPluginReferenceDescriptor SequencerScriptingPluginDescriptor; + SequencerScriptingPluginDescriptor.bEnabled = true; + SequencerScriptingPluginDescriptor.Name = OPConstants::SequencerScripting_PluginName; + ProjectDescriptor.Plugins.Add(SequencerScriptingPluginDescriptor); + + FPluginReferenceDescriptor MovieRenderPipelinePluginDescriptor; + MovieRenderPipelinePluginDescriptor.bEnabled = true; + MovieRenderPipelinePluginDescriptor.Name = OPConstants::MovieRenderPipeline_PluginName; + ProjectDescriptor.Plugins.Add(MovieRenderPipelinePluginDescriptor); + + FPluginReferenceDescriptor EditorScriptingPluginDescriptor; + EditorScriptingPluginDescriptor.bEnabled = true; + EditorScriptingPluginDescriptor.Name = OPConstants::EditorScriptingUtils_PluginName; + ProjectDescriptor.Plugins.Add(EditorScriptingPluginDescriptor); +} + +FOP_ActionResult UOPGenerateProjectCommandlet::TrySave() +{ + FText FailReason; + const bool bSaved = ProjectDescriptor.Save(ProjectInformation.ProjectFilename, FailReason); + + return FOP_ActionResult(bSaved ? EOP_ActionResult::Ok : EOP_ActionResult::ProjectNotSaved, FailReason); +} + +FOPGenerateProjectParams UOPGenerateProjectCommandlet::ParseParameters(const FString& Params) const +{ + FOPGenerateProjectParams ParamsResult; + + TArray Tokens, Switches; + ParseCommandLine(*Params, Tokens, Switches); + + return ParamsResult; +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp new file mode 100644 index 0000000000..23ae2dd329 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Commandlets/OPActionResult.cpp @@ -0,0 +1,40 @@ +// Copyright 2023, Ayon, All rights reserved. + +#include "Commandlets/OPActionResult.h" +#include "Logging/OP_Log.h" + +EOP_ActionResult::Type& FOP_ActionResult::GetStatus() +{ + return Status; +} + +FText& FOP_ActionResult::GetReason() +{ + return Reason; +} + +FOP_ActionResult::FOP_ActionResult():Status(EOP_ActionResult::Type::Ok) +{ + +} + +FOP_ActionResult::FOP_ActionResult(const EOP_ActionResult::Type& InEnum):Status(InEnum) +{ + TryLog(); +} + +FOP_ActionResult::FOP_ActionResult(const EOP_ActionResult::Type& InEnum, const FText& InReason):Status(InEnum), Reason(InReason) +{ + TryLog(); +}; + +bool FOP_ActionResult::IsProblem() const +{ + return Status != EOP_ActionResult::Ok; +} + +void FOP_ActionResult::TryLog() const +{ + if(IsProblem()) + UE_LOG(LogCommandletOPGenerateProject, Error, TEXT("%s"), *Reason.ToString()); +} diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp new file mode 100644 index 0000000000..198fb9df0c --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/Logging/OP_Log.cpp @@ -0,0 +1,3 @@ +// Copyright 2023, Ayon, All rights reserved. + +#include "Logging/OP_Log.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPype.cpp similarity index 98% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPype.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPype.cpp index d23de61102..65da29da35 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPype.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPype.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPype.h" #include "ISettingsContainer.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeCommands.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeCommands.cpp similarity index 88% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeCommands.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeCommands.cpp index 6187bd7c7e..881814e278 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeCommands.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeCommands.cpp @@ -1,4 +1,4 @@ -// Copyright Epic Games, Inc. All Rights Reserved. +// Copyright 2023, Ayon, All rights reserved. #include "OpenPypeCommands.h" diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeLib.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp similarity index 96% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeLib.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp index a58e921288..008025e816 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypeLib.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeLib.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPypeLib.h" #include "AssetViewUtils.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp similarity index 98% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp index 0b56111a49..05d5c8a87d 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstance.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstance.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "OpenPypePublishInstance.h" @@ -55,7 +56,7 @@ void UOpenPypePublishInstance::OnAssetCreated(const FAssetData& InAssetData) if (!IsValid(Asset)) { UE_LOG(LogAssetData, Warning, TEXT("Asset \"%s\" is not valid! Skipping the addition."), - *InAssetData.GetObjectPathString()); + *InAssetData.ObjectPath.ToString()); return; } diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp similarity index 94% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp index 9b26da7fa4..a32ebe32cb 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPypePublishInstanceFactory.h" #include "OpenPypePublishInstance.h" diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp similarity index 90% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePythonBridge.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp index 8113231503..6ebfc528f0 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Private/OpenPypePythonBridge.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #include "OpenPypePythonBridge.h" UOpenPypePythonBridge* UOpenPypePythonBridge::Get() diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeSettings.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp similarity index 88% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeSettings.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp index a6b9eba749..6562a81138 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeSettings.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeSettings.cpp @@ -1,4 +1,4 @@ -๏ปฟ// Fill out your copyright notice in the Description page of Project Settings. +๏ปฟ// Copyright 2023, Ayon, All rights reserved. #include "OpenPypeSettings.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp similarity index 97% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeStyle.cpp rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp index 49e805da4d..a4d75e048e 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/OpenPypeStyle.cpp +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Private/OpenPypeStyle.cpp @@ -1,3 +1,5 @@ +// Copyright 2023, Ayon, All rights reserved. + #include "OpenPypeStyle.h" #include "OpenPype.h" #include "Framework/Application/SlateApplication.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainer.h similarity index 93% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainer.h index 2c06e59d6f..9157569c08 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainer.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainer.h @@ -9,19 +9,19 @@ #include "AssetContainer.generated.h" /** - * + * */ UCLASS(Blueprintable) class OPENPYPE_API UAssetContainer : public UAssetUserData { GENERATED_BODY() - + public: UAssetContainer(const FObjectInitializer& ObjectInitalizer); // ~UAssetContainer(); - UPROPERTY(EditAnywhere, BlueprintReadOnly) + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Assets") TArray assets; // There seems to be no reflection option to expose array of FAssetData @@ -35,5 +35,3 @@ private: void OnAssetRemoved(const FAssetData& AssetData); void OnAssetRenamed(const FAssetData& AssetData, const FString& str); }; - - diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainerFactory.h similarity index 98% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainerFactory.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainerFactory.h index 331ce6bb50..9095f8a3d7 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/AssetContainerFactory.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/AssetContainerFactory.h @@ -7,7 +7,7 @@ #include "AssetContainerFactory.generated.h" /** - * + * */ UCLASS() class OPENPYPE_API UAssetContainerFactory : public UFactory @@ -18,4 +18,4 @@ public: UAssetContainerFactory(const FObjectInitializer& ObjectInitializer); virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; virtual bool ShouldShowInNewMenu() const override; -}; \ No newline at end of file +}; diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h new file mode 100644 index 0000000000..6a6c6406e7 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/Implementations/OPGenerateProjectCommandlet.h @@ -0,0 +1,61 @@ +// Copyright 2023, Ayon, All rights reserved. +#pragma once + + +#include "GameProjectUtils.h" +#include "Commandlets/OPActionResult.h" +#include "ProjectDescriptor.h" +#include "Commandlets/Commandlet.h" +#include "OPGenerateProjectCommandlet.generated.h" + +struct FProjectDescriptor; +struct FProjectInformation; + +/** +* @brief Structure which parses command line parameters and generates FProjectInformation +*/ +USTRUCT() +struct FOPGenerateProjectParams +{ + GENERATED_BODY() + +private: + FString CommandLineParams; + TArray Tokens; + TArray Switches; + +public: + FOPGenerateProjectParams(); + FOPGenerateProjectParams(const FString& CommandLineParams); + + FProjectInformation GenerateUEProjectInformation() const; + +private: + FString TryGetToken(const int32 Index) const; + FString GetProjectFileName() const; + + bool IsSwitchPresent(const FString& Switch) const; +}; + +UCLASS() +class OPENPYPE_API UOPGenerateProjectCommandlet : public UCommandlet +{ + GENERATED_BODY() + +private: + FProjectInformation ProjectInformation; + FProjectDescriptor ProjectDescriptor; + +public: + UOPGenerateProjectCommandlet(); + + virtual int32 Main(const FString& CommandLineParams) override; + +private: + FOPGenerateProjectParams ParseParameters(const FString& Params) const; + FOP_ActionResult TryCreateProject() const; + FOP_ActionResult TryLoadProjectDescriptor(); + void AttachPluginsToProjectDescriptor(); + FOP_ActionResult TrySave(); +}; + 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 new file mode 100644 index 0000000000..c960bbf190 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Commandlets/OPActionResult.h @@ -0,0 +1,83 @@ +// Copyright 2023, Ayon, All rights reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "OPActionResult.generated.h" + +/** + * @brief This macro returns error code when is problem or does nothing when there is no problem. + * @param ActionResult FOP_ActionResult structure + */ +#define EVALUATE_OP_ACTION_RESULT(ActionResult) \ + if(ActionResult.IsProblem()) \ + return ActionResult.GetStatus(); + +/** +* @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... +*/ +UENUM() +namespace EOP_ActionResult +{ + enum Type + { + Ok, + ProjectNotCreated, + ProjectNotLoaded, + ProjectNotSaved, + //....Here insert another values + + //Do not remove! + //Usable for looping through enum values + __Last UMETA(Hidden) + }; +} + + +/** + * @brief This struct holds action result enum and optionally reason of fail + */ +USTRUCT() +struct FOP_ActionResult +{ + GENERATED_BODY() + +public: + /** @brief Default constructor usable when there is no problem */ + FOP_ActionResult(); + + /** + * @brief This constructor initializes variables & attempts to log when is error + * @param InEnum Status + */ + FOP_ActionResult(const EOP_ActionResult::Type& InEnum); + + /** + * @brief This constructor initializes variables & attempts to log when is error + * @param InEnum Status + * @param InReason Reason of potential fail + */ + FOP_ActionResult(const EOP_ActionResult::Type& InEnum, const FText& InReason); + +private: + /** @brief Action status */ + EOP_ActionResult::Type Status; + + /** @brief Optional reason of fail */ + FText Reason; + +public: + /** + * @brief Checks if there is problematic state + * @return true when status is not equal to EOP_ActionResult::Ok + */ + bool IsProblem() const; + EOP_ActionResult::Type& GetStatus(); + FText& GetReason(); + +private: + void TryLog() const; +}; + diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h new file mode 100644 index 0000000000..3740c5285a --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/Logging/OP_Log.h @@ -0,0 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. +#pragma once + +DEFINE_LOG_CATEGORY_STATIC(LogCommandletOPGenerateProject, Log, All); \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OPConstants.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OPConstants.h new file mode 100644 index 0000000000..f4587f7a50 --- /dev/null +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OPConstants.h @@ -0,0 +1,13 @@ +// Copyright 2023, Ayon, All rights reserved. +#pragma once + +namespace OPConstants +{ + const FString OP_PluginName = "OpenPype"; + const FString PythonScript_PluginName = "PythonScriptPlugin"; + const FString SequencerScripting_PluginName = "SequencerScripting"; + const FString MovieRenderPipeline_PluginName = "MovieRenderPipeline"; + const FString EditorScriptingUtils_PluginName = "EditorScriptingUtilities"; +} + + diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPype.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPype.h similarity index 86% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPype.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPype.h index 4261476da8..b89760099b 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPype.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPype.h @@ -1,4 +1,4 @@ -// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. +// Copyright 2023, Ayon, All rights reserved. #pragma once diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeCommands.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeCommands.h similarity index 91% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeCommands.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeCommands.h index 62ffb8de33..99b0be26f0 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeCommands.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeCommands.h @@ -1,4 +1,4 @@ -// Copyright Epic Games, Inc. All Rights Reserved. +// Copyright 2023, Ayon, All rights reserved. #pragma once diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeLib.h similarity index 90% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeLib.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeLib.h index 06425c7c7d..ef4d1027ea 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypeLib.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeLib.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "Engine.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h similarity index 92% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h index 146025bd6d..bce41ef1b1 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypePublishInstance.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstance.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "Engine.h" @@ -17,7 +18,7 @@ public: * * @return - Set of UObjects. Careful! They are returning raw pointers. Seems like an issue in UE5 */ - UFUNCTION(BlueprintCallable, BlueprintPure) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python") TSet GetInternalAssets() const { //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed. @@ -34,7 +35,7 @@ public: * * @return - TSet of assets (UObjects). Careful! They are returning raw pointers. Seems like an issue in UE5 */ - UFUNCTION(BlueprintCallable, BlueprintPure) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python") TSet GetExternalAssets() const { //For some reason it can only return Raw Pointers? Seems like an issue which they haven't fixed. @@ -54,7 +55,7 @@ public: * * @attention If the bAddExternalAssets variable is false, external assets won't be included! */ - UFUNCTION(BlueprintCallable, BlueprintPure) + UFUNCTION(BlueprintCallable, BlueprintPure, Category="Python") TSet GetAllAssets() const { const TSet>& IteratedSet = bAddExternalAssets diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h similarity index 92% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h index 7d2c77fe6e..3fdb984411 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "CoreMinimal.h" diff --git a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePythonBridge.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h similarity index 90% rename from openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePythonBridge.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h index 692aab2e5e..827f76f56b 100644 --- a/openpype/hosts/unreal/integration/UE_4.7/Source/OpenPype/Public/OpenPypePythonBridge.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypePythonBridge.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "Engine.h" #include "OpenPypePythonBridge.generated.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeSettings.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h similarity index 90% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeSettings.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h index aca80946bb..b818fe0e95 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeSettings.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeSettings.h @@ -1,4 +1,4 @@ -๏ปฟ// Fill out your copyright notice in the Description page of Project Settings. +๏ปฟ// Copyright 2023, Ayon, All rights reserved. #pragma once diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h similarity index 89% rename from openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeStyle.h rename to openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h index ae704251e1..039abe96ef 100644 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/OpenPypeStyle.h +++ b/openpype/hosts/unreal/integration/UE_5.0/OpenPype/Source/OpenPype/Public/OpenPypeStyle.h @@ -1,3 +1,4 @@ +// Copyright 2023, Ayon, All rights reserved. #pragma once #include "CoreMinimal.h" #include "Styling/SlateStyle.h" diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp deleted file mode 100644 index b943150bdd..0000000000 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Private/AssetContainerFactory.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include "AssetContainerFactory.h" -#include "AssetContainer.h" - -UAssetContainerFactory::UAssetContainerFactory(const FObjectInitializer& ObjectInitializer) - : UFactory(ObjectInitializer) -{ - SupportedClass = UAssetContainer::StaticClass(); - bCreateNew = false; - bEditorImport = true; -} - -UObject* UAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) -{ - UAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags); - return AssetContainer; -} - -bool UAssetContainerFactory::ShouldShowInNewMenu() const { - return false; -} diff --git a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h deleted file mode 100644 index 331ce6bb50..0000000000 --- a/openpype/hosts/unreal/integration/UE_5.0/Source/OpenPype/Public/AssetContainerFactory.h +++ /dev/null @@ -1,21 +0,0 @@ -// Fill out your copyright notice in the Description page of Project Settings. - -#pragma once - -#include "CoreMinimal.h" -#include "Factories/Factory.h" -#include "AssetContainerFactory.generated.h" - -/** - * - */ -UCLASS() -class OPENPYPE_API UAssetContainerFactory : public UFactory -{ - GENERATED_BODY() - -public: - UAssetContainerFactory(const FObjectInitializer& ObjectInitializer); - virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; - virtual bool ShouldShowInNewMenu() const override; -}; \ No newline at end of file diff --git a/openpype/hosts/unreal/lib.py b/openpype/hosts/unreal/lib.py index 095f5e414b..28a5106042 100644 --- a/openpype/hosts/unreal/lib.py +++ b/openpype/hosts/unreal/lib.py @@ -4,6 +4,10 @@ import os import platform import json + +from typing import List + +import openpype from distutils import dir_util import subprocess import re @@ -73,7 +77,7 @@ def get_engine_versions(env=None): return OrderedDict() -def get_editor_executable_path(engine_path: Path, engine_version: str) -> Path: +def get_editor_exe_path(engine_path: Path, engine_version: str) -> Path: """Get UE Editor executable path.""" ue_path = engine_path / "Engine/Binaries" if platform.system().lower() == "windows": @@ -214,77 +218,71 @@ def create_unreal_project(project_name: str, # created in different UE4 version. When user convert such project # to his UE4 version, Engine ID is replaced in uproject file. If some # other user tries to open it, it will present him with similar error. - ue_modules = Path() - if platform.system().lower() == "windows": - ue_modules_path = engine_path / "Engine/Binaries/Win64" - if ue_version.split(".")[0] == "4": - ue_modules_path /= "UE4Editor.modules" - elif ue_version.split(".")[0] == "5": - ue_modules_path /= "UnrealEditor.modules" - ue_modules = Path(ue_modules_path) - if platform.system().lower() == "linux": - ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", - "Linux", "UE4Editor.modules")) + # engine_path should be the location of UE_X.X folder - if platform.system().lower() == "darwin": - ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", - "Mac", "UE4Editor.modules")) + ue_editor_exe: Path = get_editor_exe_path(engine_path, ue_version) + cmdlet_project: Path = get_path_to_cmdlet_project(ue_version) - if ue_modules.exists(): - print("--- Loading Engine ID from modules file ...") - with open(ue_modules, "r") as mp: - loaded_modules = json.load(mp) + project_file = pr_dir / f"{project_name}.uproject" - if loaded_modules.get("BuildId"): - ue_id = "{" + loaded_modules.get("BuildId") + "}" - - plugins_path = None - if os.path.isdir(env.get("OPENPYPE_UNREAL_PLUGIN", "")): - # copy plugin to correct path under project - plugins_path = pr_dir / "Plugins" - openpype_plugin_path = plugins_path / "OpenPype" - if not openpype_plugin_path.is_dir(): - openpype_plugin_path.mkdir(parents=True, exist_ok=True) - dir_util._path_created = {} - dir_util.copy_tree(os.environ.get("OPENPYPE_UNREAL_PLUGIN"), - openpype_plugin_path.as_posix()) - - if not (openpype_plugin_path / "Binaries").is_dir() \ - or not (openpype_plugin_path / "Intermediate").is_dir(): - dev_mode = True - - # data for project file - data = { - "FileVersion": 3, - "EngineAssociation": ue_id, - "Category": "", - "Description": "", - "Plugins": [ - {"Name": "PythonScriptPlugin", "Enabled": True}, - {"Name": "EditorScriptingUtilities", "Enabled": True}, - {"Name": "SequencerScripting", "Enabled": True}, - {"Name": "MovieRenderPipeline", "Enabled": True}, - {"Name": "OpenPype", "Enabled": True} - ] - } + print("--- Generating a new project ...") + commandlet_cmd = [f'{ue_editor_exe.as_posix()}', + f'{cmdlet_project.as_posix()}', + f'-run=OPGenerateProject', + f'{project_file.resolve().as_posix()}'] if dev_mode or preset["dev_mode"]: - # this will add the project module and necessary source file to - # make it a C++ project and to (hopefully) make Unreal Editor to - # compile all # sources at start + commandlet_cmd.append('-GenerateCode') - data["Modules"] = [{ - "Name": project_name, - "Type": "Runtime", - "LoadingPhase": "Default", - "AdditionalDependencies": ["Engine"], - }] + gen_process = subprocess.Popen(commandlet_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) - # write project file - project_file = pr_dir / f"{project_name}.uproject" - with open(project_file, mode="w") as pf: - json.dump(data, pf, indent=4) + for line in gen_process.stdout: + print(line.decode(), end='') + gen_process.stdout.close() + return_code = gen_process.wait() + + if return_code and return_code != 0: + raise RuntimeError(f'Failed to generate \'{project_name}\' project! ' + f'Exited with return code {return_code}') + + print("--- Project has been generated successfully.") + + with open(project_file.as_posix(), mode="r+") as pf: + pf_json = json.load(pf) + pf_json["EngineAssociation"] = _get_build_id(engine_path, ue_version) + pf.seek(0) + json.dump(pf_json, pf, indent=4) + pf.truncate() + print(f'--- Engine ID has been written into the project file') + + if dev_mode or preset["dev_mode"]: + u_build_tool = get_path_to_ubt(engine_path, ue_version) + + arch = "Win64" + if platform.system().lower() == "windows": + arch = "Win64" + elif platform.system().lower() == "linux": + arch = "Linux" + elif platform.system().lower() == "darwin": + # we need to test this out + arch = "Mac" + + command1 = [u_build_tool.as_posix(), "-projectfiles", + f"-project={project_file}", "-progress"] + + subprocess.run(command1) + + command2 = [u_build_tool.as_posix(), + f"-ModuleWithSuffix={project_name},3555", arch, + "Development", "-TargetType=Editor", + f'-Project={project_file}', + f'{project_file}', + "-IgnoreJunk"] + + subprocess.run(command2) # ensure we have PySide2 installed in engine python_path = None @@ -307,176 +305,123 @@ def create_unreal_project(project_name: str, subprocess.check_call( [python_path.as_posix(), "-m", "pip", "install", "pyside2"]) - if dev_mode or preset["dev_mode"]: - _prepare_cpp_project(project_file, engine_path, ue_version) + +def get_path_to_uat(engine_path: Path) -> Path: + if platform.system().lower() == "windows": + return engine_path / "Engine/Build/BatchFiles/RunUAT.bat" + + if platform.system().lower() == "linux" \ + or platform.system().lower() == "darwin": + return engine_path / "Engine/Build/BatchFiles/RunUAT.sh" -def _prepare_cpp_project( - project_file: Path, engine_path: Path, ue_version: str) -> None: - """Prepare CPP Unreal Project. +def get_path_to_cmdlet_project(ue_version: str) -> Path: + cmd_project = Path(os.path.dirname(os.path.abspath(openpype.__file__))) - This function will add source files needed for project to be - rebuild along with the OpenPype integration plugin. + # For now, only tested on Windows (For Linux and Mac it has to be implemented) + if ue_version.split(".")[0] == "4": + cmd_project /= "hosts/unreal/integration/UE_4.7" + elif ue_version.split(".")[0] == "5": + cmd_project /= "hosts/unreal/integration/UE_5.0" - There seems not to be automated way to do it from command line. - But there might be way to create at least those target and build files - by some generator. This needs more research as manually writing - those files is rather hackish. :skull_and_crossbones: + return cmd_project / "CommandletProject/CommandletProject.uproject" - Args: - project_file (str): Path to .uproject file. - engine_path (str): Path to unreal engine associated with project. - - """ - project_name = project_file.stem - project_dir = project_file.parent - targets_dir = project_dir / "Source" - sources_dir = targets_dir / project_name - - sources_dir.mkdir(parents=True, exist_ok=True) - (project_dir / "Content").mkdir(parents=True, exist_ok=True) - - module_target = ''' -using UnrealBuildTool; -using System.Collections.Generic; - -public class {0}Target : TargetRules -{{ - public {0}Target( TargetInfo Target) : base(Target) - {{ - Type = TargetType.Game; - ExtraModuleNames.AddRange( new string[] {{ "{0}" }} ); - }} -}} -'''.format(project_name) - - editor_module_target = ''' -using UnrealBuildTool; -using System.Collections.Generic; - -public class {0}EditorTarget : TargetRules -{{ - public {0}EditorTarget( TargetInfo Target) : base(Target) - {{ - Type = TargetType.Editor; - - ExtraModuleNames.AddRange( new string[] {{ "{0}" }} ); - }} -}} -'''.format(project_name) - - module_build = ''' -using UnrealBuildTool; -public class {0} : ModuleRules -{{ - public {0}(ReadOnlyTargetRules Target) : base(Target) - {{ - PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; - PublicDependencyModuleNames.AddRange(new string[] {{ "Core", - "CoreUObject", "Engine", "InputCore" }}); - PrivateDependencyModuleNames.AddRange(new string[] {{ }}); - }} -}} -'''.format(project_name) - - module_cpp = ''' -#include "{0}.h" -#include "Modules/ModuleManager.h" - -IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, {0}, "{0}" ); -'''.format(project_name) - - module_header = ''' -#pragma once -#include "CoreMinimal.h" -''' - - game_mode_cpp = ''' -#include "{0}GameModeBase.h" -'''.format(project_name) - - game_mode_h = ''' -#pragma once - -#include "CoreMinimal.h" -#include "GameFramework/GameModeBase.h" -#include "{0}GameModeBase.generated.h" - -UCLASS() -class {1}_API A{0}GameModeBase : public AGameModeBase -{{ - GENERATED_BODY() -}}; -'''.format(project_name, project_name.upper()) - - with open(targets_dir / f"{project_name}.Target.cs", mode="w") as f: - f.write(module_target) - - with open(targets_dir / f"{project_name}Editor.Target.cs", mode="w") as f: - f.write(editor_module_target) - - with open(sources_dir / f"{project_name}.Build.cs", mode="w") as f: - f.write(module_build) - - with open(sources_dir / f"{project_name}.cpp", mode="w") as f: - f.write(module_cpp) - - with open(sources_dir / f"{project_name}.h", mode="w") as f: - f.write(module_header) - - with open(sources_dir / f"{project_name}GameModeBase.cpp", mode="w") as f: - f.write(game_mode_cpp) - - with open(sources_dir / f"{project_name}GameModeBase.h", mode="w") as f: - f.write(game_mode_h) - +def get_path_to_ubt(engine_path: Path, ue_version: str) -> Path: u_build_tool_path = engine_path / "Engine/Binaries/DotNET" + if ue_version.split(".")[0] == "4": u_build_tool_path /= "UnrealBuildTool.exe" elif ue_version.split(".")[0] == "5": u_build_tool_path /= "UnrealBuildTool/UnrealBuildTool.exe" - u_build_tool = Path(u_build_tool_path) - u_header_tool = None - arch = "Win64" + return Path(u_build_tool_path) + + +def _get_build_id(engine_path: Path, ue_version: str) -> str: + ue_modules = Path() if platform.system().lower() == "windows": - arch = "Win64" - u_header_tool = Path( - engine_path / "Engine/Binaries/Win64/UnrealHeaderTool.exe") - elif platform.system().lower() == "linux": - arch = "Linux" - u_header_tool = Path( - engine_path / "Engine/Binaries/Linux/UnrealHeaderTool") - elif platform.system().lower() == "darwin": - # we need to test this out - arch = "Mac" - u_header_tool = Path( - engine_path / "Engine/Binaries/Mac/UnrealHeaderTool") + ue_modules_path = engine_path / "Engine/Binaries/Win64" + if ue_version.split(".")[0] == "4": + ue_modules_path /= "UE4Editor.modules" + elif ue_version.split(".")[0] == "5": + ue_modules_path /= "UnrealEditor.modules" + ue_modules = Path(ue_modules_path) - if not u_header_tool: - raise NotImplementedError("Unsupported platform") + if platform.system().lower() == "linux": + ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + "Linux", "UE4Editor.modules")) - command1 = [u_build_tool.as_posix(), "-projectfiles", - f"-project={project_file}", "-progress"] + if platform.system().lower() == "darwin": + ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + "Mac", "UE4Editor.modules")) - subprocess.run(command1) + if ue_modules.exists(): + print("--- Loading Engine ID from modules file ...") + with open(ue_modules, "r") as mp: + loaded_modules = json.load(mp) - command2 = [u_build_tool.as_posix(), - f"-ModuleWithSuffix={project_name},3555", arch, - "Development", "-TargetType=Editor", - f'-Project={project_file}', - f'{project_file}', - "-IgnoreJunk"] + if loaded_modules.get("BuildId"): + return "{" + loaded_modules.get("BuildId") + "}" - subprocess.run(command2) - """ - uhtmanifest = os.path.join(os.path.dirname(project_file), - f"{project_name}.uhtmanifest") +def try_installing_plugin(engine_path: Path, env: dict = None) -> None: + env = env or os.environ - command3 = [u_header_tool, f'"{project_file}"', f'"{uhtmanifest}"', - "-Unattended", "-WarningsAsErrors", "-installed"] + integration_plugin_path: Path = Path(env.get("OPENPYPE_UNREAL_PLUGIN", "")) - subprocess.run(command3) - """ + if not os.path.isdir(integration_plugin_path): + raise RuntimeError("Path to the integration plugin is null!") + + # Create a path to the plugin in the engine + op_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/OpenPype" + + if not op_plugin_path.is_dir(): + print("--- OpenPype Plugin is not present. Installing ...") + op_plugin_path.mkdir(parents=True, exist_ok=True) + + engine_plugin_config_path: Path = op_plugin_path / "Config" + engine_plugin_config_path.mkdir(exist_ok=True) + + dir_util._path_created = {} + + if not (op_plugin_path / "Binaries").is_dir() \ + or not (op_plugin_path / "Intermediate").is_dir(): + print("--- Binaries are not present. Building the plugin ...") + _build_and_move_plugin(engine_path, op_plugin_path, env) + + +def _build_and_move_plugin(engine_path: Path, + plugin_build_path: Path, + env: dict = None) -> None: + uat_path: Path = get_path_to_uat(engine_path) + + env = env or os.environ + integration_plugin_path: Path = Path(env.get("OPENPYPE_UNREAL_PLUGIN", "")) + + if uat_path.is_file(): + temp_dir: Path = integration_plugin_path.parent / "Temp" + temp_dir.mkdir(exist_ok=True) + uplugin_path: Path = integration_plugin_path / "OpenPype.uplugin" + + # 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()}'] + subprocess.run(build_plugin_cmd) + + # Copy the contents of the 'Temp' dir into the + # 'OpenPype' directory in the engine + dir_util.copy_tree(temp_dir.as_posix(), plugin_build_path.as_posix()) + + # We need to also copy the config folder. + # The UAT doesn't include the Config folder in the build + plugin_install_config_path: Path = plugin_build_path / "Config" + integration_plugin_config_path = integration_plugin_path / "Config" + + dir_util.copy_tree(integration_plugin_config_path.as_posix(), + plugin_install_config_path.as_posix()) + + dir_util.remove_tree(temp_dir.as_posix()) diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py index bf1489d688..642924e2d6 100644 --- a/openpype/hosts/unreal/plugins/create/create_camera.py +++ b/openpype/hosts/unreal/plugins/create/create_camera.py @@ -1,41 +1,38 @@ +# -*- coding: utf-8 -*- import unreal -from unreal import EditorAssetLibrary as eal -from unreal import EditorLevelLibrary as ell -from openpype.hosts.unreal.api.pipeline import instantiate -from openpype.pipeline import LegacyCreator +from openpype.pipeline import CreatorError +from openpype.hosts.unreal.api.pipeline import UNREAL_VERSION +from openpype.hosts.unreal.api.plugin import ( + UnrealAssetCreator, +) -class CreateCamera(LegacyCreator): - """Layout output for character rigs""" +class CreateCamera(UnrealAssetCreator): + """Create Camera.""" - name = "layoutMain" + identifier = "io.openpype.creators.unreal.camera" label = "Camera" family = "camera" - icon = "cubes" + icon = "fa.camera" - root = "/Game/OpenPype/Instances" - suffix = "_INS" + def create(self, subset_name, instance_data, pre_create_data): + if pre_create_data.get("use_selection"): + sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() + selection = [a.get_path_name() for a in sel_objects] - def __init__(self, *args, **kwargs): - super(CreateCamera, self).__init__(*args, **kwargs) + if len(selection) != 1: + raise CreatorError("Please select only one object.") - def process(self): - data = self.data + # Add the current level path to the metadata + if UNREAL_VERSION.major == 5: + world = unreal.UnrealEditorSubsystem().get_editor_world() + else: + world = unreal.EditorLevelLibrary.get_editor_world() - name = data["subset"] + instance_data["level"] = world.get_path_name() - data["level"] = ell.get_editor_world().get_path_name() - - if not eal.does_directory_exist(self.root): - eal.make_directory(self.root) - - factory = unreal.LevelSequenceFactoryNew() - tools = unreal.AssetToolsHelpers().get_asset_tools() - tools.create_asset(name, f"{self.root}/{name}", None, factory) - - asset_name = f"{self.root}/{name}/{name}.{name}" - - data["members"] = [asset_name] - - instantiate(f"{self.root}", name, data, None, self.suffix) + super(CreateCamera, self).create( + subset_name, + instance_data, + pre_create_data) diff --git a/openpype/hosts/unreal/plugins/create/create_layout.py b/openpype/hosts/unreal/plugins/create/create_layout.py index c1067b00d9..1d2e800a13 100644 --- a/openpype/hosts/unreal/plugins/create/create_layout.py +++ b/openpype/hosts/unreal/plugins/create/create_layout.py @@ -1,42 +1,13 @@ # -*- coding: utf-8 -*- -from unreal import EditorLevelLibrary - -from openpype.pipeline import LegacyCreator -from openpype.hosts.unreal.api.pipeline import instantiate +from openpype.hosts.unreal.api.plugin import ( + UnrealActorCreator, +) -class CreateLayout(LegacyCreator): +class CreateLayout(UnrealActorCreator): """Layout output for character rigs.""" - name = "layoutMain" + identifier = "io.openpype.creators.unreal.layout" label = "Layout" family = "layout" icon = "cubes" - - root = "/Game" - suffix = "_INS" - - def __init__(self, *args, **kwargs): - super(CreateLayout, self).__init__(*args, **kwargs) - - def process(self): - data = self.data - - name = data["subset"] - - selection = [] - # if (self.options or {}).get("useSelection"): - # sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() - # selection = [a.get_path_name() for a in sel_objects] - - data["level"] = EditorLevelLibrary.get_editor_world().get_path_name() - - data["members"] = [] - - if (self.options or {}).get("useSelection"): - # Set as members the selected actors - for actor in EditorLevelLibrary.get_selected_level_actors(): - data["members"].append("{}.{}".format( - actor.get_outer().get_name(), actor.get_name())) - - instantiate(self.root, name, data, selection, self.suffix) diff --git a/openpype/hosts/unreal/plugins/create/create_look.py b/openpype/hosts/unreal/plugins/create/create_look.py index 4abf3f6095..f6c73e47e6 100644 --- a/openpype/hosts/unreal/plugins/create/create_look.py +++ b/openpype/hosts/unreal/plugins/create/create_look.py @@ -1,56 +1,57 @@ # -*- coding: utf-8 -*- -"""Create look in Unreal.""" -import unreal # noqa -from openpype.hosts.unreal.api import pipeline, plugin -from openpype.pipeline import LegacyCreator +import unreal + +from openpype.pipeline import CreatorError +from openpype.hosts.unreal.api.pipeline import ( + create_folder +) +from openpype.hosts.unreal.api.plugin import ( + UnrealAssetCreator +) +from openpype.lib import UILabelDef -class CreateLook(LegacyCreator): +class CreateLook(UnrealAssetCreator): """Shader connections defining shape look.""" - name = "unrealLook" - label = "Unreal - Look" + identifier = "io.openpype.creators.unreal.look" + label = "Look" family = "look" icon = "paint-brush" - root = "/Game/Avalon/Assets" - suffix = "_INS" + def create(self, subset_name, instance_data, pre_create_data): + # We need to set this to True for the parent class to work + pre_create_data["use_selection"] = True + sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() + selection = [a.get_path_name() for a in sel_objects] - def __init__(self, *args, **kwargs): - super(CreateLook, self).__init__(*args, **kwargs) + if len(selection) != 1: + raise CreatorError("Please select only one asset.") - def process(self): - name = self.data["subset"] + selected_asset = selection[0] - selection = [] - if (self.options or {}).get("useSelection"): - sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() - selection = [a.get_path_name() for a in sel_objects] + look_directory = "/Game/OpenPype/Looks" # Create the folder - path = f"{self.root}/{self.data['asset']}" - new_name = pipeline.create_folder(path, name) - full_path = f"{path}/{new_name}" + folder_name = create_folder(look_directory, subset_name) + path = f"{look_directory}/{folder_name}" + + instance_data["look"] = path # Create a new cube static mesh ar = unreal.AssetRegistryHelpers.get_asset_registry() cube = ar.get_asset_by_object_path("/Engine/BasicShapes/Cube.Cube") - # Create the avalon publish instance object - container_name = f"{name}{self.suffix}" - pipeline.create_publish_instance( - instance=container_name, path=full_path) - # Get the mesh of the selected object - original_mesh = ar.get_asset_by_object_path(selection[0]).get_asset() - materials = original_mesh.get_editor_property('materials') + original_mesh = ar.get_asset_by_object_path(selected_asset).get_asset() + materials = original_mesh.get_editor_property('static_materials') - self.data["members"] = [] + pre_create_data["members"] = [] # Add the materials to the cube for material in materials: - name = material.get_editor_property('material_slot_name') - object_path = f"{full_path}/{name}.{name}" + mat_name = material.get_editor_property('material_slot_name') + object_path = f"{path}/{mat_name}.{mat_name}" unreal_object = unreal.EditorAssetLibrary.duplicate_loaded_asset( cube.get_asset(), object_path ) @@ -61,8 +62,16 @@ class CreateLook(LegacyCreator): unreal_object.add_material( material.get_editor_property('material_interface')) - self.data["members"].append(object_path) + pre_create_data["members"].append(object_path) unreal.EditorAssetLibrary.save_asset(object_path) - pipeline.imprint(f"{full_path}/{container_name}", self.data) + super(CreateLook, self).create( + subset_name, + instance_data, + pre_create_data) + + def get_pre_create_attr_defs(self): + return [ + UILabelDef("Select the asset from which to create the look.") + ] diff --git a/openpype/hosts/unreal/plugins/create/create_render.py b/openpype/hosts/unreal/plugins/create/create_render.py index a85d17421b..5834d2e7a7 100644 --- a/openpype/hosts/unreal/plugins/create/create_render.py +++ b/openpype/hosts/unreal/plugins/create/create_render.py @@ -1,117 +1,138 @@ +# -*- coding: utf-8 -*- import unreal -from openpype.hosts.unreal.api import pipeline -from openpype.pipeline import LegacyCreator +from openpype.pipeline import CreatorError +from openpype.hosts.unreal.api.pipeline import ( + get_subsequences +) +from openpype.hosts.unreal.api.plugin import ( + UnrealAssetCreator +) +from openpype.lib import UILabelDef -class CreateRender(LegacyCreator): +class CreateRender(UnrealAssetCreator): """Create instance for sequence for rendering""" - name = "unrealRender" - label = "Unreal - Render" + identifier = "io.openpype.creators.unreal.render" + label = "Render" family = "render" - icon = "cube" - asset_types = ["LevelSequence"] - - root = "/Game/OpenPype/PublishInstances" - suffix = "_INS" - - def process(self): - subset = self.data["subset"] + icon = "eye" + def create(self, subset_name, instance_data, pre_create_data): ar = unreal.AssetRegistryHelpers.get_asset_registry() - # The asset name is the the third element of the path which contains - # the map. - # The index of the split path is 3 because the first element is an - # empty string, as the path begins with "/Content". - a = unreal.EditorUtilityLibrary.get_selected_assets()[0] - asset_name = a.get_path_name().split("/")[3] + sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() + selection = [ + a.get_path_name() for a in sel_objects + if a.get_class().get_name() == "LevelSequence"] - # Get the master sequence and the master level. - # There should be only one sequence and one level in the directory. - filter = unreal.ARFilter( - class_names=["LevelSequence"], - package_paths=[f"/Game/OpenPype/{asset_name}"], - recursive_paths=False) - sequences = ar.get_assets(filter) - ms = sequences[0].get_editor_property('object_path') - filter = unreal.ARFilter( - class_names=["World"], - package_paths=[f"/Game/OpenPype/{asset_name}"], - recursive_paths=False) - levels = ar.get_assets(filter) - ml = levels[0].get_editor_property('object_path') + if not selection: + raise CreatorError("Please select at least one Level Sequence.") - selection = [] - if (self.options or {}).get("useSelection"): - sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() - selection = [ - a.get_path_name() for a in sel_objects - if a.get_class().get_name() in self.asset_types] - else: - selection.append(self.data['sequence']) + seq_data = None - unreal.log(f"selection: {selection}") + for sel in selection: + selected_asset = ar.get_asset_by_object_path(sel).get_asset() + selected_asset_path = selected_asset.get_path_name() - path = f"{self.root}" - unreal.EditorAssetLibrary.make_directory(path) + # Check if the selected asset is a level sequence asset. + if selected_asset.get_class().get_name() != "LevelSequence": + unreal.log_warning( + f"Skipping {selected_asset.get_name()}. It isn't a Level " + "Sequence.") - ar = unreal.AssetRegistryHelpers.get_asset_registry() + # The asset name is the third element of the path which + # contains the map. + # To take the asset name, we remove from the path the prefix + # "/Game/OpenPype/" and then we split the path by "/". + sel_path = selected_asset_path + asset_name = sel_path.replace("/Game/OpenPype/", "").split("/")[0] - for a in selection: - ms_obj = ar.get_asset_by_object_path(ms).get_asset() + # Get the master sequence and the master level. + # There should be only one sequence and one level in the directory. + ar_filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_paths=[f"/Game/OpenPype/{asset_name}"], + recursive_paths=False) + sequences = ar.get_assets(ar_filter) + master_seq = sequences[0].get_asset().get_path_name() + master_seq_obj = sequences[0].get_asset() + ar_filter = unreal.ARFilter( + class_names=["World"], + package_paths=[f"/Game/OpenPype/{asset_name}"], + recursive_paths=False) + levels = ar.get_assets(ar_filter) + master_lvl = levels[0].get_asset().get_path_name() - seq_data = None + # If the selected asset is the master sequence, we get its data + # and then we create the instance for the master sequence. + # Otherwise, we cycle from the master sequence to find the selected + # sequence and we get its data. This data will be used to create + # the instance for the selected sequence. In particular, + # we get the frame range of the selected sequence and its final + # output path. + master_seq_data = { + "sequence": master_seq_obj, + "output": f"{master_seq_obj.get_name()}", + "frame_range": ( + master_seq_obj.get_playback_start(), + master_seq_obj.get_playback_end())} - if a == ms: - seq_data = { - "sequence": ms_obj, - "output": f"{ms_obj.get_name()}", - "frame_range": ( - ms_obj.get_playback_start(), ms_obj.get_playback_end()) - } + if selected_asset_path == master_seq: + seq_data = master_seq_data else: - seq_data_list = [{ - "sequence": ms_obj, - "output": f"{ms_obj.get_name()}", - "frame_range": ( - ms_obj.get_playback_start(), ms_obj.get_playback_end()) - }] + seq_data_list = [master_seq_data] - for s in seq_data_list: - subscenes = pipeline.get_subsequences(s.get('sequence')) + for seq in seq_data_list: + subscenes = get_subsequences(seq.get('sequence')) - for ss in subscenes: + for sub_seq in subscenes: + sub_seq_obj = sub_seq.get_sequence() curr_data = { - "sequence": ss.get_sequence(), - "output": (f"{s.get('output')}/" - f"{ss.get_sequence().get_name()}"), + "sequence": sub_seq_obj, + "output": (f"{seq.get('output')}/" + f"{sub_seq_obj.get_name()}"), "frame_range": ( - ss.get_start_frame(), ss.get_end_frame() - 1) - } + sub_seq.get_start_frame(), + sub_seq.get_end_frame() - 1)} - if ss.get_sequence().get_path_name() == a: + # If the selected asset is the current sub-sequence, + # we get its data and we break the loop. + # Otherwise, we add the current sub-sequence data to + # the list of sequences to check. + if sub_seq_obj.get_path_name() == selected_asset_path: seq_data = curr_data break + seq_data_list.append(curr_data) + # If we found the selected asset, we break the loop. if seq_data is not None: break + # If we didn't find the selected asset, we don't create the + # instance. if not seq_data: + unreal.log_warning( + f"Skipping {selected_asset.get_name()}. It isn't a " + "sub-sequence of the master sequence.") continue - d = self.data.copy() - d["members"] = [a] - d["sequence"] = a - d["master_sequence"] = ms - d["master_level"] = ml - d["output"] = seq_data.get('output') - d["frameStart"] = seq_data.get('frame_range')[0] - d["frameEnd"] = seq_data.get('frame_range')[1] + instance_data["members"] = [selected_asset_path] + instance_data["sequence"] = selected_asset_path + instance_data["master_sequence"] = master_seq + instance_data["master_level"] = master_lvl + instance_data["output"] = seq_data.get('output') + instance_data["frameStart"] = seq_data.get('frame_range')[0] + instance_data["frameEnd"] = seq_data.get('frame_range')[1] - container_name = f"{subset}{self.suffix}" - pipeline.create_publish_instance( - instance=container_name, path=path) - pipeline.imprint(f"{path}/{container_name}", d) + super(CreateRender, self).create( + subset_name, + instance_data, + pre_create_data) + + def get_pre_create_attr_defs(self): + return [ + UILabelDef("Select the sequence to render.") + ] diff --git a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py index 45d517d27d..1acf7084d1 100644 --- a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py @@ -1,35 +1,13 @@ # -*- coding: utf-8 -*- -"""Create Static Meshes as FBX geometry.""" -import unreal # noqa -from openpype.hosts.unreal.api.pipeline import ( - instantiate, +from openpype.hosts.unreal.api.plugin import ( + UnrealAssetCreator, ) -from openpype.pipeline import LegacyCreator -class CreateStaticMeshFBX(LegacyCreator): - """Static FBX geometry.""" +class CreateStaticMeshFBX(UnrealAssetCreator): + """Create Static Meshes as FBX geometry.""" - name = "unrealStaticMeshMain" - label = "Unreal - Static Mesh" + identifier = "io.openpype.creators.unreal.staticmeshfbx" + label = "Static Mesh (FBX)" family = "unrealStaticMesh" icon = "cube" - asset_types = ["StaticMesh"] - - root = "/Game" - suffix = "_INS" - - def __init__(self, *args, **kwargs): - super(CreateStaticMeshFBX, self).__init__(*args, **kwargs) - - def process(self): - - name = self.data["subset"] - - selection = [] - if (self.options or {}).get("useSelection"): - sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() - selection = [a.get_path_name() for a in sel_objects] - - unreal.log("selection: {}".format(selection)) - instantiate(self.root, name, self.data, selection, self.suffix) diff --git a/openpype/hosts/unreal/plugins/create/create_uasset.py b/openpype/hosts/unreal/plugins/create/create_uasset.py index ee584ac00c..70f17d478b 100644 --- a/openpype/hosts/unreal/plugins/create/create_uasset.py +++ b/openpype/hosts/unreal/plugins/create/create_uasset.py @@ -1,41 +1,31 @@ -"""Create UAsset.""" +# -*- coding: utf-8 -*- from pathlib import Path import unreal -from openpype.hosts.unreal.api import pipeline -from openpype.pipeline import LegacyCreator +from openpype.pipeline import CreatorError +from openpype.hosts.unreal.api.plugin import ( + UnrealAssetCreator, +) -class CreateUAsset(LegacyCreator): - """UAsset.""" +class CreateUAsset(UnrealAssetCreator): + """Create UAsset.""" - name = "UAsset" + identifier = "io.openpype.creators.unreal.uasset" label = "UAsset" family = "uasset" icon = "cube" - root = "/Game/OpenPype" - suffix = "_INS" + def create(self, subset_name, instance_data, pre_create_data): + if pre_create_data.get("use_selection"): + ar = unreal.AssetRegistryHelpers.get_asset_registry() - def __init__(self, *args, **kwargs): - super(CreateUAsset, self).__init__(*args, **kwargs) - - def process(self): - ar = unreal.AssetRegistryHelpers.get_asset_registry() - - subset = self.data["subset"] - path = f"{self.root}/PublishInstances/" - - unreal.EditorAssetLibrary.make_directory(path) - - selection = [] - if (self.options or {}).get("useSelection"): sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() selection = [a.get_path_name() for a in sel_objects] if len(selection) != 1: - raise RuntimeError("Please select only one object.") + raise CreatorError("Please select only one object.") obj = selection[0] @@ -43,19 +33,14 @@ class CreateUAsset(LegacyCreator): sys_path = unreal.SystemLibrary.get_system_path(asset) if not sys_path: - raise RuntimeError( + raise CreatorError( f"{Path(obj).name} is not on the disk. Likely it needs to" "be saved first.") if Path(sys_path).suffix != ".uasset": - raise RuntimeError(f"{Path(sys_path).name} is not a UAsset.") + raise CreatorError(f"{Path(sys_path).name} is not a UAsset.") - unreal.log("selection: {}".format(selection)) - container_name = f"{subset}{self.suffix}" - pipeline.create_publish_instance( - instance=container_name, path=path) - - data = self.data.copy() - data["members"] = selection - - pipeline.imprint(f"{path}/{container_name}", data) + super(CreateUAsset, self).create( + subset_name, + instance_data, + pre_create_data) diff --git a/openpype/hosts/unreal/plugins/publish/collect_instance_members.py b/openpype/hosts/unreal/plugins/publish/collect_instance_members.py new file mode 100644 index 0000000000..46ca51ab7e --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/collect_instance_members.py @@ -0,0 +1,46 @@ +import unreal + +import pyblish.api + + +class CollectInstanceMembers(pyblish.api.InstancePlugin): + """ + Collect members of instance. + + This collector will collect the assets for the families that support to + have them included as External Data, and will add them to the instance + as members. + """ + + order = pyblish.api.CollectorOrder + 0.1 + hosts = ["unreal"] + families = ["camera", "look", "unrealStaticMesh", "uasset"] + label = "Collect Instance Members" + + def process(self, instance): + """Collect members of instance.""" + self.log.info("Collecting instance members") + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + inst_path = instance.data.get('instance_path') + inst_name = instance.data.get('objectName') + + pub_instance = ar.get_asset_by_object_path( + f"{inst_path}.{inst_name}").get_asset() + + if not pub_instance: + self.log.error(f"{inst_path}.{inst_name}") + raise RuntimeError(f"Instance {instance} not found.") + + if not pub_instance.get_editor_property("add_external_assets"): + # No external assets in the instance + return + + assets = pub_instance.get_editor_property('asset_data_external') + + members = [asset.get_path_name() for asset in assets] + + self.log.debug(f"Members: {members}") + + instance.data["members"] = members diff --git a/openpype/hosts/unreal/plugins/publish/collect_instances.py b/openpype/hosts/unreal/plugins/publish/collect_instances.py deleted file mode 100644 index 27b711cad6..0000000000 --- a/openpype/hosts/unreal/plugins/publish/collect_instances.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect publishable instances in Unreal.""" -import ast -import unreal # noqa -import pyblish.api -from openpype.hosts.unreal.api.pipeline import UNREAL_VERSION -from openpype.pipeline.publish import KnownPublishError - - -class CollectInstances(pyblish.api.ContextPlugin): - """Gather instances by OpenPypePublishInstance class - - This collector finds all paths containing `OpenPypePublishInstance` class - asset - - Identifier: - id (str): "pyblish.avalon.instance" - - """ - - label = "Collect Instances" - order = pyblish.api.CollectorOrder - 0.1 - hosts = ["unreal"] - - def process(self, context): - - ar = unreal.AssetRegistryHelpers.get_asset_registry() - class_name = [ - "/Script/OpenPype", - "OpenPypePublishInstance" - ] if ( - UNREAL_VERSION.major == 5 - and UNREAL_VERSION.minor > 0 - ) else "OpenPypePublishInstance" # noqa - instance_containers = ar.get_assets_by_class(class_name, True) - - for container_data in instance_containers: - asset = container_data.get_asset() - data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) - data["objectName"] = container_data.asset_name - # convert to strings - data = {str(key): str(value) for (key, value) in data.items()} - if not data.get("family"): - raise KnownPublishError("instance has no family") - - # content of container - members = ast.literal_eval(data.get("members")) - self.log.debug(members) - self.log.debug(asset.get_path_name()) - # remove instance container - self.log.info("Creating instance for {}".format(asset.get_name())) - - instance = context.create_instance(asset.get_name()) - instance[:] = members - - # Store the exact members of the object set - instance.data["setMembers"] = members - instance.data["families"] = [data.get("family")] - instance.data["level"] = data.get("level") - instance.data["parent"] = data.get("parent") - - label = "{0} ({1})".format(asset.get_name()[:-4], - data["asset"]) - - instance.data["label"] = label - - instance.data.update(data) diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py index 4e37cc6a86..16e365ca96 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_camera.py +++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py @@ -3,10 +3,9 @@ import os import unreal -from unreal import EditorAssetLibrary as eal -from unreal import EditorLevelLibrary as ell from openpype.pipeline import publish +from openpype.hosts.unreal.api.pipeline import UNREAL_VERSION class ExtractCamera(publish.Extractor): @@ -18,6 +17,8 @@ class ExtractCamera(publish.Extractor): optional = True def process(self, instance): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + # Define extract output file path staging_dir = self.staging_dir(instance) fbx_filename = "{}.fbx".format(instance.name) @@ -26,23 +27,54 @@ class ExtractCamera(publish.Extractor): self.log.info("Performing extraction..") # Check if the loaded level is the same of the instance - current_level = ell.get_editor_world().get_path_name() + if UNREAL_VERSION.major == 5: + world = unreal.UnrealEditorSubsystem().get_editor_world() + else: + world = unreal.EditorLevelLibrary.get_editor_world() + current_level = world.get_path_name() assert current_level == instance.data.get("level"), \ "Wrong level loaded" - for member in instance[:]: - data = eal.find_asset_data(member) - if data.asset_class == "LevelSequence": - ar = unreal.AssetRegistryHelpers.get_asset_registry() - sequence = ar.get_asset_by_object_path(member).get_asset() - unreal.SequencerTools.export_fbx( - ell.get_editor_world(), - sequence, - sequence.get_bindings(), - unreal.FbxExportOption(), - os.path.join(staging_dir, fbx_filename) - ) - break + for member in instance.data.get('members'): + data = ar.get_asset_by_object_path(member) + if UNREAL_VERSION.major == 5: + is_level_sequence = ( + data.asset_class_path.asset_name == "LevelSequence") + else: + is_level_sequence = (data.asset_class == "LevelSequence") + + if is_level_sequence: + sequence = data.get_asset() + if UNREAL_VERSION.major == 5 and UNREAL_VERSION.minor >= 1: + params = unreal.SequencerExportFBXParams( + world=world, + root_sequence=sequence, + sequence=sequence, + bindings=sequence.get_bindings(), + master_tracks=sequence.get_master_tracks(), + fbx_file_name=os.path.join(staging_dir, fbx_filename) + ) + unreal.SequencerTools.export_level_sequence_fbx(params) + elif UNREAL_VERSION.major == 4 and UNREAL_VERSION.minor == 26: + unreal.SequencerTools.export_fbx( + world, + sequence, + sequence.get_bindings(), + unreal.FbxExportOption(), + os.path.join(staging_dir, fbx_filename) + ) + else: + # Unreal 5.0 or 4.27 + unreal.SequencerTools.export_level_sequence_fbx( + world, + sequence, + sequence.get_bindings(), + unreal.FbxExportOption(), + os.path.join(staging_dir, fbx_filename) + ) + + if not os.path.isfile(os.path.join(staging_dir, fbx_filename)): + raise RuntimeError("Failed to extract camera") if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/unreal/plugins/publish/extract_look.py b/openpype/hosts/unreal/plugins/publish/extract_look.py index f999ad8651..4b32b4eb95 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_look.py +++ b/openpype/hosts/unreal/plugins/publish/extract_look.py @@ -29,13 +29,13 @@ class ExtractLook(publish.Extractor): for member in instance: asset = ar.get_asset_by_object_path(member) - object = asset.get_asset() + obj = asset.get_asset() name = asset.get_editor_property('asset_name') json_element = {'material': str(name)} - material_obj = object.get_editor_property('static_materials')[0] + material_obj = obj.get_editor_property('static_materials')[0] material = material_obj.material_interface base_color = mat_lib.get_material_property_input_node( diff --git a/openpype/hosts/unreal/plugins/publish/extract_uasset.py b/openpype/hosts/unreal/plugins/publish/extract_uasset.py index 89d779d368..f719df2a82 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_uasset.py +++ b/openpype/hosts/unreal/plugins/publish/extract_uasset.py @@ -22,7 +22,13 @@ class ExtractUAsset(publish.Extractor): staging_dir = self.staging_dir(instance) filename = "{}.uasset".format(instance.name) - obj = instance[0] + members = instance.data.get("members", []) + + if not members: + raise RuntimeError("No members found in instance.") + + # UAsset publishing supports only one member + obj = members[0] asset = ar.get_asset_by_object_path(obj).get_asset() sys_path = unreal.SystemLibrary.get_system_path(asset) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index a64b7c2911..9eb7724a60 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -30,7 +30,7 @@ from .vendor_bin_utils import ( ) from .attribute_definitions import ( - AbtractAttrDef, + AbstractAttrDef, UIDef, UISeparatorDef, @@ -82,9 +82,6 @@ from .mongo import ( validate_mongo_connection, OpenPypeMongoConnection ) -from .anatomy import ( - Anatomy -) from .dateutils import ( get_datetime_data, @@ -119,36 +116,19 @@ from .transcoding import ( ) from .avalon_context import ( CURRENT_DOC_SCHEMAS, - PROJECT_NAME_ALLOWED_SYMBOLS, - PROJECT_NAME_REGEX, create_project, - is_latest, - any_outdated, - get_asset, - get_linked_assets, - get_latest_version, - get_system_general_anatomy_data, get_workfile_template_key, get_workfile_template_key_from_context, - get_workdir_data, - get_workdir, - get_workdir_with_workdir_data, get_last_workfile_with_version, get_last_workfile, - create_workfile_doc, - save_workfile_data_to_doc, - get_workfile_doc, - BuildWorkfile, get_creator_by_name, get_custom_workfile_template, - change_timer_to_current_context, - get_custom_workfile_template_by_context, get_custom_workfile_template_by_string_context, get_custom_workfile_template @@ -186,8 +166,6 @@ from .plugin_tools import ( get_subset_name, get_subset_name_with_asset_doc, prepare_template_data, - filter_pyblish_plugins, - set_plugin_attributes_from_settings, source_hash, ) @@ -246,7 +224,7 @@ __all__ = [ "get_ffmpeg_tool_path", "is_oiio_supported", - "AbtractAttrDef", + "AbstractAttrDef", "UIDef", "UISeparatorDef", @@ -278,34 +256,17 @@ __all__ = [ "convert_ffprobe_fps_to_float", "CURRENT_DOC_SCHEMAS", - "PROJECT_NAME_ALLOWED_SYMBOLS", - "PROJECT_NAME_REGEX", "create_project", - "is_latest", - "any_outdated", - "get_asset", - "get_linked_assets", - "get_latest_version", - "get_system_general_anatomy_data", "get_workfile_template_key", "get_workfile_template_key_from_context", - "get_workdir_data", - "get_workdir", - "get_workdir_with_workdir_data", "get_last_workfile_with_version", "get_last_workfile", - "create_workfile_doc", - "save_workfile_data_to_doc", - "get_workfile_doc", - "BuildWorkfile", "get_creator_by_name", - "change_timer_to_current_context", - "get_custom_workfile_template_by_context", "get_custom_workfile_template_by_string_context", "get_custom_workfile_template", @@ -338,8 +299,6 @@ __all__ = [ "TaskNotSetError", "get_subset_name", "get_subset_name_with_asset_doc", - "filter_pyblish_plugins", - "set_plugin_attributes_from_settings", "source_hash", "format_file_size", @@ -358,8 +317,6 @@ __all__ = [ "terminal", - "Anatomy", - "get_datetime_data", "get_formatted_current_time", diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py deleted file mode 100644 index 6d339f058f..0000000000 --- a/openpype/lib/anatomy.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Code related to project Anatomy was moved -to 'openpype.pipeline.anatomy' please change your imports as soon as -possible. File will be probably removed in OpenPype 3.14.* -""" - -import warnings -import functools - - -class AnatomyDeprecatedWarning(DeprecationWarning): - pass - - -def anatomy_deprecated(func): - """Mark functions as deprecated. - - It will result in a warning being emitted when the function is used. - """ - - @functools.wraps(func) - def new_func(*args, **kwargs): - warnings.simplefilter("always", AnatomyDeprecatedWarning) - warnings.warn( - ( - "Deprecated import of 'Anatomy'." - " Class was moved to 'openpype.pipeline.anatomy'." - " Please change your imports of Anatomy in codebase." - ), - category=AnatomyDeprecatedWarning - ) - return func(*args, **kwargs) - return new_func - - -@anatomy_deprecated -def Anatomy(*args, **kwargs): - from openpype.pipeline.anatomy import Anatomy - return Anatomy(*args, **kwargs) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 04db0edc64..b5cd15f41a 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -20,7 +20,7 @@ def register_attr_def_class(cls): Currently are registered definitions used to deserialize data to objects. Attrs: - cls (AbtractAttrDef): Non-abstract class to be registered with unique + cls (AbstractAttrDef): Non-abstract class to be registered with unique 'type' attribute. Raises: @@ -36,7 +36,7 @@ def get_attributes_keys(attribute_definitions): """Collect keys from list of attribute definitions. Args: - attribute_definitions (List[AbtractAttrDef]): Objects of attribute + attribute_definitions (List[AbstractAttrDef]): Objects of attribute definitions. Returns: @@ -57,8 +57,8 @@ def get_default_values(attribute_definitions): """Receive default values for attribute definitions. Args: - attribute_definitions (List[AbtractAttrDef]): Attribute definitions for - which default values should be collected. + attribute_definitions (List[AbstractAttrDef]): Attribute definitions + for which default values should be collected. Returns: Dict[str, Any]: Default values for passet attribute definitions. @@ -76,15 +76,15 @@ def get_default_values(attribute_definitions): class AbstractAttrDefMeta(ABCMeta): - """Meta class to validate existence of 'key' attribute. + """Metaclass to validate existence of 'key' attribute. - Each object of `AbtractAttrDef` mus have defined 'key' attribute. + Each object of `AbstractAttrDef` mus have defined 'key' attribute. """ def __call__(self, *args, **kwargs): obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs) init_class = getattr(obj, "__init__class__", None) - if init_class is not AbtractAttrDef: + if init_class is not AbstractAttrDef: raise TypeError("{} super was not called in __init__.".format( type(obj) )) @@ -92,7 +92,7 @@ class AbstractAttrDefMeta(ABCMeta): @six.add_metaclass(AbstractAttrDefMeta) -class AbtractAttrDef(object): +class AbstractAttrDef(object): """Abstraction of attribute definiton. Each attribute definition must have implemented validation and @@ -145,7 +145,7 @@ class AbtractAttrDef(object): self.disabled = disabled self._id = uuid.uuid4().hex - self.__init__class__ = AbtractAttrDef + self.__init__class__ = AbstractAttrDef @property def id(self): @@ -154,7 +154,15 @@ class AbtractAttrDef(object): def __eq__(self, other): if not isinstance(other, self.__class__): return False - return self.key == other.key + return ( + self.key == other.key + and self.hidden == other.hidden + and self.default == other.default + and self.disabled == other.disabled + ) + + def __ne__(self, other): + return not self.__eq__(other) @abstractproperty def type(self): @@ -212,7 +220,7 @@ class AbtractAttrDef(object): # UI attribute definitoins won't hold value # ----------------------------------------- -class UIDef(AbtractAttrDef): +class UIDef(AbstractAttrDef): is_value_def = False def __init__(self, key=None, default=None, *args, **kwargs): @@ -237,7 +245,7 @@ class UILabelDef(UIDef): # Attribute defintioins should hold value # --------------------------------------- -class UnknownDef(AbtractAttrDef): +class UnknownDef(AbstractAttrDef): """Definition is not known because definition is not available. This attribute can be used to keep existing data unchanged but does not @@ -254,7 +262,7 @@ class UnknownDef(AbtractAttrDef): return value -class HiddenDef(AbtractAttrDef): +class HiddenDef(AbstractAttrDef): """Hidden value of Any type. This attribute can be used for UI purposes to pass values related @@ -274,7 +282,7 @@ class HiddenDef(AbtractAttrDef): return value -class NumberDef(AbtractAttrDef): +class NumberDef(AbstractAttrDef): """Number definition. Number can have defined minimum/maximum value and decimal points. Value @@ -350,7 +358,7 @@ class NumberDef(AbtractAttrDef): return round(float(value), self.decimals) -class TextDef(AbtractAttrDef): +class TextDef(AbstractAttrDef): """Text definition. Text can have multiline option so endline characters are allowed regex @@ -415,7 +423,7 @@ class TextDef(AbtractAttrDef): return data -class EnumDef(AbtractAttrDef): +class EnumDef(AbstractAttrDef): """Enumeration of single item from items. Args: @@ -457,7 +465,7 @@ class EnumDef(AbtractAttrDef): return self.default def serialize(self): - data = super(TextDef, self).serialize() + data = super(EnumDef, self).serialize() data["items"] = copy.deepcopy(self.items) return data @@ -523,7 +531,8 @@ class EnumDef(AbtractAttrDef): return output -class BoolDef(AbtractAttrDef): + +class BoolDef(AbstractAttrDef): """Boolean representation. Args: @@ -768,7 +777,7 @@ class FileDefItem(object): return output -class FileDef(AbtractAttrDef): +class FileDef(AbstractAttrDef): """File definition. It is possible to define filters of allowed file extensions and if supports folders. @@ -886,7 +895,7 @@ def serialize_attr_def(attr_def): """Serialize attribute definition to data. Args: - attr_def (AbtractAttrDef): Attribute definition to serialize. + attr_def (AbstractAttrDef): Attribute definition to serialize. Returns: Dict[str, Any]: Serialized data. @@ -899,7 +908,7 @@ def serialize_attr_defs(attr_defs): """Serialize attribute definitions to data. Args: - attr_defs (List[AbtractAttrDef]): Attribute definitions to serialize. + attr_defs (List[AbstractAttrDef]): Attribute definitions to serialize. Returns: List[Dict[str, Any]]: Serialized data. diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 12f4a5198b..a9ae27cb79 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1,6 +1,5 @@ """Should be used only inside of hosts.""" -import os -import copy + import platform import logging import functools @@ -10,17 +9,12 @@ import six from openpype.client import ( get_project, - get_assets, get_asset_by_name, - get_last_version_by_subset_name, - get_workfile_info, ) from openpype.client.operations import ( CURRENT_ASSET_DOC_SCHEMA, CURRENT_PROJECT_SCHEMA, CURRENT_PROJECT_CONFIG_SCHEMA, - PROJECT_NAME_ALLOWED_SYMBOLS, - PROJECT_NAME_REGEX, ) from .profiles_filtering import filter_profiles from .path_templates import StringTemplate @@ -128,70 +122,6 @@ def with_pipeline_io(func): return wrapped -@deprecated("openpype.pipeline.context_tools.is_representation_from_latest") -def is_latest(representation): - """Return whether the representation is from latest version - - Args: - representation (dict): The representation document from the database. - - Returns: - bool: Whether the representation is of latest version. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - from openpype.pipeline.context_tools import is_representation_from_latest - - return is_representation_from_latest(representation) - - -@deprecated("openpype.pipeline.load.any_outdated_containers") -def any_outdated(): - """Return whether the current scene has any outdated content. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - from openpype.pipeline.load import any_outdated_containers - - return any_outdated_containers() - - -@deprecated("openpype.pipeline.context_tools.get_current_project_asset") -def get_asset(asset_name=None): - """ Returning asset document from database by its name. - - Doesn't count with duplicities on asset names! - - Args: - asset_name (str) - - Returns: - (MongoDB document) - - Deprecated: - Function will be removed after release version 3.15.* - """ - - from openpype.pipeline.context_tools import get_current_project_asset - - return get_current_project_asset(asset_name=asset_name) - - -@deprecated("openpype.pipeline.template_data.get_general_template_data") -def get_system_general_anatomy_data(system_settings=None): - """ - Deprecated: - Function will be removed after release version 3.15.* - """ - from openpype.pipeline.template_data import get_general_template_data - - return get_general_template_data(system_settings) - - @deprecated("openpype.client.get_linked_asset_ids") def get_linked_asset_ids(asset_doc): """Return linked asset ids for `asset_doc` from DB @@ -214,66 +144,6 @@ def get_linked_asset_ids(asset_doc): return get_linked_asset_ids(project_name, asset_doc=asset_doc) -@deprecated("openpype.client.get_linked_assets") -def get_linked_assets(asset_doc): - """Return linked assets for `asset_doc` from DB - - Args: - asset_doc (dict): Asset document from DB - - Returns: - (list) Asset documents of input links for passed asset doc. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - from openpype.pipeline import legacy_io - from openpype.client import get_linked_assets - - project_name = legacy_io.active_project() - - return get_linked_assets(project_name, asset_doc=asset_doc) - - -@deprecated("openpype.client.get_last_version_by_subset_name") -def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): - """Retrieve latest version from `asset_name`, and `subset_name`. - - Do not use if you want to query more than 5 latest versions as this method - query 3 times to mongo for each call. For those cases is better to use - more efficient way, e.g. with help of aggregations. - - Args: - asset_name (str): Name of asset. - subset_name (str): Name of subset. - dbcon (AvalonMongoDB, optional): Avalon Mongo connection with Session. - project_name (str, optional): Find latest version in specific project. - - Returns: - None: If asset, subset or version were not found. - dict: Last version document for entered. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - if not project_name: - if not dbcon: - from openpype.pipeline import legacy_io - - log.debug("Using `legacy_io` for query.") - dbcon = legacy_io - # Make sure is installed - dbcon.install() - - project_name = dbcon.active_project() - - return get_last_version_by_subset_name( - project_name, subset_name, asset_name=asset_name - ) - - @deprecated( "openpype.pipeline.workfile.get_workfile_template_key_from_context") def get_workfile_template_key_from_context( @@ -361,142 +231,6 @@ def get_workfile_template_key( ) -@deprecated("openpype.pipeline.template_data.get_template_data") -def get_workdir_data(project_doc, asset_doc, task_name, host_name): - """Prepare data for workdir template filling from entered information. - - Args: - project_doc (dict): Mongo document of project from MongoDB. - asset_doc (dict): Mongo document of asset from MongoDB. - task_name (str): Task name for which are workdir data preapred. - host_name (str): Host which is used to workdir. This is required - because workdir template may contain `{app}` key. - - Returns: - dict: Data prepared for filling workdir template. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - from openpype.pipeline.template_data import get_template_data - - return get_template_data( - project_doc, asset_doc, task_name, host_name - ) - - -@deprecated("openpype.pipeline.workfile.get_workdir_with_workdir_data") -def get_workdir_with_workdir_data( - workdir_data, anatomy=None, project_name=None, template_key=None -): - """Fill workdir path from entered data and project's anatomy. - - It is possible to pass only project's name instead of project's anatomy but - one of them **must** be entered. It is preferred to enter anatomy if is - available as initialization of a new Anatomy object may be time consuming. - - Args: - workdir_data (dict): Data to fill workdir template. - anatomy (Anatomy): Anatomy object for specific project. Optional if - `project_name` is entered. - project_name (str): Project's name. Optional if `anatomy` is entered - otherwise Anatomy object is created with using the project name. - template_key (str): Key of work templates in anatomy templates. If not - passed `get_workfile_template_key_from_context` is used to get it. - dbcon(AvalonMongoDB): Mongo connection. Required only if 'template_key' - and 'project_name' are not passed. - - Returns: - TemplateResult: Workdir path. - - Raises: - ValueError: When both `anatomy` and `project_name` are set to None. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - if not anatomy and not project_name: - raise ValueError(( - "Missing required arguments one of `project_name` or `anatomy`" - " must be entered." - )) - - if not project_name: - project_name = anatomy.project_name - - from openpype.pipeline.workfile import get_workdir_with_workdir_data - - return get_workdir_with_workdir_data( - workdir_data, project_name, anatomy, template_key - ) - - -@deprecated("openpype.pipeline.workfile.get_workdir_with_workdir_data") -def get_workdir( - project_doc, - asset_doc, - task_name, - host_name, - anatomy=None, - template_key=None -): - """Fill workdir path from entered data and project's anatomy. - - Args: - project_doc (dict): Mongo document of project from MongoDB. - asset_doc (dict): Mongo document of asset from MongoDB. - task_name (str): Task name for which are workdir data preapred. - host_name (str): Host which is used to workdir. This is required - because workdir template may contain `{app}` key. In `Session` - is stored under `AVALON_APP` key. - anatomy (Anatomy): Optional argument. Anatomy object is created using - project name from `project_doc`. It is preferred to pass this - argument as initialization of a new Anatomy object may be time - consuming. - template_key (str): Key of work templates in anatomy templates. Default - value is defined in `get_workdir_with_workdir_data`. - - Returns: - TemplateResult: Workdir path. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - from openpype.pipeline.workfile import get_workdir - # Output is TemplateResult object which contain useful data - return get_workdir( - project_doc, - asset_doc, - task_name, - host_name, - anatomy, - template_key - ) - - -@deprecated("openpype.pipeline.context_tools.get_template_data_from_session") -def template_data_from_session(session=None): - """ Return dictionary with template from session keys. - - Args: - session (dict, Optional): The Session to use. If not provided use the - currently active global Session. - - Returns: - dict: All available data from session. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - from openpype.pipeline.context_tools import get_template_data_from_session - - return get_template_data_from_session(session) - - @deprecated("openpype.pipeline.context_tools.compute_session_changes") def compute_session_changes( session, task=None, asset=None, app=None, template_key=None @@ -588,133 +322,6 @@ def update_current_task(task=None, asset=None, app=None, template_key=None): return change_current_context(asset, task, template_key) -@deprecated("openpype.client.get_workfile_info") -def get_workfile_doc(asset_id, task_name, filename, dbcon=None): - """Return workfile document for entered context. - - Do not use this method to get more than one document. In that cases use - custom query as this will return documents from database one by one. - - Args: - asset_id (ObjectId): Mongo ID of an asset under which workfile belongs. - task_name (str): Name of task under which the workfile belongs. - filename (str): Name of a workfile. - dbcon (AvalonMongoDB): Optionally enter avalon AvalonMongoDB object and - `legacy_io` is used if not entered. - - Returns: - dict: Workfile document or None. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - # Use legacy_io if dbcon is not entered - if not dbcon: - from openpype.pipeline import legacy_io - dbcon = legacy_io - - project_name = dbcon.active_project() - return get_workfile_info(project_name, asset_id, task_name, filename) - - -@deprecated -def create_workfile_doc(asset_doc, task_name, filename, workdir, dbcon=None): - """Creates or replace workfile document in mongo. - - Do not use this method to update data. This method will remove all - additional data from existing document. - - Args: - asset_doc (dict): Document of asset under which workfile belongs. - task_name (str): Name of task for which is workfile related to. - filename (str): Filename of workfile. - workdir (str): Path to directory where `filename` is located. - dbcon (AvalonMongoDB): Optionally enter avalon AvalonMongoDB object and - `legacy_io` is used if not entered. - """ - - from openpype.pipeline import Anatomy - from openpype.pipeline.template_data import get_template_data - - # Use legacy_io if dbcon is not entered - if not dbcon: - from openpype.pipeline import legacy_io - dbcon = legacy_io - - # Filter of workfile document - doc_filter = { - "type": "workfile", - "parent": asset_doc["_id"], - "task_name": task_name, - "filename": filename - } - # Document data are copy of filter - doc_data = copy.deepcopy(doc_filter) - - # Prepare project for workdir data - project_name = dbcon.active_project() - project_doc = get_project(project_name) - workdir_data = get_template_data( - project_doc, asset_doc, task_name, dbcon.Session["AVALON_APP"] - ) - # Prepare anatomy - anatomy = Anatomy(project_name) - # Get workdir path (result is anatomy.TemplateResult) - template_workdir = get_workdir_with_workdir_data( - workdir_data, anatomy - ) - template_workdir_path = str(template_workdir).replace("\\", "/") - - # Replace slashses in workdir path where workfile is located - mod_workdir = workdir.replace("\\", "/") - - # Replace workdir from templates with rootless workdir - rootles_workdir = mod_workdir.replace( - template_workdir_path, - template_workdir.rootless.replace("\\", "/") - ) - - doc_data["schema"] = "pype:workfile-1.0" - doc_data["files"] = ["/".join([rootles_workdir, filename])] - doc_data["data"] = {} - - dbcon.replace_one( - doc_filter, - doc_data, - upsert=True - ) - - -@deprecated -def save_workfile_data_to_doc(workfile_doc, data, dbcon=None): - if not workfile_doc: - # TODO add log message - return - - if not data: - return - - # Use legacy_io if dbcon is not entered - if not dbcon: - from openpype.pipeline import legacy_io - dbcon = legacy_io - - # Convert data to mongo modification keys/values - # - this is naive implementation which does not expect nested - # dictionaries - set_data = {} - for key, value in data.items(): - new_key = "data.{}".format(key) - set_data[new_key] = value - - # Update workfile document with data - dbcon.update_one( - {"_id": workfile_doc["_id"]}, - {"$set": set_data} - ) - - @deprecated("openpype.pipeline.workfile.BuildWorkfile") def BuildWorkfile(): """Build workfile class was moved to workfile pipeline. @@ -747,38 +354,6 @@ def get_creator_by_name(creator_name, case_sensitive=False): return get_legacy_creator_by_name(creator_name, case_sensitive) -@deprecated -def change_timer_to_current_context(): - """Called after context change to change timers. - - Deprecated: - This method is specific for TimersManager module so please use the - functionality from there. Function will be removed after release - version 3.15.* - """ - - from openpype.pipeline import legacy_io - - webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") - if not webserver_url: - log.warning("Couldn't find webserver url") - return - - rest_api_url = "{}/timers_manager/start_timer".format(webserver_url) - try: - import requests - except Exception: - log.warning("Couldn't start timer") - return - data = { - "project_name": legacy_io.Session["AVALON_PROJECT"], - "asset_name": legacy_io.Session["AVALON_ASSET"], - "task_name": legacy_io.Session["AVALON_TASK"] - } - - requests.post(rest_api_url, json=data) - - def _get_task_context_data_for_anatomy( project_doc, asset_doc, task_name, anatomy=None ): @@ -800,6 +375,8 @@ def _get_task_context_data_for_anatomy( dict: With Anatomy context data. """ + from openpype.pipeline.template_data import get_general_template_data + if anatomy is None: from openpype.pipeline import Anatomy anatomy = Anatomy(project_doc["name"]) @@ -840,7 +417,7 @@ def _get_task_context_data_for_anatomy( } } - system_general_data = get_system_general_anatomy_data() + system_general_data = get_general_template_data() data.update(system_general_data) return data diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index f1f2a4fa0a..759a4db0cb 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -81,11 +81,14 @@ def run_subprocess(*args, **kwargs): Entered arguments and keyword arguments are passed to subprocess Popen. + On windows are 'creationflags' filled with flags that should cause ignore + creation of new window. + Args: - *args: Variable length arument list passed to Popen. + *args: Variable length argument list passed to Popen. **kwargs : Arbitrary keyword arguments passed to Popen. Is possible to - pass `logging.Logger` object under "logger" if want to use - different than lib's logger. + pass `logging.Logger` object under "logger" to use custom logger + for output. Returns: str: Full output of subprocess concatenated stdout and stderr. @@ -95,6 +98,17 @@ def run_subprocess(*args, **kwargs): return code. """ + # Modify creation flags on windows to hide console window if in UI mode + if ( + platform.system().lower() == "windows" + and "creationflags" not in kwargs + ): + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) + # Get environents from kwarg or use current process environments if were # not passed. env = kwargs.get("env") or os.environ @@ -107,22 +121,22 @@ def run_subprocess(*args, **kwargs): logger = Logger.get_logger("run_subprocess") # set overrides - kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE) - kwargs['stderr'] = kwargs.get('stderr', subprocess.PIPE) - kwargs['stdin'] = kwargs.get('stdin', subprocess.PIPE) - kwargs['env'] = filtered_env + kwargs["stdout"] = kwargs.get("stdout", subprocess.PIPE) + kwargs["stderr"] = kwargs.get("stderr", subprocess.PIPE) + kwargs["stdin"] = kwargs.get("stdin", subprocess.PIPE) + kwargs["env"] = filtered_env proc = subprocess.Popen(*args, **kwargs) full_output = "" _stdout, _stderr = proc.communicate() if _stdout: - _stdout = _stdout.decode("utf-8") + _stdout = _stdout.decode("utf-8", errors="backslashreplace") full_output += _stdout logger.debug(_stdout) if _stderr: - _stderr = _stderr.decode("utf-8") + _stderr = _stderr.decode("utf-8", errors="backslashreplace") # Add additional line break if output already contains stdout if full_output: full_output += "\n" diff --git a/openpype/lib/file_transaction.py b/openpype/lib/file_transaction.py index cba361a8d4..fe70b37cb1 100644 --- a/openpype/lib/file_transaction.py +++ b/openpype/lib/file_transaction.py @@ -189,6 +189,6 @@ class FileTransaction(object): def _same_paths(self, src, dst): # handles same paths but with C:/project vs c:/project if os.path.exists(src) and os.path.exists(dst): - return os.path.samefile(src, dst) + return os.stat(src) == os.stat(dst) return src == dst diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 1e157dfbfd..10fd3940b8 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -8,7 +8,6 @@ import warnings import functools from openpype.client import get_asset_by_id -from openpype.settings import get_project_settings log = logging.getLogger(__name__) @@ -101,8 +100,6 @@ def get_subset_name_with_asset_doc( is not passed. dynamic_data (dict): Dynamic data specific for a creator which creates instance. - dbcon (AvalonMongoDB): Mongo connection to be able query asset document - if 'asset_doc' is not passed. """ from openpype.pipeline.create import get_subset_name @@ -202,122 +199,6 @@ def prepare_template_data(fill_pairs): return fill_data -@deprecated("openpype.pipeline.publish.lib.filter_pyblish_plugins") -def filter_pyblish_plugins(plugins): - """Filter pyblish plugins by presets. - - This servers as plugin filter / modifier for pyblish. It will load plugin - definitions from presets and filter those needed to be excluded. - - Args: - plugins (dict): Dictionary of plugins produced by :mod:`pyblish-base` - `discover()` method. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - from openpype.pipeline.publish.lib import filter_pyblish_plugins - - filter_pyblish_plugins(plugins) - - -@deprecated -def set_plugin_attributes_from_settings( - plugins, superclass, host_name=None, project_name=None -): - """Change attribute values on Avalon plugins by project settings. - - This function should be used only in host context. Modify - behavior of plugins. - - Args: - plugins (list): Plugins discovered by origin avalon discover method. - superclass (object): Superclass of plugin type (e.g. Cretor, Loader). - host_name (str): Name of host for which plugins are loaded and from. - Value from environment `AVALON_APP` is used if not entered. - project_name (str): Name of project for which settings will be loaded. - Value from environment `AVALON_PROJECT` is used if not entered. - - Deprecated: - Function will be removed after release version 3.15.* - """ - - # Function is not used anymore - from openpype.pipeline import LegacyCreator, LoaderPlugin - - # determine host application to use for finding presets - if host_name is None: - host_name = os.environ.get("AVALON_APP") - - if project_name is None: - project_name = os.environ.get("AVALON_PROJECT") - - # map plugin superclass to preset json. Currently supported is load and - # create (LoaderPlugin and LegacyCreator) - plugin_type = None - if superclass is LoaderPlugin or issubclass(superclass, LoaderPlugin): - plugin_type = "load" - elif superclass is LegacyCreator or issubclass(superclass, LegacyCreator): - plugin_type = "create" - - if not host_name or not project_name or plugin_type is None: - msg = "Skipped attributes override from settings." - if not host_name: - msg += " Host name is not defined." - - if not project_name: - msg += " Project name is not defined." - - if plugin_type is None: - msg += " Plugin type is unsupported for class {}.".format( - superclass.__name__ - ) - - print(msg) - return - - print(">>> Finding presets for {}:{} ...".format(host_name, plugin_type)) - - project_settings = get_project_settings(project_name) - plugin_type_settings = ( - project_settings - .get(host_name, {}) - .get(plugin_type, {}) - ) - global_type_settings = ( - project_settings - .get("global", {}) - .get(plugin_type, {}) - ) - if not global_type_settings and not plugin_type_settings: - return - - for plugin in plugins: - plugin_name = plugin.__name__ - - plugin_settings = None - # Look for plugin settings in host specific settings - if plugin_name in plugin_type_settings: - plugin_settings = plugin_type_settings[plugin_name] - - # Look for plugin settings in global settings - elif plugin_name in global_type_settings: - plugin_settings = global_type_settings[plugin_name] - - if not plugin_settings: - continue - - print(">>> We have preset for {}".format(plugin_name)) - for option, value in plugin_settings.items(): - if option == "enabled" and value is False: - setattr(plugin, "active", False) - print(" - is disabled by preset") - else: - setattr(plugin, option, value) - print(" - setting `{}`: `{}`".format(option, value)) - - def source_hash(filepath, *args): """Generate simple identifier for a source file. This is used to identify whether a source file has previously been diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index 6fad3b547f..9e8e94842c 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -28,6 +28,7 @@ def import_filepath(filepath, module_name=None): # Prepare module object where content of file will be parsed module = types.ModuleType(module_name) + module.__file__ = filepath if six.PY3: # Use loader so module has full specs @@ -41,7 +42,6 @@ def import_filepath(filepath, module_name=None): # Execute content and store it to module object six.exec_(_stream.read(), module.__dict__) - module.__file__ = filepath return module diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 57279d0380..799693554f 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -5,6 +5,7 @@ import json import collections import tempfile import subprocess +import platform import xml.etree.ElementTree @@ -745,11 +746,18 @@ def get_ffprobe_data(path_to_file, logger=None): logger.debug("FFprobe command: {}".format( subprocess.list2cmdline(args) )) - popen = subprocess.Popen( - args, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + kwargs = { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + } + if platform.system().lower() == "windows": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) + + popen = subprocess.Popen(args, **kwargs) popen_stdout, popen_stderr = popen.communicate() if popen_stdout: @@ -1037,3 +1045,90 @@ def convert_ffprobe_fps_to_float(value): if divisor == 0.0: return 0.0 return dividend / divisor + + +def convert_colorspace( + input_path, + output_path, + config_path, + source_colorspace, + target_colorspace=None, + view=None, + display=None, + additional_command_args=None, + logger=None +): + """Convert source file from one color space to another. + + Args: + input_path (str): Path that should be converted. It is expected that + contains single file or image sequence of same type + (sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs, + eg `big.1-3#.tif`) + output_path (str): Path to output filename. + (must follow format of 'input_path', eg. single file or + sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`) + config_path (str): path to OCIO config file + source_colorspace (str): ocio valid color space of source files + target_colorspace (str): ocio valid target color space + if filled, 'view' and 'display' must be empty + view (str): name for viewer space (ocio valid) + both 'view' and 'display' must be filled (if 'target_colorspace') + display (str): name for display-referred reference space (ocio valid) + additional_command_args (list): arguments for oiiotool (like binary + depth for .dpx) + logger (logging.Logger): Logger used for logging. + Raises: + ValueError: if misconfigured + """ + if logger is None: + logger = logging.getLogger(__name__) + + oiio_cmd = [ + get_oiio_tools_path(), + input_path, + # Don't add any additional attributes + "--nosoftwareattrib", + "--colorconfig", config_path + ] + + if all([target_colorspace, view, display]): + raise ValueError("Colorspace and both screen and display" + " cannot be set together." + "Choose colorspace or screen and display") + if not target_colorspace and not all([view, display]): + raise ValueError("Both screen and display must be set.") + + if additional_command_args: + oiio_cmd.extend(additional_command_args) + + if target_colorspace: + oiio_cmd.extend(["--colorconvert", + source_colorspace, + target_colorspace]) + if view and display: + oiio_cmd.extend(["--iscolorspace", source_colorspace]) + oiio_cmd.extend(["--ociodisplay", display, view]) + + oiio_cmd.extend(["-o", output_path]) + + logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) + run_subprocess(oiio_cmd, logger=logger) + + +def split_cmd_args(in_args): + """Makes sure all entered arguments are separated in individual items. + + Split each argument string with " -" to identify if string contains + one or more arguments. + Args: + in_args (list): of arguments ['-n', '-d uint10'] + Returns + (list): ['-n', '-d', 'unint10'] + """ + splitted_args = [] + for arg in in_args: + if not arg.strip(): + continue + splitted_args.extend(arg.split(" ")) + return splitted_args diff --git a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py index f26047bb9d..83dd5b49e2 100644 --- a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py @@ -12,6 +12,7 @@ from openpype.pipeline import legacy_io from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build @attr.s @@ -87,9 +88,13 @@ class AfterEffectsSubmitDeadline( "AVALON_APP_NAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS", - "OPENPYPE_VERSION", "IS_TEST" ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index 425883393f..84fca11d9d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -14,6 +14,7 @@ from openpype.pipeline import legacy_io from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build class _ZipFile(ZipFile): @@ -279,10 +280,14 @@ class HarmonySubmitDeadline( "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", - "OPENPYPE_LOG_NO_COLORS", - "OPENPYPE_VERSION", + "OPENPYPE_LOG_NO_COLORS" "IS_TEST" ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py index 6a62f83cae..68aa653804 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py @@ -9,6 +9,7 @@ import pyblish.api from openpype.pipeline import legacy_io from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): @@ -133,9 +134,13 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): # Submit along the current Avalon tool setup that we launched # this application with so the Render Slave can build its own # similar environment using it, e.g. "houdini17.5;pluginx2.3" - "AVALON_TOOLS", - "OPENPYPE_VERSION" + "AVALON_TOOLS" ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + # Add mongo url if it's enabled if context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index 2b17b644b8..73ab689c9a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -10,6 +10,7 @@ import pyblish.api from openpype.pipeline import legacy_io from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin): @@ -105,9 +106,13 @@ class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin): # Submit along the current Avalon tool setup that we launched # this application with so the Render Slave can build its own # similar environment using it, e.g. "maya2018;vray4.x;yeti3.1.9" - "AVALON_TOOLS", - "OPENPYPE_VERSION" + "AVALON_TOOLS" ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + # Add mongo url if it's enabled if context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py new file mode 100644 index 0000000000..417a03de74 --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -0,0 +1,218 @@ +import os +import getpass +import copy + +import attr +from openpype.pipeline import legacy_io +from openpype.settings import get_project_settings +from openpype.hosts.max.api.lib import ( + get_current_renderer, + get_multipass_setting +) +from openpype.hosts.max.api.lib_rendersettings import RenderSettings +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo + + +@attr.s +class MaxPluginInfo(object): + SceneFile = attr.ib(default=None) # Input + Version = attr.ib(default=None) # Mandatory for Deadline + SaveFile = attr.ib(default=True) + IgnoreInputs = attr.ib(default=True) + + +class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): + + label = "Submit Render to Deadline" + hosts = ["max"] + families = ["maxrender"] + targets = ["local"] + + use_published = True + priority = 50 + tile_priority = 50 + chunk_size = 1 + jobInfo = {} + pluginInfo = {} + group = None + deadline_pool = None + deadline_pool_secondary = None + framePerTask = 1 + + def get_job_info(self): + job_info = DeadlineJobInfo(Plugin="3dsmax") + + # todo: test whether this works for existing production cases + # where custom jobInfo was stored in the project settings + job_info.update(self.jobInfo) + + instance = self._instance + context = instance.context + + # Always use the original work file name for the Job name even when + # rendering is done from the published Work File. The original work + # file name is clearer because it can also have subversion strings, + # etc. which are stripped for the published file. + src_filepath = context.data["currentFile"] + src_filename = os.path.basename(src_filepath) + + job_info.Name = "%s - %s" % (src_filename, instance.name) + job_info.BatchName = src_filename + job_info.Plugin = instance.data["plugin"] + job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) + + # Deadline requires integers in frame range + frames = "{start}-{end}".format( + start=int(instance.data["frameStart"]), + end=int(instance.data["frameEnd"]) + ) + job_info.Frames = frames + + 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 + + # Add options from RenderGlobals + render_globals = instance.data.get("renderGlobals", {}) + job_info.update(render_globals) + + keys = [ + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "OPENPYPE_SG_USER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV", + "OPENPYPE_VERSION", + "IS_TEST" + ] + # Add mongo url if it's enabled + if self._instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") + + environment = dict({key: os.environ[key] for key in keys + if key in os.environ}, **legacy_io.Session) + + for key in keys: + value = environment.get(key) + if not value: + continue + job_info.EnvironmentKeyValue[key] = value + + # to recognize job from PYPE for turning Event On/Off + job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" + job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" + + # Add list of expected files to job + # --------------------------------- + exp = instance.data.get("expectedFiles") + for filepath in exp: + job_info.OutputDirectory += os.path.dirname(filepath) + job_info.OutputFilename += os.path.basename(filepath) + + return job_info + + def get_plugin_info(self): + instance = self._instance + + plugin_info = MaxPluginInfo( + SceneFile=self.scene_path, + Version=instance.data["maxversion"], + SaveFile=True, + IgnoreInputs=True + ) + + plugin_payload = attr.asdict(plugin_info) + + # Patching with pluginInfo from settings + for key, value in self.pluginInfo.items(): + plugin_payload[key] = value + + return plugin_payload + + def process_submission(self): + + instance = self._instance + filepath = self.scene_path + + expected_files = instance.data["expectedFiles"] + if not expected_files: + raise RuntimeError("No Render Elements found!") + output_dir = os.path.dirname(expected_files[0]) + instance.data["outputDir"] = output_dir + instance.data["toBeRenderedOn"] = "deadline" + + filename = os.path.basename(filepath) + + payload_data = { + "filename": filename, + "dirname": output_dir + } + + self.log.debug("Submitting 3dsMax render..") + payload = self._use_published_name(payload_data) + job_info, plugin_info = payload + self.submit(self.assemble_payload(job_info, plugin_info)) + + def _use_published_name(self, data): + instance = self._instance + job_info = copy.deepcopy(self.job_info) + plugin_info = copy.deepcopy(self.plugin_info) + plugin_data = {} + project_setting = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + + multipass = get_multipass_setting(project_setting) + if multipass: + plugin_data["DisableMultipass"] = 0 + else: + plugin_data["DisableMultipass"] = 1 + + expected_files = instance.data.get("expectedFiles") + if not expected_files: + raise RuntimeError("No render elements found") + old_output_dir = os.path.dirname(expected_files[0]) + output_beauty = RenderSettings().get_render_output(instance.name, + old_output_dir) + filepath = self.from_published_scene() + + def _clean_name(path): + return os.path.splitext(os.path.basename(path))[0] + + new_scene = _clean_name(filepath) + orig_scene = _clean_name(instance.context.data["currentFile"]) + + output_beauty = output_beauty.replace(orig_scene, new_scene) + output_beauty = output_beauty.replace("\\", "/") + plugin_data["RenderOutput"] = output_beauty + + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + if renderer in [ + "ART_Renderer", + "Redshift_Renderer", + "V_Ray_6_Hotfix_3", + "V_Ray_GPU_6_Hotfix_3", + "Default_Scanline_Renderer", + "Quicksilver_Hardware_Renderer", + ]: + render_elem_list = RenderSettings().get_render_element() + for i, element in enumerate(render_elem_list): + element = element.replace(orig_scene, new_scene) + plugin_data["RenderElementOutputFilename%d" % i] = element # noqa + + self.log.debug("plugin data:{}".format(plugin_data)) + plugin_info.update(plugin_data) + + return job_info, plugin_info diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 070d4eab18..15025e47f2 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -38,6 +38,7 @@ from openpype.hosts.maya.api.lib import get_attr_in_layer from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build def _validate_deadline_bool_value(instance, attribute, value): @@ -64,6 +65,7 @@ class MayaPluginInfo(object): # Include all lights flag RenderSetupIncludeLights = attr.ib( default="1", validator=_validate_deadline_bool_value) + StrictErrorChecking = attr.ib(default=True) @attr.s @@ -164,10 +166,14 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", - "OPENPYPE_DEV", - "OPENPYPE_VERSION", + "OPENPYPE_DEV" "IS_TEST" ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") @@ -219,6 +225,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): "renderSetupIncludeLights", default_rs_include_lights) if rs_include_lights not in {"1", "0", True, False}: rs_include_lights = default_rs_include_lights + strict_error_checking = instance.data.get("strict_error_checking", + True) plugin_info = MayaPluginInfo( SceneFile=self.scene_path, Version=cmds.about(version=True), @@ -227,6 +235,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): RenderSetupIncludeLights=rs_include_lights, # noqa ProjectPath=context.data["workspaceDir"], UsingRenderLayers=True, + StrictErrorChecking=strict_error_checking ) plugin_payload = attr.asdict(plugin_info) @@ -410,8 +419,13 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): assembly_job_info.Name += " - Tile Assembly Job" assembly_job_info.Frames = 1 assembly_job_info.MachineLimit = 1 - assembly_job_info.Priority = instance.data.get("tile_priority", - self.tile_priority) + assembly_job_info.Priority = instance.data.get( + "tile_priority", self.tile_priority + ) + + pool = instance.context.data["project_settings"]["deadline"] + pool = pool["publish"]["ProcessSubmittedJobOnFarm"]["deadline_pool"] + assembly_job_info.Pool = pool or instance.data.get("primaryPool", "") assembly_plugin_info = { "CleanupTiles": 1, diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index bab6591c7f..25f859554f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -7,6 +7,7 @@ from maya import cmds from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.settings import get_project_settings from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build import pyblish.api @@ -104,9 +105,13 @@ class MayaSubmitRemotePublishDeadline(pyblish.api.InstancePlugin): keys = [ "FTRACK_API_USER", "FTRACK_API_KEY", - "FTRACK_SERVER", - "OPENPYPE_VERSION" + "FTRACK_SERVER" ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index d1948d8d50..faa66effbd 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -10,6 +10,7 @@ import pyblish.api import nuke from openpype.pipeline import legacy_io from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build class NukeSubmitDeadline(pyblish.api.InstancePlugin): @@ -266,8 +267,13 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): "NUKE_PATH", "TOOL_ENV", "FOUNDRY_LICENSE", - "OPENPYPE_VERSION" + "OPENPYPE_SG_USER", ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + # Add mongo url if it's enabled if instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 7e39a644a2..53c09ad22f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -20,6 +20,7 @@ from openpype.pipeline import ( ) from openpype.tests.lib import is_in_tests from openpype.pipeline.farm.patterning import match_aov_pattern +from openpype.lib import is_running_from_build def get_resources(project_name, version, extension=None): @@ -117,15 +118,17 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): deadline_plugin = "OpenPype" targets = ["local"] - hosts = ["fusion", "maya", "nuke", "celaction", "aftereffects", "harmony"] + hosts = ["fusion", "max", "maya", "nuke", + "celaction", "aftereffects", "harmony"] families = ["render.farm", "prerender.farm", - "renderlayer", "imagesequence", "vrayscene"] + "renderlayer", "imagesequence", "maxrender", "vrayscene"] aov_filter = {"maya": [r".*([Bb]eauty).*"], "aftereffects": [r".*"], # for everything from AE "harmony": [r".*"], # for everything from AE - "celaction": [r".*"]} + "celaction": [r".*"], + "max": [r".*"]} environ_job_filter = [ "OPENPYPE_METADATA_FILE" @@ -137,9 +140,13 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", - "OPENPYPE_VERSION" + "OPENPYPE_SG_USER", ] + # Add OpenPype version if we are running from build. + if is_running_from_build(): + environ_keys.append("OPENPYPE_VERSION") + # custom deadline attributes deadline_department = "" deadline_pool = "" @@ -188,7 +195,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): metadata_path = os.path.join(output_dir, metadata_filename) # Convert output dir to `{root}/rest/of/path/...` with Anatomy - success, roothless_mtdt_p = self.anatomy.find_root_template_from_path( + success, rootless_mtdt_p = self.anatomy.find_root_template_from_path( metadata_path) if not success: # `rootless_path` is not set to `output_dir` if none of roots match @@ -196,9 +203,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "Could not find root path for remapping \"{}\"." " This may cause issues on farm." ).format(output_dir)) - roothless_mtdt_p = metadata_path + rootless_mtdt_p = metadata_path - return metadata_path, roothless_mtdt_p + return metadata_path, rootless_mtdt_p def _submit_deadline_post_job(self, instance, job, instances): """Submit publish job to Deadline. @@ -231,7 +238,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # Transfer the environment from the original job to this dependent # job so they use the same environment - metadata_path, roothless_metadata_path = \ + metadata_path, rootless_metadata_path = \ self._create_metadata_path(instance) environment = { @@ -268,7 +275,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): args = [ "--headless", 'publish', - roothless_metadata_path, + rootless_metadata_path, "--targets", "deadline", "--targets", "farm" ] @@ -277,6 +284,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): args.append("--automatic-tests") # Generate the payload for Deadline submission + secondary_pool = ( + self.deadline_pool_secondary or instance.data.get("secondaryPool") + ) payload = { "JobInfo": { "Plugin": self.deadline_plugin, @@ -290,10 +300,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "Priority": priority, "Group": self.deadline_group, - "Pool": instance.data.get("primaryPool"), - "SecondaryPool": instance.data.get("secondaryPool"), - - "OutputDirectory0": output_dir + "Pool": self.deadline_pool or instance.data.get("primaryPool"), + "SecondaryPool": secondary_pool, + # ensure the outputdirectory with correct slashes + "OutputDirectory0": output_dir.replace("\\", "/") }, "PluginInfo": { "Version": self.plugin_pype_version, @@ -405,7 +415,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): assert fn is not None, "padding string wasn't found" # list of tuples (source, destination) staging = representation.get("stagingDir") - staging = self.anatomy.fill_roots(staging) + staging = self.anatomy.fill_root(staging) resource_files.append( (frame, os.path.join(staging, @@ -427,7 +437,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): self.log.info( "Finished copying %i files" % len(resource_files)) - def _create_instances_for_aov(self, instance_data, exp_files): + def _create_instances_for_aov( + self, instance_data, exp_files, additional_data + ): """Create instance for each AOV found. This will create new instance for every aov it can detect in expected @@ -514,6 +526,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # toggle preview on if multipart is on if instance_data.get("multipartExr"): + self.log.debug("Adding preview tag because its multipartExr") preview = True self.log.debug("preview:{}".format(preview)) new_instance = deepcopy(instance_data) @@ -528,6 +541,14 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): else: files = os.path.basename(col) + # Copy render product "colorspace" data to representation. + colorspace = "" + products = additional_data["renderProducts"].layer_data.products + for product in products: + if product.productName == aov: + colorspace = product.colorspace + break + rep = { "name": ext, "ext": ext, @@ -537,7 +558,16 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # If expectedFile are absolute, we need only filenames "stagingDir": staging, "fps": new_instance.get("fps"), - "tags": ["review"] if preview else [] + "tags": ["review"] if preview else [], + "colorspaceData": { + "colorspace": colorspace, + "config": { + "path": additional_data["colorspaceConfig"], + "template": additional_data["colorspaceTemplate"] + }, + "display": additional_data["display"], + "view": additional_data["view"] + } } # support conversion from tiled to scanline @@ -581,7 +611,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): host_name = os.environ.get("AVALON_APP", "") collections, remainders = clique.assemble(exp_files) - # create representation for every collected sequento ce + # create representation for every collected sequence for collection in collections: ext = collection.tail.lstrip(".") preview = False @@ -593,6 +623,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if instance["useSequenceForReview"]: # toggle preview on if multipart is on if instance.get("multipartExr", False): + self.log.debug( + "Adding preview tag because its multipartExr" + ) preview = True else: render_file_name = list(collection)[0] @@ -646,7 +679,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): self._solve_families(instance, preview) - # add reminders as representations + # add remainders as representations for remainder in remainders: ext = remainder.split(".")[-1] @@ -666,7 +699,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "name": ext, "ext": ext, "files": os.path.basename(remainder), - "stagingDir": os.path.dirname(remainder), + "stagingDir": staging, } preview = match_aov_pattern( @@ -700,8 +733,14 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if preview: if "ftrack" not in families: if os.environ.get("FTRACK_SERVER"): + self.log.debug( + "Adding \"ftrack\" to families because of preview tag." + ) families.append("ftrack") if "review" not in families: + self.log.debug( + "Adding \"review\" to families because of preview tag." + ) families.append("review") instance["families"] = families @@ -901,6 +940,17 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # we cannot attach AOVs to other subsets as we consider every # AOV subset of its own. + config = instance.data["colorspaceConfig"] + additional_data = { + "renderProducts": instance.data["renderProducts"], + "colorspaceConfig": instance.data["colorspaceConfig"], + "display": instance.data["colorspaceDisplay"], + "view": instance.data["colorspaceView"], + "colorspaceTemplate": config.replace( + str(context.data["anatomy"].roots["work"]), "{root[work]}" + ) + } + if len(data.get("attachTo")) > 0: assert len(data.get("expectedFiles")[0].keys()) == 1, ( "attaching multiple AOVs or renderable cameras to " @@ -911,7 +961,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # there are multiple renderable cameras in scene) instances = self._create_instances_for_aov( instance_skeleton_data, - data.get("expectedFiles")) + data.get("expectedFiles"), + additional_data + ) self.log.info("got {} instance{}".format( len(instances), "s" if len(instances) > 1 else "")) @@ -960,6 +1012,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): ''' render_job = None + submission_type = "" if instance.data.get("toBeRenderedOn") == "deadline": render_job = data.pop("deadlineSubmissionJob", None) submission_type = "deadline" @@ -1043,7 +1096,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): } publish_job.update({"ftrack": ftrack}) - metadata_path, roothless_metadata_path = self._create_metadata_path( + metadata_path, rootless_metadata_path = self._create_metadata_path( instance) self.log.info("Writing json file: {}".format(metadata_path)) 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 f0a3ddd246..f34f71d213 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 @@ -91,7 +91,7 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): for job_id in render_job_ids: job_info = self._get_job_info(job_id) - frame_list = job_info["Props"]["Frames"] + frame_list = job_info["Props"].get("Frames") if frame_list: all_frame_lists.extend(frame_list.split(',')) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 984590ddba..20a58c9131 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -1,3 +1,4 @@ +# /usr/bin/env python3 # -*- coding: utf-8 -*- import os import tempfile @@ -35,7 +36,7 @@ class OpenPypeVersion: self.prerelease = prerelease is_valid = True - if not major or not minor or not patch: + if major is None or minor is None or patch is None: is_valid = False self.is_valid = is_valid @@ -157,7 +158,7 @@ def get_openpype_version_from_path(path, build=True): # fix path for application bundle on macos if platform.system().lower() == "darwin": - path = os.path.join(path, "Contents", "MacOS", "lib", "Python") + path = os.path.join(path, "MacOS") version_file = os.path.join(path, "openpype", "version.py") if not os.path.isfile(version_file): @@ -189,6 +190,11 @@ def get_openpype_executable(): exe_list = config.GetConfigEntryWithDefault("OpenPypeExecutable", "") dir_list = config.GetConfigEntryWithDefault( "OpenPypeInstallationDirs", "") + + # clean '\ ' for MacOS pasting + if platform.system().lower() == "darwin": + exe_list = exe_list.replace("\\ ", " ") + dir_list = dir_list.replace("\\ ", " ") return exe_list, dir_list @@ -196,19 +202,21 @@ def get_openpype_versions(dir_list): print(">>> Getting OpenPype executable ...") openpype_versions = [] - install_dir = DirectoryUtils.SearchDirectoryList(dir_list) - if install_dir: - print("--- Looking for OpenPype at: {}".format(install_dir)) - sub_dirs = [ - f.path for f in os.scandir(install_dir) - if f.is_dir() - ] - for subdir in sub_dirs: - version = get_openpype_version_from_path(subdir) - if not version: - continue - print(" - found: {} - {}".format(version, subdir)) - openpype_versions.append((version, subdir)) + # special case of multiple install dirs + for dir_list in dir_list.split(","): + install_dir = DirectoryUtils.SearchDirectoryList(dir_list) + if install_dir: + print("--- Looking for OpenPype at: {}".format(install_dir)) + sub_dirs = [ + f.path for f in os.scandir(install_dir) + if f.is_dir() + ] + for subdir in sub_dirs: + version = get_openpype_version_from_path(subdir) + if not version: + continue + print(" - found: {} - {}".format(version, subdir)) + openpype_versions.append((version, subdir)) return openpype_versions @@ -218,8 +226,8 @@ def get_requested_openpype_executable( requested_version_obj = OpenPypeVersion.from_string(requested_version) if not requested_version_obj: print(( - ">>> Requested version does not match version regex \"{}\"" - ).format(VERSION_REGEX)) + ">>> Requested version '{}' does not match version regex '{}'" + ).format(requested_version, VERSION_REGEX)) return None print(( @@ -272,7 +280,8 @@ def get_requested_openpype_executable( # Deadline decide. exe_list = [ os.path.join(version_dir, "openpype_console.exe"), - os.path.join(version_dir, "openpype_console") + os.path.join(version_dir, "openpype_console"), + os.path.join(version_dir, "MacOS", "openpype_console") ] return FileUtils.SearchFileList(";".join(exe_list)) @@ -333,7 +342,7 @@ def inject_openpype_environment(deadlinePlugin): "app": job.GetJobEnvironmentKeyValue("AVALON_APP_NAME"), "envgroup": "farm" } - + if job.GetJobEnvironmentKeyValue('IS_TEST'): args.append("--automatic-tests") diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py index 6b0f69d98f..6e1b973fb9 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py @@ -73,7 +73,7 @@ class OpenPypeDeadlinePlugin(DeadlinePlugin): """ # fix path for application bundle on macos if platform.system().lower() == "darwin": - path = os.path.join(path, "Contents", "MacOS", "lib", "Python") + path = os.path.join(path, "MacOS") version_file = os.path.join(path, "openpype", "version.py") if not os.path.isfile(version_file): @@ -107,19 +107,28 @@ class OpenPypeDeadlinePlugin(DeadlinePlugin): "Scanning for compatible requested " f"version {requested_version}")) dir_list = self.GetConfigEntry("OpenPypeInstallationDirs") - install_dir = DirectoryUtils.SearchDirectoryList(dir_list) - if dir: - sub_dirs = [ - f.path for f in os.scandir(install_dir) - if f.is_dir() - ] - for subdir in sub_dirs: - version = self.get_openpype_version_from_path(subdir) - if not version: - continue - openpype_versions.append((version, subdir)) + + # clean '\ ' for MacOS pasting + if platform.system().lower() == "darwin": + dir_list = dir_list.replace("\\ ", " ") + + for dir_list in dir_list.split(","): + install_dir = DirectoryUtils.SearchDirectoryList(dir_list) + if install_dir: + sub_dirs = [ + f.path for f in os.scandir(install_dir) + if f.is_dir() + ] + for subdir in sub_dirs: + version = self.get_openpype_version_from_path(subdir) + if not version: + continue + openpype_versions.append((version, subdir)) exe_list = self.GetConfigEntry("OpenPypeExecutable") + # clean '\ ' for MacOS pasting + if platform.system().lower() == "darwin": + exe_list = exe_list.replace("\\ ", " ") exe = FileUtils.SearchFileList(exe_list) if openpype_versions: # if looking for requested compatible version, @@ -161,7 +170,9 @@ class OpenPypeDeadlinePlugin(DeadlinePlugin): os.path.join( compatible_versions[-1][1], "openpype_console.exe"), os.path.join( - compatible_versions[-1][1], "openpype_console") + compatible_versions[-1][1], "openpype_console"), + os.path.join( + compatible_versions[-1][1], "MacOS", "openpype_console") ] exe = FileUtils.SearchFileList(";".join(exe_list)) diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index 625a3f1a28..861f16518c 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -204,10 +204,10 @@ def info_about_input(oiiotool_path, filepath): _stdout, _stderr = popen.communicate() output = "" if _stdout: - output += _stdout.decode("utf-8") + output += _stdout.decode("utf-8", errors="backslashreplace") if _stderr: - output += _stderr.decode("utf-8") + output += _stderr.decode("utf-8", errors="backslashreplace") output = output.replace("\r\n", "\n") xml_started = False diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index 6f14f8428d..d61b5f0b26 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -64,6 +64,16 @@ class FtrackModule( self._timers_manager_module = None def get_ftrack_url(self): + """Resolved ftrack url. + + Resolving is trying to fill missing information in url and tried to + connect to the server. + + Returns: + Union[str, None]: Final variant of url or None if url could not be + reached. + """ + if self._ftrack_url is _URL_NOT_SET: self._ftrack_url = resolve_ftrack_url( self._settings_ftrack_url, @@ -73,8 +83,19 @@ class FtrackModule( ftrack_url = property(get_ftrack_url) + @property + def settings_ftrack_url(self): + """Ftrack url from settings in a format as it is. + + Returns: + str: Ftrack url from settings. + """ + + return self._settings_ftrack_url + def get_global_environments(self): """Ftrack's global environments.""" + return { "FTRACK_SERVER": self.ftrack_url } @@ -510,7 +531,10 @@ def resolve_ftrack_url(url, logger=None): url = "https://" + url ftrack_url = None - if not url.endswith("ftrackapp.com"): + if url and _check_ftrack_url(url): + ftrack_url = url + + if not ftrack_url and not url.endswith("ftrackapp.com"): ftrackapp_url = url + ".ftrackapp.com" if _check_ftrack_url(ftrackapp_url): ftrack_url = ftrackapp_url diff --git a/openpype/modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/ftrack/ftrack_server/event_server_cli.py index 25ebad6658..ad7ffd8e25 100644 --- a/openpype/modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/ftrack/ftrack_server/event_server_cli.py @@ -316,7 +316,7 @@ def main_loop(ftrack_url): statuser_failed_count = 0 # If thread failed test Ftrack and Mongo connection - elif not statuser_thread.isAlive(): + elif not statuser_thread.is_alive(): statuser_thread.join() statuser_thread = None ftrack_accessible = False @@ -359,7 +359,7 @@ def main_loop(ftrack_url): storer_failed_count = 0 # If thread failed test Ftrack and Mongo connection - elif not storer_thread.isAlive(): + elif not storer_thread.is_alive(): if storer_thread.mongo_error: raise MongoPermissionsError() storer_thread.join() @@ -396,7 +396,7 @@ def main_loop(ftrack_url): processor_failed_count = 0 # If thread failed test Ftrack and Mongo connection - elif not processor_thread.isAlive(): + elif not processor_thread.is_alive(): if processor_thread.mongo_error: raise Exception( "Exiting because have issue with acces to MongoDB" diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 2d06e2ab02..75f43cb22f 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -3,6 +3,7 @@ import json import copy import pyblish.api +from openpype.pipeline.publish import get_publish_repre_path from openpype.lib.openpype_version import get_openpype_version from openpype.lib.transcoding import ( get_ffprobe_streams, @@ -55,6 +56,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "reference": "reference" } keep_first_subset_name_for_review = True + upload_reviewable_with_origin_name = False asset_versions_status_profiles = [] additional_metadata_keys = [] @@ -153,7 +155,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): if not review_representations or has_movie_review: for repre in thumbnail_representations: - repre_path = self._get_repre_path(instance, repre, False) + repre_path = get_publish_repre_path(instance, repre, False) if not repre_path: self.log.warning( "Published path is not set and source was removed." @@ -210,7 +212,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "from {}".format(repre)) continue - repre_path = self._get_repre_path(instance, repre, False) + repre_path = get_publish_repre_path(instance, repre, False) if not repre_path: self.log.warning( "Published path is not set and source was removed." @@ -293,6 +295,13 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): ) # Add item to component list component_list.append(review_item) + if self.upload_reviewable_with_origin_name: + origin_name_component = copy.deepcopy(review_item) + filename = os.path.basename(repre_path) + origin_name_component["component_data"]["name"] = ( + os.path.splitext(filename)[0] + ) + component_list.append(origin_name_component) # Duplicate thumbnail component for all not first reviews if first_thumbnail_component is not None: @@ -324,7 +333,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Add others representations as component for repre in other_representations: - published_path = self._get_repre_path(instance, repre, True) + published_path = get_publish_repre_path(instance, repre, True) if not published_path: continue # Create copy of base comp item and append it @@ -364,51 +373,6 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): def _collect_additional_metadata(self, streams): pass - def _get_repre_path(self, instance, repre, only_published): - """Get representation path that can be used for integration. - - When 'only_published' is set to true the validation of path is not - relevant. In that case we just need what is set in 'published_path' - as "reference". The reference is not used to get or upload the file but - for reference where the file was published. - - Args: - instance (pyblish.Instance): Processed instance object. Used - for source of staging dir if representation does not have - filled it. - repre (dict): Representation on instance which could be and - could not be integrated with main integrator. - only_published (bool): Care only about published paths and - ignore if filepath is not existing anymore. - - Returns: - str: Path to representation file. - None: Path is not filled or does not exists. - """ - - published_path = repre.get("published_path") - if published_path: - published_path = os.path.normpath(published_path) - if os.path.exists(published_path): - return published_path - - if only_published: - return published_path - - comp_files = repre["files"] - if isinstance(comp_files, (tuple, list, set)): - filename = comp_files[0] - else: - filename = comp_files - - staging_dir = repre.get("stagingDir") - if not staging_dir: - staging_dir = instance.data["stagingDir"] - src_path = os.path.normpath(os.path.join(staging_dir, filename)) - if os.path.exists(src_path): - return src_path - return None - def _get_asset_version_status_name(self, instance): if not self.asset_versions_status_profiles: return None diff --git a/openpype/modules/ftrack/tray/login_dialog.py b/openpype/modules/ftrack/tray/login_dialog.py index fbb3455775..f374a71178 100644 --- a/openpype/modules/ftrack/tray/login_dialog.py +++ b/openpype/modules/ftrack/tray/login_dialog.py @@ -139,8 +139,7 @@ class CredentialsDialog(QtWidgets.QDialog): self.fill_ftrack_url() def fill_ftrack_url(self): - url = os.getenv("FTRACK_SERVER") - checked_url = self.check_url(url) + checked_url = self.check_url() if checked_url == self.ftsite_input.text(): return @@ -154,7 +153,7 @@ class CredentialsDialog(QtWidgets.QDialog): self.api_input.setEnabled(enabled) self.user_input.setEnabled(enabled) - if not url: + if not checked_url: self.btn_advanced.hide() self.btn_simple.hide() self.btn_ftrack_login.hide() @@ -254,13 +253,13 @@ class CredentialsDialog(QtWidgets.QDialog): ) def _on_ftrack_login_clicked(self): - url = self.check_url(self.ftsite_input.text()) + url = self.check_url() if not url: return # If there is an existing server thread running we need to stop it. if self._login_server_thread: - if self._login_server_thread.isAlive(): + if self._login_server_thread.is_alive(): self._login_server_thread.stop() self._login_server_thread.join() self._login_server_thread = None @@ -302,21 +301,21 @@ class CredentialsDialog(QtWidgets.QDialog): if is_logged is not None: self.set_is_logged(is_logged) - def check_url(self, url): - if url is not None: - url = url.strip("/ ") - - if not url: + def check_url(self): + settings_url = self._module.settings_ftrack_url + url = self._module.ftrack_url + if not settings_url: self.set_error( "Ftrack URL is not defined in settings!" ) return - if "http" not in url: - if url.endswith("ftrackapp.com"): - url = "https://" + url - else: - url = "https://{}.ftrackapp.com".format(url) + if url is None: + self.set_error( + "Specified URL does not lead to a valid Ftrack server." + ) + return + try: result = requests.get( url, diff --git a/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py b/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py index 9d5d2271bf..acfd6d1820 100644 --- a/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py +++ b/openpype/modules/shotgrid/plugins/publish/collect_shotgrid_session.py @@ -83,6 +83,11 @@ class CollectShotgridSession(pyblish.api.ContextPlugin): "login to shotgrid withing openpype Tray" ) + # Set OPENPYPE_SG_USER with login so other deadline tasks can make + # use of it + self.log.info("Setting OPENPYPE_SG_USER to '%s'.", login) + os.environ["OPENPYPE_SG_USER"] = login + session = shotgun_api3.Shotgun( base_url=shotgrid_url, script_name=shotgrid_script_name, diff --git a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py index cfd2d10fd9..ad400572c9 100644 --- a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py +++ b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_publish.py @@ -1,11 +1,13 @@ import os import pyblish.api +from openpype.pipeline.publish import get_publish_repre_path + class IntegrateShotgridPublish(pyblish.api.InstancePlugin): """ Create published Files from representations and add it to version. If - representation is tagged add shotgrid review, it will add it in + representation is tagged as shotgrid review, it will add it in path to movie for a movie file or path to frame for an image sequence. """ @@ -22,12 +24,14 @@ class IntegrateShotgridPublish(pyblish.api.InstancePlugin): for representation in instance.data.get("representations", []): - local_path = representation.get("published_path") - code = os.path.basename(local_path) + local_path = get_publish_repre_path( + instance, representation, False + ) if representation.get("tags", []): continue + code = os.path.basename(local_path) published_file = self._find_existing_publish( code, context, shotgrid_version ) diff --git a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py index a1b7140e22..e1fa0c5174 100644 --- a/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py +++ b/openpype/modules/shotgrid/plugins/publish/integrate_shotgrid_version.py @@ -1,6 +1,7 @@ -import os import pyblish.api +from openpype.pipeline.publish import get_publish_repre_path + class IntegrateShotgridVersion(pyblish.api.InstancePlugin): """Integrate Shotgrid Version""" @@ -36,13 +37,14 @@ class IntegrateShotgridVersion(pyblish.api.InstancePlugin): self.log.info("Use existing Shotgrid version: {}".format(version)) data_to_update = {} - status = context.data.get("intent", {}).get("value") - if status: - data_to_update["sg_status_list"] = status + intent = context.data.get("intent") + if intent: + data_to_update["sg_status_list"] = intent["value"] for representation in instance.data.get("representations", []): - local_path = representation.get("published_path") - code = os.path.basename(local_path) + local_path = get_publish_repre_path( + instance, representation, False + ) if "shotgridreview" in representation.get("tags", []): diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 612031efac..86c97586d2 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -8,6 +8,7 @@ from abc import ABCMeta, abstractmethod import time from openpype.client import OpenPypeMongoConnection +from openpype.pipeline.publish import get_publish_repre_path from openpype.lib.plugin_tools import prepare_template_data @@ -167,9 +168,8 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): thumbnail_path = None for repre in instance.data.get("representations", []): if repre.get('thumbnail') or "thumbnail" in repre.get('tags', []): - repre_thumbnail_path = ( - repre.get("published_path") or - os.path.join(repre["stagingDir"], repre["files"]) + repre_thumbnail_path = get_publish_repre_path( + instance, repre, False ) if os.path.exists(repre_thumbnail_path): thumbnail_path = repre_thumbnail_path @@ -184,11 +184,10 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): if (repre.get("review") or "review" in tags or "burnin" in tags): - repre_review_path = ( - repre.get("published_path") or - os.path.join(repre["stagingDir"], repre["files"]) + repre_review_path = get_publish_repre_path( + instance, repre, False ) - if os.path.exists(repre_review_path): + if repre_review_path and os.path.exists(repre_review_path): review_path = repre_review_path if "burnin" in tags: # burnin has precedence if exists break diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index aef3623efa..5b873a37cf 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -50,13 +50,10 @@ async def upload(module, project_name, file, representation, provider_name, presets=preset) file_path = file.get("path", "") - try: - local_file_path, remote_file_path = resolve_paths( - module, file_path, project_name, - remote_site_name, remote_handler - ) - except Exception as exp: - print(exp) + local_file_path, remote_file_path = resolve_paths( + module, file_path, project_name, + remote_site_name, remote_handler + ) target_folder = os.path.dirname(remote_file_path) folder_id = remote_handler.create_folder(target_folder) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index ba0abe7d3b..28863c091a 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -3,7 +3,6 @@ import sys import time from datetime import datetime import threading -import platform import copy import signal from collections import deque, defaultdict @@ -25,7 +24,11 @@ from openpype.lib import Logger, get_local_site_id from openpype.pipeline import AvalonMongoDB, Anatomy from openpype.settings.lib import ( get_default_anatomy_settings, - get_anatomy_settings + get_anatomy_settings, + get_local_settings, +) +from openpype.settings.constants import ( + DEFAULT_PROJECT_KEY ) from .providers.local_drive import LocalDriveHandler @@ -639,6 +642,110 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return get_local_site_id() return active_site + def get_active_site_type(self, project_name, local_settings=None): + """Active site which is defined by artist. + + Unlike 'get_active_site' is this method also checking local settings + where might be different active site set by user. The output is limited + to "studio" and "local". + + This method is used by Anatomy when is decided which + + Todos: + Check if sync server is enabled for the project. + - To be able to do that the sync settings MUST NOT be cached for + all projects at once. The sync settings preparation for all + projects is reasonable only in sync server loop. + + Args: + project_name (str): Name of project where to look for active site. + local_settings (Optional[dict[str, Any]]): Prepared local settings. + + Returns: + Literal["studio", "local"]: Active site. + """ + + if not self.enabled: + return "studio" + + if local_settings is None: + local_settings = get_local_settings() + + local_project_settings = local_settings.get("projects") + project_settings = get_project_settings(project_name) + sync_server_settings = project_settings["global"]["sync_server"] + if not sync_server_settings["enabled"]: + return "studio" + + project_active_site = sync_server_settings["config"]["active_site"] + if not local_project_settings: + return project_active_site + + project_locals = local_project_settings.get(project_name) or {} + default_locals = local_project_settings.get(DEFAULT_PROJECT_KEY) or {} + active_site = ( + project_locals.get("active_site") + or default_locals.get("active_site") + ) + if active_site: + return active_site + return project_active_site + + def get_site_root_overrides( + self, project_name, site_name, local_settings=None + ): + """Get root overrides for project on a site. + + Implemented to be used in 'Anatomy' for other than 'studio' site. + + Args: + project_name (str): Project for which root overrides should be + received. + site_name (str): Name of site for which should be received roots. + local_settings (Optional[dict[str, Any]]): Prepare local settigns + values. + + Returns: + Union[dict[str, Any], None]: Root overrides for this machine. + """ + + # Validate that site name is valid + if site_name not in ("studio", "local"): + # Considure local site id as 'local' + if site_name != get_local_site_id(): + raise ValueError(( + "Root overrides are available only for" + " default sites not for \"{}\"" + ).format(site_name)) + site_name = "local" + + if local_settings is None: + local_settings = get_local_settings() + + if not local_settings: + return + + local_project_settings = local_settings.get("projects") or {} + + # Check for roots existence in local settings first + roots_project_locals = ( + local_project_settings + .get(project_name, {}) + ) + roots_default_locals = ( + local_project_settings + .get(DEFAULT_PROJECT_KEY, {}) + ) + + # Skip rest of processing if roots are not set + if not roots_project_locals and not roots_default_locals: + return + + # Combine roots from local settings + roots_locals = roots_default_locals.get(site_name) or {} + roots_locals.update(roots_project_locals.get(site_name) or {}) + return roots_locals + # remote sites def get_remote_sites(self, project_name): """ diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index f5319c5a48..7a2ef59a5a 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -86,6 +86,12 @@ from .context_tools import ( registered_host, deregister_host, get_process_id, + + get_current_context, + get_current_host_name, + get_current_project_name, + get_current_asset_name, + get_current_task_name, ) install = install_host uninstall = uninstall_host @@ -176,6 +182,13 @@ __all__ = ( "register_host", "registered_host", "deregister_host", + "get_process_id", + + "get_current_context", + "get_current_host_name", + "get_current_project_name", + "get_current_asset_name", + "get_current_task_name", # Backwards compatible function names "install", diff --git a/openpype/pipeline/anatomy.py b/openpype/pipeline/anatomy.py index a18b46d9ac..683960f3d8 100644 --- a/openpype/pipeline/anatomy.py +++ b/openpype/pipeline/anatomy.py @@ -9,7 +9,6 @@ import six import time from openpype.settings.lib import ( - get_project_settings, get_local_settings, ) from openpype.settings.constants import ( @@ -24,7 +23,7 @@ from openpype.lib.path_templates import ( FormatObject, ) from openpype.lib.log import Logger -from openpype.lib import get_local_site_id +from openpype.modules import ModulesManager log = Logger.get_logger(__name__) @@ -57,19 +56,13 @@ class BaseAnatomy(object): root_key_regex = re.compile(r"{(root?[^}]+)}") root_name_regex = re.compile(r"root\[([^]]+)\]") - def __init__(self, project_doc, local_settings, site_name): + def __init__(self, project_doc, root_overrides=None): project_name = project_doc["name"] self.project_name = project_name - - if (site_name and - site_name not in ["studio", "local", get_local_site_id()]): - raise RuntimeError("Anatomy could be created only for default " - "local sites not for {}".format(site_name)) - - self._site_name = site_name + self.project_code = project_doc["data"]["code"] self._data = self._prepare_anatomy_data( - project_doc, local_settings, site_name + project_doc, root_overrides ) self._templates_obj = AnatomyTemplates(self) self._roots_obj = Roots(self) @@ -91,28 +84,18 @@ class BaseAnatomy(object): def items(self): return copy.deepcopy(self._data).items() - def _prepare_anatomy_data(self, project_doc, local_settings, site_name): + def _prepare_anatomy_data(self, project_doc, root_overrides): """Prepare anatomy data for further processing. Method added to replace `{task}` with `{task[name]}` in templates. """ - project_name = project_doc["name"] + anatomy_data = self._project_doc_to_anatomy_data(project_doc) - templates_data = anatomy_data.get("templates") - if templates_data: - # Replace `{task}` with `{task[name]}` in templates - value_queue = collections.deque() - value_queue.append(templates_data) - while value_queue: - item = value_queue.popleft() - if not isinstance(item, dict): - continue - - self._apply_local_settings_on_anatomy_data(anatomy_data, - local_settings, - project_name, - site_name) + self._apply_local_settings_on_anatomy_data( + anatomy_data, + root_overrides + ) return anatomy_data @@ -346,7 +329,7 @@ class BaseAnatomy(object): return output def _apply_local_settings_on_anatomy_data( - self, anatomy_data, local_settings, project_name, site_name + self, anatomy_data, root_overrides ): """Apply local settings on anatomy data. @@ -365,13 +348,138 @@ class BaseAnatomy(object): Args: anatomy_data (dict): Data for anatomy. - local_settings (dict): Data of local settings. - project_name (str): Name of project for which anatomy data are. + root_overrides (dict): Data of local settings. """ - if not local_settings: + + # Skip processing if roots for current active site are not available in + # local settings + if not root_overrides: return + current_platform = platform.system().lower() + + root_data = anatomy_data["roots"] + for root_name, path in root_overrides.items(): + if root_name not in root_data: + continue + anatomy_data["roots"][root_name][current_platform] = ( + path + ) + + +class CacheItem: + """Helper to cache data. + + Helper does not handle refresh of data and does not mark data as outdated. + Who uses the object should check of outdated state on his own will. + """ + + default_lifetime = 10 + + def __init__(self, lifetime=None): + self._data = None + self._cached = None + self._lifetime = lifetime or self.default_lifetime + + @property + def data(self): + """Cached data/object. + + Returns: + Any: Whatever was cached. + """ + + return self._data + + @property + def is_outdated(self): + """Item has outdated cache. + + Lifetime of cache item expired or was not yet set. + + Returns: + bool: Item is outdated. + """ + + if self._cached is None: + return True + return (time.time() - self._cached) > self._lifetime + + def update_data(self, data): + """Update cache of data. + + Args: + data (Any): Data to cache. + """ + + self._data = data + self._cached = time.time() + + +class Anatomy(BaseAnatomy): + _sync_server_addon_cache = CacheItem() + _project_cache = collections.defaultdict(CacheItem) + _default_site_id_cache = collections.defaultdict(CacheItem) + _root_overrides_cache = collections.defaultdict( + lambda: collections.defaultdict(CacheItem) + ) + + def __init__(self, project_name=None, site_name=None): + if not project_name: + project_name = os.environ.get("AVALON_PROJECT") + + if not project_name: + raise ProjectNotSet(( + "Implementation bug: Project name is not set. Anatomy requires" + " to load data for specific project." + )) + + project_doc = self.get_project_doc_from_cache(project_name) + root_overrides = self._get_site_root_overrides(project_name, site_name) + + super(Anatomy, self).__init__(project_doc, root_overrides) + + @classmethod + def get_project_doc_from_cache(cls, project_name): + project_cache = cls._project_cache[project_name] + if project_cache.is_outdated: + project_cache.update_data(get_project(project_name)) + return copy.deepcopy(project_cache.data) + + @classmethod + def get_sync_server_addon(cls): + if cls._sync_server_addon_cache.is_outdated: + manager = ModulesManager() + cls._sync_server_addon_cache.update_data( + manager.get_enabled_module("sync_server") + ) + return cls._sync_server_addon_cache.data + + @classmethod + def _get_studio_roots_overrides(cls, project_name, local_settings=None): + """This would return 'studio' site override by local settings. + + Notes: + This logic handles local overrides of studio site which may be + available even when sync server is not enabled. + Handling of 'studio' and 'local' site was separated as preparation + for AYON development where that will be received from + separated sources. + + Args: + project_name (str): Name of project. + local_settings (Optional[dict[str, Any]]): Prepared local settings. + + Returns: + Union[Dict[str, str], None]): Local root overrides. + """ + + if local_settings is None: + local_settings = get_local_settings() + local_project_settings = local_settings.get("projects") or {} + if not local_project_settings: + return None # Check for roots existence in local settings first roots_project_locals = ( @@ -388,106 +496,59 @@ class BaseAnatomy(object): return # Combine roots from local settings - roots_locals = roots_default_locals.get(site_name) or {} - roots_locals.update(roots_project_locals.get(site_name) or {}) - # Skip processing if roots for current active site are not available in - # local settings - if not roots_locals: - return - - current_platform = platform.system().lower() - - root_data = anatomy_data["roots"] - for root_name, path in roots_locals.items(): - if root_name not in root_data: - continue - anatomy_data["roots"][root_name][current_platform] = ( - path - ) - - -class Anatomy(BaseAnatomy): - _project_cache = {} - _site_cache = {} - - def __init__(self, project_name=None, site_name=None): - if not project_name: - project_name = os.environ.get("AVALON_PROJECT") - - if not project_name: - raise ProjectNotSet(( - "Implementation bug: Project name is not set. Anatomy requires" - " to load data for specific project." - )) - - project_doc = self.get_project_doc_from_cache(project_name) - local_settings = get_local_settings() - if not site_name: - site_name = self.get_site_name_from_cache( - project_name, local_settings - ) - - super(Anatomy, self).__init__( - project_doc, - local_settings, - site_name - ) + roots_locals = roots_default_locals.get("studio") or {} + roots_locals.update(roots_project_locals.get("studio") or {}) + return roots_locals @classmethod - def get_project_doc_from_cache(cls, project_name): - project_cache = cls._project_cache.get(project_name) - if project_cache is not None: - if time.time() - project_cache["start"] > 10: - cls._project_cache.pop(project_name) - project_cache = None + def _get_site_root_overrides(cls, project_name, site_name): + """Get root overrides for site. - if project_cache is None: - project_cache = { - "project_doc": get_project(project_name), - "start": time.time() - } - cls._project_cache[project_name] = project_cache + Args: + project_name (str): Project name for which root overrides should be + received. + site_name (Union[str, None]): Name of site for which root overrides + should be returned. + """ - return copy.deepcopy( - cls._project_cache[project_name]["project_doc"] - ) + # Local settings may be used more than once or may not be used at all + # - to avoid slowdowns 'get_local_settings' is not called until it's + # really needed + local_settings = None - @classmethod - def get_site_name_from_cache(cls, project_name, local_settings): - site_cache = cls._site_cache.get(project_name) - if site_cache is not None: - if time.time() - site_cache["start"] > 10: - cls._site_cache.pop(project_name) - site_cache = None + # First check if sync server is available and enabled + sync_server = cls.get_sync_server_addon() + if sync_server is None or not sync_server.enabled: + # QUESTION is ok to force 'studio' when site sync is not enabled? + site_name = "studio" - if site_cache: - return site_cache["site_name"] + elif not site_name: + # Use sync server to receive active site name + project_cache = cls._default_site_id_cache[project_name] + if project_cache.is_outdated: + local_settings = get_local_settings() + project_cache.update_data( + sync_server.get_active_site_type( + project_name, local_settings + ) + ) + site_name = project_cache.data - local_project_settings = local_settings.get("projects") - if not local_project_settings: - return - - project_locals = local_project_settings.get(project_name) or {} - default_locals = local_project_settings.get(DEFAULT_PROJECT_KEY) or {} - active_site = ( - project_locals.get("active_site") - or default_locals.get("active_site") - ) - if not active_site: - project_settings = get_project_settings(project_name) - active_site = ( - project_settings - ["global"] - ["sync_server"] - ["config"] - ["active_site"] - ) - - cls._site_cache[project_name] = { - "site_name": active_site, - "start": time.time() - } - return active_site + site_cache = cls._root_overrides_cache[project_name][site_name] + if site_cache.is_outdated: + if site_name == "studio": + # Handle studio root overrides without sync server + # - studio root overrides can be done even without sync server + roots_overrides = cls._get_studio_roots_overrides( + project_name, local_settings + ) + else: + # Ask sync server to get roots overrides + roots_overrides = sync_server.get_site_root_overrides( + project_name, site_name, local_settings + ) + site_cache.update_data(roots_overrides) + return site_cache.data class AnatomyTemplateUnsolved(TemplateUnsolved): diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index e7480cf59e..6f68bdc5bf 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -198,41 +198,19 @@ def validate_imageio_colorspace_in_config(config_path, colorspace_name): return True -def get_ocio_config_colorspaces(config_path): - """Get all colorspace data - - Wrapper function for aggregating all names and its families. - Families can be used for building menu and submenus in gui. - - Args: - config_path (str): path leading to config.ocio file - - Returns: - dict: colorspace and family in couple - """ - if sys.version_info[0] == 2: - return get_colorspace_data_subprocess(config_path) - - from ..scripts.ocio_wrapper import _get_colorspace_data - return _get_colorspace_data(config_path) - - -def get_colorspace_data_subprocess(config_path): - """Get colorspace data via subprocess +def get_data_subprocess(config_path, data_type): + """Get data via subprocess Wrapper for Python 2 hosts. Args: config_path (str): path leading to config.ocio file - - Returns: - dict: colorspace and family in couple """ with _make_temp_json_file() as tmp_json_path: # Prepare subprocess arguments args = [ "run", get_ocio_config_script_path(), - "config", "get_colorspace", + "config", data_type, "--in_path", config_path, "--out_path", tmp_json_path @@ -251,6 +229,47 @@ def get_colorspace_data_subprocess(config_path): return json.loads(return_json_data) +def compatible_python(): + """Only 3.9 or higher can directly use PyOpenColorIO in ocio_wrapper""" + compatible = False + if sys.version[0] == 3 and sys.version[1] >= 9: + compatible = True + return compatible + + +def get_ocio_config_colorspaces(config_path): + """Get all colorspace data + + Wrapper function for aggregating all names and its families. + Families can be used for building menu and submenus in gui. + + Args: + config_path (str): path leading to config.ocio file + + Returns: + dict: colorspace and family in couple + """ + if compatible_python(): + from ..scripts.ocio_wrapper import _get_colorspace_data + return _get_colorspace_data(config_path) + else: + return get_colorspace_data_subprocess(config_path) + + +def get_colorspace_data_subprocess(config_path): + """Get colorspace data via subprocess + + Wrapper for Python 2 hosts. + + Args: + config_path (str): path leading to config.ocio file + + Returns: + dict: colorspace and family in couple + """ + return get_data_subprocess(config_path, "get_colorspace") + + def get_ocio_config_views(config_path): """Get all viewer data @@ -263,12 +282,12 @@ def get_ocio_config_views(config_path): Returns: dict: `display/viewer` and viewer data """ - if sys.version_info[0] == 2: + if compatible_python(): + from ..scripts.ocio_wrapper import _get_views_data + return _get_views_data(config_path) + else: return get_views_data_subprocess(config_path) - from ..scripts.ocio_wrapper import _get_views_data - return _get_views_data(config_path) - def get_views_data_subprocess(config_path): """Get viewers data via subprocess @@ -281,27 +300,7 @@ def get_views_data_subprocess(config_path): Returns: dict: `display/viewer` and viewer data """ - with _make_temp_json_file() as tmp_json_path: - # Prepare subprocess arguments - args = [ - "run", get_ocio_config_script_path(), - "config", "get_views", - "--in_path", config_path, - "--out_path", tmp_json_path - - ] - log.info("Executing: {}".format(" ".join(args))) - - process_kwargs = { - "logger": log, - "env": {} - } - - run_openpype_process(*args, **process_kwargs) - - # return all colorspaces - return_json_data = open(tmp_json_path).read() - return json.loads(return_json_data) + return get_data_subprocess(config_path, "get_views") def get_imageio_config( @@ -344,9 +343,9 @@ def get_imageio_config( imageio_global, imageio_host = _get_imageio_settings( project_settings, host_name) - config_host = imageio_host["ocio_config"] + config_host = imageio_host.get("ocio_config", {}) - if config_host["enabled"]: + if config_host.get("enabled"): config_data = _get_config_data( config_host["filepath"], anatomy_data ) @@ -438,13 +437,14 @@ def get_imageio_file_rules(project_name, host_name, project_settings=None): # get file rules from global and host_name frules_global = imageio_global["file_rules"] - frules_host = imageio_host["file_rules"] + # host is optional, some might not have any settings + frules_host = imageio_host.get("file_rules", {}) # compile file rules dictionary file_rules = {} if frules_global["enabled"]: file_rules.update(frules_global["rules"]) - if frules_host["enabled"]: + if frules_host and frules_host["enabled"]: file_rules.update(frules_host["rules"]) return file_rules @@ -455,7 +455,7 @@ def _get_imageio_settings(project_settings, host_name): Args: project_settings (dict): project settings. - Defaults to None. + Defaults to None. host_name (str): host name Returns: @@ -463,6 +463,7 @@ def _get_imageio_settings(project_settings, host_name): """ # get image io from global and host_name imageio_global = project_settings["global"]["imageio"] - imageio_host = project_settings[host_name]["imageio"] + # host is optional, some might not have any settings + imageio_host = project_settings.get(host_name, {}).get("imageio", {}) return imageio_global, imageio_host diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index da0ce8ecf4..6610fd7da7 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -11,6 +11,7 @@ import pyblish.api from pyblish.lib import MessageHandler import openpype +from openpype.host import HostBase from openpype.client import ( get_project, get_asset_by_id, @@ -306,6 +307,58 @@ def debug_host(): return host +def get_current_host_name(): + """Current host name. + + Function is based on currently registered host integration or environment + variant 'AVALON_APP'. + + Returns: + Union[str, None]: Name of host integration in current process or None. + """ + + host = registered_host() + if isinstance(host, HostBase): + return host.name + return os.environ.get("AVALON_APP") + + +def get_global_context(): + return { + "project_name": os.environ.get("AVALON_PROJECT"), + "asset_name": os.environ.get("AVALON_ASSET"), + "task_name": os.environ.get("AVALON_TASK"), + } + + +def get_current_context(): + host = registered_host() + if isinstance(host, HostBase): + return host.get_current_context() + return get_global_context() + + +def get_current_project_name(): + host = registered_host() + if isinstance(host, HostBase): + return host.get_current_project_name() + return get_global_context()["project_name"] + + +def get_current_asset_name(): + host = registered_host() + if isinstance(host, HostBase): + return host.get_current_asset_name() + return get_global_context()["asset_name"] + + +def get_current_task_name(): + host = registered_host() + if isinstance(host, HostBase): + return host.get_current_task_name() + return get_global_context()["task_name"] + + def get_current_project(fields=None): """Helper function to get project document based on global Session. @@ -316,7 +369,7 @@ def get_current_project(fields=None): None: Project is not set. """ - project_name = legacy_io.active_project() + project_name = get_current_project_name() return get_project(project_name, fields=fields) @@ -341,12 +394,12 @@ def get_current_project_asset(asset_name=None, asset_id=None, fields=None): None: Asset is not set or not exist. """ - project_name = legacy_io.active_project() + project_name = get_current_project_name() if asset_id: return get_asset_by_id(project_name, asset_id, fields=fields) if not asset_name: - asset_name = legacy_io.Session.get("AVALON_ASSET") + asset_name = get_current_asset_name() # Skip if is not set even on context if not asset_name: return None @@ -363,7 +416,7 @@ def is_representation_from_latest(representation): bool: Whether the representation is of latest version. """ - project_name = legacy_io.active_project() + project_name = get_current_project_name() return version_is_latest(project_name, representation["parent"]) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 9c468ae8fc..acc2bb054f 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -8,17 +8,23 @@ import inspect from uuid import uuid4 from contextlib import contextmanager -from openpype.client import get_assets +import pyblish.logic +import pyblish.api + +from openpype.client import get_assets, get_asset_by_name from openpype.settings import ( get_system_settings, get_project_settings ) +from openpype.lib.attribute_definitions import ( + UnknownDef, + serialize_attr_defs, + deserialize_attr_defs, + get_default_values, +) from openpype.host import IPublishHost from openpype.pipeline import legacy_io -from openpype.pipeline.mongodb import ( - AvalonMongoDB, - session_data_from_environment, -) +from openpype.pipeline.plugin_discover import DiscoverResult from .creator_plugins import ( Creator, @@ -28,6 +34,7 @@ from .creator_plugins import ( CreatorError, ) +# Changes of instances and context are send as tuple of 2 information UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) @@ -153,7 +160,7 @@ class CreatorsRemoveFailed(CreatorsOperationFailed): class CreatorsCreateFailed(CreatorsOperationFailed): def __init__(self, failed_info): - msg = "Faled to create instances" + msg = "Failed to create instances" super(CreatorsCreateFailed, self).__init__( msg, failed_info ) @@ -177,6 +184,319 @@ def prepare_failed_creator_operation_info( } +_EMPTY_VALUE = object() + + +class TrackChangesItem(object): + """Helper object to track changes in data. + + Has access to full old and new data and will create deep copy of them, + so it is not needed to create copy before passed in. + + Can work as a dictionary if old or new value is a dictionary. In + that case received object is another object of 'TrackChangesItem'. + + Goal is to be able to get old or new value as was or only changed values + or get information about removed/changed keys, and all of that on + any "dictionary level". + + ``` + # Example of possible usages + >>> old_value = { + ... "key_1": "value_1", + ... "key_2": { + ... "key_sub_1": 1, + ... "key_sub_2": { + ... "enabled": True + ... } + ... }, + ... "key_3": "value_2" + ... } + >>> new_value = { + ... "key_1": "value_1", + ... "key_2": { + ... "key_sub_2": { + ... "enabled": False + ... }, + ... "key_sub_3": 3 + ... }, + ... "key_3": "value_3" + ... } + + >>> changes = TrackChangesItem(old_value, new_value) + >>> changes.changed + True + + >>> changes["key_2"]["key_sub_1"].new_value is None + True + + >>> list(sorted(changes.changed_keys)) + ['key_2', 'key_3'] + + >>> changes["key_2"]["key_sub_2"]["enabled"].changed + True + + >>> changes["key_2"].removed_keys + {'key_sub_1'} + + >>> list(sorted(changes["key_2"].available_keys)) + ['key_sub_1', 'key_sub_2', 'key_sub_3'] + + >>> changes.new_value == new_value + True + + # Get only changed values + only_changed_new_values = { + key: changes[key].new_value + for key in changes.changed_keys + } + ``` + + Args: + old_value (Any): Old value. + new_value (Any): New value. + """ + + def __init__(self, old_value, new_value): + self._changed = old_value != new_value + # Resolve if value is '_EMPTY_VALUE' after comparison of the values + if old_value is _EMPTY_VALUE: + old_value = None + if new_value is _EMPTY_VALUE: + new_value = None + self._old_value = copy.deepcopy(old_value) + self._new_value = copy.deepcopy(new_value) + + self._old_is_dict = isinstance(old_value, dict) + self._new_is_dict = isinstance(new_value, dict) + + self._old_keys = None + self._new_keys = None + self._available_keys = None + self._removed_keys = None + + self._changed_keys = None + + self._sub_items = None + + def __getitem__(self, key): + """Getter looks into subitems if object is dictionary.""" + + if self._sub_items is None: + self._prepare_sub_items() + return self._sub_items[key] + + def __bool__(self): + """Boolean of object is if old and new value are the same.""" + + return self._changed + + def get(self, key, default=None): + """Try to get sub item.""" + + if self._sub_items is None: + self._prepare_sub_items() + return self._sub_items.get(key, default) + + @property + def old_value(self): + """Get copy of old value. + + Returns: + Any: Whatever old value was. + """ + + return copy.deepcopy(self._old_value) + + @property + def new_value(self): + """Get copy of new value. + + Returns: + Any: Whatever new value was. + """ + + return copy.deepcopy(self._new_value) + + @property + def changed(self): + """Value changed. + + Returns: + bool: If data changed. + """ + + return self._changed + + @property + def is_dict(self): + """Object can be used as dictionary. + + Returns: + bool: When can be used that way. + """ + + return self._old_is_dict or self._new_is_dict + + @property + def changes(self): + """Get changes in raw data. + + This method should be used only if 'is_dict' value is 'True'. + + Returns: + Dict[str, Tuple[Any, Any]]: Changes are by key in tuple + (, ). If 'is_dict' is 'False' then + output is always empty dictionary. + """ + + output = {} + if not self.is_dict: + return output + + old_value = self.old_value + new_value = self.new_value + for key in self.changed_keys: + _old = None + _new = None + if self._old_is_dict: + _old = old_value.get(key) + if self._new_is_dict: + _new = new_value.get(key) + output[key] = (_old, _new) + return output + + # Methods/properties that can be used when 'is_dict' is 'True' + @property + def old_keys(self): + """Keys from old value. + + Empty set is returned if old value is not a dict. + + Returns: + Set[str]: Keys from old value. + """ + + if self._old_keys is None: + self._prepare_keys() + return set(self._old_keys) + + @property + def new_keys(self): + """Keys from new value. + + Empty set is returned if old value is not a dict. + + Returns: + Set[str]: Keys from new value. + """ + + if self._new_keys is None: + self._prepare_keys() + return set(self._new_keys) + + @property + def changed_keys(self): + """Keys that has changed from old to new value. + + Empty set is returned if both old and new value are not a dict. + + Returns: + Set[str]: Keys of changed keys. + """ + + if self._changed_keys is None: + self._prepare_sub_items() + return set(self._changed_keys) + + @property + def available_keys(self): + """All keys that are available in old and new value. + + Empty set is returned if both old and new value are not a dict. + Output is Union of 'old_keys' and 'new_keys'. + + Returns: + Set[str]: All keys from old and new value. + """ + + if self._available_keys is None: + self._prepare_keys() + return set(self._available_keys) + + @property + def removed_keys(self): + """Key that are not available in new value but were in old value. + + Returns: + Set[str]: All removed keys. + """ + + if self._removed_keys is None: + self._prepare_sub_items() + return set(self._removed_keys) + + def _prepare_keys(self): + old_keys = set() + new_keys = set() + if self._old_is_dict and self._new_is_dict: + old_keys = set(self._old_value.keys()) + new_keys = set(self._new_value.keys()) + + elif self._old_is_dict: + old_keys = set(self._old_value.keys()) + + elif self._new_is_dict: + new_keys = set(self._new_value.keys()) + + self._old_keys = old_keys + self._new_keys = new_keys + self._available_keys = old_keys | new_keys + self._removed_keys = old_keys - new_keys + + def _prepare_sub_items(self): + sub_items = {} + changed_keys = set() + + old_keys = self.old_keys + new_keys = self.new_keys + new_value = self.new_value + old_value = self.old_value + if self._old_is_dict and self._new_is_dict: + for key in self.available_keys: + item = TrackChangesItem( + old_value.get(key), new_value.get(key) + ) + sub_items[key] = item + if item.changed or key not in old_keys or key not in new_keys: + changed_keys.add(key) + + elif self._old_is_dict: + old_keys = set(old_value.keys()) + available_keys = set(old_keys) + changed_keys = set(available_keys) + for key in available_keys: + # NOTE Use '_EMPTY_VALUE' because old value could be 'None' + # which would result in "unchanged" item + sub_items[key] = TrackChangesItem( + old_value.get(key), _EMPTY_VALUE + ) + + elif self._new_is_dict: + new_keys = set(new_value.keys()) + available_keys = set(new_keys) + changed_keys = set(available_keys) + for key in available_keys: + # NOTE Use '_EMPTY_VALUE' because new value could be 'None' + # which would result in "unchanged" item + sub_items[key] = TrackChangesItem( + _EMPTY_VALUE, new_value.get(key) + ) + + self._sub_items = sub_items + self._changed_keys = changed_keys + + class InstanceMember: """Representation of instance member. @@ -208,14 +528,12 @@ class AttributeValues(object): Has dictionary like methods. Not all of them are allowed all the time. Args: - attr_defs(AbtractAttrDef): Defintions of value type and properties. + attr_defs(AbstractAttrDef): Defintions of value type and properties. values(dict): Values after possible conversion. origin_data(dict): Values loaded from host before conversion. """ def __init__(self, attr_defs, values, origin_data=None): - from openpype.lib.attribute_definitions import UnknownDef - if origin_data is None: origin_data = copy.deepcopy(values) self._origin_data = origin_data @@ -288,11 +606,25 @@ class AttributeValues(object): @property def attr_defs(self): - """Pointer to attribute definitions.""" - return self._attr_defs + """Pointer to attribute definitions. + + Returns: + List[AbstractAttrDef]: Attribute definitions. + """ + + return list(self._attr_defs) + + @property + def origin_data(self): + return copy.deepcopy(self._origin_data) def data_to_store(self): - """Create new dictionary with data to store.""" + """Create new dictionary with data to store. + + Returns: + Dict[str, Any]: Attribute values that should be stored. + """ + output = {} for key in self._data: output[key] = self[key] @@ -302,28 +634,14 @@ class AttributeValues(object): output[key] = attr_def.default return output - @staticmethod - def calculate_changes(new_data, old_data): - """Calculate changes of 2 dictionary objects.""" - changes = {} - for key, new_value in new_data.items(): - old_value = old_data.get(key) - if old_value != new_value: - changes[key] = (old_value, new_value) - return changes + def get_serialized_attr_defs(self): + """Serialize attribute definitions to json serializable types. - def changes(self): - return self.calculate_changes(self._data, self._origin_data) + Returns: + List[Dict[str, Any]]: Serialized attribute definitions. + """ - def apply_changes(self, changes): - for key, item in changes.items(): - old_value, new_value = item - if new_value is None: - if key in self: - self.pop(key) - - elif self.get(key) != new_value: - self[key] = new_value + return serialize_attr_defs(self._attr_defs) class CreatorAttributeValues(AttributeValues): @@ -362,13 +680,14 @@ class PublishAttributes: """Wrapper for publish plugin attribute definitions. Cares about handling attribute definitions of multiple publish plugins. + Keep information about attribute definitions and their values. Args: parent(CreatedInstance, CreateContext): Parent for which will be data stored and from which are data loaded. origin_data(dict): Loaded data by plugin class name. - attr_plugins(list): List of publish plugins that may have defined - attribute definitions. + attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish + plugins that may have defined attribute definitions. """ def __init__(self, parent, origin_data, attr_plugins=None): @@ -442,36 +761,9 @@ class PublishAttributes: output[key] = attr_value.data_to_store() return output - def changes(self): - """Return changes per each key.""" - - changes = {} - for key, attr_val in self._data.items(): - attr_changes = attr_val.changes() - if attr_changes: - if key not in changes: - changes[key] = {} - changes[key].update(attr_val) - - for key, value in self._origin_data.items(): - if key not in self._data: - changes[key] = (value, None) - return changes - - def apply_changes(self, changes): - for key, item in changes.items(): - if isinstance(item, dict): - self._data[key].apply_changes(item) - continue - - old_value, new_value = item - if new_value is not None: - raise ValueError( - "Unexpected type \"{}\" expected None".format( - str(type(new_value)) - ) - ) - self.pop(key) + @property + def origin_data(self): + return copy.deepcopy(self._origin_data) def set_publish_plugins(self, attr_plugins): """Set publish plugins attribute definitions.""" @@ -509,6 +801,42 @@ class PublishAttributes: self, [], value, value ) + def serialize_attributes(self): + return { + "attr_defs": { + plugin_name: attrs_value.get_serialized_attr_defs() + for plugin_name, attrs_value in self._data.items() + }, + "plugin_names_order": self._plugin_names_order, + "missing_plugins": self._missing_plugins + } + + def deserialize_attributes(self, data): + self._plugin_names_order = data["plugin_names_order"] + self._missing_plugins = data["missing_plugins"] + + attr_defs = deserialize_attr_defs(data["attr_defs"]) + + origin_data = self._origin_data + data = self._data + self._data = {} + + added_keys = set() + for plugin_name, attr_defs_data in attr_defs.items(): + attr_defs = deserialize_attr_defs(attr_defs_data) + value = data.get(plugin_name) or {} + orig_value = copy.deepcopy(origin_data.get(plugin_name) or {}) + self._data[plugin_name] = PublishAttributeValues( + self, attr_defs, value, orig_value + ) + + for key, value in data.items(): + if key not in added_keys: + self._missing_plugins.append(key) + self._data[key] = PublishAttributeValues( + self, [], value, value + ) + class CreatedInstance: """Instance entity with data that will be stored to workfile. @@ -517,15 +845,22 @@ class CreatedInstance: about instance like "asset" and "task" and all data used for filling subset name as creators may have custom data for subset name filling. + Notes: + Object have 2 possible initialization. One using 'creator' object which + is recommended for api usage. Second by passing information about + creator. + Args: - family(str): Name of family that will be created. - subset_name(str): Name of subset that will be created. - data(dict): Data used for filling subset name or override data from - already existing instance. - creator(BaseCreator): Creator responsible for instance. - host(ModuleType): Host implementation loaded with - `openpype.pipeline.registered_host`. - new(bool): Is instance new. + family (str): Name of family that will be created. + subset_name (str): Name of subset that will be created. + data (Dict[str, Any]): Data used for filling subset name or override + data from already existing instance. + creator (Union[BaseCreator, None]): Creator responsible for instance. + creator_identifier (str): Identifier of creator plugin. + creator_label (str): Creator plugin label. + group_label (str): Default group label from creator plugin. + creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from + creator. """ # Keys that can't be changed or removed from data after loading using @@ -542,9 +877,24 @@ class CreatedInstance: ) def __init__( - self, family, subset_name, data, creator, new=True + self, + family, + subset_name, + data, + creator=None, + creator_identifier=None, + creator_label=None, + group_label=None, + creator_attr_defs=None, ): - self.creator = creator + if creator is not None: + creator_identifier = creator.identifier + group_label = creator.get_group_label() + creator_label = creator.label + creator_attr_defs = creator.get_instance_attr_defs() + + self._creator_label = creator_label + self._group_label = group_label or creator_identifier # Instance members may have actions on them # TODO implement members logic @@ -574,7 +924,7 @@ class CreatedInstance: self._data["family"] = family self._data["subset"] = subset_name self._data["active"] = data.get("active", True) - self._data["creator_identifier"] = creator.identifier + self._data["creator_identifier"] = creator_identifier # Pop from source data all keys that are defined in `_data` before # this moment and through their values away @@ -588,10 +938,12 @@ class CreatedInstance: # Stored creator specific attribute values # {key: value} creator_values = copy.deepcopy(orig_creator_attributes) - creator_attr_defs = creator.get_instance_attr_defs() self._data["creator_attributes"] = CreatorAttributeValues( - self, creator_attr_defs, creator_values, orig_creator_attributes + self, + list(creator_attr_defs), + creator_values, + orig_creator_attributes ) # Stored publish specific attribute values @@ -676,64 +1028,27 @@ class CreatedInstance: label = self._data.get("group") if label: return label - return self.creator.get_group_label() + return self._group_label + + @property + def origin_data(self): + return copy.deepcopy(self._orig_data) @property def creator_identifier(self): - return self.creator.identifier + return self._data["creator_identifier"] @property def creator_label(self): - return self.creator.label or self.creator_identifier - - @property - def create_context(self): - return self.creator.create_context - - @property - def host(self): - return self.create_context.host - - @property - def has_set_asset(self): - """Asset name is set in data.""" - return "asset" in self._data - - @property - def has_set_task(self): - """Task name is set in data.""" - return "task" in self._data - - @property - def has_valid_context(self): - """Context data are valid for publishing.""" - return self.has_valid_asset and self.has_valid_task - - @property - def has_valid_asset(self): - """Asset set in context exists in project.""" - if not self.has_set_asset: - return False - return self._asset_is_valid - - @property - def has_valid_task(self): - """Task set in context exists in project.""" - if not self.has_set_task: - return False - return self._task_is_valid - - def set_asset_invalid(self, invalid): - # TODO replace with `set_asset_name` - self._asset_is_valid = not invalid - - def set_task_invalid(self, invalid): - # TODO replace with `set_task_name` - self._task_is_valid = not invalid + return self._creator_label or self.creator_identifier @property def id(self): - """Instance identifier.""" + """Instance identifier. + + Returns: + str: UUID of instance. + """ return self._data["instance_id"] @@ -742,6 +1057,10 @@ class CreatedInstance: """Legacy access to data. Access to data is needed to modify values. + + Returns: + CreatedInstance: Object can be used as dictionary but with + validations of immutable keys. """ return self @@ -769,29 +1088,7 @@ class CreatedInstance: def changes(self): """Calculate and return changes.""" - changes = {} - new_keys = set() - for key, new_value in self._data.items(): - new_keys.add(key) - if key in ("creator_attributes", "publish_attributes"): - continue - - old_value = self._orig_data.get(key) - if old_value != new_value: - changes[key] = (old_value, new_value) - - creator_attr_changes = self.creator_attributes.changes() - if creator_attr_changes: - changes["creator_attributes"] = creator_attr_changes - - publish_attr_changes = self.publish_attributes.changes() - if publish_attr_changes: - changes["publish_attributes"] = publish_attr_changes - - for key, old_value in self._orig_data.items(): - if key not in new_keys: - changes[key] = (old_value, None) - return changes + return TrackChangesItem(self._orig_data, self.data_to_store()) def mark_as_stored(self): """Should be called when instance data are stored. @@ -818,6 +1115,12 @@ class CreatedInstance: @property def creator_attribute_defs(self): + """Attribute defintions defined by creator plugin. + + Returns: + List[AbstractAttrDef]: Attribute defitions. + """ + return self.creator_attributes.attr_defs @property @@ -829,7 +1132,7 @@ class CreatedInstance: It is possible to recreate the instance using these data. - Todo: + Todos: We probably don't need OrderedDict. When data are loaded they are not ordered anymore. @@ -850,7 +1153,15 @@ class CreatedInstance: @classmethod def from_existing(cls, instance_data, creator): - """Convert instance data from workfile to CreatedInstance.""" + """Convert instance data from workfile to CreatedInstance. + + Args: + instance_data (Dict[str, Any]): Data in a structure ready for + 'CreatedInstance' object. + creator (Creator): Creator plugin which is creating the instance + of for which the instance belong. + """ + instance_data = copy.deepcopy(instance_data) family = instance_data.get("family", None) @@ -859,26 +1170,49 @@ class CreatedInstance: subset_name = instance_data.get("subset", None) return cls( - family, subset_name, instance_data, creator, new=False + family, subset_name, instance_data, creator ) def set_publish_plugins(self, attr_plugins): + """Set publish plugins with attribute definitions. + + This method should be called only from 'CreateContext'. + + Args: + attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which + inherit from 'OpenPypePyblishPluginMixin' and may contain + attribute definitions. + """ + self.publish_attributes.set_publish_plugins(attr_plugins) def add_members(self, members): """Currently unused method.""" + for member in members: if member not in self._members: self._members.append(member) def serialize_for_remote(self): + """Serialize object into data to be possible recreated object. + + Returns: + Dict[str, Any]: Serialized data. + """ + + creator_attr_defs = self.creator_attributes.get_serialized_attr_defs() + publish_attributes = self.publish_attributes.serialize_attributes() return { "data": self.data_to_store(), - "orig_data": copy.deepcopy(self._orig_data) + "orig_data": copy.deepcopy(self._orig_data), + "creator_attr_defs": creator_attr_defs, + "publish_attributes": publish_attributes, + "creator_label": self._creator_label, + "group_label": self._group_label, } @classmethod - def deserialize_on_remote(cls, serialized_data, creator_items): + def deserialize_on_remote(cls, serialized_data): """Convert instance data to CreatedInstance. This is fake instance in remote process e.g. in UI process. The creator @@ -888,79 +1222,77 @@ class CreatedInstance: Args: serialized_data (Dict[str, Any]): Serialized data for remote recreating. Should contain 'data' and 'orig_data'. - creator_items (Dict[str, Any]): Mapping of creator identifier and - objects that behave like a creator for most of attribute - access. """ instance_data = copy.deepcopy(serialized_data["data"]) creator_identifier = instance_data["creator_identifier"] - creator_item = creator_items[creator_identifier] - family = instance_data.get("family", None) - if family is None: - family = creator_item.family + family = instance_data["family"] subset_name = instance_data.get("subset", None) + creator_label = serialized_data["creator_label"] + group_label = serialized_data["group_label"] + creator_attr_defs = deserialize_attr_defs( + serialized_data["creator_attr_defs"] + ) + publish_attributes = serialized_data["publish_attributes"] + obj = cls( - family, subset_name, instance_data, creator_item, new=False + family, + subset_name, + instance_data, + creator_identifier=creator_identifier, + creator_label=creator_label, + group_label=group_label, + creator_attributes=creator_attr_defs ) obj._orig_data = serialized_data["orig_data"] + obj.publish_attributes.deserialize_attributes(publish_attributes) return obj - def remote_changes(self): - """Prepare serializable changes on remote side. + # Context validation related methods/properties + @property + def has_set_asset(self): + """Asset name is set in data.""" - Returns: - Dict[str, Any]: Prepared changes that can be send to client side. - """ + return "asset" in self._data - return { - "changes": self.changes(), - "asset_is_valid": self._asset_is_valid, - "task_is_valid": self._task_is_valid, - } + @property + def has_set_task(self): + """Task name is set in data.""" - def update_from_remote(self, remote_changes): - """Apply changes from remote side on client side. + return "task" in self._data - Args: - remote_changes (Dict[str, Any]): Changes created on remote side. - """ + @property + def has_valid_context(self): + """Context data are valid for publishing.""" - self._asset_is_valid = remote_changes["asset_is_valid"] - self._task_is_valid = remote_changes["task_is_valid"] + return self.has_valid_asset and self.has_valid_task - changes = remote_changes["changes"] - creator_attributes = changes.pop("creator_attributes", None) or {} - publish_attributes = changes.pop("publish_attributes", None) or {} - if changes: - self.apply_changes(changes) + @property + def has_valid_asset(self): + """Asset set in context exists in project.""" - if creator_attributes: - self.creator_attributes.apply_changes(creator_attributes) + if not self.has_set_asset: + return False + return self._asset_is_valid - if publish_attributes: - self.publish_attributes.apply_changes(publish_attributes) + @property + def has_valid_task(self): + """Task set in context exists in project.""" - def apply_changes(self, changes): - """Apply changes created via 'changes'. + if not self.has_set_task: + return False + return self._task_is_valid - Args: - Dict[str, Tuple[Any, Any]]: Instance changes to apply. Same values - are kept untouched. - """ + def set_asset_invalid(self, invalid): + # TODO replace with `set_asset_name` + self._asset_is_valid = not invalid - for key, item in changes.items(): - old_value, new_value = item - if new_value is None: - if key in self: - self.pop(key) - else: - current_value = self.get(key) - if current_value != new_value: - self[key] = new_value + def set_task_invalid(self, invalid): + # TODO replace with `set_task_name` + self._task_is_valid = not invalid class ConvertorItem(object): @@ -1000,11 +1332,13 @@ class CreateContext: Context itself also can store data related to whole creation (workfile). - those are mainly for Context publish plugins + Todos: + Don't use 'AvalonMongoDB'. It's used only to keep track about current + context which should be handled by host. + Args: host(ModuleType): Host implementation which handles implementation and global metadata. - dbcon(AvalonMongoDB): Connection to mongo with context (at least - project). headless(bool): Context is created out of UI (Current not used). reset(bool): Reset context on initialization. discover_publish_plugins(bool): Discover publish plugins during reset @@ -1012,16 +1346,8 @@ class CreateContext: """ def __init__( - self, host, dbcon=None, headless=False, reset=True, - discover_publish_plugins=True + self, host, headless=False, reset=True, discover_publish_plugins=True ): - # Create conncetion if is not passed - if dbcon is None: - session = session_data_from_environment(True) - dbcon = AvalonMongoDB(session) - dbcon.install() - - self.dbcon = dbcon self.host = host # Prepare attribute for logger (Created on demand in `log` property) @@ -1045,6 +1371,10 @@ class CreateContext: " Missing methods: {}" ).format(joined_methods)) + self._current_project_name = None + self._current_asset_name = None + self._current_task_name = None + self._host_is_valid = host_is_valid # Currently unused variable self.headless = headless @@ -1052,12 +1382,16 @@ class CreateContext: # Instances by their ID self._instances_by_id = {} + self.creator_discover_result = None + self.convertor_discover_result = None # Discovered creators self.creators = {} # Prepare categories of creators self.autocreators = {} # Manual creators self.manual_creators = {} + # Creators that are disabled + self.disabled_creators = {} self.convertors_plugins = {} self.convertor_items_by_id = {} @@ -1097,6 +1431,53 @@ class CreateContext: """Access to global publish attributes.""" return self._publish_attributes + def get_sorted_creators(self, identifiers=None): + """Sorted creators by 'order' attribute. + + Args: + identifiers (Iterable[str]): Filter creators by identifiers. All + creators are returned if 'None' is passed. + + Returns: + List[BaseCreator]: Sorted creator plugins by 'order' value. + """ + + if identifiers is not None: + identifiers = set(identifiers) + creators = [ + creator + for identifier, creator in self.creators.items() + if identifier in identifiers + ] + else: + creators = self.creators.values() + + return sorted( + creators, key=lambda creator: creator.order + ) + + @property + def sorted_creators(self): + """Sorted creators by 'order' attribute. + + Returns: + List[BaseCreator]: Sorted creator plugins by 'order' value. + """ + + return self.get_sorted_creators() + + @property + def sorted_autocreators(self): + """Sorted auto-creators by 'order' attribute. + + Returns: + List[AutoCreator]: Sorted plugins by 'order' value. + """ + + return sorted( + self.autocreators.values(), key=lambda creator: creator.order + ) + @classmethod def get_host_misssing_methods(cls, host): """Collect missing methods from host. @@ -1117,11 +1498,20 @@ class CreateContext: @property def host_name(self): + if hasattr(self.host, "name"): + return self.host.name return os.environ["AVALON_APP"] - @property - def project_name(self): - return self.dbcon.active_project() + def get_current_project_name(self): + return self._current_project_name + + def get_current_asset_name(self): + return self._current_asset_name + + def get_current_task_name(self): + return self._current_task_name + + project_name = property(get_current_project_name) @property def log(self): @@ -1138,7 +1528,7 @@ class CreateContext: self.reset_preparation() - self.reset_avalon_context() + self.reset_current_context() self.reset_plugins(discover_publish_plugins) self.reset_context_data() @@ -1185,14 +1575,22 @@ class CreateContext: self._collection_shared_data = None self.refresh_thumbnails() - def reset_avalon_context(self): - """Give ability to reset avalon context. + def reset_current_context(self): + """Refresh current context. Reset is based on optional host implementation of `get_current_context` function or using `legacy_io.Session`. Some hosts have ability to change context file without using workfiles - tool but that change is not propagated to + tool but that change is not propagated to 'legacy_io.Session' + nor 'os.environ'. + + Todos: + UI: Current context should be also checked on save - compare + initial values vs. current values. + Related to UI checks: Current workfile can be also considered + as current context information as that's where the metadata + are stored. We should store the workfile (if is available) too. """ project_name = asset_name = task_name = None @@ -1210,12 +1608,9 @@ class CreateContext: if not task_name: task_name = legacy_io.Session.get("AVALON_TASK") - if project_name: - self.dbcon.Session["AVALON_PROJECT"] = project_name - if asset_name: - self.dbcon.Session["AVALON_ASSET"] = asset_name - if task_name: - self.dbcon.Session["AVALON_TASK"] = task_name + self._current_project_name = project_name + self._current_asset_name = asset_name + self._current_task_name = task_name def reset_plugins(self, discover_publish_plugins=True): """Reload plugins. @@ -1229,18 +1624,15 @@ class CreateContext: self._reset_convertor_plugins() def _reset_publish_plugins(self, discover_publish_plugins): - import pyblish.logic - from openpype.pipeline import OpenPypePyblishPluginMixin from openpype.pipeline.publish import ( - publish_plugins_discover, - DiscoverResult + publish_plugins_discover ) # Reset publish plugins self._attr_plugins_by_family = {} - discover_result = DiscoverResult() + discover_result = DiscoverResult(pyblish.api.Plugin) plugins_with_defs = [] plugins_by_targets = [] plugins_mismatch_targets = [] @@ -1277,9 +1669,12 @@ class CreateContext: # Discover and prepare creators creators = {} + disabled_creators = {} autocreators = {} manual_creators = {} - for creator_class in discover_creator_plugins(): + report = discover_creator_plugins(return_report=True) + self.creator_discover_result = report + for creator_class in report.plugins: if inspect.isabstract(creator_class): self.log.info( "Skipping abstract Creator {}".format(str(creator_class)) @@ -1311,6 +1706,9 @@ class CreateContext: self, self.headless ) + if not creator.enabled: + disabled_creators[creator_identifier] = creator + continue creators[creator_identifier] = creator if isinstance(creator, AutoCreator): autocreators[creator_identifier] = creator @@ -1321,10 +1719,13 @@ class CreateContext: self.manual_creators = manual_creators self.creators = creators + self.disabled_creators = disabled_creators def _reset_convertor_plugins(self): convertors_plugins = {} - for convertor_class in discover_convertor_plugins(): + report = discover_convertor_plugins(return_report=True) + self.convertor_discover_result = report + for convertor_class in report.plugins: if inspect.isabstract(convertor_class): self.log.info( "Skipping abstract Creator {}".format(str(convertor_class)) @@ -1375,11 +1776,10 @@ class CreateContext: def context_data_changes(self): """Changes of attributes.""" - changes = {} - publish_attribute_changes = self._publish_attributes.changes() - if publish_attribute_changes: - changes["publish_attributes"] = publish_attribute_changes - return changes + + return TrackChangesItem( + self._original_context_data, self.context_data_to_store() + ) def creator_adds_instance(self, instance): """Creator adds new instance to context. @@ -1402,7 +1802,7 @@ class CreateContext: self._instances_by_id[instance.id] = instance # Prepare publish plugin attributes and set it on instance attr_plugins = self._get_publish_plugins_with_attr_for_family( - instance.creator.family + instance.family ) instance.set_publish_plugins(attr_plugins) @@ -1411,40 +1811,128 @@ class CreateContext: with self.bulk_instances_collection(): self._bulk_instances_to_process.append(instance) - def create(self, identifier, *args, **kwargs): - """Wrapper for creators to trigger created. + def _get_creator_in_create(self, identifier): + """Creator by identifier with unified error. - Different types of creators may expect different arguments thus the - hints for args are blind. + Helper method to get creator by identifier with same error when creator + is not available. Args: - identifier (str): Creator's identifier. - *args (Tuple[Any]): Arguments for create method. - **kwargs (Dict[Any, Any]): Keyword argument for create method. + identifier (str): Identifier of creator plugin. + + Returns: + BaseCreator: Creator found by identifier. + + Raises: + CreatorError: When identifier is not known. """ - error_message = "Failed to run Creator with identifier \"{}\". {}" creator = self.creators.get(identifier) - label = getattr(creator, "label", None) - failed = False - add_traceback = False - exc_info = None - try: - # Fake CreatorError (Could be maybe specific exception?) - if creator is None: + # Fake CreatorError (Could be maybe specific exception?) + if creator is None: + raise CreatorError( + "Creator {} was not found".format(identifier) + ) + return creator + + def create( + self, + creator_identifier, + variant, + asset_doc=None, + task_name=None, + pre_create_data=None + ): + """Trigger create of plugins with standartized arguments. + + Arguments 'asset_doc' and 'task_name' use current context as default + values. If only 'task_name' is provided it will be overriden by + task name from current context. If 'task_name' is not provided + when 'asset_doc' is, it is considered that task name is not specified, + which can lead to error if subset name template requires task name. + + Args: + creator_identifier (str): Identifier of creator plugin. + variant (str): Variant used for subset name. + asset_doc (Dict[str, Any]): Asset document which define context of + creation (possible context of created instance/s). + task_name (str): Name of task to which is context related. + pre_create_data (Dict[str, Any]): Pre-create attribute values. + + Returns: + Any: Output of triggered creator's 'create' method. + + Raises: + CreatorError: If creator was not found or asset is empty. + """ + + creator = self._get_creator_in_create(creator_identifier) + + project_name = self.project_name + if asset_doc is None: + asset_name = self.get_current_asset_name() + asset_doc = get_asset_by_name(project_name, asset_name) + task_name = self.get_current_task_name() + if asset_doc is None: raise CreatorError( - "Creator {} was not found".format(identifier) + "Asset with name {} was not found".format(asset_name) ) - creator.create(*args, **kwargs) + if pre_create_data is None: + pre_create_data = {} + + precreate_attr_defs = creator.get_pre_create_attr_defs() or [] + # Create default values of precreate data + _pre_create_data = get_default_values(precreate_attr_defs) + # Update passed precreate data to default values + # TODO validate types + _pre_create_data.update(pre_create_data) + + subset_name = creator.get_subset_name( + variant, + task_name, + asset_doc, + project_name, + self.host_name + ) + instance_data = { + "asset": asset_doc["name"], + "task": task_name, + "family": creator.family, + "variant": variant + } + return creator.create( + subset_name, + instance_data, + _pre_create_data + ) + + def _create_with_unified_error( + self, identifier, creator, *args, **kwargs + ): + error_message = "Failed to run Creator with identifier \"{}\". {}" + + label = None + add_traceback = False + result = None + fail_info = None + success = False + + try: + # Try to get creator and his label + if creator is None: + creator = self._get_creator_in_create(identifier) + label = getattr(creator, "label", label) + + # Run create + result = creator.create(*args, **kwargs) + success = True except CreatorError: - failed = True exc_info = sys.exc_info() self.log.warning(error_message.format(identifier, exc_info[1])) except: - failed = True add_traceback = True exc_info = sys.exc_info() self.log.warning( @@ -1452,12 +1940,38 @@ class CreateContext: exc_info=True ) - if failed: - raise CreatorsCreateFailed([ - prepare_failed_creator_operation_info( - identifier, label, exc_info, add_traceback - ) - ]) + if not success: + fail_info = prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) + return result, fail_info + + def create_with_unified_error(self, identifier, *args, **kwargs): + """Trigger create but raise only one error if anything fails. + + Added to raise unified exception. Capture any possible issues and + reraise it with unified information. + + Args: + identifier (str): Identifier of creator. + *args (Tuple[Any]): Arguments for create method. + **kwargs (Dict[Any, Any]): Keyword argument for create method. + + Raises: + CreatorsCreateFailed: When creation fails due to any possible + reason. If anything goes wrong this is only possible exception + the method should raise. + """ + + result, fail_info = self._create_with_unified_error( + identifier, None, *args, **kwargs + ) + if fail_info is not None: + raise CreatorsCreateFailed([fail_info]) + return result + + def _remove_instance(self, instance): + self._instances_by_id.pop(instance.id, None) def creator_removed_instance(self, instance): """When creator removes instance context should be acknowledged. @@ -1470,7 +1984,7 @@ class CreateContext: from scene metadata. """ - self._instances_by_id.pop(instance.id, None) + self._remove_instance(instance) def add_convertor_item(self, convertor_identifier, label): self.convertor_items_by_id[convertor_identifier] = ConvertorItem( @@ -1514,7 +2028,7 @@ class CreateContext: # Collect instances error_message = "Collection of instances for creator {} failed. {}" failed_info = [] - for creator in self.creators.values(): + for creator in self.sorted_creators: label = creator.label identifier = creator.identifier failed = False @@ -1584,37 +2098,12 @@ class CreateContext: Reset instances if any autocreator executed properly. """ - error_message = "Failed to run AutoCreator with identifier \"{}\". {}" failed_info = [] - for identifier, creator in self.autocreators.items(): - label = creator.label - failed = False - add_traceback = False - try: - creator.create() - - except CreatorError: - failed = True - exc_info = sys.exc_info() - self.log.warning(error_message.format(identifier, exc_info[1])) - - # Use bare except because some hosts raise their exceptions that - # do not inherit from python's `BaseException` - except: - failed = True - add_traceback = True - exc_info = sys.exc_info() - self.log.warning( - error_message.format(identifier, ""), - exc_info=True - ) - - if failed: - failed_info.append( - prepare_failed_creator_operation_info( - identifier, label, exc_info, add_traceback - ) - ) + for creator in self.sorted_autocreators: + identifier = creator.identifier + _, fail_info = self._create_with_unified_error(identifier, creator) + if fail_info is not None: + failed_info.append(fail_info) if failed_info: raise CreatorsCreateFailed(failed_info) @@ -1691,19 +2180,26 @@ class CreateContext: """Save instance specific values.""" instances_by_identifier = collections.defaultdict(list) for instance in self._instances_by_id.values(): + instance_changes = instance.changes() + if not instance_changes: + continue + identifier = instance.creator_identifier - instances_by_identifier[identifier].append(instance) + instances_by_identifier[identifier].append( + UpdateData(instance, instance_changes) + ) + + if not instances_by_identifier: + return error_message = "Instances update of creator \"{}\" failed. {}" failed_info = [] - for identifier, creator_instances in instances_by_identifier.items(): - update_list = [] - for instance in creator_instances: - instance_changes = instance.changes() - if instance_changes: - update_list.append(UpdateData(instance, instance_changes)) - creator = self.creators[identifier] + for creator in self.get_sorted_creators( + instances_by_identifier.keys() + ): + identifier = creator.identifier + update_list = instances_by_identifier[identifier] if not update_list: continue @@ -1739,9 +2235,13 @@ class CreateContext: def remove_instances(self, instances): """Remove instances from context. + All instances that don't have creator identifier leading to existing + creator are just removed from context. + Args: - instances(list): Instances that should be removed - from context. + instances(List[CreatedInstance]): Instances that should be removed. + Remove logic is done using creator, which may require to + do other cleanup than just remove instance from context. """ instances_by_identifier = collections.defaultdict(list) @@ -1749,10 +2249,21 @@ class CreateContext: identifier = instance.creator_identifier instances_by_identifier[identifier].append(instance) + # Just remove instances from context if creator is not available + missing_creators = set(instances_by_identifier) - set(self.creators) + for identifier in missing_creators: + for instance in instances_by_identifier[identifier]: + self._remove_instance(instance) + error_message = "Instances removement of creator \"{}\" failed. {}" failed_info = [] - for identifier, creator_instances in instances_by_identifier.items(): - creator = self.creators.get(identifier) + # Remove instances by creator plugin order + for creator in self.get_sorted_creators( + instances_by_identifier.keys() + ): + identifier = creator.identifier + creator_instances = instances_by_identifier[identifier] + label = creator.label failed = False add_traceback = False @@ -1795,6 +2306,7 @@ class CreateContext: family(str): Instance family for which should be attribute definitions returned. """ + if family not in self._attr_plugins_by_family: import pyblish.logic @@ -1810,7 +2322,13 @@ class CreateContext: return self._attr_plugins_by_family[family] def _get_publish_plugins_with_attr_for_context(self): - """Publish plugins attributes for Context plugins.""" + """Publish plugins attributes for Context plugins. + + Returns: + List[pyblish.api.Plugin]: Publish plugins that have attribute + definitions for context. + """ + plugins = [] for plugin in self.plugins_with_defs: if not plugin.__instanceEnabled__: @@ -1835,7 +2353,7 @@ class CreateContext: return self._collection_shared_data def run_convertor(self, convertor_identifier): - """Run convertor plugin by it's idenfitifier. + """Run convertor plugin by identifier. Conversion is skipped if convertor is not available. @@ -1848,7 +2366,7 @@ class CreateContext: convertor.convert() def run_convertors(self, convertor_identifiers): - """Run convertor plugins by idenfitifiers. + """Run convertor plugins by identifiers. Conversion is skipped if convertor is not available. It is recommended to trigger reset after conversion to reload instances. diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 8500dd1e22..bd3fbaf78f 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -79,6 +79,10 @@ class SubsetConvertorPlugin(object): self._log = Logger.get_logger(self.__class__.__name__) return self._log + @property + def host(self): + return self._create_context.host + @abstractproperty def identifier(self): """Converted identifier. @@ -107,7 +111,11 @@ class SubsetConvertorPlugin(object): @property def create_context(self): - """Quick access to create context.""" + """Quick access to create context. + + Returns: + CreateContext: Context which initialized the plugin. + """ return self._create_context @@ -149,6 +157,12 @@ class BaseCreator: Single object should be used for multiple instances instead of single instance per one creator object. Do not store temp data or mid-process data to `self` if it's not Plugin specific. + + Args: + project_settings (Dict[str, Any]): Project settings. + system_settings (Dict[str, Any]): System settings. + create_context (CreateContext): Context which initialized creator. + headless (bool): Running in headless mode. """ # Label shown in UI @@ -157,6 +171,10 @@ class BaseCreator: # Cached group label after first call 'get_group_label' _cached_group_label = None + # Order in which will be plugin executed (collect & update instances) + # less == earlier -> Order '90' will be processed before '100' + order = 100 + # Variable to store logger _log = None @@ -425,8 +443,8 @@ class BaseCreator: keys/values when plugin attributes change. Returns: - List[AbtractAttrDef]: Attribute definitions that can be tweaked for - created instance. + List[AbstractAttrDef]: Attribute definitions that can be tweaked + for created instance. """ return self.instance_attr_defs @@ -489,6 +507,17 @@ class Creator(BaseCreator): # - similar to instance attribute definitions pre_create_attr_defs = [] + @property + def show_order(self): + """Order in which is creator shown in UI. + + Returns: + int: Order in which is creator shown (less == earlier). By default + is using Creator's 'order' or processing. + """ + + return self.order + @abstractmethod def create(self, subset_name, instance_data, pre_create_data): """Create new instance and store it. @@ -563,8 +592,8 @@ class Creator(BaseCreator): updating keys/values when plugin attributes change. Returns: - List[AbtractAttrDef]: Attribute definitions that can be tweaked for - created instance. + List[AbstractAttrDef]: Attribute definitions that can be tweaked + for created instance. """ return self.pre_create_attr_defs @@ -586,12 +615,12 @@ class AutoCreator(BaseCreator): pass -def discover_creator_plugins(): - return discover(BaseCreator) +def discover_creator_plugins(*args, **kwargs): + return discover(BaseCreator, *args, **kwargs) -def discover_convertor_plugins(): - return discover(SubsetConvertorPlugin) +def discover_convertor_plugins(*args, **kwargs): + return discover(SubsetConvertorPlugin, *args, **kwargs) def discover_legacy_creator_plugins(): diff --git a/openpype/pipeline/create/subset_name.py b/openpype/pipeline/create/subset_name.py index ed05dd6083..3f0692b46a 100644 --- a/openpype/pipeline/create/subset_name.py +++ b/openpype/pipeline/create/subset_name.py @@ -70,7 +70,8 @@ def get_subset_name( host_name=None, default_template=None, dynamic_data=None, - project_settings=None + project_settings=None, + family_filter=None, ): """Calculate subset name based on passed context and OpenPype settings. @@ -82,23 +83,35 @@ def get_subset_name( That's main reason why so many arguments are required to calculate subset name. + Option to pass family filter was added for special cases when creator or + automated publishing require special subset name template which would be + hard to maintain using its family value. + Why not just pass the right family? -> Family is also used as fill + value and for filtering of publish plugins. + + Todos: + Find better filtering options to avoid requirement of + argument 'family_filter'. + Args: family (str): Instance family. variant (str): In most of the cases it is user input during creation. task_name (str): Task name on which context is instance created. asset_doc (dict): Queried asset document with its tasks in data. Used to get task type. - project_name (str): Name of project on which is instance created. - Important for project settings that are loaded. - host_name (str): One of filtering criteria for template profile - filters. - default_template (str): Default template if any profile does not match - passed context. Constant 'DEFAULT_SUBSET_TEMPLATE' is used if - is not passed. - dynamic_data (dict): Dynamic data specific for a creator which creates - instance. - project_settings (Union[Dict[str, Any], None]): Prepared settings for - project. Settings are queried if not passed. + project_name (Optional[str]): Name of project on which is instance + created. Important for project settings that are loaded. + host_name (Optional[str]): One of filtering criteria for template + profile filters. + default_template (Optional[str]): Default template if any profile does + not match passed context. Constant 'DEFAULT_SUBSET_TEMPLATE' + is used if is not passed. + dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for + a creator which creates instance. + project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings + for project. Settings are queried if not passed. + family_filter (Optional[str]): Use different family for subset template + filtering. Value of 'family' is used when not passed. """ if not family: @@ -119,7 +132,7 @@ def get_subset_name( template = get_subset_name_template( project_name, - family, + family_filter or family, task_name, task_type, host_name, diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index b5e55834db..e380d65bbe 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -2,7 +2,10 @@ import os import logging from openpype.settings import get_system_settings, get_project_settings -from openpype.pipeline import legacy_io +from openpype.pipeline import ( + schema, + legacy_io, +) from openpype.pipeline.plugin_discover import ( discover, register_plugin, @@ -18,16 +21,15 @@ class LoaderPlugin(list): Arguments: context (dict): avalon-core:context-1.0 - name (str, optional): Use pre-defined name - namespace (str, optional): Use pre-defined namespace .. versionadded:: 4.0 This class was introduced """ - families = list() - representations = list() + families = [] + representations = [] + extensions = {"*"} order = 0 is_multiple_contexts_compatible = False enabled = True @@ -79,6 +81,102 @@ class LoaderPlugin(list): print(" - setting `{}`: `{}`".format(option, value)) setattr(cls, option, value) + @classmethod + def has_valid_extension(cls, repre_doc): + """Has representation document valid extension for loader. + + Args: + repre_doc (dict[str, Any]): Representation document. + + Returns: + bool: Representation has valid extension + """ + + if "*" in cls.extensions: + return True + + # Get representation main file extension from 'context' + repre_context = repre_doc.get("context") or {} + ext = repre_context.get("ext") + if not ext: + # Legacy way how to get extensions + path = repre_doc.get("data", {}).get("path") + if not path: + cls.log.info( + "Representation doesn't have known source of extension" + " information." + ) + return False + + cls.log.debug("Using legacy source of extension from path.") + ext = os.path.splitext(path)[-1].lstrip(".") + + # If representation does not have extension then can't be valid + if not ext: + return False + + valid_extensions_low = {ext.lower() for ext in cls.extensions} + return ext.lower() in valid_extensions_low + + @classmethod + def is_compatible_loader(cls, context): + """Return whether a loader is compatible with a context. + + On override make sure it is overriden as class or static method. + + This checks the version's families and the representation for the given + loader plugin. + + Args: + context (dict[str, Any]): Documents of context for which should + be loader used. + + Returns: + bool: Is loader compatible for context. + """ + + plugin_repre_names = cls.get_representations() + plugin_families = cls.families + if ( + not plugin_repre_names + or not plugin_families + or not cls.extensions + ): + return False + + repre_doc = context.get("representation") + if not repre_doc: + return False + + plugin_repre_names = set(plugin_repre_names) + if ( + "*" not in plugin_repre_names + and repre_doc["name"] not in plugin_repre_names + ): + return False + + if not cls.has_valid_extension(repre_doc): + return False + + plugin_families = set(plugin_families) + if "*" in plugin_families: + return True + + subset_doc = context["subset"] + maj_version, _ = schema.get_schema_version(subset_doc["schema"]) + if maj_version < 3: + families = context["version"]["data"].get("families") + else: + families = subset_doc["data"].get("families") + if families is None: + family = subset_doc["data"].get("family") + if family: + families = [family] + + if not families: + return False + return any(family in plugin_families for family in families) + @classmethod def get_representations(cls): return cls.representations diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index e2b3675115..fefdb8537b 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -28,7 +28,6 @@ from openpype.lib import ( TemplateUnsolved, ) from openpype.pipeline import ( - schema, legacy_io, Anatomy, ) @@ -643,7 +642,10 @@ def get_representation_path(representation, root=None, dbcon=None): def path_from_config(): try: - version_, subset, asset, project = dbcon.parenthood(representation) + project_name = dbcon.active_project() + version_, subset, asset, project = get_representation_parents( + project_name, representation + ) except ValueError: log.debug( "Representation %s wasn't found in database, " @@ -748,25 +750,9 @@ def is_compatible_loader(Loader, context): Returns: bool - """ - maj_version, _ = schema.get_schema_version(context["subset"]["schema"]) - if maj_version < 3: - families = context["version"]["data"].get("families", []) - else: - families = context["subset"]["data"]["families"] - representation = context["representation"] - has_family = ( - "*" in Loader.families or any( - family in Loader.families for family in families - ) - ) - representations = Loader.get_representations() - has_representation = ( - "*" in representations or representation["name"] in representations - ) - return has_family and has_representation + return Loader.is_compatible_loader(context) def loaders_from_repre_context(loaders, repre_context): diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py index 7edd9ac290..e5257b801a 100644 --- a/openpype/pipeline/plugin_discover.py +++ b/openpype/pipeline/plugin_discover.py @@ -135,11 +135,12 @@ class PluginDiscoverContext(object): allow_duplicates (bool): Validate class name duplications. ignore_classes (list): List of classes that will be ignored and not added to result. + return_report (bool): Output will be full report if set to 'True'. Returns: - DiscoverResult: Object holding succesfully discovered plugins, - ignored plugins, plugins with missing abstract implementation - and duplicated plugin. + Union[DiscoverResult, list[Any]]: Object holding successfully + discovered plugins, ignored plugins, plugins with missing + abstract implementation and duplicated plugin. """ if not ignore_classes: @@ -268,9 +269,34 @@ class _GlobalDiscover: return cls._context -def discover(superclass, allow_duplicates=True): +def discover( + superclass, + allow_duplicates=True, + ignore_classes=None, + return_report=False +): + """Find and return subclasses of `superclass` + + Args: + superclass (type): Class which determines discovered subclasses. + allow_duplicates (bool): Validate class name duplications. + ignore_classes (list): List of classes that will be ignored + and not added to result. + return_report (bool): Output will be full report if set to 'True'. + + Returns: + Union[DiscoverResult, list[Any]]: Object holding successfully + discovered plugins, ignored plugins, plugins with missing + abstract implementation and duplicated plugin. + """ + context = _GlobalDiscover.get_context() - return context.discover(superclass, allow_duplicates) + return context.discover( + superclass, + allow_duplicates, + ignore_classes, + return_report + ) def get_last_discovered_plugins(superclass): diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py index dc6fc0f97a..05ba1c9c33 100644 --- a/openpype/pipeline/publish/__init__.py +++ b/openpype/pipeline/publish/__init__.py @@ -25,7 +25,6 @@ from .publish_plugins import ( from .lib import ( get_publish_template_name, - DiscoverResult, publish_plugins_discover, load_help_content_from_plugin, load_help_content_from_filepath, @@ -36,6 +35,7 @@ from .lib import ( filter_instances_for_context_plugin, context_plugin_should_run, get_instance_staging_dir, + get_publish_repre_path, ) from .abstract_expected_files import ExpectedFiles @@ -68,7 +68,6 @@ __all__ = ( "get_publish_template_name", - "DiscoverResult", "publish_plugins_discover", "load_help_content_from_plugin", "load_help_content_from_filepath", @@ -79,6 +78,7 @@ __all__ = ( "filter_instances_for_context_plugin", "context_plugin_should_run", "get_instance_staging_dir", + "get_publish_repre_path", "ExpectedFiles", diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index c76671fa39..1ec641bac4 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -10,11 +10,19 @@ import six import pyblish.plugin import pyblish.api -from openpype.lib import Logger, filter_profiles +from openpype.lib import ( + Logger, + import_filepath, + filter_profiles +) from openpype.settings import ( get_project_settings, get_system_settings, ) +from openpype.pipeline import ( + tempdir +) +from openpype.pipeline.plugin_discover import DiscoverResult from .contants import ( DEFAULT_PUBLISH_TEMPLATE, @@ -196,28 +204,6 @@ def get_publish_template_name( return template or default_template -class DiscoverResult: - """Hold result of publish plugins discovery. - - Stores discovered plugins duplicated plugins and file paths which - crashed on execution of file. - """ - def __init__(self): - self.plugins = [] - self.crashed_file_paths = {} - self.duplicated_plugins = [] - - def __iter__(self): - for plugin in self.plugins: - yield plugin - - def __getitem__(self, item): - return self.plugins[item] - - def __setitem__(self, item, value): - self.plugins[item] = value - - class HelpContent: def __init__(self, title, description, detail=None): self.title = title @@ -285,7 +271,7 @@ def publish_plugins_discover(paths=None): """ # The only difference with `pyblish.api.discover` - result = DiscoverResult() + result = DiscoverResult(pyblish.api.Plugin) plugins = dict() plugin_names = [] @@ -316,12 +302,8 @@ def publish_plugins_discover(paths=None): if not mod_ext == ".py": continue - module = types.ModuleType(mod_name) - module.__file__ = abspath - try: - with open(abspath, "rb") as f: - six.exec_(f.read(), module.__dict__) + module = import_filepath(abspath, mod_name) # Store reference to original module, to avoid # garbage collection from collecting it's global @@ -595,7 +577,7 @@ def context_plugin_should_run(plugin, context): Args: plugin (pyblish.api.Plugin): Plugin with filters. - context (pyblish.api.Context): Pyblish context with insances. + context (pyblish.api.Context): Pyblish context with instances. Returns: bool: Context plugin should run based on valid instances. @@ -609,12 +591,21 @@ def context_plugin_should_run(plugin, context): def get_instance_staging_dir(instance): """Unified way how staging dir is stored and created on instances. - First check if 'stagingDir' is already set in instance data. If there is - not create new in tempdir. + First check if 'stagingDir' is already set in instance data. + In case there already is new tempdir will not be created. + + It also supports `OPENPYPE_TMPDIR`, so studio can define own temp + shared repository per project or even per more granular context. + Template formatting is supported also with optional keys. Folder is + created in case it doesn't exists. + + Available anatomy formatting keys: + - root[work | ] + - project[name | code] Note: - Staging dir does not have to be necessarily in tempdir so be carefull - about it's usage. + Staging dir does not have to be necessarily in tempdir so be careful + about its usage. Args: instance (pyblish.lib.Instance): Instance for which we want to get @@ -623,12 +614,79 @@ def get_instance_staging_dir(instance): Returns: str: Path to staging dir of instance. """ + staging_dir = instance.data.get('stagingDir') + if staging_dir: + return staging_dir - staging_dir = instance.data.get("stagingDir") - if not staging_dir: + anatomy = instance.context.data.get("anatomy") + + # get customized tempdir path from `OPENPYPE_TMPDIR` env var + custom_temp_dir = tempdir.create_custom_tempdir( + anatomy.project_name, anatomy) + + if custom_temp_dir: + staging_dir = os.path.normpath( + tempfile.mkdtemp( + prefix="pyblish_tmp_", + dir=custom_temp_dir + ) + ) + else: staging_dir = os.path.normpath( tempfile.mkdtemp(prefix="pyblish_tmp_") ) - instance.data["stagingDir"] = staging_dir + instance.data['stagingDir'] = staging_dir return staging_dir + + +def get_publish_repre_path(instance, repre, only_published=False): + """Get representation path that can be used for integration. + + When 'only_published' is set to true the validation of path is not + relevant. In that case we just need what is set in 'published_path' + as "reference". The reference is not used to get or upload the file but + for reference where the file was published. + + Args: + instance (pyblish.Instance): Processed instance object. Used + for source of staging dir if representation does not have + filled it. + repre (dict): Representation on instance which could be and + could not be integrated with main integrator. + only_published (bool): Care only about published paths and + ignore if filepath is not existing anymore. + + Returns: + str: Path to representation file. + None: Path is not filled or does not exists. + """ + + published_path = repre.get("published_path") + if published_path: + published_path = os.path.normpath(published_path) + if os.path.exists(published_path): + return published_path + + if only_published: + return published_path + + comp_files = repre["files"] + if isinstance(comp_files, (tuple, list, set)): + filename = comp_files[0] + else: + filename = comp_files + + staging_dir = repre.get("stagingDir") + if not staging_dir: + staging_dir = get_instance_staging_dir(instance) + + # Expand the staging dir path in case it's been stored with the root + # template syntax + anatomy = instance.context.data["anatomy"] + staging_dir = anatomy.fill_root(staging_dir) + + src_path = os.path.normpath(os.path.join(staging_dir, filename)) + if os.path.exists(src_path): + return src_path + return None diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index d9145275f7..e2ae893aa9 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -118,7 +118,7 @@ class OpenPypePyblishPluginMixin: Attributes available for all families in plugin's `families` attribute. Returns: - list: Attribute definitions for plugin. + list: Attribute definitions for plugin. """ return [] @@ -372,6 +372,12 @@ class ExtractorColormanaged(Extractor): ``` """ + ext = representation["ext"] + # check extension + self.log.debug("__ ext: `{}`".format(ext)) + if ext.lower() not in self.allowed_ext: + return + if colorspace_settings is None: colorspace_settings = self.get_colorspace_settings(context) @@ -386,12 +392,6 @@ class ExtractorColormanaged(Extractor): self.log.info("Config data is : `{}`".format( config_data)) - ext = representation["ext"] - # check extension - self.log.debug("__ ext: `{}`".format(ext)) - if ext.lower() not in self.allowed_ext: - return - project_name = context.data["projectName"] host_name = context.data["hostName"] project_settings = context.data["project_settings"] diff --git a/openpype/pipeline/tempdir.py b/openpype/pipeline/tempdir.py new file mode 100644 index 0000000000..55a1346b08 --- /dev/null +++ b/openpype/pipeline/tempdir.py @@ -0,0 +1,59 @@ +""" +Temporary folder operations +""" + +import os +from openpype.lib import StringTemplate +from openpype.pipeline import Anatomy + + +def create_custom_tempdir(project_name, anatomy=None): + """ Create custom tempdir + + Template path formatting is supporting: + - optional key formatting + - available keys: + - root[work | ] + - project[name | code] + + Args: + project_name (str): project name + anatomy (openpype.pipeline.Anatomy)[optional]: Anatomy object + + Returns: + str | None: formatted path or None + """ + openpype_tempdir = os.getenv("OPENPYPE_TMPDIR") + if not openpype_tempdir: + return + + custom_tempdir = None + if "{" in openpype_tempdir: + if anatomy is None: + anatomy = Anatomy(project_name) + # create base formate data + data = { + "root": anatomy.roots, + "project": { + "name": anatomy.project_name, + "code": anatomy.project_code, + } + } + # path is anatomy template + custom_tempdir = StringTemplate.format_template( + openpype_tempdir, data).normalized() + + else: + # path is absolute + custom_tempdir = openpype_tempdir + + # create the dir path if it doesn't exists + if not os.path.exists(custom_tempdir): + try: + # create it if it doesn't exists + os.makedirs(custom_tempdir) + except IOError as error: + raise IOError( + "Path couldn't be created: {}".format(error)) + + return custom_tempdir diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index 1266c27fd7..119e4aaeb7 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -842,7 +842,8 @@ class PlaceholderPlugin(object): """Placeholder options for data showed. Returns: - List[AbtractAttrDef]: Attribute definitions of placeholder options. + List[AbstractAttrDef]: Attribute definitions of + placeholder options. """ return [] @@ -1143,7 +1144,7 @@ class PlaceholderLoadMixin(object): as defaults for attributes. Returns: - List[AbtractAttrDef]: Attribute definitions common for load + List[AbstractAttrDef]: Attribute definitions common for load plugins. """ @@ -1513,7 +1514,7 @@ class PlaceholderCreateMixin(object): as defaults for attributes. Returns: - List[AbtractAttrDef]: Attribute definitions common for create + List[AbstractAttrDef]: Attribute definitions common for create plugins. """ diff --git a/openpype/plugins/load/add_site.py b/openpype/plugins/load/add_site.py index ac931e41db..e31f746f51 100644 --- a/openpype/plugins/load/add_site.py +++ b/openpype/plugins/load/add_site.py @@ -34,12 +34,24 @@ class AddSyncSite(load.LoaderPlugin): return self._sync_server def load(self, context, name=None, namespace=None, data=None): - self.log.info("Adding {} to representation: {}".format( - data["site_name"], data["_id"])) - family = context["representation"]["context"]["family"] - project_name = data["project_name"] - repre_id = data["_id"] + """"Adds site skeleton information on representation_id + + Looks for loaded containers for workfile, adds them site skeleton too + (eg. they should be downloaded too). + Args: + context (dict): + name (str): + namespace (str): + data (dict): expects {"site_name": SITE_NAME_TO_ADD} + """ + # self.log wont propagate + project_name = context["project"]["name"] + repre_doc = context["representation"] + family = repre_doc["context"]["family"] + repre_id = repre_doc["_id"] site_name = data["site_name"] + print("Adding {} to representation: {}".format( + data["site_name"], repre_id)) self.sync_server.add_site(project_name, repre_id, site_name, force=True) @@ -52,6 +64,8 @@ class AddSyncSite(load.LoaderPlugin): ) for link_repre_id in links: try: + print("Adding {} to linked representation: {}".format( + data["site_name"], link_repre_id)) self.sync_server.add_site(project_name, link_repre_id, site_name, force=False) diff --git a/openpype/plugins/load/remove_site.py b/openpype/plugins/load/remove_site.py index c5f442b2f5..bea8b1b346 100644 --- a/openpype/plugins/load/remove_site.py +++ b/openpype/plugins/load/remove_site.py @@ -3,7 +3,10 @@ from openpype.pipeline import load class RemoveSyncSite(load.LoaderPlugin): - """Remove sync site and its files on representation""" + """Remove sync site and its files on representation. + + Removes files only on local site! + """ representations = ["*"] families = ["*"] @@ -24,13 +27,18 @@ class RemoveSyncSite(load.LoaderPlugin): return self._sync_server def load(self, context, name=None, namespace=None, data=None): - self.log.info("Removing {} on representation: {}".format( - data["site_name"], data["_id"])) - self.sync_server.remove_site(data["project_name"], - data["_id"], - data["site_name"], + project_name = context["project"]["name"] + repre_doc = context["representation"] + repre_id = repre_doc["_id"] + site_name = data["site_name"] + + print("Removing {} on representation: {}".format(site_name, repre_id)) + + self.sync_server.remove_site(project_name, + repre_id, + site_name, True) - self.log.debug("Site added.") + self.log.debug("Site removed.") def filepath_from_context(self, context): """No real file loading""" diff --git a/openpype/plugins/publish/collect_frames_fix.py b/openpype/plugins/publish/collect_frames_fix.py new file mode 100644 index 0000000000..bdd49585a5 --- /dev/null +++ b/openpype/plugins/publish/collect_frames_fix.py @@ -0,0 +1,80 @@ +import pyblish.api +from openpype.lib.attribute_definitions import ( + TextDef, + BoolDef +) + +from openpype.pipeline.publish import OpenPypePyblishPluginMixin +from openpype.client.entities import ( + get_last_version_by_subset_name, + get_representations +) + + +class CollectFramesFixDef( + pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin +): + """Provides text field to insert frame(s) to be rerendered. + + Published files of last version of an instance subset are collected into + instance.data["last_version_published_files"]. All these but frames + mentioned in text field will be reused for new version. + """ + order = pyblish.api.CollectorOrder + 0.495 + label = "Collect Frames to Fix" + targets = ["local"] + hosts = ["nuke"] + families = ["render", "prerender"] + enabled = True + + def process(self, instance): + attribute_values = self.get_attr_values_from_data(instance.data) + frames_to_fix = attribute_values.get("frames_to_fix") + rewrite_version = attribute_values.get("rewrite_version") + + if frames_to_fix: + instance.data["frames_to_fix"] = frames_to_fix + + subset_name = instance.data["subset"] + asset_name = instance.data["asset"] + + project_entity = instance.data["projectEntity"] + project_name = project_entity["name"] + + version = get_last_version_by_subset_name(project_name, + subset_name, + asset_name=asset_name) + if not version: + self.log.warning("No last version found, " + "re-render not possible") + return + + representations = get_representations(project_name, + version_ids=[version["_id"]]) + published_files = [] + for repre in representations: + if repre["context"]["family"] not in self.families: + continue + + for file_info in repre.get("files"): + published_files.append(file_info["path"]) + + instance.data["last_version_published_files"] = published_files + self.log.debug("last_version_published_files::{}".format( + instance.data["last_version_published_files"])) + + if rewrite_version: + instance.data["version"] = version["name"] + # limits triggering version validator + instance.data.pop("latestVersion") + + @classmethod + def get_attribute_defs(cls): + return [ + TextDef("frames_to_fix", label="Frames to fix", + placeholder="5,10-15", + regex="[0-9,-]+"), + BoolDef("rewrite_version", label="Rewrite latest version", + default=False), + ] diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index d3398c885e..5fcf8feb56 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -32,7 +32,7 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): thumbnail_paths_by_instance_id.get(None) ) - project_name = create_context.project_name + project_name = create_context.get_current_project_name() if project_name: context.data["projectName"] = project_name @@ -53,11 +53,15 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): context.data.update(create_context.context_data_to_store()) context.data["newPublishing"] = True # Update context data - for key in ("AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK"): - value = create_context.dbcon.Session.get(key) - if value is not None: - legacy_io.Session[key] = value - os.environ[key] = value + asset_name = create_context.get_current_asset_name() + task_name = create_context.get_current_task_name() + for key, value in ( + ("AVALON_PROJECT", project_name), + ("AVALON_ASSET", asset_name), + ("AVALON_TASK", task_name) + ): + legacy_io.Session[key] = value + os.environ[key] = value def create_instance( self, diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index e72c12d9a9..f659791d95 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -14,16 +14,19 @@ from openpype.pipeline.editorial import ( range_from_frames, make_sequence_collection ) - +from openpype.pipeline.publish import ( + get_publish_template_name +) class CollectOtioSubsetResources(pyblish.api.InstancePlugin): """Get Resources for a subset version""" label = "Collect OTIO Subset Resources" - order = pyblish.api.CollectorOrder - 0.077 + order = pyblish.api.CollectorOrder + 0.491 families = ["clip"] hosts = ["resolve", "hiero", "flame"] + def process(self, instance): if "audio" in instance.data["family"]: @@ -35,14 +38,21 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): if not instance.data.get("versionData"): instance.data["versionData"] = {} + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + publish_template_category = anatomy.templates[template_name] + template = os.path.normpath(publish_template_category["path"]) + self.log.debug( + ">> template: {}".format(template)) + handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] # get basic variables otio_clip = instance.data["otioClip"] - otio_avalable_range = otio_clip.available_range() - media_fps = otio_avalable_range.start_time.rate - available_duration = otio_avalable_range.duration.value + otio_available_range = otio_clip.available_range() + media_fps = otio_available_range.start_time.rate + available_duration = otio_available_range.duration.value # get available range trimmed with processed retimes retimed_attributes = get_media_range_with_retimes( @@ -84,6 +94,11 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): frame_start = instance.data["frameStart"] frame_end = frame_start + (media_out - media_in) + # Fit start /end frame to media in /out + if "{originalBasename}" in template: + frame_start = media_in + frame_end = media_out + # add to version data start and end range data # for loader plugins to be correctly displayed and loaded instance.data["versionData"].update({ @@ -153,7 +168,6 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): repre = self._create_representation( frame_start, frame_end, collection=collection) - instance.data["originalBasename"] = collection.format("{head}") else: _trim = False dirname, filename = os.path.split(media_ref.target_url) @@ -168,8 +182,6 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): repre = self._create_representation( frame_start, frame_end, file=filename, trim=_trim) - instance.data["originalBasename"] = os.path.splitext(filename)[0] - instance.data["originalDirname"] = self.staging_dir if repre: @@ -225,3 +237,26 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): if kwargs.get("trim") is True: representation_data["tags"] = ["trim"] return representation_data + + def get_template_name(self, instance): + """Return anatomy template name to use for integration""" + + # Anatomy data is pre-filled by Collectors + context = instance.context + project_name = context.data["projectName"] + + # Task can be optional in anatomy data + host_name = context.data["hostName"] + family = instance.data["family"] + anatomy_data = instance.data["anatomyData"] + task_info = anatomy_data.get("task") or {} + + return get_publish_template_name( + project_name, + host_name, + family, + task_name=task_info.get("name"), + task_type=task_info.get("type"), + project_settings=context.data["project_settings"], + logger=self.log + ) diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index dcd80fbbdf..4a5f9f1cc2 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -61,7 +61,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "background", "effect", "staticMesh", - "skeletalMesh" + "skeletalMesh", + "xgen" ] def process(self, instance): diff --git a/openpype/plugins/publish/extract_color_transcode.py b/openpype/plugins/publish/extract_color_transcode.py new file mode 100644 index 0000000000..58e0350a2e --- /dev/null +++ b/openpype/plugins/publish/extract_color_transcode.py @@ -0,0 +1,368 @@ +import os +import copy +import clique +import pyblish.api + +from openpype.pipeline import publish +from openpype.lib import ( + + is_oiio_supported, +) + +from openpype.lib.transcoding import ( + convert_colorspace, + get_transcode_temp_directory, +) + +from openpype.lib.profiles_filtering import filter_profiles + + +class ExtractOIIOTranscode(publish.Extractor): + """ + Extractor to convert colors from one colorspace to different. + + Expects "colorspaceData" on representation. This dictionary is collected + previously and denotes that representation files should be converted. + This dict contains source colorspace information, collected by hosts. + + Target colorspace is selected by profiles in the Settings, based on: + - families + - host + - task types + - task names + - subset names + + Can produce one or more representations (with different extensions) based + on output definition in format: + "output_name: { + "extension": "png", + "colorspace": "ACES - ACEScg", + "display": "", + "view": "", + "tags": [], + "custom_tags": [] + } + + If 'extension' is empty original representation extension is used. + 'output_name' will be used as name of new representation. In case of value + 'passthrough' name of original representation will be used. + + 'colorspace' denotes target colorspace to be transcoded into. Could be + empty if transcoding should be only into display and viewer colorspace. + (In that case both 'display' and 'view' must be filled.) + """ + + label = "Transcode color spaces" + order = pyblish.api.ExtractorOrder + 0.019 + + optional = True + + # Supported extensions + supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"] + + # Configurable by Settings + profiles = None + options = None + + def process(self, instance): + if not self.profiles: + self.log.debug("No profiles present for color transcode") + return + + if "representations" not in instance.data: + self.log.debug("No representations, skipping.") + return + + if not is_oiio_supported(): + self.log.warning("OIIO not supported, no transcoding possible.") + return + + profile = self._get_profile(instance) + if not profile: + return + + new_representations = [] + repres = instance.data["representations"] + for idx, repre in enumerate(list(repres)): + self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) + if not self._repre_is_valid(repre): + continue + + added_representations = False + added_review = False + + colorspace_data = repre["colorspaceData"] + source_colorspace = colorspace_data["colorspace"] + config_path = colorspace_data.get("config", {}).get("path") + if not config_path or not os.path.exists(config_path): + self.log.warning("Config file doesn't exist, skipping") + continue + + for output_name, output_def in profile.get("outputs", {}).items(): + new_repre = copy.deepcopy(repre) + + original_staging_dir = new_repre["stagingDir"] + new_staging_dir = get_transcode_temp_directory() + new_repre["stagingDir"] = new_staging_dir + + if isinstance(new_repre["files"], list): + files_to_convert = copy.deepcopy(new_repre["files"]) + else: + files_to_convert = [new_repre["files"]] + + output_extension = output_def["extension"] + output_extension = output_extension.replace('.', '') + self._rename_in_representation(new_repre, + files_to_convert, + output_name, + output_extension) + + transcoding_type = output_def["transcoding_type"] + + target_colorspace = view = display = None + if transcoding_type == "colorspace": + target_colorspace = (output_def["colorspace"] or + colorspace_data.get("colorspace")) + else: + view = output_def["view"] or colorspace_data.get("view") + display = (output_def["display"] or + colorspace_data.get("display")) + + # both could be already collected by DCC, + # but could be overwritten when transcoding + if view: + new_repre["colorspaceData"]["view"] = view + if display: + new_repre["colorspaceData"]["display"] = display + if target_colorspace: + new_repre["colorspaceData"]["colorspace"] = \ + target_colorspace + + additional_command_args = (output_def["oiiotool_args"] + ["additional_command_args"]) + + files_to_convert = self._translate_to_sequence( + files_to_convert) + for file_name in files_to_convert: + input_path = os.path.join(original_staging_dir, + file_name) + output_path = self._get_output_file_path(input_path, + new_staging_dir, + output_extension) + convert_colorspace( + input_path, + output_path, + config_path, + source_colorspace, + target_colorspace, + view, + display, + additional_command_args, + self.log + ) + + # cleanup temporary transcoded files + for file_name in new_repre["files"]: + transcoded_file_path = os.path.join(new_staging_dir, + file_name) + instance.context.data["cleanupFullPaths"].append( + transcoded_file_path) + + custom_tags = output_def.get("custom_tags") + if custom_tags: + if new_repre.get("custom_tags") is None: + new_repre["custom_tags"] = [] + new_repre["custom_tags"].extend(custom_tags) + + # Add additional tags from output definition to representation + if new_repre.get("tags") is None: + new_repre["tags"] = [] + for tag in output_def["tags"]: + if tag not in new_repre["tags"]: + new_repre["tags"].append(tag) + + if tag == "review": + added_review = True + + new_representations.append(new_repre) + added_representations = True + + if added_representations: + self._mark_original_repre_for_deletion(repre, profile, + added_review) + + for repre in tuple(instance.data["representations"]): + tags = repre.get("tags") or [] + if "delete" in tags and "thumbnail" not in tags: + instance.data["representations"].remove(repre) + + instance.data["representations"].extend(new_representations) + + def _rename_in_representation(self, new_repre, files_to_convert, + output_name, output_extension): + """Replace old extension with new one everywhere in representation. + + Args: + new_repre (dict) + files_to_convert (list): of filenames from repre["files"], + standardized to always list + output_name (str): key of output definition from Settings, + if "" token used, keep original repre name + output_extension (str): extension from output definition + """ + if output_name != "passthrough": + new_repre["name"] = output_name + if not output_extension: + return + + new_repre["ext"] = output_extension + + renamed_files = [] + for file_name in files_to_convert: + file_name, _ = os.path.splitext(file_name) + file_name = '{}.{}'.format(file_name, + output_extension) + renamed_files.append(file_name) + new_repre["files"] = renamed_files + + def _rename_in_representation(self, new_repre, files_to_convert, + output_name, output_extension): + """Replace old extension with new one everywhere in representation. + + Args: + new_repre (dict) + files_to_convert (list): of filenames from repre["files"], + standardized to always list + output_name (str): key of output definition from Settings, + if "" token used, keep original repre name + output_extension (str): extension from output definition + """ + if output_name != "passthrough": + new_repre["name"] = output_name + if not output_extension: + return + + new_repre["ext"] = output_extension + + renamed_files = [] + for file_name in files_to_convert: + file_name, _ = os.path.splitext(file_name) + file_name = '{}.{}'.format(file_name, + output_extension) + renamed_files.append(file_name) + new_repre["files"] = renamed_files + + def _translate_to_sequence(self, files_to_convert): + """Returns original list or list with filename formatted in single + sequence format. + + Uses clique to find frame sequence, in this case it merges all frames + into sequence format (FRAMESTART-FRAMEEND#) and returns it. + If sequence not found, it returns original list + + Args: + files_to_convert (list): list of file names + Returns: + (list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr] + """ + pattern = [clique.PATTERNS["frames"]] + collections, remainder = clique.assemble( + files_to_convert, patterns=pattern, + assume_padded_when_ambiguous=True) + + if collections: + if len(collections) > 1: + raise ValueError( + "Too many collections {}".format(collections)) + + collection = collections[0] + frames = list(collection.indexes) + frame_str = "{}-{}#".format(frames[0], frames[-1]) + file_name = "{}{}{}".format(collection.head, frame_str, + collection.tail) + + files_to_convert = [file_name] + + return files_to_convert + + def _get_output_file_path(self, input_path, output_dir, + output_extension): + """Create output file name path.""" + file_name = os.path.basename(input_path) + file_name, input_extension = os.path.splitext(file_name) + if not output_extension: + output_extension = input_extension.replace(".", "") + new_file_name = '{}.{}'.format(file_name, + output_extension) + return os.path.join(output_dir, new_file_name) + + def _get_profile(self, instance): + """Returns profile if and how repre should be color transcoded.""" + host_name = instance.context.data["hostName"] + family = instance.data["family"] + task_data = instance.data["anatomyData"].get("task", {}) + task_name = task_data.get("name") + task_type = task_data.get("type") + subset = instance.data["subset"] + filtering_criteria = { + "hosts": host_name, + "families": family, + "task_names": task_name, + "task_types": task_type, + "subsets": subset + } + profile = filter_profiles(self.profiles, filtering_criteria, + logger=self.log) + + if not profile: + self.log.info(( + "Skipped instance. None of profiles in presets are for" + " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" + " | Task type \"{}\" | Subset \"{}\" " + ).format(host_name, family, task_name, task_type, subset)) + + self.log.debug("profile: {}".format(profile)) + return profile + + def _repre_is_valid(self, repre): + """Validation if representation should be processed. + + Args: + repre (dict): Representation which should be checked. + + Returns: + bool: False if can't be processed else True. + """ + + if repre.get("ext") not in self.supported_exts: + self.log.debug(( + "Representation '{}' of unsupported extension. Skipped." + ).format(repre["name"])) + return False + + if not repre.get("files"): + self.log.debug(( + "Representation '{}' have empty files. Skipped." + ).format(repre["name"])) + return False + + if not repre.get("colorspaceData"): + self.log.debug("Representation '{}' has no colorspace data. " + "Skipped.") + return False + + return True + + def _mark_original_repre_for_deletion(self, repre, profile, added_review): + """If new transcoded representation created, delete old.""" + if not repre.get("tags"): + repre["tags"] = [] + + delete_original = profile["delete_original"] + + if delete_original: + if "delete" not in repre["tags"]: + repre["tags"].append("delete") + + if added_review and "review" in repre["tags"]: + repre["tags"].remove("review") diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index dcb43d7fa2..0f6dacba18 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -169,7 +169,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "Skipped representation. All output definitions from" " selected profile does not match to representation's" " custom tags. \"{}\"" - ).format(str(tags))) + ).format(str(custom_tags))) continue outputs_per_representations.append((repre, outputs)) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index e1219fb68d..b117006871 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -506,6 +506,43 @@ class IntegrateAsset(pyblish.api.InstancePlugin): return version_doc + def _validate_repre_files(self, files, is_sequence_representation): + """Validate representation files before transfer preparation. + + Check if files contain only filenames instead of full paths and check + if sequence don't contain more than one sequence or has remainders. + + Args: + files (Union[str, List[str]]): Files from representation. + is_sequence_representation (bool): Files are for sequence. + + Raises: + KnownPublishError: If validations don't pass. + """ + + if not files: + return + + if not is_sequence_representation: + files = [files] + + if any(os.path.isabs(fname) for fname in files): + raise KnownPublishError("Given file names contain full paths") + + if not is_sequence_representation: + return + + src_collections, remainders = clique.assemble(files) + if len(files) < 2 or len(src_collections) != 1 or remainders: + raise KnownPublishError(( + "Files of representation does not contain proper" + " sequence files.\nCollected collections: {}" + "\nCollected remainders: {}" + ).format( + ", ".join([str(col) for col in src_collections]), + ", ".join([str(rem) for rem in remainders]) + )) + def prepare_representation(self, repre, template_name, existing_repres_by_name, @@ -534,6 +571,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin): template_data["representation"] = repre["name"] template_data["ext"] = repre["ext"] + # allow overwriting existing version + template_data["version"] = version["name"] + # add template data for colorspaceData if repre.get("colorspaceData"): colorspace = repre["colorspaceData"]["colorspace"] @@ -584,7 +624,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): is_udim = bool(repre.get("udim")) # handle publish in place - if "originalDirname" in template: + if "{originalDirname}" in template: # store as originalDirname only original value without project root # if instance collected originalDirname is present, it should be # used for all represe @@ -603,24 +643,64 @@ class IntegrateAsset(pyblish.api.InstancePlugin): template_data["originalDirname"] = without_root is_sequence_representation = isinstance(files, (list, tuple)) - if is_sequence_representation: - # Collection of files (sequence) - if any(os.path.isabs(fname) for fname in files): - raise KnownPublishError("Given file names contain full paths") + self._validate_repre_files(files, is_sequence_representation) + # Output variables of conditions below: + # - transfers (List[Tuple[str, str]]): src -> dst filepaths to copy + # - repre_context (Dict[str, Any]): context data used to fill template + # - template_data (Dict[str, Any]): source data used to fill template + # - to add required data to 'repre_context' not used for + # formatting + # - anatomy_filled (Dict[str, Any]): filled anatomy of last file + # - to fill 'publishDir' on instance.data -> not ideal + + # Treat template with 'orignalBasename' in special way + if "{originalBasename}" in template: + # Remove 'frame' from template data + template_data.pop("frame", None) + + # Find out first frame string value + first_index_padded = None + if not is_udim and is_sequence_representation: + col = clique.assemble(files)[0][0] + sorted_frames = tuple(sorted(col.indexes)) + # First frame used for end value + first_frame = sorted_frames[0] + # Get last frame for padding + last_frame = sorted_frames[-1] + # Use padding from collection of length of last frame as string + padding = max(col.padding, len(str(last_frame))) + first_index_padded = get_frame_padded( + frame=first_frame, + padding=padding + ) + + # Convert files to list for single file as remaining part is only + # transfers creation (iteration over files) + if not is_sequence_representation: + files = [files] + + repre_context = None + transfers = [] + for src_file_name in files: + template_data["originalBasename"], _ = os.path.splitext( + src_file_name) + + anatomy_filled = anatomy.format(template_data) + dst = anatomy_filled[template_name]["path"] + src = os.path.join(stagingdir, src_file_name) + transfers.append((src, dst)) + if repre_context is None: + repre_context = dst.used_values + + if not is_udim and first_index_padded is not None: + repre_context["frame"] = first_index_padded + + elif is_sequence_representation: + # Collection of files (sequence) src_collections, remainders = clique.assemble(files) - if len(files) < 2 or len(src_collections) != 1 or remainders: - raise KnownPublishError(( - "Files of representation does not contain proper" - " sequence files.\nCollected collections: {}" - "\nCollected remainders: {}" - ).format( - ", ".join([str(col) for col in src_collections]), - ", ".join([str(rem) for rem in remainders]) - )) src_collection = src_collections[0] - template_data["originalBasename"] = src_collection.head[:-1] destination_indexes = list(src_collection.indexes) # Use last frame for minimum padding # - that should cover both 'udim' and 'frame' minimum padding @@ -642,11 +722,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # In case source are published in place we need to # skip renumbering repre_frame_start = repre.get("frameStart") - if ( - "originalBasename" not in template - and repre_frame_start is not None - ): - index_frame_start = int(repre["frameStart"]) + if repre_frame_start is not None: + index_frame_start = int(repre_frame_start) # Shift destination sequence to the start frame destination_indexes = [ index_frame_start + idx @@ -702,15 +779,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): else: # Single file - fname = files - if os.path.isabs(fname): - self.log.error( - "Filename in representation is filepath {}".format(fname) - ) - raise KnownPublishError( - "This is a bug. Representation file name is full path" - ) - template_data["originalBasename"], _ = os.path.splitext(fname) # Manage anatomy template data template_data.pop("frame", None) if is_udim: @@ -722,7 +790,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): dst = os.path.normpath(template_filled) # Single file transfer - src = os.path.join(stagingdir, fname) + src = os.path.join(stagingdir, files) transfers = [(src, dst)] # todo: Are we sure the assumption each representation diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 05d383415b..e796f7b376 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -386,6 +386,25 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): repre["_id"] = old_repre["_id"] update_data = prepare_representation_update_data( old_repre, repre) + + # Keep previously synchronized sites up-to-date + # by comparing old and new sites and adding old sites + # if missing in new ones + old_repre_files_sites = [ + f.get("sites", []) for f in old_repre.get("files", []) + ] + for i, file in enumerate(repre.get("files", [])): + repre_sites_names = { + s["name"] for s in file.get("sites", []) + } + for site in old_repre_files_sites[i]: + if site["name"] not in repre_sites_names: + # Pop the date to tag for sync + site.pop("created_dt", None) + file["sites"].append(site) + + update_data["files"][i] = file + op_session.update_entity( project_name, old_repre["type"], diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 0685b2e52a..16558642c6 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -157,11 +157,21 @@ def _get_views_data(config_path): config = ocio.Config().CreateFromFile(str(config_path)) - return { - f"{d}/{v}": {"display": d, "view": v} - for d in config.getDisplays() - for v in config.getViews(d) - } + data = {} + for display in config.getDisplays(): + for view in config.getViews(display): + colorspace = config.getDisplayViewColorSpaceName(display, view) + # Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa + if colorspace == "": + colorspace = display + + data[f"{display}/{view}"] = { + "display": display, + "view": view, + "colorspace": colorspace + } + + return data if __name__ == '__main__': diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 7223e8d4de..cb4646c099 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -52,7 +52,16 @@ def _get_ffprobe_data(source): "-show_streams", source ] - proc = subprocess.Popen(command, stdout=subprocess.PIPE) + kwargs = { + "stdout": subprocess.PIPE, + } + if platform.system().lower() == "windows": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) + proc = subprocess.Popen(command, **kwargs) out = proc.communicate()[0] if proc.returncode != 0: raise RuntimeError("Failed to run: %s" % command) @@ -331,22 +340,26 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): ) print("Launching command: {}".format(command)) - proc = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - shell=True - ) + kwargs = { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "shell": True, + } + if platform.system().lower() == "windows": + kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0) + | getattr(subprocess, "CREATE_NO_WINDOW", 0) + ) + proc = subprocess.Popen(command, **kwargs) _stdout, _stderr = proc.communicate() if _stdout: - for line in _stdout.split(b"\r\n"): - print(line.decode("utf-8")) + print(_stdout.decode("utf-8", errors="backslashreplace")) # This will probably never happen as ffmpeg use stdout if _stderr: - for line in _stderr.split(b"\r\n"): - print(line.decode("utf-8")) + print(_stderr.decode("utf-8", errors="backslashreplace")) if proc.returncode != 0: raise RuntimeError( diff --git a/openpype/settings/defaults/project_anatomy/attributes.json b/openpype/settings/defaults/project_anatomy/attributes.json index bf8bbef8de..0cc414fb69 100644 --- a/openpype/settings/defaults/project_anatomy/attributes.json +++ b/openpype/settings/defaults/project_anatomy/attributes.json @@ -23,4 +23,4 @@ ], "tools_env": [], "active": true -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index caa2a8a206..d38d0a0774 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -255,4 +255,4 @@ ] } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_anatomy/roots.json b/openpype/settings/defaults/project_anatomy/roots.json index ce295e946f..8171d17d56 100644 --- a/openpype/settings/defaults/project_anatomy/roots.json +++ b/openpype/settings/defaults/project_anatomy/roots.json @@ -4,4 +4,4 @@ "darwin": "/Volumes/path", "linux": "/mnt/share/projects" } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_anatomy/tasks.json b/openpype/settings/defaults/project_anatomy/tasks.json index 74504cc4d7..135462839f 100644 --- a/openpype/settings/defaults/project_anatomy/tasks.json +++ b/openpype/settings/defaults/project_anatomy/tasks.json @@ -41,4 +41,4 @@ "Compositing": { "short_name": "comp" } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index 32230e0625..02c0e35377 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -55,7 +55,7 @@ }, "source": { "folder": "{root[work]}/{originalDirname}", - "file": "{originalBasename}<.{@frame}><_{udim}>.{ext}", + "file": "{originalBasename}.{ext}", "path": "{@folder}/{@file}" }, "__dynamic_keys_labels__": { @@ -66,4 +66,4 @@ "source": "source" } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/aftereffects.json b/openpype/settings/defaults/project_settings/aftereffects.json index e4b957fb85..669e1db0b8 100644 --- a/openpype/settings/defaults/project_settings/aftereffects.json +++ b/openpype/settings/defaults/project_settings/aftereffects.json @@ -33,4 +33,4 @@ "create_first_version": false, "custom_templates": [] } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 3585d2ad0a..fe05f94590 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -82,4 +82,4 @@ "active": false } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/celaction.json b/openpype/settings/defaults/project_settings/celaction.json index ad01e62d95..bdba6d7322 100644 --- a/openpype/settings/defaults/project_settings/celaction.json +++ b/openpype/settings/defaults/project_settings/celaction.json @@ -16,4 +16,4 @@ "anatomy_template_key_metadata": "render" } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index ceb0b2e39a..7183603c4b 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -33,7 +33,20 @@ "limit": [], "jobInfo": {}, "pluginInfo": {}, - "scene_patches": [] + "scene_patches": [], + "strict_error_checking": true + }, + "MaxSubmitDeadline": { + "enabled": true, + "optional": false, + "active": true, + "use_published": true, + "priority": 50, + "chunk_size": 10, + "group": "none", + "deadline_pool": "", + "deadline_pool_secondary": "", + "framePerTask": 1 }, "NukeSubmitDeadline": { "enabled": true, @@ -102,8 +115,11 @@ ], "harmony": [ ".*" + ], + "max": [ + ".*" ] } } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index cbd99c4560..5a13d81384 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -114,17 +114,6 @@ "render", "review" ], - "representations": [ - "exr", - "dpx", - "jpg", - "jpeg", - "png", - "h264", - "mov", - "mp4", - "exr16fpdwaa" - ], "reel_group_name": "OpenPype_Reels", "reel_name": "Loaded", "clip_name_template": "{asset}_{subset}<_{output}>", @@ -143,17 +132,6 @@ "render", "review" ], - "representations": [ - "exr", - "dpx", - "jpg", - "jpeg", - "png", - "h264", - "mov", - "mp4", - "exr16fpdwaa" - ], "reel_name": "OP_LoadedReel", "clip_name_template": "{batch}_{asset}_{subset}<_{output}>", "layer_rename_template": "{asset}_{subset}<_{output}>", @@ -163,4 +141,4 @@ ] } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index cdf861df4a..4ca4a35d1f 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -324,7 +324,8 @@ "animation", "look", "rig", - "camera" + "camera", + "renderlayer" ], "task_types": [], "tasks": [], @@ -488,10 +489,11 @@ }, "keep_first_subset_name_for_review": true, "asset_versions_status_profiles": [], - "additional_metadata_keys": [] + "additional_metadata_keys": [], + "upload_reviewable_with_origin_name": false }, "IntegrateFtrackFarmStatus": { "farm_status_profiles": [] } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json index 720178e17a..954606820a 100644 --- a/openpype/settings/defaults/project_settings/fusion.json +++ b/openpype/settings/defaults/project_settings/fusion.json @@ -17,4 +17,4 @@ } } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 0e078dc157..a5e2d25a88 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -68,6 +68,10 @@ "output": [] } }, + "ExtractOIIOTranscode": { + "enabled": true, + "profiles": [] + }, "ExtractReview": { "enabled": true, "profiles": [ @@ -607,4 +611,4 @@ "linux": [] }, "project_environments": {} -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/harmony.json b/openpype/settings/defaults/project_settings/harmony.json index 1f4ea88272..3f51a9c28b 100644 --- a/openpype/settings/defaults/project_settings/harmony.json +++ b/openpype/settings/defaults/project_settings/harmony.json @@ -50,4 +50,4 @@ "skip_timelines_check": [] } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/hiero.json b/openpype/settings/defaults/project_settings/hiero.json index c6180d0a58..100c1f5b47 100644 --- a/openpype/settings/defaults/project_settings/hiero.json +++ b/openpype/settings/defaults/project_settings/hiero.json @@ -60,16 +60,6 @@ "render", "review" ], - "representations": [ - "exr", - "dpx", - "jpg", - "jpeg", - "png", - "h264", - "mov", - "mp4" - ], "clip_name_template": "{asset}_{subset}_{representation}" } }, @@ -97,4 +87,4 @@ } ] } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 68cc8945fe..1b7faf8526 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -76,4 +76,4 @@ "active": true } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/kitsu.json b/openpype/settings/defaults/project_settings/kitsu.json index 3a9723b9c0..95b3da04ae 100644 --- a/openpype/settings/defaults/project_settings/kitsu.json +++ b/openpype/settings/defaults/project_settings/kitsu.json @@ -10,4 +10,4 @@ "note_status_shortname": "wfa" } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json new file mode 100644 index 0000000000..667b42411d --- /dev/null +++ b/openpype/settings/defaults/project_settings/max.json @@ -0,0 +1,8 @@ +{ + "RenderSettings": { + "default_render_image_folder": "renders/3dsmax", + "aov_separator": "underscore", + "image_format": "exr", + "multipass": true + } +} diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 31591bf734..dca0b95293 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1,4 +1,5 @@ { + "open_workfile_post_initialization": false, "imageio": { "ocio_config": { "enabled": false, @@ -93,6 +94,16 @@ "force_combine": true, "aov_list": [], "additional_options": [] + }, + "renderman_renderer": { + "image_prefix": "{aov_separator}..", + "image_dir": "/", + "display_filters": [], + "imageDisplay_dir": "/{aov_separator}imageDisplayFilter..", + "sample_filters": [], + "cryptomatte_dir": "/{aov_separator}cryptomatte..", + "watermark_dir": "/{aov_separator}watermarkFilter..", + "additional_options": [] } }, "create": { @@ -136,6 +147,7 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, + "include_user_defined_attributes": false, "defaults": [ "Main" ] @@ -154,6 +166,7 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, + "include_user_defined_attributes": false, "defaults": [ "Main" ] @@ -166,6 +179,13 @@ "Main" ] }, + "CreateReview": { + "enabled": true, + "defaults": [ + "Main" + ], + "useMayaTimeline": true + }, "CreateAss": { "enabled": true, "defaults": [ @@ -186,6 +206,14 @@ "maskColor_manager": false, "maskOperator": false }, + "CreateVrayProxy": { + "enabled": true, + "vrmesh": true, + "alembic": true, + "defaults": [ + "Main" + ] + }, "CreateMultiverseUsd": { "enabled": true, "defaults": [ @@ -234,12 +262,6 @@ "Main" ] }, - "CreateReview": { - "enabled": true, - "defaults": [ - "Main" - ] - }, "CreateRig": { "enabled": true, "defaults": [ @@ -255,12 +277,6 @@ "Anim" ] }, - "CreateVrayProxy": { - "enabled": true, - "defaults": [ - "Main" - ] - }, "CreateVRayScene": { "enabled": true, "defaults": [ @@ -396,6 +412,16 @@ "optional": false, "active": true }, + "ValidateGLSLMaterial": { + "enabled": true, + "optional": false, + "active": true + }, + "ValidateGLSLPlugin": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateRenderImageRule": { "enabled": true, "optional": false, @@ -653,7 +679,7 @@ "families": [ "pointcache", "model", - "vrayproxy" + "vrayproxy.alembic" ] }, "ExtractObj": { @@ -804,6 +830,11 @@ "twoSidedLighting": true, "lineAAEnable": true, "multiSample": 8, + "useDefaultMaterial": false, + "wireframeOnShaded": false, + "xray": false, + "jointXray": false, + "backfaceCulling": false, "ssaoEnable": false, "ssaoAmount": 1, "ssaoRadius": 16, @@ -882,6 +913,11 @@ "optional": true, "active": true, "bake_attributes": [] + }, + "ExtractGLB": { + "enabled": true, + "active": true, + "ogsfx_path": "/maya2glTF/PBR/shaders/glTF_PBR.ogsfx" } }, "load": { @@ -1087,4 +1123,4 @@ "ValidateNoAnimation": false } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index cd8ea02272..2545411e0a 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -246,6 +246,7 @@ "sourcetype": "python", "title": "Gizmo Note", "command": "nuke.nodes.StickyNote(label='You can create your own toolbar menu in the Nuke GizmoMenu of OpenPype')", + "icon": "", "shortcut": "" } ] @@ -445,6 +446,41 @@ "value": false } ], + "reformat_nodes_config": { + "enabled": false, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": [ + { + "type": "text", + "name": "type", + "value": "to format" + }, + { + "type": "text", + "name": "format", + "value": "HD_1080" + }, + { + "type": "text", + "name": "filter", + "value": "Lanczos6" + }, + { + "type": "bool", + "name": "black_outside", + "value": true + }, + { + "type": "bool", + "name": "pbb", + "value": false + } + ] + } + ] + }, "extension": "mov", "add_custom_tags": [] } @@ -532,4 +568,4 @@ "profiles": [] }, "filters": {} -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index cdfab0c439..bcf21f55dd 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -67,4 +67,4 @@ "create_first_version": false, "custom_templates": [] } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/resolve.json b/openpype/settings/defaults/project_settings/resolve.json index 66013c5ac7..264f3bd902 100644 --- a/openpype/settings/defaults/project_settings/resolve.json +++ b/openpype/settings/defaults/project_settings/resolve.json @@ -27,4 +27,4 @@ "handleEnd": 10 } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/royalrender.json b/openpype/settings/defaults/project_settings/royalrender.json index be267b11d8..b72fed8474 100644 --- a/openpype/settings/defaults/project_settings/royalrender.json +++ b/openpype/settings/defaults/project_settings/royalrender.json @@ -4,4 +4,4 @@ "review": true } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/shotgrid.json b/openpype/settings/defaults/project_settings/shotgrid.json index 774bce714b..83b6f69074 100644 --- a/openpype/settings/defaults/project_settings/shotgrid.json +++ b/openpype/settings/defaults/project_settings/shotgrid.json @@ -19,4 +19,4 @@ "step": "step" } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/slack.json b/openpype/settings/defaults/project_settings/slack.json index c156fed08e..910f099d04 100644 --- a/openpype/settings/defaults/project_settings/slack.json +++ b/openpype/settings/defaults/project_settings/slack.json @@ -17,4 +17,4 @@ ] } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/standalonepublisher.json b/openpype/settings/defaults/project_settings/standalonepublisher.json index b6e2e056a1..d923b4db43 100644 --- a/openpype/settings/defaults/project_settings/standalonepublisher.json +++ b/openpype/settings/defaults/project_settings/standalonepublisher.json @@ -304,4 +304,4 @@ } } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index 8a222a6dd2..fdea4aeaba 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -321,4 +321,4 @@ "active": true } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/tvpaint.json b/openpype/settings/defaults/project_settings/tvpaint.json index 5a3e1dc2df..9173a8c3d5 100644 --- a/openpype/settings/defaults/project_settings/tvpaint.json +++ b/openpype/settings/defaults/project_settings/tvpaint.json @@ -10,10 +10,47 @@ } }, "stop_timer_on_application_exit": false, + "create": { + "create_workfile": { + "enabled": true, + "default_variant": "Main", + "default_variants": [] + }, + "create_review": { + "enabled": true, + "active_on_create": true, + "default_variant": "Main", + "default_variants": [] + }, + "create_render_scene": { + "enabled": true, + "active_on_create": false, + "mark_for_review": true, + "default_pass_name": "beauty", + "default_variant": "Main", + "default_variants": [] + }, + "create_render_layer": { + "mark_for_review": false, + "default_pass_name": "beauty", + "default_variant": "Main", + "default_variants": [] + }, + "create_render_pass": { + "mark_for_review": false, + "default_variant": "Main", + "default_variants": [] + }, + "auto_detect_render": { + "allow_group_rename": true, + "group_name_template": "L{group_index}", + "group_idx_offset": 10, + "group_idx_padding": 3 + } + }, "publish": { - "CollectRenderScene": { - "enabled": false, - "render_layer": "Main" + "CollectRenderInstances": { + "ignore_render_pass_transparency": false }, "ExtractSequence": { "review_bg": [ @@ -75,4 +112,4 @@ "custom_templates": [] }, "filters": {} -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/unreal.json b/openpype/settings/defaults/project_settings/unreal.json index b06bf28714..75cee11bd9 100644 --- a/openpype/settings/defaults/project_settings/unreal.json +++ b/openpype/settings/defaults/project_settings/unreal.json @@ -14,4 +14,4 @@ "project_setup": { "dev_mode": true } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/project_settings/webpublisher.json b/openpype/settings/defaults/project_settings/webpublisher.json index 27eac131b7..e830ba6a40 100644 --- a/openpype/settings/defaults/project_settings/webpublisher.json +++ b/openpype/settings/defaults/project_settings/webpublisher.json @@ -141,4 +141,4 @@ "layer_name_regex": "(?PL[0-9]{3}_\\w+)_(?P.+)" } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index d78b54fa05..219fe7ea53 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -337,6 +337,134 @@ } } }, + "nukeassist": { + "enabled": true, + "label": "Nuke Assist", + "icon": "{}/app_icons/nuke.png", + "host_name": "nuke", + "environment": { + "NUKE_PATH": [ + "{NUKE_PATH}", + "{OPENPYPE_STUDIO_PLUGINS}/nuke" + ] + }, + "variants": { + "13-2": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke13.2v1\\Nuke13.2.exe" + ], + "darwin": [], + "linux": [ + "/usr/local/Nuke13.2v1/Nuke13.2" + ] + }, + "arguments": { + "windows": ["--nukeassist"], + "darwin": ["--nukeassist"], + "linux": ["--nukeassist"] + }, + "environment": {} + }, + "13-0": { + "use_python_2": false, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke13.0v1\\Nuke13.0.exe" + ], + "darwin": [], + "linux": [ + "/usr/local/Nuke13.0v1/Nuke13.0" + ] + }, + "arguments": { + "windows": ["--nukeassist"], + "darwin": ["--nukeassist"], + "linux": ["--nukeassist"] + }, + "environment": {} + }, + "12-2": { + "use_python_2": true, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke12.2v3\\Nuke12.2.exe" + ], + "darwin": [], + "linux": [ + "/usr/local/Nuke12.2v3Nuke12.2" + ] + }, + "arguments": { + "windows": ["--nukeassist"], + "darwin": ["--nukeassist"], + "linux": ["--nukeassist"] + }, + "environment": {} + }, + "12-0": { + "use_python_2": true, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" + ], + "darwin": [], + "linux": [ + "/usr/local/Nuke12.0v1/Nuke12.0" + ] + }, + "arguments": { + "windows": ["--nukeassist"], + "darwin": ["--nukeassist"], + "linux": ["--nukeassist"] + }, + "environment": {} + }, + "11-3": { + "use_python_2": true, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" + ], + "darwin": [], + "linux": [ + "/usr/local/Nuke11.3v5/Nuke11.3" + ] + }, + "arguments": { + "windows": ["--nukeassist"], + "darwin": ["--nukeassist"], + "linux": ["--nukeassist"] + }, + "environment": {} + }, + "11-2": { + "use_python_2": true, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": ["--nukeassist"], + "darwin": ["--nukeassist"], + "linux": ["--nukeassist"] + }, + "environment": {} + }, + "__dynamic_keys_labels__": { + "13-2": "13.2", + "13-0": "13.0", + "12-2": "12.2", + "12-0": "12.0", + "11-3": "11.3", + "11-2": "11.2" + } + } + }, "nukex": { "enabled": true, "label": "Nuke X", @@ -1302,7 +1430,9 @@ "variant_label": "Current", "use_python_2": false, "executables": { - "windows": ["C:/Program Files/CelAction/CelAction2D Studio/CelAction2D.exe"], + "windows": [ + "C:/Program Files/CelAction/CelAction2D Studio/CelAction2D.exe" + ], "darwin": [], "linux": [] }, @@ -1392,4 +1522,4 @@ } }, "additional_apps": {} -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index 909ffc1ee4..d2994d1a62 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -18,4 +18,4 @@ "production_version": "", "staging_version": "", "version_check_interval": 5 -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 703e72cb5d..1ddbfd2726 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -211,4 +211,4 @@ "linux": "" } } -} \ No newline at end of file +} diff --git a/openpype/settings/defaults/system_settings/tools.json b/openpype/settings/defaults/system_settings/tools.json index 243cde40cc..921e13af3a 100644 --- a/openpype/settings/defaults/system_settings/tools.json +++ b/openpype/settings/defaults/system_settings/tools.json @@ -87,4 +87,4 @@ "renderman": "Pixar Renderman" } } -} \ No newline at end of file +} diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index ff76fa5180..f2e24fb522 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -440,8 +440,9 @@ class RootEntity(BaseItemEntity): os.makedirs(dirpath) self.log.debug("Saving data to: {}\n{}".format(subpath, value)) + data = json.dumps(value, indent=4) + "\n" with open(output_path, "w") as file_stream: - json.dump(value, file_stream, indent=4) + file_stream.write(data) dynamic_values_item = self.collect_dynamic_schema_entities() dynamic_values_item.save_values() diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index b3c5c62a89..4d68683608 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -82,6 +82,10 @@ "type": "schema", "name": "schema_project_slack" }, + { + "type": "schema", + "name": "schema_project_max" + }, { "type": "schema", "name": "schema_project_maya" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 08a505bd47..a320dfca4f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -195,6 +195,71 @@ ] } + }, + { + "type": "boolean", + "key": "strict_error_checking", + "label": "Strict Error Checking", + "default": true + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "MaxSubmitDeadline", + "label": "3dsMax Submit to Deadline", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "boolean", + "key": "use_published", + "label": "Use Published scene" + }, + { + "type": "number", + "key": "priority", + "label": "Priority" + }, + { + "type": "number", + "key": "chunk_size", + "label": "Chunk Size" + }, + { + "type": "text", + "key": "group", + "label": "Group Name" + }, + { + "type": "text", + "key": "deadline_pool", + "label": "Deadline pool" + }, + { + "type": "text", + "key": "deadline_pool_secondary", + "label": "Deadline pool (secondary)" + }, + { + "type": "number", + "key": "framePerTask", + "label": "Frame Per Task" } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index 0f20c0efbe..aab8f21d15 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -494,12 +494,6 @@ "label": "Families", "object_type": "text" }, - { - "type": "list", - "key": "representations", - "label": "Representations", - "object_type": "text" - }, { "type": "separator" }, @@ -552,12 +546,6 @@ "label": "Families", "object_type": "text" }, - { - "type": "list", - "key": "representations", - "label": "Representations", - "object_type": "text" - }, { "type": "separator" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index da414cc961..7050721742 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -1037,6 +1037,21 @@ {"fps": "FPS"}, {"code": "Codec"} ] + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "upload_reviewable_with_origin_name", + "label": "Upload reviewable with origin name" + }, + { + "type": "label", + "label": "Note: Reviewable will be uploaded twice into ftrack when enabled. One with original name and second with required 'ftrackreview-mp4'. That may cause dramatic increase of ftrack storage usage." + }, + { + "type": "separator" } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json b/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json index 03bfb56ad1..f44f92438c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json @@ -266,12 +266,6 @@ "label": "Families", "object_type": "text" }, - { - "type": "list", - "key": "representations", - "label": "Representations", - "object_type": "text" - }, { "type": "text", "key": "clip_name_template", @@ -334,4 +328,4 @@ "name": "schema_scriptsmenu" } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json new file mode 100644 index 0000000000..8a283c1acc --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json @@ -0,0 +1,56 @@ +{ + "type": "dict", + "collapsible": true, + "key": "max", + "label": "Max", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "RenderSettings", + "label": "Render Settings", + "children": [ + { + "type": "text", + "key": "default_render_image_folder", + "label": "Default render image folder" + }, + { + "key": "aov_separator", + "label": "AOV Separator character", + "type": "enum", + "multiselection": false, + "default": "underscore", + "enum_items": [ + {"dash": "- (dash)"}, + {"underscore": "_ (underscore)"}, + {"dot": ". (dot)"} + ] + }, + { + "key": "image_format", + "label": "Output Image Format", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"bmp": "bmp"}, + {"exr": "exr"}, + {"tif": "tif"}, + {"tiff": "tiff"}, + {"jpg": "jpg"}, + {"png": "png"}, + {"tga": "tga"}, + {"dds": "dds"} + ] + }, + { + "type": "boolean", + "key": "multipass", + "label": "multipass" + } + ] + } + ] +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index d83666b5b2..47dfb37024 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -5,6 +5,11 @@ "label": "Maya", "is_file": true, "children": [ + { + "type": "boolean", + "key": "open_workfile_post_initialization", + "label": "Open Workfile Post Initialization" + }, { "key": "imageio", "type": "dict", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index b1a8cc1812..26c64e6219 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -17,6 +17,11 @@ "key": "menu", "label": "OpenPype Menu shortcuts", "children": [ + { + "type": "text", + "key": "create", + "label": "Create..." + }, { "type": "text", "key": "publish", @@ -288,4 +293,4 @@ "name": "schema_publish_gui_filter" } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json index db38c938dc..708b688ba5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -30,14 +30,14 @@ { "type": "dict", "collapsible": true, - "key": "publish", - "label": "Publish plugins", + "key": "create", + "label": "Create plugins", "children": [ { "type": "dict", "collapsible": true, - "key": "CollectRenderScene", - "label": "Collect Render Scene", + "key": "create_workfile", + "label": "Create Workfile", "is_group": true, "checkbox_key": "enabled", "children": [ @@ -47,13 +47,211 @@ "label": "Enabled" }, { - "type": "label", - "label": "It is possible to fill 'render_layer' or 'variant' in subset name template with custom value.
- value of 'render_pass' is always \"beauty\"." + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default variants", + "object_type": { + "type": "text" + } + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "create_review", + "label": "Create Review", + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "active_on_create", + "label": "Active by default" }, { "type": "text", - "key": "render_layer", - "label": "Render Layer" + "key": "default_variant", + "label": "Default variant" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default variants", + "object_type": { + "type": "text" + } + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "create_render_scene", + "label": "Create Render Scene", + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "active_on_create", + "label": "Active by default" + }, + { + "type": "boolean", + "key": "mark_for_review", + "label": "Review by default" + }, + { + "type": "text", + "key": "default_pass_name", + "label": "Default beauty pass" + }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default variants", + "object_type": { + "type": "text" + } + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "create_render_layer", + "label": "Create Render Layer", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "mark_for_review", + "label": "Review by default" + }, + { + "type": "text", + "key": "default_pass_name", + "label": "Default beauty pass" + }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default variants", + "object_type": { + "type": "text" + } + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "create_render_pass", + "label": "Create Render Pass", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "mark_for_review", + "label": "Review by default" + }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default variants", + "object_type": { + "type": "text" + } + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "auto_detect_render", + "label": "Auto-Detect Create Render", + "is_group": true, + "children": [ + { + "type": "label", + "label": "The creator tries to auto-detect Render Layers and Render Passes in scene. For Render Layers is used group name as a variant and for Render Passes is used TVPaint layer name.

Group names can be renamed by their used order in scene. The renaming template where can be used {group_index} formatting key which is filled by \"used position index of group\".
- Template: L{group_index}
- Group offset: 10
- Group padding: 3
Would create group names \"L010\", \"L020\", ..." + }, + { + "type": "boolean", + "key": "allow_group_rename", + "label": "Allow group rename" + }, + { + "type": "text", + "key": "group_name_template", + "label": "Group name template" + }, + { + "key": "group_idx_offset", + "label": "Group index Offset", + "type": "number", + "decimal": 0, + "minimum": 1 + }, + { + "key": "group_idx_padding", + "type": "number", + "label": "Group index Padding", + "decimal": 0, + "minimum": 1 + } + ] + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CollectRenderInstances", + "label": "Collect Render Instances", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "ignore_render_pass_transparency", + "label": "Ignore Render Pass opacity" } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 5388d04bc9..76574e8b9b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -197,6 +197,136 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ExtractOIIOTranscode", + "label": "Extract OIIO Transcode", + "checkbox_key": "enabled", + "is_group": true, + "children": [ + { + "type": "label", + "label": "Configure Output Definition(s) for new representation(s). \nEmpty 'Extension' denotes keeping source extension. \nName(key) of output definition will be used as new representation name \nunless 'passthrough' value is used to keep existing name. \nFill either 'Colorspace' (for target colorspace) or \nboth 'Display' and 'View' (for display and viewer colorspaces)." + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "profiles", + "label": "Profiles", + "object_type": { + "type": "dict", + "children": [ + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "hosts", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "task_names", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "key": "subsets", + "label": "Subset names", + "type": "list", + "object_type": "text" + }, + { + "type": "boolean", + "key": "delete_original", + "label": "Delete Original Representation" + }, + { + "type": "splitter" + }, + { + "key": "outputs", + "label": "Output Definitions", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "extension", + "label": "Extension", + "type": "text" + }, + { + "type": "enum", + "key": "transcoding_type", + "label": "Transcoding type", + "enum_items": [ + { "colorspace": "Use Colorspace" }, + { "display": "Use Display&View" } + ] + }, + { + "key": "colorspace", + "label": "Colorspace", + "type": "text" + }, + { + "key": "display", + "label": "Display", + "type": "text" + }, + { + "key": "view", + "label": "View", + "type": "text" + }, + { + "key": "oiiotool_args", + "label": "OIIOtool arguments", + "type": "dict", + "highlight_content": true, + "children": [ + { + "key": "additional_command_args", + "label": "Arguments", + "type": "list", + "object_type": "text" + } + ] + }, + { + "type": "schema", + "name": "schema_representation_tags" + }, + { + "key": "custom_tags", + "label": "Custom Tags", + "type": "list", + "object_type": "text" + } + ] + } + } + ] + } + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json index 62c33f55fc..1f0e4eeffb 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json @@ -184,6 +184,10 @@ { "type": "splitter" }, + { + "type": "label", + "label": "Display" + }, { "type":"boolean", "key": "renderDepthOfField", @@ -221,6 +225,31 @@ { "type": "splitter" }, + { + "type": "boolean", + "key": "useDefaultMaterial", + "label": "Use Default Material" + }, + { + "type": "boolean", + "key": "wireframeOnShaded", + "label": "Wireframe On Shaded" + }, + { + "type": "boolean", + "key": "xray", + "label": "X-Ray" + }, + { + "type": "boolean", + "key": "jointXray", + "label": "X-Ray Joints" + }, + { + "type": "boolean", + "key": "backfaceCulling", + "label": "Backface Culling" + }, { "type": "boolean", "key": "ssaoEnable", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index e1a3082616..1598f90643 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -132,6 +132,11 @@ "key": "write_face_sets", "label": "Write Face Sets" }, + { + "type": "boolean", + "key": "include_user_defined_attributes", + "label": "Include User Defined Attributes" + }, { "type": "list", "key": "defaults", @@ -192,6 +197,11 @@ "key": "write_face_sets", "label": "Write Face Sets" }, + { + "type": "boolean", + "key": "include_user_defined_attributes", + "label": "Include User Defined Attributes" + }, { "type": "list", "key": "defaults", @@ -230,6 +240,31 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CreateReview", + "label": "Create Review", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "defaults", + "label": "Default Subsets", + "object_type": "text" + }, + { + "type": "boolean", + "key": "useMayaTimeline", + "label": "Use Maya Timeline for Frame Range." + } + ] + }, { "type": "dict", "collapsible": true, @@ -322,6 +357,36 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CreateVrayProxy", + "label": "Create VRay Proxy", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "vrmesh", + "label": "VrMesh" + }, + { + "type": "boolean", + "key": "alembic", + "label": "Alembic" + }, + { + "type": "list", + "key": "defaults", + "label": "Default Subsets", + "object_type": "text" + } + ] + }, { "type": "schema_template", "name": "template_create_plugin", @@ -358,10 +423,6 @@ "key": "CreateRenderSetup", "label": "Create Render Setup" }, - { - "key": "CreateReview", - "label": "Create Review" - }, { "key": "CreateRig", "label": "Create Rig" @@ -370,10 +431,6 @@ "key": "CreateSetDress", "label": "Create Set Dress" }, - { - "key": "CreateVrayProxy", - "label": "Create VRay Proxy" - }, { "key": "CreateVRayScene", "label": "Create VRay Scene" diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 873bb79c95..994e2d0032 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -408,6 +408,14 @@ "key": "ValidateCurrentRenderLayerIsRenderable", "label": "Validate Current Render Layer Has Renderable Camera" }, + { + "key": "ValidateGLSLMaterial", + "label": "Validate GLSL Material" + }, + { + "key": "ValidateGLSLPlugin", + "label": "Validate GLSL Plugin" + }, { "key": "ValidateRenderImageRule", "label": "Validate Images File Rule (Workspace)" @@ -956,6 +964,30 @@ "is_list": true } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ExtractGLB", + "label": "Extract GLB", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "text", + "key": "ogsfx_path", + "label": "GLSL Shader Directory" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json index e90495891b..636dfa114c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json @@ -423,6 +423,93 @@ } } ] - } + }, + { + "type": "dict", + "collapsible": true, + "key": "renderman_renderer", + "label": "Renderman Renderer", + "is_group": true, + "children": [ + { + "key": "image_prefix", + "label": "Image prefix template", + "type": "text" + }, + { + "key": "image_dir", + "label": "Image Output Directory", + "type": "text" + }, + { + "key": "display_filters", + "label": "Display Filters", + "type": "enum", + "multiselection": true, + "defaults": "empty", + "enum_items": [ + {"PxrBackgroundDisplayFilter": "PxrBackgroundDisplayFilter"}, + {"PxrCopyAOVDisplayFilter": "PxrCopyAOVDisplayFilter"}, + {"PxrEdgeDetect":"PxrEdgeDetect"}, + {"PxrFilmicTonemapperDisplayFilter": "PxrFilmicTonemapperDisplayFilter"}, + {"PxrGradeDisplayFilter": "PxrGradeDisplayFilter"}, + {"PxrHalfBufferErrorFilter": "PxrHalfBufferErrorFilter"}, + {"PxrImageDisplayFilter": "PxrImageDisplayFilter"}, + {"PxrLightSaturation": "PxrLightSaturation"}, + {"PxrShadowDisplayFilter": "PxrShadowDisplayFilter"}, + {"PxrStylizedHatching": "PxrStylizedHatching"}, + {"PxrStylizedLines": "PxrStylizedLines"}, + {"PxrStylizedToon": "PxrStylizedToon"}, + {"PxrWhitePointDisplayFilter": "PxrWhitePointDisplayFilter"} + ] + }, + { + "key": "imageDisplay_dir", + "label": "Image Display Filter Directory", + "type": "text" + }, + { + "key": "sample_filters", + "label": "Sample Filters", + "type": "enum", + "multiselection": true, + "defaults": "empty", + "enum_items": [ + {"PxrBackgroundSampleFilter": "PxrBackgroundSampleFilter"}, + {"PxrCopyAOVSampleFilter": "PxrCopyAOVSampleFilter"}, + {"PxrCryptomatte": "PxrCryptomatte"}, + {"PxrFilmicTonemapperSampleFilter": "PxrFilmicTonemapperSampleFilter"}, + {"PxrGradeSampleFilter": "PxrGradeSampleFilter"}, + {"PxrShadowFilter": "PxrShadowFilter"}, + {"PxrWatermarkFilter": "PxrWatermarkFilter"}, + {"PxrWhitePointSampleFilter": "PxrWhitePointSampleFilter"} + ] + }, + { + "key": "cryptomatte_dir", + "label": "Cryptomatte Output Directory", + "type": "text" + }, + { + "key": "watermark_dir", + "label": "Watermark Filter Directory", + "type": "text" + }, + { + "type": "label", + "label": "Add additional options - put attribute and value, like Ci" + }, + { + "type": "dict-modifiable", + "store_as_list": true, + "key": "additional_options", + "label": "Additional Renderer Options", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + } + ] + } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 5b9145e7d9..1c542279fc 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -271,6 +271,10 @@ { "type": "separator" }, + { + "type": "label", + "label": "Currently we are supporting also multiple reposition nodes.
Older single reformat node is still supported
and if it is activated then preference will
be on it. If you want to use multiple reformat
nodes then you need to disable single reformat
node and enable multiple Reformat nodes here." + }, { "type": "boolean", "key": "reformat_node_add", @@ -287,6 +291,49 @@ } ] }, + { + "key": "reformat_nodes_config", + "type": "dict", + "label": "Reformat Nodes", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Reposition knobs supported only.
You can add multiple reformat nodes
and set their knobs. Order of reformat
nodes is important. First reformat node
will be applied first and last reformat
node will be applied last." + }, + { + "key": "reposition_nodes", + "type": "list", + "label": "Reposition nodes", + "object_type": { + "type": "dict", + "children": [ + { + "key": "node_class", + "label": "Node class", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } + } + ] + }, { "type": "separator" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json index abe14970c5..e4c65177a7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_scriptsgizmo.json @@ -72,6 +72,11 @@ "key": "command", "label": "Python command" }, + { + "type": "text", + "key": "icon", + "label": "Icon Path" + }, { "type": "text", "key": "shortcut", diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json index 06a805086a..abea37a9ab 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -25,6 +25,14 @@ "nuke_label": "Nuke" } }, + { + "type": "schema_template", + "name": "template_nuke", + "template_data": { + "nuke_type": "nukeassist", + "nuke_label": "Nuke Assist" + } + }, { "type": "schema_template", "name": "template_nuke", diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index 373029d9df..a1f3331ccc 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -9,6 +9,7 @@ import six import openpype.version from openpype.client.mongo import OpenPypeMongoConnection from openpype.client.entities import get_project_connection, get_project +from openpype.lib.pype_info import get_workstation_info from .constants import ( GLOBAL_SETTINGS_KEY, @@ -235,6 +236,18 @@ class SettingsHandler(object): """ pass + @abstractmethod + def save_change_log(self, project_name, changes, settings_type): + """Stores changes to settings to separate logging collection. + + Args: + project_name(str, null): Project name for which overrides are + or None for global settings. + changes(dict): Data of project overrides with override metadata. + settings_type (str): system|project|anatomy + """ + pass + @abstractmethod def get_studio_system_settings_overrides(self, return_version): """Studio overrides of system settings.""" @@ -913,6 +926,32 @@ class MongoSettingsHandler(SettingsHandler): return data + def save_change_log(self, project_name, changes, settings_type): + """Log all settings changes to separate collection""" + if not changes: + return + + if settings_type == "project" and not project_name: + project_name = "default" + + host_info = get_workstation_info() + + document = { + "local_id": host_info["local_id"], + "username": host_info["username"], + "hostname": host_info["hostname"], + "hostip": host_info["hostip"], + "system_name": host_info["system_name"], + "date_created": datetime.datetime.now(), + "project": project_name, + "settings_type": settings_type, + "changes": changes + } + collection_name = "settings_log" + collection = (self.settings_collection[self.database_name] + [collection_name]) + collection.insert_one(document) + def _save_project_anatomy_data(self, project_name, data_cache): # Create copy of data as they will be modified during save new_data = data_cache.data_copy() diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 796eaeda01..73554df236 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -159,6 +159,7 @@ def save_studio_settings(data): except SaveWarningExc as exc: warnings.extend(exc.warnings) + _SETTINGS_HANDLER.save_change_log(None, changes, "system") _SETTINGS_HANDLER.save_studio_settings(data) if warnings: raise SaveWarningExc(warnings) @@ -218,7 +219,7 @@ def save_project_settings(project_name, overrides): ) except SaveWarningExc as exc: warnings.extend(exc.warnings) - + _SETTINGS_HANDLER.save_change_log(project_name, changes, "project") _SETTINGS_HANDLER.save_project_settings(project_name, overrides) if warnings: @@ -280,6 +281,7 @@ def save_project_anatomy(project_name, anatomy_data): except SaveWarningExc as exc: warnings.extend(exc.warnings) + _SETTINGS_HANDLER.save_change_log(project_name, changes, "anatomy") _SETTINGS_HANDLER.save_project_anatomy(project_name, anatomy_data) if warnings: diff --git a/openpype/tests/test_lib_restructuralization.py b/openpype/tests/test_lib_restructuralization.py index c8952e5a1c..669706d470 100644 --- a/openpype/tests/test_lib_restructuralization.py +++ b/openpype/tests/test_lib_restructuralization.py @@ -5,11 +5,9 @@ def test_backward_compatibility(printer): printer("Test if imports still work") try: - from openpype.lib import filter_pyblish_plugins from openpype.lib import execute_hook from openpype.lib import PypeHook - from openpype.lib import get_latest_version from openpype.lib import ApplicationLaunchFailed from openpype.lib import get_ffmpeg_tool_path @@ -18,10 +16,6 @@ def test_backward_compatibility(printer): from openpype.lib import get_version_from_path from openpype.lib import version_up - from openpype.lib import is_latest - from openpype.lib import any_outdated - from openpype.lib import get_asset - from openpype.lib import get_linked_assets from openpype.lib import get_ffprobe_streams from openpype.hosts.fusion.lib import switch_item diff --git a/openpype/tests/test_pyblish_filter.py b/openpype/tests/test_pyblish_filter.py index ea23da26e4..b74784145f 100644 --- a/openpype/tests/test_pyblish_filter.py +++ b/openpype/tests/test_pyblish_filter.py @@ -1,9 +1,9 @@ -from . import lib +import os import pyblish.api import pyblish.util import pyblish.plugin -from openpype.lib import filter_pyblish_plugins -import os +from openpype.pipeline.publish.lib import filter_pyblish_plugins +from . import lib def test_pyblish_plugin_filter_modifier(printer, monkeypatch): diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index bf61dc3776..18e2e13d06 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -4,7 +4,7 @@ import copy from qtpy import QtWidgets, QtCore from openpype.lib.attribute_definitions import ( - AbtractAttrDef, + AbstractAttrDef, UnknownDef, HiddenDef, NumberDef, @@ -16,7 +16,11 @@ from openpype.lib.attribute_definitions import ( UISeparatorDef, UILabelDef ) -from openpype.tools.utils import CustomTextComboBox +from openpype.tools.utils import ( + CustomTextComboBox, + FocusSpinBox, + FocusDoubleSpinBox, +) from openpype.widgets.nice_checkbox import NiceCheckbox from .files_widget import FilesWidget @@ -33,9 +37,9 @@ def create_widget_for_attr_def(attr_def, parent=None): def _create_widget_for_attr_def(attr_def, parent=None): - if not isinstance(attr_def, AbtractAttrDef): + if not isinstance(attr_def, AbstractAttrDef): raise TypeError("Unexpected type \"{}\" expected \"{}\"".format( - str(type(attr_def)), AbtractAttrDef + str(type(attr_def)), AbstractAttrDef )) if isinstance(attr_def, NumberDef): @@ -142,6 +146,9 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): if attr_def.label: label_widget = QtWidgets.QLabel(attr_def.label, self) + tooltip = attr_def.tooltip + if tooltip: + label_widget.setToolTip(tooltip) layout.addWidget( label_widget, row, 0, 1, expand_cols ) @@ -243,10 +250,10 @@ class NumberAttrWidget(_BaseAttrDefWidget): def _ui_init(self): decimals = self.attr_def.decimals if decimals > 0: - input_widget = QtWidgets.QDoubleSpinBox(self) + input_widget = FocusDoubleSpinBox(self) input_widget.setDecimals(decimals) else: - input_widget = QtWidgets.QSpinBox(self) + input_widget = FocusSpinBox(self) if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) diff --git a/openpype/tools/loader/lib.py b/openpype/tools/loader/lib.py index 552dc91a10..d47bc7e07a 100644 --- a/openpype/tools/loader/lib.py +++ b/openpype/tools/loader/lib.py @@ -2,7 +2,7 @@ import inspect from qtpy import QtGui import qtawesome -from openpype.lib.attribute_definitions import AbtractAttrDef +from openpype.lib.attribute_definitions import AbstractAttrDef from openpype.tools.attribute_defs import AttributeDefinitionsDialog from openpype.tools.utils.widgets import ( OptionalAction, @@ -43,7 +43,7 @@ def get_options(action, loader, parent, repre_contexts): if not getattr(action, "optioned", False) or not loader_options: return options - if isinstance(loader_options[0], AbtractAttrDef): + if isinstance(loader_options[0], AbstractAttrDef): qargparse_options = False dialog = AttributeDefinitionsDialog(loader_options, parent) else: diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index c0e68fcc7a..98ac9c871f 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -339,7 +339,7 @@ class SubsetWidget(QtWidgets.QWidget): repre_docs = get_representations( project_name, version_ids=version_ids, - fields=["name", "parent"] + fields=["name", "parent", "data", "context"] ) repre_docs_by_version_id = { @@ -1264,7 +1264,7 @@ class RepresentationWidget(QtWidgets.QWidget): repre_docs = list(get_representations( project_name, representation_ids=repre_ids, - fields=["name", "parent"] + fields=["name", "parent", "data", "context"] )) version_ids = [ @@ -1480,23 +1480,21 @@ class RepresentationWidget(QtWidgets.QWidget): repre_ids = [] data_by_repre_id = {} selected_side = action_representation.get("selected_side") + site_name = "{}_site_name".format(selected_side) is_sync_loader = tools_lib.is_sync_loader(loader) for item in items: - item_id = item.get("_id") - repre_ids.append(item_id) + repre_id = item["_id"] + repre_ids.append(repre_id) if not is_sync_loader: continue - site_name = "{}_site_name".format(selected_side) data_site_name = item.get(site_name) if not data_site_name: continue - data_by_repre_id[item_id] = { - "_id": item_id, - "site_name": data_site_name, - "project_name": self.dbcon.active_project() + data_by_repre_id[repre_id] = { + "site_name": data_site_name } repre_contexts = get_repres_contexts(repre_ids, self.dbcon) @@ -1586,8 +1584,8 @@ def _load_representations_by_loader(loader, repre_contexts, version_name = version_doc.get("name") try: if data_by_repre_id: - _id = repre_context["representation"]["_id"] - data = data_by_repre_id.get(_id) + repre_id = repre_context["representation"]["_id"] + data = data_by_repre_id.get(repre_id) options.update(data) load_with_repre_context( loader, diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py index e9fdd4774a..b2bfd7dd5c 100644 --- a/openpype/tools/publisher/constants.py +++ b/openpype/tools/publisher/constants.py @@ -24,6 +24,7 @@ CREATOR_THUMBNAIL_ENABLED_ROLE = QtCore.Qt.UserRole + 5 FAMILY_ROLE = QtCore.Qt.UserRole + 6 GROUP_ROLE = QtCore.Qt.UserRole + 7 CONVERTER_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 8 +CREATOR_SORT_ROLE = QtCore.Qt.UserRole + 9 __all__ = ( @@ -36,6 +37,7 @@ __all__ = ( "IS_GROUP_ROLE", "CREATOR_IDENTIFIER_ROLE", "CREATOR_THUMBNAIL_ENABLED_ROLE", + "CREATOR_SORT_ROLE", "FAMILY_ROLE", "GROUP_ROLE", "CONVERTER_IDENTIFIER_ROLE", diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 50a814de5c..49e7eeb4f7 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -18,6 +18,7 @@ from openpype.client import ( ) from openpype.lib.events import EventSystem from openpype.lib.attribute_definitions import ( + UIDef, serialize_attr_defs, deserialize_attr_defs, ) @@ -169,6 +170,8 @@ class PublishReport: def __init__(self, controller): self.controller = controller + self._create_discover_result = None + self._convert_discover_result = None self._publish_discover_result = None self._plugin_data = [] self._plugin_data_with_plugin = [] @@ -181,6 +184,10 @@ class PublishReport: def reset(self, context, create_context): """Reset report and clear all data.""" + self._create_discover_result = create_context.creator_discover_result + self._convert_discover_result = ( + create_context.convertor_discover_result + ) self._publish_discover_result = create_context.publish_discover_result self._plugin_data = [] self._plugin_data_with_plugin = [] @@ -293,9 +300,19 @@ class PublishReport: if plugin not in self._stored_plugins: plugins_data.append(self._create_plugin_data_item(plugin)) - crashed_file_paths = {} + reports = [] + if self._create_discover_result is not None: + reports.append(self._create_discover_result) + + if self._convert_discover_result is not None: + reports.append(self._convert_discover_result) + if self._publish_discover_result is not None: - items = self._publish_discover_result.crashed_file_paths.items() + reports.append(self._publish_discover_result) + + crashed_file_paths = {} + for report in reports: + items = report.crashed_file_paths.items() for filepath, exc_info in items: crashed_file_paths[filepath] = "".join( traceback.format_exception(*exc_info) @@ -826,14 +843,14 @@ class CreatorItem: label, group_label, icon, - instance_attributes_defs, description, detailed_description, default_variant, default_variants, create_allow_context_change, create_allow_thumbnail, - pre_create_attributes_defs + show_order, + pre_create_attributes_defs, ): self.identifier = identifier self.creator_type = creator_type @@ -847,12 +864,9 @@ class CreatorItem: self.default_variants = default_variants self.create_allow_context_change = create_allow_context_change self.create_allow_thumbnail = create_allow_thumbnail - self.instance_attributes_defs = instance_attributes_defs + self.show_order = show_order self.pre_create_attributes_defs = pre_create_attributes_defs - def get_instance_attr_defs(self): - return self.instance_attributes_defs - def get_group_label(self): return self.group_label @@ -874,6 +888,7 @@ class CreatorItem: pre_create_attr_defs = None create_allow_context_change = None create_allow_thumbnail = None + show_order = creator.order if creator_type is CreatorTypes.artist: description = creator.get_description() detail_description = creator.get_detail_description() @@ -882,6 +897,7 @@ class CreatorItem: pre_create_attr_defs = creator.get_pre_create_attr_defs() create_allow_context_change = creator.create_allow_context_change create_allow_thumbnail = creator.create_allow_thumbnail + show_order = creator.show_order identifier = creator.identifier return cls( @@ -891,26 +907,20 @@ class CreatorItem: creator.label or identifier, creator.get_group_label(), creator.get_icon(), - creator.get_instance_attr_defs(), description, detail_description, default_variant, default_variants, create_allow_context_change, create_allow_thumbnail, - pre_create_attr_defs + show_order, + pre_create_attr_defs, ) def to_data(self): - instance_attributes_defs = None - if self.instance_attributes_defs is not None: - instance_attributes_defs = serialize_attr_defs( - self.instance_attributes_defs - ) - pre_create_attributes_defs = None if self.pre_create_attributes_defs is not None: - instance_attributes_defs = serialize_attr_defs( + pre_create_attributes_defs = serialize_attr_defs( self.pre_create_attributes_defs ) @@ -927,18 +937,12 @@ class CreatorItem: "default_variants": self.default_variants, "create_allow_context_change": self.create_allow_context_change, "create_allow_thumbnail": self.create_allow_thumbnail, - "instance_attributes_defs": instance_attributes_defs, + "show_order": self.show_order, "pre_create_attributes_defs": pre_create_attributes_defs, } @classmethod def from_data(cls, data): - instance_attributes_defs = data["instance_attributes_defs"] - if instance_attributes_defs is not None: - data["instance_attributes_defs"] = deserialize_attr_defs( - instance_attributes_defs - ) - pre_create_attributes_defs = data["pre_create_attributes_defs"] if pre_create_attributes_defs is not None: data["pre_create_attributes_defs"] = deserialize_attr_defs( @@ -1521,9 +1525,6 @@ class BasePublisherController(AbstractPublisherController): def _reset_attributes(self): """Reset most of attributes that can be reset.""" - # Reset creator items - self._creator_items = None - self.publish_is_running = False self.publish_has_validated = False self.publish_has_crashed = False @@ -1589,20 +1590,19 @@ class PublisherController(BasePublisherController): Handle both creation and publishing parts. Args: - dbcon (AvalonMongoDB): Connection to mongo with context. headless (bool): Headless publishing. ATM not implemented or used. """ _log = None - def __init__(self, dbcon=None, headless=False): + def __init__(self, headless=False): super(PublisherController, self).__init__() self._host = registered_host() self._headless = headless self._create_context = CreateContext( - self._host, dbcon, headless=headless, reset=False + self._host, headless=headless, reset=False ) self._publish_plugins_proxy = None @@ -1756,7 +1756,7 @@ class PublisherController(BasePublisherController): self._create_context.reset_preparation() # Reset avalon context - self._create_context.reset_avalon_context() + self._create_context.reset_current_context() self._asset_docs_cache.reset() @@ -1779,6 +1779,8 @@ class PublisherController(BasePublisherController): self._resetting_plugins = True self._create_context.reset_plugins() + # Reset creator items + self._creator_items = None self._resetting_plugins = False @@ -1879,12 +1881,12 @@ class PublisherController(BasePublisherController): which should be attribute definitions returned. """ + # NOTE it would be great if attrdefs would have hash method implemented + # so they could be used as keys in dictionary output = [] _attr_defs = {} for instance in instances: - creator_identifier = instance.creator_identifier - creator_item = self.creator_items[creator_identifier] - for attr_def in creator_item.instance_attributes_defs: + for attr_def in instance.creator_attribute_defs: found_idx = None for idx, _attr_def in _attr_defs.items(): if attr_def == _attr_def: @@ -1937,6 +1939,8 @@ class PublisherController(BasePublisherController): plugin_values = all_plugin_values[plugin_name] for attr_def in attr_defs: + if isinstance(attr_def, UIDef): + continue if attr_def.key not in plugin_values: plugin_values[attr_def.key] = [] attr_values = plugin_values[attr_def.key] @@ -2018,9 +2022,10 @@ class PublisherController(BasePublisherController): success = True try: - self._create_context.create( + self._create_context.create_with_unified_error( creator_identifier, subset_name, instance_data, options ) + except CreatorsOperationFailed as exc: success = False self._emit_event( diff --git a/openpype/tools/publisher/control_qt.py b/openpype/tools/publisher/control_qt.py index 3639c4bb30..132b42f9ec 100644 --- a/openpype/tools/publisher/control_qt.py +++ b/openpype/tools/publisher/control_qt.py @@ -136,10 +136,7 @@ class QtRemotePublishController(BasePublisherController): created_instances = {} for serialized_data in serialized_instances: - item = CreatedInstance.deserialize_on_remote( - serialized_data, - self._creator_items - ) + item = CreatedInstance.deserialize_on_remote(serialized_data) created_instances[item.id] = item self._created_instances = created_instances diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index 47f8ebb914..3fd5243ce9 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -385,6 +385,7 @@ class InstanceCardWidget(CardWidget): self._last_subset_name = None self._last_variant = None + self._last_label = None icon_widget = IconValuePixmapLabel(group_icon, self) icon_widget.setObjectName("FamilyIconLabel") @@ -462,14 +463,17 @@ class InstanceCardWidget(CardWidget): def _update_subset_name(self): variant = self.instance["variant"] subset_name = self.instance["subset"] + label = self.instance.label if ( variant == self._last_variant and subset_name == self._last_subset_name + and label == self._last_label ): return self._last_variant = variant self._last_subset_name = subset_name + self._last_label = label # Make `variant` bold label = html_escape(self.instance.label) found_parts = set(re.findall(variant, label, re.IGNORECASE)) diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py index 07b124f616..ef9c5b98fe 100644 --- a/openpype/tools/publisher/widgets/create_widget.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -18,9 +18,10 @@ from .tasks_widget import CreateWidgetTasksWidget from .precreate_widget import PreCreateWidget from ..constants import ( VARIANT_TOOLTIP, - CREATOR_IDENTIFIER_ROLE, FAMILY_ROLE, + CREATOR_IDENTIFIER_ROLE, CREATOR_THUMBNAIL_ENABLED_ROLE, + CREATOR_SORT_ROLE, ) SEPARATORS = ("---separator---", "---") @@ -90,12 +91,19 @@ class CreatorShortDescWidget(QtWidgets.QWidget): self._description_label.setText(description) +class CreatorsProxyModel(QtCore.QSortFilterProxyModel): + def lessThan(self, left, right): + l_show_order = left.data(CREATOR_SORT_ROLE) + r_show_order = right.data(CREATOR_SORT_ROLE) + if l_show_order == r_show_order: + return super(CreatorsProxyModel, self).lessThan(left, right) + return l_show_order < r_show_order + + class CreateWidget(QtWidgets.QWidget): def __init__(self, controller, parent=None): super(CreateWidget, self).__init__(parent) - self.setWindowTitle("Create new instance") - self._controller = controller self._asset_name = None @@ -141,7 +149,7 @@ class CreateWidget(QtWidgets.QWidget): creators_view = QtWidgets.QListView(creators_view_widget) creators_model = QtGui.QStandardItemModel() - creators_sort_model = QtCore.QSortFilterProxyModel() + creators_sort_model = CreatorsProxyModel() creators_sort_model.setSourceModel(creators_model) creators_view.setModel(creators_sort_model) @@ -441,28 +449,33 @@ class CreateWidget(QtWidgets.QWidget): # Add new families new_creators = set() - for identifier, creator_item in self._controller.creator_items.items(): + creator_items_by_identifier = self._controller.creator_items + for identifier, creator_item in creator_items_by_identifier.items(): if creator_item.creator_type != "artist": continue # TODO add details about creator new_creators.add(identifier) if identifier in existing_items: + is_new = False item = existing_items[identifier] else: + is_new = True item = QtGui.QStandardItem() item.setFlags( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable ) - self._creators_model.appendRow(item) item.setData(creator_item.label, QtCore.Qt.DisplayRole) + item.setData(creator_item.show_order, CREATOR_SORT_ROLE) item.setData(identifier, CREATOR_IDENTIFIER_ROLE) item.setData( creator_item.create_allow_thumbnail, CREATOR_THUMBNAIL_ENABLED_ROLE ) item.setData(creator_item.family, FAMILY_ROLE) + if is_new: + self._creators_model.appendRow(item) # Remove families that are no more available for identifier in (old_creators - new_creators): @@ -482,8 +495,9 @@ class CreateWidget(QtWidgets.QWidget): index = indexes[0] identifier = index.data(CREATOR_IDENTIFIER_ROLE) + create_item = creator_items_by_identifier.get(identifier) - self._set_creator_by_identifier(identifier) + self._set_creator(create_item) def _on_plugins_refresh(self): # Trigger refresh only if is visible diff --git a/openpype/tools/publisher/widgets/overview_widget.py b/openpype/tools/publisher/widgets/overview_widget.py index 022de2dc34..8706daeda6 100644 --- a/openpype/tools/publisher/widgets/overview_widget.py +++ b/openpype/tools/publisher/widgets/overview_widget.py @@ -146,6 +146,7 @@ class OverviewWidget(QtWidgets.QFrame): self._subset_list_view = subset_list_view self._subset_views_layout = subset_views_layout + self._create_btn = create_btn self._delete_btn = delete_btn self._subset_attributes_widget = subset_attributes_widget @@ -388,11 +389,13 @@ class OverviewWidget(QtWidgets.QFrame): def _on_publish_start(self): """Publish started.""" + self._create_btn.setEnabled(False) self._subset_attributes_wrap.setEnabled(False) def _on_publish_reset(self): """Context in controller has been refreshed.""" + self._create_btn.setEnabled(True) self._subset_attributes_wrap.setEnabled(True) self._subset_content_widget.setEnabled(self._controller.host_is_valid) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 2e8d0ce37c..86475460aa 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -9,7 +9,7 @@ import collections from qtpy import QtWidgets, QtCore, QtGui import qtawesome -from openpype.lib.attribute_definitions import UnknownDef +from openpype.lib.attribute_definitions import UnknownDef, UIDef from openpype.tools.attribute_defs import create_widget_for_attr_def from openpype.tools import resources from openpype.tools.flickcharm import FlickCharm @@ -250,21 +250,25 @@ class PublishReportBtn(PublishIconBtn): self._actions = [] def add_action(self, label, identifier): - action = QtWidgets.QAction(label) - action.setData(identifier) - action.triggered.connect( - functools.partial(self._on_action_trigger, action) + self._actions.append( + (label, identifier) ) - self._actions.append(action) - def _on_action_trigger(self, action): - identifier = action.data() + def _on_action_trigger(self, identifier): self.triggered.emit(identifier) def mouseReleaseEvent(self, event): super(PublishReportBtn, self).mouseReleaseEvent(event) menu = QtWidgets.QMenu(self) - menu.addActions(self._actions) + actions = [] + for item in self._actions: + label, identifier = item + action = QtWidgets.QAction(label, menu) + action.triggered.connect( + functools.partial(self._on_action_trigger, identifier) + ) + actions.append(action) + menu.addActions(actions) menu.exec_(event.globalPos()) @@ -1220,7 +1224,8 @@ class GlobalAttrsWidget(QtWidgets.QWidget): asset_task_combinations = [] for instance in instances: - if instance.creator is None: + # NOTE I'm not sure how this can even happen? + if instance.creator_identifier is None: editable = False variants.add(instance.get("variant") or self.unknown_value) @@ -1437,7 +1442,16 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): ) content_widget = QtWidgets.QWidget(self._scroll_area) - content_layout = QtWidgets.QFormLayout(content_widget) + attr_def_widget = QtWidgets.QWidget(content_widget) + attr_def_layout = QtWidgets.QGridLayout(attr_def_widget) + attr_def_layout.setColumnStretch(0, 0) + attr_def_layout.setColumnStretch(1, 1) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.addWidget(attr_def_widget, 0) + content_layout.addStretch(1) + + row = 0 for plugin_name, attr_defs, all_plugin_values in result: plugin_values = all_plugin_values[plugin_name] @@ -1454,8 +1468,29 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): hidden_widget = True if not hidden_widget: + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + + col_num = 2 - expand_cols label = attr_def.label or attr_def.key - content_layout.addRow(label, widget) + if label: + label_widget = QtWidgets.QLabel(label, content_widget) + tooltip = attr_def.tooltip + if tooltip: + label_widget.setToolTip(tooltip) + attr_def_layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + attr_def_layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + row += 1 + + if isinstance(attr_def, UIDef): + continue widget.value_changed.connect(self._input_value_changed) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 097e289f32..74977d65d8 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -366,7 +366,7 @@ class PublisherWindow(QtWidgets.QDialog): def make_sure_is_visible(self): if self._window_is_visible: - self.setWindowState(QtCore.Qt.ActiveWindow) + self.setWindowState(QtCore.Qt.WindowActive) else: self.show() @@ -561,29 +561,30 @@ class PublisherWindow(QtWidgets.QDialog): return self._tabs_widget.is_current_tab(identifier) def _go_to_create_tab(self): - self._set_current_tab("create") + if self._create_tab.isEnabled(): + self._set_current_tab("create") def _go_to_publish_tab(self): self._set_current_tab("publish") - def _go_to_details_tab(self): - self._set_current_tab("details") - def _go_to_report_tab(self): self._set_current_tab("report") + def _go_to_details_tab(self): + self._set_current_tab("details") + def _is_on_create_tab(self): return self._is_current_tab("create") def _is_on_publish_tab(self): return self._is_current_tab("publish") - def _is_on_details_tab(self): - return self._is_current_tab("details") - def _is_on_report_tab(self): return self._is_current_tab("report") + def _is_on_details_tab(self): + return self._is_current_tab("details") + def _set_publish_overlay_visibility(self, visible): if visible: widget = self._publish_overlay @@ -647,16 +648,10 @@ class PublisherWindow(QtWidgets.QDialog): # otherwise 'create' is used # - this happens only on first show if first_reset: - if self._overview_widget.has_items(): - self._go_to_publish_tab() - else: - self._go_to_create_tab() + self._go_to_create_tab() - elif ( - not self._is_on_create_tab() - and not self._is_on_publish_tab() - ): - # If current tab is not 'Create' or 'Publish' go to 'Publish' + elif self._is_on_report_tab(): + # Go to 'Publish' tab if is on 'Details' tab # - this can happen when publishing started and was reset # at that moment it doesn't make sense to stay at publish # specific tabs. diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py index 47baeaebea..4aaad38bbc 100644 --- a/openpype/tools/sceneinventory/switch_dialog.py +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -2,7 +2,6 @@ import collections import logging from qtpy import QtWidgets, QtCore import qtawesome -from bson.objectid import ObjectId from openpype.client import ( get_asset_by_name, @@ -161,7 +160,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_ids = set() content_loaders = set() for item in self._items: - repre_ids.add(ObjectId(item["representation"])) + repre_ids.add(str(item["representation"])) content_loaders.add(item["loader"]) project_name = self.active_project() @@ -170,7 +169,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): representation_ids=repre_ids, archived=True )) - repres_by_id = {repre["_id"]: repre for repre in repres} + repres_by_id = {str(repre["_id"]): repre for repre in repres} # stash context values, works only for single representation if len(repres) == 1: @@ -181,22 +180,22 @@ class SwitchAssetDialog(QtWidgets.QDialog): content_repres = {} archived_repres = [] missing_repres = [] - version_ids = [] + version_ids = set() for repre_id in repre_ids: if repre_id not in repres_by_id: missing_repres.append(repre_id) elif repres_by_id[repre_id]["type"] == "archived_representation": repre = repres_by_id[repre_id] archived_repres.append(repre) - version_ids.append(repre["parent"]) + version_ids.add(repre["parent"]) else: repre = repres_by_id[repre_id] content_repres[repre_id] = repres_by_id[repre_id] - version_ids.append(repre["parent"]) + version_ids.add(repre["parent"]) versions = get_versions( project_name, - version_ids=set(version_ids), + version_ids=version_ids, hero=True ) content_versions = {} @@ -1249,7 +1248,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_docs_by_parent_id_by_name[parent_id][name] = repre_doc for container in self._items: - container_repre_id = ObjectId(container["representation"]) + container_repre_id = container["representation"] container_repre = self.content_repres[container_repre_id] container_repre_name = container_repre["name"] diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 3c4e03a195..a04171e429 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -4,7 +4,6 @@ from functools import partial from qtpy import QtWidgets, QtCore import qtawesome -from bson.objectid import ObjectId from openpype.client import ( get_version_by_id, @@ -84,22 +83,20 @@ class SceneInventoryView(QtWidgets.QTreeView): if not items: return - repre_ids = [] - for item in items: - item_id = ObjectId(item["representation"]) - if item_id not in repre_ids: - repre_ids.append(item_id) + repre_ids = { + item["representation"] + for item in items + } project_name = legacy_io.active_project() repre_docs = get_representations( project_name, representation_ids=repre_ids, fields=["parent"] ) - version_ids = [] - for repre_doc in repre_docs: - version_id = repre_doc["parent"] - if version_id not in version_ids: - version_ids.append(version_id) + version_ids = { + repre_doc["parent"] + for repre_doc in repre_docs + } loaded_versions = get_versions( project_name, version_ids=version_ids, hero=True @@ -107,18 +104,17 @@ class SceneInventoryView(QtWidgets.QTreeView): loaded_hero_versions = [] versions_by_parent_id = collections.defaultdict(list) - version_parents = [] + subset_ids = set() for version in loaded_versions: if version["type"] == "hero_version": loaded_hero_versions.append(version) else: parent_id = version["parent"] versions_by_parent_id[parent_id].append(version) - if parent_id not in version_parents: - version_parents.append(parent_id) + subset_ids.add(parent_id) all_versions = get_versions( - project_name, subset_ids=version_parents, hero=True + project_name, subset_ids=subset_ids, hero=True ) hero_versions = [] versions = [] @@ -146,11 +142,10 @@ class SceneInventoryView(QtWidgets.QTreeView): switch_to_versioned = None if has_loaded_hero_versions: def _on_switch_to_versioned(items): - repre_ids = [] - for item in items: - item_id = ObjectId(item["representation"]) - if item_id not in repre_ids: - repre_ids.append(item_id) + repre_ids = { + item["representation"] + for item in items + } repre_docs = get_representations( project_name, @@ -158,13 +153,13 @@ class SceneInventoryView(QtWidgets.QTreeView): fields=["parent"] ) - version_ids = [] + version_ids = set() version_id_by_repre_id = {} for repre_doc in repre_docs: version_id = repre_doc["parent"] - version_id_by_repre_id[repre_doc["_id"]] = version_id - if version_id not in version_ids: - version_ids.append(version_id) + repre_id = str(repre_doc["_id"]) + version_id_by_repre_id[repre_id] = version_id + version_ids.add(version_id) hero_versions = get_hero_versions( project_name, @@ -172,10 +167,10 @@ class SceneInventoryView(QtWidgets.QTreeView): fields=["version_id"] ) - version_ids = set() + hero_src_version_ids = set() for hero_version in hero_versions: version_id = hero_version["version_id"] - version_ids.add(version_id) + hero_src_version_ids.add(version_id) hero_version_id = hero_version["_id"] for _repre_id, current_version_id in ( version_id_by_repre_id.items() @@ -185,7 +180,7 @@ class SceneInventoryView(QtWidgets.QTreeView): version_docs = get_versions( project_name, - version_ids=version_ids, + version_ids=hero_src_version_ids, fields=["name"] ) version_name_by_id = {} @@ -194,7 +189,7 @@ class SceneInventoryView(QtWidgets.QTreeView): version_doc["name"] for item in items: - repre_id = ObjectId(item["representation"]) + repre_id = item["representation"] version_id = version_id_by_repre_id.get(repre_id) version_name = version_name_by_id.get(version_id) if version_name is not None: diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index d51ebb5744..4292e2d726 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -1,4 +1,6 @@ from .widgets import ( + FocusSpinBox, + FocusDoubleSpinBox, CustomTextComboBox, PlaceholderLineEdit, BaseClickableFrame, @@ -34,6 +36,8 @@ from .overlay_messages import ( __all__ = ( + "FocusSpinBox", + "FocusDoubleSpinBox", "CustomTextComboBox", "PlaceholderLineEdit", "BaseClickableFrame", diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index a9d6fa35b2..b416c56797 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -8,11 +8,39 @@ from openpype.style import ( get_objected_colors, get_style_image_path ) -from openpype.lib.attribute_definitions import AbtractAttrDef +from openpype.lib.attribute_definitions import AbstractAttrDef log = logging.getLogger(__name__) +class FocusSpinBox(QtWidgets.QSpinBox): + """QSpinBox which allow scroll wheel changes only in active state.""" + + def __init__(self, *args, **kwargs): + super(FocusSpinBox, self).__init__(*args, **kwargs) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + def wheelEvent(self, event): + if not self.hasFocus(): + event.ignore() + else: + super(FocusSpinBox, self).wheelEvent(event) + + +class FocusDoubleSpinBox(QtWidgets.QDoubleSpinBox): + """QDoubleSpinBox which allow scroll wheel changes only in active state.""" + + def __init__(self, *args, **kwargs): + super(FocusDoubleSpinBox, self).__init__(*args, **kwargs) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + def wheelEvent(self, event): + if not self.hasFocus(): + event.ignore() + else: + super(FocusDoubleSpinBox, self).wheelEvent(event) + + class CustomTextComboBox(QtWidgets.QComboBox): """Combobox which can have different text showed.""" @@ -406,7 +434,7 @@ class OptionalAction(QtWidgets.QWidgetAction): def set_option_tip(self, options): sep = "\n\n" - if not options or not isinstance(options[0], AbtractAttrDef): + if not options or not isinstance(options[0], AbstractAttrDef): mak = (lambda opt: opt["name"] + " :\n " + opt["help"]) self.option_tip = sep.join(mak(opt) for opt in options) return diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 765d32b3d5..18be746d49 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -621,7 +621,7 @@ class FilesWidget(QtWidgets.QWidget): "caption": "Work Files", "filter": ext_filter } - if qtpy.API in ("pyside", "pyside2"): + if qtpy.API in ("pyside", "pyside2", "pyside6"): kwargs["dir"] = self._workfiles_root else: kwargs["directory"] = self._workfiles_root diff --git a/openpype/version.py b/openpype/version.py index 831df22d6e..4d6f3d43e4 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.15.0" +__version__ = "3.15.2-nightly.3" diff --git a/pyproject.toml b/pyproject.toml index 3b9836d8eb..2fc4f6fe39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.15.0" # OpenPype +version = "3.15.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" @@ -146,10 +146,6 @@ hash = "b9950f5d2fa3720b52b8be55bacf5f56d33f9e029d38ee86534995f3d8d253d2" url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.2.20-linux-centos7.tgz" hash = "3894dec7e4e521463891a869586850e8605f5fd604858b674c87323bf33e273d" -[openpype.thirdparty.oiio.darwin] -url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-darwin.tgz" -hash = "sha256:..." - [openpype.thirdparty.ocioconfig] url = "https://distribute.openpype.io/thirdparty/OpenColorIO-Configs-1.0.2.zip" hash = "4ac17c1f7de83465e6f51dd352d7117e07e765b66d00443257916c828e35b6ce" diff --git a/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects.py b/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects.py index 04fe6cb9aa..30761693a8 100644 --- a/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects.py +++ b/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects.py @@ -62,7 +62,7 @@ class TestDeadlinePublishInAfterEffects(AEDeadlinePublishTestClass): failures.append( DBAssert.count_of_types(dbcon, "representation", 4)) - additional_args = {"context.subset": "renderTest_taskMain", + additional_args = {"context.subset": "workfileTest_task", "context.ext": "aep"} failures.append( DBAssert.count_of_types(dbcon, "representation", 1, @@ -71,7 +71,7 @@ class TestDeadlinePublishInAfterEffects(AEDeadlinePublishTestClass): additional_args = {"context.subset": "renderTest_taskMain", "context.ext": "png"} failures.append( - DBAssert.count_of_types(dbcon, "representation", 1, + DBAssert.count_of_types(dbcon, "representation", 2, additional_args=additional_args)) additional_args = {"context.subset": "renderTest_taskMain", diff --git a/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects_multicomposition.py b/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects_multicomposition.py index f009b45f4d..d372efcb9a 100644 --- a/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects_multicomposition.py +++ b/tests/integration/hosts/aftereffects/test_deadline_publish_in_aftereffects_multicomposition.py @@ -47,7 +47,7 @@ class TestDeadlinePublishInAfterEffectsMultiComposition(AEDeadlinePublishTestCla print("test_db_asserts") failures = [] - failures.append(DBAssert.count_of_types(dbcon, "version", 2)) + failures.append(DBAssert.count_of_types(dbcon, "version", 3)) failures.append( DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1})) @@ -80,7 +80,7 @@ class TestDeadlinePublishInAfterEffectsMultiComposition(AEDeadlinePublishTestCla additional_args = {"context.subset": "renderTest_taskMain", "context.ext": "png"} failures.append( - DBAssert.count_of_types(dbcon, "representation", 1, + DBAssert.count_of_types(dbcon, "representation", 2, additional_args=additional_args)) additional_args = {"context.subset": "renderTest_taskMain", diff --git a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py index 57d5a3e3f1..2e4f343a5a 100644 --- a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py +++ b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py @@ -60,7 +60,7 @@ class TestPublishInAfterEffects(AELocalPublishTestClass): failures.append( DBAssert.count_of_types(dbcon, "representation", 4)) - additional_args = {"context.subset": "renderTest_taskMain", + additional_args = {"context.subset": "workfileTest_task", "context.ext": "aep"} failures.append( DBAssert.count_of_types(dbcon, "representation", 1, @@ -69,7 +69,7 @@ class TestPublishInAfterEffects(AELocalPublishTestClass): additional_args = {"context.subset": "renderTest_taskMain", "context.ext": "png"} failures.append( - DBAssert.count_of_types(dbcon, "representation", 1, + DBAssert.count_of_types(dbcon, "representation", 2, additional_args=additional_args)) additional_args = {"context.subset": "renderTest_taskMain", diff --git a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects_multiframe.py b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects_multiframe.py index 2d95eada99..dcf34844d1 100644 --- a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects_multiframe.py +++ b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects_multiframe.py @@ -47,7 +47,7 @@ class TestPublishInAfterEffects(AELocalPublishTestClass): failures.append( DBAssert.count_of_types(dbcon, "representation", 4)) - additional_args = {"context.subset": "renderTest_taskMain", + additional_args = {"context.subset": "workfileTest_task", "context.ext": "aep"} failures.append( DBAssert.count_of_types(dbcon, "representation", 1, diff --git a/tools/ci_tools.py b/tools/ci_tools.py index c8f0cd48b4..750bf8645d 100644 --- a/tools/ci_tools.py +++ b/tools/ci_tools.py @@ -7,16 +7,10 @@ from github import Github import os def get_release_type_github(Log, github_token): - # print(Log) minor_labels = ["Bump Minor"] - # patch_labels = [ - # "type: enhancement", - # "type: bug", - # "type: deprecated", - # "type: Feature"] g = Github(github_token) - repo = g.get_repo("pypeclub/OpenPype") + repo = g.get_repo("ynput/OpenPype") labels = set() for line in Log.splitlines(): @@ -35,12 +29,12 @@ def get_release_type_github(Log, github_token): else: return "patch" - # TODO: if all is working fine, this part can be cleaned up eventually + # TODO: if all is working fine, this part can be cleaned up eventually # if any(label in labels for label in patch_labels): # return "patch" - + return None - + def remove_prefix(text, prefix): return text[text.startswith(prefix) and len(prefix):] @@ -93,12 +87,16 @@ def file_regex_replace(filename, regex, version): f.truncate() -def bump_file_versions(version): +def bump_file_versions(version, nightly=False): filename = "./openpype/version.py" regex = "(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-((0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?" file_regex_replace(filename, regex, version) + if nightly: + # skip nightly reversion in pyproject.toml + return + # bump pyproject.toml filename = "pyproject.toml" regex = "version = \"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(\+((0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?\" # OpenPype" @@ -196,7 +194,7 @@ def main(): if options.nightly: next_tag_v = calculate_next_nightly(github_token=options.github_token) print(next_tag_v) - bump_file_versions(next_tag_v) + bump_file_versions(next_tag_v, True) if options.finalize: new_release = finalize_prerelease(options.finalize) @@ -222,7 +220,7 @@ def main(): new_prerelease = current_prerelease.bump_prerelease().__str__() print(new_prerelease) bump_file_versions(new_prerelease) - + if options.version: bump_file_versions(options.version) print(f"Injected version {options.version} into the release") diff --git a/tools/create_env.ps1 b/tools/create_env.ps1 index f79bc2a076..7d1f6635d0 100644 --- a/tools/create_env.ps1 +++ b/tools/create_env.ps1 @@ -68,7 +68,7 @@ function Install-Poetry() { } $env:POETRY_HOME="$openpype_root\.poetry" - # $env:POETRY_VERSION="1.1.15" + $env:POETRY_VERSION="1.3.2" (Invoke-WebRequest -Uri https://install.python-poetry.org/ -UseBasicParsing).Content | & $($python) - } diff --git a/tools/create_env.sh b/tools/create_env.sh index fbae69e56d..6915d3f000 100755 --- a/tools/create_env.sh +++ b/tools/create_env.sh @@ -109,7 +109,7 @@ detect_python () { install_poetry () { echo -e "${BIGreen}>>>${RST} Installing Poetry ..." export POETRY_HOME="$openpype_root/.poetry" - # export POETRY_VERSION="1.1.15" + export POETRY_VERSION="1.3.2" command -v curl >/dev/null 2>&1 || { echo -e "${BIRed}!!!${RST}${BIYellow} Missing ${RST}${BIBlue}curl${BIYellow} command.${RST}"; return 1; } curl -sSL https://install.python-poetry.org/ | python - } diff --git a/tools/fetch_thirdparty_libs.py b/tools/fetch_thirdparty_libs.py index 7bcdde2ab2..70257caa46 100644 --- a/tools/fetch_thirdparty_libs.py +++ b/tools/fetch_thirdparty_libs.py @@ -130,8 +130,10 @@ def install_thirdparty(pyproject, openpype_root, platform_name): _print("trying to get universal url for all platforms") url = v.get("url") if not url: - _print("cannot get url", 1) - sys.exit(1) + _print("cannot get url for all platforms", 1) + _print((f"Warning: {k} is not installed for current platform " + "and it might be missing in the build"), 1) + continue else: url = v.get(platform_name).get("url") destination_path = destination_path / platform_name diff --git a/tools/openpype_console.bat b/tools/openpype_console.bat new file mode 100644 index 0000000000..04b28c389f --- /dev/null +++ b/tools/openpype_console.bat @@ -0,0 +1,15 @@ +goto comment +SYNOPSIS + Helper script running scripts through the OpenPype environment. + +DESCRIPTION + This script is usually used as a replacement for building when tested farm integration like Deadline. + +EXAMPLE + +cmd> .\openpype_console.bat path/to/python_script.py +:comment + +cd "%~dp0\.." +echo %OPENPYPE_MONGO% +.poetry\bin\poetry.exe run python start.py %* diff --git a/website/docs/admin_environment.md b/website/docs/admin_environment.md new file mode 100644 index 0000000000..1eb755b90b --- /dev/null +++ b/website/docs/admin_environment.md @@ -0,0 +1,30 @@ +--- +id: admin_environment +title: Environment +sidebar_label: Environment +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## OPENPYPE_TMPDIR: + - Custom staging dir directory + - Supports anatomy keys formatting. ex `{root[work]}/{project[name]}/temp` + - supported formatting keys: + - root[work] + - project[name | code] + +## OPENPYPE_DEBUG + - setting logger to debug mode + - example value: "1" (to activate) + +## OPENPYPE_LOG_LEVEL + - stringified numeric value of log level. [Here for more info](https://docs.python.org/3/library/logging.html#logging-levels) + - example value: "10" + +## OPENPYPE_MONGO +- If set it takes precedence over the one set in keyring +- for more details on how to use it go [here](admin_use#check-for-mongodb-database-connection) + +## OPENPYPE_USERNAME +- if set it overides system created username diff --git a/website/docs/admin_settings_system.md b/website/docs/admin_settings_system.md index 8aeb281109..d61713ccd5 100644 --- a/website/docs/admin_settings_system.md +++ b/website/docs/admin_settings_system.md @@ -13,18 +13,23 @@ Settings applicable to the full studio. ![general_settings](assets/settings/settings_system_general.png) -**`Studio Name`** - Full name of the studio (can be used as variable on some places) +### Studio Name +Full name of the studio (can be used as variable on some places) -**`Studio Code`** - Studio acronym or a short code (can be used as variable on some places) +### Studio Code +Studio acronym or a short code (can be used as variable on some places) -**`Admin Password`** - After setting admin password, normal user won't have access to OpenPype settings +### Admin Password +After setting admin password, normal user won't have access to OpenPype settings and Project Manager GUI. Please keep in mind that this is a studio wide password and it is meant purely as a simple barrier to prevent artists from accidental setting changes. -**`Environment`** - Globally applied environment variables that will be appended to any OpenPype process in the studio. +### Environment +Globally applied environment variables that will be appended to any OpenPype process in the studio. -**`Disk mapping`** - Platform dependent configuration for mapping of virtual disk(s) on an artist's OpenPype machines before OP starts up. -Uses `subst` command, if configured volume character in `Destination` field already exists, no re-mapping is done for that character(volume). +### Disk mapping +- Platform dependent configuration for mapping of virtual disk(s) on an artist's OpenPype machines before OP starts up. +- Uses `subst` command, if configured volume character in `Destination` field already exists, no re-mapping is done for that character(volume). ### FFmpeg and OpenImageIO tools We bundle FFmpeg tools for all platforms and OpenImageIO tools for Windows and Linux. By default, bundled tools are used, but it is possible to set environment variables `OPENPYPE_FFMPEG_PATHS` and `OPENPYPE_OIIO_PATHS` in system settings environments to look for them in different directory. @@ -171,4 +176,4 @@ In the image before you can see that we set most of the environment variables in In this example MTOA will automatically will the `MAYA_VERSION`(which is set by Maya Application environment) and `MTOA_VERSION` into the `MTOA` variable. We then use the `MTOA` to set all the other variables needed for it to function within Maya. ![tools](assets/settings/tools_01.png) -All of the tools defined in here can then be assigned to projects. You can also change the tools versions on any project level all the way down to individual asset or shot overrides. So if you just need to upgrade you render plugin for a single shot, while not risking the incompatibilities on the rest of the project, it is possible. \ No newline at end of file +All the tools defined in here can then be assigned to projects. You can also change the tools versions on any project level all the way down to individual asset or shot overrides. So if you just need to upgrade you render plugin for a single shot, while not risking the incompatibilities on the rest of the project, it is possible. diff --git a/website/docs/artist_getting_started.md b/website/docs/artist_getting_started.md index 2f88a9f238..301a58fa56 100644 --- a/website/docs/artist_getting_started.md +++ b/website/docs/artist_getting_started.md @@ -7,6 +7,7 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; + ## Working in the studio In studio environment you should have OpenPype already installed and deployed, so you can start using it without much setup. Your admin has probably put OpenPype icon on your desktop or even had your computer set up so OpenPype will start automatically. @@ -15,70 +16,66 @@ If this is not the case, please contact your administrator to consult on how to ## Working from home -If you are working from home though, you'll need to install it yourself. You should, however, receive the OpenPype installer files from your studio -admin, supervisor or production, because OpenPype versions and executables might not be compatible between studios. +If you are working from **home** though, you'll **need to install** it yourself. You should, however, receive the OpenPype installer files from your studio +admin, supervisor or production, because OpenPype versions and executables might not be compatible between studios. -To install OpenPype you just need to unzip it anywhere on the disk +Installing OpenPype is possible by Installer or by unzipping downloaded ZIP archive to any drive location. -To use it, you have two options - -**openpype_gui.exe** is the most common for artists. It runs OpenPype GUI in system tray. From there you can run all the available tools. To use any of the features, OpenPype must be running in the tray. - -**openpype_console.exe** in useful for debugging and error reporting. It opens console window where all the necessary information will appear during user's work. +:::tip Using the OpenPype Installer +See the [Installation section](artist_install.md) for more information on how to use the OpenPype Installer +::: - +You can run OpenPype by desktop "OP" icon (if it exists after installing) or by directly executing - +**openpype_gui.exe** located in the OpenPype folder. This executable being suitable **for artists**. -WIP - Windows instructions once installers are finished +or alternatively by - - +**openpype_console.exe** which is more suitable for **TDs/Admin** for debugging and error reporting. This one runs with +opened console window where all the necessary info will appear during user's work session. -WIP - Linux instructions once installers are finished +:::tip Is OpenPype running? +OpenPype runs in the operating system's tray. If you see turquoise OpenPype icon in the tray you can easily tell OpenPype is currently running. +Keep in mind that on Windows this icon might be hidden by default, in which case, the artist can simply drag the icon down to the tray. +::: - - - -WIP - Mac instructions once installers are finished - - - +![Systray](assets/artist_systray.png) ## First Launch -When you first start OpenPype, you will be asked to give it some basic information. +When you first start OpenPype, you will be asked to fill in some basic information. + ### MongoDB -In most cases that will only be your studio MongoDB Address. +In most cases you will only have to supply the MongoDB Address. +It's the database URL you should have received from your Studio admin and often will look like this -It is a URL that you should receive from you studio and most often will look like this `mongodb://username:passwword@mongo.mystudiodomain.com:12345` or `mongodb://192.168.100.15:27071`, it really depends on your studio setup. When OpenPype Igniter +`mongodb://username:passwword@mongo.mystudiodomain.com:12345` + + or + + `mongodb://192.168.100.15:27071` + +it really depends on your studio setup. When OpenPype Igniter asks for it, just put it in the corresponding text field and press `install` button. ### OpenPype Version Repository -Sometimes your studio might also ask you to fill in the path to it's version -repository. This is a location where OpenPype will be looking for when checking -if it's up to date and where updates are installed from automatically. +Sometimes your Studio might also ask you to fill in the path to its version +repository. This is a location where OpenPype will search for the latest versions, check +if it's up to date and where updates are installed from automatically. -This pat is usually taken from the database directly, so you shouldn't need it. +This path is usually taken from the database directly, so you shouldn't need it. ## Updates -If you're connected to your studio, OpenPype will check for, and install updates automatically every time you run it. That's why during the first start, it will go through a quick update installation process, even though you might have just installed it. +If you're connected to your Studio, OpenPype will check for, and install updates automatically every time you run it. That's why during the first start it can go through a quick update installation process, even though you might have just installed it. -## Advanced use +## Advanced Usage For more advanced use of OpenPype commands please visit [Admin section](admin_openpype_commands.md). diff --git a/website/docs/artist_hosts_3dsmax.md b/website/docs/artist_hosts_3dsmax.md new file mode 100644 index 0000000000..71ba8785dc --- /dev/null +++ b/website/docs/artist_hosts_3dsmax.md @@ -0,0 +1,125 @@ +--- +id: artist_hosts_3dsmax +title: 3dsmax +sidebar_label: 3dsmax +--- + +:::note Work in progress +This part of documentation is still work in progress. +::: + + + + +## First Steps With OpenPype + +Locate **OpenPype Icon** in the OS tray (if hidden dive in the tray toolbar). + +> If you cannot locate the OpenPype icon ...it is not probably running so check [Getting Started](artist_getting_started.md) first. + +By clicking the icon ```OpenPype Menu``` rolls out. + +Choose ```OpenPype Menu > Launcher``` to open the ```Launcher``` window. + +When opened you can **choose** the **project** to work in from the list. Then choose the particular **asset** you want to work on then choose **task** +and finally **run 3dsmax by its icon** in the tools. + +![Menu OpenPype](assets/3dsmax_tray_OP.png) + +:::note Launcher Content +The list of available projects, assets, tasks and tools will differ according to your Studio and need to be set in advance by supervisor/admin. +::: + +## Running in the 3dsmax + +If 3dsmax has been launched via OP Launcher there should be **OpenPype Menu** visible in 3dsmax **top header** after start. +This is the core functional area for you as a user. Most of your actions will take place here. + +![Menu OpenPype](assets/3dsmax_menu_first_OP.png) + +:::note OpenPype Menu +User should use this menu exclusively for **Opening/Saving** when dealing with work files not standard ```File Menu``` even though user still being able perform file operations via this menu but prefferably just performing quick saves during work session not saving actual workfile versions. +::: + +## Working With Scene Files + +In OpenPype menu first go to ```Work Files``` menu item so **Work Files Window** shows up. + + Here you can perform Save / Load actions as you would normally do with ```File Save ``` and ```File Open``` in the standard 3dsmax ```File Menu``` and navigate to different project components like assets, tasks, workfiles etc. + + +![Menu OpenPype](assets/3dsmax_menu_OP.png) + +You first choose particular asset and assigned task and corresponding workfile you would like to open. + +If not any workfile present simply hit ```Save As``` and keep ```Subversion``` empty and hit ```Ok```. + +![Save As Dialog](assets/3dsmax_SavingFirstFile_OP.png) + +OpenPype correctly names it and add version to the workfile. This basically happens whenever user trigger ```Save As``` action. Resulting into incremental version numbers like + +```workfileName_v001``` + +```workfileName_v002``` + + etc. + +Basically meaning user is free of guessing what is the correct naming and other neccessities to keep everthing in order and managed. + +> Note: user still has also other options for naming like ```Subversion```, ```Artist's Note``` but we won't dive into those now. + +Here you can see resulting work file after ```Save As``` action. + +![Save As Dialog](assets/3dsmax_SavingFirstFile2_OP.png) + +## Understanding Context + +As seen on our example OpenPype created pretty first workfile and named it ```220901_couch_modeling_v001.max``` meaning it sits in the Project ```220901``` being it ```couch``` asset and workfile being ```modeling``` task and obviously ```v001``` telling user its first existing version of this workfile. + +It is good to be aware that whenever you as a user choose ```asset``` and ```task``` you happen to be in so called **context** meaning that all user actions are in relation with particular ```asset```. This could be quickly seen in host application header and ```OpenPype Menu``` and its accompanying tools. + +![Workfile Context](assets/3dsmax_context.png) + +> Whenever you choose different ```asset``` and its ```task``` in **Work Files window** you are basically changing context to the current asset/task you have chosen. + + +This concludes the basics of working with workfiles in 3dsmax using OpenPype and its tools. Following chapters will cover other aspects like creating multiple assets types and their publishing for later usage in the production. + +--- + +## Creating and Publishing Instances + +:::warning Important +Before proceeding further please check [Glossary](artist_concepts.md) and [What Is Publishing?](artist_publish.md) So you have clear idea about terminology. +::: + + +### Intro + +Current OpenPype integration (ver 3.15.0) supports only ```PointCache``` and ```Camera``` families now. + +**Pointcache** family being basically any geometry outputted as Alembic cache (.abc) format + +**Camera** family being 3dsmax Camera object with/without animation outputted as native .max, FBX, Alembic format + + +--- + +:::note Work in progress +This part of documentation is still work in progress. +::: + +## ...to be added + + + + diff --git a/website/docs/artist_hosts_maya.md b/website/docs/artist_hosts_maya.md index 5cd8efa153..07495fbc96 100644 --- a/website/docs/artist_hosts_maya.md +++ b/website/docs/artist_hosts_maya.md @@ -308,13 +308,25 @@ Select its root and Go **OpenPype โ†’ Create...** and select **Point Cache**. After that, publishing will create corresponding **abc** files. +When creating the instance, a objectset child `proxy` will be created. Meshes in the `proxy` objectset will be the viewport representation where loading supports proxies. Proxy representations are stored as `resources` of the subset. + Example setup: ![Maya - Point Cache Example](assets/maya-pointcache_setup.png) -:::note Publish on farm -If your studio has Deadline configured, artists could choose to offload potentially long running export of pointache and publish it to the farm. -Only thing that is necessary is to toggle `Farm` property in created pointcache instance to True. +#### Options + +- **Frame Start**: which frame to start the export at. +- **Frame End**: which frame to end the export at. +- **Handle Start**: additional frames to export at frame start. Ei. frame start - handle start = export start. +- **Handle Start**: additional frames to export at frame end. Ei. frame end + handle end = export end. +- **Step**: frequency of sampling the export. For example when dealing with quick movements for motion blur, a step size of less than 1 might be better. +- **Refresh**: refresh the viewport when exporting the pointcache. For performance is best to leave off, but certain situations can require to refresh the viewport, for example using the Bullet plugin. +- **Attr**: specific attributes to publish separated by `;`. +- **AttrPrefix**: specific attributes which start with this prefix to publish separated by `;`. +- **Include User Defined Attribudes**: include all user defined attributes in the publish. +- **Farm**: if your studio has Deadline configured, artists could choose to offload potentially long running export of pointache and publish it to the farm. Only thing that is necessary is to toggle this attribute in created pointcache instance to True. +- **Priority**: Farm priority. ### Loading Point Caches @@ -601,3 +613,20 @@ about customizing review process refer to [admin section](project_settings/setti If you don't move `modelMain` into `reviewMain`, review will be generated but it will be published as separate entity. + + +## Inventory Actions + +### Connect Geometry + +This action will connect geometries between containers. + +#### Usage + +Select 1 container of type `animation` or `pointcache`, then 1+ container of any type. + +#### Details + +The action searches the selected containers for 1 animation container of type `animation` or `pointcache`. This animation container will be connected to the rest of the selected containers. Matching geometries between containers is done by comparing the attribute `cbId`. + +The connection between geometries is done with a live blendshape. diff --git a/website/docs/artist_hosts_maya_arnold.md b/website/docs/artist_hosts_maya_arnold.md new file mode 100644 index 0000000000..b3c02a0894 --- /dev/null +++ b/website/docs/artist_hosts_maya_arnold.md @@ -0,0 +1,30 @@ +--- +id: artist_hosts_maya_arnold +title: Arnold for Maya +sidebar_label: Arnold +--- +## Arnold Scene Source (.ass) +Arnold Scene Source can be published as a single file or a sequence of files, determined by the frame range. + +When creating the instance, two objectsets are created; `content` and `proxy`. Meshes in the `proxy` objectset will be the viewport representation when loading as `standin`. Proxy representations are stored as `resources` of the subset. + +### Arnold Scene Source Proxy Workflow +In order to utilize operators and proxies, the content and proxy nodes need to share the same names (including the shape names). This is done by parenting the content and proxy nodes into separate groups. For example: + +![Arnold Scene Source](assets/maya-arnold_scene_source.png) + +## Standin +Arnold Scene Source `ass` and Alembic `abc` are supported to load as standins. + +### Standin Proxy Workflow +If a subset has a proxy representation, this will be used as display in the viewport. At render time the standin path will be replaced using the recommended string replacement workflow; + +https://help.autodesk.com/view/ARNOL/ENU/?guid=arnold_for_maya_operators_am_Updating_procedural_file_paths_with_string_replace_html + +Since the content and proxy nodes share the same names and hierarchy, any manually shader assignments will be shared. + + +:::note for advanced users +You can stop the proxy swapping by disabling the string replacement operator found in the container. +![Arnold Standin](assets/maya-arnold_standin.png) +::: diff --git a/website/docs/artist_hosts_maya_xgen.md b/website/docs/artist_hosts_maya_xgen.md index 8b0174a29f..ec5f2ed921 100644 --- a/website/docs/artist_hosts_maya_xgen.md +++ b/website/docs/artist_hosts_maya_xgen.md @@ -4,26 +4,96 @@ title: Xgen for Maya sidebar_label: Xgen --- -## Working with Xgen in OpenPype +OpenPype supports Xgen classic with the follow workflow. It eases the otherwise cumbersome issues around Xgen's side car files and hidden behaviour inside Maya. The workflow supports publishing, loading and updating of Xgen collections, along with connecting animation from geometry and (guide) curves. -OpenPype support publishing and loading of Xgen interactive grooms. You can publish -them as mayaAscii files with scalps that can be loaded into another maya scene, or as -alembic caches. +## Setup -### Publishing Xgen Grooms +### Settings -To prepare xgen for publishing just select all the descriptions that should be published together and the create Xgen Subset in the scene using - **OpenPype menu** โ†’ **Create**... and select **Xgen Interactive**. Leave Use selection checked. +Go to project settings > `Maya` > enable `Open Workfile Post Initialization`; -For actual publishing of your groom to go **OpenPype โ†’ Publish** and then press โ–ถ to publish. This will export `.ma` file containing your grooms with any geometries they are attached to and also a baked cache in `.abc` format +`project_settings/maya/open_workfile_post_initialization` +This is due to two errors occurring when opening workfile containing referenced xgen nodes on launch of Maya, specifically: -:::tip adding more descriptions -You can add multiple xgen description into the subset you are about to publish, simply by -adding them to the maya set that was created for you. Please make sure that only xgen description nodes are present inside of the set and not the scalp geometry. -::: +- ``Critical``: Duplicate collection errors on launching workfile. This is because Maya first imports Xgen when referencing in external Maya files, then imports Xgen again when the reference edits are applied. +``` +Importing XGen Collections... +# Error: XGen: Failed to find description ball_xgenMain_01_:parent in collection ball_xgenMain_01_:collection. Abort applying delta: P:/PROJECTS/OP01_CG_demo/shots/sh040/work/Lighting/cg_sh040_Lighting_v001__ball_xgenMain_01___collection.xgen # +# Error: XGen: Tried to import a duplicate collection, ball_xgenMain_02_:collection, from file P:/PROJECTS/OP01_CG_demo/shots/sh040/work/Lighting/cg_sh040_Lighting_v001__ball_xgenMain_02___collection.xgen. Aborting import. # +``` +- ``Non-critical``: Errors on opening workfile and failed opening of published xgen. This is because Maya imports Xgen when referencing in external Maya files but the reference edits that ensure the location of the Xgen files are correct, has not been applied yet. +``` +Importing XGen Collections... +# Error: XGen: Failed to open file: P:/PROJECTS/OP01_CG_demo/shots/sh040/work/Lighting/cg_ball_xgenMain_v035__ball_rigMain_01___collection.xgen # +# Error: XGen: Failed to import collection from file P:/PROJECTS/OP01_CG_demo/shots/sh040/work/Lighting/cg_ball_xgenMain_v035__ball_rigMain_01___collection.xgen # +``` -### Loading Xgen +Go to project settings > `Deadline` > `Publish plugins` > `Maya Submit to Deadline` > disable `Use Published scene`; -You can use published xgens by loading them using OpenPype Publisher. You can choose to reference or import xgen. We don't have any automatic mesh linking at the moment and it is expected, that groom is published with a scalp, that can then be manually attached to your animated mesh for example. +`project_settings/deadline/publish/MayaSubmitDeadline/use_published` -The alembic representation can be loaded too and it contains the groom converted to curves. Keep in mind that the density of the alembic directly depends on your viewport xgen density at the point of export. +This is due to temporary workaround while fixing rendering with published scenes. + +## Create + +Create an Xgen instance to publish. This needs to contain only **one Xgen collection**. + +`OpenPype > Create... > Xgen` + +You can create multiple Xgen instances if you have multiple collections to publish. + +### Publish + +The publishing process will grab geometry used for Xgen along with any external files used in the collection's descriptions. This creates an isolated Maya file with just the Xgen collection's dependencies, so you can use any nested geometry when creating the Xgen description. An Xgen version will consist of: + +- Maya file (`.ma`) - this contains the geometry and the connections to the Xgen collection and descriptions. +- Xgen file (`.xgen`) - this contains the Xgen collection and description. +- Resource files (`.ptx`, `.xuv`) - this contains Xgen side car files used in the collection and descriptions. + +## Load + +Open the Loader tool, `OpenPype > Loader...`, and navigate to the published Xgen version. On right-click you'll get the option `Reference Xgen (ma)` +When loading an Xgen version the following happens: + +- References in the Maya file. +- Copies the Xgen file (`.xgen`) to the current workspace. +- Modifies the Xgen file copy to load the current workspace first then the published Xgen collection. +- Makes a custom attribute on the Xgen collection, `float_ignore`, which can be seen under the `Expressions` tab of the `Xgen` UI. This is done to initialize the Xgen delta file workflow. +- Setup an Xgen delta file (`.xgd`) to store any workspace changes of the published Xgen version. + +When the loading is done, Xgen collection will be in the Xgen delta file workflow which means any changes done in the Maya workfile will be stored in the current workspace. The published Xgen collection will remain intact, even if the user assigns maps to any attributes or otherwise modifies any attribute. + +### Updating + +When there are changes to the Xgen version, the user will be notified when opening the workfile or publishing. Since the Xgen is referenced, it follows the standard Maya referencing system and overrides. + +For example publishing `xgenMain` version 1 with the attribute `renderer` set to `None`, then version 2 has `renderer` set to `Arnold Renderer`. When updating from version 1 to 2, the `renderer` attribute will be updated to `Arnold Renderer` unless there is a local override. + +### Connect Patches + +When loading in an Xgen version, it does not have any connections to anything in the workfile, so its static in the position it was published in. Use the [Connect Geometry](artist_hosts_maya#connect-geometry) action to connect Xgen to any matching loaded animated geometry. + +### Connect Guides + +Along with patches you can also connect the Xgen guides to an Alembic cache. + +#### Usage + +Select 1 animation container, of family `animation` or `pointcache`, then the Xgen containers to connect to. Right-click > `Actions` > `Connect Xgen`. + +***Note: Only alembic (`.abc`) representations are allowed.*** + +#### Details + +Connecting the guide will make Xgen use the Alembic directly, setting the attributes under `Guide Animation`, so the Alembic needs to contain the same amount of curves as guides in the Xgen. + +The animation container gets connected with the Xgen container, so if the animation container is updated so will the Xgen container's attribute. + +## Rendering + +To render with Xgen, follow the [Rendering With OpenPype](artist_hosts_maya#rendering-with-openpype) guide. + +### Details + +When submitting a workfile with Xgen, all Xgen related files will be collected and published as the workfiles resources. This means the published workfile is no longer referencing the workspace Xgen files. diff --git a/website/docs/artist_hosts_tvpaint.md b/website/docs/artist_hosts_tvpaint.md index a0ce5d5ff8..a8a6cee5f8 100644 --- a/website/docs/artist_hosts_tvpaint.md +++ b/website/docs/artist_hosts_tvpaint.md @@ -6,89 +6,77 @@ sidebar_label: TVPaint - [Work Files](artist_tools_workfiles) - [Load](artist_tools_loader) -- [Create](artist_tools_creator) -- [Subset Manager](artist_tools_subset_manager) - [Scene Inventory](artist_tools_inventory) - [Publish](artist_tools_publisher) - [Library](artist_tools_library) ## Setup -When you launch TVPaint with OpenPype for the very first time it is necessary to do some additional steps. Right after the TVPaint launching a few system windows will pop up. +When you launch TVPaint with OpenPype for the very first time it is necessary to do some additional steps. Right after the TVPaint launching a few system windows will pop up. ![permission](assets/tvp_permission.png) -Choose `Replace the file in the destination`. Then another window shows up. +Choose `Replace the file in the destination`. Then another window shows up. ![permission2](assets/tvp_permission2.png) Click on `Continue`. -After opening TVPaint go to the menu bar: `Windows โ†’ Plugins โ†’ OpenPype`. +After opening TVPaint go to the menu bar: `Windows โ†’ Plugins โ†’ OpenPype`. ![pypewindow](assets/tvp_hidden_window.gif) -Another TVPaint window pop up. Please press `Yes`. This window will be presented in every single TVPaint launching. Unfortunately, there is no other way how to workaround it. +Another TVPaint window pop up. Please press `Yes`. This window will be presented in every single TVPaint launching. Unfortunately, there is no other way how to workaround it. ![writefile](assets/tvp_write_file.png) -Now OpenPype Tools menu is in your TVPaint work area. +Now OpenPype Tools menu is in your TVPaint work area. ![openpypetools](assets/tvp_openpype_menu.png) -You can start your work. +You can start your work. --- ## Usage In TVPaint you can find the Tools in OpenPype menu extension. The OpenPype Tools menu should be available in your work area. However, sometimes it happens that the Tools menu is hidden. You can display the extension panel by going to `Windows -> Plugins -> OpenPype`. - -## Create -In TVPaint you can create and publish **[Reviews](#review)**, **[Workfile](#workfile)**, **[Render Passes](#render-pass)** and **[Render Layers](#render-layer)**. - -You have the possibility to organize your layers by using `Color group`. - -On the bottom left corner of your timeline, you will note a `Color group` button. - -![colorgroups](assets/tvp_color_groups.png) - -It allows you to choose a group by checking one of the colors of the color list. - -![colorgroups](assets/tvp_color_groups2.png) - -The timeline's animation layer can be marked by the color you pick from your Color group. Layers in the timeline with the same color are gathered into a group represents one render layer. - -![timeline](assets/tvp_timeline_color.png) +## Create & Publish +To be able to publish, you have to mark what should be published. The marking part is called **Create**. In TVPaint you can create and publish **[Reviews](#review)**, **[Workfile](#workfile)**, **[Render Layers](#render-layer)** and **[Render Passes](#render-pass)**. :::important -OpenPype specifically never tries to guess what you want to publish from the scene. Therefore, you have to tell OpenPype what you want to publish. There are three ways how to publish render from the scene. +TVPaint integration tries to not guess what you want to publish from the scene. Therefore, you should tell what you want to publish. ::: -When you want to publish `review` or `render layer` or `render pass`, open the `Creator` through the Tools menu `Create` button. +![createlayer](assets/tvp_publisher.png) ### Review -`Review` renders the whole file as is and sends the resulting QuickTime to Ftrack. -- Is automatically created during publishing. +`Review` will render all visible layers and create a reviewable output. +- Is automatically created without any manual work. +- You can disable the created instance if you want to skip review. ### Workfile -`Workfile` stores the source workfile as is during publishing (e.g. for backup). -- Is automatically created during publishing. +`Workfile` integrate the source TVPaint file during publishing. Publishing of workfile is useful for backups. +- Is automatically created without any manual work. +- You can disable the created instance if you want to skip review. ### Render Layer
+Render Layer bakes all the animation layers of one particular color group together. -Render Layer bakes all the animation layers of one particular color group together. +- In the **Create** tab, pick `Render Layer` +- Fill `variant`, type in the name that the final published RenderLayer should have according to the naming convention in your studio. *(L10, BG, Hero, etc.)* + - Color group will be renamed to the **variant** value +- Choose color group from combobox + - or select a layer of a particular color and set combobox to **<Use selection>** +- Hit `Create` button -- Choose any amount of animation layers that need to be rendered together and assign them a color group. -- Select any layer of a particular color -- Go to `Creator` and choose `RenderLayer`. -- In the `Subset`, type in the name that the final published RenderLayer should have according to the naming convention in your studio. *(L10, BG, Hero, etc.)* -- Press `Create` -- When you run [publish](#publish), the whole color group will be rendered together and published as a single `RenderLayer` +After creating a RenderLayer, choose any amount of animation layers that need to be rendered together and assign them the color group. + +You can change `variant` later in **Publish** tab.
@@ -97,27 +85,45 @@ Render Layer bakes all the animation layers of one particular color group togeth
+
+**How to mark TVPaint layer to a group** +In the bottom left corner of your timeline, you will note a **Color group** button. +![colorgroups](assets/tvp_color_groups.png) + +It allows you to choose a group by checking one of the colors of the color list. + +![colorgroups](assets/tvp_color_groups2.png) + +The timeline's animation layer can be marked by the color you pick from your Color group. Layers in the timeline with the same color are gathered into a group represents one render layer. + +![timeline](assets/tvp_timeline_color.png) ### Render Pass -Render Passes are smaller individual elements of a Render Layer. A `character` render layer might +Render Passes are smaller individual elements of a [Render Layer](artist_hosts_tvpaint.md#render-layer). A `character` render layer might consist of multiple render passes such as `Line`, `Color` and `Shadow`. +Render Passes are specific because they have to belong to a particular Render Layer. You have to select to which Render Layer the pass belongs. Try to refresh if you don't see a specific Render Layer in the options.
-Render Passes are specific because they have to belong to a particular layer. If you try to create a render pass and did not create any render layers before, an error message will pop up. -When you want to create `RenderPass` -- choose one or several animation layers within one color group that you want to publish -- In the Creator, pick `RenderPass` -- Fill the `Subset` with the name of your pass, e.g. `Color`. +When you want to create Render Pass +- choose one or several TVPaint layers. +- in the **Create** tab, pick `Render Pass`. +- fill the `variant` with desired name of pass, e.g. `Color`. +- select the Render Layer you want the Render Pass to belong to from the combobox. + - if you don't see new Render Layer try refresh first. - Press `Create` +After creating a Render Pass, selected the TVPaint layers that should be marked with color group of Render Layer. + +You can change `variant` or Render Layer later in **Publish** tab. +
@@ -126,52 +132,26 @@ When you want to create `RenderPass`
+:::warning +You cannot change TVPaint layer name once you mark it as part of Render Pass. You would have to remove created Render Pass and create it again with new TVPaint layer name. +::: +

-In this example, OpenPype will render selected animation layers within the given color group. E.i. the layers *L020_colour_fx*, *L020_colour_mouth*, and *L020_colour_eye* will be rendered as one pass belonging to the yellow RenderLayer. +In this example, OpenPype will render selected animation layers within the given color group. E.i. the layers *L020_colour_fx*, *L020_colour_mouth*, and *L020_colour_eye* will be rendered as one pass belonging to the yellow RenderLayer. ![renderpass](assets/tvp_timeline_color2.png) - -:::note -You can check your RendrePasses and RenderLayers in [Subset Manager](#subset-manager) or you can start publishing. The publisher will show you a collection of all instances on the left side. -::: - - ---- - -## Publish - -
-
- -Now that you have created the required instances, you can publish them via `Publish` tool. -- Click on `Publish` in OpenPype Tools menu. -- wait until all instances are collected. -- You can check on the left side whether all your instances have been created and are ready for publishing. +Now that you have created the required instances, you can publish them. - Fill the comment on the bottom of the window. -- Press the `Play` button to publish - -
-
- -![pyblish](assets/tvp_pyblish_render.png) - -
-
- -Once the `Publisher` turns gets green your renders have been published. +- Double check enabled instance and their context. +- Press `Publish`. +- Wait to finish. +- Once the `Publisher` turns turns green your renders have been published. --- -## Subset Manager -All created instances (render layers, passes, and reviews) will be shown as a simple list. If you don't want to publish some, right click on the item in the list and select `Remove instance`. - -![subsetmanager](assets/tvp_subset_manager.png) - ---- - -## Load +## Load When you want to load existing published work you can reach the `Loader` through the OpenPype Tools `Load` button. The supported families for TVPaint are: @@ -192,4 +172,4 @@ Scene Inventory shows you everything that you have loaded into your scene using ![sceneinventory](assets/tvp_scene_inventory.png) -You can switch to a previous version of the file or update it to the latest or delete items. +You can switch to a previous version of the file or update it to the latest or delete items. diff --git a/website/docs/assets/3dsmax_SavingFirstFile2_OP.png b/website/docs/assets/3dsmax_SavingFirstFile2_OP.png new file mode 100644 index 0000000000..4066ee0f1a Binary files /dev/null and b/website/docs/assets/3dsmax_SavingFirstFile2_OP.png differ diff --git a/website/docs/assets/3dsmax_SavingFirstFile_OP.png b/website/docs/assets/3dsmax_SavingFirstFile_OP.png new file mode 100644 index 0000000000..c4832ca6bb Binary files /dev/null and b/website/docs/assets/3dsmax_SavingFirstFile_OP.png differ diff --git a/website/docs/assets/3dsmax_context.png b/website/docs/assets/3dsmax_context.png new file mode 100644 index 0000000000..9b84cb2587 Binary files /dev/null and b/website/docs/assets/3dsmax_context.png differ diff --git a/website/docs/assets/3dsmax_menu_OP.png b/website/docs/assets/3dsmax_menu_OP.png new file mode 100644 index 0000000000..bce2f9aac0 Binary files /dev/null and b/website/docs/assets/3dsmax_menu_OP.png differ diff --git a/website/docs/assets/3dsmax_menu_first_OP.png b/website/docs/assets/3dsmax_menu_first_OP.png new file mode 100644 index 0000000000..c3a7b00cbb Binary files /dev/null and b/website/docs/assets/3dsmax_menu_first_OP.png differ diff --git a/website/docs/assets/3dsmax_model_OP.png b/website/docs/assets/3dsmax_model_OP.png new file mode 100644 index 0000000000..293c06642c Binary files /dev/null and b/website/docs/assets/3dsmax_model_OP.png differ diff --git a/website/docs/assets/3dsmax_tray_OP.png b/website/docs/assets/3dsmax_tray_OP.png new file mode 100644 index 0000000000..cfd0b07ef6 Binary files /dev/null and b/website/docs/assets/3dsmax_tray_OP.png differ diff --git a/website/docs/assets/maya-arnold_scene_source.png b/website/docs/assets/maya-arnold_scene_source.png new file mode 100644 index 0000000000..4150b78aac Binary files /dev/null and b/website/docs/assets/maya-arnold_scene_source.png differ diff --git a/website/docs/assets/maya-arnold_standin.png b/website/docs/assets/maya-arnold_standin.png new file mode 100644 index 0000000000..74571a86fa Binary files /dev/null and b/website/docs/assets/maya-arnold_standin.png differ diff --git a/website/docs/assets/maya-pointcache_setup.png b/website/docs/assets/maya-pointcache_setup.png index 8904baa239..b2dc126901 100644 Binary files a/website/docs/assets/maya-pointcache_setup.png and b/website/docs/assets/maya-pointcache_setup.png differ diff --git a/website/docs/assets/tvp_create_layer.png b/website/docs/assets/tvp_create_layer.png index 9d243da17a..25081bdf46 100644 Binary files a/website/docs/assets/tvp_create_layer.png and b/website/docs/assets/tvp_create_layer.png differ diff --git a/website/docs/assets/tvp_create_pass.png b/website/docs/assets/tvp_create_pass.png index 7d226ea4b5..6c8e600af2 100644 Binary files a/website/docs/assets/tvp_create_pass.png and b/website/docs/assets/tvp_create_pass.png differ diff --git a/website/docs/assets/tvp_openpype_menu.png b/website/docs/assets/tvp_openpype_menu.png index cb5c2d4aac..23eaf33fc3 100644 Binary files a/website/docs/assets/tvp_openpype_menu.png and b/website/docs/assets/tvp_openpype_menu.png differ diff --git a/website/docs/assets/tvp_publisher.png b/website/docs/assets/tvp_publisher.png new file mode 100644 index 0000000000..e5b1f936df Binary files /dev/null and b/website/docs/assets/tvp_publisher.png differ diff --git a/website/docs/module_deadline.md b/website/docs/module_deadline.md index c96da91909..ab1016788d 100644 --- a/website/docs/module_deadline.md +++ b/website/docs/module_deadline.md @@ -28,16 +28,16 @@ For [AWS Thinkbox Deadline](https://www.awsthinkbox.com/deadline) support you ne OpenPype integration for Deadline consists of two parts: - The `OpenPype` Deadline Plug-in -- A `GlobalJobPreLoad` Deadline Script (this gets triggered for each deadline job) +- A `GlobalJobPreLoad` Deadline Script (this gets triggered for each deadline job) The `GlobalJobPreLoad` handles populating render and publish jobs with proper environment variables using settings from the `OpenPype` Deadline Plug-in. -The `OpenPype` Deadline Plug-in must be configured to point to a valid OpenPype executable location. The executable need to be installed to +The `OpenPype` Deadline Plug-in must be configured to point to a valid OpenPype executable location. The executable need to be installed to destinations accessible by DL process. Check permissions (must be executable and accessible by Deadline process) - Enable `Tools > Super User Mode` in Deadline Monitor -- Go to `Tools > Configure Plugins...`, find `OpenPype` in the list on the left side, find location of OpenPype +- Go to `Tools > Configure Plugins...`, find `OpenPype` in the list on the left side, find location of OpenPype executable. It is recommended to use the `openpype_console` executable as it provides a bit more logging. - In case of multi OS farms, provide multiple locations, each Deadline Worker goes through the list and tries to find the first accessible @@ -45,12 +45,22 @@ executable. It is recommended to use the `openpype_console` executable as it pro ![Configure plugin](assets/deadline_configure_plugin.png) +### Pools + +The main pools can be configured at `project_settings/deadline/publish/CollectDeadlinePools/primary_pool`, which is applied to the rendering jobs. + +The dependent publishing job's pool uses `project_settings/deadline/publish/ProcessSubmittedJobOnFarm/deadline_pool`. If nothing is specified the pool will fallback to the primary pool above. + +:::note maya tile rendering +The logic for publishing job pool assignment applies to tiling jobs. +::: + ## Troubleshooting #### Publishing jobs fail directly in DCCs - Double check that all previously described steps were finished -- Check that `deadlinewebservice` is running on DL server +- Check that `deadlinewebservice` is running on DL server - Check that user's machine has access to deadline server on configured port #### Jobs are failing on DL side @@ -61,40 +71,40 @@ Each publishing from OpenPype consists of 2 jobs, first one is rendering, second - Jobs are failing with `OpenPype executable was not found` error - Check if OpenPype is installed on the Worker handling this job and ensure `OpenPype` Deadline Plug-in is properly [configured](#configuration) + Check if OpenPype is installed on the Worker handling this job and ensure `OpenPype` Deadline Plug-in is properly [configured](#configuration) - Publishing job is failing with `ffmpeg not installed` error - + OpenPype executable has to have access to `ffmpeg` executable, check OpenPype `Setting > General` ![FFmpeg setting](assets/ffmpeg_path.png) - Both jobs finished successfully, but there is no review on Ftrack - Make sure that you correctly set published family to be send to Ftrack. + Make sure that you correctly set published family to be send to Ftrack. ![Ftrack Family](assets/ftrack/ftrack-collect-main.png) Example: I want send to Ftrack review of rendered images from Harmony : - `Host names`: "harmony" - - `Families`: "render" + - `Families`: "render" - `Add Ftrack Family` to "Enabled" - + Make sure that you actually configured to create review for published subset in `project_settings/ftrack/publish/CollectFtrackFamily` ![Ftrack Family](assets/deadline_review.png) - Example: I want to create review for all reviewable subsets in Harmony : + Example: I want to create review for all reviewable subsets in Harmony : - Add "harmony" as a new key an ".*" as a value. - Rendering jobs are stuck in 'Queued' state or failing Make sure that your Deadline is not limiting specific jobs to be run only on specific machines. (Eg. only some machines have installed particular application.) - + Check `project_settings/deadline` - + ![Deadline group](assets/deadline_group.png) Example: I have separated machines with "Harmony" installed into "harmony" group on Deadline. I want rendering jobs published from Harmony to run only on those machines. diff --git a/website/docs/module_kitsu.md b/website/docs/module_kitsu.md index 73e31a280b..7738ee1ce2 100644 --- a/website/docs/module_kitsu.md +++ b/website/docs/module_kitsu.md @@ -37,3 +37,8 @@ This functionality cannot deal with all cases and is not error proof, some inter ```bash openpype_console module kitsu push-to-zou -l me@domain.ext -p my_password ``` + +## Q&A +### Is it safe to rename an entity from Kitsu? +Absolutely! Entities are linked by their unique IDs between the two databases. +But renaming from the OP's Project Manager won't apply the change to Kitsu, it'll be overriden during the next synchronization. diff --git a/website/docs/project_settings/assets/global_oiio_transcode.png b/website/docs/project_settings/assets/global_oiio_transcode.png new file mode 100644 index 0000000000..d818ecfe19 Binary files /dev/null and b/website/docs/project_settings/assets/global_oiio_transcode.png differ diff --git a/website/docs/project_settings/assets/global_oiio_transcode2.png b/website/docs/project_settings/assets/global_oiio_transcode2.png new file mode 100644 index 0000000000..906f780830 Binary files /dev/null and b/website/docs/project_settings/assets/global_oiio_transcode2.png differ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 37fed93e69..b320b5502f 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -45,6 +45,25 @@ The _input pattern_ matching uses REGEX expression syntax (try [regexr.com](http The **colorspace name** value is a raw string input and no validation is run after saving project settings. We recommend to open the specified `config.ocio` file and copy pasting the exact colorspace names. ::: +### Extract OIIO Transcode +OIIOTools transcoder plugin with configurable output presets. Any incoming representation with `colorspaceData` is convertable to single or multiple representations with different target colorspaces or display and viewer names found in linked **config.ocio** file. + +`oiiotool` is used for transcoding, eg. `oiiotool` must be present in `vendor/bin/oiio` or environment variable `OPENPYPE_OIIO_PATHS` must be provided for custom oiio installation. + +Notable parameters: +- **`Delete Original Representation`** - keep or remove original representation. If old representation is kept, but there is new transcoded representation with 'Create review' tag, original representation loses its 'review' tag if present. +- **`Extension`** - target extension. If left empty, original extension is used. +- **`Transcoding type`** - transcoding into colorspace or into display and viewer space could be used. Cannot use both at the same time. +- **`Colorspace`** - target colorspace, which must be available in used color config. (If `Transcoding type` is `Use Colorspace` value in configuration is used OR if empty value collected on instance from DCC). +- **`Display & View`** - display and viewer colorspace. (If `Transcoding type` is `Use Display&View` values in configuration is used OR if empty values collected on instance from DCC). +- **`Arguments`** - special additional command line arguments for `oiiotool`. + + +Example here describes use case for creation of new color coded review of png image sequence. Original representation's files are kept intact, review is created from transcoded files, but these files are removed in cleanup process. +![global_oiio_transcode](assets/global_oiio_transcode.png) + +Another use case is to transcode in Maya only `beauty` render layers and use collected `Display` and `View` colorspaces from DCC. +![global_oiio_transcode_in_Maya](assets/global_oiio_transcode.png)n ## Profile filters diff --git a/website/sidebars.js b/website/sidebars.js index cc945a019e..93887e00f6 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -44,11 +44,13 @@ module.exports = { "artist_hosts_maya_multiverse", "artist_hosts_maya_yeti", "artist_hosts_maya_xgen", + "artist_hosts_maya_arnold", "artist_hosts_maya_vray", "artist_hosts_maya_redshift", ], }, "artist_hosts_blender", + "artist_hosts_3dsmax", "artist_hosts_harmony", "artist_hosts_houdini", "artist_hosts_aftereffects", @@ -85,6 +87,7 @@ module.exports = { type: "category", label: "Configuration", items: [ + "admin_environment", "admin_settings", "admin_settings_system", "admin_settings_project_anatomy", diff --git a/website/yarn.lock b/website/yarn.lock index 9af21c7500..559c58f931 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -4273,9 +4273,9 @@ htmlparser2@^6.1.0: entities "^2.0.0" http-cache-semantics@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" - integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== http-deceiver@^1.2.7: version "1.2.7" @@ -7180,9 +7180,9 @@ typedarray-to-buffer@^3.1.5: is-typedarray "^1.0.0" ua-parser-js@^0.7.30: - version "0.7.31" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6" - integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ== + version "0.7.33" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" + integrity sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw== unherit@^1.0.4: version "1.1.3"