diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 45604e431d..d0853e74d6 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -43,10 +43,10 @@ jobs: uses: heinrichreimer/github-changelog-generator-action@v2.2 with: token: ${{ secrets.ADMIN_TOKEN }} - breakingLabel: '#### 💥 Breaking' - enhancementLabel: '#### 🚀 Enhancements' - bugsLabel: '#### 🐛 Bug fixes' - deprecatedLabel: '#### ⚠️ Deprecations' + breakingLabel: '**💥 Breaking**' + enhancementLabel: '**🚀 Enhancements**' + bugsLabel: '**🐛 Bug fixes**' + deprecatedLabel: '**⚠️ Deprecations**' addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]}}' issues: false issuesWoLabels: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5d1822310..37e1cb4b15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: Stable Release on: - push: - tags: - - '*[0-9].*[0-9].*[0-9]*' + release: + types: + - prereleased jobs: create_release: @@ -23,35 +23,26 @@ jobs: - name: Install Python requirements run: pip install gitpython semver - - name: Set env - run: | - echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - - git config user.email ${{ secrets.CI_EMAIL }} - git config user.name ${{ secrets.CI_USER }} - git fetch - git checkout -b main origin/main - git tag -d ${GITHUB_REF#refs/*/} - git remote set-url --push origin https://pypebot:${{ secrets.ADMIN_TOKEN }}@github.com/pypeclub/openpype - git push origin --delete ${GITHUB_REF#refs/*/} - echo PREVIOUS_VERSION=`git describe --tags --match="[0-9]*" --abbrev=0` >> $GITHUB_ENV - - name: 💉 Inject new version into files id: version - if: steps.version_type.outputs.type != 'skip' run: | - python ./tools/ci_tools.py --version ${{ env.RELEASE_VERSION }} + 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_type.outputs.type != 'skip' + if: steps.version.outputs.release_tag != 'skip' id: generate-full-changelog uses: heinrichreimer/github-changelog-generator-action@v2.2 with: token: ${{ secrets.ADMIN_TOKEN }} - breakingLabel: '#### 💥 Breaking' - enhancementLabel: '#### 🚀 Enhancements' - bugsLabel: '#### 🐛 Bug fixes' - deprecatedLabel: '#### ⚠️ Deprecations' + breakingLabel: '**💥 Breaking**' + enhancementLabel: '**🚀 Enhancements**' + bugsLabel: '**🐛 Bug fixes**' + deprecatedLabel: '**⚠️ Deprecations**' addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]}}' issues: false issuesWoLabels: false @@ -64,39 +55,76 @@ jobs: compareLink: true stripGeneratorNotice: true verbose: true - futureRelease: ${{ env.RELEASE_VERSION }} + futureRelease: ${{ steps.version.outputs.release_tag }} excludeTagsRegex: "CI/.+" releaseBranch: "main" - - name: "🖨️ Print changelog to console" - run: echo ${{ steps.generate-last-changelog.outputs.changelog }} - - name: 💾 Commit and Tag id: git_commit - if: steps.version_type.outputs.type != 'skip' + 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="${{ env.RELEASE_VERSION }}" - git push - git tag -fa $tag_name -m "stable release" - git remote set-url --push origin https://pypebot:${{ secrets.ADMIN_TOKEN }}@github.com/pypeclub/openpype - git push origin $tag_name + tag_name="${{ steps.version.outputs.release_tag }}" + git tag -a $tag_name -m "stable release" - - name: "🚀 Github Release" - uses: docker://antonyurchenko/git-release:latest - env: - GITHUB_TOKEN: ${{ secrets.ADMIN_TOKEN }} - DRAFT_RELEASE: "false" - PRE_RELEASE: "false" - CHANGELOG_FILE: "CHANGELOG.md" - ALLOW_EMPTY_CHANGELOG: "false" - ALLOW_TAG_PREFIX: "true" + - name: 🔏 Push to protected main branch + if: steps.version.outputs.release_tag != 'skip' + uses: CasperWA/push-protected@v2 + 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 }} + breakingLabel: '**💥 Breaking**' + enhancementLabel: '**🚀 Enhancements**' + bugsLabel: '**🐛 Bug fixes**' + deprecatedLabel: '**⚠️ Deprecations**' + addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]}}' + 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: 🔨 Merge main back to develop + - 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 - if: steps.version_type.outputs.type != 'skip' with: github_token: ${{ secrets.ADMIN_TOKEN }} source_ref: 'main' diff --git a/CHANGELOG.md b/CHANGELOG.md index 537be94076..0b69a8e2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,96 @@ # Changelog -## [3.1.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.2.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.0.0...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.4...HEAD) -#### 🚀 Enhancements +**🚀 Enhancements** +- Settings UI copy/paste [\#1769](https://github.com/pypeclub/OpenPype/pull/1769) +- Workfile tool widths [\#1766](https://github.com/pypeclub/OpenPype/pull/1766) +- Push hierarchical attributes care about task parent changes [\#1763](https://github.com/pypeclub/OpenPype/pull/1763) +- Application executables with environment variables [\#1757](https://github.com/pypeclub/OpenPype/pull/1757) +- Settings Hosts enum [\#1739](https://github.com/pypeclub/OpenPype/pull/1739) +- Validate containers settings [\#1736](https://github.com/pypeclub/OpenPype/pull/1736) +- PS - added loader from sequence [\#1726](https://github.com/pypeclub/OpenPype/pull/1726) +- Autoupdate launcher [\#1725](https://github.com/pypeclub/OpenPype/pull/1725) +- Subset template and TVPaint subset template docs [\#1717](https://github.com/pypeclub/OpenPype/pull/1717) +- Toggle Ftrack upload in StandalonePublisher [\#1708](https://github.com/pypeclub/OpenPype/pull/1708) +- Overscan color extract review [\#1701](https://github.com/pypeclub/OpenPype/pull/1701) +- Nuke: Prerender Frame Range by default [\#1699](https://github.com/pypeclub/OpenPype/pull/1699) +- Smoother edges of color triangle [\#1695](https://github.com/pypeclub/OpenPype/pull/1695) + +**🐛 Bug fixes** + +- FFprobe streams order [\#1775](https://github.com/pypeclub/OpenPype/pull/1775) +- Project specific environments [\#1767](https://github.com/pypeclub/OpenPype/pull/1767) +- Settings UI with refresh button [\#1764](https://github.com/pypeclub/OpenPype/pull/1764) +- Standalone publisher thumbnail extractor fix [\#1761](https://github.com/pypeclub/OpenPype/pull/1761) +- Anatomy others templates don't cause crash [\#1758](https://github.com/pypeclub/OpenPype/pull/1758) +- Backend acre module commit update [\#1745](https://github.com/pypeclub/OpenPype/pull/1745) +- hiero: precollect instances failing when audio selected [\#1743](https://github.com/pypeclub/OpenPype/pull/1743) +- Hiero: creator instance error [\#1742](https://github.com/pypeclub/OpenPype/pull/1742) +- Nuke: fixing render creator for no selection format failing [\#1741](https://github.com/pypeclub/OpenPype/pull/1741) +- Local settings UI crash on missing defaults [\#1737](https://github.com/pypeclub/OpenPype/pull/1737) +- TVPaint white background on thumbnail [\#1735](https://github.com/pypeclub/OpenPype/pull/1735) +- Ftrack missing custom attribute message [\#1734](https://github.com/pypeclub/OpenPype/pull/1734) +- Launcher project changes [\#1733](https://github.com/pypeclub/OpenPype/pull/1733) +- Ftrack sync status [\#1732](https://github.com/pypeclub/OpenPype/pull/1732) +- TVPaint use layer name for default variant [\#1724](https://github.com/pypeclub/OpenPype/pull/1724) +- Default subset template for TVPaint review and workfile families [\#1716](https://github.com/pypeclub/OpenPype/pull/1716) +- Maya: Extract review hotfix [\#1714](https://github.com/pypeclub/OpenPype/pull/1714) +- Settings: Imageio improving granularity [\#1711](https://github.com/pypeclub/OpenPype/pull/1711) +- Application without executables [\#1679](https://github.com/pypeclub/OpenPype/pull/1679) + +**Merged pull requests:** + +- Bump prismjs from 1.23.0 to 1.24.0 in /website [\#1773](https://github.com/pypeclub/OpenPype/pull/1773) +- TVPaint ftrack family [\#1755](https://github.com/pypeclub/OpenPype/pull/1755) +- global: removing obsolete ftrack validator plugin [\#1710](https://github.com/pypeclub/OpenPype/pull/1710) + +## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...2.18.4) + +**Merged pull requests:** + +- celaction fixes [\#1754](https://github.com/pypeclub/OpenPype/pull/1754) +- celaciton: audio subset changed data structure [\#1750](https://github.com/pypeclub/OpenPype/pull/1750) + +## [2.18.3](https://github.com/pypeclub/OpenPype/tree/2.18.3) (2021-06-23) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.2...2.18.3) + +**🐛 Bug fixes** + +- Tools names forwards compatibility [\#1727](https://github.com/pypeclub/OpenPype/pull/1727) + +## [2.18.2](https://github.com/pypeclub/OpenPype/tree/2.18.2) (2021-06-16) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.1.0...2.18.2) + +**🚀 Enhancements** + +- StandalonePublisher: adding exception for adding `delete` tag to repre [\#1650](https://github.com/pypeclub/OpenPype/pull/1650) + +**🐛 Bug fixes** + +- Maya: Extract review hotfix - 2.x backport [\#1713](https://github.com/pypeclub/OpenPype/pull/1713) +- StandalonePublisher: instance data attribute `keepSequence` [\#1668](https://github.com/pypeclub/OpenPype/pull/1668) + +**Merged pull requests:** + +- 1698 Nuke: Prerender Frame Range by default [\#1709](https://github.com/pypeclub/OpenPype/pull/1709) + +## [3.1.0](https://github.com/pypeclub/OpenPype/tree/3.1.0) (2021-06-15) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.1.0-nightly.4...3.1.0) + +**🚀 Enhancements** + +- Log Viewer with OpenPype style [\#1703](https://github.com/pypeclub/OpenPype/pull/1703) +- Scrolling in OpenPype info widget [\#1702](https://github.com/pypeclub/OpenPype/pull/1702) +- OpenPype style in modules [\#1694](https://github.com/pypeclub/OpenPype/pull/1694) - Sort applications and tools alphabetically in Settings UI [\#1689](https://github.com/pypeclub/OpenPype/pull/1689) - \#683 - Validate Frame Range in Standalone Publisher [\#1683](https://github.com/pypeclub/OpenPype/pull/1683) - Hiero: old container versions identify with red color [\#1682](https://github.com/pypeclub/OpenPype/pull/1682) @@ -13,26 +98,30 @@ - Remove outline in stylesheet [\#1667](https://github.com/pypeclub/OpenPype/pull/1667) - TVPaint: Creator take layer name as default value for subset variant [\#1663](https://github.com/pypeclub/OpenPype/pull/1663) - TVPaint custom subset template [\#1662](https://github.com/pypeclub/OpenPype/pull/1662) +- Editorial: conform assets validator [\#1659](https://github.com/pypeclub/OpenPype/pull/1659) - Feature Slack integration [\#1657](https://github.com/pypeclub/OpenPype/pull/1657) - Nuke - Publish simplification [\#1653](https://github.com/pypeclub/OpenPype/pull/1653) -- StandalonePublisher: adding exception for adding `delete` tag to repre [\#1650](https://github.com/pypeclub/OpenPype/pull/1650) - \#1333 - added tooltip hints to Pyblish buttons [\#1649](https://github.com/pypeclub/OpenPype/pull/1649) -#### 🐛 Bug fixes +**🐛 Bug fixes** +- Nuke: broken publishing rendered frames [\#1707](https://github.com/pypeclub/OpenPype/pull/1707) +- Standalone publisher Thumbnail export args [\#1705](https://github.com/pypeclub/OpenPype/pull/1705) - Bad zip can break OpenPype start [\#1691](https://github.com/pypeclub/OpenPype/pull/1691) +- Hiero: published whole edit mov [\#1687](https://github.com/pypeclub/OpenPype/pull/1687) - Ftrack subprocess handle of stdout/stderr [\#1675](https://github.com/pypeclub/OpenPype/pull/1675) - Settings list race condifiton and mutable dict list conversion [\#1671](https://github.com/pypeclub/OpenPype/pull/1671) - Mac launch arguments fix [\#1660](https://github.com/pypeclub/OpenPype/pull/1660) - Fix missing dbm python module [\#1652](https://github.com/pypeclub/OpenPype/pull/1652) +- Transparent branches in view on Mac [\#1648](https://github.com/pypeclub/OpenPype/pull/1648) - Add asset on task item [\#1646](https://github.com/pypeclub/OpenPype/pull/1646) - Project manager save and queue [\#1645](https://github.com/pypeclub/OpenPype/pull/1645) - New project anatomy values [\#1644](https://github.com/pypeclub/OpenPype/pull/1644) **Merged pull requests:** +- update dependencies [\#1697](https://github.com/pypeclub/OpenPype/pull/1697) - Bump normalize-url from 4.5.0 to 4.5.1 in /website [\#1686](https://github.com/pypeclub/OpenPype/pull/1686) -- Add docstrings to Project manager tool [\#1556](https://github.com/pypeclub/OpenPype/pull/1556) # Changelog diff --git a/openpype/hosts/hiero/plugins/create/create_shot_clip.py b/openpype/hosts/hiero/plugins/create/create_shot_clip.py index 25be9f090b..0c5bf93a3f 100644 --- a/openpype/hosts/hiero/plugins/create/create_shot_clip.py +++ b/openpype/hosts/hiero/plugins/create/create_shot_clip.py @@ -1,3 +1,4 @@ +from copy import deepcopy import openpype.hosts.hiero.api as phiero # from openpype.hosts.hiero.api import plugin, lib # reload(lib) @@ -206,20 +207,24 @@ class CreateShotClip(phiero.Creator): presets = None def process(self): + # Creator copy of object attributes that are modified during `process` + presets = deepcopy(self.presets) + gui_inputs = deepcopy(self.gui_inputs) + # get key pares from presets and match it on ui inputs - for k, v in self.gui_inputs.items(): + for k, v in gui_inputs.items(): if v["type"] in ("dict", "section"): # nested dictionary (only one level allowed # for sections and dict) for _k, _v in v["value"].items(): - if self.presets.get(_k): - self.gui_inputs[k][ - "value"][_k]["value"] = self.presets[_k] - if self.presets.get(k): - self.gui_inputs[k]["value"] = self.presets[k] + if presets.get(_k): + gui_inputs[k][ + "value"][_k]["value"] = presets[_k] + if presets.get(k): + gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs - widget = self.widget(self.gui_name, self.gui_info, self.gui_inputs) + widget = self.widget(self.gui_name, self.gui_info, gui_inputs) widget.exec_() if len(self.selected) < 1: diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 6e5c640e35..4984849aa7 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -46,12 +46,6 @@ class PrecollectInstances(pyblish.api.ContextPlugin): source_clip = track_item.source() self.log.debug("clip_name: {}".format(clip_name)) - # get clips subtracks and anotations - annotations = self.clip_annotations(source_clip) - subtracks = self.clip_subtrack(track_item) - self.log.debug("Annotations: {}".format(annotations)) - self.log.debug(">> Subtracks: {}".format(subtracks)) - # get openpype tag data tag_data = phiero.get_track_item_pype_data(track_item) self.log.debug("__ tag_data: {}".format(pformat(tag_data))) @@ -62,6 +56,12 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if tag_data.get("id") != "pyblish.avalon.instance": continue + # get clips subtracks and anotations + annotations = self.clip_annotations(source_clip) + subtracks = self.clip_subtrack(track_item) + self.log.debug("Annotations: {}".format(annotations)) + self.log.debug(">> Subtracks: {}".format(subtracks)) + # solve handles length tag_data["handleStart"] = min( tag_data["handleStart"], int(track_item.handleInLength())) diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index fa1ce7f9a9..57e3f478f1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -72,7 +72,7 @@ class ExtractPlayblast(openpype.api.Extractor): # Isolate view is requested by having objects in the set besides a # camera. - if preset.pop("isolate_view", False) or instance.data.get("isolate"): + if preset.pop("isolate_view", False) and instance.data.get("isolate"): preset["isolate"] = instance.data["setMembers"] # Show/Hide image planes on request. diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 5a91888781..aa8adc3986 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -75,7 +75,7 @@ class ExtractThumbnail(openpype.api.Extractor): # Isolate view is requested by having objects in the set besides a # camera. - if preset.pop("isolate_view", False) or instance.data.get("isolate"): + if preset.pop("isolate_view", False) and instance.data.get("isolate"): preset["isolate"] = instance.data["setMembers"] with maintained_time(): diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 7c274a03c7..d7f3fdc6ba 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -286,7 +286,8 @@ def add_button_write_to_read(node): node.addKnob(knob) -def create_write_node(name, data, input=None, prenodes=None, review=True): +def create_write_node(name, data, input=None, prenodes=None, + review=True, linked_knobs=None): ''' Creating write node which is group node Arguments: @@ -465,12 +466,16 @@ def create_write_node(name, data, input=None, prenodes=None, review=True): GN.addKnob(nuke.Text_Knob('', 'Rendering')) # Add linked knobs. - linked_knob_names = [ - "_grp-start_", - "use_limit", "first", "last", - "_grp-end_", - "Render" - ] + linked_knob_names = [] + + # add input linked knobs and create group only if any input + if linked_knobs: + linked_knob_names.append("_grp-start_") + linked_knob_names.extend(linked_knobs) + linked_knob_names.append("_grp-end_") + + linked_knob_names.append("Render") + for name in linked_knob_names: if "_grp-start_" in name: knob = nuke.Tab_Knob( @@ -481,13 +486,20 @@ def create_write_node(name, data, input=None, prenodes=None, review=True): "rnd_attr_end", "Rendering attributes", nuke.TABENDGROUP) GN.addKnob(knob) else: - link = nuke.Link_Knob("") - link.makeLink(write_node.name(), name) - link.setName(name) - if "Render" in name: - link.setLabel("Render Local") - link.setFlag(0x1000) - GN.addKnob(link) + if "___" in name: + # add devider + GN.addKnob(nuke.Text_Knob("")) + else: + # add linked knob by name + link = nuke.Link_Knob("") + link.makeLink(write_node.name(), name) + link.setName(name) + + # make render + if "Render" in name: + link.setLabel("Render Local") + link.setFlag(0x1000) + GN.addKnob(link) # adding write to read button add_button_write_to_read(GN) diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index 6e1a2ddd96..1b925014ad 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -103,7 +103,8 @@ class CreateWritePrerender(plugin.PypeCreator): write_data, input=selected_node, prenodes=[], - review=False) + review=False, + linked_knobs=["channels", "___", "first", "last", "use_limit"]) # relinking to collected connections for i, input in enumerate(inputs): @@ -122,19 +123,9 @@ class CreateWritePrerender(plugin.PypeCreator): w_node = n write_node.end() - # add inner write node Tab - write_node.addKnob(nuke.Tab_Knob("WriteLinkedKnobs")) - - # linking knobs to group property panel - linking_knobs = ["channels", "___", "first", "last", "use_limit"] - for k in linking_knobs: - if "___" in k: - write_node.addKnob(nuke.Text_Knob('')) - else: - lnk = nuke.Link_Knob(k) - lnk.makeLink(w_node.name(), k) - lnk.setName(k.replace('_', ' ').capitalize()) - lnk.clearFlag(nuke.STARTLINE) - write_node.addKnob(lnk) + if self.presets.get("use_range_limit"): + w_node["use_limit"].setValue(True) + w_node["first"].setValue(nuke.root()["first_frame"].value()) + w_node["last"].setValue(nuke.root()["last_frame"].value()) return write_node diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index 9ddf0e4a87..a1381122ee 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -100,6 +100,13 @@ class CreateWriteRender(plugin.PypeCreator): "/{subset}.{frame}.{ext}")}) # add crop node to cut off all outside of format bounding box + # get width and height + try: + width, height = (selected_node.width(), selected_node.height()) + except AttributeError: + actual_format = nuke.root().knob('format').value() + width, height = (actual_format.width(), actual_format.height()) + _prenodes = [ { "name": "Crop01", @@ -108,8 +115,8 @@ class CreateWriteRender(plugin.PypeCreator): ("box", [ 0.0, 0.0, - selected_node.width(), - selected_node.height() + width, + height ]) ], "dependent": None diff --git a/openpype/hosts/nuke/plugins/publish/precollect_writes.py b/openpype/hosts/nuke/plugins/publish/precollect_writes.py index 5eaac89e84..0b5fbc0479 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_writes.py @@ -1,5 +1,6 @@ import os import re +from pprint import pformat import nuke import pyblish.api import openpype.api as pype @@ -17,6 +18,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin): def process(self, instance): _families_test = [instance.data["family"]] + instance.data["families"] + self.log.debug("_families_test: {}".format(_families_test)) node = None for x in instance: @@ -133,22 +135,29 @@ class CollectNukeWrites(pyblish.api.InstancePlugin): "outputDir": output_dir, "ext": ext, "label": label, - "handleStart": handle_start, - "handleEnd": handle_end, - "frameStart": first_frame + handle_start, - "frameEnd": last_frame - handle_end, - "frameStartHandle": first_frame, - "frameEndHandle": last_frame, "outputType": output_type, "colorspace": colorspace, "deadlineChunkSize": deadlineChunkSize, "deadlinePriority": deadlinePriority }) - if "prerender" in _families_test: + if self.is_prerender(_families_test): instance.data.update({ - "family": "prerender", - "families": [] + "handleStart": 0, + "handleEnd": 0, + "frameStart": first_frame, + "frameEnd": last_frame, + "frameStartHandle": first_frame, + "frameEndHandle": last_frame, + }) + else: + instance.data.update({ + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStart": first_frame + handle_start, + "frameEnd": last_frame - handle_end, + "frameStartHandle": first_frame, + "frameEndHandle": last_frame, }) # * Add audio to instance if exists. @@ -170,4 +179,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin): "filename": api.get_representation_path(repre_doc) }] - self.log.debug("instance.data: {}".format(instance.data)) + self.log.debug("instance.data: {}".format(pformat(instance.data))) + + def is_prerender(self, families): + return next((f for f in families if "prerender" in f), None) diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index 8b71aff1ac..0c88014649 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -61,7 +61,6 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): hosts = ["nuke", "nukestudio"] actions = [RepairCollectionActionToLocal, RepairCollectionActionToFarm] - def process(self, instance): for repre in instance.data["representations"]: @@ -78,10 +77,10 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): collection = collections[0] - frame_length = int( - instance.data["frameEndHandle"] - - instance.data["frameStartHandle"] + 1 - ) + fstartH = instance.data["frameStartHandle"] + fendH = instance.data["frameEndHandle"] + + frame_length = int(fendH - fstartH + 1) if frame_length != 1: if len(collections) != 1: @@ -95,7 +94,16 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): raise ValidationException(msg) collected_frames_len = int(len(collection.indexes)) + coll_start = min(collection.indexes) + coll_end = max(collection.indexes) + self.log.info("frame_length: {}".format(frame_length)) + self.log.info("collected_frames_len: {}".format( + collected_frames_len)) + self.log.info("fstartH-fendH: {}-{}".format(fstartH, fendH)) + self.log.info( + "coll_start-coll_end: {}-{}".format(coll_start, coll_end)) + self.log.info( "len(collection.indexes): {}".format(collected_frames_len) ) @@ -103,8 +111,11 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin): if ("slate" in instance.data["families"]) \ and (frame_length != collected_frames_len): collected_frames_len -= 1 + fstartH += 1 - assert (collected_frames_len == frame_length), ( + assert ((collected_frames_len >= frame_length) + and (coll_start <= fstartH) + and (coll_end >= fendH)), ( "{} missing frames. Use repair to render all frames" ).format(__name__) diff --git a/openpype/hosts/photoshop/plugins/lib.py b/openpype/hosts/photoshop/plugins/lib.py new file mode 100644 index 0000000000..74aff06114 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/lib.py @@ -0,0 +1,26 @@ +import re + + +def get_unique_layer_name(layers, asset_name, subset_name): + """ + Gets all layer names and if 'asset_name_subset_name' is present, it + increases suffix by 1 (eg. creates unique layer name - for Loader) + Args: + layers (list) of dict with layers info (name, id etc.) + asset_name (string): + subset_name (string): + + Returns: + (string): name_00X (without version) + """ + name = "{}_{}".format(asset_name, subset_name) + names = {} + for layer in layers: + layer_name = re.sub(r'_\d{3}$', '', layer.name) + if layer_name in names.keys(): + names[layer_name] = names[layer_name] + 1 + else: + names[layer_name] = 1 + occurrences = names.get(name, 0) + + return "{}_{:0>3d}".format(name, occurrences + 1) diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 44cc96c96f..d043323768 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -1,7 +1,9 @@ -from avalon import api, photoshop -import os import re +from avalon import api, photoshop + +from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name + stub = photoshop.stub() @@ -15,8 +17,9 @@ class ImageLoader(api.Loader): representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = self._get_unique_layer_name(context["asset"]["name"], - name) + layer_name = get_unique_layer_name(stub.get_layers(), + context["asset"]["name"], + name) with photoshop.maintained_selection(): layer = stub.import_smart_object(self.fname, layer_name) @@ -69,25 +72,3 @@ class ImageLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - - def _get_unique_layer_name(self, asset_name, subset_name): - """ - Gets all layer names and if 'name' is present in them, increases - suffix by 1 (eg. creates unique layer name - for Loader) - Args: - name (string): in format asset_subset - - Returns: - (string): name_00X (without version) - """ - name = "{}_{}".format(asset_name, subset_name) - names = {} - for layer in stub.get_layers(): - layer_name = re.sub(r'_\d{3}$', '', layer.name) - if layer_name in names.keys(): - names[layer_name] = names[layer_name] + 1 - else: - names[layer_name] = 1 - occurrences = names.get(name, 0) - - return "{}_{:0>3d}".format(name, occurrences + 1) diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py new file mode 100644 index 0000000000..8704627b12 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -0,0 +1,98 @@ +import os + +from avalon import api +from avalon import photoshop +from avalon.pipeline import get_representation_path_from_context +from avalon.vendor import qargparse + +from openpype.lib import Anatomy +from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name + +stub = photoshop.stub() + + +class ImageFromSequenceLoader(api.Loader): + """ Load specifing image from sequence + + Used only as quick load of reference file from a sequence. + + Plain ImageLoader picks first frame from sequence. + + Loads only existing files - currently not possible to limit loaders + to single select - multiselect. If user selects multiple repres, list + for all of them is provided, but selection is only single file. + This loader will be triggered multiple times, but selected name will + match only to proper path. + + Loader doesnt do containerization as there is currently no data model + of 'frame of rendered files' (only rendered sequence), update would be + difficult. + """ + + families = ["render"] + representations = ["*"] + options = [] + + def load(self, context, name=None, namespace=None, data=None): + if data.get("frame"): + self.fname = os.path.join(os.path.dirname(self.fname), + data["frame"]) + if not os.path.exists(self.fname): + return + + stub = photoshop.stub() + layer_name = get_unique_layer_name(stub.get_layers(), + context["asset"]["name"], + name) + + with photoshop.maintained_selection(): + layer = stub.import_smart_object(self.fname, layer_name) + + self[:] = [layer] + namespace = namespace or layer_name + + return namespace + + @classmethod + def get_options(cls, repre_contexts): + """ + Returns list of files for selected 'repre_contexts'. + + It returns only files with same extension as in context as it is + expected that context points to sequence of frames. + + Returns: + (list) of qargparse.Choice + """ + files = [] + for context in repre_contexts: + fname = get_representation_path_from_context(context) + _, file_extension = os.path.splitext(fname) + + for file_name in os.listdir(os.path.dirname(fname)): + if not file_name.endswith(file_extension): + continue + files.append(file_name) + + # return selection only if there is something + if not files or len(files) <= 1: + return [] + + return [ + qargparse.Choice( + "frame", + label="Select specific file", + items=files, + default=0, + help="Which frame should be loaded?" + ) + ] + + def update(self, container, representation): + """No update possible, not containerized.""" + pass + + def remove(self, container): + """No update possible, not containerized.""" + pass + diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py index 43ab13cd79..6913e0836d 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py @@ -34,7 +34,6 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): # presets batch_extensions = ["edl", "xml", "psd"] - default_families = ["ftrack"] def process(self, context): # get json paths from os and load them @@ -213,10 +212,6 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): subset = in_data["subset"] # If instance data already contain families then use it instance_families = in_data.get("families") or [] - # Make sure default families are in instance - for default_family in self.default_families or []: - if default_family not in instance_families: - instance_families.append(default_family) instance = context.create_instance(subset) instance.data.update( diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_instances.py index eb04217136..d753a3d9bb 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_instances.py @@ -16,12 +16,12 @@ class CollectInstances(pyblish.api.InstancePlugin): subsets = { "referenceMain": { "family": "review", - "families": ["clip", "ftrack"], + "families": ["clip"], "extensions": [".mp4"] }, "audioMain": { "family": "audio", - "families": ["clip", "ftrack"], + "families": ["clip"], "extensions": [".wav"], }, "shotMain": { diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_matchmove.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_matchmove.py deleted file mode 100644 index 5d9e8ddfb4..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_matchmove.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Requires: - Nothing - -Provides: - Instance -""" - -import pyblish.api -import logging - - -log = logging.getLogger("collector") - - -class CollectMatchmovePublish(pyblish.api.InstancePlugin): - """ - Collector with only one reason for its existence - remove 'ftrack' - family implicitly added by Standalone Publisher - """ - - label = "Collect Matchmove - SA Publish" - order = pyblish.api.CollectorOrder - families = ["matchmove"] - hosts = ["standalonepublisher"] - - def process(self, instance): - if "ftrack" in instance.data["families"]: - instance.data["families"].remove("ftrack") diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index 963d47956a..0792254716 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -66,7 +66,6 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): else: # Convert to jpeg if not yet full_input_path = os.path.join(thumbnail_repre["stagingDir"], file) - full_input_path = '"{}"'.format(full_input_path) self.log.info("input {}".format(full_input_path)) full_thumbnail_path = tempfile.mkstemp(suffix=".jpg")[1] diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py index e3086fb638..943cb73b98 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py @@ -43,7 +43,10 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): self.log.warning("Cannot check for extension {}".format(ext)) return - frames = len(instance.data.get("representations", [None])[0]["files"]) + files = instance.data.get("representations", [None])[0]["files"] + if isinstance(files, str): + files = [files] + frames = len(files) err_msg = "Frame duration from DB:'{}' ". format(int(duration)) +\ " doesn't match number of files:'{}'".format(frames) +\ diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py index eeb7d32d50..af6c0f0eee 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py @@ -58,18 +58,14 @@ class CreateRenderlayer(plugin.Creator): # Get currently selected layers layers_data = lib.layers_data() - group_ids = set() - for layer in layers_data: - if layer["selected"]: - group_ids.add(layer["group_id"]) - + selected_layers = [ + layer + for layer in layers_data + if layer["selected"] + ] # Return layer name if only one is selected - if len(group_ids) == 1: - group_id = list(group_ids)[0] - groups_data = lib.groups_data() - for group in groups_data: - if group["group_id"] == group_id: - return group["name"] + if len(selected_layers) == 1: + return selected_layers[0]["name"] # Use defaults if cls.defaults: diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 4468bfae40..e496b144cd 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -103,8 +103,6 @@ class CollectInstances(pyblish.api.ContextPlugin): instance.data["layers"] = copy.deepcopy( context.data["layersData"] ) - # Add ftrack family - instance.data["families"].append("ftrack") elif family == "renderLayer": instance = self.create_render_layer_instance( @@ -186,9 +184,6 @@ class CollectInstances(pyblish.api.ContextPlugin): instance_data["layers"] = group_layers - # Add ftrack family - instance_data["families"].append("ftrack") - return context.create_instance(**instance_data) def create_render_pass_instance(self, context, instance_data): diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index 13c6c9eb78..d8bb03f541 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -1,5 +1,6 @@ import os import json +import tempfile import pyblish.api import avalon.api @@ -153,9 +154,45 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): "sceneMarkIn": int(mark_in_frame), "sceneMarkInState": mark_in_state == "set", "sceneMarkOut": int(mark_out_frame), - "sceneMarkOutState": mark_out_state == "set" + "sceneMarkOutState": mark_out_state == "set", + "sceneBgColor": self._get_bg_color() } self.log.debug( "Scene data: {}".format(json.dumps(scene_data, indent=4)) ) context.data.update(scene_data) + + def _get_bg_color(self): + """Background color set on scene. + + Is important for review exporting where scene bg color is used as + background. + """ + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + output_file.close() + output_filepath = output_file.name.replace("\\", "/") + george_script_lines = [ + # Variable containing full path to output file + "output_path = \"{}\"".format(output_filepath), + "tv_background", + "bg_color = result", + # Write data to output file + ( + "tv_writetextfile" + " \"strict\" \"append\" '\"'output_path'\"' bg_color" + ) + ] + + george_script = "\n".join(george_script_lines) + lib.execute_george_through_file(george_script) + + with open(output_filepath, "r") as stream: + data = stream.read() + + os.remove(output_filepath) + data = data.strip() + if not data: + return None + return data.split(" ") diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 007b5c41f1..536df2adb0 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -1,5 +1,6 @@ import os import shutil +import copy import tempfile import pyblish.api @@ -13,6 +14,9 @@ class ExtractSequence(pyblish.api.Extractor): hosts = ["tvpaint"] families = ["review", "renderPass", "renderLayer"] + # Modifiable with settings + review_bg = [255, 255, 255, 255] + def process(self, instance): self.log.info( "* Processing instance \"{}\"".format(instance.data["label"]) @@ -53,6 +57,8 @@ class ExtractSequence(pyblish.api.Extractor): handle_start = instance.context.data["handleStart"] handle_end = instance.context.data["handleEnd"] + scene_bg_color = instance.context.data["sceneBgColor"] + # --- Fallbacks ---------------------------------------------------- # This is required if validations of ranges are ignored. # - all of this code won't change processing if range to render @@ -120,7 +126,8 @@ class ExtractSequence(pyblish.api.Extractor): if instance.data["family"] == "review": output_filenames, thumbnail_fullpath = self.render_review( - filename_template, output_dir, mark_in, mark_out + filename_template, output_dir, mark_in, mark_out, + scene_bg_color ) else: # Render output @@ -241,7 +248,9 @@ class ExtractSequence(pyblish.api.Extractor): for path in repre_filepaths ] - def render_review(self, filename_template, output_dir, mark_in, mark_out): + def render_review( + self, filename_template, output_dir, mark_in, mark_out, scene_bg_color + ): """ Export images from TVPaint using `tv_savesequence` command. Args: @@ -252,6 +261,8 @@ class ExtractSequence(pyblish.api.Extractor): output_dir (str): Directory where files will be stored. mark_in (int): Starting frame index from which export will begin. mark_out (int): On which frame index export will end. + scene_bg_color (list): Bg color set in scene. Result of george + script command `tv_background`. Retruns: tuple: With 2 items first is list of filenames second is path to @@ -263,7 +274,11 @@ class ExtractSequence(pyblish.api.Extractor): filename_template.format(frame=mark_in) ) + bg_color = self._get_review_bg_color() + george_script_lines = [ + # Change bg color to color from settings + "tv_background \"color\" {} {} {}".format(*bg_color), "tv_SaveMode \"PNG\"", "export_path = \"{}\"".format( first_frame_filepath.replace("\\", "/") @@ -272,6 +287,18 @@ class ExtractSequence(pyblish.api.Extractor): mark_in, mark_out ) ] + if scene_bg_color: + # Change bg color back to previous scene bg color + _scene_bg_color = copy.deepcopy(scene_bg_color) + bg_type = _scene_bg_color.pop(0) + orig_color_command = [ + "tv_background", + "\"{}\"".format(bg_type) + ] + orig_color_command.extend(_scene_bg_color) + + george_script_lines.append(" ".join(orig_color_command)) + lib.execute_george_through_file("\n".join(george_script_lines)) first_frame_filepath = None @@ -291,12 +318,13 @@ class ExtractSequence(pyblish.api.Extractor): if first_frame_filepath is None: first_frame_filepath = filepath - thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg") + thumbnail_filepath = None if first_frame_filepath and os.path.exists(first_frame_filepath): + thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg") source_img = Image.open(first_frame_filepath) - thumbnail_obj = Image.new("RGB", source_img.size, (255, 255, 255)) - thumbnail_obj.paste(source_img) - thumbnail_obj.save(thumbnail_filepath) + if source_img.mode.lower() != "rgb": + source_img = source_img.convert("RGB") + source_img.save(thumbnail_filepath) return output_filenames, thumbnail_filepath @@ -392,12 +420,35 @@ class ExtractSequence(pyblish.api.Extractor): if thumbnail_src_filepath and os.path.exists(thumbnail_src_filepath): source_img = Image.open(thumbnail_src_filepath) thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg") - thumbnail_obj = Image.new("RGB", source_img.size, (255, 255, 255)) - thumbnail_obj.paste(source_img) - thumbnail_obj.save(thumbnail_filepath) + # Composite background only on rgba images + # - just making sure + if source_img.mode.lower() == "rgba": + bg_color = self._get_review_bg_color() + self.log.debug("Adding thumbnail background color {}.".format( + " ".join([str(val) for val in bg_color]) + )) + bg_image = Image.new("RGBA", source_img.size, bg_color) + thumbnail_obj = Image.alpha_composite(bg_image, source_img) + thumbnail_obj.convert("RGB").save(thumbnail_filepath) + + else: + self.log.info(( + "Source for thumbnail has mode \"{}\" (Expected: RGBA)." + " Can't use thubmanail background color." + ).format(source_img.mode)) + source_img.save(thumbnail_filepath) return output_filenames, thumbnail_filepath + def _get_review_bg_color(self): + red = green = blue = 255 + if self.review_bg: + if len(self.review_bg) == 4: + red, green, blue, _ = self.review_bg + elif len(self.review_bg) == 3: + red, green, blue = self.review_bg + return (red, green, blue) + def _render_layer( self, layer, diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index c16c6e2e99..7a4a55363c 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -733,6 +733,9 @@ class Templates: continue default_key_values[key] = templates.pop(key) + # Pop "others" key before before expected keys are processed + other_templates = templates.pop("others") or {} + keys_by_subkey = {} for sub_key, sub_value in templates.items(): key_values = {} @@ -740,7 +743,6 @@ class Templates: key_values.update(sub_value) keys_by_subkey[sub_key] = cls.prepare_inner_keys(key_values) - other_templates = templates.get("others") or {} for sub_key, sub_value in other_templates.items(): if sub_key in keys_by_subkey: log.warning(( diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index d82b7cd847..1eac7ea776 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -179,7 +179,7 @@ class Application: if group.enabled: enabled = data.get("enabled", True) self.enabled = enabled - self.use_python_2 = data["use_python_2"] + self.use_python_2 = data.get("use_python_2", False) self.label = data.get("variant_label") or name self.full_name = "/".join((group.name, name)) @@ -191,26 +191,32 @@ class Application: self.full_label = full_label self._environment = data.get("environment") or {} + arguments = data.get("arguments") + if isinstance(arguments, dict): + arguments = arguments.get(platform.system().lower()) + + if not arguments: + arguments = [] + self.arguments = arguments + + if "executables" not in data: + self.executables = [ + UndefinedApplicationExecutable() + ] + return + _executables = data["executables"] + if isinstance(_executables, dict): + _executables = _executables.get(platform.system().lower()) + if not _executables: _executables = [] - elif isinstance(_executables, dict): - _executables = _executables.get(platform.system().lower()) or [] - - _arguments = data["arguments"] - if not _arguments: - _arguments = [] - - elif isinstance(_arguments, dict): - _arguments = _arguments.get(platform.system().lower()) or [] - executables = [] for executable in _executables: executables.append(ApplicationExecutable(executable)) self.executables = executables - self.arguments = _arguments def __repr__(self): return "<{}> - {}".format(self.__class__.__name__, self.full_name) @@ -454,6 +460,12 @@ class ApplicationExecutable: if os.path.exists(_executable): executable = _executable + # Try to format executable with environments + try: + executable = executable.format(**os.environ) + except Exception: + pass + self.executable_path = executable def __str__(self): @@ -484,6 +496,27 @@ class ApplicationExecutable: return bool(self._realpath()) +class UndefinedApplicationExecutable(ApplicationExecutable): + """Some applications do not require executable path from settings. + + In that case this class is used to "fake" existing executable. + """ + def __init__(self): + pass + + def __str__(self): + return self.__class__.__name__ + + def __repr__(self): + return "<{}>".format(self.__class__.__name__) + + def as_args(self): + return [] + + def exists(self): + return True + + @six.add_metaclass(ABCMeta) class LaunchHook: """Abstract base class of launch hook.""" @@ -1126,6 +1159,9 @@ def prepare_host_environments(data, implementation_envs=True): def apply_project_environments_value(project_name, env, project_settings=None): """Apply project specific environments on passed environments. + The enviornments are applied on passed `env` argument value so it is not + required to apply changes back. + Args: project_name (str): Name of project for which environemnts should be received. @@ -1134,6 +1170,9 @@ def apply_project_environments_value(project_name, env, project_settings=None): project_settings (dict): Project settings for passed project name. Optional if project settings are already prepared. + Returns: + dict: Passed env values with applied project environments. + Raises: KeyError: If project settings do not contain keys for project specific environments. @@ -1144,10 +1183,9 @@ def apply_project_environments_value(project_name, env, project_settings=None): project_settings = get_project_settings(project_name) env_value = project_settings["global"]["project_environments"] - if not env_value: - return env - parsed = acre.parse(env_value) - return _merge_env(parsed, env) + if env_value: + env.update(_merge_env(acre.parse(env_value), env)) + return env def prepare_context_environments(data): @@ -1176,9 +1214,8 @@ def prepare_context_environments(data): # Load project specific environments project_name = project_doc["name"] - data["env"] = apply_project_environments_value( - project_name, data["env"] - ) + # Apply project specific environments on current env value + apply_project_environments_value(project_name, data["env"]) app = data["app"] workdir_data = get_workdir_data( diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index 3b923cb608..a8c75c20da 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -89,8 +89,13 @@ def ffprobe_streams(path_to_file, logger=None): popen_stdout, popen_stderr = popen.communicate() if popen_stdout: - logger.debug("ffprobe stdout: {}".format(popen_stdout)) + logger.debug("FFprobe stdout:\n{}".format( + popen_stdout.decode("utf-8") + )) if popen_stderr: - logger.debug("ffprobe stderr: {}".format(popen_stderr)) + logger.warning("FFprobe stderr:\n{}".format( + popen_stderr.decode("utf-8") + )) + return json.loads(popen_stdout)["streams"] diff --git a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py index c0b3137455..81719258e1 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py @@ -2,7 +2,10 @@ import collections import datetime import ftrack_api -from openpype.modules.ftrack.lib import BaseEvent +from openpype.modules.ftrack.lib import ( + BaseEvent, + query_custom_attributes +) class PushFrameValuesToTaskEvent(BaseEvent): @@ -55,10 +58,6 @@ class PushFrameValuesToTaskEvent(BaseEvent): if entity_info.get("entityType") != "task": continue - # Skip `Task` entity type - if entity_info["entity_type"].lower() == "task": - continue - # Care only about changes of status changes = entity_info.get("changes") if not changes: @@ -74,6 +73,14 @@ class PushFrameValuesToTaskEvent(BaseEvent): if project_id is None: continue + # Skip `Task` entity type if parent didn't change + if entity_info["entity_type"].lower() == "task": + if ( + "parent_id" not in changes + or changes["parent_id"]["new"] is None + ): + continue + if project_id not in entities_info_by_project_id: entities_info_by_project_id[project_id] = [] entities_info_by_project_id[project_id].append(entity_info) @@ -117,11 +124,24 @@ class PushFrameValuesToTaskEvent(BaseEvent): )) return + interest_attributes = set(interest_attributes) + interest_entity_types = set(interest_entity_types) + + # Separate value changes and task parent changes + _entities_info = [] + task_parent_changes = [] + for entity_info in entities_info: + if entity_info["entity_type"].lower() == "task": + task_parent_changes.append(entity_info) + else: + _entities_info.append(entity_info) + entities_info = _entities_info + # Filter entities info with changes interesting_data, changed_keys_by_object_id = self.filter_changes( session, event, entities_info, interest_attributes ) - if not interesting_data: + if not interesting_data and not task_parent_changes: return # Prepare object types @@ -131,6 +151,289 @@ class PushFrameValuesToTaskEvent(BaseEvent): name_low = object_type["name"].lower() object_types_by_name[name_low] = object_type + # NOTE it would be nice to check if `interesting_data` do not contain + # value changs of tasks that were created or moved + # - it is a complex way how to find out + if interesting_data: + self.process_attribute_changes( + session, object_types_by_name, + interesting_data, changed_keys_by_object_id, + interest_entity_types, interest_attributes + ) + + if task_parent_changes: + self.process_task_parent_change( + session, object_types_by_name, task_parent_changes, + interest_entity_types, interest_attributes + ) + + def process_task_parent_change( + self, session, object_types_by_name, task_parent_changes, + interest_entity_types, interest_attributes + ): + """Push custom attribute values if task parent has changed. + + Parent is changed if task is created or if is moved under different + entity. We don't care about all task changes only about those that + have it's parent in interest types (from settings). + + Tasks hierarchical value should be unset or set based on parents + real hierarchical value and non hierarchical custom attribute value + should be set to hierarchical value. + """ + # Store task ids which were created or moved under parent with entity + # type defined in settings (interest_entity_types). + task_ids = set() + # Store parent ids of matching task ids + matching_parent_ids = set() + # Store all entity ids of all entities to be able query hierarchical + # values. + whole_hierarchy_ids = set() + # Store parent id of each entity id + parent_id_by_entity_id = {} + for entity_info in task_parent_changes: + # Ignore entities with less parents than 2 + # NOTE entity itself is also part of "parents" value + parents = entity_info.get("parents") or [] + if len(parents) < 2: + continue + + parent_info = parents[1] + # Check if parent has entity type we care about. + if parent_info["entity_type"] not in interest_entity_types: + continue + + task_ids.add(entity_info["entityId"]) + matching_parent_ids.add(parent_info["entityId"]) + + # Store whole hierarchi of task entity + prev_id = None + for item in parents: + item_id = item["entityId"] + whole_hierarchy_ids.add(item_id) + + if prev_id is None: + prev_id = item_id + continue + + parent_id_by_entity_id[prev_id] = item_id + if item["entityType"] == "show": + break + prev_id = item_id + + # Just skip if nothing is interesting for our settings + if not matching_parent_ids: + return + + # Query object type ids of parent ids for custom attribute + # definitions query + entities = session.query( + "select object_type_id from TypedContext where id in ({})".format( + self.join_query_keys(matching_parent_ids) + ) + ) + + # Prepare task object id + task_object_id = object_types_by_name["task"]["id"] + + # All object ids for which we're querying custom attribute definitions + object_type_ids = set() + object_type_ids.add(task_object_id) + for entity in entities: + object_type_ids.add(entity["object_type_id"]) + + attrs_by_obj_id, hier_attrs = self.attrs_configurations( + session, object_type_ids, interest_attributes + ) + + # Skip if all task attributes are not available + task_attrs = attrs_by_obj_id.get(task_object_id) + if not task_attrs: + return + + # Skip attributes that is not in both hierarchical and nonhierarchical + # TODO be able to push values if hierarchical is available + for key in interest_attributes: + if key not in hier_attrs: + task_attrs.pop(key, None) + + elif key not in task_attrs: + hier_attrs.pop(key) + + # Skip if nothing remained + if not task_attrs: + return + + # Do some preparations for custom attribute values query + attr_key_by_id = {} + nonhier_id_by_key = {} + hier_attr_ids = [] + for key, attr_id in hier_attrs.items(): + attr_key_by_id[attr_id] = key + hier_attr_ids.append(attr_id) + + conf_ids = list(hier_attr_ids) + for key, attr_id in task_attrs.items(): + attr_key_by_id[attr_id] = key + nonhier_id_by_key[key] = attr_id + conf_ids.append(attr_id) + + # Query custom attribute values + # - result does not contain values for all entities only result of + # query callback to ftrack server + result = query_custom_attributes( + session, conf_ids, whole_hierarchy_ids + ) + + # Prepare variables where result will be stored + # - hierachical values should not contain attribute with value by + # default + hier_values_by_entity_id = { + entity_id: {} + for entity_id in whole_hierarchy_ids + } + # - real values of custom attributes + values_by_entity_id = { + entity_id: { + attr_id: None + for attr_id in conf_ids + } + for entity_id in whole_hierarchy_ids + } + for item in result: + attr_id = item["configuration_id"] + entity_id = item["entity_id"] + value = item["value"] + + values_by_entity_id[entity_id][attr_id] = value + + if attr_id in hier_attr_ids and value is not None: + hier_values_by_entity_id[entity_id][attr_id] = value + + # Prepare values for all task entities + # - going through all parents and storing first value value + # - store None to those that are already known that do not have set + # value at all + for task_id in tuple(task_ids): + for attr_id in hier_attr_ids: + entity_ids = [] + value = None + entity_id = task_id + while value is None: + entity_value = hier_values_by_entity_id[entity_id] + if attr_id in entity_value: + value = entity_value[attr_id] + if value is None: + break + + if value is None: + entity_ids.append(entity_id) + + entity_id = parent_id_by_entity_id.get(entity_id) + if entity_id is None: + break + + for entity_id in entity_ids: + hier_values_by_entity_id[entity_id][attr_id] = value + + # Prepare changes to commit + changes = [] + for task_id in tuple(task_ids): + parent_id = parent_id_by_entity_id[task_id] + for attr_id in hier_attr_ids: + attr_key = attr_key_by_id[attr_id] + nonhier_id = nonhier_id_by_key[attr_key] + + # Real value of hierarchical attribute on parent + # - If is none then should be unset + real_parent_value = values_by_entity_id[parent_id][attr_id] + # Current hierarchical value of a task + # - Will be compared to real parent value + hier_value = hier_values_by_entity_id[task_id][attr_id] + + # Parent value that can be inherited from it's parent entity + parent_value = hier_values_by_entity_id[parent_id][attr_id] + # Task value of nonhierarchical custom attribute + nonhier_value = values_by_entity_id[task_id][nonhier_id] + + if real_parent_value != hier_value: + changes.append({ + "new_value": real_parent_value, + "attr_id": attr_id, + "entity_id": task_id, + "attr_key": attr_key + }) + + if parent_value != nonhier_value: + changes.append({ + "new_value": parent_value, + "attr_id": nonhier_id, + "entity_id": task_id, + "attr_key": attr_key + }) + + self._commit_changes(session, changes) + + def _commit_changes(self, session, changes): + uncommited_changes = False + for idx, item in enumerate(changes): + new_value = item["new_value"] + attr_id = item["attr_id"] + entity_id = item["entity_id"] + attr_key = item["attr_key"] + + entity_key = collections.OrderedDict() + entity_key["configuration_id"] = attr_id + entity_key["entity_id"] = entity_id + self._cached_changes.append({ + "attr_key": attr_key, + "entity_id": entity_id, + "value": new_value, + "time": datetime.datetime.now() + }) + if new_value is None: + op = ftrack_api.operation.DeleteEntityOperation( + "CustomAttributeValue", + entity_key + ) + else: + op = ftrack_api.operation.UpdateEntityOperation( + "ContextCustomAttributeValue", + entity_key, + "value", + ftrack_api.symbol.NOT_SET, + new_value + ) + + session.recorded_operations.push(op) + self.log.info(( + "Changing Custom Attribute \"{}\" to value" + " \"{}\" on entity: {}" + ).format(attr_key, new_value, entity_id)) + + if (idx + 1) % 20 == 0: + uncommited_changes = False + try: + session.commit() + except Exception: + session.rollback() + self.log.warning( + "Changing of values failed.", exc_info=True + ) + else: + uncommited_changes = True + if uncommited_changes: + try: + session.commit() + except Exception: + session.rollback() + self.log.warning("Changing of values failed.", exc_info=True) + + def process_attribute_changes( + self, session, object_types_by_name, + interesting_data, changed_keys_by_object_id, + interest_entity_types, interest_attributes + ): # Prepare task object id task_object_id = object_types_by_name["task"]["id"] @@ -216,13 +519,13 @@ class PushFrameValuesToTaskEvent(BaseEvent): task_entity_ids.add(task_id) parent_id_by_task_id[task_id] = task_entity["parent_id"] - self.finalize( + self.finalize_attribute_changes( session, interesting_data, changed_keys, attrs_by_obj_id, hier_attrs, task_entity_ids, parent_id_by_task_id ) - def finalize( + def finalize_attribute_changes( self, session, interesting_data, changed_keys, attrs_by_obj_id, hier_attrs, task_entity_ids, parent_id_by_task_id @@ -248,6 +551,7 @@ class PushFrameValuesToTaskEvent(BaseEvent): session, attr_ids, entity_ids, task_entity_ids, hier_attrs ) + changes = [] for entity_id, current_values in current_values_by_id.items(): parent_id = parent_id_by_task_id.get(entity_id) if not parent_id: @@ -272,39 +576,13 @@ class PushFrameValuesToTaskEvent(BaseEvent): if new_value == old_value: continue - entity_key = collections.OrderedDict() - entity_key["configuration_id"] = attr_id - entity_key["entity_id"] = entity_id - self._cached_changes.append({ - "attr_key": attr_key, + changes.append({ + "new_value": new_value, + "attr_id": attr_id, "entity_id": entity_id, - "value": new_value, - "time": datetime.datetime.now() + "attr_key": attr_key }) - if new_value is None: - op = ftrack_api.operation.DeleteEntityOperation( - "CustomAttributeValue", - entity_key - ) - else: - op = ftrack_api.operation.UpdateEntityOperation( - "ContextCustomAttributeValue", - entity_key, - "value", - ftrack_api.symbol.NOT_SET, - new_value - ) - - session.recorded_operations.push(op) - self.log.info(( - "Changing Custom Attribute \"{}\" to value" - " \"{}\" on entity: {}" - ).format(attr_key, new_value, entity_id)) - try: - session.commit() - except Exception: - session.rollback() - self.log.warning("Changing of values failed.", exc_info=True) + self._commit_changes(session, changes) def filter_changes( self, session, event, entities_info, interest_attributes diff --git a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py index d20e2ff5a8..f215bedcc2 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py +++ b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py @@ -66,15 +66,7 @@ class VersionToTaskStatus(BaseEvent): )) return - _status_mapping = event_settings["mapping"] - if not _status_mapping: - self.log.debug( - "Project \"{}\" does not have set mapping for {}".format( - project_name, self.__class__.__name__ - ) - ) - return - + _status_mapping = event_settings["mapping"] or {} status_mapping = { key.lower(): value for key, value in _status_mapping.items() diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py index ce6d5284b6..9dc2d67279 100644 --- a/openpype/modules/ftrack/lib/__init__.py +++ b/openpype/modules/ftrack/lib/__init__.py @@ -13,7 +13,8 @@ from .custom_attributes import ( default_custom_attributes_definition, app_definitions_from_app_manager, tool_definitions_from_app_manager, - get_openpype_attr + get_openpype_attr, + query_custom_attributes ) from . import avalon_sync @@ -37,6 +38,7 @@ __all__ = ( "app_definitions_from_app_manager", "tool_definitions_from_app_manager", "get_openpype_attr", + "query_custom_attributes", "avalon_sync", diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 5d1da005dc..2458308af5 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -402,16 +402,18 @@ class SyncEntitiesFactory: items = [] items.append({ "type": "label", - "value": "# Can't access Custom attribute <{}>".format( - CUST_ATTR_ID_KEY - ) + "value": ( + "# Can't access Custom attribute: \"{}\"" + ).format(CUST_ATTR_ID_KEY) }) items.append({ "type": "label", "value": ( - "

- Check if user \"{}\" has permissions" - " to access the Custom attribute

" - ).format(self._api_key) + "

- Check if your User and API key has permissions" + " to access the Custom attribute." + "
Username:\"{}\"" + "
API key:\"{}\"

" + ).format(self._api_user, self._api_key) }) items.append({ "type": "label", diff --git a/openpype/modules/ftrack/lib/custom_attributes.py b/openpype/modules/ftrack/lib/custom_attributes.py index f6b82c90b1..53facd4ab2 100644 --- a/openpype/modules/ftrack/lib/custom_attributes.py +++ b/openpype/modules/ftrack/lib/custom_attributes.py @@ -81,3 +81,60 @@ def get_openpype_attr(session, split_hierarchical=True, query_keys=None): return custom_attributes, hier_custom_attributes return custom_attributes + + +def join_query_keys(keys): + """Helper to join keys to query.""" + return ",".join(["\"{}\"".format(key) for key in keys]) + + +def query_custom_attributes(session, conf_ids, entity_ids, table_name=None): + """Query custom attribute values from ftrack database. + + Using ftrack call method result may differ based on used table name and + version of ftrack server. + + Args: + session(ftrack_api.Session): Connected ftrack session. + conf_id(list, set, tuple): Configuration(attribute) ids which are + queried. + entity_ids(list, set, tuple): Entity ids for which are values queried. + table_name(str): Table nam from which values are queried. Not + recommended to change until you know what it means. + """ + output = [] + # Just skip + if not conf_ids or not entity_ids: + return output + + if table_name is None: + table_name = "ContextCustomAttributeValue" + + # Prepare values to query + attributes_joined = join_query_keys(conf_ids) + attributes_len = len(conf_ids) + + # Query values in chunks + chunk_size = int(5000 / attributes_len) + # Make sure entity_ids is `list` for chunk selection + entity_ids = list(entity_ids) + for idx in range(0, len(entity_ids), chunk_size): + entity_ids_joined = join_query_keys( + entity_ids[idx:idx + chunk_size] + ) + + call_expr = [{ + "action": "query", + "expression": ( + "select value, entity_id from {}" + " where entity_id in ({}) and configuration_id in ({})" + ).format(table_name, entity_ids_joined, attributes_joined) + }] + if hasattr(session, "call"): + [result] = session.call(call_expr) + else: + [result] = session._call(call_expr) + + for item in result["data"]: + output.append(item) + return output diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py index e6daed9a33..b505a429b5 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py @@ -1,29 +1,107 @@ +""" +Requires: + none + +Provides: + instance -> families ([]) +""" import pyblish.api +import avalon.api + +from openpype.lib.plugin_tools import filter_profiles -class CollectFtrackFamilies(pyblish.api.InstancePlugin): - """Collect family for ftrack publishing - - Add ftrack family to those instance that should be published to ftrack - +class CollectFtrackFamily(pyblish.api.InstancePlugin): """ + Adds explicitly 'ftrack' to families to upload instance to FTrack. - order = pyblish.api.CollectorOrder + 0.3 - label = 'Add ftrack family' - families = ["model", - "setdress", - "model", - "animation", - "look", - "rig", - "camera" - ] - hosts = ["maya"] + Uses selection by combination of hosts/families/tasks names via + profiles resolution. + + Triggered everywhere, checks instance against configured. + + Checks advanced filtering which works on 'families' not on main + 'family', as some variants dynamically resolves addition of ftrack + based on 'families' (editorial drives it by presence of 'review') + """ + label = "Collect Ftrack Family" + order = pyblish.api.CollectorOrder + 0.4998 + + profiles = None def process(self, instance): + if not self.profiles: + self.log.warning("No profiles present for adding Ftrack family") + return - # make ftrack publishable - if instance.data.get('families'): - instance.data['families'].append('ftrack') + task_name = instance.data.get("task", + avalon.api.Session["AVALON_TASK"]) + host_name = avalon.api.Session["AVALON_APP"] + family = instance.data["family"] + + filtering_criteria = { + "hosts": host_name, + "families": family, + "tasks": task_name + } + profile = filter_profiles(self.profiles, filtering_criteria, + logger=self.log) + + if profile: + families = instance.data.get("families") + add_ftrack_family = profile["add_ftrack_family"] + + additional_filters = profile.get("additional_filters") + if additional_filters: + add_ftrack_family = self._get_add_ftrack_f_from_addit_filters( + additional_filters, + families, + add_ftrack_family + ) + + if add_ftrack_family: + self.log.debug("Adding ftrack family for '{}'". + format(instance.data.get("family"))) + + if families and "ftrack" not in families: + instance.data["families"].append("ftrack") + else: + instance.data["families"] = ["ftrack"] else: - instance.data['families'] = ['ftrack'] + self.log.debug("Instance '{}' doesn't match any profile".format( + instance.data.get("family"))) + + def _get_add_ftrack_f_from_addit_filters(self, + additional_filters, + families, + add_ftrack_family): + """ + Compares additional filters - working on instance's families. + + Triggered for more detailed filtering when main family matches, + but content of 'families' actually matter. + (For example 'review' in 'families' should result in adding to + Ftrack) + + Args: + additional_filters (dict) - from Setting + families (list) - subfamilies + add_ftrack_family (bool) - add ftrack to families if True + """ + override_filter = None + override_filter_value = -1 + for additional_filter in additional_filters: + filter_families = set(additional_filter["families"]) + valid = filter_families <= set(families) # issubset + if not valid: + continue + + value = len(filter_families) + if value > override_filter_value: + override_filter = additional_filter + override_filter_value = value + + if override_filter: + add_ftrack_family = override_filter["add_ftrack_family"] + + return add_ftrack_family diff --git a/openpype/modules/settings_action.py b/openpype/modules/settings_action.py index 1035dc0dcd..9db4a252bc 100644 --- a/openpype/modules/settings_action.py +++ b/openpype/modules/settings_action.py @@ -114,6 +114,7 @@ class LocalSettingsAction(PypeModule, ITrayAction): # Tray attributes self.settings_window = None + self._first_trigger = True def connect_with_modules(self, *_a, **_kw): return @@ -153,6 +154,9 @@ class LocalSettingsAction(PypeModule, ITrayAction): self.settings_window.raise_() self.settings_window.activateWindow() - # Reset content if was not visible - if not was_visible: + # Do not reset if it's first trigger of action + if self._first_trigger: + self._first_trigger = False + elif not was_visible: + # Reset content if was not visible self.settings_window.reset() diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index e1e24af3ea..de54b554e3 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -975,20 +975,52 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE Skipped using instance's resolution full_input_path_single_file = temp_data["full_input_path_single_file"] - input_data = ffprobe_streams( - full_input_path_single_file, self.log - )[0] - input_width = int(input_data["width"]) - input_height = int(input_data["height"]) + try: + streams = ffprobe_streams( + full_input_path_single_file, self.log + ) + except Exception: + raise AssertionError(( + "FFprobe couldn't read information about input file: \"{}\"" + ).format(full_input_path_single_file)) + + # Try to find first stream with defined 'width' and 'height' + # - this is to avoid order of streams where audio can be as first + # - there may be a better way (checking `codec_type`?) + input_width = None + input_height = None + for stream in streams: + if "width" in stream and "height" in stream: + input_width = int(stream["width"]) + input_height = int(stream["height"]) + break + + # Raise exception of any stream didn't define input resolution + if input_width is None: + raise AssertionError(( + "FFprobe couldn't read resolution from input file: \"{}\"" + ).format(full_input_path_single_file)) # NOTE Setting only one of `width` or `heigth` is not allowed # - settings value can't have None but has value of 0 output_width = output_def.get("width") or None output_height = output_def.get("height") or None + # Overscal color + overscan_color_value = "black" + overscan_color = output_def.get("overscan_color") + if overscan_color: + bg_red, bg_green, bg_blue, _ = overscan_color + overscan_color_value = "#{0:0>2X}{1:0>2X}{2:0>2X}".format( + bg_red, bg_green, bg_blue + ) + self.log.debug("Overscan color: `{}`".format(overscan_color_value)) + # Convert overscan value video filters overscan_crop = output_def.get("overscan_crop") - overscan = OverscanCrop(input_width, input_height, overscan_crop) + overscan = OverscanCrop( + input_width, input_height, overscan_crop, overscan_color_value + ) overscan_crop_filters = overscan.video_filters() # Add overscan filters to filters if are any and modify input # resolution by it's values @@ -1158,9 +1190,10 @@ class ExtractReview(pyblish.api.InstancePlugin): "scale={}x{}:flags=lanczos".format( width_scale, height_scale ), - "pad={}:{}:{}:{}:black".format( + "pad={}:{}:{}:{}:{}".format( output_width, output_height, - width_half_pad, height_half_pad + width_half_pad, height_half_pad, + overscan_color_value ), "setsar=1" ]) @@ -1707,12 +1740,15 @@ class OverscanCrop: item_regex = re.compile(r"([\+\-])?([0-9]+)(.+)?") relative_source_regex = re.compile(r"%([\+\-])") - def __init__(self, input_width, input_height, string_value): + def __init__( + self, input_width, input_height, string_value, overscal_color=None + ): # Make sure that is not None string_value = string_value or "" self.input_width = input_width self.input_height = input_height + self.overscal_color = overscal_color width, height = self._convert_string_to_values(string_value) self._width_value = width @@ -1767,16 +1803,22 @@ class OverscanCrop: elif width >= self.input_width and height >= self.input_height: output.append( - "pad={}:{}:(iw-ow)/2:(ih-oh)/2".format(width, height) + "pad={}:{}:(iw-ow)/2:(ih-oh)/2:{}".format( + width, height, self.overscal_color + ) ) elif width > self.input_width and height < self.input_height: output.append("crop=iw:{}".format(height)) - output.append("pad={}:ih:(iw-ow)/2:(ih-oh)/2".format(width)) + output.append("pad={}:ih:(iw-ow)/2:(ih-oh)/2:{}".format( + width, self.overscal_color + )) elif width < self.input_width and height > self.input_height: output.append("crop={}:ih".format(width)) - output.append("pad=iw:{}:(iw-ow)/2:(ih-oh)/2".format(height)) + output.append("pad=iw:{}:(iw-ow)/2:(ih-oh)/2:{}".format( + height, self.overscal_color + )) return output diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index fb36a930fb..2b07d7db74 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -26,9 +26,23 @@ class ExtractReviewSlate(openpype.api.Extractor): slate_path = inst_data.get("slateFrame") ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") - slate_stream = openpype.lib.ffprobe_streams(slate_path, self.log)[0] - slate_width = slate_stream["width"] - slate_height = slate_stream["height"] + slate_streams = openpype.lib.ffprobe_streams(slate_path, self.log) + # Try to find first stream with defined 'width' and 'height' + # - this is to avoid order of streams where audio can be as first + # - there may be a better way (checking `codec_type`?)+ + slate_width = None + slate_height = None + for slate_stream in slate_streams: + if "width" in slate_stream and "height" in slate_stream: + slate_width = int(slate_stream["width"]) + slate_height = int(slate_stream["height"]) + break + + # Raise exception of any stream didn't define input resolution + if slate_width is None: + raise AssertionError(( + "FFprobe couldn't read resolution from input file: \"{}\"" + ).format(slate_path)) if "reviewToWidth" in inst_data: use_legacy_code = True @@ -309,16 +323,29 @@ class ExtractReviewSlate(openpype.api.Extractor): ) return codec_args - codec_name = streams[0].get("codec_name") + # Try to find first stream that is not an audio + no_audio_stream = None + for stream in streams: + if stream.get("codec_type") != "audio": + no_audio_stream = stream + break + + if no_audio_stream is None: + self.log.warning(( + "Couldn't find stream that is not an audio from file \"{}\"" + ).format(full_input_path)) + return codec_args + + codec_name = no_audio_stream.get("codec_name") if codec_name: codec_args.append("-codec:v {}".format(codec_name)) - profile_name = streams[0].get("profile") + profile_name = no_audio_stream.get("profile") if profile_name: profile_name = profile_name.replace(" ", "_").lower() codec_args.append("-profile:v {}".format(profile_name)) - pix_fmt = streams[0].get("pix_fmt") + pix_fmt = no_audio_stream.get("pix_fmt") if pix_fmt: codec_args.append("-pix_fmt {}".format(pix_fmt)) return codec_args diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index ff16c22663..fcebc876f5 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -78,6 +78,10 @@ { "name": "colorspace", "value": "linear" + }, + { + "name": "create_directories", + "value": "True" } ] }, @@ -114,6 +118,10 @@ { "name": "colorspace", "value": "linear" + }, + { + "name": "create_directories", + "value": "True" } ] } diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index b964ce07c3..88f4e1e2e7 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -200,6 +200,88 @@ } }, "publish": { + "CollectFtrackFamily": { + "enabled": true, + "profiles": [ + { + "hosts": [ + "standalonepublisher" + ], + "families": [], + "tasks": [], + "add_ftrack_family": true, + "advanced_filtering": [] + }, + { + "hosts": [ + "standalonepublisher" + ], + "families": [ + "matchmove", + "shot" + ], + "tasks": [], + "add_ftrack_family": false, + "advanced_filtering": [] + }, + { + "hosts": [ + "standalonepublisher" + ], + "families": [ + "review", + "plate" + ], + "tasks": [], + "add_ftrack_family": false, + "advanced_filtering": [ + { + "families": [ + "clip", + "review" + ], + "add_ftrack_family": true + } + ] + }, + { + "hosts": [ + "maya" + ], + "families": [ + "model", + "setdress", + "animation", + "look", + "rig", + "camera" + ], + "tasks": [], + "add_ftrack_family": true, + "advanced_filtering": [] + }, + { + "hosts": [ + "tvpaint" + ], + "families": [ + "renderPass" + ], + "tasks": [], + "add_ftrack_family": false, + "advanced_filtering": [] + }, + { + "hosts": [ + "tvpaint" + ], + "families": [], + "tasks": [], + "add_ftrack_family": true, + "advanced_filtering": [] + } + ] + }, "IntegrateFtrackNote": { "enabled": true, "note_with_intent_template": "{intent}: {comment}", diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 4351f18a60..037fa63a29 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -56,6 +56,12 @@ ] }, "overscan_crop": "", + "overscan_color": [ + 0, + 0, + 0, + 255 + ], "width": 0, "height": 0, "bg_color": [ @@ -226,6 +232,17 @@ ], "tasks": [], "template": "{family}{Task}_{Render_layer}_{Render_pass}" + }, + { + "families": [ + "review", + "workfile" + ], + "hosts": [ + "tvpaint" + ], + "tasks": [], + "template": "{family}{Task}" } ] }, diff --git a/openpype/settings/defaults/project_settings/harmony.json b/openpype/settings/defaults/project_settings/harmony.json index 0c7a35c058..3cc175ae72 100644 --- a/openpype/settings/defaults/project_settings/harmony.json +++ b/openpype/settings/defaults/project_settings/harmony.json @@ -5,6 +5,11 @@ ".*" ] }, + "ValidateContainers": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateSceneSettings": { "enabled": true, "optional": true, diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json new file mode 100644 index 0000000000..811a446e59 --- /dev/null +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -0,0 +1,9 @@ +{ + "publish": { + "ValidateContainers": { + "enabled": true, + "optional": true, + "active": true + } + } +} \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index ba685ae502..284a1a0040 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -127,6 +127,11 @@ "CollectMayaRender": { "sync_workfile_version": false }, + "ValidateContainers": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateShaderName": { "enabled": false, "regex": "(?P.*)_(.*)_SHD" diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 3736f67268..71bf46d5b3 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -13,13 +13,19 @@ "fpath_template": "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}" }, "CreateWritePrerender": { - "fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}" + "fpath_template": "{work}/prerenders/nuke/{subset}/{subset}.{frame}.{ext}", + "use_range_limit": true } }, "publish": { "PreCollectNukeInstances": { "sync_workfile_version": true }, + "ValidateContainers": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateKnobs": { "enabled": false, "knobs": { diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index b306a757a6..4c36e4bd49 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -7,6 +7,11 @@ } }, "publish": { + "ValidateContainers": { + "enabled": true, + "optional": true, + "active": true + }, "ExtractImage": { "formats": [ "png", diff --git a/openpype/settings/defaults/project_settings/standalonepublisher.json b/openpype/settings/defaults/project_settings/standalonepublisher.json index 7172612a74..443203951d 100644 --- a/openpype/settings/defaults/project_settings/standalonepublisher.json +++ b/openpype/settings/defaults/project_settings/standalonepublisher.json @@ -105,16 +105,23 @@ "label": "Render", "family": "render", "icon": "image", - "defaults": ["Animation", "Lighting", "Lookdev", "Compositing"], + "defaults": [ + "Animation", + "Lighting", + "Lookdev", + "Compositing" + ], "help": "Rendered images or video files" }, "create_mov_batch": { - "name": "mov_batch", - "label": "Batch Mov", - "family": "render_mov_batch", - "icon": "image", - "defaults": ["Main"], - "help": "Process multiple Mov files and publish them for layout and comp." + "name": "mov_batch", + "label": "Batch Mov", + "family": "render_mov_batch", + "icon": "image", + "defaults": [ + "Main" + ], + "help": "Process multiple Mov files and publish them for layout and comp." }, "__dynamic_keys_labels__": { "create_workfile": "Workfile", diff --git a/openpype/settings/defaults/project_settings/tvpaint.json b/openpype/settings/defaults/project_settings/tvpaint.json index b4f3b315ec..763802a73f 100644 --- a/openpype/settings/defaults/project_settings/tvpaint.json +++ b/openpype/settings/defaults/project_settings/tvpaint.json @@ -1,5 +1,13 @@ { "publish": { + "ExtractSequence": { + "review_bg": [ + 255, + 255, + 255, + 255 + ] + }, "ValidateProjectSettings": { "enabled": true, "optional": true, diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 020924db67..224f9dc318 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -807,7 +807,6 @@ "environment": {}, "variants": { "2-83": { - "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Blender Foundation\\Blender 2.83\\blender.exe" @@ -829,7 +828,6 @@ "environment": {} }, "2-90": { - "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Blender Foundation\\Blender 2.90\\blender.exe" @@ -851,7 +849,6 @@ "environment": {} }, "2-91": { - "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Blender Foundation\\Blender 2.91\\blender.exe" @@ -891,7 +888,6 @@ "20": { "enabled": true, "variant_label": "20", - "use_python_2": false, "executables": { "windows": [], "darwin": [], @@ -907,7 +903,6 @@ "17": { "enabled": true, "variant_label": "17", - "use_python_2": false, "executables": { "windows": [], "darwin": [ @@ -932,7 +927,6 @@ "environment": {}, "variants": { "animation_11-64bits": { - "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\TVPaint Developpement\\TVPaint Animation 11 (64bits)\\TVPaint Animation 11 (64bits).exe" @@ -948,7 +942,6 @@ "environment": {} }, "animation_11-32bits": { - "use_python_2": false, "executables": { "windows": [ "C:\\Program Files (x86)\\TVPaint Developpement\\TVPaint Animation 11 (32bits)\\TVPaint Animation 11 (32bits).exe" @@ -982,7 +975,6 @@ "2020": { "enabled": true, "variant_label": "2020", - "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe Photoshop 2020\\Photoshop.exe" @@ -1000,7 +992,6 @@ "2021": { "enabled": true, "variant_label": "2021", - "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe Photoshop 2021\\Photoshop.exe" @@ -1030,7 +1021,6 @@ "2020": { "enabled": true, "variant_label": "2020", - "use_python_2": false, "executables": { "windows": [ "" @@ -1048,7 +1038,6 @@ "2021": { "enabled": true, "variant_label": "2021", - "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe After Effects 2021\\Support Files\\AfterFX.exe" @@ -1101,16 +1090,6 @@ "variants": { "4-26": { "use_python_2": false, - "executables": { - "windows": [], - "darwin": [], - "linux": [] - }, - "arguments": { - "windows": [], - "darwin": [], - "linux": [] - }, "environment": {} } } diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index f64ca1e98d..94eb819f2b 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -101,6 +101,7 @@ from .color_entity import ColorEntity from .enum_entity import ( BaseEnumEntity, EnumEntity, + HostsEnumEntity, AppsEnumEntity, ToolsEnumEntity, TaskTypeEnumEntity, @@ -153,6 +154,7 @@ __all__ = ( "BaseEnumEntity", "EnumEntity", + "HostsEnumEntity", "AppsEnumEntity", "ToolsEnumEntity", "TaskTypeEnumEntity", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index c6bff1ff47..82705d1406 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -279,6 +279,11 @@ class BaseItemEntity(BaseEntity): self, "Dynamic entity can't require restart." ) + @abstractproperty + def root_key(self): + """Root is represented as this dictionary key.""" + pass + @abstractmethod def set_override_state(self, state): """Set override state and trigger it on children. @@ -866,6 +871,10 @@ class ItemEntity(BaseItemEntity): """Call save on root item.""" self.root_item.save() + @property + def root_key(self): + return self.root_item.root_key + def schema_validations(self): if not self.label and self.use_label_wrap: reason = ( @@ -885,7 +894,11 @@ class ItemEntity(BaseItemEntity): def create_schema_object(self, *args, **kwargs): """Reference method for creation of entities defined in RootEntity.""" - return self.root_item.create_schema_object(*args, **kwargs) + return self.schema_hub.create_schema_object(*args, **kwargs) + + @property + def schema_hub(self): + return self.root_item.schema_hub def get_entity_from_path(self, path): return self.root_item.get_entity_from_path(path) diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py index 7a1b1d9848..dfaa75e761 100644 --- a/openpype/settings/entities/color_entity.py +++ b/openpype/settings/entities/color_entity.py @@ -12,6 +12,17 @@ class ColorEntity(InputEntity): def _item_initalization(self): self.valid_value_types = (list, ) self.value_on_not_set = [0, 0, 0, 255] + self.use_alpha = self.schema_data.get("use_alpha", True) + + def set_override_state(self, *args, **kwargs): + super(ColorEntity, self).set_override_state(*args, **kwargs) + value = self._current_value + if ( + not self.use_alpha + and isinstance(value, list) + and len(value) == 4 + ): + value[3] = 255 def convert_to_valid_type(self, value): """Conversion to valid type. @@ -51,4 +62,8 @@ class ColorEntity(InputEntity): ).format(value) raise BaseInvalidValueType(reason, self.path) new_value.append(item) + + # Make sure + if not self.use_alpha: + new_value[3] = 255 return new_value diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index 052bbda4d0..c965dc3b5a 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -1,4 +1,5 @@ import copy +import collections from .lib import ( WRAPPER_TYPES, @@ -138,7 +139,16 @@ class DictImmutableKeysEntity(ItemEntity): method when handling gui wrappers. """ added_children = [] - for children_schema in schema_data["children"]: + children_deque = collections.deque() + for _children_schema in schema_data["children"]: + children_schemas = self.schema_hub.resolve_schema_data( + _children_schema + ) + for children_schema in children_schemas: + children_deque.append(children_schema) + + while children_deque: + children_schema = children_deque.popleft() if children_schema["type"] in WRAPPER_TYPES: _children_schema = copy.deepcopy(children_schema) wrapper_children = self._add_children( diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 0b0575a255..63e0afeb47 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -101,6 +101,78 @@ class EnumEntity(BaseEnumEntity): super(EnumEntity, self).schema_validations() +class HostsEnumEntity(BaseEnumEntity): + """Enumeration of host names. + + Enum items are hardcoded in definition of the entity. + + Hosts enum can have defined empty value as valid option which is + represented by empty string. Schema key to set this option is + `use_empty_value` (true/false). And to set label of empty value set + `empty_label` (string). + + Enum can have single and multiselection. + + NOTE: + Host name is not the same as application name. Host name defines + implementation instead of application name. + """ + schema_types = ["hosts-enum"] + + def _item_initalization(self): + self.multiselection = self.schema_data.get("multiselection", True) + self.use_empty_value = self.schema_data.get( + "use_empty_value", not self.multiselection + ) + custom_labels = self.schema_data.get("custom_labels") or {} + + host_names = [ + "aftereffects", + "blender", + "celaction", + "fusion", + "harmony", + "hiero", + "houdini", + "maya", + "nuke", + "photoshop", + "resolve", + "tvpaint", + "unreal" + ] + if self.use_empty_value: + host_names.insert(0, "") + # Add default label for empty value if not available + if "" not in custom_labels: + custom_labels[""] = "< without host >" + + # These are hardcoded there is not list of available host in OpenPype + enum_items = [] + valid_keys = set() + for key in host_names: + label = custom_labels.get(key, key) + valid_keys.add(key) + enum_items.append({key: label}) + + self.enum_items = enum_items + self.valid_keys = valid_keys + + if self.multiselection: + self.valid_value_types = (list, ) + self.value_on_not_set = [] + else: + for key in valid_keys: + if self.value_on_not_set is NOT_SET: + self.value_on_not_set = key + break + + self.valid_value_types = (STRING_TYPE, ) + + # GUI attribute + self.placeholder = self.schema_data.get("placeholder") + + class AppsEnumEntity(BaseEnumEntity): schema_types = ["apps-enum"] diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index 05f4ea64f8..42a08232b9 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -2,6 +2,7 @@ import os import re import json import copy +import inspect from .exceptions import ( SchemaTemplateMissingKeys, @@ -25,335 +26,6 @@ TEMPLATE_METADATA_KEYS = ( template_key_pattern = re.compile(r"(\{.*?[^{0]*\})") -def _pop_metadata_item(template): - found_idx = None - for idx, item in enumerate(template): - if not isinstance(item, dict): - continue - - for key in TEMPLATE_METADATA_KEYS: - if key in item: - found_idx = idx - break - - if found_idx is not None: - break - - metadata_item = {} - if found_idx is not None: - metadata_item = template.pop(found_idx) - return metadata_item - - -def _fill_schema_template_data( - template, template_data, skip_paths, required_keys=None, missing_keys=None -): - first = False - if required_keys is None: - first = True - - if "skip_paths" in template_data: - skip_paths = template_data["skip_paths"] - if not isinstance(skip_paths, list): - skip_paths = [skip_paths] - - # Cleanup skip paths (skip empty values) - skip_paths = [path for path in skip_paths if path] - - required_keys = set() - missing_keys = set() - - # Copy template data as content may change - template = copy.deepcopy(template) - - # Get metadata item from template - metadata_item = _pop_metadata_item(template) - - # Check for default values for template data - default_values = metadata_item.get(DEFAULT_VALUES_KEY) or {} - - for key, value in default_values.items(): - if key not in template_data: - template_data[key] = value - - if not template: - output = template - - elif isinstance(template, list): - # Store paths by first part if path - # - None value says that whole key should be skipped - skip_paths_by_first_key = {} - for path in skip_paths: - parts = path.split("/") - key = parts.pop(0) - if key not in skip_paths_by_first_key: - skip_paths_by_first_key[key] = [] - - value = "/".join(parts) - skip_paths_by_first_key[key].append(value or None) - - output = [] - for item in template: - # Get skip paths for children item - _skip_paths = [] - if not isinstance(item, dict): - pass - - elif item.get("type") in WRAPPER_TYPES: - _skip_paths = copy.deepcopy(skip_paths) - - elif skip_paths_by_first_key: - # Check if this item should be skipped - key = item.get("key") - if key and key in skip_paths_by_first_key: - _skip_paths = skip_paths_by_first_key[key] - # Skip whole item if None is in skip paths value - if None in _skip_paths: - continue - - output_item = _fill_schema_template_data( - item, template_data, _skip_paths, required_keys, missing_keys - ) - if output_item: - output.append(output_item) - - elif isinstance(template, dict): - output = {} - for key, value in template.items(): - output[key] = _fill_schema_template_data( - value, template_data, skip_paths, required_keys, missing_keys - ) - if output.get("type") in WRAPPER_TYPES and not output.get("children"): - return {} - - elif isinstance(template, STRING_TYPE): - # TODO find much better way how to handle filling template data - template = template.replace("{{", "__dbcb__").replace("}}", "__decb__") - for replacement_string in template_key_pattern.findall(template): - key = str(replacement_string[1:-1]) - required_keys.add(key) - if key not in template_data: - missing_keys.add(key) - continue - - value = template_data[key] - if replacement_string == template: - # Replace the value with value from templates data - # - with this is possible to set value with different type - template = value - else: - # Only replace the key in string - template = template.replace(replacement_string, value) - - output = template.replace("__dbcb__", "{").replace("__decb__", "}") - - else: - output = template - - if first and missing_keys: - raise SchemaTemplateMissingKeys(missing_keys, required_keys) - - return output - - -def _fill_schema_template(child_data, schema_collection, schema_templates): - template_name = child_data["name"] - template = schema_templates.get(template_name) - if template is None: - if template_name in schema_collection: - raise KeyError(( - "Schema \"{}\" is used as `schema_template`" - ).format(template_name)) - raise KeyError("Schema template \"{}\" was not found".format( - template_name - )) - - # Default value must be dictionary (NOT list) - # - empty list would not add any item if `template_data` are not filled - template_data = child_data.get("template_data") or {} - if isinstance(template_data, dict): - template_data = [template_data] - - skip_paths = child_data.get("skip_paths") or [] - if isinstance(skip_paths, STRING_TYPE): - skip_paths = [skip_paths] - - output = [] - for single_template_data in template_data: - try: - filled_child = _fill_schema_template_data( - template, single_template_data, skip_paths - ) - - except SchemaTemplateMissingKeys as exc: - raise SchemaTemplateMissingKeys( - exc.missing_keys, exc.required_keys, template_name - ) - - for item in filled_child: - filled_item = _fill_inner_schemas( - item, schema_collection, schema_templates - ) - if filled_item["type"] == "schema_template": - output.extend(_fill_schema_template( - filled_item, schema_collection, schema_templates - )) - else: - output.append(filled_item) - return output - - -def _fill_inner_schemas(schema_data, schema_collection, schema_templates): - if schema_data["type"] == "schema": - raise ValueError("First item in schema data can't be schema.") - - children_key = "children" - object_type_key = "object_type" - for item_key in (children_key, object_type_key): - children = schema_data.get(item_key) - if not children: - continue - - if object_type_key == item_key: - if not isinstance(children, dict): - continue - children = [children] - - new_children = [] - for child in children: - child_type = child["type"] - if child_type == "schema": - schema_name = child["name"] - if schema_name not in schema_collection: - if schema_name in schema_templates: - raise KeyError(( - "Schema template \"{}\" is used as `schema`" - ).format(schema_name)) - raise KeyError( - "Schema \"{}\" was not found".format(schema_name) - ) - - filled_child = _fill_inner_schemas( - schema_collection[schema_name], - schema_collection, - schema_templates - ) - - elif child_type in ("template", "schema_template"): - for filled_child in _fill_schema_template( - child, schema_collection, schema_templates - ): - new_children.append(filled_child) - continue - - else: - filled_child = _fill_inner_schemas( - child, schema_collection, schema_templates - ) - - new_children.append(filled_child) - - if item_key == object_type_key: - if len(new_children) != 1: - raise KeyError(( - "Failed to fill object type with type: {} | name {}" - ).format( - child_type, str(child.get("name")) - )) - new_children = new_children[0] - - schema_data[item_key] = new_children - return schema_data - - -# TODO reimplement logic inside entities -def validate_environment_groups_uniquenes( - schema_data, env_groups=None, keys=None -): - is_first = False - if env_groups is None: - is_first = True - env_groups = {} - keys = [] - - my_keys = copy.deepcopy(keys) - key = schema_data.get("key") - if key: - my_keys.append(key) - - env_group_key = schema_data.get("env_group_key") - if env_group_key: - if env_group_key not in env_groups: - env_groups[env_group_key] = [] - env_groups[env_group_key].append("/".join(my_keys)) - - children = schema_data.get("children") - if not children: - return - - for child in children: - validate_environment_groups_uniquenes( - child, env_groups, copy.deepcopy(my_keys) - ) - - if is_first: - invalid = {} - for env_group_key, key_paths in env_groups.items(): - if len(key_paths) > 1: - invalid[env_group_key] = key_paths - - if invalid: - raise SchemaDuplicatedEnvGroupKeys(invalid) - - -def validate_schema(schema_data): - validate_environment_groups_uniquenes(schema_data) - - -def get_gui_schema(subfolder, main_schema_name): - dirpath = os.path.join( - os.path.dirname(__file__), - "schemas", - subfolder - ) - loaded_schemas = {} - loaded_schema_templates = {} - for root, _, filenames in os.walk(dirpath): - for filename in filenames: - basename, ext = os.path.splitext(filename) - if ext != ".json": - continue - - filepath = os.path.join(root, filename) - with open(filepath, "r") as json_stream: - try: - schema_data = json.load(json_stream) - except Exception as exc: - raise ValueError(( - "Unable to parse JSON file {}\n{}" - ).format(filepath, str(exc))) - if isinstance(schema_data, list): - loaded_schema_templates[basename] = schema_data - else: - loaded_schemas[basename] = schema_data - - main_schema = _fill_inner_schemas( - loaded_schemas[main_schema_name], - loaded_schemas, - loaded_schema_templates - ) - validate_schema(main_schema) - return main_schema - - -def get_studio_settings_schema(): - return get_gui_schema("system_schema", "schema_main") - - -def get_project_settings_schema(): - return get_gui_schema("projects_schema", "schema_main") - - class OverrideStateItem: """Object used as item for `OverrideState` enum. @@ -426,3 +98,506 @@ class OverrideState: DEFAULTS = OverrideStateItem(0, "Defaults") STUDIO = OverrideStateItem(1, "Studio overrides") PROJECT = OverrideStateItem(2, "Project Overrides") + + +class SchemasHub: + def __init__(self, schema_subfolder, reset=True): + self._schema_subfolder = schema_subfolder + + self._loaded_types = {} + self._gui_types = tuple() + + self._crashed_on_load = {} + self._loaded_templates = {} + self._loaded_schemas = {} + + # It doesn't make sence to reload types on each reset as they can't be + # changed + self._load_types() + + # Trigger reset + if reset: + self.reset() + + def reset(self): + self._load_schemas() + + @property + def gui_types(self): + return self._gui_types + + def get_schema(self, schema_name): + """Get schema definition data by it's name. + + Returns: + dict: Copy of schema loaded from json files. + + Raises: + KeyError: When schema name is stored in loaded templates or json + file was not possible to parse or when schema name was not + found. + """ + if schema_name not in self._loaded_schemas: + if schema_name in self._loaded_templates: + raise KeyError(( + "Template \"{}\" is used as `schema`" + ).format(schema_name)) + + elif schema_name in self._crashed_on_load: + crashed_item = self._crashed_on_load[schema_name] + raise KeyError( + "Unable to parse schema file \"{}\". {}".format( + crashed_item["filpath"], crashed_item["message"] + ) + ) + + raise KeyError( + "Schema \"{}\" was not found".format(schema_name) + ) + return copy.deepcopy(self._loaded_schemas[schema_name]) + + def get_template(self, template_name): + """Get template definition data by it's name. + + Returns: + list: Copy of template items loaded from json files. + + Raises: + KeyError: When template name is stored in loaded schemas or json + file was not possible to parse or when template name was not + found. + """ + if template_name not in self._loaded_templates: + if template_name in self._loaded_schemas: + raise KeyError(( + "Schema \"{}\" is used as `template`" + ).format(template_name)) + + elif template_name in self._crashed_on_load: + crashed_item = self._crashed_on_load[template_name] + raise KeyError( + "Unable to parse templace file \"{}\". {}".format( + crashed_item["filpath"], crashed_item["message"] + ) + ) + + raise KeyError( + "Template \"{}\" was not found".format(template_name) + ) + return copy.deepcopy(self._loaded_templates[template_name]) + + def resolve_schema_data(self, schema_data): + """Resolve single item schema data as few types can be expanded. + + This is mainly for 'schema' and 'template' types. Type 'schema' does + not have entity representation and 'template' may contain more than one + output schemas. + + In other cases is retuned passed schema item in list. + + Goal is to have schema and template resolving at one place. + + Returns: + list: Resolved schema data. + """ + schema_type = schema_data["type"] + if schema_type not in ("schema", "template", "schema_template"): + return [schema_data] + + if schema_type == "schema": + return self.resolve_schema_data( + self.get_schema(schema_data["name"]) + ) + + template_name = schema_data["name"] + template_def = self.get_template(template_name) + + filled_template = self._fill_template( + schema_data, template_def + ) + return filled_template + + def create_schema_object(self, schema_data, *args, **kwargs): + """Create entity for passed schema data. + + Args: + schema_data(dict): Schema definition of settings entity. + + Returns: + ItemEntity: Created entity for passed schema data item. + + Raises: + ValueError: When 'schema', 'template' or any of wrapper types are + passed. + KeyError: When type of passed schema is not known. + """ + schema_type = schema_data["type"] + if schema_type in ("schema", "template", "schema_template"): + raise ValueError( + "Got unresolved schema data of type \"{}\"".format(schema_type) + ) + + if schema_type in WRAPPER_TYPES: + raise ValueError(( + "Function `create_schema_object` can't create entities" + " of any wrapper type. Got type: \"{}\"" + ).format(schema_type)) + + klass = self._loaded_types.get(schema_type) + if not klass: + raise KeyError("Unknown type \"{}\"".format(schema_type)) + + return klass(schema_data, *args, **kwargs) + + def _load_types(self): + """Prepare entity types for cretion of their objects. + + Currently all classes in `openpype.settings.entities` that inherited + from `BaseEntity` are stored as loaded types. GUI types are stored to + separated attribute to not mess up api access of entities. + + TODOs: + Add more dynamic way how to add custom types from anywhere and + better handling of abstract classes. Skipping them is dangerous. + """ + + from openpype.settings import entities + + # Define known abstract classes + known_abstract_classes = ( + entities.BaseEntity, + entities.BaseItemEntity, + entities.ItemEntity, + entities.EndpointEntity, + entities.InputEntity, + entities.BaseEnumEntity + ) + + self._loaded_types = {} + _gui_types = [] + for attr in dir(entities): + item = getattr(entities, attr) + # Filter classes + if not inspect.isclass(item): + continue + + # Skip classes that do not inherit from BaseEntity + if not issubclass(item, entities.BaseEntity): + continue + + # Skip class that is abstract by design + if item in known_abstract_classes: + continue + + if inspect.isabstract(item): + # Create an object to get crash and get traceback + item() + + # Backwards compatibility + # Single entity may have multiple schema types + for schema_type in item.schema_types: + self._loaded_types[schema_type] = item + + if item.gui_type: + _gui_types.append(item) + self._gui_types = tuple(_gui_types) + + def _load_schemas(self): + """Load schema definitions from json files.""" + + # Refresh all affecting variables + self._crashed_on_load = {} + self._loaded_templates = {} + self._loaded_schemas = {} + + dirpath = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "schemas", + self._schema_subfolder + ) + loaded_schemas = {} + loaded_templates = {} + for root, _, filenames in os.walk(dirpath): + for filename in filenames: + basename, ext = os.path.splitext(filename) + if ext != ".json": + continue + + filepath = os.path.join(root, filename) + with open(filepath, "r") as json_stream: + try: + schema_data = json.load(json_stream) + except Exception as exc: + msg = str(exc) + print("Unable to parse JSON file {}\n{}".format( + filepath, msg + )) + self._crashed_on_load[basename] = { + "filepath": filepath, + "message": msg + } + continue + + if basename in self._crashed_on_load: + crashed_item = self._crashed_on_load[basename] + raise KeyError(( + "Duplicated filename \"{}\"." + " One of them crashed on load \"{}\" {}" + ).format( + filename, + crashed_item["filpath"], + crashed_item["message"] + )) + + if isinstance(schema_data, list): + if basename in loaded_templates: + raise KeyError( + "Duplicated template filename \"{}\"".format( + filename + ) + ) + loaded_templates[basename] = schema_data + else: + if basename in loaded_schemas: + raise KeyError( + "Duplicated schema filename \"{}\"".format( + filename + ) + ) + loaded_schemas[basename] = schema_data + + self._loaded_templates = loaded_templates + self._loaded_schemas = loaded_schemas + + def _fill_template(self, child_data, template_def): + """Fill template based on schema definition and template definition. + + Based on `child_data` is `template_def` modified and result is + returned. + + Template definition may have defined data to fill which + should be filled with data from child data. + + Child data may contain more than one output definition of an template. + + Child data can define paths to skip. Path is full path of an item + which won't be returned. + + TODO: + Be able to handle wrapper items here. + + Args: + child_data(dict): Schema data of template item. + template_def(dict): Template definition that will be filled with + child_data. + + Returns: + list: Resolved template always returns list of schemas. + """ + template_name = child_data["name"] + + # Default value must be dictionary (NOT list) + # - empty list would not add any item if `template_data` are not filled + template_data = child_data.get("template_data") or {} + if isinstance(template_data, dict): + template_data = [template_data] + + skip_paths = child_data.get("skip_paths") or [] + if isinstance(skip_paths, STRING_TYPE): + skip_paths = [skip_paths] + + output = [] + for single_template_data in template_data: + try: + output.extend(self._fill_template_data( + template_def, single_template_data, skip_paths + )) + + except SchemaTemplateMissingKeys as exc: + raise SchemaTemplateMissingKeys( + exc.missing_keys, exc.required_keys, template_name + ) + return output + + def _fill_template_data( + self, + template, + template_data, + skip_paths, + required_keys=None, + missing_keys=None + ): + """Fill template values with data from schema data. + + Template has more abilities than schemas. It is expected that template + will be used at multiple places (but may not). Schema represents + exactly one entity and it's children but template may represent more + entities. + + Template can have "keys to fill" from their definition. Some key may be + required and some may be optional because template has their default + values defined. + + Template also have ability to "skip paths" which means to skip entities + from it's content. A template can be used across multiple places with + different requirements. + + Raises: + SchemaTemplateMissingKeys: When fill data do not contain all + required keys for template. + """ + first = False + if required_keys is None: + first = True + + if "skip_paths" in template_data: + skip_paths = template_data["skip_paths"] + if not isinstance(skip_paths, list): + skip_paths = [skip_paths] + + # Cleanup skip paths (skip empty values) + skip_paths = [path for path in skip_paths if path] + + required_keys = set() + missing_keys = set() + + # Copy template data as content may change + template = copy.deepcopy(template) + + # Get metadata item from template + metadata_item = self._pop_metadata_item(template) + + # Check for default values for template data + default_values = metadata_item.get(DEFAULT_VALUES_KEY) or {} + + for key, value in default_values.items(): + if key not in template_data: + template_data[key] = value + + if not template: + output = template + + elif isinstance(template, list): + # Store paths by first part if path + # - None value says that whole key should be skipped + skip_paths_by_first_key = {} + for path in skip_paths: + parts = path.split("/") + key = parts.pop(0) + if key not in skip_paths_by_first_key: + skip_paths_by_first_key[key] = [] + + value = "/".join(parts) + skip_paths_by_first_key[key].append(value or None) + + output = [] + for item in template: + # Get skip paths for children item + _skip_paths = [] + if not isinstance(item, dict): + pass + + elif item.get("type") in WRAPPER_TYPES: + _skip_paths = copy.deepcopy(skip_paths) + + elif skip_paths_by_first_key: + # Check if this item should be skipped + key = item.get("key") + if key and key in skip_paths_by_first_key: + _skip_paths = skip_paths_by_first_key[key] + # Skip whole item if None is in skip paths value + if None in _skip_paths: + continue + + output_item = self._fill_template_data( + item, + template_data, + _skip_paths, + required_keys, + missing_keys + ) + if output_item: + output.append(output_item) + + elif isinstance(template, dict): + output = {} + for key, value in template.items(): + output[key] = self._fill_template_data( + value, + template_data, + skip_paths, + required_keys, + missing_keys + ) + if ( + output.get("type") in WRAPPER_TYPES + and not output.get("children") + ): + return {} + + elif isinstance(template, STRING_TYPE): + # TODO find much better way how to handle filling template data + template = ( + template + .replace("{{", "__dbcb__") + .replace("}}", "__decb__") + ) + full_replacement = False + for replacement_string in template_key_pattern.findall(template): + key = str(replacement_string[1:-1]) + required_keys.add(key) + if key not in template_data: + missing_keys.add(key) + continue + + value = template_data[key] + if replacement_string == template: + # Replace the value with value from templates data + # - with this is possible to set value with different type + template = value + full_replacement = True + else: + # Only replace the key in string + template = template.replace(replacement_string, value) + + if not full_replacement: + output = ( + template + .replace("__dbcb__", "{") + .replace("__decb__", "}") + ) + else: + output = template + + else: + output = template + + if first and missing_keys: + raise SchemaTemplateMissingKeys(missing_keys, required_keys) + + return output + + def _pop_metadata_item(self, template_def): + """Pop template metadata from template definition. + + Template metadata may define default values if are not passed from + schema data. + """ + + found_idx = None + for idx, item in enumerate(template_def): + if not isinstance(item, dict): + continue + + for key in TEMPLATE_METADATA_KEYS: + if key in item: + found_idx = idx + break + + if found_idx is not None: + break + + metadata_item = {} + if found_idx is not None: + metadata_item = template_def.pop(found_idx) + return metadata_item diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 401d3980c9..5397bf21a1 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -1,7 +1,7 @@ import os import json import copy -import inspect +import collections from abc import abstractmethod @@ -10,8 +10,7 @@ from .lib import ( NOT_SET, WRAPPER_TYPES, OverrideState, - get_studio_settings_schema, - get_project_settings_schema + SchemasHub ) from .exceptions import ( SchemaError, @@ -53,7 +52,12 @@ class RootEntity(BaseItemEntity): """ schema_types = ["root"] - def __init__(self, schema_data, reset): + def __init__(self, schema_hub, reset, main_schema_name=None): + self.schema_hub = schema_hub + if not main_schema_name: + main_schema_name = "schema_main" + schema_data = schema_hub.get_schema(main_schema_name) + super(RootEntity, self).__init__(schema_data) self._require_restart_callbacks = [] self._item_ids_require_restart = set() @@ -130,7 +134,17 @@ class RootEntity(BaseItemEntity): def _add_children(self, schema_data, first=True): added_children = [] - for children_schema in schema_data["children"]: + children_deque = collections.deque() + for _children_schema in schema_data["children"]: + children_schemas = self.schema_hub.resolve_schema_data( + _children_schema + ) + for children_schema in children_schemas: + children_deque.append(children_schema) + + while children_deque: + children_schema = children_deque.popleft() + if children_schema["type"] in WRAPPER_TYPES: _children_schema = copy.deepcopy(children_schema) wrapper_children = self._add_children( @@ -143,11 +157,13 @@ class RootEntity(BaseItemEntity): child_obj = self.create_schema_object(children_schema, self) self.children.append(child_obj) added_children.append(child_obj) - if isinstance(child_obj, self._gui_types): + if isinstance(child_obj, self.schema_hub.gui_types): continue if child_obj.key in self.non_gui_children: - raise KeyError("Duplicated key \"{}\"".format(child_obj.key)) + raise KeyError( + "Duplicated key \"{}\"".format(child_obj.key) + ) self.non_gui_children[child_obj.key] = child_obj if not first: @@ -160,9 +176,6 @@ class RootEntity(BaseItemEntity): # Store `self` to `root_item` for children entities self.root_item = self - self._loaded_types = None - self._gui_types = None - # Children are stored by key as keys are immutable and are defined by # schema self.valid_value_types = (dict, ) @@ -189,11 +202,10 @@ class RootEntity(BaseItemEntity): if not KEY_REGEX.match(key): raise InvalidKeySymbols(self.path, key) + @abstractmethod def get_entity_from_path(self, path): - """Return system settings entity.""" - raise NotImplementedError(( - "Method `get_entity_from_path` not available for \"{}\"" - ).format(self.__class__.__name__)) + """Return entity matching passed path.""" + pass def create_schema_object(self, schema_data, *args, **kwargs): """Create entity by entered schema data. @@ -201,54 +213,9 @@ class RootEntity(BaseItemEntity): Available entities are loaded on first run. Children entities can call this method. """ - if self._loaded_types is None: - # Load available entities - from openpype.settings import entities - - # Define known abstract classes - known_abstract_classes = ( - entities.BaseEntity, - entities.BaseItemEntity, - entities.ItemEntity, - entities.EndpointEntity, - entities.InputEntity, - entities.BaseEnumEntity - ) - - self._loaded_types = {} - _gui_types = [] - for attr in dir(entities): - item = getattr(entities, attr) - # Filter classes - if not inspect.isclass(item): - continue - - # Skip classes that do not inherit from BaseEntity - if not issubclass(item, entities.BaseEntity): - continue - - # Skip class that is abstract by design - if item in known_abstract_classes: - continue - - if inspect.isabstract(item): - # Create an object to get crash and get traceback - item() - - # Backwards compatibility - # Single entity may have multiple schema types - for schema_type in item.schema_types: - self._loaded_types[schema_type] = item - - if item.gui_type: - _gui_types.append(item) - self._gui_types = tuple(_gui_types) - - klass = self._loaded_types.get(schema_data["type"]) - if not klass: - raise KeyError("Unknown type \"{}\"".format(schema_data["type"])) - - return klass(schema_data, *args, **kwargs) + return self.schema_hub.create_schema_object( + schema_data, *args, **kwargs + ) def set_override_state(self, state): """Set override state and trigger it on children. @@ -491,18 +458,32 @@ class SystemSettings(RootEntity): schema_data (dict): Pass schema data to entity. This is for development and debugging purposes. """ - def __init__( - self, set_studio_state=True, reset=True, schema_data=None - ): - if schema_data is None: - # Load system schemas - schema_data = get_studio_settings_schema() + root_key = SYSTEM_SETTINGS_KEY - super(SystemSettings, self).__init__(schema_data, reset) + def __init__( + self, set_studio_state=True, reset=True, schema_hub=None + ): + if schema_hub is None: + # Load system schemas + schema_hub = SchemasHub("system_schema") + + super(SystemSettings, self).__init__(schema_hub, reset) if set_studio_state: self.set_studio_state() + def get_entity_from_path(self, path): + """Return system settings entity.""" + path_parts = path.split("/") + first_part = path_parts[0] + output = self + if first_part == self.root_key: + path_parts.pop(0) + + for path_part in path_parts: + output = output[path_part] + return output + def _reset_values(self): default_value = get_default_settings()[SYSTEM_SETTINGS_KEY] for key, child_obj in self.non_gui_children.items(): @@ -600,22 +581,24 @@ class ProjectSettings(RootEntity): schema_data (dict): Pass schema data to entity. This is for development and debugging purposes. """ + root_key = PROJECT_SETTINGS_KEY + def __init__( self, project_name=None, change_state=True, reset=True, - schema_data=None + schema_hub=None ): self._project_name = project_name self._system_settings_entity = None - if schema_data is None: + if schema_hub is None: # Load system schemas - schema_data = get_project_settings_schema() + schema_hub = SchemasHub("projects_schema") - super(ProjectSettings, self).__init__(schema_data, reset) + super(ProjectSettings, self).__init__(schema_hub, reset) if change_state: if self.project_name is None: diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 6c31b61f59..bbd53fa46b 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -272,6 +272,25 @@ } ``` +### hosts-enum +- enumeration of available hosts +- multiselection can be allowed with setting key `"multiselection"` to `True` (Default: `False`) +- it is possible to add empty value (represented with empty string) with setting `"use_empty_value"` to `True` (Default: `False`) +- it is possible to set `"custom_labels"` for host names where key `""` is empty value (Default: `{}`) +``` +{ + "key": "host", + "label": "Host name", + "type": "hosts-enum", + "multiselection": false, + "use_empty_value": true, + "custom_labels": { + "": "N/A", + "nuke": "Nuke" + } +} +``` + ## Inputs for setting value using Pure inputs - these inputs also have required `"key"` - attribute `"label"` is required in few conditions diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index bee9712878..4a8a9d496e 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_hiero" }, + { + "type": "schema", + "name": "schema_project_houdini" + }, { "type": "schema", "name": "schema_project_blender" 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 aae2bb2539..a94ebc8888 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -604,6 +604,82 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "CollectFtrackFamily", + "label": "Collect Ftrack Family", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "collapsible": true, + "key": "profiles", + "label": "Profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "hosts", + "label": "Host names", + "type": "list", + "object_type": "text" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "tasks", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "add_ftrack_family", + "label": "Add Ftrack Family", + "type": "boolean" + }, + { + "type": "list", + "collapsible": true, + "key": "advanced_filtering", + "label": "Advanced adding if additional families present", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "families", + "label": "Additional Families", + "type": "list", + "object_type": "text" + }, + { + "key": "add_ftrack_family", + "label": "Add Ftrack Family", + "type": "boolean" + } + ] + } + } + ] + } + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json index 8b5d638cd8..ce6246a8de 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json @@ -25,6 +25,16 @@ } ] }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateContainers", + "label": "ValidateContainers" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json new file mode 100644 index 0000000000..c6de257a61 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json @@ -0,0 +1,27 @@ +{ + "type": "dict", + "collapsible": true, + "key": "houdini", + "label": "Houdini", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateContainers", + "label": "ValidateContainers" + } + ] + } + ] + } + ] +} 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 f709e84651..01a954f283 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -77,6 +77,11 @@ "type": "text", "key": "fpath_template", "label": "Path template" + }, + { + "type": "boolean", + "key": "use_range_limit", + "label": "Use Frame range limit by default" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json index 4eb6c26dbb..3b65f08ac4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -33,6 +33,16 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateContainers", + "label": "ValidateContainers" + } + ] + }, { "type": "dict", "collapsible": true, @@ -50,7 +60,7 @@ "object_type": "text" } ] - } + } ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json index 58708776ca..170de7c8a2 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -59,10 +59,10 @@ "object_type": "text" }, { + "type": "hosts-enum", "key": "hosts", "label": "Host names", - "type": "list", - "object_type": "text" + "multiselection": true }, { "type": "separator" 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 6f90bb4263..67aa4b0a06 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -11,6 +11,25 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "key": "ExtractSequence", + "label": "ExtractSequence", + "is_group": true, + "children": [ + { + "type": "label", + "label": "Review BG color is used for whole scene review and for thumbnails." + }, + { + "type": "color", + "key": "review_bg", + "label": "Review BG color", + "use_alpha": false + } + ] + }, { "type": "schema_template", "name": "template_publish_plugin", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 3c589f9492..2b2eab8868 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -3,7 +3,6 @@ "key": "imageio", "label": "Color Management and Output Formats", "is_file": true, - "is_group": true, "children": [ { "key": "hiero", @@ -15,6 +14,7 @@ "type": "dict", "label": "Workfile", "collapsible": false, + "is_group": true, "children": [ { "type": "form", @@ -89,6 +89,7 @@ "type": "dict", "label": "Colorspace on Inputs by regex detection", "collapsible": true, + "is_group": true, "children": [ { "type": "list", @@ -123,6 +124,7 @@ "type": "dict", "label": "Viewer", "collapsible": false, + "is_group": true, "children": [ { "type": "text", @@ -136,6 +138,7 @@ "type": "dict", "label": "Workfile", "collapsible": false, + "is_group": true, "children": [ { "type": "form", @@ -233,6 +236,7 @@ "type": "dict", "label": "Nodes", "collapsible": true, + "is_group": true, "children": [ { "key": "requiredNodes", @@ -335,6 +339,7 @@ "type": "dict", "label": "Colorspace on Inputs by regex detection", "collapsible": true, + "is_group": true, "children": [ { "type": "list", 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 0c89575d74..496635287f 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 @@ -90,10 +90,10 @@ "object_type": "text" }, { + "type": "hosts-enum", "key": "hosts", "label": "Hosts", - "type": "list", - "object_type": "text" + "multiselection": true }, { "type": "splitter" @@ -182,6 +182,16 @@ "key": "overscan_crop", "label": "Overscan crop" }, + { + "type": "label", + "label": "Overscan color is used when input aspect ratio is not same as output aspect ratio." + }, + { + "type": "color", + "label": "Overscan color", + "key": "overscan_color", + "use_alpha": false + }, { "type": "label", "label": "Width and Height must be both set to higher value than 0 else source resolution is used." @@ -348,10 +358,10 @@ "object_type": "text" }, { + "type": "hosts-enum", "key": "hosts", "label": "Hosts", - "type": "list", - "object_type": "text" + "multiselection": true }, { "type": "splitter" @@ -482,10 +492,10 @@ "object_type": "text" }, { + "type": "hosts-enum", "key": "hosts", "label": "Hosts", - "type": "list", - "object_type": "text" + "multiselection": true }, { "key": "tasks", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index 224389d42e..8c92a45a56 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -35,10 +35,10 @@ "object_type": "text" }, { + "type": "hosts-enum", "key": "hosts", "label": "Hosts", - "type": "list", - "object_type": "text" + "multiselection": true }, { "key": "tasks", @@ -75,10 +75,10 @@ "type": "dict", "children": [ { + "type": "hosts-enum", "key": "hosts", "label": "Hosts", - "type": "list", - "object_type": "text" + "multiselection": true }, { "key": "tasks", 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 0abcdd2965..5ca7059ee5 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 @@ -28,7 +28,16 @@ "type": "label", "label": "Validators" }, - + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateContainers", + "label": "ValidateContainers" + } + ] + }, { "type": "dict", "collapsible": true, 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 6873ed5190..782179cfd1 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 @@ -29,6 +29,16 @@ "type": "label", "label": "Validators" }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateContainers", + "label": "ValidateContainers" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_aftereffects.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_aftereffects.json index afadf48173..6c36a9bb8a 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_aftereffects.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_aftereffects.json @@ -29,11 +29,13 @@ "template_data": [ { "app_variant_label": "2020", - "app_variant": "2020" + "app_variant": "2020", + "variant_skip_paths": ["use_python_2"] }, { "app_variant_label": "2021", - "app_variant": "2021" + "app_variant": "2021", + "variant_skip_paths": ["use_python_2"] } ] } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_blender.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_blender.json index 0a6c8ca035..27ead6e6da 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_blender.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_blender.json @@ -30,7 +30,8 @@ "children": [ { "type": "schema_template", - "name": "template_host_variant_items" + "name": "template_host_variant_items", + "skip_paths": ["use_python_2"] } ] } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_harmony.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_harmony.json index 083885a53b..c122b8930b 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_harmony.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_harmony.json @@ -29,11 +29,13 @@ "template_data": [ { "app_variant_label": "20", - "app_variant": "20" + "app_variant": "20", + "variant_skip_paths": ["use_python_2"] }, { "app_variant_label": "17", - "app_variant": "17" + "app_variant": "17", + "variant_skip_paths": ["use_python_2"] } ] } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json index 9c21166b63..7bcd89c650 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json @@ -29,11 +29,13 @@ "template_data": [ { "app_variant_label": "2020", - "app_variant": "2020" + "app_variant": "2020", + "variant_skip_paths": ["use_python_2"] }, { "app_variant_label": "2021", - "app_variant": "2021" + "app_variant": "2021", + "variant_skip_paths": ["use_python_2"] } ] } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_tvpaint.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_tvpaint.json index c39e6f7a30..ff57d767c4 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_tvpaint.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_tvpaint.json @@ -30,7 +30,8 @@ "children": [ { "type": "schema_template", - "name": "template_host_variant_items" + "name": "template_host_variant_items", + "skip_paths": ["use_python_2"] } ] } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_unreal.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_unreal.json index df5ec0e6fa..133d6c9eaf 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_unreal.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_unreal.json @@ -30,7 +30,12 @@ "children": [ { "type": "schema_template", - "name": "template_host_variant_items" + "name": "template_host_variant_items", + "skip_paths": [ + "executables", + "separator", + "arguments" + ] } ] } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/template_host_unchangables.json b/openpype/settings/entities/schemas/system_schema/host_settings/template_host_unchangables.json index e8b2a70076..c4d8d89209 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/template_host_unchangables.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/template_host_unchangables.json @@ -14,25 +14,11 @@ "roles": ["developer"] }, { - "type": "enum", + "type": "hosts-enum", "key": "host_name", "label": "Host implementation", - "enum_items": [ - { "": "< without host >" }, - { "aftereffects": "aftereffects" }, - { "blender": "blender" }, - { "celaction": "celaction" }, - { "fusion": "fusion" }, - { "harmony": "harmony" }, - { "hiero": "hiero" }, - { "houdini": "houdini" }, - { "maya": "maya" }, - { "nuke": "nuke" }, - { "photoshop": "photoshop" }, - { "resolve": "resolve" }, - { "tvpaint": "tvpaint" }, - { "unreal": "unreal" } - ], + "multiselection": false, + "use_empty_value": true, "roles": ["developer"] } ] diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant.json b/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant.json index 33cde3d216..96a936c27b 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant.json @@ -1,4 +1,9 @@ [ + { + "__default_values__": { + "variant_skip_paths": null + } + }, { "type": "dict", "key": "{app_variant}", @@ -19,7 +24,8 @@ }, { "type": "schema_template", - "name": "template_host_variant_items" + "name": "template_host_variant_items", + "skip_paths": "{variant_skip_paths}" } ] } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant_items.json b/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant_items.json index ab4d2374a3..409efb006e 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant_items.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant_items.json @@ -14,6 +14,7 @@ "placeholder": "Executable path" }, { + "key": "separator", "type":"separator" }, { diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index f61166fa69..4a3e66de33 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -532,7 +532,11 @@ def apply_local_settings_on_system_settings(system_settings, local_settings): variants = system_settings["applications"][app_group_name]["variants"] for app_name, app_value in value.items(): - if not app_value or app_name not in variants: + if ( + not app_value + or app_name not in variants + or "executables" not in variants[app_name] + ): continue executable = app_value.get("executable") diff --git a/openpype/tools/launcher/lib.py b/openpype/tools/launcher/lib.py index b4e6a0c3e9..65d40cd0df 100644 --- a/openpype/tools/launcher/lib.py +++ b/openpype/tools/launcher/lib.py @@ -15,7 +15,7 @@ provides a bridge between the file-based project inventory and configuration. """ import os -from Qt import QtGui +from Qt import QtGui, QtCore from avalon.vendor import qtawesome from openpype.api import resources @@ -23,6 +23,51 @@ ICON_CACHE = {} NOT_FOUND = type("NotFound", (object, ), {}) +class ProjectHandler(QtCore.QObject): + """Handler of project model and current project in Launcher tool. + + Helps to organize two separate widgets handling current project selection. + + It is easier to trigger project change callbacks from one place than from + multiple differect places without proper handling or sequence changes. + + Args: + dbcon(AvalonMongoDB): Mongo connection with Session. + model(ProjectModel): Object of projects model which is shared across + all widgets using projects. Arg dbcon should be used as source for + the model. + """ + # Project list will be refreshed each 10000 msecs + # - this is not part of helper implementation but should be used by widgets + # that may require reshing of projects + refresh_interval = 10000 + + # Signal emmited when project has changed + project_changed = QtCore.Signal(str) + + def __init__(self, dbcon, model): + super(ProjectHandler, self).__init__() + # Store project model for usage + self.model = model + # Store dbcon + self.dbcon = dbcon + + self.current_project = dbcon.Session.get("AVALON_PROJECT") + + def set_project(self, project_name): + # Change current project of this handler + self.current_project = project_name + # Change session project to take effect for other widgets using the + # dbcon object. + self.dbcon.Session["AVALON_PROJECT"] = project_name + + # Trigger change signal when everything is updated to new project + self.project_changed.emit(project_name) + + def refresh_model(self): + self.model.refresh() + + def get_action_icon(action): icon_name = action.icon if not icon_name: diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 25b6dcdbf0..846a07e081 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -325,19 +325,59 @@ class ProjectModel(QtGui.QStandardItemModel): self.hide_invisible = False self.project_icon = qtawesome.icon("fa.map", color="white") + self._project_names = set() def refresh(self): - self.clear() - self.beginResetModel() - + project_names = set() for project_doc in self.get_projects(): - item = QtGui.QStandardItem(self.project_icon, project_doc["name"]) - self.appendRow(item) + project_names.add(project_doc["name"]) - self.endResetModel() + origin_project_names = set(self._project_names) + self._project_names = project_names + + project_names_to_remove = origin_project_names - project_names + if project_names_to_remove: + row_counts = {} + continuous = None + for row in range(self.rowCount()): + index = self.index(row, 0) + index_name = index.data(QtCore.Qt.DisplayRole) + if index_name in project_names_to_remove: + if continuous is None: + continuous = row + row_counts[continuous] = 0 + row_counts[continuous] += 1 + else: + continuous = None + + for row in reversed(sorted(row_counts.keys())): + count = row_counts[row] + self.removeRows(row, count) + + continuous = None + row_counts = {} + for idx, project_name in enumerate(sorted(project_names)): + if project_name in origin_project_names: + continuous = None + continue + + if continuous is None: + continuous = idx + row_counts[continuous] = [] + + row_counts[continuous].append(project_name) + + for row in reversed(sorted(row_counts.keys())): + items = [] + for project_name in row_counts[row]: + item = QtGui.QStandardItem(self.project_icon, project_name) + items.append(item) + + self.invisibleRootItem().insertRows(row, items) def get_projects(self): project_docs = [] + for project_doc in sorted( self.dbcon.projects(), key=lambda x: x["name"] ): diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index 22b08d7d15..048210115c 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -20,22 +20,15 @@ from .constants import ( class ProjectBar(QtWidgets.QWidget): - project_changed = QtCore.Signal(int) - - def __init__(self, dbcon, parent=None): + def __init__(self, project_handler, parent=None): super(ProjectBar, self).__init__(parent) - self.dbcon = dbcon - - model = ProjectModel(dbcon) - model.hide_invisible = True - project_combobox = QtWidgets.QComboBox(self) # Change delegate so stylysheets are applied project_delegate = QtWidgets.QStyledItemDelegate(project_combobox) project_combobox.setItemDelegate(project_delegate) - project_combobox.setModel(model) + project_combobox.setModel(project_handler.model) project_combobox.setRootModelIndex(QtCore.QModelIndex()) layout = QtWidgets.QHBoxLayout(self) @@ -47,21 +40,43 @@ class ProjectBar(QtWidgets.QWidget): QtWidgets.QSizePolicy.Maximum ) - self.model = model + refresh_timer = QtCore.QTimer() + refresh_timer.setInterval(project_handler.refresh_interval) + + self.project_handler = project_handler self.project_delegate = project_delegate self.project_combobox = project_combobox - - # Initialize - self.refresh() + self.refresh_timer = refresh_timer # Signals - self.project_combobox.currentIndexChanged.connect(self.project_changed) + refresh_timer.timeout.connect(self._on_refresh_timeout) + self.project_combobox.currentIndexChanged.connect(self.on_index_change) + project_handler.project_changed.connect(self._on_project_change) # Set current project by default if it's set. - project_name = self.dbcon.Session.get("AVALON_PROJECT") + project_name = project_handler.current_project if project_name: self.set_project(project_name) + def showEvent(self, event): + if not self.refresh_timer.isActive(): + self.refresh_timer.start() + super(ProjectBar, self).showEvent(event) + + def _on_refresh_timeout(self): + if not self.isVisible(): + # Stop timer if widget is not visible + self.refresh_timer.stop() + + elif self.isActiveWindow(): + # Refresh projects if window is active + self.project_handler.refresh_model() + + def _on_project_change(self, project_name): + if self.get_current_project() == project_name: + return + self.set_project(project_name) + def get_current_project(self): return self.project_combobox.currentText() @@ -69,27 +84,18 @@ class ProjectBar(QtWidgets.QWidget): index = self.project_combobox.findText(project_name) if index < 0: # Try refresh combobox model - self.project_combobox.blockSignals(True) - self.model.refresh() - self.project_combobox.blockSignals(False) - + self.project_handler.refresh_model() index = self.project_combobox.findText(project_name) if index >= 0: self.project_combobox.setCurrentIndex(index) - def refresh(self): - prev_project_name = self.get_current_project() + def on_index_change(self, idx): + if not self.isVisible(): + return - # Refresh without signals - self.project_combobox.blockSignals(True) - - self.model.refresh() - self.set_project(prev_project_name) - - self.project_combobox.blockSignals(False) - - self.project_changed.emit(self.project_combobox.currentIndex()) + project_name = self.get_current_project() + self.project_handler.set_project(project_name) class ActionBar(QtWidgets.QWidget): diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index af749814b7..979aab42cf 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -12,7 +12,7 @@ from avalon.tools import lib as tools_lib from avalon.tools.widgets import AssetWidget from avalon.vendor import qtawesome from .models import ProjectModel -from .lib import get_action_label +from .lib import get_action_label, ProjectHandler from .widgets import ( ProjectBar, ActionBar, @@ -89,37 +89,49 @@ class ProjectIconView(QtWidgets.QListView): class ProjectsPanel(QtWidgets.QWidget): """Projects Page""" - - project_clicked = QtCore.Signal(str) - - def __init__(self, dbcon, parent=None): + def __init__(self, project_handler, parent=None): super(ProjectsPanel, self).__init__(parent=parent) layout = QtWidgets.QVBoxLayout(self) - self.dbcon = dbcon - self.dbcon.install() - view = ProjectIconView(parent=self) view.setSelectionMode(QtWidgets.QListView.NoSelection) flick = FlickCharm(parent=self) flick.activateOn(view) - model = ProjectModel(self.dbcon) - model.hide_invisible = True - model.refresh() - view.setModel(model) + + view.setModel(project_handler.model) layout.addWidget(view) + refresh_timer = QtCore.QTimer() + refresh_timer.setInterval(project_handler.refresh_interval) + + refresh_timer.timeout.connect(self._on_refresh_timeout) view.clicked.connect(self.on_clicked) - self.model = model self.view = view + self.refresh_timer = refresh_timer + self.project_handler = project_handler def on_clicked(self, index): if index.isValid(): project_name = index.data(QtCore.Qt.DisplayRole) - self.project_clicked.emit(project_name) + self.project_handler.set_project(project_name) + + def showEvent(self, event): + self.project_handler.refresh_model() + if not self.refresh_timer.isActive(): + self.refresh_timer.start() + super(ProjectsPanel, self).showEvent(event) + + def _on_refresh_timeout(self): + if not self.isVisible(): + # Stop timer if widget is not visible + self.refresh_timer.stop() + + elif self.isActiveWindow(): + # Refresh projects if window is active + self.project_handler.refresh_model() class AssetsPanel(QtWidgets.QWidget): @@ -127,7 +139,7 @@ class AssetsPanel(QtWidgets.QWidget): back_clicked = QtCore.Signal() session_changed = QtCore.Signal() - def __init__(self, dbcon, parent=None): + def __init__(self, project_handler, dbcon, parent=None): super(AssetsPanel, self).__init__(parent=parent) self.dbcon = dbcon @@ -142,7 +154,7 @@ class AssetsPanel(QtWidgets.QWidget): btn_back = QtWidgets.QPushButton(project_bar_widget) btn_back.setIcon(btn_back_icon) - project_bar = ProjectBar(self.dbcon, project_bar_widget) + project_bar = ProjectBar(project_handler, project_bar_widget) layout.addWidget(btn_back) layout.addWidget(project_bar) @@ -185,24 +197,19 @@ class AssetsPanel(QtWidgets.QWidget): layout.addWidget(body) # signals - project_bar.project_changed.connect(self.on_project_changed) + project_handler.project_changed.connect(self.on_project_changed) assets_widget.selection_changed.connect(self.on_asset_changed) assets_widget.refreshed.connect(self.on_asset_changed) tasks_widget.task_changed.connect(self.on_task_change) btn_back.clicked.connect(self.back_clicked) + self.project_handler = project_handler self.project_bar = project_bar self.assets_widget = assets_widget self.tasks_widget = tasks_widget self._btn_back = btn_back - # Force initial refresh for the assets since we might not be - # trigging a Project switch if we click the project that was set - # prior to launching the Launcher - # todo: remove this behavior when AVALON_PROJECT is not required - assets_widget.refresh() - def showEvent(self, event): super(AssetsPanel, self).showEvent(event) @@ -211,19 +218,7 @@ class AssetsPanel(QtWidgets.QWidget): btn_size = self.project_bar.height() self._btn_back.setFixedSize(QtCore.QSize(btn_size, btn_size)) - def set_project(self, project): - before = self.project_bar.get_current_project() - if before == project: - self.assets_widget.refresh() - return - - self.project_bar.set_project(project) - self.on_project_changed() - def on_project_changed(self): - project_name = self.project_bar.get_current_project() - self.dbcon.Session["AVALON_PROJECT"] = project_name - self.session_changed.emit() self.assets_widget.refresh() @@ -232,11 +227,8 @@ class AssetsPanel(QtWidgets.QWidget): """Callback on asset selection changed This updates the task view. - """ - print("Asset changed..") - asset_name = None asset_silo = None @@ -276,6 +268,8 @@ class AssetsPanel(QtWidgets.QWidget): class LauncherWindow(QtWidgets.QDialog): """Launcher interface""" + # Refresh actions each 10000msecs + actions_refresh_timeout = 10000 def __init__(self, parent=None): super(LauncherWindow, self).__init__(parent) @@ -298,8 +292,12 @@ class LauncherWindow(QtWidgets.QDialog): self.windowFlags() | QtCore.Qt.WindowMinimizeButtonHint ) - project_panel = ProjectsPanel(self.dbcon) - asset_panel = AssetsPanel(self.dbcon) + project_model = ProjectModel(self.dbcon) + project_model.hide_invisible = True + project_handler = ProjectHandler(self.dbcon, project_model) + + project_panel = ProjectsPanel(project_handler) + asset_panel = AssetsPanel(project_handler, self.dbcon) page_slider = SlidePageWidget() page_slider.addWidget(project_panel) @@ -344,6 +342,12 @@ class LauncherWindow(QtWidgets.QDialog): layout.setSpacing(0) layout.setContentsMargins(0, 0, 0, 0) + actions_refresh_timer = QtCore.QTimer() + actions_refresh_timer.setInterval(self.actions_refresh_timeout) + + self.actions_refresh_timer = actions_refresh_timer + self.project_handler = project_handler + self.message_label = message_label self.project_panel = project_panel self.asset_panel = asset_panel @@ -353,23 +357,21 @@ class LauncherWindow(QtWidgets.QDialog): self._page = 0 # signals + actions_refresh_timer.timeout.connect(self._on_action_timer) actions_bar.action_clicked.connect(self.on_action_clicked) action_history.trigger_history.connect(self.on_history_action) - project_panel.project_clicked.connect(self.on_project_clicked) + project_handler.project_changed.connect(self.on_project_change) asset_panel.back_clicked.connect(self.on_back_clicked) asset_panel.session_changed.connect(self.on_session_changed) - # todo: Simplify this callback connection - asset_panel.project_bar.project_changed.connect( - self.on_project_changed - ) - self.resize(520, 740) def showEvent(self, event): - super().showEvent(event) - # TODO implement refresh/reset which will trigger updating - self.discover_actions() + if not self.actions_refresh_timer.isActive(): + self.actions_refresh_timer.start() + self.discover_actions() + + super(LauncherWindow, self).showEvent(event) def set_page(self, page): current = self.page_slider.currentIndex() @@ -385,13 +387,6 @@ class LauncherWindow(QtWidgets.QDialog): QtCore.QTimer.singleShot(5000, lambda: self.message_label.setText("")) self.log.debug(message) - def on_project_changed(self): - project_name = self.asset_panel.project_bar.get_current_project() - self.dbcon.Session["AVALON_PROJECT"] = project_name - - # Update the Action plug-ins available for the current project - self.discover_actions() - def on_session_changed(self): self.filter_actions() @@ -402,17 +397,23 @@ class LauncherWindow(QtWidgets.QDialog): def filter_actions(self): self.actions_bar.filter_actions() - def on_project_clicked(self, project_name): - self.dbcon.Session["AVALON_PROJECT"] = project_name - # Refresh projects - self.asset_panel.set_project(project_name) + def _on_action_timer(self): + if not self.isVisible(): + # Stop timer if widget is not visible + self.actions_refresh_timer.stop() + + elif self.isActiveWindow(): + # Refresh projects if window is active + self.discover_actions() + + def on_project_change(self, project_name): + # Update the Action plug-ins available for the current project self.set_page(1) self.discover_actions() def on_back_clicked(self): - self.dbcon.Session["AVALON_PROJECT"] = None + self.project_handler.set_project(None) self.set_page(0) - self.project_panel.model.refresh() # Refresh projects self.discover_actions() def on_action_clicked(self, action): diff --git a/openpype/tools/settings/local_settings/apps_widget.py b/openpype/tools/settings/local_settings/apps_widget.py index 5f4e5dd1c5..e6a4132955 100644 --- a/openpype/tools/settings/local_settings/apps_widget.py +++ b/openpype/tools/settings/local_settings/apps_widget.py @@ -121,6 +121,9 @@ class AppGroupWidget(QtWidgets.QWidget): widgets_by_variant_name = {} for variant_name, variant_entity in valid_variants.items(): + if "executables" not in variant_entity: + continue + variant_widget = AppVariantWidget( group_label, variant_name, variant_entity, content_widget ) @@ -193,8 +196,12 @@ class LocalApplicationsWidgets(QtWidgets.QWidget): # Create App group specific widget and store it by the key group_widget = AppGroupWidget(entity, self) - self.widgets_by_group_name[key] = group_widget - self.content_layout.addWidget(group_widget) + if group_widget.widgets_by_variant_name: + self.widgets_by_group_name[key] = group_widget + self.content_layout.addWidget(group_widget) + else: + group_widget.setVisible(False) + group_widget.deleteLater() def update_local_settings(self, value): if not value: diff --git a/openpype/tools/settings/local_settings/window.py b/openpype/tools/settings/local_settings/window.py index 69562d0b1f..9e8fd89b23 100644 --- a/openpype/tools/settings/local_settings/window.py +++ b/openpype/tools/settings/local_settings/window.py @@ -168,9 +168,6 @@ class LocalSettingsWindow(QtWidgets.QWidget): scroll_widget = QtWidgets.QScrollArea(self) scroll_widget.setObjectName("GroupWidget") - settings_widget = LocalSettingsWidget(scroll_widget) - - scroll_widget.setWidget(settings_widget) scroll_widget.setWidgetResizable(True) footer = QtWidgets.QWidget(self) @@ -191,7 +188,12 @@ class LocalSettingsWindow(QtWidgets.QWidget): save_btn.clicked.connect(self._on_save_clicked) reset_btn.clicked.connect(self._on_reset_clicked) - self.settings_widget = settings_widget + # Do not create local settings widget in init phase as it's using + # settings objects that must be OK to be able create this widget + # - we want to show dialog if anything goes wrong + # - without reseting nothing is shown + self._settings_widget = None + self._scroll_widget = scroll_widget self.reset_btn = reset_btn self.save_btn = save_btn @@ -203,13 +205,53 @@ class LocalSettingsWindow(QtWidgets.QWidget): def reset(self): if self._reset_on_show: self._reset_on_show = False - value = get_local_settings() - self.settings_widget.update_local_settings(value) + + error_msg = None + try: + # Create settings widget if is not created yet + if self._settings_widget is None: + self._settings_widget = LocalSettingsWidget( + self._scroll_widget + ) + self._scroll_widget.setWidget(self._settings_widget) + + value = get_local_settings() + self._settings_widget.update_local_settings(value) + + except Exception as exc: + error_msg = str(exc) + + crashed = error_msg is not None + # Enable/Disable save button if crashed or not + self.save_btn.setEnabled(not crashed) + # Show/Hide settings widget if crashed or not + if self._settings_widget: + self._settings_widget.setVisible(not crashed) + + if not crashed: + return + + # Show message with error + title = "Something went wrong" + msg = ( + "Bug: Loading of settings failed." + " Please contact your project manager or OpenPype team." + "\n\nError message:\n{}" + ).format(error_msg) + + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Critical, + title, + msg, + QtWidgets.QMessageBox.Ok, + self + ) + dialog.exec_() def _on_reset_clicked(self): self.reset() def _on_save_clicked(self): - value = self.settings_widget.settings_value() + value = self._settings_widget.settings_value() save_local_settings(value) self.reset() diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index 03f920b7dc..eb5f82ab9a 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -1,3 +1,5 @@ +import json + from Qt import QtWidgets, QtGui, QtCore from openpype.tools.settings import CHILD_OFFSET from .widgets import ExpandingWidget @@ -125,6 +127,117 @@ class BaseWidget(QtWidgets.QWidget): actions_mapping[action] = remove_from_project_override menu.addAction(action) + def _copy_value_actions(self, menu): + def copy_value(): + mime_data = QtCore.QMimeData() + + if self.entity.is_dynamic_item or self.entity.is_in_dynamic_item: + entity_path = None + else: + entity_path = "/".join( + [self.entity.root_key, self.entity.path] + ) + + value = self.entity.value + # Copy for settings tool + settings_data = { + "root_key": self.entity.root_key, + "value": value, + "path": entity_path + } + settings_encoded_data = QtCore.QByteArray() + settings_stream = QtCore.QDataStream( + settings_encoded_data, QtCore.QIODevice.WriteOnly + ) + settings_stream.writeQString(json.dumps(settings_data)) + mime_data.setData( + "application/copy_settings_value", settings_encoded_data + ) + + # Copy as json + json_encoded_data = None + if isinstance(value, (dict, list)): + json_encoded_data = QtCore.QByteArray() + json_stream = QtCore.QDataStream( + json_encoded_data, QtCore.QIODevice.WriteOnly + ) + json_stream.writeQString(json.dumps(value)) + + mime_data.setData("application/json", json_encoded_data) + + # Copy as text + if json_encoded_data is None: + # Store value as string + mime_data.setText(str(value)) + else: + # Store data as json string + mime_data.setText(json.dumps(value, indent=4)) + + QtWidgets.QApplication.clipboard().setMimeData(mime_data) + + action = QtWidgets.QAction("Copy", menu) + return [(action, copy_value)] + + def _paste_value_actions(self, menu): + output = [] + # Allow paste of value only if were copied from this UI + mime_data = QtWidgets.QApplication.clipboard().mimeData() + mime_value = mime_data.data("application/copy_settings_value") + # Skip if there is nothing to do + if not mime_value: + return output + + settings_stream = QtCore.QDataStream( + mime_value, QtCore.QIODevice.ReadOnly + ) + mime_data_value_str = settings_stream.readQString() + mime_data_value = json.loads(mime_data_value_str) + + value = mime_data_value["value"] + path = mime_data_value["path"] + root_key = mime_data_value["root_key"] + + # Try to find matching entity to be able paste values to same spot + # - entity can't by dynamic or in dynamic item + # - must be in same root entity as source copy + # Can't copy system settings <-> project settings + matching_entity = None + if path and root_key == self.entity.root_key: + try: + matching_entity = self.entity.get_entity_from_path(path) + except Exception: + pass + + def _set_entity_value(_entity, _value): + try: + _entity.set(_value) + except Exception: + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Value does not match settings schema") + dialog.setIcon(QtWidgets.QMessageBox.Warning) + dialog.setText(( + "Pasted value does not seem to match schema of destination" + " settings entity." + )) + dialog.exec_() + + # Simple paste value method + def paste_value(): + _set_entity_value(self.entity, value) + + action = QtWidgets.QAction("Paste", menu) + output.append((action, paste_value)) + + # Paste value to matchin entity + def paste_value_to_path(): + _set_entity_value(matching_entity, value) + + if matching_entity is not None: + action = QtWidgets.QAction("Paste to same place", menu) + output.append((action, paste_value_to_path)) + + return output + def show_actions_menu(self, event=None): if event and event.button() != QtCore.Qt.RightButton: return @@ -144,6 +257,15 @@ class BaseWidget(QtWidgets.QWidget): self._add_to_project_override_action(menu, actions_mapping) self._remove_from_project_override_action(menu, actions_mapping) + ui_actions = [] + ui_actions.extend(self._copy_value_actions(menu)) + ui_actions.extend(self._paste_value_actions(menu)) + if ui_actions: + menu.addSeparator() + for action, callback in ui_actions: + menu.addAction(action) + actions_mapping[action] = callback + if not actions_mapping: action = QtWidgets.QAction("< No action >") actions_mapping[action] = None diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 34ab4c464a..392c749211 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -183,6 +183,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget): footer_widget = QtWidgets.QWidget(configurations_widget) footer_layout = QtWidgets.QHBoxLayout(footer_widget) + refresh_icon = qtawesome.icon("fa.refresh", color="white") + refresh_btn = QtWidgets.QPushButton(footer_widget) + refresh_btn.setIcon(refresh_icon) + + footer_layout.addWidget(refresh_btn, 0) + if self.user_role == "developer": self._add_developer_ui(footer_layout) @@ -205,8 +211,10 @@ class SettingsCategoryWidget(QtWidgets.QWidget): main_layout.addWidget(configurations_widget, 1) save_btn.clicked.connect(self._save) + refresh_btn.clicked.connect(self._on_refresh) self.save_btn = save_btn + self.refresh_btn = refresh_btn self.require_restart_label = require_restart_label self.scroll_widget = scroll_widget self.content_layout = content_layout @@ -220,10 +228,6 @@ class SettingsCategoryWidget(QtWidgets.QWidget): return def _add_developer_ui(self, footer_layout): - refresh_icon = qtawesome.icon("fa.refresh", color="white") - refresh_button = QtWidgets.QPushButton() - refresh_button.setIcon(refresh_icon) - modify_defaults_widget = QtWidgets.QWidget() modify_defaults_checkbox = QtWidgets.QCheckBox(modify_defaults_widget) modify_defaults_checkbox.setChecked(self._hide_studio_overrides) @@ -235,10 +239,8 @@ class SettingsCategoryWidget(QtWidgets.QWidget): modify_defaults_layout.addWidget(label_widget) modify_defaults_layout.addWidget(modify_defaults_checkbox) - footer_layout.addWidget(refresh_button, 0) footer_layout.addWidget(modify_defaults_widget, 0) - refresh_button.clicked.connect(self._on_refresh) modify_defaults_checkbox.stateChanged.connect( self._on_modify_defaults ) diff --git a/openpype/tools/settings/settings/color_widget.py b/openpype/tools/settings/settings/color_widget.py index fa0cd2c989..b38b46f3cb 100644 --- a/openpype/tools/settings/settings/color_widget.py +++ b/openpype/tools/settings/settings/color_widget.py @@ -25,7 +25,9 @@ class ColorWidget(InputWidget): self._dialog.open() return - dialog = ColorDialog(self.input_field.color(), self) + dialog = ColorDialog( + self.input_field.color(), self.entity.use_alpha, self + ) self._dialog = dialog dialog.open() @@ -120,12 +122,12 @@ class ColorViewer(QtWidgets.QWidget): class ColorDialog(QtWidgets.QDialog): - def __init__(self, color=None, parent=None): + def __init__(self, color=None, use_alpha=True, parent=None): super(ColorDialog, self).__init__(parent) self.setWindowTitle("Color picker dialog") - picker_widget = ColorPickerWidget(color, self) + picker_widget = ColorPickerWidget(color, use_alpha, self) footer_widget = QtWidgets.QWidget(self) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index c79e55a143..d567e26d74 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -944,10 +944,8 @@ class Window(QtWidgets.QMainWindow): split_widget.addWidget(tasks_widget) split_widget.addWidget(files_widget) split_widget.addWidget(side_panel) - split_widget.setStretchFactor(0, 1) - split_widget.setStretchFactor(1, 1) - split_widget.setStretchFactor(2, 3) - split_widget.setStretchFactor(3, 1) + split_widget.setSizes([255, 160, 455, 175]) + body_layout.addWidget(split_widget) # Add top margin for tasks to align it visually with files as @@ -976,7 +974,7 @@ class Window(QtWidgets.QMainWindow): # Force focus on the open button by default, required for Houdini. files_widget.btn_open.setFocus() - self.resize(1000, 600) + self.resize(1200, 600) def keyPressEvent(self, event): """Custom keyPressEvent. diff --git a/openpype/version.py b/openpype/version.py index bf261d41b2..0371d5f4e3 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.1.0-nightly.3" +__version__ = "3.2.0-nightly.5" diff --git a/openpype/widgets/color_widgets/color_inputs.py b/openpype/widgets/color_widgets/color_inputs.py index eda8c618f1..6f5d4baa02 100644 --- a/openpype/widgets/color_widgets/color_inputs.py +++ b/openpype/widgets/color_widgets/color_inputs.py @@ -4,35 +4,6 @@ from Qt import QtWidgets, QtCore, QtGui from .color_view import draw_checkerboard_tile -slide_style = """ -QSlider::groove:horizontal { - background: qlineargradient( - x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #000, stop: 1 #fff - ); - height: 8px; - border-radius: 4px; -} - -QSlider::handle:horizontal { - background: qlineargradient( - x1:0, y1:0, x2:1, y2:1, stop:0 #ddd, stop:1 #bbb - ); - border: 1px solid #777; - width: 8px; - margin-top: -1px; - margin-bottom: -1px; - border-radius: 4px; -} - -QSlider::handle:horizontal:hover { - background: qlineargradient( - x1:0, y1:0, x2:1, y2:1, stop:0 #eee, stop:1 #ddd - ); - border: 1px solid #444;ff - border-radius: 4px; -}""" - - class AlphaSlider(QtWidgets.QSlider): def __init__(self, *args, **kwargs): super(AlphaSlider, self).__init__(*args, **kwargs) @@ -80,7 +51,7 @@ class AlphaSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform) + painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) rect = self.style().subControlRect( QtWidgets.QStyle.CC_Slider, opt, @@ -135,19 +106,8 @@ class AlphaSlider(QtWidgets.QSlider): painter.save() - gradient = QtGui.QRadialGradient() - radius = handle_rect.height() / 2 - center_x = handle_rect.width() / 2 + handle_rect.x() - center_y = handle_rect.height() - gradient.setCenter(center_x, center_y) - gradient.setCenterRadius(radius) - gradient.setFocalPoint(center_x, center_y) - - gradient.setColorAt(0.9, QtGui.QColor(127, 127, 127)) - gradient.setColorAt(1, QtCore.Qt.transparent) - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(gradient) + painter.setBrush(QtGui.QColor(127, 127, 127)) painter.drawEllipse(handle_rect) painter.restore() diff --git a/openpype/widgets/color_widgets/color_picker_widget.py b/openpype/widgets/color_widgets/color_picker_widget.py index 81ec1f87aa..228d35a77c 100644 --- a/openpype/widgets/color_widgets/color_picker_widget.py +++ b/openpype/widgets/color_widgets/color_picker_widget.py @@ -17,19 +17,12 @@ from .color_inputs import ( class ColorPickerWidget(QtWidgets.QWidget): color_changed = QtCore.Signal(QtGui.QColor) - def __init__(self, color=None, parent=None): + def __init__(self, color=None, use_alpha=True, parent=None): super(ColorPickerWidget, self).__init__(parent) # Color triangle color_triangle = QtColorTriangle(self) - alpha_slider_proxy = QtWidgets.QWidget(self) - alpha_slider = AlphaSlider(QtCore.Qt.Horizontal, alpha_slider_proxy) - - alpha_slider_layout = QtWidgets.QHBoxLayout(alpha_slider_proxy) - alpha_slider_layout.setContentsMargins(5, 5, 5, 5) - alpha_slider_layout.addWidget(alpha_slider, 1) - # Eye picked widget pick_widget = PickScreenColorWidget() pick_widget.setMaximumHeight(50) @@ -47,8 +40,6 @@ class ColorPickerWidget(QtWidgets.QWidget): color_view = ColorViewer(self) color_view.setMaximumHeight(50) - alpha_inputs = AlphaInputs(self) - color_inputs_color = QtGui.QColor() col_inputs_by_label = [ ("HEX", HEXInputs(color_inputs_color, self)), @@ -58,6 +49,7 @@ class ColorPickerWidget(QtWidgets.QWidget): ] layout = QtWidgets.QGridLayout(self) + empty_col = 1 label_col = empty_col + 1 input_col = label_col + 1 @@ -65,6 +57,9 @@ class ColorPickerWidget(QtWidgets.QWidget): empty_widget.setFixedWidth(10) layout.addWidget(empty_widget, 0, empty_col) + layout.setColumnStretch(0, 1) + layout.setColumnStretch(input_col, 1) + row = 0 layout.addWidget(btn_pick_color, row, label_col) layout.addWidget(color_view, row, input_col) @@ -84,20 +79,41 @@ class ColorPickerWidget(QtWidgets.QWidget): layout.setRowStretch(row, 1) row += 1 - layout.addWidget(alpha_slider_proxy, row, 0) + alpha_label = None + alpha_slider_proxy = None + alpha_slider = None + alpha_inputs = None + if not use_alpha: + color.setAlpha(255) + else: + alpha_inputs = AlphaInputs(self) + alpha_label = QtWidgets.QLabel("Alpha", self) + alpha_slider_proxy = QtWidgets.QWidget(self) + alpha_slider = AlphaSlider( + QtCore.Qt.Horizontal, alpha_slider_proxy + ) + + alpha_slider_layout = QtWidgets.QHBoxLayout(alpha_slider_proxy) + alpha_slider_layout.setContentsMargins(5, 5, 5, 5) + alpha_slider_layout.addWidget(alpha_slider, 1) + + layout.addWidget(alpha_slider_proxy, row, 0) + + layout.addWidget(alpha_label, row, label_col) + layout.addWidget(alpha_inputs, row, input_col) + + row += 1 - layout.addWidget(QtWidgets.QLabel("Alpha", self), row, label_col) - layout.addWidget(alpha_inputs, row, input_col) - row += 1 layout.setRowStretch(row, 1) color_view.set_color(color_triangle.cur_color) color_triangle.color_changed.connect(self.triangle_color_changed) - alpha_slider.valueChanged.connect(self._on_alpha_slider_change) pick_widget.color_selected.connect(self.on_color_change) - alpha_inputs.alpha_changed.connect(self._on_alpha_inputs_changed) btn_pick_color.released.connect(self.pick_color) + if alpha_slider: + alpha_slider.valueChanged.connect(self._on_alpha_slider_change) + alpha_inputs.alpha_changed.connect(self._on_alpha_inputs_changed) self.color_input_fields = color_input_fields self.color_inputs_color = color_inputs_color @@ -131,7 +147,8 @@ class ColorPickerWidget(QtWidgets.QWidget): return self.color_view.color() def set_color(self, color): - self.alpha_inputs.set_alpha(color.alpha()) + if self.alpha_inputs: + self.alpha_inputs.set_alpha(color.alpha()) self.on_color_change(color) def pick_color(self): @@ -163,10 +180,10 @@ class ColorPickerWidget(QtWidgets.QWidget): def alpha_changed(self, value): self.color_view.set_alpha(value) - if self.alpha_slider.value() != value: + if self.alpha_slider and self.alpha_slider.value() != value: self.alpha_slider.setValue(value) - if self.alpha_inputs.alpha_value != value: + if self.alpha_inputs and self.alpha_inputs.alpha_value != value: self.alpha_inputs.set_alpha(value) def _on_alpha_inputs_changed(self, value): diff --git a/openpype/widgets/color_widgets/color_triangle.py b/openpype/widgets/color_widgets/color_triangle.py index d4db175d84..f4a86c4fa5 100644 --- a/openpype/widgets/color_widgets/color_triangle.py +++ b/openpype/widgets/color_widgets/color_triangle.py @@ -241,7 +241,11 @@ class QtColorTriangle(QtWidgets.QWidget): # Blit the static generated background with the hue gradient onto # the double buffer. - buf = QtGui.QImage(self.bg_image.copy()) + buf = QtGui.QImage( + self.bg_image.width(), + self.bg_image.height(), + QtGui.QImage.Format_RGB32 + ) # Draw the trigon # Find the color with only the hue, and max value and saturation @@ -254,9 +258,21 @@ class QtColorTriangle(QtWidgets.QWidget): ) # Slow step: convert the image to a pixmap - pix = QtGui.QPixmap.fromImage(buf) + pix = self.bg_image.copy() pix_painter = QtGui.QPainter(pix) - pix_painter.setRenderHint(QtGui.QPainter.Antialiasing) + + pix_painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + + trigon_path = QtGui.QPainterPath() + trigon_path.moveTo(self.point_a) + trigon_path.lineTo(self.point_b) + trigon_path.lineTo(self.point_c) + trigon_path.closeSubpath() + pix_painter.setClipPath(trigon_path) + + pix_painter.drawImage(0, 0, buf) + + pix_painter.setClipping(False) # Draw an outline of the triangle pix_painter.setPen(self._triangle_outline_pen) @@ -724,27 +740,37 @@ class QtColorTriangle(QtWidgets.QWidget): lx = leftX[y] rx = rightX[y] + # if the xdist is 0, don't draw anything. + xdist = rx - lx + if xdist == 0.0: + continue + lxi = int(floor(lx)) rxi = int(floor(rx)) rc = rightColors[y] lc = leftColors[y] - # if the xdist is 0, don't draw anything. - xdist = rx - lx - if xdist != 0.0: - r = lc.r - g = lc.g - b = lc.b - rdelta = (rc.r - r) / xdist - gdelta = (rc.g - g) / xdist - bdelta = (rc.b - b) / xdist + r = lc.r + g = lc.g + b = lc.b + rdelta = (rc.r - r) / xdist + gdelta = (rc.g - g) / xdist + bdelta = (rc.b - b) / xdist - # Inner loop 2. Draws the line from left to right. - for x in range(lxi, rxi + 1): - buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b))) - r += rdelta - g += gdelta - b += bdelta + # Draw 2 more pixels on left side for smoothing + for x in range(lxi - 2, lxi): + buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b))) + + # Inner loop 2. Draws the line from left to right. + for x in range(lxi, rxi): + buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b))) + r += rdelta + g += gdelta + b += bdelta + + # Draw 2 more pixels on right side for smoothing + for x in range(rxi, rxi + 3): + buf.setPixel(x, y, QtGui.qRgb(int(r), int(g), int(b))) def _radius_at(self, pos, rect): mousexdist = pos.x() - float(rect.center().x()) diff --git a/openpype/widgets/color_widgets/color_view.py b/openpype/widgets/color_widgets/color_view.py index 8644281a1d..b5fce28894 100644 --- a/openpype/widgets/color_widgets/color_view.py +++ b/openpype/widgets/color_widgets/color_view.py @@ -5,6 +5,8 @@ def draw_checkerboard_tile(piece_size=None, color_1=None, color_2=None): if piece_size is None: piece_size = 7 + # Make sure piece size is not float + piece_size = int(piece_size) if color_1 is None: color_1 = QtGui.QColor(188, 188, 188) diff --git a/poetry.lock b/poetry.lock index 48e6f95469..30dbe50c19 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,7 +11,7 @@ develop = false type = "git" url = "https://github.com/pypeclub/acre.git" reference = "master" -resolved_reference = "efc1b8faa8f84568538b936688ae6f7604dd194c" +resolved_reference = "68784b7eb5b7bb5f409b61ab31d4403878a3e1b7" [[package]] name = "aiohttp" diff --git a/repos/avalon-core b/repos/avalon-core index efde026e5a..d8be0bdb37 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit efde026e5aad72dac0e69848005419e2c4f067f2 +Subproject commit d8be0bdb37961e32243f1de0eb9696e86acf7443 diff --git a/tools/ci_tools.py b/tools/ci_tools.py index 85ef6bdcee..436551c243 100644 --- a/tools/ci_tools.py +++ b/tools/ci_tools.py @@ -92,6 +92,24 @@ def calculate_next_nightly(token="nightly"): next_tag = last_pre_v.bump_prerelease(token=token).__str__() return next_tag +def finalize_latest_nightly(): + last_prerelease, last_pre_tag = get_last_version("CI") + last_pre_v = VersionInfo.parse(last_prerelease) + last_pre_v_finalized = last_pre_v.finalize_version() + # print(last_pre_v_finalized) + + return last_pre_v_finalized.__str__() + +def finalize_prerelease(prerelease): + + if "/" in prerelease: + prerelease = prerelease.split("/")[-1] + + prerelease_v = VersionInfo.parse(prerelease) + prerelease_v_finalized = prerelease_v.finalize_version() + + return prerelease_v_finalized.__str__() + def main(): usage = "usage: %prog [options] arg" @@ -102,12 +120,22 @@ def main(): parser.add_option("-b", "--bump", dest="bump", action="store_true", help="Return if there is something to bump") - parser.add_option("-v", "--version", - dest="version", action="store", - help="work with explicit version") + parser.add_option("-r", "--release-latest", + dest="releaselatest", action="store_true", + help="finalize latest prerelease to a release") parser.add_option("-p", "--prerelease", dest="prerelease", action="store", help="define prerelease token") + parser.add_option("-f", "--finalize", + dest="finalize", action="store", + help="define prerelease token") + parser.add_option("-v", "--version", + dest="version", action="store", + help="work with explicit version") + parser.add_option("-l", "--lastversion", + dest="lastversion", action="store", + help="work with explicit version") + (options, args) = parser.parse_args() @@ -124,6 +152,25 @@ def main(): print(next_tag_v) bump_file_versions(next_tag_v) + if options.finalize: + new_release = finalize_prerelease(options.finalize) + print(new_release) + bump_file_versions(new_release) + + if options.lastversion: + last_release, last_release_tag = get_last_version(options.lastversion) + print(last_release_tag) + + if options.releaselatest: + new_release = finalize_latest_nightly() + last_release, last_release_tag = get_last_version("release") + + if VersionInfo.parse(new_release) > VersionInfo.parse(last_release): + print(new_release) + bump_file_versions(new_release) + else: + print("skip") + if options.prerelease: current_prerelease = VersionInfo.parse(options.prerelease) new_prerelease = current_prerelease.bump_prerelease().__str__() diff --git a/website/docs/admin_hosts_aftereffects.md b/website/docs/admin_hosts_aftereffects.md index dc43820465..e3a09058da 100644 --- a/website/docs/admin_hosts_aftereffects.md +++ b/website/docs/admin_hosts_aftereffects.md @@ -14,7 +14,7 @@ All of them are Project based, eg. each project could have different configurati Location: Settings > Project > AfterEffects -![Harmony Project Settings](assets/admin_hosts_aftereffects_settings.png) +![AfterEffects Project Settings](assets/admin_hosts_aftereffects_settings.png) ## Publish plugins diff --git a/website/docs/admin_hosts_tvpaint.md b/website/docs/admin_hosts_tvpaint.md new file mode 100644 index 0000000000..a99cd19010 --- /dev/null +++ b/website/docs/admin_hosts_tvpaint.md @@ -0,0 +1,30 @@ +--- +id: admin_hosts_tvpaint +title: TVPaint +sidebar_label: TVPaint +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Subset name templates +Definition of possibile subset name templates in TVPaint integration. + +### [Render Layer](artist_hosts_tvpaint#render-layer) +Render layer has additional keys for subset name template. It is possible to use **render_layer** and **render_pass**. + +- Key **render_layer** is alias for variant (user's input). +- For key **render_pass** is used predefined value `"Beauty"` (ATM value can't be changed). + +### [Render pass](artist_hosts_tvpaint#render-pass) +Render pass has additional keys for subset name template. It is possible to use **render_layer** and **render_pass**. +- Key **render_layer** is filled with value of **render_pass** from `renderLayer` group. +- Key **render_pass** is alias for variant (user's input). + +:::important Render Layer/Pass templates +It is recommended to use same subset name template for both **renderLayer** and **renderPass** families. +- Example template: `"{family}{Task}_{Render_layer}_{Render_pass}"` +::: + +### [Review](artist_hosts_tvpaint#review) and [Workfile](artist_hosts_tvpaint#workfile) +Families **review** and **workfile** are not manually created but are automatically generated during publishing. That's why it is recommended to not use **variant** key in their subset name template. diff --git a/website/docs/artist_hosts_tvpaint.md b/website/docs/artist_hosts_tvpaint.md index 19cb615158..2e831e64d8 100644 --- a/website/docs/artist_hosts_tvpaint.md +++ b/website/docs/artist_hosts_tvpaint.md @@ -45,7 +45,7 @@ In TVPaint you can find the Tools in OpenPype menu extension. The OpenPype Tools ## Create -In TVPaint you can create and publish **[Reviews](#review)**, **[Render Passes](#render-pass)**, and **[Render Layers](#render-layer)**. +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`. @@ -67,26 +67,13 @@ OpenPype specifically never tries to guess what you want to publish from the sce When you want to publish `review` or `render layer` or `render pass`, open the `Creator` through the Tools menu `Create` button. -### Review +### Review +`Review` renders the whole file as is and sends the resulting QuickTime to Ftrack. +- Is automatically created during publishing. -
-
- -`Review` renders the whole file as is and sends the resulting QuickTime to Ftrack. - -To create reviewable quicktime of your animation: - -- select `Review` in the `Creator` -- press `Create` -- When you run [publish](#publish), file will be rendered and converted to quicktime.` - -
-
- -![createreview](assets/tvp_create_review.png) - -
-
+### Workfile +`Workfile` stores the source workfile as is during publishing (e.g. for backup). +- Is automatically created during publishing. ### Render Layer diff --git a/website/docs/assets/ftrack/ftrack-collect-advanced.png b/website/docs/assets/ftrack/ftrack-collect-advanced.png new file mode 100644 index 0000000000..5249685c86 Binary files /dev/null and b/website/docs/assets/ftrack/ftrack-collect-advanced.png differ diff --git a/website/docs/assets/ftrack/ftrack-collect-main.png b/website/docs/assets/ftrack/ftrack-collect-main.png new file mode 100644 index 0000000000..7c75cd6269 Binary files /dev/null and b/website/docs/assets/ftrack/ftrack-collect-main.png differ diff --git a/website/docs/assets/tvp_create_review.png b/website/docs/assets/tvp_create_review.png deleted file mode 100644 index d6e9f63428..0000000000 Binary files a/website/docs/assets/tvp_create_review.png and /dev/null differ diff --git a/website/docs/module_ftrack.md b/website/docs/module_ftrack.md index bd0dbaef4f..c3b9fd6bc2 100644 --- a/website/docs/module_ftrack.md +++ b/website/docs/module_ftrack.md @@ -196,7 +196,7 @@ Is used to remove value from `Avalon/Mongo Id` Custom Attribute when entity is c ### Sync status from Task to Parent -List of parent boject types where this is triggered ("Shot", "Asset build", etc. Skipped if it is empty) +List of parent object types where this is triggered ("Shot", "Asset build", etc. Skipped if it is empty) ### Sync status from Version to Task @@ -214,3 +214,29 @@ This is usefull for example if first version publish doesn't contain any actual ### Update status on next task Change status on next task by task types order when task status state changed to "Done". All tasks with the same Task mapping of next task status changes From → To. Some status can be ignored. + +## Publish plugins + +### Collect Ftrack Family + +Reviews uploads to Ftrack could be configured by combination of hosts, families and task names. +(Currently implemented only in Standalone Publisher, Maya.) + +#### Profiles + +Profiles are used to select when to add Ftrack family to the instance. One or multiple profiles could be configured, Families, Task names (regex available), Host names combination is needed. + +Eg. If I want review created and uploaded to Ftrack for render published from Maya , setting is: + +Host names: 'Maya' +Families: 'render' +Add Ftrack Family: enabled + +![Collect Ftrack Family](assets/ftrack/ftrack-collect-main.png) + +#### Advanced adding if additional families present + +In special cases adding 'ftrack' based on main family ('Families' set higher) is not enough. +(For example upload to Ftrack for 'plate' main family should only happen if 'review' is contained in instance 'families', not added in other cases. ) + +![Collect Ftrack Family](assets/ftrack/ftrack-collect-advanced.png) \ No newline at end of file diff --git a/website/docs/project_settings/assets/global_extract_review_output_defs.png b/website/docs/project_settings/assets/global_extract_review_output_defs.png index ce3c00ca40..f4c1661b11 100644 Binary files a/website/docs/project_settings/assets/global_extract_review_output_defs.png and b/website/docs/project_settings/assets/global_extract_review_output_defs.png differ diff --git a/website/docs/project_settings/assets/global_tools_creator_subset_template.png b/website/docs/project_settings/assets/global_tools_creator_subset_template.png new file mode 100644 index 0000000000..c4e863c4e0 Binary files /dev/null and b/website/docs/project_settings/assets/global_tools_creator_subset_template.png differ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 5d23dd75e6..e6336c36e2 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -112,6 +112,10 @@ Profile may generate multiple outputs from a single input. Each output must defi | "-10% -200px" | 1800px 800px | | "-10% -0px" | 1800px 1000px | +- **`Overscan color`** + - Color of empty area caused by different aspect ratio of input and output. + - By default is set to black color. + - **`Letter Box`** - **Enabled** - Enable letter boxes - **Ratio** - Ratio of letter boxes @@ -124,6 +128,14 @@ Profile may generate multiple outputs from a single input. Each output must defi ![global_extract_review_letter_box_settings](assets/global_extract_review_letter_box_settings.png) ![global_extract_review_letter_box](assets/global_extract_review_letter_box.png) +- **`Background color`** + - Background color can be used for inputs with possible transparency (e.g. png sequence). + - Input's without possible alpha channel are ignored all the time (e.g. mov). + - Background color slows down rendering process. + - set alpha to `0` to not use this option at all (in most of cases background stays black) + - other than `0` alpha will draw color as background + + ### IntegrateAssetNew Saves information for all published subsets into DB, published assets are available for other hosts, tools and tasks after. @@ -160,6 +172,39 @@ Applicable context filters: ## Tools Settings for OpenPype tools. +## Creator +Settings related to [Creator tool](artist_tools.md#details). + +### Subset name profiles +![global_tools_creator_subset_template](assets/global_tools_creator_subset_template.png) + +Subset name helps to identify published content. More specific name helps with organization and avoid mixing of published content. Subset name is defined using one of templates defined in **Subset name profiles settings**. The template is filled with context information at the time of creation. + +Usage of template is defined by profile filtering using creator's family, host and task name. Profile without filters is used as default template and it is recommend to set default template. If default template is not available `"{family}{Task}"` is used. + +**Formatting keys** + +All templates can contain text and formatting keys **family**, **task** and **variant** e.g. `"MyStudio_{family}_{task}"` (example - not recommended in production). + +|Key|Description| +|---|---| +|family|Creators family| +|task|Task under which is creation triggered| +|variant|User input in creator tool| + +**Formatting keys have 3 variants with different letter capitalization.** + +|Task|Key variant|Description|Result| +|---|---|---|---| +|`bgAnim`|`{task}`|Keep original value as is.|`bgAnim`| +|`bgAnim`|`{Task}`|Capitalize first letter of value.|`BgAnim`| +|`bgAnim`|`{TASK}`|Each letter which be capitalized.|`BGANIM`| + +Template may look like `"{family}{Task}{Variant}"`. + +Some creators may have other keys as their context may require more information or more specific values. Make sure you've read documentation of host you're using. + + ## Workfiles All settings related to Workfile tool. diff --git a/website/sidebars.js b/website/sidebars.js index 59071ec34f..d38973e40f 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -90,7 +90,8 @@ module.exports = { "admin_hosts_maya", "admin_hosts_resolve", "admin_hosts_harmony", - "admin_hosts_aftereffects" + "admin_hosts_aftereffects", + "admin_hosts_tvpaint" ], }, { diff --git a/website/yarn.lock b/website/yarn.lock index 2d5ec103d4..a63bf37731 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2667,15 +2667,6 @@ cli-boxes@^2.2.1: resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== -clipboard@^2.0.0: - version "2.0.8" - resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba" - integrity sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ== - dependencies: - good-listener "^1.2.2" - select "^1.1.2" - tiny-emitter "^2.0.0" - cliui@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -3310,11 +3301,6 @@ del@^6.0.0: rimraf "^3.0.2" slash "^3.0.0" -delegate@^3.1.2: - version "3.2.0" - resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" - integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== - depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -4224,13 +4210,6 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" -good-listener@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" - integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= - dependencies: - delegate "^3.1.2" - got@^9.6.0: version "9.6.0" resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" @@ -6615,11 +6594,9 @@ prism-react-renderer@^1.1.1: integrity sha512-GHqzxLYImx1iKN1jJURcuRoA/0ygCcNhfGw1IT8nPIMzarmKQ3Nc+JcG0gi8JXQzuh0C5ShE4npMIoqNin40hg== prismjs@^1.23.0: - version "1.23.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" - integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== - optionalDependencies: - clipboard "^2.0.0" + version "1.24.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.24.0.tgz#0409c30068a6c52c89ef7f1089b3ca4de56be2ac" + integrity sha512-SqV5GRsNqnzCL8k5dfAjCNhUrF3pR0A9lTDSCUZeh/LIshheXJEaP0hwLz2t4XHivd2J/v2HR+gRnigzeKe3cQ== process-nextick-args@~2.0.0: version "2.0.1" @@ -7390,11 +7367,6 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -select@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" - integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= - selfsigned@^1.10.8: version "1.10.8" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.8.tgz#0d17208b7d12c33f8eac85c41835f27fc3d81a30" @@ -8016,11 +7988,6 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= -tiny-emitter@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" - integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== - tiny-invariant@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"