diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 82f9a6ae9d..60ce608b21 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -20,12 +20,12 @@ jobs: python-version: 3.7 - name: Install Python requirements - run: pip install gitpython semver + run: pip install gitpython semver PyGithub - name: πŸ”Ž Determine next version type id: version_type run: | - TYPE=$(python ./tools/ci_tools.py --bump) + TYPE=$(python ./tools/ci_tools.py --bump --github_token ${{ secrets.GITHUB_TOKEN }}) echo ::set-output name=type::$TYPE @@ -43,11 +43,7 @@ jobs: 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"]},"feature":{"prefix":"### πŸ†• New features","labels":["feature"]},}' + addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["type: documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]},"feature":{"prefix":"**πŸ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**πŸ’₯ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**πŸš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**πŸ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}}' issues: false issuesWoLabels: false sinceTag: "3.0.0" @@ -80,6 +76,7 @@ jobs: git add . git commit -m "[Automated] Bump version" tag_name="CI/${{ steps.version.outputs.next_tag }}" + echo $tag_name git tag -a $tag_name -m "nightly build" - name: Push to protected main branch diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37e1cb4b15..3f85525c26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: with: python-version: 3.7 - name: Install Python requirements - run: pip install gitpython semver + run: pip install gitpython semver PyGithub - name: πŸ’‰ Inject new version into files id: version @@ -39,11 +39,7 @@ jobs: 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"]}}' + addSections: '{"tests":{"prefix":"### βœ… Testing","labels":["tests"]},"feature":{"prefix":"**πŸ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**πŸ’₯ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**πŸš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**πŸ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]},"documentation":{"prefix":"### πŸ“– Documentation","labels":["type: documentation"]}}' issues: false issuesWoLabels: false sinceTag: "3.0.0" @@ -85,11 +81,7 @@ jobs: 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"]}}' + addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["type: documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]},"feature":{"prefix":"**πŸ†• New features**", "labels":["type: feature"]},"breaking":{"prefix":"**πŸ’₯ Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**πŸš€ Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**πŸ› Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}}' issues: false issuesWoLabels: false sinceTag: ${{ steps.version.outputs.last_release }} diff --git a/.gitmodules b/.gitmodules index 28f164726d..e1b0917e9d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,4 +9,4 @@ url = https://github.com/arrow-py/arrow.git [submodule "openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api"] path = openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api - url = https://bitbucket.org/ftrack/ftrack-python-api.git + url = https://bitbucket.org/ftrack/ftrack-python-api.git \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e1737458b2..99c994f13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,91 +1,150 @@ # Changelog -## [3.4.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.5.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.1...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.1...HEAD) + +**πŸ†• New features** + +- Maya: Validate setdress top group [\#2068](https://github.com/pypeclub/OpenPype/pull/2068) +- Maya: Enable publishing render attrib sets \(e.g. V-Ray Displacement\) with model [\#1955](https://github.com/pypeclub/OpenPype/pull/1955) + +**πŸš€ Enhancements** + +- Project manager: Filter first item after selection of project [\#2069](https://github.com/pypeclub/OpenPype/pull/2069) +- Tools: add support for pyenv on windows [\#2051](https://github.com/pypeclub/OpenPype/pull/2051) + +**πŸ› Bug fixes** + +- Fix Sync Queue when project disabled [\#2063](https://github.com/pypeclub/OpenPype/pull/2063) +- TVPaint: Fixed rendered frame indexes [\#1946](https://github.com/pypeclub/OpenPype/pull/1946) + +## [3.4.1](https://github.com/pypeclub/OpenPype/tree/3.4.1) (2021-09-23) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.1-nightly.1...3.4.1) + +**πŸ†• New features** + +- Settings: Flag project as deactivated and hide from tools' view [\#2008](https://github.com/pypeclub/OpenPype/pull/2008) + +**πŸš€ Enhancements** + +- General: Startup validations [\#2054](https://github.com/pypeclub/OpenPype/pull/2054) +- Nuke: proxy mode validator [\#2052](https://github.com/pypeclub/OpenPype/pull/2052) +- Ftrack: Removed ftrack interface [\#2049](https://github.com/pypeclub/OpenPype/pull/2049) +- Settings UI: Deffered set value on entity [\#2044](https://github.com/pypeclub/OpenPype/pull/2044) +- Loader: Families filtering [\#2043](https://github.com/pypeclub/OpenPype/pull/2043) +- Settings UI: Project view enhancements [\#2042](https://github.com/pypeclub/OpenPype/pull/2042) +- Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039) +- Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038) +- Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030) +- WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028) +- TimersManager: Removed interface of timers manager [\#2024](https://github.com/pypeclub/OpenPype/pull/2024) +- Feature Maya import asset from scene inventory [\#2018](https://github.com/pypeclub/OpenPype/pull/2018) + +**πŸ› Bug fixes** + +- Timers manger: Typo fix [\#2058](https://github.com/pypeclub/OpenPype/pull/2058) +- Hiero: Editorial fixes [\#2057](https://github.com/pypeclub/OpenPype/pull/2057) +- Differentiate jpg sequences from thumbnail [\#2056](https://github.com/pypeclub/OpenPype/pull/2056) +- FFmpeg: Split command to list does not work [\#2046](https://github.com/pypeclub/OpenPype/pull/2046) +- Removed shell flag in subprocess call [\#2045](https://github.com/pypeclub/OpenPype/pull/2045) **Merged pull requests:** -- Ftrack: Fix hosts attribute in collect ftrack username [\#1972](https://github.com/pypeclub/OpenPype/pull/1972) -- Removed deprecated submodules [\#1967](https://github.com/pypeclub/OpenPype/pull/1967) +- Bump prismjs from 1.24.0 to 1.25.0 in /website [\#2050](https://github.com/pypeclub/OpenPype/pull/2050) + +## [3.4.0](https://github.com/pypeclub/OpenPype/tree/3.4.0) (2021-09-17) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.0-nightly.6...3.4.0) + +### πŸ“– Documentation + +- Documentation: Ftrack launch argsuments update [\#2014](https://github.com/pypeclub/OpenPype/pull/2014) +- Nuke Quick Start / Tutorial [\#1952](https://github.com/pypeclub/OpenPype/pull/1952) + +**πŸ†• New features** + +- Nuke: Compatibility with Nuke 13 [\#2003](https://github.com/pypeclub/OpenPype/pull/2003) + +**πŸš€ Enhancements** + +- Added possibility to configure of synchronization of workfile version… [\#2041](https://github.com/pypeclub/OpenPype/pull/2041) +- General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036) +- Console interpreter: Handle invalid sizes on initialization [\#2022](https://github.com/pypeclub/OpenPype/pull/2022) +- Ftrack: Show OpenPype versions in event server status [\#2019](https://github.com/pypeclub/OpenPype/pull/2019) +- General: Staging icon [\#2017](https://github.com/pypeclub/OpenPype/pull/2017) +- Ftrack: Sync to avalon actions have jobs [\#2015](https://github.com/pypeclub/OpenPype/pull/2015) +- Modules: Connect method is not required [\#2009](https://github.com/pypeclub/OpenPype/pull/2009) +- Settings UI: Number with configurable steps [\#2001](https://github.com/pypeclub/OpenPype/pull/2001) +- Moving project folder structure creation out of ftrack module \#1989 [\#1996](https://github.com/pypeclub/OpenPype/pull/1996) +- Configurable items for providers without Settings [\#1987](https://github.com/pypeclub/OpenPype/pull/1987) +- Global: Example addons [\#1986](https://github.com/pypeclub/OpenPype/pull/1986) +- Standalone Publisher: Extract harmony zip handle workfile template [\#1982](https://github.com/pypeclub/OpenPype/pull/1982) +- Settings UI: Number sliders [\#1978](https://github.com/pypeclub/OpenPype/pull/1978) +- Workfiles: Support more workfile templates [\#1966](https://github.com/pypeclub/OpenPype/pull/1966) - Launcher: Fix crashes on action click [\#1964](https://github.com/pypeclub/OpenPype/pull/1964) - Settings: Minor fixes in UI and missing default values [\#1963](https://github.com/pypeclub/OpenPype/pull/1963) - Blender: Toggle system console works on windows [\#1962](https://github.com/pypeclub/OpenPype/pull/1962) -- Resolve path when adding to zip [\#1960](https://github.com/pypeclub/OpenPype/pull/1960) -- Bump url-parse from 1.5.1 to 1.5.3 in /website [\#1958](https://github.com/pypeclub/OpenPype/pull/1958) +- Global: Settings defined by Addons/Modules [\#1959](https://github.com/pypeclub/OpenPype/pull/1959) +- CI: change release numbering triggers [\#1954](https://github.com/pypeclub/OpenPype/pull/1954) - Global: Avalon Host name collector [\#1949](https://github.com/pypeclub/OpenPype/pull/1949) -- Global: Define hosts in CollectSceneVersion [\#1948](https://github.com/pypeclub/OpenPype/pull/1948) -- Maya: Add Xgen family support [\#1947](https://github.com/pypeclub/OpenPype/pull/1947) -- Add face sets to exported alembics [\#1942](https://github.com/pypeclub/OpenPype/pull/1942) -- Bump path-parse from 1.0.6 to 1.0.7 in /website [\#1933](https://github.com/pypeclub/OpenPype/pull/1933) -- \#1894 - adds host to template\_name\_profiles for filtering [\#1915](https://github.com/pypeclub/OpenPype/pull/1915) -- Environments: Tool environments in alphabetical order [\#1910](https://github.com/pypeclub/OpenPype/pull/1910) -- Disregard publishing time. [\#1888](https://github.com/pypeclub/OpenPype/pull/1888) -- Feature/webpublisher backend [\#1876](https://github.com/pypeclub/OpenPype/pull/1876) -- Dynamic modules [\#1872](https://github.com/pypeclub/OpenPype/pull/1872) -- Houdini: add Camera, Point Cache, Composite, Redshift ROP and VDB Cache support [\#1821](https://github.com/pypeclub/OpenPype/pull/1821) + +**πŸ› Bug fixes** + +- Workfiles tool: Task selection [\#2040](https://github.com/pypeclub/OpenPype/pull/2040) +- Ftrack: Delete old versions missing settings key [\#2037](https://github.com/pypeclub/OpenPype/pull/2037) +- Nuke: typo on a button [\#2034](https://github.com/pypeclub/OpenPype/pull/2034) +- Hiero: Fix "none" named tags [\#2033](https://github.com/pypeclub/OpenPype/pull/2033) +- FFmpeg: Subprocess arguments as list [\#2032](https://github.com/pypeclub/OpenPype/pull/2032) +- General: Fix Python 2 breaking line [\#2016](https://github.com/pypeclub/OpenPype/pull/2016) +- Bugfix/webpublisher task type [\#2006](https://github.com/pypeclub/OpenPype/pull/2006) +- Nuke thumbnails generated from middle of the sequence [\#1992](https://github.com/pypeclub/OpenPype/pull/1992) +- Nuke: last version from path gets correct version [\#1990](https://github.com/pypeclub/OpenPype/pull/1990) +- nuke, resolve, hiero: precollector order lest then 0.5 [\#1984](https://github.com/pypeclub/OpenPype/pull/1984) +- Last workfile with multiple work templates [\#1981](https://github.com/pypeclub/OpenPype/pull/1981) +- Collectors order [\#1977](https://github.com/pypeclub/OpenPype/pull/1977) +- Stop timer was within validator order range. [\#1975](https://github.com/pypeclub/OpenPype/pull/1975) +- Ftrack: arrow submodule has https url source [\#1974](https://github.com/pypeclub/OpenPype/pull/1974) +- Ftrack: Fix hosts attribute in collect ftrack username [\#1972](https://github.com/pypeclub/OpenPype/pull/1972) +- Deadline: Houdini plugins in different hierarchy [\#1970](https://github.com/pypeclub/OpenPype/pull/1970) +- Removed deprecated submodules [\#1967](https://github.com/pypeclub/OpenPype/pull/1967) +- Global: ExtractJpeg can handle filepaths with spaces [\#1961](https://github.com/pypeclub/OpenPype/pull/1961) ## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.3.1-nightly.1...3.3.1) -**Merged pull requests:** +**πŸš€ Enhancements** + +- OpenPype: Add version validation and `--headless` mode and update progress πŸ”„ [\#1939](https://github.com/pypeclub/OpenPype/pull/1939) + +**πŸ› Bug fixes** -- TVPaint: Fixed rendered frame indexes [\#1946](https://github.com/pypeclub/OpenPype/pull/1946) - Maya: Menu actions fix [\#1945](https://github.com/pypeclub/OpenPype/pull/1945) - standalone: editorial shared object problem [\#1941](https://github.com/pypeclub/OpenPype/pull/1941) -- Bugfix nuke deadline app name [\#1928](https://github.com/pypeclub/OpenPype/pull/1928) ## [3.3.0](https://github.com/pypeclub/OpenPype/tree/3.3.0) (2021-08-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.3.0-nightly.11...3.3.0) -**Merged pull requests:** +**πŸ†• New features** + +- Settings UI: Breadcrumbs in settings [\#1932](https://github.com/pypeclub/OpenPype/pull/1932) + +**πŸš€ Enhancements** - Python console interpreter [\#1940](https://github.com/pypeclub/OpenPype/pull/1940) -- Fix - make AE workfile publish to Ftrack configurable [\#1937](https://github.com/pypeclub/OpenPype/pull/1937) + +**πŸ› Bug fixes** + - Fix - ftrack family was added incorrectly in some cases [\#1935](https://github.com/pypeclub/OpenPype/pull/1935) -- Settings UI: Breadcrumbs in settings [\#1932](https://github.com/pypeclub/OpenPype/pull/1932) - Fix - Deadline publish on Linux started Tray instead of headless publishing [\#1930](https://github.com/pypeclub/OpenPype/pull/1930) - Maya: Validate Model Name - repair accident deletion in settings defaults [\#1929](https://github.com/pypeclub/OpenPype/pull/1929) -- Global: Updated logos and Default settings [\#1927](https://github.com/pypeclub/OpenPype/pull/1927) -- Nuke: submit to farm failed due `ftrack` family remove [\#1926](https://github.com/pypeclub/OpenPype/pull/1926) -- Check for missing ✨ Python when using `pyenv` [\#1925](https://github.com/pypeclub/OpenPype/pull/1925) -- Maya: Scene patching 🩹on submission to Deadline [\#1923](https://github.com/pypeclub/OpenPype/pull/1923) -- Fix - validate takes repre\["files"\] as list all the time [\#1922](https://github.com/pypeclub/OpenPype/pull/1922) -- Settings: Default values for enum [\#1920](https://github.com/pypeclub/OpenPype/pull/1920) -- Settings UI: Modifiable dict view enhance [\#1919](https://github.com/pypeclub/OpenPype/pull/1919) -- standalone: validator asset parents [\#1917](https://github.com/pypeclub/OpenPype/pull/1917) -- Nuke: update video file crassing [\#1916](https://github.com/pypeclub/OpenPype/pull/1916) -- Fix - texture validators for workfiles triggers only for textures workfiles [\#1914](https://github.com/pypeclub/OpenPype/pull/1914) -- submodules: avalon-core update [\#1911](https://github.com/pypeclub/OpenPype/pull/1911) -- Settings UI: List order works as expected [\#1906](https://github.com/pypeclub/OpenPype/pull/1906) -- Add support for multiple Deadline β˜ οΈβž– servers [\#1905](https://github.com/pypeclub/OpenPype/pull/1905) -- Hiero: loaded clip was not set colorspace from version data [\#1904](https://github.com/pypeclub/OpenPype/pull/1904) -- Pyblish UI: Fix collecting stage processing [\#1903](https://github.com/pypeclub/OpenPype/pull/1903) -- Burnins: Use input's bitrate in h624 [\#1902](https://github.com/pypeclub/OpenPype/pull/1902) -- Feature AE local render [\#1901](https://github.com/pypeclub/OpenPype/pull/1901) -- Ftrack: Where I run action enhancement [\#1900](https://github.com/pypeclub/OpenPype/pull/1900) -- Ftrack: Private project server actions [\#1899](https://github.com/pypeclub/OpenPype/pull/1899) -- Support nested studio plugins paths. [\#1898](https://github.com/pypeclub/OpenPype/pull/1898) -- Bug: fixed python detection [\#1893](https://github.com/pypeclub/OpenPype/pull/1893) -- Settings: global validators with options [\#1892](https://github.com/pypeclub/OpenPype/pull/1892) -- Settings: Conditional dict enum positioning [\#1891](https://github.com/pypeclub/OpenPype/pull/1891) -- global: integrate name missing default template [\#1890](https://github.com/pypeclub/OpenPype/pull/1890) -- publisher: editorial plugins fixes [\#1889](https://github.com/pypeclub/OpenPype/pull/1889) -- Expose stop timer through rest api. [\#1886](https://github.com/pypeclub/OpenPype/pull/1886) -- TVPaint: Increment workfile [\#1885](https://github.com/pypeclub/OpenPype/pull/1885) -- Allow Multiple Notes to run on tasks. [\#1882](https://github.com/pypeclub/OpenPype/pull/1882) -- Normalize path returned from Workfiles. [\#1880](https://github.com/pypeclub/OpenPype/pull/1880) -- Prepare for pyside2 [\#1869](https://github.com/pypeclub/OpenPype/pull/1869) -- Filter hosts in settings host-enum [\#1868](https://github.com/pypeclub/OpenPype/pull/1868) -- Local actions with process identifier [\#1867](https://github.com/pypeclub/OpenPype/pull/1867) -- Workfile tool start at host launch support [\#1865](https://github.com/pypeclub/OpenPype/pull/1865) -- Maya: add support for `RedshiftNormalMap` node, fix `tx` linear space πŸš€ [\#1863](https://github.com/pypeclub/OpenPype/pull/1863) -- Workfiles tool event arguments fix [\#1862](https://github.com/pypeclub/OpenPype/pull/1862) -- Maya: support for configurable `dirmap` πŸ—ΊοΈ [\#1859](https://github.com/pypeclub/OpenPype/pull/1859) -- Maya: don't add reference members as connections to the container set πŸ“¦ [\#1855](https://github.com/pypeclub/OpenPype/pull/1855) -- Settings list can use template or schema as object type [\#1815](https://github.com/pypeclub/OpenPype/pull/1815) + +**Merged pull requests:** + +- Fix - make AE workfile publish to Ftrack configurable [\#1937](https://github.com/pypeclub/OpenPype/pull/1937) ## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13) diff --git a/igniter/__init__.py b/igniter/__init__.py index 20bf9be106..defd45e233 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -12,6 +12,9 @@ from .version import __version__ as version def open_dialog(): """Show Igniter dialog.""" + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) from Qt import QtWidgets, QtCore from .install_dialog import InstallDialog @@ -28,8 +31,31 @@ def open_dialog(): return d.result() +def open_update_window(openpype_version): + """Open update window.""" + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) + from Qt import QtWidgets, QtCore + from .update_window import UpdateWindow + + scale_attr = getattr(QtCore.Qt, "AA_EnableHighDpiScaling", None) + if scale_attr is not None: + QtWidgets.QApplication.setAttribute(scale_attr) + + app = QtWidgets.QApplication(sys.argv) + + d = UpdateWindow(version=openpype_version) + d.open() + + app.exec_() + version_path = d.get_version_path() + return version_path + + __all__ = [ "BootstrapRepos", "open_dialog", + "open_update_window", "version" ] diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index b49a2f6e7f..f7f35824c8 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -9,6 +9,7 @@ import sys import tempfile from pathlib import Path from typing import Union, Callable, List, Tuple +import hashlib from zipfile import ZipFile, BadZipFile @@ -28,6 +29,25 @@ LOG_WARNING = 1 LOG_ERROR = 3 +def sha256sum(filename): + """Calculate sha256 for content of the file. + + Args: + filename (str): Path to file. + + Returns: + str: hex encoded sha256 + + """ + h = hashlib.sha256() + b = bytearray(128 * 1024) + mv = memoryview(b) + with open(filename, 'rb', buffering=0) as f: + for n in iter(lambda: f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() + + class OpenPypeVersion(semver.VersionInfo): """Class for storing information about OpenPype version. @@ -261,7 +281,8 @@ class BootstrapRepos: self.live_repo_dir = Path(Path(__file__).parent / ".." / "repos") @staticmethod - def get_version_path_from_list(version: str, version_list: list) -> Path: + def get_version_path_from_list( + version: str, version_list: list) -> Union[Path, None]: """Get path for specific version in list of OpenPype versions. Args: @@ -275,6 +296,7 @@ class BootstrapRepos: for v in version_list: if str(v) == version: return v.path + return None @staticmethod def get_local_live_version() -> str: @@ -487,6 +509,7 @@ class BootstrapRepos: openpype_root = openpype_path.resolve() # generate list of filtered paths dir_filter = [openpype_root / f for f in self.openpype_filter] + checksums = [] file: Path for file in openpype_list: @@ -508,12 +531,119 @@ class BootstrapRepos: processed_path = file self._print(f"- processing {processed_path}") - zip_file.write(file, file.resolve().relative_to(openpype_root)) + checksums.append( + ( + sha256sum(file.as_posix()), + file.resolve().relative_to(openpype_root) + ) + ) + zip_file.write( + file, file.resolve().relative_to(openpype_root)) + checksums_str = "" + for c in checksums: + checksums_str += "{}:{}\n".format(c[0], c[1]) + zip_file.writestr("checksums", checksums_str) # test if zip is ok zip_file.testzip() self._progress_callback(100) + def validate_openpype_version(self, path: Path) -> tuple: + """Validate version directory or zip file. + + This will load `checksums` file if present, calculate checksums + of existing files in given path and compare. It will also compare + lists of files together for missing files. + + Args: + path (Path): Path to OpenPype version to validate. + + Returns: + tuple(bool, str): with version validity as first item + and string with reason as second. + + """ + if not path.exists(): + return False, "Path doesn't exist" + + if path.is_file(): + return self._validate_zip(path) + return self._validate_dir(path) + + @staticmethod + def _validate_zip(path: Path) -> tuple: + """Validate content of zip file.""" + with ZipFile(path, "r") as zip_file: + # read checksums + try: + checksums_data = str(zip_file.read("checksums")) + except IOError: + # FIXME: This should be set to False sometimes in the future + return True, "Cannot read checksums for archive." + + # split it to the list of tuples + checksums = [ + tuple(line.split(":")) + for line in checksums_data.split("\n") if line + ] + + # calculate and compare checksums in the zip file + for file in checksums: + h = hashlib.sha256() + try: + h.update(zip_file.read(file[1])) + except FileNotFoundError: + return False, f"Missing file [ {file[1]} ]" + if h.hexdigest() != file[0]: + return False, f"Invalid checksum on {file[1]}" + + # get list of files in zip minus `checksums` file itself + # and turn in to set to compare against list of files + # from checksum file. If difference exists, something is + # wrong + files_in_zip = zip_file.namelist() + files_in_zip.remove("checksums") + files_in_zip = set(files_in_zip) + files_in_checksum = set([file[1] for file in checksums]) + diff = files_in_zip.difference(files_in_checksum) + if diff: + return False, f"Missing files {diff}" + + return True, "All ok" + + @staticmethod + def _validate_dir(path: Path) -> tuple: + checksums_file = Path(path / "checksums") + if not checksums_file.exists(): + # FIXME: This should be set to False sometimes in the future + return True, "Cannot read checksums for archive." + checksums_data = checksums_file.read_text() + checksums = [ + tuple(line.split(":")) + for line in checksums_data.split("\n") if line + ] + files_in_dir = [ + file.relative_to(path).as_posix() + for file in path.iterdir() if file.is_file() + ] + files_in_dir.remove("checksums") + files_in_dir = set(files_in_dir) + files_in_checksum = set([file[1] for file in checksums]) + + for file in checksums: + try: + current = sha256sum((path / file[1]).as_posix()) + except FileNotFoundError: + return False, f"Missing file [ {file[1]} ]" + + if file[0] != current: + return False, f"Invalid checksum on {file[1]}" + diff = files_in_dir.difference(files_in_checksum) + if diff: + return False, f"Missing files {diff}" + + return True, "All ok" + @staticmethod def add_paths_from_archive(archive: Path) -> None: """Add first-level directory and 'repos' as paths to :mod:`sys.path`. @@ -837,6 +967,7 @@ class BootstrapRepos: # test if destination directory already exist, if so lets delete it. if destination.exists() and force: + self._print("removing existing directory") try: shutil.rmtree(destination) except OSError as e: @@ -846,6 +977,7 @@ class BootstrapRepos: raise OpenPypeVersionIOError( f"cannot remove existing {destination}") from e elif destination.exists() and not force: + self._print("destination directory already exists") raise OpenPypeVersionExists(f"{destination} already exist.") else: # create destination parent directories even if they don't exist. @@ -855,6 +987,7 @@ class BootstrapRepos: if openpype_version.path.is_dir(): # create zip inside temporary directory. self._print("Creating zip from directory ...") + self._progress_callback(0) with tempfile.TemporaryDirectory() as temp_dir: temp_zip = \ Path(temp_dir) / f"openpype-v{openpype_version}.zip" @@ -880,13 +1013,16 @@ class BootstrapRepos: raise OpenPypeVersionInvalid("Invalid file format") if not self.is_inside_user_data(openpype_version.path): + self._progress_callback(35) openpype_version.path = self._copy_zip( openpype_version.path, destination) # extract zip there self._print("extracting zip to destination ...") with ZipFile(openpype_version.path, "r") as zip_ref: + self._progress_callback(75) zip_ref.extractall(destination) + self._progress_callback(100) return destination diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 1ec8cc6768..1fe67e3397 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -14,21 +14,13 @@ from .tools import ( validate_mongo_connection, get_openpype_path_from_db ) + +from .nice_progress_bar import NiceProgressBar from .user_settings import OpenPypeSecureRegistry +from .tools import load_stylesheet from .version import __version__ -def load_stylesheet(): - stylesheet_path = os.path.join( - os.path.dirname(__file__), - "stylesheet.css" - ) - with open(stylesheet_path, "r") as file_stream: - stylesheet = file_stream.read() - - return stylesheet - - class ButtonWithOptions(QtWidgets.QFrame): option_clicked = QtCore.Signal(str) @@ -91,25 +83,6 @@ class ButtonWithOptions(QtWidgets.QFrame): self.option_clicked.emit(self._default_value) -class NiceProgressBar(QtWidgets.QProgressBar): - def __init__(self, parent=None): - super(NiceProgressBar, self).__init__(parent) - self._real_value = 0 - - def setValue(self, value): - self._real_value = value - if value != 0 and value < 11: - value = 11 - - super(NiceProgressBar, self).setValue(value) - - def value(self): - return self._real_value - - def text(self): - return "{} %".format(self._real_value) - - class ConsoleWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(ConsoleWidget, self).__init__(parent) diff --git a/igniter/nice_progress_bar.py b/igniter/nice_progress_bar.py new file mode 100644 index 0000000000..47d695a101 --- /dev/null +++ b/igniter/nice_progress_bar.py @@ -0,0 +1,20 @@ +from Qt import QtCore, QtGui, QtWidgets # noqa + + +class NiceProgressBar(QtWidgets.QProgressBar): + def __init__(self, parent=None): + super(NiceProgressBar, self).__init__(parent) + self._real_value = 0 + + def setValue(self, value): + self._real_value = value + if value != 0 and value < 11: + value = 11 + + super(NiceProgressBar, self).setValue(value) + + def value(self): + return self._real_value + + def text(self): + return "{} %".format(self._real_value) diff --git a/igniter/tools.py b/igniter/tools.py index 529d535c25..c934289064 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -248,3 +248,15 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: if os.path.exists(path): return path return None + + +def load_stylesheet() -> str: + """Load css style sheet. + + Returns: + str: content of the stylesheet + + """ + stylesheet_path = Path(__file__).parent.resolve() / "stylesheet.css" + + return stylesheet_path.read_text() diff --git a/igniter/update_thread.py b/igniter/update_thread.py new file mode 100644 index 0000000000..f4fc729faf --- /dev/null +++ b/igniter/update_thread.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Working thread for update.""" +from Qt.QtCore import QThread, Signal, QObject # noqa + +from .bootstrap_repos import ( + BootstrapRepos, + OpenPypeVersion +) + + +class UpdateThread(QThread): + """Install Worker thread. + + This class takes care of finding OpenPype version on user entered path + (or loading this path from database). If nothing is entered by user, + OpenPype will create its zip files from repositories that comes with it. + + If path contains plain repositories, they are zipped and installed to + user data dir. + + """ + progress = Signal(int) + message = Signal((str, bool)) + + def __init__(self, parent=None): + self._result = None + self._openpype_version = None + QThread.__init__(self, parent) + + def set_version(self, openpype_version: OpenPypeVersion): + self._openpype_version = openpype_version + + def result(self): + """Result of finished installation.""" + return self._result + + def _set_result(self, value): + if self._result is not None: + raise AssertionError("BUG: Result was set more than once!") + self._result = value + + def run(self): + """Thread entry point. + + Using :class:`BootstrapRepos` to either install OpenPype as zip files + or copy them from location specified by user or retrieved from + database. + """ + bs = BootstrapRepos( + progress_callback=self.set_progress, message=self.message) + version_path = bs.install_version(self._openpype_version) + self._set_result(version_path) + + def set_progress(self, progress: int) -> None: + """Helper to set progress bar. + + Args: + progress (int): Progress in percents. + + """ + self.progress.emit(progress) diff --git a/igniter/update_window.py b/igniter/update_window.py new file mode 100644 index 0000000000..d7908c240b --- /dev/null +++ b/igniter/update_window.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""Progress window to show when OpenPype is updating/installing locally.""" +import os +from .update_thread import UpdateThread +from Qt import QtCore, QtGui, QtWidgets # noqa +from .bootstrap_repos import OpenPypeVersion +from .nice_progress_bar import NiceProgressBar +from .tools import load_stylesheet + + +class UpdateWindow(QtWidgets.QDialog): + """OpenPype update window.""" + + _width = 500 + _height = 100 + + def __init__(self, version: OpenPypeVersion, parent=None): + super(UpdateWindow, self).__init__(parent) + self._openpype_version = version + self._result_version_path = None + + self.setWindowTitle( + f"OpenPype is updating ..." + ) + self.setModal(True) + self.setWindowFlags( + QtCore.Qt.WindowMinimizeButtonHint + ) + + current_dir = os.path.dirname(os.path.abspath(__file__)) + roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf") + poppins_font_path = os.path.join(current_dir, "Poppins") + icon_path = os.path.join(current_dir, "openpype_icon.png") + + # Install roboto font + QtGui.QFontDatabase.addApplicationFont(roboto_font_path) + for filename in os.listdir(poppins_font_path): + if os.path.splitext(filename)[1] == ".ttf": + QtGui.QFontDatabase.addApplicationFont(filename) + + # Load logo + pixmap_openpype_logo = QtGui.QPixmap(icon_path) + # Set logo as icon of window + self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) + + self._pixmap_openpype_logo = pixmap_openpype_logo + + self._update_thread = None + + self.resize(QtCore.QSize(self._width, self._height)) + self._init_ui() + + # Set stylesheet + self.setStyleSheet(load_stylesheet()) + self._run_update() + + def _init_ui(self): + + # Main info + # -------------------------------------------------------------------- + main_label = QtWidgets.QLabel( + f"OpenPype is updating to {self._openpype_version}", self) + main_label.setWordWrap(True) + main_label.setObjectName("MainLabel") + + # Progress bar + # -------------------------------------------------------------------- + progress_bar = NiceProgressBar(self) + progress_bar.setAlignment(QtCore.Qt.AlignCenter) + progress_bar.setTextVisible(False) + + # add all to main + main = QtWidgets.QVBoxLayout(self) + main.addSpacing(15) + main.addWidget(main_label, 0) + main.addSpacing(15) + main.addWidget(progress_bar, 0) + main.addSpacing(15) + + self._progress_bar = progress_bar + + def _run_update(self): + """Start install process. + + This will once again validate entered path and mongo if ok, start + working thread that will do actual job. + """ + # Check if install thread is not already running + if self._update_thread and self._update_thread.isRunning(): + return + self._progress_bar.setRange(0, 0) + update_thread = UpdateThread(self) + update_thread.set_version(self._openpype_version) + update_thread.message.connect(self.update_console) + update_thread.progress.connect(self._update_progress) + update_thread.finished.connect(self._installation_finished) + + self._update_thread = update_thread + + update_thread.start() + + def get_version_path(self): + return self._result_version_path + + def _installation_finished(self): + status = self._update_thread.result() + self._result_version_path = status + self._progress_bar.setRange(0, 1) + self._update_progress(100) + QtWidgets.QApplication.processEvents() + self.done(0) + + def _update_progress(self, progress: int): + # not updating progress as we are not able to determine it + # correctly now. Progress bar is set to un-deterministic mode + # until we are able to get progress in better way. + """ + self._progress_bar.setRange(0, 0) + self._progress_bar.setValue(progress) + text_visible = self._progress_bar.isTextVisible() + if progress == 0: + if text_visible: + self._progress_bar.setTextVisible(False) + elif not text_visible: + self._progress_bar.setTextVisible(True) + """ + return + + def update_console(self, msg: str, error: bool = False) -> None: + """Display message in console. + + Args: + msg (str): message. + error (bool): if True, print it red. + """ + print(msg) diff --git a/openpype/api.py b/openpype/api.py index ce18097eca..e4bbb104a3 100644 --- a/openpype/api.py +++ b/openpype/api.py @@ -24,7 +24,9 @@ from .lib import ( get_latest_version, get_global_environments, get_local_site_id, - change_openpype_mongo_url + change_openpype_mongo_url, + create_project_folders, + get_project_basic_paths ) from .lib.mongo import ( @@ -72,6 +74,7 @@ __all__ = [ "get_current_project_settings", "get_anatomy_settings", "get_environments", + "get_project_basic_paths", "SystemSettings", @@ -120,5 +123,9 @@ __all__ = [ "get_global_environments", "get_local_site_id", - "change_openpype_mongo_url" + "change_openpype_mongo_url", + + "get_project_basic_paths", + "create_project_folders" + ] diff --git a/openpype/cli.py b/openpype/cli.py index c446d5e443..18cc1c63cd 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -18,6 +18,8 @@ from .pype_commands import PypeCommands @click.option("--list-versions", is_flag=True, expose_value=False, help=("list all detected versions. Use With `--use-staging " "to list staging versions.")) +@click.option("--validate-version", expose_value=False, + help="validate given version integrity") def main(ctx): """Pype is main command serving as entry point to pipeline system. diff --git a/openpype/hosts/fusion/api/menu.py b/openpype/hosts/fusion/api/menu.py index 3f04bf839b..9093aa9e5e 100644 --- a/openpype/hosts/fusion/api/menu.py +++ b/openpype/hosts/fusion/api/menu.py @@ -10,8 +10,10 @@ from .pipeline import ( from avalon.tools import ( creator, - loader, sceneinventory, +) +from openpype.tools import ( + loader, libraryloader ) diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 0095530087..688e75f6fe 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -3,7 +3,7 @@ Basic avalon integration """ import os -from avalon.tools import workfiles +from openpype.tools import workfiles from avalon import api as avalon from pyblish import api as pyblish from openpype.api import Logger diff --git a/openpype/hosts/harmony/plugins/publish/extract_render.py b/openpype/hosts/harmony/plugins/publish/extract_render.py index 8374a9427a..827b03443c 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_render.py +++ b/openpype/hosts/harmony/plugins/publish/extract_render.py @@ -91,7 +91,8 @@ class ExtractRender(pyblish.api.InstancePlugin): thumbnail_path = os.path.join(path, "thumbnail.png") ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") args = [ - "{}".format(ffmpeg_path), "-y", + ffmpeg_path, + "-y", "-i", os.path.join(path, list(collections[0])[0]), "-vf", "scale=300:-1", "-vframes", "1", diff --git a/openpype/hosts/hiero/api/menu.py b/openpype/hosts/hiero/api/menu.py index ab49251093..bcd78aa5bb 100644 --- a/openpype/hosts/hiero/api/menu.py +++ b/openpype/hosts/hiero/api/menu.py @@ -41,7 +41,8 @@ def menu_install(): apply_colorspace_project, apply_colorspace_clips ) # here is the best place to add menu - from avalon.tools import cbloader, creator, sceneinventory + from avalon.tools import creator, sceneinventory + from openpype.tools import loader from avalon.vendor.Qt import QtGui menu_name = os.environ['AVALON_LABEL'] @@ -90,7 +91,7 @@ def menu_install(): loader_action = menu.addAction("Load ...") loader_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) - loader_action.triggered.connect(cbloader.show) + loader_action.triggered.connect(loader.show) sceneinventory_action = menu.addAction("Manage ...") sceneinventory_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index ab7e2bdabf..12f6923de7 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -4,10 +4,8 @@ Basic avalon integration import os import contextlib from collections import OrderedDict -from avalon.tools import ( - workfiles, - publish as _publish -) +from avalon.tools import publish as _publish +from openpype.tools import workfiles from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api as avalon from avalon import schema diff --git a/openpype/hosts/hiero/api/tags.py b/openpype/hosts/hiero/api/tags.py index d2502f3c71..68f8d35106 100644 --- a/openpype/hosts/hiero/api/tags.py +++ b/openpype/hosts/hiero/api/tags.py @@ -10,16 +10,16 @@ log = Logger().get_logger(__name__) def tag_data(): return { - "Retiming": { - "editable": "1", - "note": "Clip has retime or TimeWarp effects (or multiple effects stacked on the clip)", # noqa - "icon": "retiming.png", - "metadata": { - "family": "retiming", - "marginIn": 1, - "marginOut": 1 - } - }, + # "Retiming": { + # "editable": "1", + # "note": "Clip has retime or TimeWarp effects (or multiple effects stacked on the clip)", # noqa + # "icon": "retiming.png", + # "metadata": { + # "family": "retiming", + # "marginIn": 1, + # "marginOut": 1 + # } + # }, "[Lenses]": { "Set lense here": { "editable": "1", @@ -31,15 +31,15 @@ def tag_data(): } } }, - "NukeScript": { - "editable": "1", - "note": "Collecting track items to Nuke scripts.", - "icon": "icons:TagNuke.png", - "metadata": { - "family": "nukescript", - "subset": "main" - } - }, + # "NukeScript": { + # "editable": "1", + # "note": "Collecting track items to Nuke scripts.", + # "icon": "icons:TagNuke.png", + # "metadata": { + # "family": "nukescript", + # "subset": "main" + # } + # }, "Comment": { "editable": "1", "note": "Comment on a shot.", @@ -78,8 +78,7 @@ def update_tag(tag, data): # set icon if any available in input data if data.get("icon"): tag.setIcon(str(data["icon"])) - # set note description of tag - tag.setNote(data["note"]) + # get metadata of tag mtd = tag.metadata() # get metadata key from data @@ -97,6 +96,9 @@ def update_tag(tag, data): "tag.{}".format(str(k)), str(v) ) + + # set note description of tag + tag.setNote(str(data["note"])) return tag @@ -106,6 +108,26 @@ def add_tags_to_workfile(): """ from .lib import get_current_project + def add_tag_to_bin(root_bin, name, data): + # for Tags to be created in root level Bin + # at first check if any of input data tag is not already created + done_tag = next((t for t in root_bin.items() + if str(name) in t.name()), None) + + if not done_tag: + # create Tag + tag = create_tag(name, data) + tag.setName(str(name)) + + log.debug("__ creating tag: {}".format(tag)) + # adding Tag to Root Bin + root_bin.addItem(tag) + else: + # update only non hierarchy tags + update_tag(done_tag, data) + done_tag.setName(str(name)) + log.debug("__ updating tag: {}".format(done_tag)) + # get project and root bin object project = get_current_project() root_bin = project.tagsBin() @@ -125,10 +147,8 @@ def add_tags_to_workfile(): for task_type in tasks.keys(): nks_pres_tags["[Tasks]"][task_type.lower()] = { "editable": "1", - "note": "", - "icon": { - "path": "icons:TagGood.png" - }, + "note": task_type, + "icon": "icons:TagGood.png", "metadata": { "family": "task", "type": task_type @@ -157,10 +177,10 @@ def add_tags_to_workfile(): # check if key is not decorated with [] so it is defined as bin bin_find = None pattern = re.compile(r"\[(.*)\]") - bin_finds = pattern.findall(_k) + _bin_finds = pattern.findall(_k) # if there is available any then pop it to string - if bin_finds: - bin_find = bin_finds.pop() + if _bin_finds: + bin_find = _bin_finds.pop() # if bin was found then create or update if bin_find: @@ -168,7 +188,6 @@ def add_tags_to_workfile(): # first check if in root lever is not already created bins bins = [b for b in root_bin.items() if b.name() in str(bin_find)] - log.debug(">>> bins: {}".format(bins)) if bins: bin = bins.pop() @@ -178,49 +197,14 @@ def add_tags_to_workfile(): bin = hiero.core.Bin(str(bin_find)) # update or create tags in the bin - for k, v in _val.items(): - tags = [t for t in bin.items() - if str(k) in t.name() - if len(str(k)) == len(t.name())] - if not tags: - # create Tag obj - tag = create_tag(k, v) - - # adding Tag to Bin - bin.addItem(tag) - else: - update_tag(tags.pop(), v) + for __k, __v in _val.items(): + add_tag_to_bin(bin, __k, __v) # finally add the Bin object to the root level Bin if root_add: # adding Tag to Root Bin root_bin.addItem(bin) else: - # for Tags to be created in root level Bin - # at first check if any of input data tag is not already created - tags = None - tags = [t for t in root_bin.items() - if str(_k) in t.name()] - - if not tags: - # create Tag - tag = create_tag(_k, _val) - - # adding Tag to Root Bin - root_bin.addItem(tag) - else: - # update Tags if they already exists - for _t in tags: - # skip bin objects - if isinstance(_t, hiero.core.Bin): - continue - - # check if Hierarchy in name and skip it - # because hierarchy could be edited - if "hierarchy" in _t.name().lower(): - continue - - # update only non hierarchy tags - update_tag(_t, _val) + add_tag_to_bin(root_bin, _k, _val) log.info("Default Tags were set...") diff --git a/openpype/hosts/hiero/otio/hiero_export.py b/openpype/hosts/hiero/otio/hiero_export.py index ccc05d5fd7..af4322e3d9 100644 --- a/openpype/hosts/hiero/otio/hiero_export.py +++ b/openpype/hosts/hiero/otio/hiero_export.py @@ -378,6 +378,17 @@ def add_otio_metadata(otio_item, media_source, **kwargs): def create_otio_timeline(): + def set_prev_item(itemindex, track_item): + # Add Gap if needed + if itemindex == 0: + # if it is first track item at track then add + # it to previouse item + return track_item + + else: + # get previouse item + return track_item.parent().items()[itemindex - 1] + # get current timeline self.timeline = hiero.ui.activeSequence() self.project_fps = self.timeline.framerate().toFloat() @@ -396,14 +407,6 @@ def create_otio_timeline(): type(track), track.name()) for itemindex, track_item in enumerate(track): - # skip offline track items - if not track_item.isMediaPresent(): - continue - - # skip if track item is disabled - if not track_item.isEnabled(): - continue - # Add Gap if needed if itemindex == 0: # if it is first track item at track then add diff --git a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py index b0b171fb61..80c6abbaef 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py @@ -5,7 +5,7 @@ import pyblish.api class PreCollectClipEffects(pyblish.api.InstancePlugin): """Collect soft effects instances.""" - order = pyblish.api.CollectorOrder - 0.579 + order = pyblish.api.CollectorOrder - 0.479 label = "Precollect Clip Effects Instances" families = ["clip"] diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 9b529edf88..85b4e273d5 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -13,7 +13,7 @@ from pprint import pformat class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" - order = pyblish.api.CollectorOrder - 0.59 + order = pyblish.api.CollectorOrder - 0.49 label = "Precollect Instances" hosts = ["hiero"] @@ -131,7 +131,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.create_shot_instance(context, **data) self.log.info("Creating instance: {}".format(instance)) - self.log.debug( + self.log.info( "_ instance.data: {}".format(pformat(instance.data))) if not with_audio: diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index 530a433423..7db155048f 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -8,11 +8,12 @@ from openpype.hosts.hiero.otio import hiero_export from Qt.QtGui import QPixmap import tempfile + class PrecollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" label = "Precollect Workfile" - order = pyblish.api.CollectorOrder - 0.6 + order = pyblish.api.CollectorOrder - 0.5 def process(self, context): diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 77ee182e7c..76585085e2 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -15,8 +15,8 @@ creator.show() diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 9219da407f..b9c184e370 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -8,7 +8,7 @@ from avalon import api as avalon from avalon import pipeline from avalon.maya import suspended_refresh from avalon.maya.pipeline import IS_HEADLESS -from avalon.tools import workfiles +from openpype.tools import workfiles from pyblish import api as pyblish from openpype.lib import any_outdated import openpype.hosts.maya @@ -35,6 +35,7 @@ def install(): pyblish.register_plugin_path(PUBLISH_PATH) avalon.register_plugin_path(avalon.Loader, LOAD_PATH) avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) log.info(PUBLISH_PATH) menu.install() @@ -97,6 +98,7 @@ def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) menu.uninstall() diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index 22945471b7..a84412963b 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -79,7 +79,7 @@ def override_toolbox_ui(): log.warning("Could not import SceneInventory tool") try: - import avalon.tools.loader as loader + import openpype.tools.loader as loader except Exception: log.warning("Could not import Loader tool") diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 121f7a08a7..448cb814d9 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -4,6 +4,53 @@ import avalon.maya from openpype.api import PypeCreatorMixin +def get_reference_node(members, log=None): + """Get the reference node from the container members + Args: + members: list of node names + + Returns: + str: Reference node name. + + """ + + from maya import cmds + + # Collect the references without .placeHolderList[] attributes as + # unique entries (objects only) and skipping the sharedReferenceNode. + references = set() + for ref in cmds.ls(members, exactType="reference", objectsOnly=True): + + # Ignore any `:sharedReferenceNode` + if ref.rsplit(":", 1)[-1].startswith("sharedReferenceNode"): + continue + + # Ignore _UNKNOWN_REF_NODE_ (PLN-160) + if ref.rsplit(":", 1)[-1].startswith("_UNKNOWN_REF_NODE_"): + continue + + references.add(ref) + + assert references, "No reference node found in container" + + # Get highest reference node (least parents) + highest = min(references, + key=lambda x: len(get_reference_node_parents(x))) + + # Warn the user when we're taking the highest reference node + if len(references) > 1: + if not log: + from openpype.lib import PypeLogger + + log = PypeLogger().get_logger(__name__) + + log.warning("More than one reference node found in " + "container, using highest reference node: " + "%s (in: %s)", highest, list(references)) + + return highest + + def get_reference_node_parents(ref): """Return all parent reference nodes of reference node @@ -109,7 +156,7 @@ class ReferenceLoader(api.Loader): loader=self.__class__.__name__ )) else: - ref_node = self._get_reference_node(nodes) + ref_node = get_reference_node(nodes, self.log) loaded_containers.append(containerise( name=name, namespace=namespace, @@ -126,46 +173,6 @@ class ReferenceLoader(api.Loader): """To be implemented by subclass""" raise NotImplementedError("Must be implemented by subclass") - def _get_reference_node(self, members): - """Get the reference node from the container members - Args: - members: list of node names - - Returns: - str: Reference node name. - - """ - - from maya import cmds - - # Collect the references without .placeHolderList[] attributes as - # unique entries (objects only) and skipping the sharedReferenceNode. - references = set() - for ref in cmds.ls(members, exactType="reference", objectsOnly=True): - - # Ignore any `:sharedReferenceNode` - if ref.rsplit(":", 1)[-1].startswith("sharedReferenceNode"): - continue - - # Ignore _UNKNOWN_REF_NODE_ (PLN-160) - if ref.rsplit(":", 1)[-1].startswith("_UNKNOWN_REF_NODE_"): - continue - - references.add(ref) - - assert references, "No reference node found in container" - - # Get highest reference node (least parents) - highest = min(references, - key=lambda x: len(get_reference_node_parents(x))) - - # Warn the user when we're taking the highest reference node - if len(references) > 1: - self.log.warning("More than one reference node found in " - "container, using highest reference node: " - "%s (in: %s)", highest, list(references)) - - return highest def update(self, container, representation): @@ -178,7 +185,7 @@ class ReferenceLoader(api.Loader): # Get reference node from container members members = cmds.sets(node, query=True, nodesOnly=True) - reference_node = self._get_reference_node(members) + reference_node = get_reference_node(members, self.log) file_type = { "ma": "mayaAscii", @@ -274,7 +281,7 @@ class ReferenceLoader(api.Loader): # Assume asset has been referenced members = cmds.sets(node, query=True) - reference_node = self._get_reference_node(members) + reference_node = get_reference_node(members, self.log) assert reference_node, ("Imported container not supported; " "container must be referenced.") diff --git a/openpype/hosts/maya/api/shader_definition_editor.py b/openpype/hosts/maya/api/shader_definition_editor.py index 73cc6246ab..ed425f4718 100644 --- a/openpype/hosts/maya/api/shader_definition_editor.py +++ b/openpype/hosts/maya/api/shader_definition_editor.py @@ -31,7 +31,7 @@ class ShaderDefinitionsEditor(QtWidgets.QWidget): self.setObjectName("shaderDefinitionEditor") self.setWindowTitle("OpenPype shader name definition editor") - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags(QtCore.Qt.Window) self.setParent(parent) diff --git a/openpype/hosts/maya/plugins/create/create_setdress.py b/openpype/hosts/maya/plugins/create/create_setdress.py index 8274a6dd83..4246183fdb 100644 --- a/openpype/hosts/maya/plugins/create/create_setdress.py +++ b/openpype/hosts/maya/plugins/create/create_setdress.py @@ -9,3 +9,8 @@ class CreateSetDress(plugin.Creator): family = "setdress" icon = "cubes" defaults = ["Main", "Anim"] + + def __init__(self, *args, **kwargs): + super(CreateSetDress, self).__init__(*args, **kwargs) + + self.data["exactSetMembersOnly"] = True diff --git a/openpype/hosts/maya/plugins/inventory/import_modelrender.py b/openpype/hosts/maya/plugins/inventory/import_modelrender.py new file mode 100644 index 0000000000..e3cad4cf2e --- /dev/null +++ b/openpype/hosts/maya/plugins/inventory/import_modelrender.py @@ -0,0 +1,92 @@ +from avalon import api, io + + +class ImportModelRender(api.InventoryAction): + + label = "Import Model Render Sets" + icon = "industry" + color = "#55DDAA" + + scene_type_regex = "meta.render.m[ab]" + look_data_type = "meta.render.json" + + @staticmethod + def is_compatible(container): + return ( + container.get("loader") == "ReferenceLoader" + and container.get("name", "").startswith("model") + ) + + def process(self, containers): + from maya import cmds + + for container in containers: + con_name = container["objectName"] + nodes = [] + for n in cmds.sets(con_name, query=True, nodesOnly=True) or []: + if cmds.nodeType(n) == "reference": + nodes += cmds.referenceQuery(n, nodes=True) + else: + nodes.append(n) + + repr_doc = io.find_one({ + "_id": io.ObjectId(container["representation"]), + }) + version_id = repr_doc["parent"] + + print("Importing render sets for model %r" % con_name) + self.assign_model_render_by_version(nodes, version_id) + + def assign_model_render_by_version(self, nodes, version_id): + """Assign nodes a specific published model render data version by id. + + This assumes the nodes correspond with the asset. + + Args: + nodes(list): nodes to assign render data to + version_id (bson.ObjectId): database id of the version of model + + Returns: + None + """ + import json + from maya import cmds + from avalon import maya, io, pipeline + from openpype.hosts.maya.api import lib + + # Get representations of shader file and relationships + look_repr = io.find_one({ + "type": "representation", + "parent": version_id, + "name": {"$regex": self.scene_type_regex}, + }) + if not look_repr: + print("No model render sets for this model version..") + return + + json_repr = io.find_one({ + "type": "representation", + "parent": version_id, + "name": self.look_data_type, + }) + + context = pipeline.get_representation_context(look_repr["_id"]) + maya_file = pipeline.get_representation_path_from_context(context) + + context = pipeline.get_representation_context(json_repr["_id"]) + json_file = pipeline.get_representation_path_from_context(context) + + # Import the look file + with maya.maintained_selection(): + shader_nodes = cmds.file(maya_file, + i=True, # import + returnNewNodes=True) + # imprint context data + + # Load relationships + shader_relation = json_file + with open(shader_relation, "r") as f: + relationships = json.load(f) + + # Assign relationships + lib.apply_shaders(relationships, shader_nodes, nodes) diff --git a/openpype/hosts/maya/plugins/inventory/import_reference.py b/openpype/hosts/maya/plugins/inventory/import_reference.py new file mode 100644 index 0000000000..2fa132a867 --- /dev/null +++ b/openpype/hosts/maya/plugins/inventory/import_reference.py @@ -0,0 +1,29 @@ +from maya import cmds + +from avalon import api + +from openpype.hosts.maya.api.plugin import get_reference_node + + +class ImportReference(api.InventoryAction): + """Imports selected reference to inside of the file.""" + + label = "Import Reference" + icon = "download" + color = "#d8d8d8" + + def process(self, containers): + references = cmds.ls(type="reference") + for container in containers: + if container["loader"] != "ReferenceLoader": + print("Not a reference, skipping") + continue + + node = container["objectName"] + members = cmds.sets(node, query=True, nodesOnly=True) + ref_node = get_reference_node(members) + + ref_file = cmds.referenceQuery(ref_node, f=True) + cmds.file(ref_file, importReference=True) + + return True # return anything to trigger model refresh diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 0dde52447d..9c047b252f 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -223,8 +223,8 @@ class CollectLook(pyblish.api.InstancePlugin): def process(self, instance): """Collect the Look in the instance with the correct layer settings""" - - with lib.renderlayer(instance.data["renderlayer"]): + renderlayer = instance.data.get("renderlayer", "defaultRenderLayer") + with lib.renderlayer(renderlayer): self.collect(instance) def collect(self, instance): @@ -357,6 +357,23 @@ class CollectLook(pyblish.api.InstancePlugin): for vray_node in vray_plugin_nodes: history.extend(cmds.listHistory(vray_node)) + # handling render attribute sets + render_set_types = [ + "VRayDisplacement", + "VRayLightMesh", + "VRayObjectProperties", + "RedshiftObjectId", + "RedshiftMeshParameters", + ] + render_sets = cmds.ls(look_sets, type=render_set_types) + if render_sets: + history.extend( + cmds.listHistory(render_sets, + future=False, + pruneDagObjects=True) + or [] + ) + files = cmds.ls(history, type="file", long=True) files.extend(cmds.ls(history, type="aiImage", long=True)) files.extend(cmds.ls(history, type="RedshiftNormalMap", long=True)) @@ -550,3 +567,45 @@ class CollectLook(pyblish.api.InstancePlugin): "source": source, # required for resources "files": files, "color_space": color_space} # required for resources + + +class CollectModelRenderSets(CollectLook): + """Collect render attribute sets for model instance. + + Collects additional render attribute sets so they can be + published with model. + + """ + + order = pyblish.api.CollectorOrder + 0.21 + families = ["model"] + label = "Collect Model Render Sets" + hosts = ["maya"] + maketx = True + + def collect_sets(self, instance): + """Collect all related objectSets except shadingEngines + + Args: + instance (list): all nodes to be published + + Returns: + dict + """ + + sets = {} + for node in instance: + related_sets = lib.get_related_sets(node) + if not related_sets: + continue + + for objset in related_sets: + if objset in sets: + continue + + if "shadingEngine" in cmds.nodeType(objset, inherited=True): + continue + + sets[objset] = {"uuid": lib.get_id(objset), "members": list()} + + return sets diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index f09d50d714..bbf25ebdc7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -122,7 +122,7 @@ def no_workspace_dir(): class ExtractLook(openpype.api.Extractor): - """Extract Look (Maya Ascii + JSON) + """Extract Look (Maya Scene + JSON) Only extracts the sets (shadingEngines and alike) alongside a .json file that stores it relationships for the sets and "attribute" data for the @@ -130,11 +130,12 @@ class ExtractLook(openpype.api.Extractor): """ - label = "Extract Look (Maya ASCII + JSON)" + label = "Extract Look (Maya Scene + JSON)" hosts = ["maya"] families = ["look"] order = pyblish.api.ExtractorOrder + 0.2 scene_type = "ma" + look_data_type = "json" @staticmethod def get_renderer_name(): @@ -176,6 +177,8 @@ class ExtractLook(openpype.api.Extractor): # no preset found pass + return "mayaAscii" if self.scene_type == "ma" else "mayaBinary" + def process(self, instance): """Plugin entry point. @@ -183,10 +186,12 @@ class ExtractLook(openpype.api.Extractor): instance: Instance to process. """ + _scene_type = self.get_maya_scene_type(instance) + # Define extract output file path dir_path = self.staging_dir(instance) maya_fname = "{0}.{1}".format(instance.name, self.scene_type) - json_fname = "{0}.json".format(instance.name) + json_fname = "{0}.{1}".format(instance.name, self.look_data_type) # Make texture dump folder maya_path = os.path.join(dir_path, maya_fname) @@ -196,11 +201,100 @@ class ExtractLook(openpype.api.Extractor): # Remove all members of the sets so they are not included in the # exported file by accident - self.log.info("Extract sets (Maya ASCII) ...") + self.log.info("Extract sets (%s) ..." % _scene_type) lookdata = instance.data["lookData"] relationships = lookdata["relationships"] sets = relationships.keys() + results = self.process_resources(instance, staging_dir=dir_path) + transfers = results["fileTransfers"] + hardlinks = results["fileHardlinks"] + hashes = results["fileHashes"] + remap = results["attrRemap"] + + # Extract in correct render layer + layer = instance.data.get("renderlayer", "defaultRenderLayer") + with lib.renderlayer(layer): + # TODO: Ensure membership edits don't become renderlayer overrides + with lib.empty_sets(sets, force=True): + # To avoid Maya trying to automatically remap the file + # textures relative to the `workspace -directory` we force + # it to a fake temporary workspace. This fixes textures + # getting incorrectly remapped. (LKD-17, PLN-101) + with no_workspace_dir(): + with lib.attribute_values(remap): + with avalon.maya.maintained_selection(): + cmds.select(sets, noExpand=True) + cmds.file( + maya_path, + force=True, + typ=_scene_type, + exportSelected=True, + preserveReferences=False, + channels=True, + constraints=True, + expressions=True, + constructionHistory=True, + ) + + # Write the JSON data + self.log.info("Extract json..") + data = { + "attributes": lookdata["attributes"], + "relationships": relationships + } + + with open(json_path, "w") as f: + json.dump(data, f) + + if "files" not in instance.data: + instance.data["files"] = [] + if "hardlinks" not in instance.data: + instance.data["hardlinks"] = [] + if "transfers" not in instance.data: + instance.data["transfers"] = [] + + instance.data["files"].append(maya_fname) + instance.data["files"].append(json_fname) + + if instance.data.get("representations") is None: + instance.data["representations"] = [] + + instance.data["representations"].append( + { + "name": self.scene_type, + "ext": self.scene_type, + "files": os.path.basename(maya_fname), + "stagingDir": os.path.dirname(maya_fname), + } + ) + instance.data["representations"].append( + { + "name": self.look_data_type, + "ext": self.look_data_type, + "files": os.path.basename(json_fname), + "stagingDir": os.path.dirname(json_fname), + } + ) + + # Set up the resources transfers/links for the integrator + instance.data["transfers"].extend(transfers) + instance.data["hardlinks"].extend(hardlinks) + + # Source hash for the textures + instance.data["sourceHashes"] = hashes + + """ + self.log.info("Returning colorspaces to their original values ...") + for attr, value in remap.items(): + self.log.info(" - {}: {}".format(attr, value)) + cmds.setAttr(attr, value, type="string") + """ + self.log.info("Extracted instance '%s' to: %s" % (instance.name, + maya_path)) + + def process_resources(self, instance, staging_dir): + # Extract the textures to transfer, possibly convert with maketx and # remap the node paths to the destination path. Note that a source # might be included more than once amongst the resources as they could @@ -218,7 +312,6 @@ class ExtractLook(openpype.api.Extractor): color_space = resource.get("color_space") for f in resource["files"]: - files_metadata[os.path.normpath(f)] = { "color_space": color_space} # files.update(os.path.normpath(f)) @@ -244,7 +337,7 @@ class ExtractLook(openpype.api.Extractor): source, mode, texture_hash = self._process_texture( filepath, do_maketx, - staging=dir_path, + staging=staging_dir, linearize=linearize, force=force_copy ) @@ -299,85 +392,13 @@ class ExtractLook(openpype.api.Extractor): self.log.info("Finished remapping destinations ...") - # Extract in correct render layer - layer = instance.data.get("renderlayer", "defaultRenderLayer") - with lib.renderlayer(layer): - # TODO: Ensure membership edits don't become renderlayer overrides - with lib.empty_sets(sets, force=True): - # To avoid Maya trying to automatically remap the file - # textures relative to the `workspace -directory` we force - # it to a fake temporary workspace. This fixes textures - # getting incorrectly remapped. (LKD-17, PLN-101) - with no_workspace_dir(): - with lib.attribute_values(remap): - with avalon.maya.maintained_selection(): - cmds.select(sets, noExpand=True) - cmds.file( - maya_path, - force=True, - typ="mayaAscii", - exportSelected=True, - preserveReferences=False, - channels=True, - constraints=True, - expressions=True, - constructionHistory=True, - ) - - # Write the JSON data - self.log.info("Extract json..") - data = { - "attributes": lookdata["attributes"], - "relationships": relationships + return { + "fileTransfers": transfers, + "fileHardlinks": hardlinks, + "fileHashes": hashes, + "attrRemap": remap, } - with open(json_path, "w") as f: - json.dump(data, f) - - if "files" not in instance.data: - instance.data["files"] = [] - if "hardlinks" not in instance.data: - instance.data["hardlinks"] = [] - if "transfers" not in instance.data: - instance.data["transfers"] = [] - - instance.data["files"].append(maya_fname) - instance.data["files"].append(json_fname) - - instance.data["representations"] = [] - instance.data["representations"].append( - { - "name": "ma", - "ext": "ma", - "files": os.path.basename(maya_fname), - "stagingDir": os.path.dirname(maya_fname), - } - ) - instance.data["representations"].append( - { - "name": "json", - "ext": "json", - "files": os.path.basename(json_fname), - "stagingDir": os.path.dirname(json_fname), - } - ) - - # Set up the resources transfers/links for the integrator - instance.data["transfers"].extend(transfers) - instance.data["hardlinks"].extend(hardlinks) - - # Source hash for the textures - instance.data["sourceHashes"] = hashes - - """ - self.log.info("Returning colorspaces to their original values ...") - for attr, value in remap.items(): - self.log.info(" - {}: {}".format(attr, value)) - cmds.setAttr(attr, value, type="string") - """ - self.log.info("Extracted instance '%s' to: %s" % (instance.name, - maya_path)) - def resource_destination(self, instance, filepath, do_maketx): """Get resource destination path. @@ -467,3 +488,26 @@ class ExtractLook(openpype.api.Extractor): return converted, COPY, texture_hash return filepath, COPY, texture_hash + + +class ExtractModelRenderSets(ExtractLook): + """Extract model render attribute sets as model metadata + + Only extracts the render attrib sets (NO shadingEngines) alongside + a .json file that stores it relationships for the sets and "attribute" + data for the instance members. + + """ + + label = "Model Render Sets" + hosts = ["maya"] + families = ["model"] + scene_type_prefix = "meta.render." + look_data_type = "meta.render.json" + + def get_maya_scene_type(self, instance): + typ = super(ExtractModelRenderSets, self).get_maya_scene_type(instance) + # add prefix + self.scene_type = self.scene_type_prefix + self.scene_type + + return typ diff --git a/openpype/hosts/maya/plugins/publish/validate_setdress_root.py b/openpype/hosts/maya/plugins/publish/validate_setdress_root.py new file mode 100644 index 0000000000..0b4842d208 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_setdress_root.py @@ -0,0 +1,25 @@ + +import pyblish.api +import openpype.api + + +class ValidateSetdressRoot(pyblish.api.InstancePlugin): + """ + """ + + order = openpype.api.ValidateContentsOrder + label = "SetDress Root" + hosts = ["maya"] + families = ["setdress"] + + def process(self, instance): + from maya import cmds + + if instance.data.get("exactSetMembersOnly"): + return + + set_member = instance.data["setMembers"] + root = cmds.ls(set_member, assemblies=True, long=True) + + if not root or root[0] not in set_member: + raise Exception("Setdress top root node is not being published.") diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 7e7cd27f90..8948cb4d78 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -7,7 +7,7 @@ from collections import OrderedDict from avalon import api, io, lib -from avalon.tools import workfiles +from openpype.tools import workfiles import avalon.nuke from avalon.nuke import lib as anlib from avalon.nuke import ( @@ -287,7 +287,7 @@ def script_name(): def add_button_write_to_read(node): name = "createReadNode" - label = "Cread Read From Rendered" + label = "Create Read From Rendered" value = "import write_to_read;write_to_read.write_to_read(nuke.thisNode())" knob = nuke.PyScript_Knob(name, label, value) knob.clearFlag(nuke.STARTLINE) @@ -727,7 +727,7 @@ class WorkfileSettings(object): log.error(msg) nuke.message(msg) - log.warning(">> root_dict: {}".format(root_dict)) + log.debug(">> root_dict: {}".format(root_dict)) # first set OCIO if self._root_node["colorManagement"].value() \ @@ -1277,6 +1277,7 @@ class ExporterReview: def clean_nodes(self): for node in self._temp_nodes: nuke.delete(node) + self._temp_nodes = [] self.log.info("Deleted nodes...") @@ -1301,6 +1302,7 @@ class ExporterReviewLut(ExporterReview): lut_style=None): # initialize parent class ExporterReview.__init__(self, klass, instance) + self._temp_nodes = [] # deal with now lut defined in viewer lut if hasattr(klass, "viewer_lut_raw"): diff --git a/openpype/hosts/nuke/plugins/load/load_sequence.py b/openpype/hosts/nuke/plugins/load/load_sequence.py index 5f2128b10f..003b406ee7 100644 --- a/openpype/hosts/nuke/plugins/load/load_sequence.py +++ b/openpype/hosts/nuke/plugins/load/load_sequence.py @@ -76,6 +76,8 @@ class LoadSequence(api.Loader): file = file.replace("\\", "/") repr_cont = context["representation"]["context"] + assert repr_cont.get("frame"), "Representation is not sequence" + if "#" not in file: frame = repr_cont.get("frame") if frame: @@ -170,6 +172,7 @@ class LoadSequence(api.Loader): assert read_node.Class() == "Read", "Must be Read" repr_cont = representation["context"] + assert repr_cont.get("frame"), "Representation is not sequence" file = api.get_representation_path(representation) diff --git a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py index a144761e5f..c3a6a3b167 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py +++ b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py @@ -2,6 +2,7 @@ import nuke import pyblish.api from avalon.nuke import maintained_selection + class CreateOutputNode(pyblish.api.ContextPlugin): """Adding output node for each ouput write node So when latly user will want to Load .nk as LifeGroup or Precomp @@ -15,8 +16,8 @@ class CreateOutputNode(pyblish.api.ContextPlugin): def process(self, context): # capture selection state with maintained_selection(): - active_node = [node for inst in context[:] - for node in inst[:] + active_node = [node for inst in context + for node in inst if "ak:family" in node.knobs()] if active_node: diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index b0d3ec6241..a0f1c9a087 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -3,6 +3,12 @@ import pyblish.api from avalon.nuke import lib as anlib from openpype.hosts.nuke.api import lib as pnlib import openpype + +try: + from __builtin__ import reload +except ImportError: + from importlib import reload + reload(pnlib) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index cea7d86c26..f4fbc2d0e4 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -4,6 +4,13 @@ from avalon.nuke import lib as anlib from openpype.hosts.nuke.api import lib as pnlib import openpype +try: + from __builtin__ import reload +except ImportError: + from importlib import reload + +reload(pnlib) + class ExtractReviewDataMov(openpype.api.Extractor): """Extracts movie and thumbnail with baked in luts diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index da30dcc632..0c9af66435 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -1,3 +1,4 @@ +import sys import os import nuke from avalon.nuke import lib as anlib @@ -5,6 +6,10 @@ import pyblish.api import openpype +if sys.version_info[0] >= 3: + unicode = str + + class ExtractThumbnail(openpype.api.Extractor): """Extracts movie and thumbnail with baked in luts @@ -112,24 +117,26 @@ class ExtractThumbnail(openpype.api.Extractor): # create write node write_node = nuke.createNode("Write") - file = fhead + "jpeg" + file = fhead + "jpg" name = "thumbnail" path = os.path.join(staging_dir, file).replace("\\", "/") instance.data["thumbnail"] = path write_node["file"].setValue(path) - write_node["file_type"].setValue("jpeg") + write_node["file_type"].setValue("jpg") write_node["raw"].setValue(1) write_node.setInput(0, previous_node) temporary_nodes.append(write_node) tags = ["thumbnail", "publish_on_farm"] # retime for + mid_frame = int((int(last_frame) - int(first_frame)) / 2) \ + + int(first_frame) first_frame = int(last_frame) / 2 last_frame = int(last_frame) / 2 repre = { 'name': name, - 'ext': "jpeg", + 'ext': "jpg", "outputName": "thumb", 'files': file, "stagingDir": staging_dir, @@ -140,7 +147,7 @@ class ExtractThumbnail(openpype.api.Extractor): instance.data["representations"].append(repre) # Render frames - nuke.execute(write_node.name(), int(first_frame), int(last_frame)) + nuke.execute(write_node.name(), int(mid_frame), int(mid_frame)) self.log.debug( "representations: {}".format(instance.data["representations"])) diff --git a/openpype/hosts/nuke/plugins/publish/increment_script_version.py b/openpype/hosts/nuke/plugins/publish/increment_script_version.py index 47fccb9125..f55ed21ee2 100644 --- a/openpype/hosts/nuke/plugins/publish/increment_script_version.py +++ b/openpype/hosts/nuke/plugins/publish/increment_script_version.py @@ -9,7 +9,7 @@ class IncrementScriptVersion(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder + 0.9 label = "Increment Script Version" optional = True - families = ["workfile", "render", "render.local", "render.farm"] + families = ["workfile"] hosts = ['nuke'] def process(self, context): diff --git a/openpype/hosts/nuke/plugins/publish/precollect_instances.py b/openpype/hosts/nuke/plugins/publish/precollect_instances.py index c2c25d0627..5c30df9a62 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_instances.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_instances.py @@ -8,12 +8,12 @@ from avalon.nuke import lib as anlib class PreCollectNukeInstances(pyblish.api.ContextPlugin): """Collect all nodes with Avalon knob.""" - order = pyblish.api.CollectorOrder - 0.59 + order = pyblish.api.CollectorOrder - 0.49 label = "Pre-collect Instances" hosts = ["nuke", "nukeassist"] # presets - sync_workfile_version = False + sync_workfile_version_on_families = [] def process(self, context): asset_data = io.find_one({ @@ -120,11 +120,12 @@ class PreCollectNukeInstances(pyblish.api.ContextPlugin): # sync workfile version _families_test = [family] + families self.log.debug("__ _families_test: `{}`".format(_families_test)) - if not next((f for f in _families_test - if "prerender" in f), - None) and self.sync_workfile_version: - # get version to instance for integration - instance.data['version'] = instance.context.data['version'] + for family_test in _families_test: + if family_test in self.sync_workfile_version_on_families: + self.log.debug("Syncing version with workfile for '{}'" + .format(family_test)) + # get version to instance for integration + instance.data['version'] = instance.context.data['version'] instance.data.update({ "subset": subset, diff --git a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py index 5d3eb5f609..0e27273ceb 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py @@ -3,13 +3,12 @@ import pyblish.api import os import openpype.api as pype from avalon.nuke import lib as anlib -reload(anlib) class CollectWorkfile(pyblish.api.ContextPlugin): """Collect current script for publish.""" - order = pyblish.api.CollectorOrder - 0.60 + order = pyblish.api.CollectorOrder - 0.50 label = "Pre-collect Workfile" hosts = ['nuke'] diff --git a/openpype/hosts/nuke/plugins/publish/precollect_writes.py b/openpype/hosts/nuke/plugins/publish/precollect_writes.py index 0b5fbc0479..47189c31fc 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_writes.py @@ -11,7 +11,7 @@ from avalon import io, api class CollectNukeWrites(pyblish.api.InstancePlugin): """Collect all write nodes.""" - order = pyblish.api.CollectorOrder - 0.58 + order = pyblish.api.CollectorOrder - 0.48 label = "Pre-collect Writes" hosts = ["nuke", "nukeassist"] families = ["write"] diff --git a/openpype/hosts/nuke/plugins/publish/validate_proxy_mode.py b/openpype/hosts/nuke/plugins/publish/validate_proxy_mode.py new file mode 100644 index 0000000000..9c6ca03ffd --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/validate_proxy_mode.py @@ -0,0 +1,33 @@ +import pyblish +import nuke + + +class FixProxyMode(pyblish.api.Action): + """ + Togger off proxy switch OFF + """ + + label = "Proxy toggle to OFF" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + rootNode = nuke.root() + rootNode["proxy"].setValue(False) + + +@pyblish.api.log +class ValidateProxyMode(pyblish.api.ContextPlugin): + """Validate active proxy mode""" + + order = pyblish.api.ValidatorOrder + label = "Validate Proxy Mode" + hosts = ["nuke"] + actions = [FixProxyMode] + + def process(self, context): + + rootNode = nuke.root() + isProxy = rootNode["proxy"].value() + + assert not isProxy, "Proxy mode should be toggled OFF" diff --git a/openpype/hosts/nuke/startup/write_to_read.py b/openpype/hosts/nuke/startup/write_to_read.py index deb5ce1b82..295a6e3c85 100644 --- a/openpype/hosts/nuke/startup/write_to_read.py +++ b/openpype/hosts/nuke/startup/write_to_read.py @@ -69,7 +69,8 @@ def evaluate_filepath_new(k_value, k_eval, project_dir, first_frame): frames = sorted(frames) firstframe = frames[0] lastframe = frames[len(frames) - 1] - if lastframe < 0: + + if int(lastframe) < 0: lastframe = firstframe return filepath, firstframe, lastframe diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index b52078fd5f..1c53c3a2ef 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -60,7 +60,8 @@ class ExtractReview(openpype.api.Extractor): # Generate thumbnail. thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") args = [ - "{}".format(ffmpeg_path), "-y", + ffmpeg_path, + "-y", "-i", output_image_path, "-vf", "scale=300:-1", "-vframes", "1", @@ -78,7 +79,8 @@ class ExtractReview(openpype.api.Extractor): # Generate mov. mov_path = os.path.join(staging_dir, "review.mov") args = [ - ffmpeg_path, "-y", + ffmpeg_path, + "-y", "-i", output_image_path, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-vframes", "1", diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index e7be3fc963..c639fd2db8 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -10,11 +10,13 @@ from .pipeline import ( from avalon.tools import ( creator, - loader, sceneinventory, - libraryloader, subsetmanager ) +from openpype.tools import ( + loader, + libraryloader, +) def load_stylesheet(): diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index a659ac7e51..80249310e8 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -4,7 +4,7 @@ Basic avalon integration import os import contextlib from collections import OrderedDict -from avalon.tools import workfiles +from openpype.tools import workfiles from avalon import api as avalon from avalon import schema from avalon.pipeline import AVALON_CONTAINER_ID diff --git a/openpype/hosts/resolve/plugins/publish/precollect_instances.py b/openpype/hosts/resolve/plugins/publish/precollect_instances.py index 95b891d95a..8f1a13a4e5 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_instances.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_instances.py @@ -8,7 +8,7 @@ from pprint import pformat class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" - order = pyblish.api.CollectorOrder - 0.59 + order = pyblish.api.CollectorOrder - 0.49 label = "Precollect Instances" hosts = ["resolve"] diff --git a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py index ee05fb6f13..1333516177 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py @@ -13,7 +13,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): """Precollect the current working file into context""" label = "Precollect Workfile" - order = pyblish.api.CollectorOrder - 0.6 + order = pyblish.api.CollectorOrder - 0.5 def process(self, context): diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py index f7f96c7d03..adbac6ef09 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py @@ -11,6 +11,7 @@ import zipfile import pyblish.api from avalon import api, io import openpype.api +from openpype.lib import get_workfile_template_key_from_context class ExtractHarmonyZip(openpype.api.Extractor): @@ -65,10 +66,10 @@ class ExtractHarmonyZip(openpype.api.Extractor): # Get Task types and Statuses for creation if needed self.task_types = self._get_all_task_types(project_entity) - self.task_statuses = self.get_all_task_statuses(project_entity) + self.task_statuses = self._get_all_task_statuses(project_entity) # Get Statuses of AssetVersions - self.assetversion_statuses = self.get_all_assetversion_statuses( + self.assetversion_statuses = self._get_all_assetversion_statuses( project_entity ) @@ -233,18 +234,28 @@ class ExtractHarmonyZip(openpype.api.Extractor): "version": 1, "ext": "zip", } + host_name = "harmony" + template_name = get_workfile_template_key_from_context( + instance.data["asset"], + instance.data.get("task"), + host_name, + project_name=project_entity["name"], + dbcon=io + ) # Get a valid work filename first with version 1 - file_template = anatomy.templates["work"]["file"] + file_template = anatomy.templates[template_name]["file"] anatomy_filled = anatomy.format(data) - work_path = anatomy_filled["work"]["path"] + work_path = anatomy_filled[template_name]["path"] # Get the final work filename with the proper version data["version"] = api.last_workfile_with_version( - os.path.dirname(work_path), file_template, data, [".zip"] + os.path.dirname(work_path), + file_template, + data, + api.HOST_WORKFILE_EXTENSIONS[host_name] )[1] - work_path = anatomy_filled["work"]["path"] base_name = os.path.splitext(os.path.basename(work_path))[0] staging_work_path = os.path.join(os.path.dirname(staging_scene), diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index 0792254716..23f0b104c8 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -58,7 +58,7 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): # use first frame as thumbnail if is sequence of jpegs full_thumbnail_path = os.path.join( thumbnail_repre["stagingDir"], file - ) + ) self.log.info( "For thumbnail is used file: {}".format(full_thumbnail_path) ) @@ -116,7 +116,7 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): # create new thumbnail representation representation = { - 'name': 'jpg', + 'name': 'thumbnail', 'ext': 'jpg', 'files': filename, "stagingDir": staging_dir, diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py index 059ac9603c..c18de5bc1c 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_trim_video_audio.py @@ -59,32 +59,35 @@ class ExtractTrimVideoAudio(openpype.api.Extractor): if "trimming" not in fml ] - args = [ - f"\"{ffmpeg_path}\"", + ffmpeg_args = [ + ffmpeg_path, "-ss", str(start / fps), - "-i", f"\"{video_file_path}\"", + "-i", video_file_path, "-t", str(dur / fps) ] if ext in [".mov", ".mp4"]: - args.extend([ + ffmpeg_args.extend([ "-crf", "18", - "-pix_fmt", "yuv420p"]) + "-pix_fmt", "yuv420p" + ]) elif ext in ".wav": - args.extend([ - "-vn -acodec pcm_s16le", - "-ar 48000 -ac 2" + ffmpeg_args.extend([ + "-vn", + "-acodec", "pcm_s16le", + "-ar", "48000", + "-ac", "2" ]) # add output path - args.append(f"\"{clip_trimed_path}\"") + ffmpeg_args.append(clip_trimed_path) - self.log.info(f"Processing: {args}") - ffmpeg_args = " ".join(args) + joined_args = " ".join(ffmpeg_args) + self.log.info(f"Processing: {joined_args}") openpype.api.run_subprocess( - ffmpeg_args, shell=True, logger=self.log + ffmpeg_args, logger=self.log ) - repr = { + repre = { "name": ext[1:], "ext": ext[1:], "files": os.path.basename(clip_trimed_path), @@ -97,10 +100,10 @@ class ExtractTrimVideoAudio(openpype.api.Extractor): } if ext in [".mov", ".mp4"]: - repr.update({ + repre.update({ "thumbnail": True, "tags": ["review", "ftrackreview", "delete"]}) - instance.data["representations"].append(repr) + instance.data["representations"].append(repre) self.log.debug(f"Instance data: {pformat(instance.data)}") diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 6584120d97..7e9b98956a 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -10,6 +10,7 @@ Provides: import os import json import clique +import tempfile import pyblish.api from avalon import io @@ -94,7 +95,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): instance.data["families"] = families instance.data["version"] = \ self._get_last_version(asset, subset) + 1 - instance.data["stagingDir"] = task_dir + instance.data["stagingDir"] = tempfile.mkdtemp() instance.data["source"] = "webpublisher" # to store logging info into DB openpype.webpublishes @@ -113,6 +114,8 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): instance.data["frameEnd"] = \ instance.data["representations"][0]["frameEnd"] else: + instance.data["frameStart"] = 0 + instance.data["frameEnd"] = 1 instance.data["representations"] = self._get_single_repre( task_dir, task_data["files"], tags ) @@ -174,7 +177,11 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): (family, [families], subset_template_name, tags) tuple AssertionError if not matching family found """ - task_obj = settings.get(task_type) + task_type = task_type.lower() + lower_cased_task_types = {} + for t_type, task in settings.items(): + lower_cased_task_types[t_type.lower()] = task + task_obj = lower_cased_task_types.get(task_type) assert task_obj, "No family configuration for '{}'".format(task_type) found_family = None diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 3d392dc745..74004a1239 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -27,6 +27,7 @@ from .execute import ( get_pype_execute_args, execute, run_subprocess, + path_to_subprocess_arg, CREATE_NO_WINDOW ) from .log import PypeLogger, timeit @@ -59,6 +60,11 @@ from .python_module_tools import ( import_module_from_dirpath ) +from .profiles_filtering import ( + compile_list_of_regexes, + filter_profiles +) + from .avalon_context import ( CURRENT_DOC_SCHEMAS, PROJECT_NAME_ALLOWED_SYMBOLS, @@ -118,13 +124,9 @@ from .applications import ( prepare_host_environments, prepare_context_environments, get_app_environments_for_context, - apply_project_environments_value, - - compile_list_of_regexes + apply_project_environments_value ) -from .profiles_filtering import filter_profiles - from .plugin_tools import ( TaskNotSetError, get_subset_name, @@ -143,7 +145,9 @@ from .plugin_tools import ( from .path_tools import ( version_up, get_version_from_path, - get_last_version_from_path + get_last_version_from_path, + create_project_folders, + get_project_basic_paths ) from .editorial import ( @@ -158,12 +162,19 @@ from .editorial import ( make_sequence_collection ) +from .pype_info import ( + get_openpype_version, + get_build_version +) + terminal = Terminal __all__ = [ "get_pype_execute_args", "execute", "run_subprocess", + "path_to_subprocess_arg", + "CREATE_NO_WINDOW", "env_value_to_bool", "get_paths_from_environ", @@ -276,5 +287,10 @@ __all__ = [ "range_from_frames", "frames_to_secons", "frames_to_timecode", - "make_sequence_collection" + "make_sequence_collection", + "create_project_folders", + "get_project_basic_paths", + + "get_openpype_version", + "get_build_version", ] diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index fbf991a32e..245f2ee9a2 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -25,11 +25,12 @@ from . import ( PypeLogger, Anatomy ) +from .profiles_filtering import filter_profiles from .local_settings import get_openpype_username from .avalon_context import ( get_workdir_data, get_workdir_with_workdir_data, - get_workfile_template_key_from_context + get_workfile_template_key ) from .python_module_tools import ( @@ -1226,8 +1227,12 @@ def prepare_context_environments(data): # Load project specific environments project_name = project_doc["name"] + project_settings = get_project_settings(project_name) + data["project_settings"] = project_settings # Apply project specific environments on current env value - apply_project_environments_value(project_name, data["env"]) + apply_project_environments_value( + project_name, data["env"], project_settings + ) app = data["app"] workdir_data = get_workdir_data( @@ -1237,17 +1242,22 @@ def prepare_context_environments(data): anatomy = data["anatomy"] - template_key = get_workfile_template_key_from_context( - asset_doc["name"], - task_name, + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + # Temp solution how to pass task type to `_prepare_last_workfile` + data["task_type"] = task_type + + workfile_template_key = get_workfile_template_key( + task_type, app.host_name, project_name=project_name, - dbcon=data["dbcon"] + project_settings=project_settings ) try: workdir = get_workdir_with_workdir_data( - workdir_data, anatomy, template_key=template_key + workdir_data, anatomy, template_key=workfile_template_key ) except Exception as exc: @@ -1281,10 +1291,10 @@ def prepare_context_environments(data): ) data["env"].update(context_env) - _prepare_last_workfile(data, workdir) + _prepare_last_workfile(data, workdir, workfile_template_key) -def _prepare_last_workfile(data, workdir): +def _prepare_last_workfile(data, workdir, workfile_template_key): """last workfile workflow preparation. Function check if should care about last workfile workflow and tries @@ -1314,13 +1324,14 @@ def _prepare_last_workfile(data, workdir): workdir_data = copy.deepcopy(_workdir_data) project_name = data["project_name"] task_name = data["task_name"] + task_type = data["task_type"] start_last_workfile = should_start_last_workfile( - project_name, app.host_name, task_name + project_name, app.host_name, task_name, task_type ) data["start_last_workfile"] = start_last_workfile workfile_startup = should_workfile_tool_start( - project_name, app.host_name, task_name + project_name, app.host_name, task_name, task_type ) data["workfile_startup"] = workfile_startup @@ -1345,7 +1356,7 @@ def _prepare_last_workfile(data, workdir): if extensions: anatomy = data["anatomy"] # Find last workfile - file_template = anatomy.templates["work"]["file"] + file_template = anatomy.templates[workfile_template_key]["file"] workdir_data.update({ "version": 1, "user": get_openpype_username(), @@ -1369,54 +1380,8 @@ def _prepare_last_workfile(data, workdir): data["last_workfile_path"] = last_workfile_path -def get_option_from_settings( - startup_presets, host_name, task_name, default_output -): - host_name_lowered = host_name.lower() - task_name_lowered = task_name.lower() - - max_points = 2 - matching_points = -1 - matching_item = None - for item in startup_presets: - hosts = item.get("hosts") or tuple() - tasks = item.get("tasks") or tuple() - - hosts_lowered = tuple(_host_name.lower() for _host_name in hosts) - # Skip item if has set hosts and current host is not in - if hosts_lowered and host_name_lowered not in hosts_lowered: - continue - - tasks_lowered = tuple(_task_name.lower() for _task_name in tasks) - # Skip item if has set tasks and current task is not in - if tasks_lowered: - task_match = False - for task_regex in compile_list_of_regexes(tasks_lowered): - if re.match(task_regex, task_name_lowered): - task_match = True - break - - if not task_match: - continue - - points = int(bool(hosts_lowered)) + int(bool(tasks_lowered)) - if points > matching_points: - matching_item = item - matching_points = points - - if matching_points == max_points: - break - - if matching_item is not None: - output = matching_item.get("enabled") - if output is None: - output = default_output - return output - return default_output - - def should_start_last_workfile( - project_name, host_name, task_name, default_output=False + project_name, host_name, task_name, task_type, default_output=False ): """Define if host should start last version workfile if possible. @@ -1438,7 +1403,7 @@ def should_start_last_workfile( """ project_settings = get_project_settings(project_name) - startup_presets = ( + profiles = ( project_settings ["global"] ["tools"] @@ -1446,15 +1411,27 @@ def should_start_last_workfile( ["last_workfile_on_startup"] ) - if not startup_presets: + if not profiles: return default_output - return get_option_from_settings( - startup_presets, host_name, task_name, default_output) + filter_data = { + "tasks": task_name, + "task_types": task_type, + "hosts": host_name + } + matching_item = filter_profiles(profiles, filter_data) + + output = None + if matching_item: + output = matching_item.get("enabled") + + if output is None: + return default_output + return output def should_workfile_tool_start( - project_name, host_name, task_name, default_output=False + project_name, host_name, task_name, task_type, default_output=False ): """Define if host should start workfile tool at host launch. @@ -1476,7 +1453,7 @@ def should_workfile_tool_start( """ project_settings = get_project_settings(project_name) - startup_presets = ( + profiles = ( project_settings ["global"] ["tools"] @@ -1484,27 +1461,20 @@ def should_workfile_tool_start( ["open_workfile_tool_on_startup"] ) - if not startup_presets: + if not profiles: return default_output - return get_option_from_settings( - startup_presets, host_name, task_name, default_output) + filter_data = { + "tasks": task_name, + "task_types": task_type, + "hosts": host_name + } + matching_item = filter_profiles(profiles, filter_data) + output = None + if matching_item: + output = matching_item.get("enabled") -def compile_list_of_regexes(in_list): - """Convert strings in entered list to compiled regex objects.""" - regexes = list() - if not in_list: - return regexes - - for item in in_list: - if not item: - continue - try: - regexes.append(re.compile(item)) - except TypeError: - print(( - "Invalid type \"{}\" value \"{}\"." - " Expected string based object. Skipping." - ).format(str(type(item)), str(item))) - return regexes + if output is None: + return default_output + return output diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 497348af33..b043cbfdb4 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -10,6 +10,7 @@ import functools from openpype.settings import get_project_settings from .anatomy import Anatomy +from .profiles_filtering import filter_profiles # avalon module is not imported at the top # - may not be in path at the time of pype.lib initialization @@ -453,8 +454,6 @@ def get_workfile_template_key( if not profiles: return default - from .profiles_filtering import filter_profiles - profile_filter = { "task_types": task_type, "hosts": host_name @@ -791,7 +790,9 @@ class BuildWorkfile: current_task_name = avalon.io.Session["AVALON_TASK"] # Load workfile presets for task - self.build_presets = self.get_build_presets(current_task_name) + self.build_presets = self.get_build_presets( + current_task_name, current_asset_entity + ) # Skip if there are any presets for task if not self.build_presets: @@ -875,7 +876,7 @@ class BuildWorkfile: return loaded_containers @with_avalon - def get_build_presets(self, task_name): + def get_build_presets(self, task_name, asset_doc): """ Returns presets to build workfile for task name. Presets are loaded for current project set in @@ -889,30 +890,33 @@ class BuildWorkfile: (dict): preset per entered task name """ host_name = avalon.api.registered_host().__name__.rsplit(".", 1)[-1] - presets = get_project_settings(avalon.io.Session["AVALON_PROJECT"]) + project_settings = get_project_settings( + avalon.io.Session["AVALON_PROJECT"] + ) + host_settings = project_settings.get(host_name) or {} # Get presets for host - wb_settings = presets.get(host_name, {}).get("workfile_builder") - + wb_settings = host_settings.get("workfile_builder") if not wb_settings: # backward compatibility - wb_settings = presets.get(host_name, {}).get("workfile_build") + wb_settings = host_settings.get("workfile_build") or {} - builder_presets = wb_settings.get("profiles") + builder_profiles = wb_settings.get("profiles") + if not builder_profiles: + return None - if not builder_presets: - return - - task_name_low = task_name.lower() - per_task_preset = None - for preset in builder_presets: - preset_tasks = preset.get("tasks") or [] - preset_tasks_low = [task.lower() for task in preset_tasks] - if task_name_low in preset_tasks_low: - per_task_preset = preset - break - - return per_task_preset + task_type = ( + asset_doc + .get("data", {}) + .get("tasks", {}) + .get(task_name, {}) + .get("type") + ) + filter_data = { + "task_types": task_type, + "tasks": task_name + } + return filter_profiles(builder_profiles, filter_data) def _filter_build_profiles(self, build_profiles, loaders_by_name): """ Filter build profiles by loaders and prepare process data. diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 12fba23e82..a1111fba29 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -1,11 +1,10 @@ -import logging import os +import shlex import subprocess +import platform from .log import PypeLogger as Logger -log = logging.getLogger(__name__) - # MSDN process creation flag (Windows only) CREATE_NO_WINDOW = 0x08000000 @@ -100,7 +99,9 @@ def run_subprocess(*args, **kwargs): filtered_env = {str(k): str(v) for k, v in env.items()} # Use lib's logger if was not passed with kwargs. - logger = kwargs.pop("logger", log) + logger = kwargs.pop("logger", None) + if logger is None: + logger = Logger.get_logger("run_subprocess") # set overrides kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE) @@ -138,6 +139,14 @@ def run_subprocess(*args, **kwargs): return full_output +def path_to_subprocess_arg(path): + """Prepare path for subprocess arguments. + + Returned path can be wrapped with quotes or kept as is. + """ + return subprocess.list2cmdline([path]) + + def get_pype_execute_args(*args): """Arguments to run pype command. diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index e1dd1e7f10..048bf0eda0 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -1,6 +1,11 @@ +import json +import logging import os import re -import logging + + +from .anatomy import Anatomy +from openpype.settings import get_project_settings log = logging.getLogger(__name__) @@ -77,7 +82,7 @@ def get_version_from_path(file): """ pattern = re.compile(r"[\._]v([0-9]+)", re.IGNORECASE) try: - return pattern.findall(file)[0] + return pattern.findall(file)[-1] except IndexError: log.error( "templates:get_version_from_workfile:" @@ -119,3 +124,75 @@ def get_last_version_from_path(path_dir, filter): return filtred_files[-1] return None + + +def compute_paths(basic_paths_items, project_root): + pattern_array = re.compile(r"\[.*\]") + project_root_key = "__project_root__" + output = [] + for path_items in basic_paths_items: + clean_items = [] + for path_item in path_items: + matches = re.findall(pattern_array, path_item) + if len(matches) > 0: + path_item = path_item.replace(matches[0], "") + if path_item == project_root_key: + path_item = project_root + clean_items.append(path_item) + output.append(os.path.normpath(os.path.sep.join(clean_items))) + return output + + +def create_project_folders(basic_paths, project_name): + anatomy = Anatomy(project_name) + roots_paths = [] + if isinstance(anatomy.roots, dict): + for root in anatomy.roots.values(): + roots_paths.append(root.value) + else: + roots_paths.append(anatomy.roots.value) + + for root_path in roots_paths: + project_root = os.path.join(root_path, project_name) + full_paths = compute_paths(basic_paths, project_root) + # Create folders + for path in full_paths: + full_path = path.format(project_root=project_root) + if os.path.exists(full_path): + log.debug( + "Folder already exists: {}".format(full_path) + ) + else: + log.debug("Creating folder: {}".format(full_path)) + os.makedirs(full_path) + + +def _list_path_items(folder_structure): + output = [] + for key, value in folder_structure.items(): + if not value: + output.append(key) + else: + paths = _list_path_items(value) + for path in paths: + if not isinstance(path, (list, tuple)): + path = [path] + + item = [key] + item.extend(path) + output.append(item) + + return output + + +def get_project_basic_paths(project_name): + project_settings = get_project_settings(project_name) + folder_structure = ( + project_settings["global"]["project_folder_structure"] + ) + if not folder_structure: + return [] + + if isinstance(folder_structure, str): + folder_structure = json.loads(folder_structure) + return _list_path_items(folder_structure) diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 1f2fb7a46e..9dccadc44e 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -35,7 +35,8 @@ def get_subset_name( project_name=None, host_name=None, default_template=None, - dynamic_data=None + dynamic_data=None, + dbcon=None ): if not family: return "" @@ -46,13 +47,42 @@ def get_subset_name( # Use only last part of class family value split by dot (`.`) family = family.rsplit(".", 1)[-1] + if project_name is None: + import avalon.api + + project_name = avalon.api.Session["AVALON_PROJECT"] + + # Function should expect asset document instead of asset id + # - that way `dbcon` is not needed + if dbcon is None: + from avalon.api import AvalonMongoDB + + dbcon = AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = project_name + + dbcon.install() + + asset_doc = dbcon.find_one( + { + "type": "asset", + "_id": asset_id + }, + { + "data.tasks": True + } + ) + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + # Get settings tools_settings = get_project_settings(project_name)["global"]["tools"] profiles = tools_settings["creator"]["subset_name_profiles"] filtering_criteria = { "families": family, "hosts": host_name, - "tasks": task_name + "tasks": task_name, + "task_types": task_type } matching_profile = filter_profiles(profiles, filtering_criteria) diff --git a/openpype/lib/profiles_filtering.py b/openpype/lib/profiles_filtering.py index c4410204dd..0bb901aff8 100644 --- a/openpype/lib/profiles_filtering.py +++ b/openpype/lib/profiles_filtering.py @@ -1,10 +1,28 @@ import re import logging -from .applications import compile_list_of_regexes log = logging.getLogger(__name__) +def compile_list_of_regexes(in_list): + """Convert strings in entered list to compiled regex objects.""" + regexes = list() + if not in_list: + return regexes + + for item in in_list: + if not item: + continue + try: + regexes.append(re.compile(item)) + except TypeError: + print(( + "Invalid type \"{}\" value \"{}\"." + " Expected string based object. Skipping." + ).format(str(type(item)), str(item))) + return regexes + + def _profile_exclusion(matching_profiles, logger): """Find out most matching profile byt host, task and family match. @@ -165,7 +183,8 @@ def filter_profiles(profiles_data, key_values, keys_order=None, logger=None): if match == -1: profile_value = profile.get(key) or [] logger.debug( - "\"{}\" not found in {}".format(key, profile_value) + "\"{}\" not found in \"{}\": {}".format(value, key, + profile_value) ) profile_points = -1 break @@ -192,13 +211,13 @@ def filter_profiles(profiles_data, key_values, keys_order=None, logger=None): ]) if not matching_profiles: - logger.warning( + logger.info( "None of profiles match your setup. {}".format(log_parts) ) return None if len(matching_profiles) > 1: - logger.warning( + logger.info( "More than one profile match your setup. {}".format(log_parts) ) diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index c56782be9e..33715e369d 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -9,23 +9,76 @@ import openpype.version from openpype.settings.lib import get_local_settings from .execute import get_pype_execute_args from .local_settings import get_local_site_id +from .python_module_tools import import_filepath + + +def get_openpype_version(): + """Version of pype that is currently used.""" + return openpype.version.__version__ def get_pype_version(): - """Version of pype that is currently used.""" - return openpype.version.__version__ + """Backwards compatibility. Remove when 100% not used.""" + print(( + "Using deprecated function 'openpype.lib.pype_info.get_pype_version'" + " replace with 'openpype.lib.pype_info.get_openpype_version'." + )) + return get_openpype_version() + + +def get_build_version(): + """OpenPype version of build.""" + # Return OpenPype version if is running from code + if not is_running_from_build(): + return get_openpype_version() + + # Import `version.py` from build directory + version_filepath = os.path.join( + os.environ["OPENPYPE_ROOT"], + "openpype", + "version.py" + ) + if not os.path.exists(version_filepath): + return None + + module = import_filepath(version_filepath, "openpype_build_version") + return getattr(module, "__version__", None) + + +def is_running_from_build(): + """Determine if current process is running from build or code. + + Returns: + bool: True if running from build. + """ + executable_path = os.environ["OPENPYPE_EXECUTABLE"] + executable_filename = os.path.basename(executable_path) + if "python" in executable_filename.lower(): + return False + return True + + +def is_running_staging(): + """Currently used OpenPype is staging version. + + Returns: + bool: True if openpype version containt 'staging'. + """ + if "staging" in get_openpype_version(): + return True + return False def get_pype_info(): """Information about currently used Pype process.""" executable_args = get_pype_execute_args() - if len(executable_args) == 1: + if is_running_from_build(): version_type = "build" else: version_type = "code" return { - "version": get_pype_version(), + "version": get_openpype_version(), "version_type": version_type, "executable": executable_args[-1], "pype_root": os.environ["OPENPYPE_REPOS_ROOT"], @@ -73,7 +126,7 @@ def extract_pype_info_to_file(dirpath): filepath (str): Full path to file where data were extracted. """ filename = "{}_{}_{}.json".format( - get_pype_version(), + get_openpype_version(), get_local_site_id(), datetime.datetime.now().strftime("%y%m%d%H%M%S") ) diff --git a/openpype/modules/README.md b/openpype/modules/README.md index a3733518ac..5716324365 100644 --- a/openpype/modules/README.md +++ b/openpype/modules/README.md @@ -1,125 +1,143 @@ # OpenPype modules/addons -OpenPype modules should contain separated logic of specific kind of implementation, like Ftrack connection and usage code or Deadline farm rendering or may contain only special plugins. Addons work the same way currently there is no difference in module and addon. +OpenPype modules should contain separated logic of specific kind of implementation, such as Ftrack connection and its usage code, Deadline farm rendering or may contain only special plugins. Addons work the same way currently, there is no difference between module and addon functionality. ## Modules concept -- modules and addons are dynamically imported to virtual python module `openpype_modules` from which it is possible to import them no matter where is the modulo located -- modules or addons should never be imported directly even if you know possible full import path - - it is because all of their content must be imported in specific order and should not be imported without defined functions as it may also break few implementation parts +- modules and addons are dynamically imported to virtual python module `openpype_modules` from which it is possible to import them no matter where is the module located +- modules or addons should never be imported directly, even if you know possible full import path + - it is because all of their content must be imported in specific order and should not be imported without defined functions as it may also break few implementation parts ### TODOs - add module/addon manifest - - definition of module (not 100% defined content e.g. minimum require OpenPype version etc.) - - defying that folder is content of a module or an addon -- module/addon have it's settings schemas and default values outside OpenPype -- add general setting of paths to modules + - definition of module (not 100% defined content e.g. minimum required OpenPype version etc.) + - defining a folder as a content of a module or an addon ## Base class `OpenPypeModule` - abstract class as base for each module -- implementation should be module's api withou GUI parts -- may implement `get_global_environments` method which should return dictionary of environments that are globally appliable and value is the same for whole studio if launched at any workstation (except os specific paths) +- implementation should contain module's api without GUI parts +- may implement `get_global_environments` method which should return dictionary of environments that are globally applicable and value is the same for whole studio if launched at any workstation (except os specific paths) - abstract parts: - - `name` attribute - name of a module - - `initialize` method - method for own initialization of a module (should not override `__init__`) - - `connect_with_modules` method - where module may look for it's interfaces implementations or check for other modules -- `__init__` should not be overriden and `initialize` should not do time consuming part but only prepare base data about module - - also keep in mind that they may be initialized in headless mode + - `name` attribute - name of a module + - `initialize` method - method for own initialization of a module (should not override `__init__`) + - `connect_with_modules` method - where module may look for it's interfaces implementations or check for other modules +- `__init__` should not be overridden and `initialize` should not do time consuming part but only prepare base data about module + - also keep in mind that they may be initialized in headless mode - connection with other modules is made with help of interfaces +## Addon class `OpenPypeAddOn` +- inherits from `OpenPypeModule` but is enabled by default and doesn't have to implement `initialize` and `connect_with_modules` methods + - that is because it is expected that addons don't need to have system settings and `enabled` value on it (but it is possible...) + +## How to add addons/modules +- in System settings go to `modules/addon_paths` (`Modules/OpenPype AddOn Paths`) where you have to add path to addon root folder +- for openpype example addons use `{OPENPYPE_REPOS_ROOT}/openpype/modules/example_addons` + +## Addon/module settings +- addons/modules may have defined custom settings definitions with default values +- it is based on settings type `dynamic_schema` which has `name` + - that item defines that it can be replaced dynamically with any schemas from module or module which won't be saved to openpype core defaults + - they can't be added to any schema hierarchy + - item must not be in settings group (under overrides) or in dynamic item (e.g. `list` of `dict-modifiable`) + - addons may define it's dynamic schema items +- they can be defined with class which inherits from `BaseModuleSettingsDef` + - it is recommended to use pre implemented `JsonFilesSettingsDef` which defined structure and use json files to define dynamic schemas, schemas and default values + - check it's docstring and check for `example_addon` in example addons +- settings definition returns schemas by dynamic schemas names + # Interfaces -- interface is class that has defined abstract methods to implement and may contain preimplemented helper methods +- interface is class that has defined abstract methods to implement and may contain pre implemented helper methods - module that inherit from an interface must implement those abstract methods otherwise won't be initialized -- it is easy to find which module object inherited from which interfaces withh 100% chance they have implemented required methods +- it is easy to find which module object inherited from which interfaces with 100% chance they have implemented required methods - interfaces can be defined in `interfaces.py` inside module directory - - the file can't use relative imports or import anything from other parts - of module itself at the header of file - - this is one of reasons why modules/addons can't be imported directly without using defined functions in OpenPype modules implementation + - the file can't use relative imports or import anything from other parts + of module itself at the header of file + - this is one of reasons why modules/addons can't be imported directly without using defined functions in OpenPype modules implementation ## Base class `OpenPypeInterface` - has nothing implemented - has ABCMeta as metaclass - is defined to be able find out classes which inherit from this base to be - able tell this is an Interface + able tell this is an Interface ## Global interfaces - few interfaces are implemented for global usage ### IPluginPaths -- module want to add directory path/s to avalon or publish plugins +- module wants to add directory path/s to avalon or publish plugins - module must implement `get_plugin_paths` which must return dictionary with possible keys `"publish"`, `"load"`, `"create"` or `"actions"` - - each key may contain list or string with path to directory with plugins + - each key may contain list or string with a path to directory with plugins ### ITrayModule -- module has more logic when used in tray - - it is possible that module can be used only in tray +- module has more logic when used in a tray + - it is possible that module can be used only in the tray - abstract methods - - `tray_init` - initialization triggered after `initialize` when used in `TrayModulesManager` and before `connect_with_modules` - - `tray_menu` - add actions to tray widget's menu that represent the module - - `tray_start` - start of module's login in tray - - module is initialized and connected with other modules - - `tray_exit` - module's cleanup like stop and join threads etc. - - order of calling is based on implementation this order is how it works with `TrayModulesManager` - - it is recommended to import and use GUI implementaion only in these methods + - `tray_init` - initialization triggered after `initialize` when used in `TrayModulesManager` and before `connect_with_modules` + - `tray_menu` - add actions to tray widget's menu that represent the module + - `tray_start` - start of module's login in tray + - module is initialized and connected with other modules + - `tray_exit` - module's cleanup like stop and join threads etc. + - order of calling is based on implementation this order is how it works with `TrayModulesManager` + - it is recommended to import and use GUI implementation only in these methods - has attribute `tray_initialized` (bool) which is set to False by default and is set by `TrayModulesManager` to True after `tray_init` - - if module has logic only in tray or for both then should be checking for `tray_initialized` attribute to decide how should handle situations + - if module has logic only in tray or for both then should be checking for `tray_initialized` attribute to decide how should handle situations ### ITrayService -- inherit from `ITrayModule` and implement `tray_menu` method for you - - add action to submenu "Services" in tray widget menu with icon and label -- abstract atttribute `label` - - label shown in menu -- interface has preimplemented methods to change icon color - - `set_service_running` - green icon - - `set_service_failed` - red icon - - `set_service_idle` - orange icon - - these states must be set by module itself `set_service_running` is default state on initialization +- inherits from `ITrayModule` and implements `tray_menu` method for you + - adds action to submenu "Services" in tray widget menu with icon and label +- abstract attribute `label` + - label shown in menu +- interface has pre implemented methods to change icon color + - `set_service_running` - green icon + - `set_service_failed` - red icon + - `set_service_idle` - orange icon + - these states must be set by module itself `set_service_running` is default state on initialization ### ITrayAction -- inherit from `ITrayModule` and implement `tray_menu` method for you - - add action to tray widget menu with label -- abstract atttribute `label` - - label shown in menu +- inherits from `ITrayModule` and implements `tray_menu` method for you + - adds action to tray widget menu with label +- abstract attribute `label` + - label shown in menu - abstract method `on_action_trigger` - - what should happen when action is triggered -- NOTE: It is good idea to implement logic in `on_action_trigger` to api method and trigger that methods on callbacks this gives ability to trigger that method outside tray + - what should happen when an action is triggered +- NOTE: It is a good idea to implement logic in `on_action_trigger` to the api method and trigger that method on callbacks. This gives ability to trigger that method outside tray ## Modules interfaces -- modules may have defined their interfaces to be able recognize other modules that would want to use their features -- -### Example: -- Ftrack module has `IFtrackEventHandlerPaths` which helps to tell Ftrack module which of other modules want to add paths to server/user event handlers - - Clockify module use `IFtrackEventHandlerPaths` and return paths to clockify ftrack synchronizers +- modules may have defined their own interfaces to be able to recognize other modules that would want to use their features -- Clockify has more inharitance it's class definition looks like +### Example: +- Ftrack module has `IFtrackEventHandlerPaths` which helps to tell Ftrack module which other modules want to add paths to server/user event handlers + - Clockify module use `IFtrackEventHandlerPaths` and returns paths to clockify ftrack synchronizers + +- Clockify inherits from more interfaces. It's class definition looks like: ``` class ClockifyModule( - OpenPypeModule, # Says it's Pype module so ModulesManager will try to initialize. - ITrayModule, # Says has special implementation when used in tray. - IPluginPaths, # Says has plugin paths that want to register (paths to clockify actions for launcher). - IFtrackEventHandlerPaths, # Says has Ftrack actions/events for user/server. - ITimersManager # Listen to other modules with timer and can trigger changes in other module timers through `TimerManager` module. + OpenPypeModule, # Says it's Pype module so ModulesManager will try to initialize. + ITrayModule, # Says has special implementation when used in tray. + IPluginPaths, # Says has plugin paths that want to register (paths to clockify actions for launcher). + IFtrackEventHandlerPaths, # Says has Ftrack actions/events for user/server. + ITimersManager # Listen to other modules with timer and can trigger changes in other module timers through `TimerManager` module. ): ``` ### ModulesManager -- collect module classes and tries to initialize them +- collects module classes and tries to initialize them - important attributes - - `modules` - list of available attributes - - `modules_by_id` - dictionary of modules mapped by their ids - - `modules_by_name` - dictionary of modules mapped by their names - - all these attributes contain all found modules even if are not enabled + - `modules` - list of available attributes + - `modules_by_id` - dictionary of modules mapped by their ids + - `modules_by_name` - dictionary of modules mapped by their names + - all these attributes contain all found modules even if are not enabled - helper methods - - `collect_global_environments` to collect all global environments from enabled modules with calling `get_global_environments` on each of them - - `collect_plugin_paths` collect plugin paths from all enabled modules - - output is always dictionary with all keys and values as list - ``` - { - "publish": [], - "create": [], - "load": [], - "actions": [] - } - ``` + - `collect_global_environments` to collect all global environments from enabled modules with calling `get_global_environments` on each of them + - `collect_plugin_paths` collects plugin paths from all enabled modules + - output is always dictionary with all keys and values as an list + ``` + { + "publish": [], + "create": [], + "load": [], + "actions": [] + } + ``` ### TrayModulesManager -- inherit from `ModulesManager` -- has specific implementations for Pype Tray tool and handle `ITrayModule` methods +- inherits from `ModulesManager` +- has specific implementation for Pype Tray tool and handle `ITrayModule` methods \ No newline at end of file diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index 583480b049..68b5f6c247 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -1,21 +1,35 @@ # -*- coding: utf-8 -*- from .base import ( OpenPypeModule, + OpenPypeAddOn, OpenPypeInterface, load_modules, ModulesManager, - TrayModulesManager + TrayModulesManager, + + BaseModuleSettingsDef, + ModuleSettingsDef, + JsonFilesSettingsDef, + + get_module_settings_defs ) __all__ = ( "OpenPypeModule", + "OpenPypeAddOn", "OpenPypeInterface", "load_modules", "ModulesManager", - "TrayModulesManager" + "TrayModulesManager", + + "BaseModuleSettingsDef", + "ModuleSettingsDef", + "JsonFilesSettingsDef", + + "get_module_settings_defs" ) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d43d5635d1..748c7857a9 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -2,9 +2,11 @@ """Base class for Pype Modules.""" import os import sys +import json import time import inspect import logging +import platform import threading import collections from uuid import uuid4 @@ -12,7 +14,18 @@ from abc import ABCMeta, abstractmethod import six import openpype -from openpype.settings import get_system_settings +from openpype.settings import ( + get_system_settings, + SYSTEM_SETTINGS_KEY, + PROJECT_SETTINGS_KEY, + SCHEMA_KEY_SYSTEM_SETTINGS, + SCHEMA_KEY_PROJECT_SETTINGS +) + +from openpype.settings.lib import ( + get_studio_system_settings_overrides, + load_json_file +) from openpype.lib import PypeLogger @@ -115,11 +128,51 @@ def get_default_modules_dir(): return os.path.join(current_dir, "default_modules") +def get_dynamic_modules_dirs(): + """Possible paths to OpenPype Addons of Modules. + + Paths are loaded from studio settings under: + `modules -> addon_paths -> {platform name}` + + Path may contain environment variable as a formatting string. + + They are not validated or checked their existence. + + Returns: + list: Paths loaded from studio overrides. + """ + output = [] + value = get_studio_system_settings_overrides() + for key in ("modules", "addon_paths", platform.system().lower()): + if key not in value: + return output + value = value[key] + + for path in value: + if not path: + continue + + try: + path = path.format(**os.environ) + except Exception: + pass + output.append(path) + return output + + def get_module_dirs(): """List of paths where OpenPype modules can be found.""" - dirpaths = [ - get_default_modules_dir() - ] + _dirpaths = [] + _dirpaths.append(get_default_modules_dir()) + _dirpaths.extend(get_dynamic_modules_dirs()) + + dirpaths = [] + for path in _dirpaths: + if not path: + continue + normalized = os.path.normpath(path) + if normalized not in dirpaths: + dirpaths.append(normalized) return dirpaths @@ -165,6 +218,9 @@ def _load_interfaces(): os.path.join(get_default_modules_dir(), "interfaces.py") ) for dirpath in dirpaths: + if not os.path.exists(dirpath): + continue + for filename in os.listdir(dirpath): if filename in ("__pycache__", ): continue @@ -272,12 +328,19 @@ def _load_modules(): # TODO add more logic how to define if folder is module or not # - check manifest and content of manifest - if os.path.isdir(fullpath): - import_module_from_dirpath(dirpath, filename, modules_key) + try: + if os.path.isdir(fullpath): + import_module_from_dirpath(dirpath, filename, modules_key) - elif ext in (".py", ): - module = import_filepath(fullpath) - setattr(openpype_modules, basename, module) + elif ext in (".py", ): + module = import_filepath(fullpath) + setattr(openpype_modules, basename, module) + + except Exception: + log.error( + "Failed to import '{}'.".format(fullpath), + exc_info=True + ) class _OpenPypeInterfaceMeta(ABCMeta): @@ -354,7 +417,6 @@ class OpenPypeModule: """ pass - @abstractmethod def connect_with_modules(self, enabled_modules): """Connect with other enabled modules.""" pass @@ -368,7 +430,12 @@ class OpenPypeModule: class OpenPypeAddOn(OpenPypeModule): - pass + # Enable Addon by default + enabled = True + + def initialize(self, module_settings): + """Initialization is not be required for most of addons.""" + pass class ModulesManager: @@ -423,6 +490,7 @@ class ModulesManager: if ( not inspect.isclass(modules_item) or modules_item is OpenPypeModule + or modules_item is OpenPypeAddOn or not issubclass(modules_item, OpenPypeModule) ): continue @@ -920,3 +988,424 @@ class TrayModulesManager(ModulesManager): ), exc_info=True ) + + +def get_module_settings_defs(): + """Check loaded addons/modules for existence of thei settings definition. + + Check if OpenPype addon/module as python module has class that inherit + from `ModuleSettingsDef` in python module variables (imported + in `__init__py`). + + Returns: + list: All valid and not abstract settings definitions from imported + openpype addons and modules. + """ + # Make sure modules are loaded + load_modules() + + import openpype_modules + + settings_defs = [] + + log = PypeLogger.get_logger("ModuleSettingsLoad") + + for raw_module in openpype_modules: + for attr_name in dir(raw_module): + attr = getattr(raw_module, attr_name) + if ( + not inspect.isclass(attr) + or attr is ModuleSettingsDef + or not issubclass(attr, ModuleSettingsDef) + ): + continue + + if inspect.isabstract(attr): + # Find missing implementations by convetion on `abc` module + not_implemented = [] + for attr_name in dir(attr): + attr = getattr(attr, attr_name, None) + abs_method = getattr( + attr, "__isabstractmethod__", None + ) + if attr and abs_method: + not_implemented.append(attr_name) + + # Log missing implementations + log.warning(( + "Skipping abstract Class: {} in module {}." + " Missing implementations: {}" + ).format( + attr_name, raw_module.__name__, ", ".join(not_implemented) + )) + continue + + settings_defs.append(attr) + + return settings_defs + + +@six.add_metaclass(ABCMeta) +class BaseModuleSettingsDef: + """Definition of settings for OpenPype module or AddOn.""" + _id = None + + @property + def id(self): + """ID created on initialization. + + ID should be per created object. Helps to store objects. + """ + if self._id is None: + self._id = uuid4() + return self._id + + @abstractmethod + def get_settings_schemas(self, schema_type): + """Setting schemas for passed schema type. + + These are main schemas by dynamic schema keys. If they're using + sub schemas or templates they should be loaded with + `get_dynamic_schemas`. + + Returns: + dict: Schema by `dynamic_schema` keys. + """ + pass + + @abstractmethod + def get_dynamic_schemas(self, schema_type): + """Settings schemas and templates that can be used anywhere. + + It is recommended to add prefix specific for addon/module to keys + (e.g. "my_addon/real_schema_name"). + + Returns: + dict: Schemas and templates by their keys. + """ + pass + + @abstractmethod + def get_defaults(self, top_key): + """Default values for passed top key. + + Top keys are (currently) "system_settings" or "project_settings". + + Should return exactly what was passed with `save_defaults`. + + Returns: + dict: Default values by path to first key in OpenPype defaults. + """ + pass + + @abstractmethod + def save_defaults(self, top_key, data): + """Save default values for passed top key. + + Top keys are (currently) "system_settings" or "project_settings". + + Passed data are by path to first key defined in main schemas. + """ + pass + + +class ModuleSettingsDef(BaseModuleSettingsDef): + """Settings definiton with separated system and procect settings parts. + + Reduce conditions that must be checked and adds predefined methods for + each case. + """ + def get_defaults(self, top_key): + """Split method into 2 methods by top key.""" + if top_key == SYSTEM_SETTINGS_KEY: + return self.get_default_system_settings() or {} + elif top_key == PROJECT_SETTINGS_KEY: + return self.get_default_project_settings() or {} + return {} + + def save_defaults(self, top_key, data): + """Split method into 2 methods by top key.""" + if top_key == SYSTEM_SETTINGS_KEY: + self.save_system_defaults(data) + elif top_key == PROJECT_SETTINGS_KEY: + self.save_project_defaults(data) + + def get_settings_schemas(self, schema_type): + """Split method into 2 methods by schema type.""" + if schema_type == SCHEMA_KEY_SYSTEM_SETTINGS: + return self.get_system_settings_schemas() or {} + elif schema_type == SCHEMA_KEY_PROJECT_SETTINGS: + return self.get_project_settings_schemas() or {} + return {} + + def get_dynamic_schemas(self, schema_type): + """Split method into 2 methods by schema type.""" + if schema_type == SCHEMA_KEY_SYSTEM_SETTINGS: + return self.get_system_dynamic_schemas() or {} + elif schema_type == SCHEMA_KEY_PROJECT_SETTINGS: + return self.get_project_dynamic_schemas() or {} + return {} + + @abstractmethod + def get_system_settings_schemas(self): + """Schemas and templates usable in system settings schemas. + + Returns: + dict: Schemas and templates by it's names. Names must be unique + across whole OpenPype. + """ + pass + + @abstractmethod + def get_project_settings_schemas(self): + """Schemas and templates usable in project settings schemas. + + Returns: + dict: Schemas and templates by it's names. Names must be unique + across whole OpenPype. + """ + pass + + @abstractmethod + def get_system_dynamic_schemas(self): + """System schemas by dynamic schema name. + + If dynamic schema name is not available in then schema will not used. + + Returns: + dict: Schemas or list of schemas by dynamic schema name. + """ + pass + + @abstractmethod + def get_project_dynamic_schemas(self): + """Project schemas by dynamic schema name. + + If dynamic schema name is not available in then schema will not used. + + Returns: + dict: Schemas or list of schemas by dynamic schema name. + """ + pass + + @abstractmethod + def get_default_system_settings(self): + """Default system settings values. + + Returns: + dict: Default values by path to first key. + """ + pass + + @abstractmethod + def get_default_project_settings(self): + """Default project settings values. + + Returns: + dict: Default values by path to first key. + """ + pass + + @abstractmethod + def save_system_defaults(self, data): + """Save default system settings values. + + Passed data are by path to first key defined in main schemas. + """ + pass + + @abstractmethod + def save_project_defaults(self, data): + """Save default project settings values. + + Passed data are by path to first key defined in main schemas. + """ + pass + + +class JsonFilesSettingsDef(ModuleSettingsDef): + """Preimplemented settings definition using json files and file structure. + + Expected file structure: + β”• root + β”‚ + β”‚ # Default values + ┝ defaults + β”‚ ┝ system_settings.json + β”‚ β”• project_settings.json + β”‚ + β”‚ # Schemas for `dynamic_template` type + ┝ dynamic_schemas + β”‚ ┝ system_dynamic_schemas.json + β”‚ β”• project_dynamic_schemas.json + β”‚ + β”‚ # Schemas that can be used anywhere (enhancement for `dynamic_schemas`) + β”• schemas + ┝ system_schemas + β”‚ ┝ # Any schema or template files + β”‚ β”• ... + β”• project_schemas + ┝ # Any schema or template files + β”• ... + + Schemas can be loaded with prefix to avoid duplicated schema/template names + across all OpenPype addons/modules. Prefix can be defined with class + attribute `schema_prefix`. + + Only think which must be implemented in `get_settings_root_path` which + should return directory path to `root` (in structure graph above). + """ + # Possible way how to define `schemas` prefix + schema_prefix = "" + + @abstractmethod + def get_settings_root_path(self): + """Directory path where settings and it's schemas are located.""" + pass + + def __init__(self): + settings_root_dir = self.get_settings_root_path() + defaults_dir = os.path.join( + settings_root_dir, "defaults" + ) + dynamic_schemas_dir = os.path.join( + settings_root_dir, "dynamic_schemas" + ) + schemas_dir = os.path.join( + settings_root_dir, "schemas" + ) + + self.system_defaults_filepath = os.path.join( + defaults_dir, "system_settings.json" + ) + self.project_defaults_filepath = os.path.join( + defaults_dir, "project_settings.json" + ) + + self.system_dynamic_schemas_filepath = os.path.join( + dynamic_schemas_dir, "system_dynamic_schemas.json" + ) + self.project_dynamic_schemas_filepath = os.path.join( + dynamic_schemas_dir, "project_dynamic_schemas.json" + ) + + self.system_schemas_dir = os.path.join( + schemas_dir, "system_schemas" + ) + self.project_schemas_dir = os.path.join( + schemas_dir, "project_schemas" + ) + + def _load_json_file_data(self, path): + if os.path.exists(path): + return load_json_file(path) + return {} + + def get_default_system_settings(self): + """Default system settings values. + + Returns: + dict: Default values by path to first key. + """ + return self._load_json_file_data(self.system_defaults_filepath) + + def get_default_project_settings(self): + """Default project settings values. + + Returns: + dict: Default values by path to first key. + """ + return self._load_json_file_data(self.project_defaults_filepath) + + def _save_data_to_filepath(self, path, data): + dirpath = os.path.dirname(path) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + + with open(path, "w") as file_stream: + json.dump(data, file_stream, indent=4) + + def save_system_defaults(self, data): + """Save default system settings values. + + Passed data are by path to first key defined in main schemas. + """ + self._save_data_to_filepath(self.system_defaults_filepath, data) + + def save_project_defaults(self, data): + """Save default project settings values. + + Passed data are by path to first key defined in main schemas. + """ + self._save_data_to_filepath(self.project_defaults_filepath, data) + + def get_system_dynamic_schemas(self): + """System schemas by dynamic schema name. + + If dynamic schema name is not available in then schema will not used. + + Returns: + dict: Schemas or list of schemas by dynamic schema name. + """ + return self._load_json_file_data(self.system_dynamic_schemas_filepath) + + def get_project_dynamic_schemas(self): + """Project schemas by dynamic schema name. + + If dynamic schema name is not available in then schema will not used. + + Returns: + dict: Schemas or list of schemas by dynamic schema name. + """ + return self._load_json_file_data(self.project_dynamic_schemas_filepath) + + def _load_files_from_path(self, path): + output = {} + if not path or not os.path.exists(path): + return output + + if os.path.isfile(path): + filename = os.path.basename(path) + basename, ext = os.path.splitext(filename) + if ext == ".json": + if self.schema_prefix: + key = "{}/{}".format(self.schema_prefix, basename) + else: + key = basename + output[key] = self._load_json_file_data(path) + return output + + path = os.path.normpath(path) + for root, _, files in os.walk(path, topdown=False): + for filename in files: + basename, ext = os.path.splitext(filename) + if ext != ".json": + continue + + json_path = os.path.join(root, filename) + store_key = os.path.join( + root.replace(path, ""), basename + ).replace("\\", "/") + if self.schema_prefix: + store_key = "{}/{}".format(self.schema_prefix, store_key) + output[store_key] = self._load_json_file_data(json_path) + + return output + + def get_system_settings_schemas(self): + """Schemas and templates usable in system settings schemas. + + Returns: + dict: Schemas and templates by it's names. Names must be unique + across whole OpenPype. + """ + return self._load_files_from_path(self.system_schemas_dir) + + def get_project_settings_schemas(self): + """Schemas and templates usable in project settings schemas. + + Returns: + dict: Schemas and templates by it's names. Names must be unique + across whole OpenPype. + """ + return self._load_files_from_path(self.project_schemas_dir) diff --git a/openpype/modules/default_modules/avalon_apps/avalon_app.py b/openpype/modules/default_modules/avalon_apps/avalon_app.py index 53e06ec90a..d21b37e520 100644 --- a/openpype/modules/default_modules/avalon_apps/avalon_app.py +++ b/openpype/modules/default_modules/avalon_apps/avalon_app.py @@ -2,13 +2,10 @@ import os import openpype from openpype import resources from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITrayModule, - IWebServerRoutes -) +from openpype_interfaces import ITrayModule -class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes): +class AvalonModule(OpenPypeModule, ITrayModule): name = "avalon" def initialize(self, modules_settings): @@ -55,12 +52,12 @@ class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes): def tray_init(self): # Add library tool try: - from avalon.tools.libraryloader import app - from avalon import style from Qt import QtGui + from avalon import style + from openpype.tools.libraryloader import LibraryLoaderWindow - self.libraryloader = app.Window( - icon=QtGui.QIcon(resources.pype_icon_filepath()), + self.libraryloader = LibraryLoaderWindow( + icon=QtGui.QIcon(resources.get_openpype_icon_filepath()), show_projects=True, show_libraries=True ) @@ -71,16 +68,6 @@ class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes): exc_info=True ) - def connect_with_modules(self, _enabled_modules): - return - - def webserver_initialization(self, server_manager): - """Implementation of IWebServerRoutes interface.""" - - if self.tray_initialized: - from .rest_api import AvalonRestApiResource - self.rest_api_obj = AvalonRestApiResource(self, server_manager) - # Definition of Tray menu def tray_menu(self, tray_menu): from Qt import QtWidgets @@ -108,3 +95,10 @@ class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes): # for Windows self.libraryloader.activateWindow() self.libraryloader.refresh() + + # Webserver module implementation + def webserver_initialization(self, server_manager): + """Add routes for webserver.""" + if self.tray_initialized: + from .rest_api import AvalonRestApiResource + self.rest_api_obj = AvalonRestApiResource(self, server_manager) diff --git a/openpype/modules/default_modules/clockify/clockify_module.py b/openpype/modules/default_modules/clockify/clockify_module.py index a9e989f4ec..932ce87c36 100644 --- a/openpype/modules/default_modules/clockify/clockify_module.py +++ b/openpype/modules/default_modules/clockify/clockify_module.py @@ -10,18 +10,14 @@ from .constants import ( from openpype.modules import OpenPypeModule from openpype_interfaces import ( ITrayModule, - IPluginPaths, - IFtrackEventHandlerPaths, - ITimersManager + IPluginPaths ) class ClockifyModule( OpenPypeModule, ITrayModule, - IPluginPaths, - IFtrackEventHandlerPaths, - ITimersManager + IPluginPaths ): name = "clockify" @@ -39,6 +35,11 @@ class ClockifyModule( self.clockapi = ClockifyAPI(master_parent=self) + # TimersManager attributes + # - set `timers_manager_connector` only in `tray_init` + self.timers_manager_connector = None + self._timers_manager_module = None + def get_global_environments(self): return { "CLOCKIFY_WORKSPACE": self.workspace_name @@ -61,6 +62,9 @@ class ClockifyModule( self.bool_timer_run = False self.bool_api_key_set = self.clockapi.set_api() + # Define itself as TimersManager connector + self.timers_manager_connector = self + def tray_start(self): if self.bool_api_key_set is False: self.show_settings() @@ -87,16 +91,13 @@ class ClockifyModule( "actions": [actions_path] } - def get_event_handler_paths(self): - """Implementaton of IFtrackEventHandlerPaths to get plugin paths.""" + def get_ftrack_event_handler_paths(self): + """Function for Ftrack module to add ftrack event handler paths.""" return { "user": [CLOCKIFY_FTRACK_USER_PATH], "server": [CLOCKIFY_FTRACK_SERVER_PATH] } - def connect_with_modules(self, *_a, **_kw): - return - def clockify_timer_stopped(self): self.bool_timer_run = False # Call `ITimersManager` method @@ -165,10 +166,6 @@ class ClockifyModule( self.set_menu_visibility() time.sleep(5) - def stop_timer(self): - """Implementation of ITimersManager.""" - self.clockapi.finish_time_entry() - def signed_in(self): if not self.timer_manager: return @@ -179,8 +176,60 @@ class ClockifyModule( if self.timer_manager.is_running: self.start_timer_manager(self.timer_manager.last_task) + def on_message_widget_close(self): + self.message_widget = None + + # Definition of Tray menu + def tray_menu(self, parent_menu): + # Menu for Tray App + from Qt import QtWidgets + menu = QtWidgets.QMenu("Clockify", parent_menu) + menu.setProperty("submenu", "on") + + # Actions + action_show_settings = QtWidgets.QAction("Settings", menu) + action_stop_timer = QtWidgets.QAction("Stop timer", menu) + + menu.addAction(action_show_settings) + menu.addAction(action_stop_timer) + + action_show_settings.triggered.connect(self.show_settings) + action_stop_timer.triggered.connect(self.stop_timer) + + self.action_stop_timer = action_stop_timer + + self.set_menu_visibility() + + parent_menu.addMenu(menu) + + def show_settings(self): + self.widget_settings.input_api_key.setText(self.clockapi.get_api_key()) + self.widget_settings.show() + + def set_menu_visibility(self): + self.action_stop_timer.setVisible(self.bool_timer_run) + + # --- TimersManager connection methods --- + def register_timers_manager(self, timer_manager_module): + """Store TimersManager for future use.""" + self._timers_manager_module = timer_manager_module + + def timer_started(self, data): + """Tell TimersManager that timer started.""" + if self._timers_manager_module is not None: + self._timers_manager_module.timer_started(self._module.id, data) + + def timer_stopped(self): + """Tell TimersManager that timer stopped.""" + if self._timers_manager_module is not None: + self._timers_manager_module.timer_stopped(self._module.id) + + def stop_timer(self): + """Called from TimersManager to stop timer.""" + self.clockapi.finish_time_entry() + def start_timer(self, input_data): - """Implementation of ITimersManager.""" + """Called from TimersManager to start timer.""" # If not api key is not entered then skip if not self.clockapi.get_api_key(): return @@ -237,36 +286,3 @@ class ClockifyModule( self.clockapi.start_time_entry( description, project_id, tag_ids=tag_ids ) - - def on_message_widget_close(self): - self.message_widget = None - - # Definition of Tray menu - def tray_menu(self, parent_menu): - # Menu for Tray App - from Qt import QtWidgets - menu = QtWidgets.QMenu("Clockify", parent_menu) - menu.setProperty("submenu", "on") - - # Actions - action_show_settings = QtWidgets.QAction("Settings", menu) - action_stop_timer = QtWidgets.QAction("Stop timer", menu) - - menu.addAction(action_show_settings) - menu.addAction(action_stop_timer) - - action_show_settings.triggered.connect(self.show_settings) - action_stop_timer.triggered.connect(self.stop_timer) - - self.action_stop_timer = action_stop_timer - - self.set_menu_visibility() - - parent_menu.addMenu(menu) - - def show_settings(self): - self.widget_settings.input_api_key.setText(self.clockapi.get_api_key()) - self.widget_settings.show() - - def set_menu_visibility(self): - self.action_stop_timer.setVisible(self.bool_timer_run) diff --git a/openpype/modules/default_modules/clockify/widgets.py b/openpype/modules/default_modules/clockify/widgets.py index fc8e7fa8a3..d58df3c067 100644 --- a/openpype/modules/default_modules/clockify/widgets.py +++ b/openpype/modules/default_modules/clockify/widgets.py @@ -13,7 +13,7 @@ class MessageWidget(QtWidgets.QWidget): super(MessageWidget, self).__init__() # Icon - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( @@ -90,7 +90,7 @@ class ClockifySettings(QtWidgets.QWidget): self.validated = False # Icon - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowTitle("Clockify settings") diff --git a/openpype/modules/default_modules/deadline/deadline_module.py b/openpype/modules/default_modules/deadline/deadline_module.py index ada5e8225a..1a179e9aaf 100644 --- a/openpype/modules/default_modules/deadline/deadline_module.py +++ b/openpype/modules/default_modules/deadline/deadline_module.py @@ -26,9 +26,6 @@ class DeadlineModule(OpenPypeModule, IPluginPaths): "not specified. Disabling module.")) return - def connect_with_modules(self, *_a, **_kw): - return - def get_plugin_paths(self): """Deadline plugin paths.""" current_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py b/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py index d449c4b7df..58f79e8a2b 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_server/action_sync_to_avalon.py @@ -1,4 +1,6 @@ import time +import sys +import json import traceback from openpype_modules.ftrack.lib import ServerAction @@ -52,17 +54,80 @@ class SyncToAvalonServer(ServerAction): return False def launch(self, session, in_entities, event): + self.log.debug("{}: Creating job".format(self.label)) + + user_entity = session.query( + "User where id is {}".format(event["source"]["user"]["id"]) + ).one() + job_entity = session.create("Job", { + "user": user_entity, + "status": "running", + "data": json.dumps({ + "description": "Sync to avalon is running..." + }) + }) + session.commit() + + project_entity = self.get_project_from_entity(in_entities[0]) + project_name = project_entity["full_name"] + + try: + result = self.synchronization(event, project_name) + + except Exception: + self.log.error( + "Synchronization failed due to code error", exc_info=True + ) + + description = "Sync to avalon Crashed (Download traceback)" + self.add_traceback_to_job( + job_entity, session, sys.exc_info(), description + ) + + msg = "An error has happened during synchronization" + title = "Synchronization report ({}):".format(project_name) + items = [] + items.append({ + "type": "label", + "value": "# {}".format(msg) + }) + items.append({ + "type": "label", + "value": ( + "

Download report from job for more information.

" + ) + }) + + report = {} + try: + report = self.entities_factory.report() + except Exception: + pass + + _items = report.get("items") or [] + if _items: + items.append(self.entities_factory.report_splitter) + items.extend(_items) + + self.show_interface(items, title, event, submit_btn_label="Ok") + + return {"success": True, "message": msg} + + job_entity["status"] = "done" + job_entity["data"] = json.dumps({ + "description": "Sync to avalon finished." + }) + session.commit() + + return result + + def synchronization(self, event, project_name): time_start = time.time() self.show_message(event, "Synchronization - Preparing data", True) - # Get ftrack project - if in_entities[0].entity_type.lower() == "project": - ft_project_name = in_entities[0]["full_name"] - else: - ft_project_name = in_entities[0]["project"]["full_name"] try: - output = self.entities_factory.launch_setup(ft_project_name) + output = self.entities_factory.launch_setup(project_name) if output is not None: return output @@ -72,7 +137,7 @@ class SyncToAvalonServer(ServerAction): time_2 = time.time() # This must happen before all filtering!!! - self.entities_factory.prepare_avalon_entities(ft_project_name) + self.entities_factory.prepare_avalon_entities(project_name) time_3 = time.time() self.entities_factory.filter_by_ignore_sync() @@ -118,7 +183,7 @@ class SyncToAvalonServer(ServerAction): report = self.entities_factory.report() if report and report.get("items"): default_title = "Synchronization report ({}):".format( - ft_project_name + project_name ) self.show_interface( items=report["items"], @@ -130,46 +195,6 @@ class SyncToAvalonServer(ServerAction): "message": "Synchronization Finished" } - except Exception: - self.log.error( - "Synchronization failed due to code error", exc_info=True - ) - msg = "An error has happened during synchronization" - title = "Synchronization report ({}):".format(ft_project_name) - items = [] - items.append({ - "type": "label", - "value": "# {}".format(msg) - }) - items.append({ - "type": "label", - "value": "## Traceback of the error" - }) - items.append({ - "type": "label", - "value": "

{}

".format( - str(traceback.format_exc()).replace( - "\n", "
").replace( - " ", " " - ) - ) - }) - - report = {"items": []} - try: - report = self.entities_factory.report() - except Exception: - pass - - _items = report.get("items", []) - if _items: - items.append(self.entities_factory.report_splitter) - items.extend(_items) - - self.show_interface(items, title, event) - - return {"success": True, "message": msg} - finally: try: self.entities_factory.dbcon.uninstall() diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py index 121c9f652b..94f359c317 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py @@ -3,7 +3,7 @@ import re import json from openpype_modules.ftrack.lib import BaseAction, statics_icon -from openpype.api import Anatomy, get_project_settings +from openpype.api import get_project_basic_paths, create_project_folders class CreateProjectFolders(BaseAction): @@ -72,25 +72,18 @@ class CreateProjectFolders(BaseAction): def launch(self, session, entities, event): # Get project entity project_entity = self.get_project_from_entity(entities[0]) - # Load settings for project project_name = project_entity["full_name"] - project_settings = get_project_settings(project_name) - project_folder_structure = ( - project_settings["global"]["project_folder_structure"] - ) - if not project_folder_structure: - return { - "success": False, - "message": "Project structure is not set." - } - try: - if isinstance(project_folder_structure, str): - project_folder_structure = json.loads(project_folder_structure) - # Get paths based on presets - basic_paths = self.get_path_items(project_folder_structure) - self.create_folders(basic_paths, project_entity) + basic_paths = get_project_basic_paths(project_name) + if not basic_paths: + return { + "success": False, + "message": "Project structure is not set." + } + + # Invoking OpenPype API to create the project folders + create_project_folders(basic_paths, project_name) self.create_ftrack_entities(basic_paths, project_entity) except Exception as exc: @@ -195,58 +188,6 @@ class CreateProjectFolders(BaseAction): self.session.commit() return new_ent - def get_path_items(self, in_dict): - output = [] - for key, value in in_dict.items(): - if not value: - output.append(key) - else: - paths = self.get_path_items(value) - for path in paths: - if not isinstance(path, (list, tuple)): - path = [path] - - output.append([key, *path]) - - return output - - def compute_paths(self, basic_paths_items, project_root): - output = [] - for path_items in basic_paths_items: - clean_items = [] - for path_item in path_items: - matches = re.findall(self.pattern_array, path_item) - if len(matches) > 0: - path_item = path_item.replace(matches[0], "") - if path_item == self.project_root_key: - path_item = project_root - clean_items.append(path_item) - output.append(os.path.normpath(os.path.sep.join(clean_items))) - return output - - def create_folders(self, basic_paths, project): - anatomy = Anatomy(project["full_name"]) - roots_paths = [] - if isinstance(anatomy.roots, dict): - for root in anatomy.roots.values(): - roots_paths.append(root.value) - else: - roots_paths.append(anatomy.roots.value) - - for root_path in roots_paths: - project_root = os.path.join(root_path, project["full_name"]) - full_paths = self.compute_paths(basic_paths, project_root) - # Create folders - for path in full_paths: - full_path = path.format(project_root=project_root) - if os.path.exists(full_path): - self.log.debug( - "Folder already exists: {}".format(full_path) - ) - else: - self.log.debug("Creating folder: {}".format(full_path)) - os.makedirs(full_path) - def register(session): CreateProjectFolders(session).register() diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py index 063f086e9c..c66d1819ac 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_old_versions.py @@ -23,6 +23,8 @@ class DeleteOldVersions(BaseAction): ) icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") + settings_key = "delete_old_versions" + dbcon = AvalonMongoDB() inteface_title = "Choose your preferences" diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py index d6ca561bbe..cd2f371f38 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_sync_to_avalon.py @@ -1,4 +1,6 @@ import time +import sys +import json import traceback from openpype_modules.ftrack.lib import BaseAction, statics_icon @@ -30,17 +32,10 @@ class SyncToAvalonLocal(BaseAction): - or do it manually (Not recommended) """ - #: Action identifier. identifier = "sync.to.avalon.local" - #: Action label. label = "OpenPype Admin" - #: Action variant variant = "- Sync To Avalon (Local)" - #: Action description. - description = "Send data from Ftrack to Avalon" - #: priority priority = 200 - #: roles that are allowed to register this action icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "sync_to_avalon_local" @@ -63,17 +58,80 @@ class SyncToAvalonLocal(BaseAction): return is_valid def launch(self, session, in_entities, event): + self.log.debug("{}: Creating job".format(self.label)) + + user_entity = session.query( + "User where id is {}".format(event["source"]["user"]["id"]) + ).one() + job_entity = session.create("Job", { + "user": user_entity, + "status": "running", + "data": json.dumps({ + "description": "Sync to avalon is running..." + }) + }) + session.commit() + + project_entity = self.get_project_from_entity(in_entities[0]) + project_name = project_entity["full_name"] + + try: + result = self.synchronization(event, project_name) + + except Exception: + self.log.error( + "Synchronization failed due to code error", exc_info=True + ) + + description = "Sync to avalon Crashed (Download traceback)" + self.add_traceback_to_job( + job_entity, session, sys.exc_info(), description + ) + + msg = "An error has happened during synchronization" + title = "Synchronization report ({}):".format(project_name) + items = [] + items.append({ + "type": "label", + "value": "# {}".format(msg) + }) + items.append({ + "type": "label", + "value": ( + "

Download report from job for more information.

" + ) + }) + + report = {} + try: + report = self.entities_factory.report() + except Exception: + pass + + _items = report.get("items") or [] + if _items: + items.append(self.entities_factory.report_splitter) + items.extend(_items) + + self.show_interface(items, title, event, submit_btn_label="Ok") + + return {"success": True, "message": msg} + + job_entity["status"] = "done" + job_entity["data"] = json.dumps({ + "description": "Sync to avalon finished." + }) + session.commit() + + return result + + def synchronization(self, event, project_name): time_start = time.time() self.show_message(event, "Synchronization - Preparing data", True) - # Get ftrack project - if in_entities[0].entity_type.lower() == "project": - ft_project_name = in_entities[0]["full_name"] - else: - ft_project_name = in_entities[0]["project"]["full_name"] try: - output = self.entities_factory.launch_setup(ft_project_name) + output = self.entities_factory.launch_setup(project_name) if output is not None: return output @@ -83,7 +141,7 @@ class SyncToAvalonLocal(BaseAction): time_2 = time.time() # This must happen before all filtering!!! - self.entities_factory.prepare_avalon_entities(ft_project_name) + self.entities_factory.prepare_avalon_entities(project_name) time_3 = time.time() self.entities_factory.filter_by_ignore_sync() @@ -129,7 +187,7 @@ class SyncToAvalonLocal(BaseAction): report = self.entities_factory.report() if report and report.get("items"): default_title = "Synchronization report ({}):".format( - ft_project_name + project_name ) self.show_interface( items=report["items"], @@ -141,46 +199,6 @@ class SyncToAvalonLocal(BaseAction): "message": "Synchronization Finished" } - except Exception: - self.log.error( - "Synchronization failed due to code error", exc_info=True - ) - msg = "An error occurred during synchronization" - title = "Synchronization report ({}):".format(ft_project_name) - items = [] - items.append({ - "type": "label", - "value": "# {}".format(msg) - }) - items.append({ - "type": "label", - "value": "## Traceback of the error" - }) - items.append({ - "type": "label", - "value": "

{}

".format( - str(traceback.format_exc()).replace( - "\n", "
").replace( - " ", " " - ) - ) - }) - - report = {"items": []} - try: - report = self.entities_factory.report() - except Exception: - pass - - _items = report.get("items", []) - if _items: - items.append(self.entities_factory.report_splitter) - items.extend(_items) - - self.show_interface(items, title, event) - - return {"success": True, "message": msg} - finally: try: self.entities_factory.dbcon.uninstall() diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 1de152535c..c73f9b100d 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -7,10 +7,8 @@ from openpype.modules import OpenPypeModule from openpype_interfaces import ( ITrayModule, IPluginPaths, - ITimersManager, ILaunchHookPaths, - ISettingsChangeListener, - IFtrackEventHandlerPaths + ISettingsChangeListener ) from openpype.settings import SaveWarningExc @@ -21,7 +19,6 @@ class FtrackModule( OpenPypeModule, ITrayModule, IPluginPaths, - ITimersManager, ILaunchHookPaths, ISettingsChangeListener ): @@ -61,6 +58,10 @@ class FtrackModule( self.user_event_handlers_paths = user_event_handlers_paths self.tray_module = None + # TimersManager connection + self.timers_manager_connector = None + self._timers_manager_module = None + def get_global_environments(self): """Ftrack's global environments.""" return { @@ -79,9 +80,17 @@ class FtrackModule( def connect_with_modules(self, enabled_modules): for module in enabled_modules: - if not isinstance(module, IFtrackEventHandlerPaths): + if not hasattr(module, "get_ftrack_event_handler_paths"): continue - paths_by_type = module.get_event_handler_paths() or {} + + try: + paths_by_type = module.get_ftrack_event_handler_paths() + except Exception: + continue + + if not isinstance(paths_by_type, dict): + continue + for key, value in paths_by_type.items(): if not value: continue @@ -102,16 +111,6 @@ class FtrackModule( elif key == "user": self.user_event_handlers_paths.extend(value) - def start_timer(self, data): - """Implementation of ITimersManager interface.""" - if self.tray_module: - self.tray_module.start_timer_manager(data) - - def stop_timer(self): - """Implementation of ITimersManager interface.""" - if self.tray_module: - self.tray_module.stop_timer_manager() - def on_system_settings_save( self, old_value, new_value, changes, new_value_metadata ): @@ -343,7 +342,10 @@ class FtrackModule( def tray_init(self): from .tray import FtrackTrayWrapper + self.tray_module = FtrackTrayWrapper(self) + # Module is it's own connector to TimersManager + self.timers_manager_connector = self def tray_menu(self, parent_menu): return self.tray_module.tray_menu(parent_menu) @@ -357,3 +359,23 @@ class FtrackModule( def set_credentials_to_env(self, username, api_key): os.environ["FTRACK_API_USER"] = username or "" os.environ["FTRACK_API_KEY"] = api_key or "" + + # --- TimersManager connection methods --- + def start_timer(self, data): + if self.tray_module: + self.tray_module.start_timer_manager(data) + + def stop_timer(self): + if self.tray_module: + self.tray_module.stop_timer_manager() + + def register_timers_manager(self, timer_manager_module): + self._timers_manager_module = timer_manager_module + + def timer_started(self, data): + if self._timers_manager_module is not None: + self._timers_manager_module.timer_started(self.id, data) + + def timer_stopped(self): + if self._timers_manager_module is not None: + self._timers_manager_module.timer_stopped(self.id) diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py index d8e4d05580..075694d8f6 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py @@ -6,7 +6,6 @@ import subprocess import socket import json import platform -import argparse import getpass import atexit import time @@ -16,7 +15,9 @@ import ftrack_api import pymongo from openpype.lib import ( get_pype_execute_args, - OpenPypeMongoConnection + OpenPypeMongoConnection, + get_openpype_version, + get_build_version ) from openpype_modules.ftrack import FTRACK_MODULE_DIR from openpype_modules.ftrack.lib import credentials @@ -236,14 +237,16 @@ def main_loop(ftrack_url): statuser_thread=statuser_thread ) - system_name, pc_name = platform.uname()[:2] host_name = socket.gethostname() - main_info = { - "created_at": datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S"), - "Username": getpass.getuser(), - "Host Name": host_name, - "Host IP": socket.gethostbyname(host_name) - } + main_info = [ + ["created_at", datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S")], + ["Username", getpass.getuser()], + ["Host Name", host_name], + ["Host IP", socket.gethostbyname(host_name)], + ["OpenPype executable", get_pype_execute_args()[-1]], + ["OpenPype version", get_openpype_version() or "N/A"], + ["OpenPype build version", get_build_version() or "N/A"] + ] main_info_str = json.dumps(main_info) # Main loop while True: diff --git a/openpype/modules/default_modules/ftrack/interfaces.py b/openpype/modules/default_modules/ftrack/interfaces.py deleted file mode 100644 index 16ce0d2e62..0000000000 --- a/openpype/modules/default_modules/ftrack/interfaces.py +++ /dev/null @@ -1,12 +0,0 @@ -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class IFtrackEventHandlerPaths(OpenPypeInterface): - """Other modules interface to return paths to ftrack event handlers. - - Expected output is dictionary with "server" and "user" keys. - """ - @abstractmethod - def get_event_handler_paths(self): - pass diff --git a/openpype/modules/default_modules/ftrack/lib/__init__.py b/openpype/modules/default_modules/ftrack/lib/__init__.py index 9dc2d67279..433a1f7881 100644 --- a/openpype/modules/default_modules/ftrack/lib/__init__.py +++ b/openpype/modules/default_modules/ftrack/lib/__init__.py @@ -5,8 +5,7 @@ from .constants import ( CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS ) -from . settings import ( - get_ftrack_url_from_settings, +from .settings import ( get_ftrack_event_mongo_info ) from .custom_attributes import ( @@ -31,7 +30,6 @@ __all__ = ( "CUST_ATTR_TOOLS", "CUST_ATTR_APPLICATIONS", - "get_ftrack_url_from_settings", "get_ftrack_event_mongo_info", "default_custom_attributes_definition", diff --git a/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py b/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py index 7027154d86..a457b886ac 100644 --- a/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py +++ b/openpype/modules/default_modules/ftrack/lib/ftrack_base_handler.py @@ -384,8 +384,8 @@ class BaseHandler(object): ) def show_interface( - self, items, title='', - event=None, user=None, username=None, user_id=None + self, items, title="", event=None, user=None, + username=None, user_id=None, submit_btn_label=None ): """ Shows interface to user @@ -428,14 +428,18 @@ class BaseHandler(object): 'applicationId=ftrack.client.web and user.id="{0}"' ).format(user_id) + event_data = { + "type": "widget", + "items": items, + "title": title + } + if submit_btn_label: + event_data["submit_button_label"] = submit_btn_label + self.session.event_hub.publish( ftrack_api.event.base.Event( topic='ftrack.action.trigger-user-interface', - data=dict( - type='widget', - items=items, - title=title - ), + data=event_data, target=target ), on_error='ignore' @@ -443,7 +447,7 @@ class BaseHandler(object): def show_interface_from_dict( self, messages, title="", event=None, - user=None, username=None, user_id=None + user=None, username=None, user_id=None, submit_btn_label=None ): if not messages: self.log.debug("No messages to show! (messages dict is empty)") @@ -469,7 +473,9 @@ class BaseHandler(object): message = {'type': 'label', 'value': '

{}

'.format(value)} items.append(message) - self.show_interface(items, title, event, user, username, user_id) + self.show_interface( + items, title, event, user, username, user_id, submit_btn_label + ) def trigger_action( self, action_name, event=None, session=None, diff --git a/openpype/modules/default_modules/ftrack/lib/settings.py b/openpype/modules/default_modules/ftrack/lib/settings.py index 027356edc6..bf44981de0 100644 --- a/openpype/modules/default_modules/ftrack/lib/settings.py +++ b/openpype/modules/default_modules/ftrack/lib/settings.py @@ -1,13 +1,4 @@ import os -from openpype.api import get_system_settings - - -def get_ftrack_settings(): - return get_system_settings()["modules"]["ftrack"] - - -def get_ftrack_url_from_settings(): - return get_ftrack_settings()["ftrack_server"] def get_ftrack_event_mongo_info(): diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py index cc2a5b7d37..70030acad9 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py @@ -68,9 +68,6 @@ class CollectFtrackFamily(pyblish.api.InstancePlugin): instance.data["families"].append("ftrack") else: instance.data["families"] = ["ftrack"] - else: - 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, diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py b/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py index 51b45eb93b..d1e2e3aaeb 100644 --- a/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py +++ b/openpype/modules/default_modules/ftrack/scripts/sub_event_processor.py @@ -13,6 +13,11 @@ from openpype_modules.ftrack.ftrack_server.lib import ( from openpype.modules import ModulesManager from openpype.api import Logger +from openpype.lib import ( + get_openpype_version, + get_build_version +) + import ftrack_api @@ -40,9 +45,11 @@ def send_status(event): new_event_data = { "subprocess_id": subprocess_id, "source": "processor", - "status_info": { - "created_at": subprocess_started.strftime("%Y.%m.%d %H:%M:%S") - } + "status_info": [ + ["created_at", subprocess_started.strftime("%Y.%m.%d %H:%M:%S")], + ["OpenPype version", get_openpype_version() or "N/A"], + ["OpenPype build version", get_build_version() or "N/A"] + ] } new_event = ftrack_api.event.base.Event( diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py b/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py index 8a2733b635..004f61338c 100644 --- a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py +++ b/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py @@ -2,6 +2,7 @@ import os import sys import json import threading +import collections import signal import socket import datetime @@ -165,7 +166,7 @@ class StatusFactory: return source = event["data"]["source"] - data = event["data"]["status_info"] + data = collections.OrderedDict(event["data"]["status_info"]) self.update_status_info(source, data) @@ -348,7 +349,7 @@ def heartbeat(): def main(args): port = int(args[-1]) - server_info = json.loads(args[-2]) + server_info = collections.OrderedDict(json.loads(args[-2])) # Create a TCP/IP socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py b/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py index a8649e0ccc..5543ed74e2 100644 --- a/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py +++ b/openpype/modules/default_modules/ftrack/scripts/sub_event_storer.py @@ -14,7 +14,11 @@ from openpype_modules.ftrack.ftrack_server.lib import ( TOPIC_STATUS_SERVER_RESULT ) from openpype_modules.ftrack.lib import get_ftrack_event_mongo_info -from openpype.lib import OpenPypeMongoConnection +from openpype.lib import ( + OpenPypeMongoConnection, + get_openpype_version, + get_build_version +) from openpype.api import Logger log = Logger.get_logger("Event storer") @@ -153,9 +157,11 @@ def send_status(event): new_event_data = { "subprocess_id": os.environ["FTRACK_EVENT_SUB_ID"], "source": "storer", - "status_info": { - "created_at": subprocess_started.strftime("%Y.%m.%d %H:%M:%S") - } + "status_info": [ + ["created_at", subprocess_started.strftime("%Y.%m.%d %H:%M:%S")], + ["OpenPype version", get_openpype_version() or "N/A"], + ["OpenPype build version", get_build_version() or "N/A"] + ] } new_event = ftrack_api.event.base.Event( diff --git a/openpype/modules/default_modules/ftrack/tray/login_dialog.py b/openpype/modules/default_modules/ftrack/tray/login_dialog.py index 6384621c8e..05d9226ca4 100644 --- a/openpype/modules/default_modules/ftrack/tray/login_dialog.py +++ b/openpype/modules/default_modules/ftrack/tray/login_dialog.py @@ -25,7 +25,7 @@ class CredentialsDialog(QtWidgets.QDialog): self._is_logged = False self._in_advance_mode = False - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/openpype/modules/default_modules/log_viewer/log_view_module.py b/openpype/modules/default_modules/log_viewer/log_view_module.py index bc1a98f4ad..14be6b392e 100644 --- a/openpype/modules/default_modules/log_viewer/log_view_module.py +++ b/openpype/modules/default_modules/log_viewer/log_view_module.py @@ -40,10 +40,6 @@ class LogViewModule(OpenPypeModule, ITrayModule): def tray_exit(self): return - def connect_with_modules(self, _enabled_modules): - """Nothing special.""" - return - def _show_logs_gui(self): if self.window: self.window.show() diff --git a/openpype/modules/default_modules/muster/muster.py b/openpype/modules/default_modules/muster/muster.py index a0e72006af..6e26ad2d7b 100644 --- a/openpype/modules/default_modules/muster/muster.py +++ b/openpype/modules/default_modules/muster/muster.py @@ -3,13 +3,10 @@ import json import appdirs import requests from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITrayModule, - IWebServerRoutes -) +from openpype_interfaces import ITrayModule -class MusterModule(OpenPypeModule, ITrayModule, IWebServerRoutes): +class MusterModule(OpenPypeModule, ITrayModule): """ Module handling Muster Render credentials. This will display dialog asking for user credentials for Muster if not already specified. @@ -54,9 +51,6 @@ class MusterModule(OpenPypeModule, ITrayModule, IWebServerRoutes): """Nothing special for Muster.""" return - def connect_with_modules(self, *_a, **_kw): - return - # Definition of Tray menu def tray_menu(self, parent): """Add **change credentials** option to tray menu.""" @@ -76,13 +70,6 @@ class MusterModule(OpenPypeModule, ITrayModule, IWebServerRoutes): parent.addMenu(menu) - def webserver_initialization(self, server_manager): - """Implementation of IWebServerRoutes interface.""" - if self.tray_initialized: - from .rest_api import MusterModuleRestApi - - self.rest_api_obj = MusterModuleRestApi(self, server_manager) - def load_credentials(self): """ Get credentials from JSON file @@ -142,6 +129,14 @@ class MusterModule(OpenPypeModule, ITrayModule, IWebServerRoutes): if self.widget_login: self.widget_login.show() + # Webserver module implementation + def webserver_initialization(self, server_manager): + """Add routes for Muster login.""" + if self.tray_initialized: + from .rest_api import MusterModuleRestApi + + self.rest_api_obj = MusterModuleRestApi(self, server_manager) + def _requests_post(self, *args, **kwargs): """ Wrapper for requests, disabling SSL certificate validation if DONT_VERIFY_SSL environment variable is found. This is useful when diff --git a/openpype/modules/default_modules/muster/widget_login.py b/openpype/modules/default_modules/muster/widget_login.py index 231b52c6bd..ae838c6cea 100644 --- a/openpype/modules/default_modules/muster/widget_login.py +++ b/openpype/modules/default_modules/muster/widget_login.py @@ -17,7 +17,7 @@ class MusterLogin(QtWidgets.QWidget): self.module = module # Icon - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/openpype/modules/default_modules/project_manager_action.py b/openpype/modules/default_modules/project_manager_action.py index c1f984a8cb..251964a059 100644 --- a/openpype/modules/default_modules/project_manager_action.py +++ b/openpype/modules/default_modules/project_manager_action.py @@ -17,9 +17,6 @@ class ProjectManagerAction(OpenPypeModule, ITrayAction): # Tray attributes self.project_manager_window = None - def connect_with_modules(self, *_a, **_kw): - return - def tray_init(self): """Initialization in tray implementation of ITrayAction.""" self.create_project_manager_window() diff --git a/openpype/modules/default_modules/python_console_interpreter/module.py b/openpype/modules/default_modules/python_console_interpreter/module.py index f4df3fb6d8..8c4a2fba73 100644 --- a/openpype/modules/default_modules/python_console_interpreter/module.py +++ b/openpype/modules/default_modules/python_console_interpreter/module.py @@ -18,9 +18,6 @@ class PythonInterpreterAction(OpenPypeModule, ITrayAction): if self._interpreter_window is not None: self._interpreter_window.save_registry() - def connect_with_modules(self, *args, **kwargs): - pass - def create_interpreter_window(self): """Initializa Settings Qt window.""" if self._interpreter_window: diff --git a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py b/openpype/modules/default_modules/python_console_interpreter/window/widgets.py index 975decf4f4..0e8dd2fb9b 100644 --- a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py +++ b/openpype/modules/default_modules/python_console_interpreter/window/widgets.py @@ -331,7 +331,7 @@ class PythonInterpreterWidget(QtWidgets.QWidget): super(PythonInterpreterWidget, self).__init__(parent) self.setWindowTitle("OpenPype Console") - self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + self.setWindowIcon(QtGui.QIcon(resources.get_openpype_icon_filepath())) self.ansi_escape = re.compile( r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]" @@ -387,8 +387,6 @@ class PythonInterpreterWidget(QtWidgets.QWidget): self.setStyleSheet(load_stylesheet()) - self.resize(self.default_width, self.default_height) - self._init_from_registry() if self._tab_widget.count() < 1: @@ -396,16 +394,23 @@ class PythonInterpreterWidget(QtWidgets.QWidget): def _init_from_registry(self): setting_registry = PythonInterpreterRegistry() - + width = None + height = None try: width = setting_registry.get_item("width") height = setting_registry.get_item("height") - if width is not None and height is not None: - self.resize(width, height) except ValueError: pass + if width is None or width < 200: + width = self.default_width + + if height is None or height < 200: + height = self.default_height + + self.resize(width, height) + try: sizes = setting_registry.get_item("splitter_sizes") if len(sizes) == len(self._widgets_splitter.sizes()): diff --git a/openpype/modules/default_modules/settings_module/settings_action.py b/openpype/modules/default_modules/settings_module/settings_action.py index 7140c57bab..2b4b51e3ad 100644 --- a/openpype/modules/default_modules/settings_module/settings_action.py +++ b/openpype/modules/default_modules/settings_module/settings_action.py @@ -19,9 +19,6 @@ class SettingsAction(OpenPypeModule, ITrayAction): # Tray attributes self.settings_window = None - def connect_with_modules(self, *_a, **_kw): - return - def tray_init(self): """Initialization in tray implementation of ITrayAction.""" self.create_settings_window() @@ -84,9 +81,6 @@ class LocalSettingsAction(OpenPypeModule, ITrayAction): self.settings_window = None self._first_trigger = True - def connect_with_modules(self, *_a, **_kw): - return - def tray_init(self): """Initialization in tray implementation of ITrayAction.""" self.create_settings_window() diff --git a/openpype/modules/default_modules/slack/slack_module.py b/openpype/modules/default_modules/slack/slack_module.py index e3f7b4ad19..9b2976d766 100644 --- a/openpype/modules/default_modules/slack/slack_module.py +++ b/openpype/modules/default_modules/slack/slack_module.py @@ -17,10 +17,6 @@ class SlackIntegrationModule(OpenPypeModule, IPluginPaths, ILaunchHookPaths): slack_settings = modules_settings[self.name] self.enabled = slack_settings["enabled"] - def connect_with_modules(self, _enabled_modules): - """Nothing special.""" - return - def get_launch_hook_paths(self): """Implementation of `ILaunchHookPaths`.""" return os.path.join(SLACK_MODULE_DIR, "launch_hooks") diff --git a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py index 2e9632134c..7fd25b9852 100644 --- a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py @@ -29,13 +29,35 @@ class AbstractProvider: @classmethod @abc.abstractmethod - def get_configurable_items(cls): + def get_system_settings_schema(cls): """ - Returns filtered dict of editable properties + Returns dict for editable properties on system settings level Returns: - (dict) + (list) of dict + """ + + @classmethod + @abc.abstractmethod + def get_project_settings_schema(cls): + """ + Returns dict for editable properties on project settings level + + + Returns: + (list) of dict + """ + + @classmethod + @abc.abstractmethod + def get_local_settings_schema(cls): + """ + Returns dict for editable properties on local settings level + + + Returns: + (list) of dict """ @abc.abstractmethod diff --git a/openpype/modules/default_modules/sync_server/providers/gdrive.py b/openpype/modules/default_modules/sync_server/providers/gdrive.py index 18d679b833..f1ec0b6a0d 100644 --- a/openpype/modules/default_modules/sync_server/providers/gdrive.py +++ b/openpype/modules/default_modules/sync_server/providers/gdrive.py @@ -8,7 +8,7 @@ import platform from openpype.api import Logger from openpype.api import get_system_settings from .abstract_provider import AbstractProvider -from ..utils import time_function, ResumableError, EditableScopes +from ..utils import time_function, ResumableError log = Logger().get_logger("SyncServer") @@ -96,30 +96,61 @@ class GDriveHandler(AbstractProvider): return self.service is not None @classmethod - def get_configurable_items(cls): + def get_system_settings_schema(cls): """ - Returns filtered dict of editable properties. + Returns dict for editable properties on system settings level + + + Returns: + (list) of dict + """ + return [] + + @classmethod + def get_project_settings_schema(cls): + """ + Returns dict for editable properties on project settings level + + + Returns: + (list) of dict + """ + # {platform} tells that value is multiplatform and only specific OS + # should be returned + editable = [ + # credentials could be overriden on Project or User level + { + 'key': "credentials_url", + 'label': "Credentials url", + 'type': 'text' + }, + # roots could be overriden only on Project leve, User cannot + { + 'key': "roots", + 'label': "Roots", + 'type': 'dict' + } + ] + return editable + + @classmethod + def get_local_settings_schema(cls): + """ + Returns dict for editable properties on local settings level Returns: (dict) """ - # {platform} tells that value is multiplatform and only specific OS - # should be returned - editable = { + editable = [ # credentials could be override on Project or User level - 'credentials_url': { - 'scope': [EditableScopes.PROJECT, - EditableScopes.LOCAL], + { + 'key': "credentials_url", 'label': "Credentials url", 'type': 'text', 'namespace': '{project_settings}/global/sync_server/sites/{site}/credentials_url/{platform}' # noqa: E501 - }, - # roots could be override only on Project leve, User cannot - 'root': {'scope': [EditableScopes.PROJECT], - 'label': "Roots", - 'type': 'dict'} - } + } + ] return editable def get_roots_config(self, anatomy=None): diff --git a/openpype/modules/default_modules/sync_server/providers/lib.py b/openpype/modules/default_modules/sync_server/providers/lib.py index 816ccca981..463e49dd4d 100644 --- a/openpype/modules/default_modules/sync_server/providers/lib.py +++ b/openpype/modules/default_modules/sync_server/providers/lib.py @@ -76,6 +76,14 @@ class ProviderFactory: return provider_info[0].get_configurable_items() + def get_provider_cls(self, provider_code): + """ + Returns class object for 'provider_code' to run class methods on. + """ + provider_info = self._get_creator_info(provider_code) + + return provider_info[0] + def _get_creator_info(self, provider): """ Collect all necessary info for provider. Currently only creator diff --git a/openpype/modules/default_modules/sync_server/providers/local_drive.py b/openpype/modules/default_modules/sync_server/providers/local_drive.py index 4b80ed44f2..8e5f170bc9 100644 --- a/openpype/modules/default_modules/sync_server/providers/local_drive.py +++ b/openpype/modules/default_modules/sync_server/providers/local_drive.py @@ -7,8 +7,6 @@ import time from openpype.api import Logger, Anatomy from .abstract_provider import AbstractProvider -from ..utils import EditableScopes - log = Logger().get_logger("SyncServer") @@ -30,18 +28,51 @@ class LocalDriveHandler(AbstractProvider): return True @classmethod - def get_configurable_items(cls): + def get_system_settings_schema(cls): """ - Returns filtered dict of editable properties + Returns dict for editable properties on system settings level + + + Returns: + (list) of dict + """ + return [] + + @classmethod + def get_project_settings_schema(cls): + """ + Returns dict for editable properties on project settings level + + + Returns: + (list) of dict + """ + # for non 'studio' sites, 'studio' is configured in Anatomy + editable = [ + { + 'key': "roots", + 'label': "Roots", + 'type': 'dict' + } + ] + return editable + + @classmethod + def get_local_settings_schema(cls): + """ + Returns dict for editable properties on local settings level + Returns: (dict) """ - editable = { - 'root': {'scope': [EditableScopes.LOCAL], - 'label': "Roots", - 'type': 'dict'} - } + editable = [ + { + 'key': "roots", + 'label': "Roots", + 'type': 'dict' + } + ] return editable def upload_file(self, source_path, target_path, diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index e65a410551..7dabd45bae 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -16,14 +16,13 @@ from openpype.api import ( get_local_site_id) from openpype.lib import PypeLogger from openpype.settings.lib import ( - get_default_project_settings, get_default_anatomy_settings, get_anatomy_settings) from .providers.local_drive import LocalDriveHandler from .providers import lib -from .utils import time_function, SyncStatus, EditableScopes +from .utils import time_function, SyncStatus log = PypeLogger().get_logger("SyncServer") @@ -399,204 +398,239 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return remote_site - def get_local_settings_schema(self): - """Wrapper for Local settings - all projects incl. Default""" - return self.get_configurable_items(EditableScopes.LOCAL) + # Methods for Settings UI to draw appropriate forms + @classmethod + def get_system_settings_schema(cls): + """ Gets system level schema of configurable items - def get_configurable_items(self, scope=None): + Used for Setting UI to provide forms. """ - Returns list of sites that could be configurable for all projects. + ret_dict = {} + for provider_code in lib.factory.providers: + ret_dict[provider_code] = \ + lib.factory.get_provider_cls(provider_code). \ + get_system_settings_schema() - Could be filtered by 'scope' argument (list) + return ret_dict - Args: - scope (list of utils.EditableScope) + @classmethod + def get_project_settings_schema(cls): + """ Gets project level schema of configurable items. - Returns: - (dict of list of dict) - { - siteA : [ - { - key:"root", label:"root", - "value":"{'work': 'c:/projects'}", - "type": "dict", - "children":[ - { "key": "work", - "type": "text", - "value": "c:/projects"} - ] - }, - { - key:"credentials_url", label:"Credentials url", - "value":"'c:/projects/cred.json'", "type": "text", - "namespace": "{project_setting}/global/sync_server/ - sites" - } - ] - } + It is not using Setting! Used for Setting UI to provide forms. """ - editable = {} - applicable_projects = list(self.connection.projects()) - applicable_projects.append(None) - for project in applicable_projects: - project_name = None - if project: - project_name = project["name"] + ret_dict = {} + for provider_code in lib.factory.providers: + ret_dict[provider_code] = \ + lib.factory.get_provider_cls(provider_code). \ + get_project_settings_schema() - items = self.get_configurable_items_for_project(project_name, - scope) - editable.update(items) + return ret_dict - return editable + @classmethod + def get_local_settings_schema(cls): + """ Gets local level schema of configurable items. - def get_local_settings_schema_for_project(self, project_name): - """Wrapper for Local settings - for specific 'project_name'""" - return self.get_configurable_items_for_project(project_name, - EditableScopes.LOCAL) - - def get_configurable_items_for_project(self, project_name=None, - scope=None): + It is not using Setting! Used for Setting UI to provide forms. """ - Returns list of items that could be configurable for specific - 'project_name' + ret_dict = {} + for provider_code in lib.factory.providers: + ret_dict[provider_code] = \ + lib.factory.get_provider_cls(provider_code). \ + get_local_settings_schema() - Args: - project_name (str) - None > default project, - scope (list of utils.EditableScope) - (optional, None is all scopes, default is LOCAL) + return ret_dict - Returns: - (dict of list of dict) - { - siteA : [ - { - key:"root", label:"root", - "type": "dict", - "children":[ - { "key": "work", - "type": "text", - "value": "c:/projects"} - ] - }, - { - key:"credentials_url", label:"Credentials url", - "value":"'c:/projects/cred.json'", "type": "text", - "namespace": "{project_setting}/global/sync_server/ - sites" - } - ] - } - """ - allowed_sites = set() - sites = self.get_all_site_configs(project_name) - if project_name: - # Local Settings can select only from allowed sites for project - allowed_sites.update(set(self.get_active_sites(project_name))) - allowed_sites.update(set(self.get_remote_sites(project_name))) - - editable = {} - for site_name in sites.keys(): - if allowed_sites and site_name not in allowed_sites: - continue - - items = self.get_configurable_items_for_site(project_name, - site_name, - scope) - # Local Settings need 'local' instead of real value - site_name = site_name.replace(get_local_site_id(), 'local') - editable[site_name] = items - - return editable - - def get_local_settings_schema_for_site(self, project_name, site_name): - """Wrapper for Local settings - for particular 'site_name and proj.""" - return self.get_configurable_items_for_site(project_name, - site_name, - EditableScopes.LOCAL) - - def get_configurable_items_for_site(self, project_name=None, - site_name=None, - scope=None): - """ - Returns list of items that could be configurable. - - Args: - project_name (str) - None > default project - site_name (str) - scope (list of utils.EditableScope) - (optional, None is all scopes) - - Returns: - (list) - [ - { - key:"root", label:"root", type:"dict", - "children":[ - { "key": "work", - "type": "text", - "value": "c:/projects"} - ] - }, ... - ] - """ - provider_name = self.get_provider_for_site(site=site_name) - items = lib.factory.get_provider_configurable_items(provider_name) - - if project_name: - sync_s = self.get_sync_project_setting(project_name, - exclude_locals=True, - cached=False) - else: - sync_s = get_default_project_settings(exclude_locals=True) - sync_s = sync_s["global"]["sync_server"] - sync_s["sites"].update( - self._get_default_site_configs(self.enabled)) - - editable = [] - if type(scope) is not list: - scope = [scope] - scope = set(scope) - for key, properties in items.items(): - if scope is None or scope.intersection(set(properties["scope"])): - val = sync_s.get("sites", {}).get(site_name, {}).get(key) - - item = { - "key": key, - "label": properties["label"], - "type": properties["type"] - } - - if properties.get("namespace"): - item["namespace"] = properties.get("namespace") - if "platform" in item["namespace"]: - try: - if val: - val = val[platform.system().lower()] - except KeyError: - st = "{}'s field value {} should be".format(key, val) # noqa: E501 - log.error(st + " multiplatform dict") - - item["namespace"] = item["namespace"].replace('{site}', - site_name) - children = [] - if properties["type"] == "dict": - if val: - for val_key, val_val in val.items(): - child = { - "type": "text", - "key": val_key, - "value": val_val - } - children.append(child) - - if properties["type"] == "dict": - item["children"] = children - else: - item["value"] = val - - editable.append(item) - - return editable + # Needs to be refactored after Settings are updated + # # Methods for Settings to get appriate values to fill forms + # def get_configurable_items(self, scope=None): + # """ + # Returns list of sites that could be configurable for all projects + # + # Could be filtered by 'scope' argument (list) + # + # Args: + # scope (list of utils.EditableScope) + # + # Returns: + # (dict of list of dict) + # { + # siteA : [ + # { + # key:"root", label:"root", + # "value":"{'work': 'c:/projects'}", + # "type": "dict", + # "children":[ + # { "key": "work", + # "type": "text", + # "value": "c:/projects"} + # ] + # }, + # { + # key:"credentials_url", label:"Credentials url", + # "value":"'c:/projects/cred.json'", "type": "text", # noqa: E501 + # "namespace": "{project_setting}/global/sync_server/ # noqa: E501 + # sites" + # } + # ] + # } + # """ + # editable = {} + # applicable_projects = list(self.connection.projects()) + # applicable_projects.append(None) + # for project in applicable_projects: + # project_name = None + # if project: + # project_name = project["name"] + # + # items = self.get_configurable_items_for_project(project_name, + # scope) + # editable.update(items) + # + # return editable + # + # def get_local_settings_schema_for_project(self, project_name): + # """Wrapper for Local settings - for specific 'project_name'""" + # return self.get_configurable_items_for_project(project_name, + # EditableScopes.LOCAL) + # + # def get_configurable_items_for_project(self, project_name=None, + # scope=None): + # """ + # Returns list of items that could be configurable for specific + # 'project_name' + # + # Args: + # project_name (str) - None > default project, + # scope (list of utils.EditableScope) + # (optional, None is all scopes, default is LOCAL) + # + # Returns: + # (dict of list of dict) + # { + # siteA : [ + # { + # key:"root", label:"root", + # "type": "dict", + # "children":[ + # { "key": "work", + # "type": "text", + # "value": "c:/projects"} + # ] + # }, + # { + # key:"credentials_url", label:"Credentials url", + # "value":"'c:/projects/cred.json'", "type": "text", + # "namespace": "{project_setting}/global/sync_server/ + # sites" + # } + # ] + # } + # """ + # allowed_sites = set() + # sites = self.get_all_site_configs(project_name) + # if project_name: + # # Local Settings can select only from allowed sites for project + # allowed_sites.update(set(self.get_active_sites(project_name))) + # allowed_sites.update(set(self.get_remote_sites(project_name))) + # + # editable = {} + # for site_name in sites.keys(): + # if allowed_sites and site_name not in allowed_sites: + # continue + # + # items = self.get_configurable_items_for_site(project_name, + # site_name, + # scope) + # # Local Settings need 'local' instead of real value + # site_name = site_name.replace(get_local_site_id(), 'local') + # editable[site_name] = items + # + # return editable + # + # def get_configurable_items_for_site(self, project_name=None, + # site_name=None, + # scope=None): + # """ + # Returns list of items that could be configurable. + # + # Args: + # project_name (str) - None > default project + # site_name (str) + # scope (list of utils.EditableScope) + # (optional, None is all scopes) + # + # Returns: + # (list) + # [ + # { + # key:"root", label:"root", type:"dict", + # "children":[ + # { "key": "work", + # "type": "text", + # "value": "c:/projects"} + # ] + # }, ... + # ] + # """ + # provider_name = self.get_provider_for_site(site=site_name) + # items = lib.factory.get_provider_configurable_items(provider_name) + # + # if project_name: + # sync_s = self.get_sync_project_setting(project_name, + # exclude_locals=True, + # cached=False) + # else: + # sync_s = get_default_project_settings(exclude_locals=True) + # sync_s = sync_s["global"]["sync_server"] + # sync_s["sites"].update( + # self._get_default_site_configs(self.enabled)) + # + # editable = [] + # if type(scope) is not list: + # scope = [scope] + # scope = set(scope) + # for key, properties in items.items(): + # if scope is None or scope.intersection(set(properties["scope"])): + # val = sync_s.get("sites", {}).get(site_name, {}).get(key) + # + # item = { + # "key": key, + # "label": properties["label"], + # "type": properties["type"] + # } + # + # if properties.get("namespace"): + # item["namespace"] = properties.get("namespace") + # if "platform" in item["namespace"]: + # try: + # if val: + # val = val[platform.system().lower()] + # except KeyError: + # st = "{}'s field value {} should be".format(key, val) # noqa: E501 + # log.error(st + " multiplatform dict") + # + # item["namespace"] = item["namespace"].replace('{site}', + # site_name) + # children = [] + # if properties["type"] == "dict": + # if val: + # for val_key, val_val in val.items(): + # child = { + # "type": "text", + # "key": val_key, + # "value": val_val + # } + # children.append(child) + # + # if properties["type"] == "dict": + # item["children"] = children + # else: + # item["value"] = val + # + # editable.append(item) + # + # return editable def reset_timer(self): """ @@ -611,7 +645,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): enabled_projects = [] if self.enabled: - for project in self.connection.projects(): + for project in self.connection.projects(projection={"name": 1}): project_name = project["name"] project_settings = self.get_sync_project_setting(project_name) if project_settings and project_settings.get("enabled"): @@ -646,9 +680,6 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return sites - def connect_with_modules(self, *_a, **kw): - return - def tray_init(self): """ Actual initialization of Sync Server. @@ -781,17 +812,22 @@ class SyncServerModule(OpenPypeModule, ITrayModule): def _prepare_sync_project_settings(self, exclude_locals): sync_project_settings = {} system_sites = self.get_all_site_configs() - for collection in self.connection.database.collection_names(False): + project_docs = self.connection.projects( + projection={"name": 1}, + only_active=True + ) + for project_doc in project_docs: + project_name = project_doc["name"] sites = copy.deepcopy(system_sites) # get all configured sites proj_settings = self._parse_sync_settings_from_settings( - get_project_settings(collection, + get_project_settings(project_name, exclude_locals=exclude_locals)) sites.update(self._get_default_site_configs( - proj_settings["enabled"], collection)) + proj_settings["enabled"], project_name)) sites.update(proj_settings['sites']) proj_settings["sites"] = sites - sync_project_settings[collection] = proj_settings + sync_project_settings[project_name] = proj_settings if not sync_project_settings: log.info("No enabled and configured projects for sync.") return sync_project_settings diff --git a/openpype/modules/default_modules/sync_server/tray/app.py b/openpype/modules/default_modules/sync_server/tray/app.py index 106076d81c..fc8558bdbc 100644 --- a/openpype/modules/default_modules/sync_server/tray/app.py +++ b/openpype/modules/default_modules/sync_server/tray/app.py @@ -26,7 +26,7 @@ class SyncServerWindow(QtWidgets.QDialog): self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setStyleSheet(style.load_stylesheet()) - self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + self.setWindowIcon(QtGui.QIcon(resources.get_openpype_icon_filepath())) self.resize(1450, 700) self.timer = QtCore.QTimer() @@ -77,19 +77,36 @@ class SyncServerWindow(QtWidgets.QDialog): self.setWindowTitle("Sync Queue") self.projects.project_changed.connect( - lambda: repres.table_view.model().set_project( - self.projects.current_project)) + self._on_project_change + ) self.pause_btn.clicked.connect(self._pause) self.pause_btn.setAutoDefault(False) self.pause_btn.setDefault(False) repres.message_generated.connect(self._update_message) + self.projects.message_generated.connect(self._update_message) self.representationWidget = repres + def _on_project_change(self): + if self.projects.current_project is None: + return + + self.representationWidget.table_view.model().set_project( + self.projects.current_project + ) + + project_name = self.projects.current_project + if not self.sync_server.get_sync_project_setting(project_name): + self.projects.message_generated.emit( + "Project {} not active anymore".format(project_name)) + self.projects.refresh() + return + def showEvent(self, event): self.representationWidget.model.set_project( self.projects.current_project) + self.projects.refresh() self._set_running(True) super().showEvent(event) diff --git a/openpype/modules/default_modules/sync_server/tray/models.py b/openpype/modules/default_modules/sync_server/tray/models.py index 8c86d3b98f..5642c5b34a 100644 --- a/openpype/modules/default_modules/sync_server/tray/models.py +++ b/openpype/modules/default_modules/sync_server/tray/models.py @@ -5,7 +5,7 @@ from bson.objectid import ObjectId from Qt import QtCore from Qt.QtCore import Qt -from avalon.tools.delegates import pretty_timestamp +from openpype.tools.utils.delegates import pretty_timestamp from avalon.vendor import qtawesome from openpype.lib import PypeLogger @@ -17,25 +17,6 @@ from . import lib log = PypeLogger().get_logger("SyncServer") -class ProjectModel(QtCore.QAbstractListModel): - def __init__(self, *args, projects=None, **kwargs): - super(ProjectModel, self).__init__(*args, **kwargs) - self.projects = projects or [] - - def data(self, index, role): - if role == Qt.DisplayRole: - # See below for the data structure. - status, text = self.projects[index.row()] - # Return the todo text only. - return text - - def rowCount(self, _index): - return len(self.todos) - - def columnCount(self, _index): - return len(self._header) - - class _SyncRepresentationModel(QtCore.QAbstractTableModel): COLUMN_LABELS = [] @@ -320,6 +301,10 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): """ self._project = project self.sync_server.set_sync_project_settings() + # project might have been deactivated in the meantime + if not self.sync_server.get_sync_project_setting(project): + return + self.active_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) self.refresh() diff --git a/openpype/modules/default_modules/sync_server/tray/widgets.py b/openpype/modules/default_modules/sync_server/tray/widgets.py index c9160733a0..45537c1c2e 100644 --- a/openpype/modules/default_modules/sync_server/tray/widgets.py +++ b/openpype/modules/default_modules/sync_server/tray/widgets.py @@ -6,15 +6,12 @@ from functools import partial from Qt import QtWidgets, QtCore, QtGui from Qt.QtCore import Qt -from openpype.tools.settings import ( - ProjectListWidget, - style -) +from openpype.tools.settings import style from openpype.api import get_local_site_id from openpype.lib import PypeLogger -from avalon.tools.delegates import pretty_timestamp +from openpype.tools.utils.delegates import pretty_timestamp from avalon.vendor import qtawesome from .models import ( @@ -28,28 +25,58 @@ from . import delegates log = PypeLogger().get_logger("SyncServer") -class SyncProjectListWidget(ProjectListWidget): +class SyncProjectListWidget(QtWidgets.QWidget): """ Lists all projects that are synchronized to choose from """ + project_changed = QtCore.Signal() + message_generated = QtCore.Signal(str) def __init__(self, sync_server, parent): super(SyncProjectListWidget, self).__init__(parent) + self.setObjectName("ProjectListWidget") + + self._parent = parent + + label_widget = QtWidgets.QLabel("Projects", self) + project_list = QtWidgets.QListView(self) + project_model = QtGui.QStandardItemModel() + project_list.setModel(project_model) + project_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + # Do not allow editing + project_list.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + layout.addWidget(label_widget, 0) + layout.addWidget(project_list, 1) + + project_list.customContextMenuRequested.connect(self._on_context_menu) + project_list.selectionModel().currentChanged.connect( + self._on_index_change + ) + + self.project_model = project_model + self.project_list = project_list self.sync_server = sync_server - self.project_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.project_list.customContextMenuRequested.connect( - self._on_context_menu) + self.current_project = None self.project_name = None self.local_site = None + self.remote_site = None self.icons = {} - self.layout().setContentsMargins(0, 0, 0, 0) + def _on_index_change(self, new_idx, _old_idx): + project_name = new_idx.data(QtCore.Qt.DisplayRole) - def validate_context_change(self): - return True + self.current_project = project_name + self.project_changed.emit() def refresh(self): - model = self.project_list.model() + model = self.project_model model.clear() project_name = None @@ -70,11 +97,15 @@ class SyncProjectListWidget(ProjectListWidget): QtCore.Qt.DisplayRole ) if not self.current_project: - self.current_project = self.project_list.model().item(0). \ - data(QtCore.Qt.DisplayRole) + self.current_project = model.item(0).data(QtCore.Qt.DisplayRole) if project_name: self.local_site = self.sync_server.get_active_site(project_name) + self.remote_site = self.sync_server.get_remote_site(project_name) + + def _can_edit(self): + """Returns true if some site is user local site, eg. could edit""" + return get_local_site_id() in (self.local_site, self.remote_site) def _get_icon(self, status): if not self.icons.get(status): @@ -98,9 +129,7 @@ class SyncProjectListWidget(ProjectListWidget): menu = QtWidgets.QMenu(self) actions_mapping = {} - can_edit = self.model.can_edit - - if can_edit: + if self._can_edit(): if self.sync_server.is_project_paused(self.project_name): action = QtWidgets.QAction("Unpause") actions_mapping[action] = self._unpause diff --git a/openpype/modules/default_modules/timers_manager/interfaces.py b/openpype/modules/default_modules/timers_manager/interfaces.py deleted file mode 100644 index 179013cffe..0000000000 --- a/openpype/modules/default_modules/timers_manager/interfaces.py +++ /dev/null @@ -1,26 +0,0 @@ -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class ITimersManager(OpenPypeInterface): - timer_manager_module = None - - @abstractmethod - def stop_timer(self): - pass - - @abstractmethod - def start_timer(self, data): - pass - - def timer_started(self, data): - if not self.timer_manager_module: - return - - self.timer_manager_module.timer_started(self.id, data) - - def timer_stopped(self): - if not self.timer_manager_module: - return - - self.timer_manager_module.timer_stopped(self.id) diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/default_modules/timers_manager/timers_manager.py index 80f448095f..47ba0b4059 100644 --- a/openpype/modules/default_modules/timers_manager/timers_manager.py +++ b/openpype/modules/default_modules/timers_manager/timers_manager.py @@ -4,28 +4,95 @@ from openpype.modules import OpenPypeModule from openpype_interfaces import ( ITimersManager, ITrayService, - IIdleManager, - IWebServerRoutes + IIdleManager ) from avalon.api import AvalonMongoDB -class TimersManager( - OpenPypeModule, ITrayService, IIdleManager, IWebServerRoutes -): +class ExampleTimersManagerConnector: + """Timers manager can handle timers of multiple modules/addons. + + Module must have object under `timers_manager_connector` attribute with + few methods. This is example class of the object that could be stored under + module. + + Required methods are 'stop_timer' and 'start_timer'. + + # TODO pass asset document instead of `hierarchy` + Example of `data` that are passed during changing timer: + ``` + data = { + "project_name": project_name, + "task_name": task_name, + "task_type": task_type, + "hierarchy": hierarchy + } + ``` + """ + # Not needed at all + def __init__(self, module): + # Store timer manager module to be able call it's methods when needed + self._timers_manager_module = None + + # Store module which want to use timers manager to have access + self._module = module + + # Required + def stop_timer(self): + """Called by timers manager when module should stop timer.""" + self._module.stop_timer() + + # Required + def start_timer(self, data): + """Method called by timers manager when should start timer.""" + self._module.start_timer(data) + + # Optional + def register_timers_manager(self, timer_manager_module): + """Method called by timers manager where it's object is passed. + + This is moment when timers manager module can be store to be able + call it's callbacks (e.g. timer started). + """ + self._timers_manager_module = timer_manager_module + + # Custom implementation + def timer_started(self, data): + """This is example of possibility to trigger callbacks on manager.""" + if self._timers_manager_module is not None: + self._timers_manager_module.timer_started(self._module.id, data) + + # Custom implementation + def timer_stopped(self): + if self._timers_manager_module is not None: + self._timers_manager_module.timer_stopped(self._module.id) + + +class TimersManager(OpenPypeModule, ITrayService, IIdleManager): """ Handles about Timers. Should be able to start/stop all timers at once. - If IdleManager is imported then is able to handle about stop timers - when user idles for a long time (set in presets). + + To be able use this advantage module has to have attribute with name + `timers_manager_connector` which has two methods 'stop_timer' + and 'start_timer'. Optionally may have `register_timers_manager` where + object of TimersManager module is passed to be able call it's callbacks. + + See `ExampleTimersManagerConnector`. """ name = "timers_manager" label = "Timers Service" + _required_methods = ( + "stop_timer", + "start_timer" + ) + def initialize(self, modules_settings): timers_settings = modules_settings[self.name] self.enabled = timers_settings["enabled"] + auto_stop = timers_settings["auto_stop"] # When timer will stop if idle manager is running (minutes) full_time = int(timers_settings["full_time"] * 60) @@ -44,7 +111,8 @@ class TimersManager( self.widget_user_idle = None self.signal_handler = None - self.modules = [] + self._connectors_by_module_id = {} + self._modules_by_id = {} def tray_init(self): from .widget_user_idle import WidgetUserIdle, SignalHandler @@ -58,13 +126,6 @@ class TimersManager( """Nothing special for TimersManager.""" return - def webserver_initialization(self, server_manager): - """Implementation of IWebServerRoutes interface.""" - if self.tray_initialized: - from .rest_api import TimersManagerModuleRestApi - self.rest_api_obj = TimersManagerModuleRestApi(self, - server_manager) - def start_timer(self, project_name, asset_name, task_name, hierarchy): """ Start timer for 'project_name', 'asset_name' and 'task_name' @@ -106,17 +167,35 @@ class TimersManager( self.timer_started(None, data) def timer_started(self, source_id, data): - for module in self.modules: - if module.id != source_id: - module.start_timer(data) + for module_id, connector in self._connectors_by_module_id.items(): + if module_id == source_id: + continue + + try: + connector.start_timer(data) + except Exception: + self.log.info( + "Failed to start timer on connector {}".format( + str(connector) + ) + ) self.last_task = data self.is_running = True def timer_stopped(self, source_id): - for module in self.modules: - if module.id != source_id: - module.stop_timer() + for module_id, connector in self._connectors_by_module_id.items(): + if module_id == source_id: + continue + + try: + connector.stop_timer() + except Exception: + self.log.info( + "Failed to stop timer on connector {}".format( + str(connector) + ) + ) def restart_timers(self): if self.last_task is not None: @@ -130,15 +209,40 @@ class TimersManager( self.widget_user_idle.refresh_context() self.is_running = False - for module in self.modules: - module.stop_timer() + self.timer_stopped(None) def connect_with_modules(self, enabled_modules): for module in enabled_modules: - if not isinstance(module, ITimersManager): + connector = getattr(module, "timers_manager_connector", None) + if connector is None: continue - module.timer_manager_module = self - self.modules.append(module) + + missing_methods = set() + for method_name in self._required_methods: + if not hasattr(connector, method_name): + missing_methods.add(method_name) + + if missing_methods: + joined = ", ".join( + ['"{}"'.format(name for name in missing_methods)] + ) + self.log.info(( + "Module \"{}\" has missing required methods {}." + ).format(module.name, joined)) + continue + + self._connectors_by_module_id[module.id] = connector + self._modules_by_id[module.id] = module + + # Optional method + if hasattr(connector, "register_timers_manager"): + try: + connector.register_timers_manager(self) + except Exception: + self.log.info(( + "Failed to register timers manager" + " for connector of module \"{}\"." + ).format(module.name)) def callbacks_by_idle_time(self): """Implementation of IIdleManager interface.""" @@ -205,6 +309,15 @@ class TimersManager( if self.widget_user_idle.bool_is_showed is False: self.widget_user_idle.show() + # Webserver module implementation + def webserver_initialization(self, server_manager): + """Add routes for timers to be able start/stop with rest api.""" + if self.tray_initialized: + from .rest_api import TimersManagerModuleRestApi + self.rest_api_obj = TimersManagerModuleRestApi( + self, server_manager + ) + def change_timer_from_host(self, project_name, asset_name, task_name): """Prepared method for calling change timers on REST api""" webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") diff --git a/openpype/modules/default_modules/timers_manager/widget_user_idle.py b/openpype/modules/default_modules/timers_manager/widget_user_idle.py index 25b4e56650..cefa6bb4fb 100644 --- a/openpype/modules/default_modules/timers_manager/widget_user_idle.py +++ b/openpype/modules/default_modules/timers_manager/widget_user_idle.py @@ -16,7 +16,7 @@ class WidgetUserIdle(QtWidgets.QWidget): self.module = module - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( QtCore.Qt.WindowCloseButtonHint diff --git a/openpype/modules/default_modules/webserver/interfaces.py b/openpype/modules/default_modules/webserver/interfaces.py deleted file mode 100644 index 779361a9ec..0000000000 --- a/openpype/modules/default_modules/webserver/interfaces.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import abstractmethod -from openpype.modules import OpenPypeInterface - - -class IWebServerRoutes(OpenPypeInterface): - """Other modules interface to register their routes.""" - @abstractmethod - def webserver_initialization(self, server_manager): - pass diff --git a/openpype/modules/default_modules/webserver/webserver_module.py b/openpype/modules/default_modules/webserver/webserver_module.py index 5bfb2d6390..686bd27bfd 100644 --- a/openpype/modules/default_modules/webserver/webserver_module.py +++ b/openpype/modules/default_modules/webserver/webserver_module.py @@ -1,12 +1,31 @@ +"""WebServerModule spawns aiohttp server in asyncio loop. + +Main usage of the module is in OpenPype tray where make sense to add ability +of other modules to add theirs routes. Module which would want use that +option must have implemented method `webserver_initialization` which must +expect `WebServerManager` object where is possible to add routes or paths +with handlers. + +WebServerManager is by default created only in tray. + +It is possible to create server manager without using module logic at all +using `create_new_server_manager`. That can be handy for standalone scripts +with predefined host and port and separated routes and logic. + +Running multiple servers in one process is not recommended and probably won't +work as expected. It is because of few limitations connected to asyncio module. + +When module's `create_server_manager` is called it is also set environment +variable "OPENPYPE_WEBSERVER_URL". Which should lead to root access point +of server. +""" + import os import socket from openpype import resources from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITrayService, - IWebServerRoutes -) +from openpype_interfaces import ITrayService class WebServerModule(OpenPypeModule, ITrayService): @@ -28,8 +47,15 @@ class WebServerModule(OpenPypeModule, ITrayService): return for module in enabled_modules: - if isinstance(module, IWebServerRoutes): + if not hasattr(module, "webserver_initialization"): + continue + + try: module.webserver_initialization(self.server_manager) + except Exception: + self.log.warning(( + "Failed to connect module \"{}\" to webserver." + ).format(module.name)) def tray_init(self): self.create_server_manager() diff --git a/openpype/modules/example_addons/example_addon/__init__.py b/openpype/modules/example_addons/example_addon/__init__.py new file mode 100644 index 0000000000..721d924436 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/__init__.py @@ -0,0 +1,15 @@ +""" Addon class definition and Settings definition must be imported here. + +If addon class or settings definition won't be here their definition won't +be found by OpenPype discovery. +""" + +from .addon import ( + AddonSettingsDef, + ExampleAddon +) + +__all__ = ( + "AddonSettingsDef", + "ExampleAddon" +) diff --git a/openpype/modules/example_addons/example_addon/addon.py b/openpype/modules/example_addons/example_addon/addon.py new file mode 100644 index 0000000000..5573e33cc1 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/addon.py @@ -0,0 +1,132 @@ +"""Addon definition is located here. + +Import of python packages that may not be available should not be imported +in global space here until are required or used. +- Qt related imports +- imports of Python 3 packages + - we still support Python 2 hosts where addon definition should available +""" + +import os + +from openpype.modules import ( + JsonFilesSettingsDef, + OpenPypeAddOn +) +# Import interface defined by this addon to be able find other addons using it +from openpype_interfaces import ( + IExampleInterface, + IPluginPaths, + ITrayAction +) + + +# Settings definition of this addon using `JsonFilesSettingsDef` +# - JsonFilesSettingsDef is prepared settings definition using json files +# to define settings and store default values +class AddonSettingsDef(JsonFilesSettingsDef): + # This will add prefixes to every schema and template from `schemas` + # subfolder. + # - it is not required to fill the prefix but it is highly + # recommended as schemas and templates may have name clashes across + # multiple addons + # - it is also recommended that prefix has addon name in it + schema_prefix = "example_addon" + + def get_settings_root_path(self): + """Implemented abstract class of JsonFilesSettingsDef. + + Return directory path where json files defying addon settings are + located. + """ + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "settings" + ) + + +class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction): + """This Addon has defined it's settings and interface. + + This example has system settings with an enabled option. And use + few other interfaces: + - `IPluginPaths` to define custom plugin paths + - `ITrayAction` to be shown in tray tool + """ + label = "Example Addon" + name = "example_addon" + + def initialize(self, settings): + """Initialization of addon.""" + module_settings = settings[self.name] + # Enabled by settings + self.enabled = module_settings.get("enabled", False) + + # Prepare variables that can be used or set afterwards + self._connected_modules = None + # UI which must not be created at this time + self._dialog = None + + def tray_init(self): + """Implementation of abstract method for `ITrayAction`. + + We're definitely in tray tool so we can pre create dialog. + """ + + self._create_dialog() + + def connect_with_modules(self, enabled_modules): + """Method where you should find connected modules. + + It is triggered by OpenPype modules manager at the best possible time. + Some addons and modules may required to connect with other modules + before their main logic is executed so changes would require to restart + whole process. + """ + self._connected_modules = [] + for module in enabled_modules: + if isinstance(module, IExampleInterface): + self._connected_modules.append(module) + + def _create_dialog(self): + # Don't recreate dialog if already exists + if self._dialog is not None: + return + + from .widgets import MyExampleDialog + + self._dialog = MyExampleDialog() + + def show_dialog(self): + """Show dialog with connected modules. + + This can be called from anywhere but can also crash in headless mode. + There is no way to prevent addon to do invalid operations if he's + not handling them. + """ + # Make sure dialog is created + self._create_dialog() + # Change value of dialog by current state + self._dialog.set_connected_modules(self.get_connected_modules()) + # Show dialog + self._dialog.open() + + def get_connected_modules(self): + """Custom implementation of addon.""" + names = set() + if self._connected_modules is not None: + for module in self._connected_modules: + names.add(module.name) + return names + + def on_action_trigger(self): + """Implementation of abstract method for `ITrayAction`.""" + self.show_dialog() + + def get_plugin_paths(self): + """Implementation of abstract method for `IPluginPaths`.""" + current_dir = os.path.dirname(os.path.abspath(__file__)) + + return { + "publish": [os.path.join(current_dir, "plugins", "publish")] + } diff --git a/openpype/modules/example_addons/example_addon/interfaces.py b/openpype/modules/example_addons/example_addon/interfaces.py new file mode 100644 index 0000000000..371536efc7 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/interfaces.py @@ -0,0 +1,28 @@ +""" Using interfaces is one way of connecting multiple OpenPype Addons/Modules. + +Interfaces must be in `interfaces.py` file (or folder). Interfaces should not +import module logic or other module in global namespace. That is because +all of them must be imported before all OpenPype AddOns and Modules. + +Ideally they should just define abstract and helper methods. If interface +require any logic or connection it should be defined in module. + +Keep in mind that attributes and methods will be added to other addon +attributes and methods so they should be unique and ideally contain +addon name in it's name. +""" + +from abc import abstractmethod +from openpype.modules import OpenPypeInterface + + +class IExampleInterface(OpenPypeInterface): + """Example interface of addon.""" + _example_module = None + + def get_example_module(self): + return self._example_module + + @abstractmethod + def example_method_of_example_interface(self): + pass diff --git a/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py b/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py new file mode 100644 index 0000000000..695120e93b --- /dev/null +++ b/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py @@ -0,0 +1,9 @@ +import pyblish.api + + +class CollectExampleAddon(pyblish.api.ContextPlugin): + order = pyblish.api.CollectorOrder + 0.4 + label = "Collect Example Addon" + + def process(self, context): + self.log.info("I'm in example addon's plugin!") diff --git a/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json b/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json new file mode 100644 index 0000000000..0a01fa8977 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json @@ -0,0 +1,15 @@ +{ + "project_settings/example_addon": { + "number": 0, + "color_1": [ + 0.0, + 0.0, + 0.0 + ], + "color_2": [ + 0.0, + 0.0, + 0.0 + ] + } +} \ No newline at end of file diff --git a/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json b/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json new file mode 100644 index 0000000000..1e77356373 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json @@ -0,0 +1,5 @@ +{ + "modules/example_addon": { + "enabled": true + } +} \ No newline at end of file diff --git a/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json new file mode 100644 index 0000000000..1f3da7b37f --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json @@ -0,0 +1,6 @@ +{ + "project_settings/global": { + "type": "schema", + "name": "example_addon/main" + } +} diff --git a/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json new file mode 100644 index 0000000000..6faa48ba74 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json @@ -0,0 +1,6 @@ +{ + "system_settings/modules": { + "type": "schema", + "name": "example_addon/main" + } +} diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json new file mode 100644 index 0000000000..ba692d860e --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json @@ -0,0 +1,30 @@ +{ + "type": "dict", + "key": "example_addon", + "label": "Example addon", + "collapsible": true, + "children": [ + { + "type": "number", + "key": "number", + "label": "This is your lucky number:", + "minimum": 7, + "maximum": 7, + "decimals": 0 + }, + { + "type": "template", + "name": "example_addon/the_template", + "template_data": [ + { + "name": "color_1", + "label": "Color 1" + }, + { + "name": "color_2", + "label": "Color 2" + } + ] + } + ] +} diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json new file mode 100644 index 0000000000..af8fd9dae4 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json @@ -0,0 +1,30 @@ +[ + { + "type": "list-strict", + "key": "{name}", + "label": "{label}", + "object_types": [ + { + "label": "Red", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + }, + { + "label": "Green", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + }, + { + "label": "Blue", + "type": "number", + "minimum": 0, + "maximum": 1, + "decimal": 3 + } + ] + } +] diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json b/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json new file mode 100644 index 0000000000..0fb0a7c1be --- /dev/null +++ b/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json @@ -0,0 +1,14 @@ +{ + "type": "dict", + "key": "example_addon", + "label": "Example addon", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] +} diff --git a/openpype/modules/example_addons/example_addon/widgets.py b/openpype/modules/example_addons/example_addon/widgets.py new file mode 100644 index 0000000000..0acf238409 --- /dev/null +++ b/openpype/modules/example_addons/example_addon/widgets.py @@ -0,0 +1,39 @@ +from Qt import QtWidgets + +from openpype.style import load_stylesheet + + +class MyExampleDialog(QtWidgets.QDialog): + def __init__(self, parent=None): + super(MyExampleDialog, self).__init__(parent) + + self.setWindowTitle("Connected modules") + + label_widget = QtWidgets.QLabel(self) + + ok_btn = QtWidgets.QPushButton("OK", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(label_widget) + layout.addLayout(btns_layout) + + ok_btn.clicked.connect(self._on_ok_clicked) + + self._label_widget = label_widget + + self.setStyleSheet(load_stylesheet()) + + def _on_ok_clicked(self): + self.done(1) + + def set_connected_modules(self, connected_modules): + if connected_modules: + message = "\n".join(connected_modules) + else: + message = ( + "Other enabled modules/addons are not using my interface." + ) + self._label_widget.setText(message) diff --git a/openpype/modules/example_addons/tiny_addon.py b/openpype/modules/example_addons/tiny_addon.py new file mode 100644 index 0000000000..62962954f5 --- /dev/null +++ b/openpype/modules/example_addons/tiny_addon.py @@ -0,0 +1,9 @@ +from openpype.modules import OpenPypeAddOn + + +class TinyAddon(OpenPypeAddOn): + """This is tiniest possible addon. + + This addon won't do much but will exist in OpenPype modules environment. + """ + name = "tiniest_addon_ever" diff --git a/openpype/plugins/load/delivery.py b/openpype/plugins/load/delivery.py index 3753f1bfc9..a8cb0070ee 100644 --- a/openpype/plugins/load/delivery.py +++ b/openpype/plugins/load/delivery.py @@ -71,7 +71,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self._set_representations(contexts) self.setWindowTitle("OpenPype - Deliver versions") - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index 1aa10fcb9b..f7d1c6b4be 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -13,7 +13,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): """ label = "Collect Hierarchy" - order = pyblish.api.CollectorOrder - 0.57 + order = pyblish.api.CollectorOrder - 0.47 families = ["shot"] hosts = ["resolve", "hiero"] diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index e1b8b95a46..a35ef47e79 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -18,7 +18,7 @@ class CollectOcioFrameRanges(pyblish.api.InstancePlugin): Adding timeline and source ranges to instance data""" label = "Collect OTIO Frame Ranges" - order = pyblish.api.CollectorOrder - 0.58 + order = pyblish.api.CollectorOrder - 0.48 families = ["shot", "clip"] hosts = ["resolve", "hiero"] diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index e78ccc032c..10ceafdcca 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -20,7 +20,7 @@ class CollectOcioReview(pyblish.api.InstancePlugin): """Get matching otio track from defined review layer""" label = "Collect OTIO Review" - order = pyblish.api.CollectorOrder - 0.57 + order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] hosts = ["resolve", "hiero"] diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index 010430a303..dd670ff850 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -18,7 +18,7 @@ class CollectOcioSubsetResources(pyblish.api.InstancePlugin): """Get Resources for a subset version""" label = "Collect OTIO Subset Resources" - order = pyblish.api.CollectorOrder - 0.57 + order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] hosts = ["resolve", "hiero"] diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index ae691285b5..3c08c1862d 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -1,10 +1,16 @@ import os import pyblish.api -import openpype.api -import openpype.lib -from openpype.lib import should_decompress, \ - get_decompress_dir, decompress +from openpype.lib import ( + get_ffmpeg_tool_path, + + run_subprocess, + path_to_subprocess_arg, + + should_decompress, + get_decompress_dir, + decompress +) import shutil @@ -85,17 +91,19 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): self.log.info("output {}".format(full_output_path)) - ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") + ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") ffmpeg_args = self.ffmpeg_args or {} jpeg_items = [] - jpeg_items.append("\"{}\"".format(ffmpeg_path)) + jpeg_items.append(path_to_subprocess_arg(ffmpeg_path)) # override file if already exists jpeg_items.append("-y") # use same input args like with mov jpeg_items.extend(ffmpeg_args.get("input") or []) # input file - jpeg_items.append("-i \"{}\"".format(full_input_path)) + jpeg_items.append("-i {}".format( + path_to_subprocess_arg(full_input_path) + )) # output arguments from presets jpeg_items.extend(ffmpeg_args.get("output") or []) @@ -104,21 +112,22 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): jpeg_items.append("-vframes 1") # output file - jpeg_items.append("\"{}\"".format(full_output_path)) + jpeg_items.append(path_to_subprocess_arg(full_output_path)) - subprocess_jpeg = " ".join(jpeg_items) + subprocess_command = " ".join(jpeg_items) # run subprocess - self.log.debug("{}".format(subprocess_jpeg)) + self.log.debug("{}".format(subprocess_command)) try: # temporary until oiiotool is supported cross platform - openpype.api.run_subprocess( - subprocess_jpeg, shell=True, logger=self.log + run_subprocess( + subprocess_command, shell=True, logger=self.log ) except RuntimeError as exp: if "Compression" in str(exp): self.log.debug("Unsupported compression on input files. " + "Skipping!!!") return + self.log.warning("Conversion crashed", exc_info=True) raise if "representations" not in instance.data: diff --git a/openpype/plugins/publish/extract_otio_audio_tracks.py b/openpype/plugins/publish/extract_otio_audio_tracks.py index 2dc822fb0e..be0bae5cdc 100644 --- a/openpype/plugins/publish/extract_otio_audio_tracks.py +++ b/openpype/plugins/publish/extract_otio_audio_tracks.py @@ -2,7 +2,8 @@ import os import pyblish import openpype.api from openpype.lib import ( - get_ffmpeg_tool_path + get_ffmpeg_tool_path, + path_to_subprocess_arg ) import tempfile import opentimelineio as otio @@ -56,14 +57,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): audio_inputs.insert(0, empty) # create cmd - cmd = '"{}"'.format(self.ffmpeg_path) + " " + cmd = path_to_subprocess_arg(self.ffmpeg_path) + " " cmd += self.create_cmd(audio_inputs) - cmd += "\"{}\"".format(audio_temp_fpath) + cmd += path_to_subprocess_arg(audio_temp_fpath) # run subprocess self.log.debug("Executing: {}".format(cmd)) openpype.api.run_subprocess( - cmd, logger=self.log + cmd, shell=True, logger=self.log ) # remove empty @@ -99,16 +100,16 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): # temp audio file audio_fpath = self.create_temp_file(name) - cmd = " ".join([ - '"{}"'.format(self.ffmpeg_path), - "-ss {}".format(start_sec), - "-t {}".format(duration_sec), - "-i \"{}\"".format(audio_file), + cmd = [ + self.ffmpeg_path, + "-ss", str(start_sec), + "-t", str(duration_sec), + "-i", audio_file, audio_fpath - ]) + ] # run subprocess - self.log.debug("Executing: {}".format(cmd)) + self.log.debug("Executing: {}".format(" ".join(cmd))) openpype.api.run_subprocess( cmd, logger=self.log ) @@ -220,17 +221,17 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): max_duration_sec = max(end_secs) # create empty cmd - cmd = " ".join([ - '"{}"'.format(self.ffmpeg_path), - "-f lavfi", - "-i anullsrc=channel_layout=stereo:sample_rate=48000", - "-t {}".format(max_duration_sec), - "\"{}\"".format(empty_fpath) - ]) + cmd = [ + self.ffmpeg_path, + "-f", "lavfi", + "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", + "-t", str(max_duration_sec), + empty_fpath + ] # generate empty with ffmpeg # run subprocess - self.log.debug("Executing: {}".format(cmd)) + self.log.debug("Executing: {}".format(" ".join(cmd))) openpype.api.run_subprocess( cmd, logger=self.log @@ -261,10 +262,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): for index, input in enumerate(inputs): input_format = input.copy() input_format.update({"i": index}) + input_format["mediaPath"] = path_to_subprocess_arg( + input_format["mediaPath"] + ) + _inputs += ( "-ss {startSec} " "-t {durationSec} " - "-i \"{mediaPath}\" " + "-i {mediaPath} " ).format(**input_format) _filters += "[{i}]adelay={delayMilSec}:all=1[r{i}]; ".format( diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index 818903b54b..ed2ba017d5 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -312,7 +312,7 @@ class ExtractOTIOReview(openpype.api.Extractor): out_frame_start += end_offset # start command list - command = ['"{}"'.format(ffmpeg_path)] + command = [ffmpeg_path] if sequence: input_dir, collection = sequence @@ -324,8 +324,8 @@ class ExtractOTIOReview(openpype.api.Extractor): # form command for rendering gap files command.extend([ - "-start_number {}".format(in_frame_start), - "-i \"{}\"".format(input_path) + "-start_number", str(in_frame_start), + "-i", input_path ]) elif video: @@ -334,13 +334,15 @@ class ExtractOTIOReview(openpype.api.Extractor): input_fps = otio_range.start_time.rate frame_duration = otio_range.duration.value sec_start = openpype.lib.frames_to_secons(frame_start, input_fps) - sec_duration = openpype.lib.frames_to_secons(frame_duration, input_fps) + sec_duration = openpype.lib.frames_to_secons( + frame_duration, input_fps + ) # form command for rendering gap files command.extend([ - "-ss {}".format(sec_start), - "-t {}".format(sec_duration), - "-i \"{}\"".format(video_path) + "-ss", str(sec_start), + "-t", str(sec_duration), + "-i", video_path ]) elif gap: @@ -349,22 +351,24 @@ class ExtractOTIOReview(openpype.api.Extractor): # form command for rendering gap files command.extend([ - "-t {} -r {}".format(sec_duration, self.actual_fps), - "-f lavfi", - "-i color=c=black:s={}x{}".format(self.to_width, - self.to_height), - "-tune stillimage" + "-t", str(sec_duration), + "-r", str(self.actual_fps), + "-f", "lavfi", + "-i", "color=c=black:s={}x{}".format( + self.to_width, self.to_height + ), + "-tune", "stillimage" ]) # add output attributes command.extend([ - "-start_number {}".format(out_frame_start), - "\"{}\"".format(output_path) + "-start_number", str(out_frame_start), + output_path ]) # execute self.log.debug("Executing: {}".format(" ".join(command))) output = openpype.api.run_subprocess( - " ".join(command), logger=self.log + command, logger=self.log ) self.log.debug("Output: {}".format(output)) diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index fdb7c4b096..3e2d39c99c 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -75,7 +75,7 @@ class ExtractOTIOTrimmingVideo(openpype.api.Extractor): output_path = self._get_ffmpeg_output(input_file_path) # start command list - command = ['"{}"'.format(ffmpeg_path)] + command = [ffmpeg_path] video_path = input_file_path frame_start = otio_range.start_time.value @@ -86,17 +86,17 @@ class ExtractOTIOTrimmingVideo(openpype.api.Extractor): # form command for rendering gap files command.extend([ - "-ss {}".format(sec_start), - "-t {}".format(sec_duration), - "-i \"{}\"".format(video_path), - "-c copy", + "-ss", str(sec_start), + "-t", str(sec_duration), + "-i", video_path, + "-c", "copy", output_path ]) # execute self.log.debug("Executing: {}".format(" ".join(command))) output = openpype.api.run_subprocess( - " ".join(command), logger=self.log + command, logger=self.log ) self.log.debug("Output: {}".format(output)) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 78cbea10be..f5d6789dd4 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -13,6 +13,9 @@ import openpype.api from openpype.lib import ( get_ffmpeg_tool_path, ffprobe_streams, + + path_to_subprocess_arg, + should_decompress, get_decompress_dir, decompress @@ -480,7 +483,9 @@ class ExtractReview(pyblish.api.InstancePlugin): # Add video/image input path ffmpeg_input_args.append( - "-i \"{}\"".format(temp_data["full_input_path"]) + "-i {}".format( + path_to_subprocess_arg(temp_data["full_input_path"]) + ) ) # Add audio arguments if there are any. Skipped when output are images. @@ -538,7 +543,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE This must be latest added item to output arguments. ffmpeg_output_args.append( - "\"{}\"".format(temp_data["full_output_path"]) + path_to_subprocess_arg(temp_data["full_output_path"]) ) return self.ffmpeg_full_args( @@ -607,7 +612,7 @@ class ExtractReview(pyblish.api.InstancePlugin): audio_filters.append(arg) all_args = [] - all_args.append("\"{}\"".format(self.ffmpeg_path)) + all_args.append(path_to_subprocess_arg(self.ffmpeg_path)) all_args.extend(input_args) if video_filters: all_args.append("-filter:v") @@ -854,7 +859,9 @@ class ExtractReview(pyblish.api.InstancePlugin): audio_in_args.append("-to {:0.10f}".format(audio_duration)) # Add audio input path - audio_in_args.append("-i \"{}\"".format(audio["filename"])) + audio_in_args.append("-i {}".format( + path_to_subprocess_arg(audio["filename"]) + )) # NOTE: These were changed from input to output arguments. # NOTE: value in "-ac" was hardcoded to 2, changed to audio inputs len. diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 2b07d7db74..7002168cdb 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -117,11 +117,13 @@ class ExtractReviewSlate(openpype.api.Extractor): input_args.extend(repre["_profile"].get('input', [])) else: input_args.extend(repre["outputDef"].get('input', [])) - input_args.append("-loop 1 -i {}".format(slate_path)) + input_args.append("-loop 1 -i {}".format( + openpype.lib.path_to_subprocess_arg(slate_path) + )) input_args.extend([ "-r {}".format(fps), - "-t 0.04"] - ) + "-t 0.04" + ]) if use_legacy_code: codec_args = repre["_profile"].get('codec', []) @@ -188,20 +190,24 @@ class ExtractReviewSlate(openpype.api.Extractor): output_args.append("-y") slate_v_path = slate_path.replace(".png", ext) - output_args.append(slate_v_path) + output_args.append( + openpype.lib.path_to_subprocess_arg(slate_v_path) + ) _remove_at_end.append(slate_v_path) slate_args = [ - "\"{}\"".format(ffmpeg_path), + openpype.lib.path_to_subprocess_arg(ffmpeg_path), " ".join(input_args), " ".join(output_args) ] - slate_subprcs_cmd = " ".join(slate_args) + slate_subprocess_cmd = " ".join(slate_args) # run slate generation subprocess - self.log.debug("Slate Executing: {}".format(slate_subprcs_cmd)) + self.log.debug( + "Slate Executing: {}".format(slate_subprocess_cmd) + ) openpype.api.run_subprocess( - slate_subprcs_cmd, shell=True, logger=self.log + slate_subprocess_cmd, shell=True, logger=self.log ) # create ffmpeg concat text file path @@ -221,23 +227,22 @@ class ExtractReviewSlate(openpype.api.Extractor): ]) # concat slate and videos together - conc_input_args = ["-y", "-f concat", "-safe 0"] - conc_input_args.append("-i {}".format(conc_text_path)) - - conc_output_args = ["-c copy"] - conc_output_args.append(output_path) - concat_args = [ ffmpeg_path, - " ".join(conc_input_args), - " ".join(conc_output_args) + "-y", + "-f", "concat", + "-safe", "0", + "-i", conc_text_path, + "-c", "copy", + output_path ] - concat_subprcs_cmd = " ".join(concat_args) # ffmpeg concat subprocess - self.log.debug("Executing concat: {}".format(concat_subprcs_cmd)) + self.log.debug( + "Executing concat: {}".format(" ".join(concat_args)) + ) openpype.api.run_subprocess( - concat_subprcs_cmd, shell=True, logger=self.log + concat_args, logger=self.log ) self.log.debug("__ repre[tags]: {}".format(repre["tags"])) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index f9e9b43f08..3bff3ff79c 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -106,12 +106,16 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "family", "hierarchy", "task", "username" ] default_template_name = "publish" - template_name_profiles = None + + # suffix to denote temporary files, use without '.' + TMP_FILE_EXT = 'tmp' # file_url : file_size of all published and uploaded files integrated_file_sizes = {} - TMP_FILE_EXT = 'tmp' # suffix to denote temporary files, use without '.' + # Attributes set by settings + template_name_profiles = None + subset_grouping_profiles = None def process(self, instance): self.integrated_file_sizes = {} @@ -165,10 +169,24 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): hierarchy = "/".join(parents) anatomy_data["hierarchy"] = hierarchy + # Make sure task name in anatomy data is same as on instance.data task_name = instance.data.get("task") if task_name: anatomy_data["task"] = task_name + else: + # Just set 'task_name' variable to context task + task_name = anatomy_data["task"] + # Find task type for current task name + # - this should be already prepared on instance + asset_tasks = ( + asset_entity.get("data", {}).get("tasks") + ) or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + instance.data["task_type"] = task_type + + # Fill family in anatomy data anatomy_data["family"] = instance.data.get("family") stagingdir = instance.data.get("stagingDir") @@ -298,14 +316,19 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): else: orig_transfers = list(instance.data['transfers']) - task_name = io.Session.get("AVALON_TASK") family = self.main_family_from_instance(instance) - key_values = {"families": family, - "tasks": task_name, - "hosts": instance.data["anatomyData"]["app"]} - profile = filter_profiles(self.template_name_profiles, key_values, - logger=self.log) + key_values = { + "families": family, + "tasks": task_name, + "hosts": instance.context.data["hostName"], + "task_types": task_type + } + profile = filter_profiles( + self.template_name_profiles, + key_values, + logger=self.log + ) template_name = "publish" if profile: @@ -730,6 +753,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): subset = io.find_one({"_id": _id}) + # QUESTION Why is changing of group and updating it's + # families in 'get_subset'? self._set_subset_group(instance, subset["_id"]) # Update families on subset. @@ -753,54 +778,74 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): subset_id (str): DB's subset _id """ - # add group if available - integrate_new_sett = (instance.context.data["project_settings"] - ["global"] - ["publish"] - ["IntegrateAssetNew"]) - - profiles = integrate_new_sett["subset_grouping_profiles"] - - filtering_criteria = { - "families": instance.data["family"], - "hosts": instance.data["anatomyData"]["app"], - "tasks": instance.data["anatomyData"]["task"] or - io.Session["AVALON_TASK"] - } - matching_profile = filter_profiles(profiles, filtering_criteria) - - filled_template = None - if matching_profile: - template = matching_profile["template"] - fill_pairs = ( - ("family", filtering_criteria["families"]), - ("task", filtering_criteria["tasks"]), - ("host", filtering_criteria["hosts"]), - ("subset", instance.data["subset"]), - ("renderlayer", instance.data.get("renderlayer")) - ) - fill_pairs = prepare_template_data(fill_pairs) - - try: - filled_template = \ - format_template_with_optional_keys(fill_pairs, template) - except KeyError: - keys = [] - if fill_pairs: - keys = fill_pairs.keys() - - msg = "Subset grouping failed. " \ - "Only {} are expected in Settings".format(','.join(keys)) - self.log.warning(msg) - - if instance.data.get("subsetGroup") or filled_template: - subset_group = instance.data.get('subsetGroup') or filled_template + # Fist look into instance data + subset_group = instance.data.get("subsetGroup") + if not subset_group: + subset_group = self._get_subset_group(instance) + if subset_group: io.update_many({ 'type': 'subset', '_id': io.ObjectId(subset_id) }, {'$set': {'data.subsetGroup': subset_group}}) + def _get_subset_group(self, instance): + """Look into subset group profiles set by settings. + + Attribute 'subset_grouping_profiles' is defined by OpenPype settings. + """ + # Skip if 'subset_grouping_profiles' is empty + if not self.subset_grouping_profiles: + return None + + # QUESTION + # - is there a chance that task name is not filled in anatomy + # data? + # - should we use context task in that case? + task_name = ( + instance.data["anatomyData"]["task"] + or io.Session["AVALON_TASK"] + ) + task_type = instance.data["task_type"] + filtering_criteria = { + "families": instance.data["family"], + "hosts": instance.context.data["hostName"], + "tasks": task_name, + "task_types": task_type + } + matching_profile = filter_profiles( + self.subset_grouping_profiles, + filtering_criteria + ) + # Skip if there is not matchin profile + if not matching_profile: + return None + + filled_template = None + template = matching_profile["template"] + fill_pairs = ( + ("family", filtering_criteria["families"]), + ("task", filtering_criteria["tasks"]), + ("host", filtering_criteria["hosts"]), + ("subset", instance.data["subset"]), + ("renderlayer", instance.data.get("renderlayer")) + ) + fill_pairs = prepare_template_data(fill_pairs) + + try: + filled_template = \ + format_template_with_optional_keys(fill_pairs, template) + except KeyError: + keys = [] + if fill_pairs: + keys = fill_pairs.keys() + + msg = "Subset grouping failed. " \ + "Only {} are expected in Settings".format(','.join(keys)) + self.log.warning(msg) + + return filled_template + def create_version(self, subset, version_number, data=None): """ Copy given source to destination diff --git a/openpype/plugins/publish/stop_timer.py b/openpype/plugins/publish/stop_timer.py index 81afd16378..5c939b5f1b 100644 --- a/openpype/plugins/publish/stop_timer.py +++ b/openpype/plugins/publish/stop_timer.py @@ -8,7 +8,7 @@ from openpype.api import get_system_settings class StopTimer(pyblish.api.ContextPlugin): label = "Stop Timer" - order = pyblish.api.ExtractorOrder - 0.5 + order = pyblish.api.ExtractorOrder - 0.49 hosts = ["*"] def process(self, context): diff --git a/openpype/plugins/publish/validate_ffmpeg_installed.py b/openpype/plugins/publish/validate_ffmpeg_installed.py deleted file mode 100644 index a5390a07b2..0000000000 --- a/openpype/plugins/publish/validate_ffmpeg_installed.py +++ /dev/null @@ -1,34 +0,0 @@ -import pyblish.api -import os -import subprocess -import openpype.lib -try: - import os.errno as errno -except ImportError: - import errno - - -class ValidateFFmpegInstalled(pyblish.api.ContextPlugin): - """Validate availability of ffmpeg tool in PATH""" - - order = pyblish.api.ValidatorOrder - label = 'Validate ffmpeg installation' - optional = True - - def is_tool(self, name): - try: - devnull = open(os.devnull, "w") - subprocess.Popen( - [name], stdout=devnull, stderr=devnull - ).communicate() - except OSError as e: - if e.errno == errno.ENOENT: - return False - return True - - def process(self, context): - ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") - self.log.info("ffmpeg path: `{}`".format(ffmpeg_path)) - if self.is_tool("{}".format(ffmpeg_path)) is False: - self.log.error("ffmpeg not found in PATH") - raise RuntimeError('ffmpeg not installed.') diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index ef4ed73974..c6886fea73 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -1,5 +1,5 @@ import os - +from openpype.lib.pype_info import is_running_staging RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -30,22 +30,22 @@ def get_liberation_font_path(bold=False, italic=False): return font_path -def pype_icon_filepath(debug=None): - if debug is None: - debug = bool(os.getenv("OPENPYPE_DEV")) +def get_openpype_icon_filepath(staging=None): + if staging is None: + staging = is_running_staging() - if debug: + if staging: icon_file_name = "openpype_icon_staging.png" else: icon_file_name = "openpype_icon.png" return get_resource("icons", icon_file_name) -def pype_splash_filepath(debug=None): - if debug is None: - debug = bool(os.getenv("OPENPYPE_DEV")) +def get_openpype_splash_filepath(staging=None): + if staging is None: + staging = is_running_staging() - if debug: + if staging: splash_file_name = "openpype_splash_staging.png" else: splash_file_name = "openpype_splash.png" diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index b5810deef4..74f2684b2a 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -1,7 +1,21 @@ +from .constants import ( + GLOBAL_SETTINGS_KEY, + SYSTEM_SETTINGS_KEY, + PROJECT_SETTINGS_KEY, + PROJECT_ANATOMY_KEY, + LOCAL_SETTING_KEY, + + SCHEMA_KEY_SYSTEM_SETTINGS, + SCHEMA_KEY_PROJECT_SETTINGS, + + KEY_ALLOWED_SYMBOLS, + KEY_REGEX +) from .exceptions import ( SaveWarningExc ) from .lib import ( + get_general_environments, get_system_settings, get_project_settings, get_current_project_settings, @@ -16,15 +30,27 @@ from .entities import ( __all__ = ( + "GLOBAL_SETTINGS_KEY", + "SYSTEM_SETTINGS_KEY", + "PROJECT_SETTINGS_KEY", + "PROJECT_ANATOMY_KEY", + "LOCAL_SETTING_KEY", + + "SCHEMA_KEY_SYSTEM_SETTINGS", + "SCHEMA_KEY_PROJECT_SETTINGS", + + "KEY_ALLOWED_SYMBOLS", + "KEY_REGEX", + "SaveWarningExc", + "get_general_environments", "get_system_settings", "get_project_settings", "get_current_project_settings", "get_anatomy_settings", "get_environments", "get_local_settings", - "SystemSettings", "ProjectSettings" ) diff --git a/openpype/settings/constants.py b/openpype/settings/constants.py index a53e88a91e..2ea19ead4b 100644 --- a/openpype/settings/constants.py +++ b/openpype/settings/constants.py @@ -14,13 +14,17 @@ METADATA_KEYS = ( M_DYNAMIC_KEY_LABEL ) -# File where studio's system overrides are stored +# Keys where studio's system overrides are stored GLOBAL_SETTINGS_KEY = "global_settings" SYSTEM_SETTINGS_KEY = "system_settings" PROJECT_SETTINGS_KEY = "project_settings" PROJECT_ANATOMY_KEY = "project_anatomy" LOCAL_SETTING_KEY = "local_settings" +# Schema hub names +SCHEMA_KEY_SYSTEM_SETTINGS = "system_schema" +SCHEMA_KEY_PROJECT_SETTINGS = "projects_schema" + DEFAULT_PROJECT_KEY = "__default_project__" KEY_ALLOWED_SYMBOLS = "a-zA-Z0-9-_ " @@ -39,6 +43,9 @@ __all__ = ( "PROJECT_ANATOMY_KEY", "LOCAL_SETTING_KEY", + "SCHEMA_KEY_SYSTEM_SETTINGS", + "SCHEMA_KEY_PROJECT_SETTINGS", + "DEFAULT_PROJECT_KEY", "KEY_ALLOWED_SYMBOLS", diff --git a/openpype/settings/defaults/project_anatomy/attributes.json b/openpype/settings/defaults/project_anatomy/attributes.json index 387e12bcea..983ac603f9 100644 --- a/openpype/settings/defaults/project_anatomy/attributes.json +++ b/openpype/settings/defaults/project_anatomy/attributes.json @@ -22,5 +22,6 @@ "aftereffects/2021", "unreal/4-26" ], - "tools_env": [] + "tools_env": [], + "active": true } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 692176a585..b3ea77a584 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -209,6 +209,7 @@ "standalonepublisher" ], "families": [], + "task_types": [], "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] @@ -221,6 +222,7 @@ "matchmove", "shot" ], + "task_types": [], "tasks": [], "add_ftrack_family": false, "advanced_filtering": [] @@ -232,6 +234,7 @@ "families": [ "plate" ], + "task_types": [], "tasks": [], "add_ftrack_family": false, "advanced_filtering": [ @@ -256,6 +259,7 @@ "rig", "camera" ], + "task_types": [], "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] @@ -267,6 +271,7 @@ "families": [ "renderPass" ], + "task_types": [], "tasks": [], "add_ftrack_family": false, "advanced_filtering": [] @@ -276,6 +281,7 @@ "tvpaint" ], "families": [], + "task_types": [], "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] @@ -288,6 +294,7 @@ "write", "render" ], + "task_types": [], "tasks": [], "add_ftrack_family": false, "advanced_filtering": [ @@ -307,6 +314,7 @@ "render", "workfile" ], + "task_types": [], "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index a53ae14914..8cc8d28e5f 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -152,6 +152,7 @@ { "families": [], "hosts": [], + "task_types": [], "tasks": [], "template_name": "publish" }, @@ -162,6 +163,7 @@ "prerender" ], "hosts": [], + "task_types": [], "tasks": [], "template_name": "render" } @@ -170,6 +172,7 @@ { "families": [], "hosts": [], + "task_types": [], "tasks": [], "template": "" } @@ -205,6 +208,7 @@ { "families": [], "hosts": [], + "task_types": [], "tasks": [], "template": "{family}{Variant}" }, @@ -213,6 +217,7 @@ "render" ], "hosts": [], + "task_types": [], "tasks": [], "template": "{family}{Task}{Variant}" }, @@ -224,6 +229,7 @@ "hosts": [ "tvpaint" ], + "task_types": [], "tasks": [], "template": "{family}{Task}_{Render_layer}_{Render_pass}" }, @@ -235,6 +241,7 @@ "hosts": [ "tvpaint" ], + "task_types": [], "tasks": [], "template": "{family}{Task}" }, @@ -245,6 +252,7 @@ "hosts": [ "aftereffects" ], + "task_types": [], "tasks": [], "template": "render{Task}{Variant}" } @@ -261,6 +269,7 @@ "last_workfile_on_startup": [ { "hosts": [], + "task_types": [], "tasks": [], "enabled": true } @@ -268,6 +277,7 @@ "open_workfile_tool_on_startup": [ { "hosts": [], + "task_types": [], "tasks": [], "enabled": false } @@ -287,6 +297,15 @@ "textures" ] } + }, + "loader": { + "family_filter_profiles": [ + { + "hosts": [], + "task_types": [], + "filter_families": [] + } + ] } }, "project_folder_structure": "{\"__project_root__\": {\"prod\": {}, \"resources\": {\"footage\": {\"plates\": {}, \"offline\": {}}, \"audio\": {}, \"art_dept\": {}}, \"editorial\": {}, \"assets[ftrack.Library]\": {\"characters[ftrack]\": {}, \"locations[ftrack]\": {}}, \"shots[ftrack.Sequence]\": {\"scripts\": {}, \"editorial[ftrack.Folder]\": {}}}}", diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index f9911897d7..3540c3eb29 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -520,6 +520,7 @@ "workfile_build": { "profiles": [ { + "task_types": [], "tasks": [ "Lighting" ], diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 136f1d6b42..ac35349415 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -30,7 +30,13 @@ }, "publish": { "PreCollectNukeInstances": { - "sync_workfile_version": true + "sync_workfile_version_on_families": [ + "nukenodes", + "camera", + "gizmo", + "source", + "render" + ] }, "ValidateContainers": { "enabled": true, @@ -96,6 +102,11 @@ }, "ExtractSlateFrame": { "viewer_lut_raw": false + }, + "IncrementScriptVersion": { + "enabled": true, + "optional": true, + "active": true } }, "load": { @@ -163,6 +174,7 @@ "builder_on_start": false, "profiles": [ { + "task_types": [], "tasks": [], "current_context": [ { diff --git a/openpype/settings/defaults/project_settings/slack.json b/openpype/settings/defaults/project_settings/slack.json index e70ef77fd2..2d10bd173d 100644 --- a/openpype/settings/defaults/project_settings/slack.json +++ b/openpype/settings/defaults/project_settings/slack.json @@ -7,8 +7,9 @@ "profiles": [ { "families": [], - "tasks": [], "hosts": [], + "task_types": [], + "tasks": [], "channel_messages": [] } ] diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 842c294599..cfdeca4b87 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -195,7 +195,7 @@ "environment": {} }, "__dynamic_keys_labels__": { - "13-0": "13.0 (Testing only)", + "13-0": "13.0", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", @@ -331,7 +331,7 @@ "environment": {} }, "__dynamic_keys_labels__": { - "13-0": "13.0 (Testing only)", + "13-0": "13.0", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index a0ba607edc..229b867327 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -1,4 +1,9 @@ { + "addon_paths": { + "windows": [], + "darwin": [], + "linux": [] + }, "avalon": { "AVALON_TIMEOUT": 1000, "AVALON_THUMBNAIL_ROOT": { diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index 8c30d5044c..aae2d1fa89 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -105,7 +105,6 @@ from .enum_entity import ( AppsEnumEntity, ToolsEnumEntity, TaskTypeEnumEntity, - ProvidersEnum, DeadlineUrlEnumEntity, AnatomyTemplatesEnumEntity ) @@ -113,7 +112,10 @@ from .enum_entity import ( from .list_entity import ListEntity from .dict_immutable_keys_entity import DictImmutableKeysEntity from .dict_mutable_keys_entity import DictMutableKeysEntity -from .dict_conditional import DictConditionalEntity +from .dict_conditional import ( + DictConditionalEntity, + SyncServerProviders +) from .anatomy_entities import AnatomyEntity @@ -161,7 +163,6 @@ __all__ = ( "AppsEnumEntity", "ToolsEnumEntity", "TaskTypeEnumEntity", - "ProvidersEnum", "DeadlineUrlEnumEntity", "AnatomyTemplatesEnumEntity", @@ -172,6 +173,7 @@ __all__ = ( "DictMutableKeysEntity", "DictConditionalEntity", + "SyncServerProviders", "AnatomyEntity" ) diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 851684520b..0e8274d374 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -104,6 +104,12 @@ class BaseItemEntity(BaseEntity): self.is_group = False # Entity's value will be stored into file with name of it's key self.is_file = False + # Default values are not stored to an openpype file + # - these must not be set through schemas directly + self.dynamic_schema_id = None + self.is_dynamic_schema_node = False + self.is_in_dynamic_schema_node = False + # Reference to parent entity which has `is_group` == True # - stays as None if none of parents is group self.group_item = None @@ -255,13 +261,22 @@ class BaseItemEntity(BaseEntity): ) # Group item can be only once in on hierarchy branch. - if self.is_group and self.group_item: + if self.is_group and self.group_item is not None: raise SchemeGroupHierarchyBug(self) + # Group item can be only once in on hierarchy branch. + if self.group_item is not None and self.is_dynamic_schema_node: + reason = ( + "Dynamic schema is inside grouped item {}." + " Change group hierarchy or remove dynamic" + " schema to be able work properly." + ).format(self.group_item.path) + raise EntitySchemaError(self, reason) + # Validate that env group entities will be stored into file. # - env group entities must store metadata which is not possible if # metadata would be outside of file - if not self.file_item and self.is_env_group: + if self.file_item is None and self.is_env_group: reason = ( "Environment item is not inside file" " item so can't store metadata for defaults." @@ -478,7 +493,15 @@ class BaseItemEntity(BaseEntity): @abstractmethod def settings_value(self): - """Value of an item without key.""" + """Value of an item without key without dynamic items.""" + pass + + @abstractmethod + def collect_dynamic_schema_entities(self): + """Collect entities that are on top of dynamically added schemas. + + This method make sence only when defaults are saved. + """ pass @abstractmethod @@ -808,6 +831,12 @@ class ItemEntity(BaseItemEntity): self.is_dynamic_item = is_dynamic_item self.is_file = self.schema_data.get("is_file", False) + # These keys have underscore as they must not be set in schemas + self.dynamic_schema_id = self.schema_data.get( + "_dynamic_schema_id", None + ) + self.is_dynamic_schema_node = self.dynamic_schema_id is not None + self.is_group = self.schema_data.get("is_group", False) self.is_in_dynamic_item = bool( not self.is_dynamic_item @@ -837,10 +866,20 @@ class ItemEntity(BaseItemEntity): self._require_restart_on_change = require_restart_on_change # File item reference - if self.parent.is_file: - self.file_item = self.parent - elif self.parent.file_item: - self.file_item = self.parent.file_item + if not self.is_dynamic_schema_node: + self.is_in_dynamic_schema_node = ( + self.parent.is_dynamic_schema_node + or self.parent.is_in_dynamic_schema_node + ) + + if ( + not self.is_dynamic_schema_node + and not self.is_in_dynamic_schema_node + ): + if self.parent.is_file: + self.file_item = self.parent + elif self.parent.file_item: + self.file_item = self.parent.file_item # Group item reference if self.parent.is_group: @@ -891,6 +930,18 @@ class ItemEntity(BaseItemEntity): def root_key(self): return self.root_item.root_key + @abstractmethod + def collect_dynamic_schema_entities(self, collector): + """Collect entities that are on top of dynamically added schemas. + + This method make sence only when defaults are saved. + + Args: + collector(DynamicSchemaValueCollector): Object where dynamic + entities are stored. + """ + pass + def schema_validations(self): if not self.label and self.use_label_wrap: reason = ( @@ -899,7 +950,12 @@ class ItemEntity(BaseItemEntity): ) raise EntitySchemaError(self, reason) - if self.is_file and self.file_item is not None: + if ( + not self.is_dynamic_schema_node + and not self.is_in_dynamic_schema_node + and self.is_file + and self.file_item is not None + ): reason = ( "Entity has set `is_file` to true but" " it's parent is already marked as file item." diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 988464d059..6f27760570 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -469,6 +469,10 @@ class DictConditionalEntity(ItemEntity): return True return False + def collect_dynamic_schema_entities(self, collector): + if self.is_dynamic_schema_node: + collector.add_entity(self) + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET @@ -482,13 +486,7 @@ class DictConditionalEntity(ItemEntity): output = {} for key, child_obj in children_items: - child_value = child_obj.settings_value() - if not child_obj.is_file and not child_obj.file_item: - for _key, _value in child_value.items(): - new_key = "/".join([key, _key]) - output[new_key] = _value - else: - output[key] = child_value + output[key] = child_obj.settings_value() return output if self.is_group: @@ -726,3 +724,49 @@ class DictConditionalEntity(ItemEntity): for children in self.children.values(): for child_entity in children: child_entity.reset_callbacks() + + +class SyncServerProviders(DictConditionalEntity): + schema_types = ["sync-server-providers"] + + def _add_children(self): + self.enum_key = "provider" + self.enum_label = "Provider" + + enum_children = self._get_enum_children() + if not enum_children: + enum_children.append({ + "key": None, + "label": "< Nothing >" + }) + self.enum_children = enum_children + + super(SyncServerProviders, self)._add_children() + + def _get_enum_children(self): + from openpype_modules import sync_server + + from openpype_modules.sync_server.providers import lib as lib_providers + + provider_code_to_label = {} + providers = lib_providers.factory.providers + for provider_code, provider_info in providers.items(): + provider, _ = provider_info + provider_code_to_label[provider_code] = provider.LABEL + + system_settings_schema = ( + sync_server + .SyncServerModule + .get_system_settings_schema() + ) + + enum_children = [] + for provider_code, configurables in system_settings_schema.items(): + label = provider_code_to_label.get(provider_code) or provider_code + + enum_children.append({ + "key": provider_code, + "label": label, + "children": configurables + }) + return enum_children diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index 73b08f101a..57e21ff5f3 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -330,15 +330,32 @@ class DictImmutableKeysEntity(ItemEntity): return True return False + def collect_dynamic_schema_entities(self, collector): + for child_obj in self.non_gui_children.values(): + child_obj.collect_dynamic_schema_entities(collector) + + if self.is_dynamic_schema_node: + collector.add_entity(self) + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET if self._override_state is OverrideState.DEFAULTS: + is_dynamic_schema_node = ( + self.is_dynamic_schema_node or self.is_in_dynamic_schema_node + ) output = {} for key, child_obj in self.non_gui_children.items(): + if child_obj.is_dynamic_schema_node: + continue + child_value = child_obj.settings_value() - if not child_obj.is_file and not child_obj.file_item: + if ( + not is_dynamic_schema_node + and not child_obj.is_file + and not child_obj.file_item + ): for _key, _value in child_value.items(): new_key = "/".join([key, _key]) output[new_key] = _value diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index c3df935269..f75fb23d82 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -261,7 +261,7 @@ class DictMutableKeysEntity(EndpointEntity): raise EntitySchemaError(self, reason) # TODO Ability to store labels should be defined with different key - if self.collapsible_key and not self.file_item: + if self.collapsible_key and self.file_item is None: reason = ( "Modifiable dictionary with collapsible keys is not under" " file item so can't store metadata." diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index cb532c5ae0..a5e734f039 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -376,11 +376,16 @@ class TaskTypeEnumEntity(BaseEnumEntity): schema_types = ["task-types-enum"] def _item_initalization(self): - self.multiselection = True - self.value_on_not_set = [] + self.multiselection = self.schema_data.get("multiselection", True) + if self.multiselection: + self.valid_value_types = (list, ) + self.value_on_not_set = [] + else: + self.valid_value_types = (STRING_TYPE, ) + self.value_on_not_set = "" + self.enum_items = [] self.valid_keys = set() - self.valid_value_types = (list, ) self.placeholder = None def _get_enum_values(self): @@ -396,53 +401,51 @@ class TaskTypeEnumEntity(BaseEnumEntity): return enum_items, valid_keys + def _convert_value_for_current_state(self, source_value): + if self.multiselection: + output = [] + for key in source_value: + if key in self.valid_keys: + output.append(key) + return output + + if source_value not in self.valid_keys: + # Take first item from enum items + for item in self.enum_items: + for key in item.keys(): + source_value = key + break + return source_value + def set_override_state(self, *args, **kwargs): super(TaskTypeEnumEntity, self).set_override_state(*args, **kwargs) self.enum_items, self.valid_keys = self._get_enum_values() - new_value = [] - for key in self._current_value: - if key in self.valid_keys: - new_value.append(key) - self._current_value = new_value + if self.multiselection: + new_value = [] + for key in self._current_value: + if key in self.valid_keys: + new_value.append(key) -class ProvidersEnum(BaseEnumEntity): - schema_types = ["providers-enum"] + if self._current_value != new_value: + self.set(new_value) + else: + if not self.enum_items: + self.valid_keys.add("") + self.enum_items.append({"": "< Empty >"}) - def _item_initalization(self): - self.multiselection = False - self.value_on_not_set = "" - self.enum_items = [] - self.valid_keys = set() - self.valid_value_types = (str, ) - self.placeholder = None + for item in self.enum_items: + for key in item.keys(): + value_on_not_set = key + break - def _get_enum_values(self): - from openpype_modules.sync_server.providers import lib as lib_providers - - providers = lib_providers.factory.providers - - valid_keys = set() - valid_keys.add('') - enum_items = [{'': 'Choose Provider'}] - for provider_code, provider_info in providers.items(): - provider, _ = provider_info - enum_items.append({provider_code: provider.LABEL}) - valid_keys.add(provider_code) - - return enum_items, valid_keys - - def set_override_state(self, *args, **kwargs): - super(ProvidersEnum, self).set_override_state(*args, **kwargs) - - self.enum_items, self.valid_keys = self._get_enum_values() - - value_on_not_set = list(self.valid_keys)[0] - if self._current_value is NOT_SET: - self._current_value = value_on_not_set - - self.value_on_not_set = value_on_not_set + self.value_on_not_set = value_on_not_set + if ( + self._current_value is NOT_SET + or self._current_value not in self.valid_keys + ): + self.set(value_on_not_set) class DeadlineUrlEnumEntity(BaseEnumEntity): diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 336d1f5c1e..0ded3ab7e5 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -49,6 +49,10 @@ class EndpointEntity(ItemEntity): super(EndpointEntity, self).schema_validations() + def collect_dynamic_schema_entities(self, collector): + if self.is_dynamic_schema_node: + collector.add_entity(self) + @abstractmethod def _settings_value(self): pass @@ -121,7 +125,11 @@ class InputEntity(EndpointEntity): def schema_validations(self): # Input entity must have file parent. - if not self.file_item: + if ( + not self.is_dynamic_schema_node + and not self.is_in_dynamic_schema_node + and self.file_item is None + ): raise EntitySchemaError(self, "Missing parent file entity.") super(InputEntity, self).schema_validations() @@ -369,6 +377,14 @@ class NumberEntity(InputEntity): self.valid_value_types = valid_value_types self.value_on_not_set = value_on_not_set + # UI specific attributes + self.show_slider = self.schema_data.get("show_slider", False) + steps = self.schema_data.get("steps", None) + # Make sure that steps are not set to `0` + if steps == 0: + steps = None + self.steps = steps + def _convert_to_valid_type(self, value): if isinstance(value, str): new_value = None diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index ac6b3e76dd..c7c9c3097e 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -115,6 +115,9 @@ class PathEntity(ItemEntity): def set(self, value): self.child_obj.set(value) + def collect_dynamic_schema_entities(self, *args, **kwargs): + self.child_obj.collect_dynamic_schema_entities(*args, **kwargs) + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET @@ -236,7 +239,12 @@ class ListStrictEntity(ItemEntity): def schema_validations(self): # List entity must have file parent. - if not self.file_item and not self.is_file: + if ( + not self.is_dynamic_schema_node + and not self.is_in_dynamic_schema_node + and not self.is_file + and self.file_item is None + ): raise EntitySchemaError( self, "Missing file entity in hierarchy." ) @@ -279,6 +287,10 @@ class ListStrictEntity(ItemEntity): for idx, item in enumerate(new_value): self.children[idx].set(item) + def collect_dynamic_schema_entities(self, collector): + if self.is_dynamic_schema_node: + collector.add_entity(self) + def settings_value(self): if self._override_state is OverrideState.NOT_DEFINED: return NOT_SET diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index 01f61d8bdf..bf3868c08d 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -3,6 +3,7 @@ import re import json import copy import inspect +import collections import contextlib from .exceptions import ( @@ -10,6 +11,12 @@ from .exceptions import ( SchemaDuplicatedEnvGroupKeys ) +from openpype.settings.constants import ( + SYSTEM_SETTINGS_KEY, + PROJECT_SETTINGS_KEY, + SCHEMA_KEY_SYSTEM_SETTINGS, + SCHEMA_KEY_PROJECT_SETTINGS +) try: STRING_TYPE = basestring except Exception: @@ -24,6 +31,10 @@ TEMPLATE_METADATA_KEYS = ( DEFAULT_VALUES_KEY, ) +SCHEMA_EXTEND_TYPES = ( + "schema", "template", "schema_template", "dynamic_schema" +) + template_key_pattern = re.compile(r"(\{.*?[^{0]*\})") @@ -102,8 +113,8 @@ class OverrideState: class SchemasHub: - def __init__(self, schema_subfolder, reset=True): - self._schema_subfolder = schema_subfolder + def __init__(self, schema_type, reset=True): + self._schema_type = schema_type self._loaded_types = {} self._gui_types = tuple() @@ -112,25 +123,60 @@ class SchemasHub: self._loaded_templates = {} self._loaded_schemas = {} + # Attributes for modules settings + self._dynamic_schemas_defs_by_id = {} + self._dynamic_schemas_by_id = {} + # Store validating and validated dynamic template or schemas self._validating_dynamic = set() self._validated_dynamic = set() - # 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() + @property + def schema_type(self): + return self._schema_type + def reset(self): + self._load_modules_settings_defs() + self._load_types() self._load_schemas() + def _load_modules_settings_defs(self): + from openpype.modules import get_module_settings_defs + + module_settings_defs = get_module_settings_defs() + for module_settings_def_cls in module_settings_defs: + module_settings_def = module_settings_def_cls() + def_id = module_settings_def.id + self._dynamic_schemas_defs_by_id[def_id] = module_settings_def + @property def gui_types(self): return self._gui_types + def resolve_dynamic_schema(self, dynamic_key): + output = [] + for def_id, def_keys in self._dynamic_schemas_by_id.items(): + if dynamic_key in def_keys: + def_schema = def_keys[dynamic_key] + if not def_schema: + continue + + if isinstance(def_schema, dict): + def_schema = [def_schema] + + all_def_schema = [] + for item in def_schema: + items = self.resolve_schema_data(item) + for _item in items: + _item["_dynamic_schema_id"] = def_id + all_def_schema.extend(items) + output.extend(all_def_schema) + return output + def get_template_name(self, item_def, default=None): """Get template name from passed item definition. @@ -260,7 +306,7 @@ class SchemasHub: list: Resolved schema data. """ schema_type = schema_data["type"] - if schema_type not in ("schema", "template", "schema_template"): + if schema_type not in SCHEMA_EXTEND_TYPES: return [schema_data] if schema_type == "schema": @@ -268,6 +314,9 @@ class SchemasHub: self.get_schema(schema_data["name"]) ) + if schema_type == "dynamic_schema": + return self.resolve_dynamic_schema(schema_data["name"]) + template_name = schema_data["name"] template_def = self.get_template(template_name) @@ -368,14 +417,16 @@ class SchemasHub: self._crashed_on_load = {} self._loaded_templates = {} self._loaded_schemas = {} + self._dynamic_schemas_by_id = {} dirpath = os.path.join( os.path.dirname(os.path.abspath(__file__)), "schemas", - self._schema_subfolder + self.schema_type ) loaded_schemas = {} loaded_templates = {} + dynamic_schemas_by_id = {} for root, _, filenames in os.walk(dirpath): for filename in filenames: basename, ext = os.path.splitext(filename) @@ -425,8 +476,34 @@ class SchemasHub: ) loaded_schemas[basename] = schema_data + defs_iter = self._dynamic_schemas_defs_by_id.items() + for def_id, module_settings_def in defs_iter: + dynamic_schemas_by_id[def_id] = ( + module_settings_def.get_dynamic_schemas(self.schema_type) + ) + module_schemas = module_settings_def.get_settings_schemas( + self.schema_type + ) + for key, schema_data in module_schemas.items(): + if isinstance(schema_data, list): + if key in loaded_templates: + raise KeyError( + "Duplicated template key \"{}\"".format(key) + ) + loaded_templates[key] = schema_data + else: + if key in loaded_schemas: + raise KeyError( + "Duplicated schema key \"{}\"".format(key) + ) + loaded_schemas[key] = schema_data + self._loaded_templates = loaded_templates self._loaded_schemas = loaded_schemas + self._dynamic_schemas_by_id = dynamic_schemas_by_id + + def get_dynamic_modules_settings_defs(self, schema_def_id): + return self._dynamic_schemas_defs_by_id.get(schema_def_id) def _fill_template(self, child_data, template_def): """Fill template based on schema definition and template definition. @@ -660,3 +737,38 @@ class SchemasHub: if found_idx is not None: metadata_item = template_def.pop(found_idx) return metadata_item + + +class DynamicSchemaValueCollector: + # Map schema hub type to store keys + schema_hub_type_map = { + SCHEMA_KEY_SYSTEM_SETTINGS: SYSTEM_SETTINGS_KEY, + SCHEMA_KEY_PROJECT_SETTINGS: PROJECT_SETTINGS_KEY + } + + def __init__(self, schema_hub): + self._schema_hub = schema_hub + self._dynamic_entities = [] + + def add_entity(self, entity): + self._dynamic_entities.append(entity) + + def create_hierarchy(self): + output = collections.defaultdict(dict) + for entity in self._dynamic_entities: + output[entity.dynamic_schema_id][entity.path] = ( + entity.settings_value() + ) + return output + + def save_values(self): + hierarchy = self.create_hierarchy() + + for schema_def_id, schema_def_value in hierarchy.items(): + schema_def = self._schema_hub.get_dynamic_modules_settings_defs( + schema_def_id + ) + top_key = self.schema_hub_type_map.get( + self._schema_hub.schema_type + ) + schema_def.save_defaults(top_key, schema_def_value) diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 4a06d2d591..05d20ee60b 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -9,8 +9,11 @@ from .base_entity import BaseItemEntity from .lib import ( NOT_SET, WRAPPER_TYPES, + SCHEMA_KEY_SYSTEM_SETTINGS, + SCHEMA_KEY_PROJECT_SETTINGS, OverrideState, - SchemasHub + SchemasHub, + DynamicSchemaValueCollector ) from .exceptions import ( SchemaError, @@ -28,6 +31,7 @@ from openpype.settings.lib import ( DEFAULTS_DIR, get_default_settings, + reset_default_settings, get_studio_system_settings_overrides, save_studio_settings, @@ -265,6 +269,16 @@ class RootEntity(BaseItemEntity): output[key] = child_obj.value return output + def collect_dynamic_schema_entities(self): + output = DynamicSchemaValueCollector(self.schema_hub) + if self._override_state is not OverrideState.DEFAULTS: + return output + + for child_obj in self.non_gui_children.values(): + child_obj.collect_dynamic_schema_entities(output) + + return output + def settings_value(self): """Value for current override state with metadata. @@ -276,6 +290,8 @@ class RootEntity(BaseItemEntity): if self._override_state is not OverrideState.DEFAULTS: output = {} for key, child_obj in self.non_gui_children.items(): + if child_obj.is_dynamic_schema_node: + continue value = child_obj.settings_value() if value is not NOT_SET: output[key] = value @@ -374,6 +390,7 @@ class RootEntity(BaseItemEntity): if self._override_state is OverrideState.DEFAULTS: self._save_default_values() + reset_default_settings() elif self._override_state is OverrideState.STUDIO: self._save_studio_values() @@ -421,6 +438,9 @@ class RootEntity(BaseItemEntity): with open(output_path, "w") as file_stream: json.dump(value, file_stream, indent=4) + dynamic_values_item = self.collect_dynamic_schema_entities() + dynamic_values_item.save_values() + @abstractmethod def _save_studio_values(self): """Save studio override values.""" @@ -476,7 +496,7 @@ class SystemSettings(RootEntity): ): if schema_hub is None: # Load system schemas - schema_hub = SchemasHub("system_schema") + schema_hub = SchemasHub(SCHEMA_KEY_SYSTEM_SETTINGS) super(SystemSettings, self).__init__(schema_hub, reset) @@ -607,7 +627,7 @@ class ProjectSettings(RootEntity): if schema_hub is None: # Load system schemas - schema_hub = SchemasHub("projects_schema") + schema_hub = SchemasHub(SCHEMA_KEY_PROJECT_SETTINGS) super(ProjectSettings, self).__init__(schema_hub, reset) diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 05605f8ce1..c8432f0f2e 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -112,6 +112,22 @@ ``` - It is possible to define default values for unfilled fields to do so one of items in list must be dictionary with key `"__default_values__"` and value as dictionary with default key: values (as in example above). +### dynamic_schema +- dynamic templates that can be defined by class of `ModuleSettingsDef` +- example: +``` +{ + "type": "dynamic_schema", + "name": "project_settings/global" +} +``` +- all valid `ModuleSettingsDef` classes where calling of `get_settings_schemas` + will return dictionary where is key "project_settings/global" with schemas + will extend and replace this item +- works almost the same way as templates + - one item can be replaced by multiple items (or by 0 items) +- goal is to dynamically loaded settings of OpenPype addons without having + their schemas or default values in main repository ## Basic Dictionary inputs - these inputs wraps another inputs into {key: value} relation @@ -300,6 +316,8 @@ How output of the schema could look like on save: - key `"decimal"` defines how many decimal places will be used, 0 is for integer input (Default: `0`) - key `"minimum"` as minimum allowed number to enter (Default: `-99999`) - key `"maxium"` as maximum allowed number to enter (Default: `99999`) +- key `"steps"` will change single step value of UI inputs (using arrows and wheel scroll) +- for UI it is possible to show slider to enable this option set `show_slider` to `true` ``` { "type": "number", @@ -311,6 +329,18 @@ How output of the schema could look like on save: } ``` +``` +{ + "type": "number", + "key": "ratio", + "label": "Ratio" + "decimal": 3, + "minimum": 0, + "maximum": 1, + "show_slider": true +} +``` + ### text - simple text input - key `"multiline"` allows to enter multiple lines of text (Default: `False`) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 575cfc9e72..c9eca5dedd 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -125,6 +125,10 @@ { "type": "schema", "name": "schema_project_unreal" + }, + { + "type": "dynamic_schema", + "name": "project_settings/global" } ] } 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 1cc08b96f8..e50e269695 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -650,6 +650,11 @@ "type": "list", "object_type": "text" }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", "label": "Task names", 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 170de7c8a2..9ca4e443bd 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -52,18 +52,23 @@ "type": "list", "object_type": "text" }, - { - "key": "tasks", - "label": "Task names", - "type": "list", - "object_type": "text" - }, { "type": "hosts-enum", "key": "hosts", "label": "Host names", "multiselection": true }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "tasks", + "label": "Task names", + "type": "list", + "object_type": "text" + }, { "type": "separator" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json index 7391108a02..a2a566da0e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json @@ -69,6 +69,11 @@ "type": "tools-enum", "key": "tools_env", "label": "Tools" + }, + { + "type": "boolean", + "key": "active", + "label": "Active Project" } ] } 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 4b91072eb6..e59d22aa89 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 @@ -502,6 +502,11 @@ "label": "Hosts", "multiselection": true }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", "label": "Task names", @@ -543,6 +548,11 @@ "label": "Hosts", "multiselection": true }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", "label": "Task names", 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 245560f115..26d3771d8a 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 @@ -40,6 +40,11 @@ "label": "Hosts", "multiselection": true }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", "label": "Task names", @@ -126,9 +131,14 @@ "unreal" ] }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", - "label": "Tasks", + "label": "Task names", "type": "list", "object_type": "text" }, @@ -161,9 +171,15 @@ "nuke" ] }, + { + "key": "task_types", + "label": "Task types", + "type": "list", + "object_type": "task-types-enum" + }, { "key": "tasks", - "label": "Tasks", + "label": "Task names", "type": "list", "object_type": "text" }, @@ -190,6 +206,48 @@ } } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "loader", + "label": "Loader", + "children": [ + { + "type": "list", + "key": "family_filter_profiles", + "label": "Family filtering", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "hosts-enum", + "key": "hosts", + "label": "Hosts", + "multiselection": true + }, + { + "type": "task-types-enum", + "key": "task_types", + "label": "Task types" + }, + { + "type": "splitter" + }, + { + "type": "template", + "name": "template_publish_families", + "template_data": { + "key": "filter_families", + "label": "Filter families", + "multiselection": 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 782179cfd1..c73453f8aa 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 @@ -16,9 +16,30 @@ "is_group": true, "children": [ { - "type": "boolean", - "key": "sync_workfile_version", - "label": "Sync Version from workfile" + "type": "enum", + "key": "sync_workfile_version_on_families", + "label": "Sync workfile version for families", + "multiselection": true, + "enum_items": [ + { + "nukenodes": "nukenodes" + }, + { + "camera": "camera" + }, + { + "gizmo": "gizmo" + }, + { + "source": "source" + }, + { + "prerender": "prerender" + }, + { + "render": "render" + } + ] } ] }, @@ -152,6 +173,38 @@ "label": "Viewer LUT raw" } ] + }, + { + "type": "splitter" + }, + { + "type": "label", + "label": "Integrators" + }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "IncrementScriptVersion", + "label": "IncrementScriptVersion", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_workfile_build.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_workfile_build.json index 078bb81bba..2a3f0ae136 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_workfile_build.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_workfile_build.json @@ -11,9 +11,14 @@ "object_type": { "type": "dict", "children": [ + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", - "label": "Tasks", + "label": "Task names", "type": "list", "object_type": "text" }, @@ -94,4 +99,4 @@ } } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json new file mode 100644 index 0000000000..9db1427562 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_publish_families.json @@ -0,0 +1,32 @@ +[ + { + "__default_values__": { + "multiselection": true + } + }, + { + "key": "{key}", + "label": "{label}", + "multiselection": "{multiselection}", + "type": "enum", + "enum_items": [ + {"action": "action"}, + {"animation": "animation"}, + {"audio": "audio"}, + {"camera": "camera"}, + {"editorial": "editorial"}, + {"layout": "layout"}, + {"look": "look"}, + {"mayaAscii": "mayaAscii"}, + {"model": "model"}, + {"pointcache": "pointcache"}, + {"reference": "reference"}, + {"render": "render"}, + {"review": "review"}, + {"rig": "rig"}, + {"setdress": "setdress"}, + {"workfile": "workfile"}, + {"xgen": "xgen"} + ] + } +] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_workfile_options.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_workfile_options.json index 815df85879..90fc4fbdd0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_workfile_options.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_workfile_options.json @@ -55,9 +55,14 @@ "object_type": { "type": "dict", "children": [ + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, { "key": "tasks", - "label": "Tasks", + "label": "Task names", "type": "list", "object_type": "text" }, diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index f633d5cb1a..af6a2d49f4 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -183,6 +183,15 @@ "minimum": -10, "maximum": -5 }, + { + "type": "number", + "key": "number_with_slider", + "label": "Number with slider", + "decimal": 2, + "minimum": 0.0, + "maximum": 1.0, + "show_slider": true + }, { "type": "text", "key": "singleline_text", diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index dd85f9351a..a2b31772e9 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -5,6 +5,18 @@ "collapsible": true, "is_file": true, "children": [ + { + "type": "path", + "key": "addon_paths", + "label": "OpenPype AddOn Paths", + "use_label_wrap": true, + "multiplatform": true, + "multipath": true, + "require_restart": true + }, + { + "type": "separator" + }, { "type": "dict", "key": "avalon", @@ -16,7 +28,8 @@ "type": "number", "key": "AVALON_TIMEOUT", "minimum": 0, - "label": "Avalon Mongo Timeout (ms)" + "label": "Avalon Mongo Timeout (ms)", + "steps": 100 }, { "type": "path", @@ -109,14 +122,7 @@ "collapsible_key": false, "object_type": { - "type": "dict", - "children": [ - { - "type": "providers-enum", - "key": "provider", - "label": "Provider" - } - ] + "type": "sync-server-providers" } } ] @@ -230,6 +236,10 @@ "label": "Enabled" } ] + }, + { + "type": "dynamic_schema", + "name": "system_settings/modules" } ] } diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 4a363910b8..60ed54bd4a 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -329,6 +329,45 @@ def reset_default_settings(): _DEFAULT_SETTINGS = None +def _get_default_settings(): + from openpype.modules import get_module_settings_defs + + defaults = load_openpype_default_settings() + + module_settings_defs = get_module_settings_defs() + for module_settings_def_cls in module_settings_defs: + module_settings_def = module_settings_def_cls() + system_defaults = module_settings_def.get_defaults( + SYSTEM_SETTINGS_KEY + ) or {} + for path, value in system_defaults.items(): + if not path: + continue + + subdict = defaults["system_settings"] + path_items = list(path.split("/")) + last_key = path_items.pop(-1) + for key in path_items: + subdict = subdict[key] + subdict[last_key] = value + + project_defaults = module_settings_def.get_defaults( + PROJECT_SETTINGS_KEY + ) or {} + for path, value in project_defaults.items(): + if not path: + continue + + subdict = defaults + path_items = list(path.split("/")) + last_key = path_items.pop(-1) + for key in path_items: + subdict = subdict[key] + subdict[last_key] = value + + return defaults + + def get_default_settings(): """Get default settings. @@ -338,12 +377,10 @@ def get_default_settings(): Returns: dict: Loaded default settings. """ - # TODO add cacher - return load_openpype_default_settings() - # global _DEFAULT_SETTINGS - # if _DEFAULT_SETTINGS is None: - # _DEFAULT_SETTINGS = load_jsons_from_dir(DEFAULTS_DIR) - # return copy.deepcopy(_DEFAULT_SETTINGS) + global _DEFAULT_SETTINGS + if _DEFAULT_SETTINGS is None: + _DEFAULT_SETTINGS = _get_default_settings() + return copy.deepcopy(_DEFAULT_SETTINGS) def load_json_file(fpath): @@ -380,8 +417,8 @@ def load_jsons_from_dir(path, *args, **kwargs): "data1": "CONTENT OF FILE" }, "folder2": { - "data1": { - "subfolder1": "CONTENT OF FILE" + "subfolder1": { + "data2": "CONTENT OF FILE" } } } diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index 87547b1a90..0d7904d133 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -91,4 +91,4 @@ def load_stylesheet(): def app_icon_path(): - return resources.pype_icon_filepath() + return resources.get_openpype_icon_filepath() diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index 14c6aff4ad..4d86970f9c 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -84,7 +84,7 @@ class ApplicationAction(api.Action): def _show_message_box(self, title, message, details=None): dialog = QtWidgets.QMessageBox() - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) dialog.setWindowIcon(icon) dialog.setStyleSheet(style.load_stylesheet()) dialog.setWindowTitle(title) diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 4988829c11..f87871409e 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -326,8 +326,6 @@ class ProjectModel(QtGui.QStandardItemModel): super(ProjectModel, self).__init__(parent=parent) self.dbcon = dbcon - - self.hide_invisible = False self.project_icon = qtawesome.icon("fa.map", color="white") self._project_names = set() @@ -380,16 +378,5 @@ class ProjectModel(QtGui.QStandardItemModel): self.invisibleRootItem().insertRows(row, items) def get_projects(self): - project_docs = [] - - for project_doc in sorted( - self.dbcon.projects(), key=lambda x: x["name"] - ): - if ( - self.hide_invisible - and not project_doc["data"].get("visible", True) - ): - continue - project_docs.append(project_doc) - - return project_docs + return sorted(self.dbcon.projects(only_active=True), + key=lambda x: x["name"]) diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index bd37a9b89c..9b839fb2bc 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -261,7 +261,7 @@ class LauncherWindow(QtWidgets.QDialog): self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False) - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setStyleSheet(style.load_stylesheet()) @@ -271,7 +271,6 @@ class LauncherWindow(QtWidgets.QDialog): ) project_model = ProjectModel(self.dbcon) - project_model.hide_invisible = True project_handler = ProjectHandler(self.dbcon, project_model) project_panel = ProjectsPanel(project_handler) diff --git a/openpype/tools/libraryloader/__init__.py b/openpype/tools/libraryloader/__init__.py new file mode 100644 index 0000000000..bbf4a1087d --- /dev/null +++ b/openpype/tools/libraryloader/__init__.py @@ -0,0 +1,11 @@ +from .app import ( + LibraryLoaderWindow, + show, + cli +) + +__all__ = [ + "LibraryLoaderWindow", + "show", + "cli", +] diff --git a/openpype/tools/libraryloader/__main__.py b/openpype/tools/libraryloader/__main__.py new file mode 100644 index 0000000000..d77bc585c5 --- /dev/null +++ b/openpype/tools/libraryloader/__main__.py @@ -0,0 +1,5 @@ +from . import cli + +if __name__ == '__main__': + import sys + sys.exit(cli(sys.argv[1:])) diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py new file mode 100644 index 0000000000..8080c547c9 --- /dev/null +++ b/openpype/tools/libraryloader/app.py @@ -0,0 +1,589 @@ +import sys + +from Qt import QtWidgets, QtCore, QtGui + +from avalon import style +from avalon.api import AvalonMongoDB +from openpype.tools.utils import lib as tools_lib +from openpype.tools.loader.widgets import ( + ThumbnailWidget, + VersionWidget, + FamilyListView, + RepresentationWidget +) +from openpype.tools.utils.widgets import AssetWidget + +from openpype.modules import ModulesManager + +from . import lib +from .widgets import LibrarySubsetWidget + +module = sys.modules[__name__] +module.window = None + + +class LibraryLoaderWindow(QtWidgets.QDialog): + """Asset library loader interface""" + + tool_title = "Library Loader 0.5" + tool_name = "library_loader" + + def __init__( + self, parent=None, icon=None, show_projects=False, show_libraries=True + ): + super(LibraryLoaderWindow, self).__init__(parent) + + self._initial_refresh = False + self._ignore_project_change = False + + # Enable minimize and maximize for app + self.setWindowTitle(self.tool_title) + self.setWindowFlags(QtCore.Qt.Window) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + if icon is not None: + self.setWindowIcon(icon) + # self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + body = QtWidgets.QWidget() + footer = QtWidgets.QWidget() + footer.setFixedHeight(20) + + container = QtWidgets.QWidget() + + self.dbcon = AvalonMongoDB() + self.dbcon.install() + self.dbcon.Session["AVALON_PROJECT"] = None + + self.show_projects = show_projects + self.show_libraries = show_libraries + + # Groups config + self.groups_config = tools_lib.GroupsConfig(self.dbcon) + self.family_config_cache = tools_lib.FamilyConfigCache(self.dbcon) + + assets = AssetWidget( + self.dbcon, multiselection=True, parent=self + ) + families = FamilyListView( + self.dbcon, self.family_config_cache, parent=self + ) + subsets = LibrarySubsetWidget( + self.dbcon, + self.groups_config, + self.family_config_cache, + tool_name=self.tool_name, + parent=self + ) + + version = VersionWidget(self.dbcon) + thumbnail = ThumbnailWidget(self.dbcon) + + # Project + self.combo_projects = QtWidgets.QComboBox() + + # Create splitter to show / hide family filters + asset_filter_splitter = QtWidgets.QSplitter() + asset_filter_splitter.setOrientation(QtCore.Qt.Vertical) + asset_filter_splitter.addWidget(self.combo_projects) + asset_filter_splitter.addWidget(assets) + asset_filter_splitter.addWidget(families) + asset_filter_splitter.setStretchFactor(1, 65) + asset_filter_splitter.setStretchFactor(2, 35) + + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + + representations = RepresentationWidget(self.dbcon) + thumb_ver_splitter = QtWidgets.QSplitter() + thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical) + thumb_ver_splitter.addWidget(thumbnail) + thumb_ver_splitter.addWidget(version) + if sync_server.enabled: + thumb_ver_splitter.addWidget(representations) + thumb_ver_splitter.setStretchFactor(0, 30) + thumb_ver_splitter.setStretchFactor(1, 35) + + container_layout = QtWidgets.QHBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + split = QtWidgets.QSplitter() + split.addWidget(asset_filter_splitter) + split.addWidget(subsets) + split.addWidget(thumb_ver_splitter) + split.setSizes([180, 950, 200]) + container_layout.addWidget(split) + + body_layout = QtWidgets.QHBoxLayout(body) + body_layout.addWidget(container) + body_layout.setContentsMargins(0, 0, 0, 0) + + message = QtWidgets.QLabel() + message.hide() + + footer_layout = QtWidgets.QVBoxLayout(footer) + footer_layout.addWidget(message) + footer_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + layout.addWidget(footer) + + self.data = { + "widgets": { + "families": families, + "assets": assets, + "subsets": subsets, + "version": version, + "thumbnail": thumbnail, + "representations": representations + }, + "label": { + "message": message, + }, + "state": { + "assetIds": None + } + } + + families.active_changed.connect(subsets.set_family_filters) + assets.selection_changed.connect(self.on_assetschanged) + assets.refresh_triggered.connect(self.on_assetschanged) + assets.view.clicked.connect(self.on_assetview_click) + subsets.active_changed.connect(self.on_subsetschanged) + subsets.version_changed.connect(self.on_versionschanged) + subsets.refreshed.connect(self._on_subset_refresh) + self.combo_projects.currentTextChanged.connect(self.on_project_change) + + self.sync_server = sync_server + + # Set default thumbnail on start + thumbnail.set_thumbnail(None) + + # Defaults + if sync_server.enabled: + split.setSizes([250, 1000, 550]) + self.resize(1800, 900) + else: + split.setSizes([250, 850, 200]) + self.resize(1300, 700) + + def showEvent(self, event): + super(LibraryLoaderWindow, self).showEvent(event) + if not self._initial_refresh: + self.refresh() + + def on_assetview_click(self, *args): + subsets_widget = self.data["widgets"]["subsets"] + selection_model = subsets_widget.view.selectionModel() + if selection_model.selectedIndexes(): + selection_model.clearSelection() + + def _set_projects(self): + # Store current project + old_project_name = self.current_project + + self._ignore_project_change = True + + # Cleanup + self.combo_projects.clear() + + # Fill combobox with projects + select_project_item = QtGui.QStandardItem("< Select project >") + select_project_item.setData(None, QtCore.Qt.UserRole + 1) + + combobox_items = [select_project_item] + + project_names = self.get_filtered_projects() + + for project_name in sorted(project_names): + item = QtGui.QStandardItem(project_name) + item.setData(project_name, QtCore.Qt.UserRole + 1) + combobox_items.append(item) + + root_item = self.combo_projects.model().invisibleRootItem() + root_item.appendRows(combobox_items) + + index = 0 + self._ignore_project_change = False + + if old_project_name: + index = self.combo_projects.findText( + old_project_name, QtCore.Qt.MatchFixedString + ) + + self.combo_projects.setCurrentIndex(index) + + def get_filtered_projects(self): + projects = list() + for project in self.dbcon.projects(): + is_library = project.get("data", {}).get("library_project", False) + if ( + (is_library and self.show_libraries) or + (not is_library and self.show_projects) + ): + projects.append(project["name"]) + + return projects + + def on_project_change(self): + if self._ignore_project_change: + return + + row = self.combo_projects.currentIndex() + index = self.combo_projects.model().index(row, 0) + project_name = index.data(QtCore.Qt.UserRole + 1) + + self.dbcon.Session["AVALON_PROJECT"] = project_name + + _config = lib.find_config() + if hasattr(_config, "install"): + _config.install() + else: + print( + "Config `%s` has no function `install`" % _config.__name__ + ) + + subsets = self.data["widgets"]["subsets"] + representations = self.data["widgets"]["representations"] + + subsets.on_project_change(self.dbcon.Session["AVALON_PROJECT"]) + representations.on_project_change(self.dbcon.Session["AVALON_PROJECT"]) + + self.family_config_cache.refresh() + self.groups_config.refresh() + + self._refresh_assets() + self._assetschanged() + + project_name = self.dbcon.active_project() or "No project selected" + title = "{} - {}".format(self.tool_title, project_name) + self.setWindowTitle(title) + + @property + def current_project(self): + if ( + not self.dbcon.active_project() or + self.dbcon.active_project() == "" + ): + return None + + return self.dbcon.active_project() + + # ------------------------------- + # Delay calling blocking methods + # ------------------------------- + + def refresh(self): + self.echo("Fetching results..") + tools_lib.schedule(self._refresh, 50, channel="mongo") + + def on_assetschanged(self, *args): + self.echo("Fetching asset..") + tools_lib.schedule(self._assetschanged, 50, channel="mongo") + + def on_subsetschanged(self, *args): + self.echo("Fetching subset..") + tools_lib.schedule(self._subsetschanged, 50, channel="mongo") + + def on_versionschanged(self, *args): + self.echo("Fetching version..") + tools_lib.schedule(self._versionschanged, 150, channel="mongo") + + def _on_subset_refresh(self, has_item): + subsets_widget = self.data["widgets"]["subsets"] + families_view = self.data["widgets"]["families"] + + subsets_widget.set_loading_state(loading=False, empty=not has_item) + families = subsets_widget.get_subsets_families() + families_view.set_enabled_families(families) + + def set_context(self, context, refresh=True): + self.echo("Setting context: {}".format(context)) + lib.schedule( + lambda: self._set_context(context, refresh=refresh), + 50, channel="mongo" + ) + + # ------------------------------ + def _refresh(self): + if not self._initial_refresh: + self._initial_refresh = True + self._set_projects() + + def _refresh_assets(self): + """Load assets from database""" + if self.current_project is not None: + # Ensure a project is loaded + project_doc = self.dbcon.find_one( + {"type": "project"}, + {"type": 1} + ) + assert project_doc, "This is a bug" + + assets_widget = self.data["widgets"]["assets"] + families_view = self.data["widgets"]["families"] + families_view.set_enabled_families(set()) + families_view.refresh() + + assets_widget.model.stop_fetch_thread() + assets_widget.refresh() + assets_widget.setFocus() + + def clear_assets_underlines(self): + last_asset_ids = self.data["state"]["assetIds"] + if not last_asset_ids: + return + + assets_widget = self.data["widgets"]["assets"] + id_role = assets_widget.model.ObjectIdRole + + for index in tools_lib.iter_model_rows(assets_widget.model, 0): + if index.data(id_role) not in last_asset_ids: + continue + + assets_widget.model.setData( + index, [], assets_widget.model.subsetColorsRole + ) + + def _assetschanged(self): + """Selected assets have changed""" + assets_widget = self.data["widgets"]["assets"] + subsets_widget = self.data["widgets"]["subsets"] + subsets_model = subsets_widget.model + + subsets_model.clear() + self.clear_assets_underlines() + + if not self.dbcon.Session.get("AVALON_PROJECT"): + subsets_widget.set_loading_state( + loading=False, + empty=True + ) + return + + # filter None docs they are silo + asset_docs = assets_widget.get_selected_assets() + if len(asset_docs) == 0: + return + + asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] + # Start loading + subsets_widget.set_loading_state( + loading=bool(asset_ids), + empty=True + ) + + subsets_model.set_assets(asset_ids) + subsets_widget.view.setColumnHidden( + subsets_model.Columns.index("asset"), + len(asset_ids) < 2 + ) + + # Clear the version information on asset change + self.data["widgets"]["version"].set_version(None) + self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs) + + self.data["state"]["assetIds"] = asset_ids + + representations = self.data["widgets"]["representations"] + # reset repre list + representations.set_version_ids([]) + + def _subsetschanged(self): + asset_ids = self.data["state"]["assetIds"] + # Skip setting colors if not asset multiselection + if not asset_ids or len(asset_ids) < 2: + self._versionschanged() + return + + subsets = self.data["widgets"]["subsets"] + selected_subsets = subsets.selected_subsets(_merged=True, _other=False) + + asset_models = {} + asset_ids = [] + for subset_node in selected_subsets: + asset_ids.extend(subset_node.get("assetIds", [])) + asset_ids = set(asset_ids) + + for subset_node in selected_subsets: + for asset_id in asset_ids: + if asset_id not in asset_models: + asset_models[asset_id] = [] + + color = None + if asset_id in subset_node.get("assetIds", []): + color = subset_node["subsetColor"] + + asset_models[asset_id].append(color) + + self.clear_assets_underlines() + + assets_widget = self.data["widgets"]["assets"] + indexes = assets_widget.view.selectionModel().selectedRows() + + for index in indexes: + id = index.data(assets_widget.model.ObjectIdRole) + if id not in asset_models: + continue + + assets_widget.model.setData( + index, asset_models[id], assets_widget.model.subsetColorsRole + ) + # Trigger repaint + assets_widget.view.updateGeometries() + # Set version in Version Widget + self._versionschanged() + + def _versionschanged(self): + + subsets = self.data["widgets"]["subsets"] + selection = subsets.view.selectionModel() + + # Active must be in the selected rows otherwise we + # assume it's not actually an "active" current index. + version_docs = None + version_doc = None + active = selection.currentIndex() + rows = selection.selectedRows(column=active.column()) + if active and active in rows: + item = active.data(subsets.model.ItemRole) + if ( + item is not None + and not (item.get("isGroup") or item.get("isMerged")) + ): + version_doc = item["version_document"] + + if rows: + version_docs = [] + for index in rows: + if not index or not index.isValid(): + continue + item = index.data(subsets.model.ItemRole) + if ( + item is None + or item.get("isGroup") + or item.get("isMerged") + ): + continue + version_docs.append(item["version_document"]) + + self.data["widgets"]["version"].set_version(version_doc) + + thumbnail_docs = version_docs + if not thumbnail_docs: + assets_widget = self.data["widgets"]["assets"] + asset_docs = assets_widget.get_selected_assets() + if len(asset_docs) > 0: + thumbnail_docs = asset_docs + + self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs) + + representations = self.data["widgets"]["representations"] + version_ids = [doc["_id"] for doc in version_docs or []] + representations.set_version_ids(version_ids) + + def _set_context(self, context, refresh=True): + """Set the selection in the interface using a context. + The context must contain `asset` data by name. + Note: Prior to setting context ensure `refresh` is triggered so that + the "silos" are listed correctly, aside from that setting the + context will force a refresh further down because it changes + the active silo and asset. + Args: + context (dict): The context to apply. + Returns: + None + """ + + asset = context.get("asset", None) + if asset is None: + return + + if refresh: + # Workaround: + # Force a direct (non-scheduled) refresh prior to setting the + # asset widget's silo and asset selection to ensure it's correctly + # displaying the silo tabs. Calling `window.refresh()` and directly + # `window.set_context()` the `set_context()` seems to override the + # scheduled refresh and the silo tabs are not shown. + self._refresh_assets() + + asset_widget = self.data["widgets"]["assets"] + asset_widget.select_assets(asset) + + def echo(self, message): + widget = self.data["label"]["message"] + widget.setText(str(message)) + widget.show() + print(message) + + tools_lib.schedule(widget.hide, 5000, channel="message") + + def closeEvent(self, event): + # Kill on holding SHIFT + modifiers = QtWidgets.QApplication.queryKeyboardModifiers() + shift_pressed = QtCore.Qt.ShiftModifier & modifiers + + if shift_pressed: + print("Force quitted..") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + print("Good bye") + return super(LibraryLoaderWindow, self).closeEvent(event) + + +def show( + debug=False, parent=None, icon=None, + show_projects=False, show_libraries=True +): + """Display Loader GUI + + Arguments: + debug (bool, optional): Run loader in debug-mode, + defaults to False + parent (QtCore.QObject, optional): The Qt object to parent to. + use_context (bool): Whether to apply the current context upon launch + + """ + # Remember window + if module.window is not None: + try: + module.window.show() + + # If the window is minimized then unminimize it. + if module.window.windowState() & QtCore.Qt.WindowMinimized: + module.window.setWindowState(QtCore.Qt.WindowActive) + + # Raise and activate the window + module.window.raise_() # for MacOS + module.window.activateWindow() # for Windows + module.window.refresh() + return + except RuntimeError as e: + if not e.message.rstrip().endswith("already deleted."): + raise + + # Garbage collected + module.window = None + + if debug: + import traceback + sys.excepthook = lambda typ, val, tb: traceback.print_last() + + with tools_lib.application(): + window = LibraryLoaderWindow( + parent, icon, show_projects, show_libraries + ) + window.setStyleSheet(style.load_stylesheet()) + window.show() + + module.window = window + + +def cli(args): + + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("project") + + show(show_projects=True, show_libraries=True) diff --git a/openpype/tools/libraryloader/lib.py b/openpype/tools/libraryloader/lib.py new file mode 100644 index 0000000000..6a497a6a16 --- /dev/null +++ b/openpype/tools/libraryloader/lib.py @@ -0,0 +1,33 @@ +import os +import importlib +import logging +from openpype.api import Anatomy + +log = logging.getLogger(__name__) + + +# `find_config` from `pipeline` +def find_config(): + log.info("Finding configuration for project..") + + config = os.environ["AVALON_CONFIG"] + + if not config: + raise EnvironmentError( + "No configuration found in " + "the project nor environment" + ) + + log.info("Found %s, loading.." % config) + return importlib.import_module(config) + + +class RegisteredRoots: + roots_per_project = {} + + @classmethod + def registered_root(cls, project_name): + if project_name not in cls.roots_per_project: + cls.roots_per_project[project_name] = Anatomy(project_name).roots + + return cls.roots_per_project[project_name] diff --git a/openpype/tools/libraryloader/widgets.py b/openpype/tools/libraryloader/widgets.py new file mode 100644 index 0000000000..45f9ea2048 --- /dev/null +++ b/openpype/tools/libraryloader/widgets.py @@ -0,0 +1,18 @@ +from Qt import QtWidgets + +from .lib import RegisteredRoots +from openpype.tools.loader.widgets import SubsetWidget + + +class LibrarySubsetWidget(SubsetWidget): + def on_copy_source(self): + """Copy formatted source path to clipboard""" + source = self.data.get("source", None) + if not source: + return + + project_name = self.dbcon.Session["AVALON_PROJECT"] + root = RegisteredRoots.registered_root(project_name) + path = source.format(root=root) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(path) diff --git a/openpype/tools/loader/__init__.py b/openpype/tools/loader/__init__.py new file mode 100644 index 0000000000..a5fda8f018 --- /dev/null +++ b/openpype/tools/loader/__init__.py @@ -0,0 +1,11 @@ +from .app import ( + LoaderWindow, + show, + cli, +) + +__all__ = ( + "LoaderWindow", + "show", + "cli", +) diff --git a/openpype/tools/loader/__main__.py b/openpype/tools/loader/__main__.py new file mode 100644 index 0000000000..146ba7fd10 --- /dev/null +++ b/openpype/tools/loader/__main__.py @@ -0,0 +1,33 @@ +"""Main entrypoint for standalone debugging + + Used for running 'avalon.tool.loader.__main__' as a module (-m), useful for + debugging without need to start host. + + Modify AVALON_MONGO accordingly +""" +import os +import sys +from . import cli + + +def my_exception_hook(exctype, value, traceback): + # Print the error and traceback + print(exctype, value, traceback) + # Call the normal Exception hook after + sys._excepthook(exctype, value, traceback) + sys.exit(1) + + +if __name__ == '__main__': + os.environ["AVALON_MONGO"] = "mongodb://localhost:27017" + os.environ["OPENPYPE_MONGO"] = "mongodb://localhost:27017" + os.environ["AVALON_DB"] = "avalon" + os.environ["AVALON_TIMEOUT"] = "1000" + os.environ["OPENPYPE_DEBUG"] = "1" + os.environ["AVALON_CONFIG"] = "pype" + os.environ["AVALON_ASSET"] = "Jungle" + + # Set the exception hook to our wrapping function + sys.excepthook = my_exception_hook + + sys.exit(cli(sys.argv[1:])) diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py new file mode 100644 index 0000000000..c18b6e798a --- /dev/null +++ b/openpype/tools/loader/app.py @@ -0,0 +1,676 @@ +import sys + +from Qt import QtWidgets, QtCore +from avalon import api, io, style, pipeline + +from openpype.tools.utils.widgets import AssetWidget + +from openpype.tools.utils import lib + +from .widgets import ( + SubsetWidget, + VersionWidget, + FamilyListView, + ThumbnailWidget, + RepresentationWidget, + OverlayFrame +) + +from openpype.modules import ModulesManager + +module = sys.modules[__name__] +module.window = None + + +# Register callback on task change +# - callback can't be defined in Window as it is weak reference callback +# so `WeakSet` will remove it immidiatelly +def on_context_task_change(*args, **kwargs): + if module.window: + module.window.on_context_task_change(*args, **kwargs) + + +pipeline.on("taskChanged", on_context_task_change) + + +class LoaderWindow(QtWidgets.QDialog): + """Asset loader interface""" + + tool_name = "loader" + + def __init__(self, parent=None): + super(LoaderWindow, self).__init__(parent) + title = "Asset Loader 2.1" + project_name = api.Session.get("AVALON_PROJECT") + if project_name: + title += " - {}".format(project_name) + self.setWindowTitle(title) + + # Groups config + self.groups_config = lib.GroupsConfig(io) + self.family_config_cache = lib.FamilyConfigCache(io) + + # Enable minimize and maximize for app + self.setWindowFlags(QtCore.Qt.Window) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + body = QtWidgets.QWidget() + footer = QtWidgets.QWidget() + footer.setFixedHeight(20) + + container = QtWidgets.QWidget() + + assets = AssetWidget(io, multiselection=True, parent=self) + assets.set_current_asset_btn_visibility(True) + + families = FamilyListView(io, self.family_config_cache, self) + subsets = SubsetWidget( + io, + self.groups_config, + self.family_config_cache, + tool_name=self.tool_name, + parent=self + ) + version = VersionWidget(io) + thumbnail = ThumbnailWidget(io) + representations = RepresentationWidget(io, self.tool_name) + + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + + thumb_ver_splitter = QtWidgets.QSplitter() + thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical) + thumb_ver_splitter.addWidget(thumbnail) + thumb_ver_splitter.addWidget(version) + if sync_server.enabled: + thumb_ver_splitter.addWidget(representations) + thumb_ver_splitter.setStretchFactor(0, 30) + thumb_ver_splitter.setStretchFactor(1, 35) + + # Create splitter to show / hide family filters + asset_filter_splitter = QtWidgets.QSplitter() + asset_filter_splitter.setOrientation(QtCore.Qt.Vertical) + asset_filter_splitter.addWidget(assets) + asset_filter_splitter.addWidget(families) + asset_filter_splitter.setStretchFactor(0, 65) + asset_filter_splitter.setStretchFactor(1, 35) + + container_layout = QtWidgets.QHBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + split = QtWidgets.QSplitter() + split.addWidget(asset_filter_splitter) + split.addWidget(subsets) + split.addWidget(thumb_ver_splitter) + + container_layout.addWidget(split) + + body_layout = QtWidgets.QHBoxLayout(body) + body_layout.addWidget(container) + body_layout.setContentsMargins(0, 0, 0, 0) + + message = QtWidgets.QLabel() + message.hide() + + footer_layout = QtWidgets.QVBoxLayout(footer) + footer_layout.addWidget(message) + footer_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + layout.addWidget(footer) + + self.data = { + "widgets": { + "families": families, + "assets": assets, + "subsets": subsets, + "version": version, + "thumbnail": thumbnail, + "representations": representations + }, + "label": { + "message": message, + }, + "state": { + "assetIds": None + } + } + + overlay_frame = OverlayFrame("Loading...", self) + overlay_frame.setVisible(False) + + families.active_changed.connect(subsets.set_family_filters) + assets.selection_changed.connect(self.on_assetschanged) + assets.refresh_triggered.connect(self.on_assetschanged) + assets.view.clicked.connect(self.on_assetview_click) + subsets.active_changed.connect(self.on_subsetschanged) + subsets.version_changed.connect(self.on_versionschanged) + subsets.refreshed.connect(self._on_subset_refresh) + + subsets.load_started.connect(self._on_load_start) + subsets.load_ended.connect(self._on_load_end) + representations.load_started.connect(self._on_load_start) + representations.load_ended.connect(self._on_load_end) + + self._overlay_frame = overlay_frame + + self.family_config_cache.refresh() + self.groups_config.refresh() + + self._refresh() + self._assetschanged() + + # Defaults + if sync_server.enabled: + split.setSizes([250, 1000, 550]) + self.resize(1800, 900) + else: + split.setSizes([250, 850, 200]) + self.resize(1300, 700) + + def resizeEvent(self, event): + super(LoaderWindow, self).resizeEvent(event) + self._overlay_frame.resize(self.size()) + + def moveEvent(self, event): + super(LoaderWindow, self).moveEvent(event) + self._overlay_frame.move(0, 0) + + # ------------------------------- + # Delay calling blocking methods + # ------------------------------- + + def on_assetview_click(self, *args): + subsets_widget = self.data["widgets"]["subsets"] + selection_model = subsets_widget.view.selectionModel() + if selection_model.selectedIndexes(): + selection_model.clearSelection() + + def refresh(self): + self.echo("Fetching results..") + lib.schedule(self._refresh, 50, channel="mongo") + + def on_assetschanged(self, *args): + self.echo("Fetching asset..") + lib.schedule(self._assetschanged, 50, channel="mongo") + + def on_subsetschanged(self, *args): + self.echo("Fetching subset..") + lib.schedule(self._subsetschanged, 50, channel="mongo") + + def on_versionschanged(self, *args): + self.echo("Fetching version..") + lib.schedule(self._versionschanged, 150, channel="mongo") + + def set_context(self, context, refresh=True): + self.echo("Setting context: {}".format(context)) + lib.schedule(lambda: self._set_context(context, refresh=refresh), + 50, channel="mongo") + + def _on_load_start(self): + # Show overlay and process events so it's repainted + self._overlay_frame.setVisible(True) + QtWidgets.QApplication.processEvents() + + def _hide_overlay(self): + self._overlay_frame.setVisible(False) + + def _on_subset_refresh(self, has_item): + subsets_widget = self.data["widgets"]["subsets"] + families_view = self.data["widgets"]["families"] + + subsets_widget.set_loading_state(loading=False, empty=not has_item) + families = subsets_widget.get_subsets_families() + families_view.set_enabled_families(families) + + def _on_load_end(self): + # Delay hiding as click events happened during loading should be + # blocked + QtCore.QTimer.singleShot(100, self._hide_overlay) + + # ------------------------------ + + def on_context_task_change(self, *args, **kwargs): + assets_widget = self.data["widgets"]["assets"] + families_view = self.data["widgets"]["families"] + # Refresh families config + families_view.refresh() + # Change to context asset on context change + assets_widget.select_assets(io.Session["AVALON_ASSET"]) + + def _refresh(self): + """Load assets from database""" + + # Ensure a project is loaded + project = io.find_one({"type": "project"}, {"type": 1}) + assert project, "Project was not found! This is a bug" + + assets_widget = self.data["widgets"]["assets"] + assets_widget.refresh() + assets_widget.setFocus() + + families_view = self.data["widgets"]["families"] + families_view.refresh() + + def clear_assets_underlines(self): + """Clear colors from asset data to remove colored underlines + When multiple assets are selected colored underlines mark which asset + own selected subsets. These colors must be cleared from asset data + on selection change so they match current selection. + """ + last_asset_ids = self.data["state"]["assetIds"] + if not last_asset_ids: + return + + assets_widget = self.data["widgets"]["assets"] + id_role = assets_widget.model.ObjectIdRole + + for index in lib.iter_model_rows(assets_widget.model, 0): + if index.data(id_role) not in last_asset_ids: + continue + + assets_widget.model.setData( + index, [], assets_widget.model.subsetColorsRole + ) + + def _assetschanged(self): + """Selected assets have changed""" + assets_widget = self.data["widgets"]["assets"] + subsets_widget = self.data["widgets"]["subsets"] + subsets_model = subsets_widget.model + + subsets_model.clear() + self.clear_assets_underlines() + + # filter None docs they are silo + asset_docs = assets_widget.get_selected_assets() + + asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] + # Start loading + subsets_widget.set_loading_state( + loading=bool(asset_ids), + empty=True + ) + + subsets_model.set_assets(asset_ids) + subsets_widget.view.setColumnHidden( + subsets_model.Columns.index("asset"), + len(asset_ids) < 2 + ) + + # Clear the version information on asset change + self.data["widgets"]["version"].set_version(None) + self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs) + + self.data["state"]["assetIds"] = asset_ids + + representations = self.data["widgets"]["representations"] + # reset repre list + representations.set_version_ids([]) + + def _subsetschanged(self): + asset_ids = self.data["state"]["assetIds"] + # Skip setting colors if not asset multiselection + if not asset_ids or len(asset_ids) < 2: + self._versionschanged() + return + + subsets = self.data["widgets"]["subsets"] + selected_subsets = subsets.selected_subsets(_merged=True, _other=False) + + asset_models = {} + asset_ids = [] + for subset_node in selected_subsets: + asset_ids.extend(subset_node.get("assetIds", [])) + asset_ids = set(asset_ids) + + for subset_node in selected_subsets: + for asset_id in asset_ids: + if asset_id not in asset_models: + asset_models[asset_id] = [] + + color = None + if asset_id in subset_node.get("assetIds", []): + color = subset_node["subsetColor"] + + asset_models[asset_id].append(color) + + self.clear_assets_underlines() + + assets_widget = self.data["widgets"]["assets"] + indexes = assets_widget.view.selectionModel().selectedRows() + + for index in indexes: + id = index.data(assets_widget.model.ObjectIdRole) + if id not in asset_models: + continue + + assets_widget.model.setData( + index, asset_models[id], assets_widget.model.subsetColorsRole + ) + # Trigger repaint + assets_widget.view.updateGeometries() + # Set version in Version Widget + self._versionschanged() + + def _versionschanged(self): + subsets = self.data["widgets"]["subsets"] + selection = subsets.view.selectionModel() + + # Active must be in the selected rows otherwise we + # assume it's not actually an "active" current index. + version_docs = None + version_doc = None + active = selection.currentIndex() + rows = selection.selectedRows(column=active.column()) + if active: + if active in rows: + item = active.data(subsets.model.ItemRole) + if ( + item is not None and + not (item.get("isGroup") or item.get("isMerged")) + ): + version_doc = item["version_document"] + + if rows: + version_docs = [] + for index in rows: + if not index or not index.isValid(): + continue + item = index.data(subsets.model.ItemRole) + if item is None: + continue + if item.get("isGroup") or item.get("isMerged"): + for child in item.children(): + version_docs.append(child["version_document"]) + else: + version_docs.append(item["version_document"]) + + self.data["widgets"]["version"].set_version(version_doc) + + thumbnail_docs = version_docs + assets_widget = self.data["widgets"]["assets"] + asset_docs = assets_widget.get_selected_assets() + if not thumbnail_docs: + if len(asset_docs) > 0: + thumbnail_docs = asset_docs + + self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs) + + representations = self.data["widgets"]["representations"] + version_ids = [doc["_id"] for doc in version_docs or []] + representations.set_version_ids(version_ids) + + # representations.change_visibility("subset", len(rows) > 1) + # representations.change_visibility("asset", len(asset_docs) > 1) + + def _set_context(self, context, refresh=True): + """Set the selection in the interface using a context. + + The context must contain `asset` data by name. + + Note: Prior to setting context ensure `refresh` is triggered so that + the "silos" are listed correctly, aside from that setting the + context will force a refresh further down because it changes + the active silo and asset. + + Args: + context (dict): The context to apply. + + Returns: + None + + """ + + asset = context.get("asset", None) + if asset is None: + return + + if refresh: + # Workaround: + # Force a direct (non-scheduled) refresh prior to setting the + # asset widget's silo and asset selection to ensure it's correctly + # displaying the silo tabs. Calling `window.refresh()` and directly + # `window.set_context()` the `set_context()` seems to override the + # scheduled refresh and the silo tabs are not shown. + self._refresh() + + asset_widget = self.data["widgets"]["assets"] + asset_widget.select_assets(asset) + + def echo(self, message): + widget = self.data["label"]["message"] + widget.setText(str(message)) + widget.show() + print(message) + + lib.schedule(widget.hide, 5000, channel="message") + + def closeEvent(self, event): + # Kill on holding SHIFT + modifiers = QtWidgets.QApplication.queryKeyboardModifiers() + shift_pressed = QtCore.Qt.ShiftModifier & modifiers + + if shift_pressed: + print("Force quitted..") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + print("Good bye") + return super(LoaderWindow, self).closeEvent(event) + + def keyPressEvent(self, event): + modifiers = event.modifiers() + ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + + # Grouping subsets on pressing Ctrl + G + if (ctrl_pressed and event.key() == QtCore.Qt.Key_G and + not event.isAutoRepeat()): + self.show_grouping_dialog() + return + + super(LoaderWindow, self).keyPressEvent(event) + event.setAccepted(True) # Avoid interfering other widgets + + def show_grouping_dialog(self): + subsets = self.data["widgets"]["subsets"] + if not subsets.is_groupable(): + self.echo("Grouping not enabled.") + return + + selected = [] + merged_items = [] + for item in subsets.selected_subsets(_merged=True): + if item.get("isMerged"): + merged_items.append(item) + else: + selected.append(item) + + for merged_item in merged_items: + for child_item in merged_item.children(): + selected.append(child_item) + + if not selected: + self.echo("No selected subset.") + return + + dialog = SubsetGroupingDialog( + items=selected, groups_config=self.groups_config, parent=self + ) + dialog.grouped.connect(self._assetschanged) + dialog.show() + + +class SubsetGroupingDialog(QtWidgets.QDialog): + grouped = QtCore.Signal() + + def __init__(self, items, groups_config, parent=None): + super(SubsetGroupingDialog, self).__init__(parent=parent) + self.setWindowTitle("Grouping Subsets") + self.setMinimumWidth(250) + self.setModal(True) + + self.items = items + self.groups_config = groups_config + self.subsets = parent.data["widgets"]["subsets"] + self.asset_ids = parent.data["state"]["assetIds"] + + name = QtWidgets.QLineEdit() + name.setPlaceholderText("Remain blank to ungroup..") + + # Menu for pre-defined subset groups + name_button = QtWidgets.QPushButton() + name_button.setFixedWidth(18) + name_button.setFixedHeight(20) + name_menu = QtWidgets.QMenu(name_button) + name_button.setMenu(name_menu) + + name_layout = QtWidgets.QHBoxLayout() + name_layout.addWidget(name) + name_layout.addWidget(name_button) + name_layout.setContentsMargins(0, 0, 0, 0) + + group_btn = QtWidgets.QPushButton("Apply") + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("Group Name")) + layout.addLayout(name_layout) + layout.addWidget(group_btn) + + group_btn.clicked.connect(self.on_group) + group_btn.setAutoDefault(True) + group_btn.setDefault(True) + + self.name = name + self.name_menu = name_menu + + self._build_menu() + + def _build_menu(self): + menu = self.name_menu + button = menu.parent() + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + active_groups = self.groups_config.active_groups(self.asset_ids) + + # Build new action group + group = QtWidgets.QActionGroup(button) + group_names = list() + for data in sorted(active_groups, key=lambda x: x["order"]): + name = data["name"] + if name in group_names: + continue + group_names.append(name) + icon = data["icon"] + + action = group.addAction(name) + action.setIcon(icon) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + button.setEnabled(not menu.isEmpty()) + + def _on_action_clicked(self, action): + self.name.setText(action.text()) + + def on_group(self): + name = self.name.text().strip() + self.subsets.group_subsets(name, self.asset_ids, self.items) + + with lib.preserve_selection(tree_view=self.subsets.view, + current_index=False): + self.grouped.emit() + self.close() + + +def show(debug=False, parent=None, use_context=False): + """Display Loader GUI + + Arguments: + debug (bool, optional): Run loader in debug-mode, + defaults to False + parent (QtCore.QObject, optional): The Qt object to parent to. + use_context (bool): Whether to apply the current context upon launch + + """ + + # Remember window + if module.window is not None: + try: + module.window.show() + + # If the window is minimized then unminimize it. + if module.window.windowState() & QtCore.Qt.WindowMinimized: + module.window.setWindowState(QtCore.Qt.WindowActive) + + # Raise and activate the window + module.window.raise_() # for MacOS + module.window.activateWindow() # for Windows + module.window.refresh() + return + except (AttributeError, RuntimeError): + # Garbage collected + module.window = None + + if debug: + import traceback + sys.excepthook = lambda typ, val, tb: traceback.print_last() + + io.install() + + any_project = next( + project for project in io.projects() + if project.get("active", True) is not False + ) + + api.Session["AVALON_PROJECT"] = any_project["name"] + module.project = any_project["name"] + + with lib.application(): + window = LoaderWindow(parent) + window.setStyleSheet(style.load_stylesheet()) + window.show() + + if use_context: + context = {"asset": api.Session["AVALON_ASSET"]} + window.set_context(context, refresh=True) + else: + window.refresh() + + module.window = window + + # Pull window to the front. + module.window.raise_() + module.window.activateWindow() + + +def cli(args): + + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("project") + + args = parser.parse_args(args) + project = args.project + + print("Entering Project: %s" % project) + + io.install() + + # Store settings + api.Session["AVALON_PROJECT"] = project + + from avalon import pipeline + + # Find the set config + _config = pipeline.find_config() + if hasattr(_config, "install"): + _config.install() + else: + print("Config `%s` has no function `install`" % + _config.__name__) + + show() diff --git a/openpype/tools/loader/images/default_thumbnail.png b/openpype/tools/loader/images/default_thumbnail.png new file mode 100644 index 0000000000..97bd958e0d Binary files /dev/null and b/openpype/tools/loader/images/default_thumbnail.png differ diff --git a/openpype/tools/loader/lib.py b/openpype/tools/loader/lib.py new file mode 100644 index 0000000000..14ebab6c85 --- /dev/null +++ b/openpype/tools/loader/lib.py @@ -0,0 +1,190 @@ +import inspect +from Qt import QtGui + +from avalon.vendor import qtawesome +from openpype.tools.utils.widgets import ( + OptionalAction, + OptionDialog +) + + +def change_visibility(model, view, column_name, visible): + """ + Hides or shows particular 'column_name'. + + "asset" and "subset" columns should be visible only in multiselect + """ + index = model.Columns.index(column_name) + view.setColumnHidden(index, not visible) + + +def get_selected_items(rows, item_role): + items = [] + for row_index in rows: + item = row_index.data(item_role) + if item.get("isGroup"): + continue + + elif item.get("isMerged"): + for idx in range(row_index.model().rowCount(row_index)): + child_index = row_index.child(idx, 0) + item = child_index.data(item_role) + if item not in items: + items.append(item) + + else: + if item not in items: + items.append(item) + return items + + +def get_options(action, loader, parent, repre_contexts): + """Provides dialog to select value from loader provided options. + + Loader can provide static or dynamically created options based on + qargparse variants. + + Args: + action (OptionalAction) - action in menu + loader (cls of api.Loader) - not initilized yet + parent (Qt element to parent dialog to) + repre_contexts (list) of dict with full info about selected repres + Returns: + (dict) - selected value from OptionDialog + None when dialog was closed or cancelled, in all other cases {} + if no options + """ + # Pop option dialog + options = {} + loader_options = loader.get_options(repre_contexts) + if getattr(action, "optioned", False) and loader_options: + dialog = OptionDialog(parent) + dialog.setWindowTitle(action.label + " Options") + dialog.create(loader_options) + + if not dialog.exec_(): + return None + + # Get option + options = dialog.parse() + + return options + + +def add_representation_loaders_to_menu(loaders, menu, repre_contexts): + """ + Loops through provider loaders and adds them to 'menu'. + + Expects loaders sorted in requested order. + Expects loaders de-duplicated if wanted. + + Args: + loaders(tuple): representation - loader + menu (OptionalMenu): + repre_contexts (dict): full info about representations (contains + their repre_doc, asset_doc, subset_doc, version_doc), + keys are repre_ids + + Returns: + menu (OptionalMenu): with new items + """ + # List the available loaders + for representation, loader in loaders: + label = None + repre_context = None + if representation: + label = representation.get("custom_label") + repre_context = repre_contexts[representation["_id"]] + + if not label: + label = get_label_from_loader(loader, representation) + + icon = get_icon_from_loader(loader) + + loader_options = loader.get_options([repre_context]) + + use_option = bool(loader_options) + action = OptionalAction(label, icon, use_option, menu) + if use_option: + # Add option box tip + action.set_option_tip(loader_options) + + action.setData((representation, loader)) + + # Add tooltip and statustip from Loader docstring + tip = inspect.getdoc(loader) + if tip: + action.setToolTip(tip) + action.setStatusTip(tip) + + menu.addAction(action) + + return menu + + +def remove_tool_name_from_loaders(available_loaders, tool_name): + if not tool_name: + return available_loaders + filtered_loaders = [] + for loader in available_loaders: + if hasattr(loader, "tool_names"): + if not ("*" in loader.tool_names or + tool_name in loader.tool_names): + continue + filtered_loaders.append(loader) + return filtered_loaders + + +def get_icon_from_loader(loader): + """Pull icon info from loader class""" + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None: + try: + key = "fa.{0}".format(icon) + color = getattr(loader, "color", "white") + icon = qtawesome.icon(key, color=color) + except Exception as e: + print("Unable to set icon for loader " + "{}: {}".format(loader, e)) + icon = None + return icon + + +def get_label_from_loader(loader, representation=None): + """Pull label info from loader class""" + label = getattr(loader, "label", None) + if label is None: + label = loader.__name__ + if representation: + # Add the representation as suffix + label = "{0} ({1})".format(label, representation['name']) + return label + + +def get_no_loader_action(menu, one_item_selected=False): + """Creates dummy no loader option in 'menu'""" + submsg = "your selection." + if one_item_selected: + submsg = "this version." + msg = "No compatible loaders for {}".format(submsg) + print(msg) + icon = qtawesome.icon( + "fa.exclamation", + color=QtGui.QColor(255, 51, 0) + ) + action = OptionalAction(("*" + msg), icon, False, menu) + return action + + +def sort_loaders(loaders, custom_sorter=None): + def sorter(value): + """Sort the Loaders by their order and then their name""" + Plugin = value[1] + return Plugin.order, Plugin.__name__ + + if not custom_sorter: + custom_sorter = sorter + + return sorted(loaders, key=custom_sorter) diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py new file mode 100644 index 0000000000..6e9c7bf220 --- /dev/null +++ b/openpype/tools/loader/model.py @@ -0,0 +1,1195 @@ +import copy +import re +import math + +from avalon import ( + style, + schema +) +from Qt import QtCore, QtGui + +from avalon.vendor import qtawesome +from avalon.lib import HeroVersionType + +from openpype.tools.utils.models import TreeModel, Item +from openpype.tools.utils import lib + +from openpype.modules import ModulesManager + + +def is_filtering_recursible(): + """Does Qt binding support recursive filtering for QSortFilterProxyModel? + + (NOTE) Recursive filtering was introduced in Qt 5.10. + + """ + return hasattr(QtCore.QSortFilterProxyModel, + "setRecursiveFilteringEnabled") + + +class BaseRepresentationModel(object): + """Methods for SyncServer useful in multiple models""" + + def reset_sync_server(self, project_name=None): + """Sets/Resets sync server vars after every change (refresh.)""" + repre_icons = {} + sync_server = None + active_site = active_provider = None + remote_site = remote_provider = None + + if not project_name: + project_name = self.dbcon.Session["AVALON_PROJECT"] + else: + self.dbcon.Session["AVALON_PROJECT"] = project_name + + if project_name: + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + + if project_name in sync_server.get_enabled_projects(): + active_site = sync_server.get_active_site(project_name) + active_provider = sync_server.get_provider_for_site( + project_name, active_site) + if active_site == 'studio': # for studio use explicit icon + active_provider = 'studio' + + remote_site = sync_server.get_remote_site(project_name) + remote_provider = sync_server.get_provider_for_site( + project_name, remote_site) + if remote_site == 'studio': # for studio use explicit icon + remote_provider = 'studio' + + repre_icons = lib.get_repre_icons() + + self.repre_icons = repre_icons + self.sync_server = sync_server + self.active_site = active_site + self.active_provider = active_provider + self.remote_site = remote_site + self.remote_provider = remote_provider + + +class SubsetsModel(TreeModel, BaseRepresentationModel): + doc_fetched = QtCore.Signal() + refreshed = QtCore.Signal(bool) + + Columns = [ + "subset", + "asset", + "family", + "version", + "time", + "author", + "frames", + "duration", + "handles", + "step", + "repre_info" + ] + + column_labels_mapping = { + "subset": "Subset", + "asset": "Asset", + "family": "Family", + "version": "Version", + "time": "Time", + "author": "Author", + "frames": "Frames", + "duration": "Duration", + "handles": "Handles", + "step": "Step", + "repre_info": "Availability" + } + + SortAscendingRole = QtCore.Qt.UserRole + 2 + SortDescendingRole = QtCore.Qt.UserRole + 3 + merged_subset_colors = [ + (55, 161, 222), # Light Blue + (231, 176, 0), # Yellow + (154, 13, 255), # Purple + (130, 184, 30), # Light Green + (211, 79, 63), # Light Red + (179, 181, 182), # Grey + (194, 57, 179), # Pink + (0, 120, 215), # Dark Blue + (0, 204, 106), # Dark Green + (247, 99, 12), # Orange + ] + not_last_hero_brush = QtGui.QBrush(QtGui.QColor(254, 121, 121)) + + # Should be minimum of required asset document keys + asset_doc_projection = { + "name": 1, + "label": 1 + } + # Should be minimum of required subset document keys + subset_doc_projection = { + "name": 1, + "parent": 1, + "schema": 1, + "data.families": 1, + "data.subsetGroup": 1 + } + + def __init__( + self, + dbcon, + groups_config, + family_config_cache, + grouping=True, + parent=None, + asset_doc_projection=None, + subset_doc_projection=None + ): + super(SubsetsModel, self).__init__(parent=parent) + + self.dbcon = dbcon + + # Projections for Mongo queries + # - let ability to modify them if used in tools that require more than + # defaults + if asset_doc_projection: + self.asset_doc_projection = asset_doc_projection + + if subset_doc_projection: + self.subset_doc_projection = subset_doc_projection + + self.asset_doc_projection = asset_doc_projection + self.subset_doc_projection = subset_doc_projection + + self.repre_icons = {} + self.sync_server = None + self.active_site = self.active_provider = None + + self.columns_index = dict( + (key, idx) for idx, key in enumerate(self.Columns) + ) + self._asset_ids = None + + self.groups_config = groups_config + self.family_config_cache = family_config_cache + self._sorter = None + self._grouping = grouping + self._icons = { + "subset": qtawesome.icon("fa.file-o", color=style.colors.default) + } + + self._doc_fetching_thread = None + self._doc_fetching_stop = False + self._doc_payload = {} + + self.doc_fetched.connect(self.on_doc_fetched) + + self.refresh() + + def set_assets(self, asset_ids): + self._asset_ids = asset_ids + self.refresh() + + def set_grouping(self, state): + self._grouping = state + self.on_doc_fetched() + + def get_subsets_families(self): + return self._doc_payload.get("subset_families") or set() + + def setData(self, index, value, role=QtCore.Qt.EditRole): + # Trigger additional edit when `version` column changed + # because it also updates the information in other columns + if index.column() == self.columns_index["version"]: + item = index.internalPointer() + parent = item["_id"] + if isinstance(value, HeroVersionType): + versions = list(self.dbcon.find({ + "type": {"$in": ["version", "hero_version"]}, + "parent": parent + }, sort=[("name", -1)])) + + version = None + last_version = None + for __version in versions: + if __version["type"] == "hero_version": + version = __version + elif last_version is None: + last_version = __version + + if version is not None and last_version is not None: + break + + _version = None + for __version in versions: + if __version["_id"] == version["version_id"]: + _version = __version + break + + version["data"] = _version["data"] + version["name"] = _version["name"] + version["is_from_latest"] = ( + last_version["_id"] == _version["_id"] + ) + + else: + version = self.dbcon.find_one({ + "name": value, + "type": "version", + "parent": parent + }) + + # update availability on active site when version changes + if self.sync_server.enabled and version: + site = self.active_site + query = self._repre_per_version_pipeline([version["_id"]], + site) + docs = list(self.dbcon.aggregate(query)) + if docs: + repre = docs.pop() + version["data"].update(self._get_repre_dict(repre)) + + self.set_version(index, version) + + return super(SubsetsModel, self).setData(index, value, role) + + def set_version(self, index, version): + """Update the version data of the given index. + + Arguments: + index (QtCore.QModelIndex): The model index. + version (dict) Version document in the database. + + """ + + assert isinstance(index, QtCore.QModelIndex) + if not index.isValid(): + return + + item = index.internalPointer() + + assert version["parent"] == item["_id"], ( + "Version does not belong to subset" + ) + + # Get the data from the version + version_data = version.get("data", dict()) + + # Compute frame ranges (if data is present) + frame_start = version_data.get( + "frameStart", + # backwards compatibility + version_data.get("startFrame", None) + ) + frame_end = version_data.get( + "frameEnd", + # backwards compatibility + version_data.get("endFrame", None) + ) + + handle_start = version_data.get("handleStart", None) + handle_end = version_data.get("handleEnd", None) + if handle_start is not None and handle_end is not None: + handles = "{}-{}".format(str(handle_start), str(handle_end)) + else: + handles = version_data.get("handles", None) + + if frame_start is not None and frame_end is not None: + # Remove superfluous zeros from numbers (3.0 -> 3) to improve + # readability for most frame ranges + start_clean = ("%f" % frame_start).rstrip("0").rstrip(".") + end_clean = ("%f" % frame_end).rstrip("0").rstrip(".") + frames = "{0}-{1}".format(start_clean, end_clean) + duration = frame_end - frame_start + 1 + else: + frames = None + duration = None + + schema_maj_version, _ = schema.get_schema_version(item["schema"]) + if schema_maj_version < 3: + families = version_data.get("families", [None]) + else: + families = item["data"]["families"] + + family = None + if families: + family = families[0] + + family_config = self.family_config_cache.family_config(family) + + item.update({ + "version": version["name"], + "version_document": version, + "author": version_data.get("author", None), + "time": version_data.get("time", None), + "family": family, + "familyLabel": family_config.get("label", family), + "familyIcon": family_config.get("icon", None), + "families": set(families), + "frameStart": frame_start, + "frameEnd": frame_end, + "duration": duration, + "handles": handles, + "frames": frames, + "step": version_data.get("step", None), + }) + + repre_info = version_data.get("repre_info") + if repre_info: + item["repre_info"] = repre_info + item["repre_icon"] = version_data.get("repre_icon") + + def _fetch(self): + asset_docs = self.dbcon.find( + { + "type": "asset", + "_id": {"$in": self._asset_ids} + }, + self.asset_doc_projection + ) + asset_docs_by_id = { + asset_doc["_id"]: asset_doc + for asset_doc in asset_docs + } + + subset_docs_by_id = {} + subset_docs = self.dbcon.find( + { + "type": "subset", + "parent": {"$in": self._asset_ids} + }, + self.subset_doc_projection + ) + subset_families = set() + for subset_doc in subset_docs: + if self._doc_fetching_stop: + return + + families = subset_doc.get("data", {}).get("families") + if families: + subset_families.add(families[0]) + + subset_docs_by_id[subset_doc["_id"]] = subset_doc + + subset_ids = list(subset_docs_by_id.keys()) + _pipeline = [ + # Find all versions of those subsets + {"$match": { + "type": "version", + "parent": {"$in": subset_ids} + }}, + # Sorting versions all together + {"$sort": {"name": 1}}, + # Group them by "parent", but only take the last + {"$group": { + "_id": "$parent", + "_version_id": {"$last": "$_id"}, + "name": {"$last": "$name"}, + "type": {"$last": "$type"}, + "data": {"$last": "$data"}, + "locations": {"$last": "$locations"}, + "schema": {"$last": "$schema"} + }} + ] + last_versions_by_subset_id = dict() + for doc in self.dbcon.aggregate(_pipeline): + if self._doc_fetching_stop: + return + doc["parent"] = doc["_id"] + doc["_id"] = doc.pop("_version_id") + last_versions_by_subset_id[doc["parent"]] = doc + + hero_versions = self.dbcon.find({ + "type": "hero_version", + "parent": {"$in": subset_ids} + }) + missing_versions = [] + for hero_version in hero_versions: + version_id = hero_version["version_id"] + if version_id not in last_versions_by_subset_id: + missing_versions.append(version_id) + + missing_versions_by_id = {} + if missing_versions: + missing_version_docs = self.dbcon.find({ + "type": "version", + "_id": {"$in": missing_versions} + }) + missing_versions_by_id = { + missing_version_doc["_id"]: missing_version_doc + for missing_version_doc in missing_version_docs + } + + for hero_version in hero_versions: + version_id = hero_version["version_id"] + subset_id = hero_version["parent"] + + version_doc = last_versions_by_subset_id.get(subset_id) + if version_doc is None: + version_doc = missing_versions_by_id.get(version_id) + if version_doc is None: + continue + + hero_version["data"] = version_doc["data"] + hero_version["name"] = HeroVersionType(version_doc["name"]) + # Add information if hero version is from latest version + hero_version["is_from_latest"] = version_id == version_doc["_id"] + + last_versions_by_subset_id[subset_id] = hero_version + + self._doc_payload = { + "asset_docs_by_id": asset_docs_by_id, + "subset_docs_by_id": subset_docs_by_id, + "subset_families": subset_families, + "last_versions_by_subset_id": last_versions_by_subset_id + } + + if self.sync_server.enabled: + version_ids = set() + for _subset_id, doc in last_versions_by_subset_id.items(): + version_ids.add(doc["_id"]) + + site = self.active_site + query = self._repre_per_version_pipeline(list(version_ids), site) + + repre_info = {} + for doc in self.dbcon.aggregate(query): + if self._doc_fetching_stop: + return + doc["provider"] = self.active_provider + repre_info[doc["_id"]] = doc + + self._doc_payload["repre_info_by_version_id"] = repre_info + + self.doc_fetched.emit() + + def fetch_subset_and_version(self): + """Query all subsets and latest versions from aggregation + (NOTE) The returned version documents are NOT the real version + document, it's generated from the MongoDB's aggregation so + some of the first level field may not be presented. + """ + self._doc_payload = {} + self._doc_fetching_stop = False + self._doc_fetching_thread = lib.create_qthread(self._fetch) + self._doc_fetching_thread.start() + + def stop_fetch_thread(self): + if self._doc_fetching_thread is not None: + self._doc_fetching_stop = True + while self._doc_fetching_thread.isRunning(): + pass + + def refresh(self): + self.stop_fetch_thread() + self.clear() + + self.reset_sync_server() + + if not self._asset_ids: + self.doc_fetched.emit() + return + + self.fetch_subset_and_version() + + def on_doc_fetched(self): + self.clear() + self.beginResetModel() + + asset_docs_by_id = self._doc_payload.get( + "asset_docs_by_id" + ) + subset_docs_by_id = self._doc_payload.get( + "subset_docs_by_id" + ) + last_versions_by_subset_id = self._doc_payload.get( + "last_versions_by_subset_id" + ) + + repre_info_by_version_id = self._doc_payload.get( + "repre_info_by_version_id" + ) + + if ( + asset_docs_by_id is None + or subset_docs_by_id is None + or last_versions_by_subset_id is None + or len(self._asset_ids) == 0 + ): + self.endResetModel() + self.refreshed.emit(False) + return + + self._fill_subset_items( + asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id, + repre_info_by_version_id + ) + + def create_multiasset_group( + self, subset_name, asset_ids, subset_counter, parent_item=None + ): + subset_color = self.merged_subset_colors[ + subset_counter % len(self.merged_subset_colors) + ] + merge_group = Item() + merge_group.update({ + "subset": "{} ({})".format(subset_name, len(asset_ids)), + "isMerged": True, + "childRow": 0, + "subsetColor": subset_color, + "assetIds": list(asset_ids), + "icon": qtawesome.icon( + "fa.circle", + color="#{0:02x}{1:02x}{2:02x}".format(*subset_color) + ) + }) + + subset_counter += 1 + self.add_child(merge_group, parent_item) + + return merge_group + + def _fill_subset_items( + self, asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id, + repre_info_by_version_id + ): + _groups_tuple = self.groups_config.split_subsets_for_groups( + subset_docs_by_id.values(), self._grouping + ) + groups, subset_docs_without_group, subset_docs_by_group = _groups_tuple + + group_item_by_name = {} + for group_data in groups: + group_name = group_data["name"] + group_item = Item() + group_item.update({ + "subset": group_name, + "isGroup": True, + "childRow": 0 + }) + group_item.update(group_data) + + self.add_child(group_item) + + group_item_by_name[group_name] = { + "item": group_item, + "index": self.index(group_item.row(), 0) + } + + subset_counter = 0 + for group_name, subset_docs_by_name in subset_docs_by_group.items(): + parent_item = group_item_by_name[group_name]["item"] + parent_index = group_item_by_name[group_name]["index"] + for subset_name in sorted(subset_docs_by_name.keys()): + subset_docs = subset_docs_by_name[subset_name] + asset_ids = [ + subset_doc["parent"] for subset_doc in subset_docs + ] + if len(subset_docs) > 1: + _parent_item = self.create_multiasset_group( + subset_name, asset_ids, subset_counter, parent_item + ) + _parent_index = self.index( + _parent_item.row(), 0, parent_index + ) + subset_counter += 1 + else: + _parent_item = parent_item + _parent_index = parent_index + + for subset_doc in subset_docs: + asset_id = subset_doc["parent"] + + data = copy.deepcopy(subset_doc) + data["subset"] = subset_name + data["asset"] = asset_docs_by_id[asset_id]["name"] + + last_version = last_versions_by_subset_id.get( + subset_doc["_id"] + ) + data["last_version"] = last_version + + # do not show subset without version + if not last_version: + continue + + data.update( + self._get_last_repre_info(repre_info_by_version_id, + last_version["_id"])) + + item = Item() + item.update(data) + self.add_child(item, _parent_item) + + index = self.index(item.row(), 0, _parent_index) + self.set_version(index, last_version) + + for subset_name in sorted(subset_docs_without_group.keys()): + subset_docs = subset_docs_without_group[subset_name] + asset_ids = [subset_doc["parent"] for subset_doc in subset_docs] + parent_item = None + parent_index = None + if len(subset_docs) > 1: + parent_item = self.create_multiasset_group( + subset_name, asset_ids, subset_counter + ) + parent_index = self.index(parent_item.row(), 0) + subset_counter += 1 + + for subset_doc in subset_docs: + asset_id = subset_doc["parent"] + + data = copy.deepcopy(subset_doc) + data["subset"] = subset_name + data["asset"] = asset_docs_by_id[asset_id]["name"] + + last_version = last_versions_by_subset_id.get( + subset_doc["_id"] + ) + data["last_version"] = last_version + + # do not show subset without version + if not last_version: + continue + + data.update( + self._get_last_repre_info(repre_info_by_version_id, + last_version["_id"])) + + item = Item() + item.update(data) + self.add_child(item, parent_item) + + index = self.index(item.row(), 0, parent_index) + self.set_version(index, last_version) + + self.endResetModel() + self.refreshed.emit(True) + + def data(self, index, role): + if not index.isValid(): + return + + if role == self.SortDescendingRole: + item = index.internalPointer() + if item.get("isGroup"): + # Ensure groups be on top when sorting by descending order + prefix = "2" + order = item["order"] + else: + if item.get("isMerged"): + prefix = "1" + else: + prefix = "0" + order = str(super(SubsetsModel, self).data( + index, QtCore.Qt.DisplayRole + )) + return prefix + order + + if role == self.SortAscendingRole: + item = index.internalPointer() + if item.get("isGroup"): + # Ensure groups be on top when sorting by ascending order + prefix = "0" + order = item["order"] + else: + if item.get("isMerged"): + prefix = "1" + else: + prefix = "2" + order = str(super(SubsetsModel, self).data( + index, QtCore.Qt.DisplayRole + )) + return prefix + order + + if role == QtCore.Qt.DisplayRole: + if index.column() == self.columns_index["family"]: + # Show familyLabel instead of family + item = index.internalPointer() + return item.get("familyLabel", None) + + elif role == QtCore.Qt.DecorationRole: + + # Add icon to subset column + if index.column() == self.columns_index["subset"]: + item = index.internalPointer() + if item.get("isGroup") or item.get("isMerged"): + return item["icon"] + else: + return self._icons["subset"] + + # Add icon to family column + if index.column() == self.columns_index["family"]: + item = index.internalPointer() + return item.get("familyIcon", None) + + if index.column() == self.columns_index.get("repre_info"): + item = index.internalPointer() + return item.get("repre_icon", None) + + elif role == QtCore.Qt.ForegroundRole: + item = index.internalPointer() + version_doc = item.get("version_document") + if version_doc and version_doc.get("type") == "hero_version": + if not version_doc["is_from_latest"]: + return self.not_last_hero_brush + + return super(SubsetsModel, self).data(index, role) + + def flags(self, index): + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + # Make the version column editable + if index.column() == self.columns_index["version"]: + flags |= QtCore.Qt.ItemIsEditable + + return flags + + def headerData(self, section, orientation, role): + """Remap column names to labels""" + if role == QtCore.Qt.DisplayRole: + if section < len(self.Columns): + key = self.Columns[section] + return self.column_labels_mapping.get(key) or key + + super(TreeModel, self).headerData(section, orientation, role) + + def _get_last_repre_info(self, repre_info_by_version_id, last_version_id): + data = {} + if repre_info_by_version_id: + repre_info = repre_info_by_version_id.get(last_version_id) + return self._get_repre_dict(repre_info) + + return data + + def _get_repre_dict(self, repre_info): + """Returns icon and str representation of availability""" + data = {} + if repre_info: + repres_str = "{}/{}".format( + int(math.floor(float(repre_info['avail_repre']))), + int(math.floor(float(repre_info['repre_count'])))) + + data["repre_info"] = repres_str + data["repre_icon"] = self.repre_icons.get(self.active_provider) + + return data + + def _repre_per_version_pipeline(self, version_ids, site): + query = [ + {"$match": {"parent": {"$in": version_ids}, + "type": "representation", + "files.sites.name": {"$exists": 1}}}, + {"$unwind": "$files"}, + {'$addFields': { + 'order_local': { + '$filter': {'input': '$files.sites', 'as': 'p', + 'cond': {'$eq': ['$$p.name', site]} + }} + }}, + {'$addFields': { + 'progress_local': {"$arrayElemAt": [{ + '$cond': [{'$size': "$order_local.progress"}, + "$order_local.progress", + # if exists created_dt count is as available + {'$cond': [ + {'$size': "$order_local.created_dt"}, + [1], + [0] + ]} + ]}, 0]} + }}, + {'$group': { # first group by repre + '_id': '$_id', + 'parent': {'$first': '$parent'}, + 'files_count': {'$sum': 1}, + 'files_avail': {'$sum': "$progress_local"}, + 'avail_ratio': {'$first': { + '$divide': [{'$sum': "$progress_local"}, {'$sum': 1}]}} + }}, + {'$group': { # second group by parent, eg version_id + '_id': '$parent', + 'repre_count': {'$sum': 1}, # total representations + # fully available representation for site + 'avail_repre': {'$sum': "$avail_ratio"} + }}, + ] + return query + + +class GroupMemberFilterProxyModel(QtCore.QSortFilterProxyModel): + """Provide the feature of filtering group by the acceptance of members + + The subset group nodes will not be filtered directly, the group node's + acceptance depends on it's child subsets' acceptance. + + """ + + if is_filtering_recursible(): + def _is_group_acceptable(self, index, node): + # (NOTE) With the help of `RecursiveFiltering` feature from + # Qt 5.10, group always not be accepted by default. + return False + filter_accepts_group = _is_group_acceptable + + else: + # Patch future function + setRecursiveFilteringEnabled = (lambda *args: None) + + def _is_group_acceptable(self, index, model): + # (NOTE) This is not recursive. + for child_row in range(model.rowCount(index)): + if self.filterAcceptsRow(child_row, index): + return True + return False + filter_accepts_group = _is_group_acceptable + + def __init__(self, *args, **kwargs): + super(GroupMemberFilterProxyModel, self).__init__(*args, **kwargs) + self.setRecursiveFilteringEnabled(True) + + +class SubsetFilterProxyModel(GroupMemberFilterProxyModel): + def filterAcceptsRow(self, row, parent): + model = self.sourceModel() + index = model.index(row, self.filterKeyColumn(), parent) + item = index.internalPointer() + if item.get("isGroup"): + return self.filter_accepts_group(index, model) + return super( + SubsetFilterProxyModel, self + ).filterAcceptsRow(row, parent) + + +class FamiliesFilterProxyModel(GroupMemberFilterProxyModel): + """Filters to specified families""" + + def __init__(self, *args, **kwargs): + super(FamiliesFilterProxyModel, self).__init__(*args, **kwargs) + self._families = set() + + def familyFilter(self): + return self._families + + def setFamiliesFilter(self, values): + """Set the families to include""" + assert isinstance(values, (tuple, list, set)) + self._families = set(values) + self.invalidateFilter() + + def filterAcceptsRow(self, row=0, parent=None): + if not self._families: + return False + + model = self.sourceModel() + index = model.index(row, 0, parent=parent or QtCore.QModelIndex()) + + # Ensure index is valid + if not index.isValid() or index is None: + return True + + # Get the item data and validate + item = model.data(index, TreeModel.ItemRole) + + if item.get("isGroup"): + return self.filter_accepts_group(index, model) + + family = item.get("family") + if not family: + return True + + # We want to keep the families which are not in the list + return family in self._families + + def sort(self, column, order): + proxy = self.sourceModel() + model = proxy.sourceModel() + # We need to know the sorting direction for pinning groups on top + if order == QtCore.Qt.AscendingOrder: + self.setSortRole(model.SortAscendingRole) + else: + self.setSortRole(model.SortDescendingRole) + + super(FamiliesFilterProxyModel, self).sort(column, order) + + +class RepresentationSortProxyModel(GroupMemberFilterProxyModel): + """To properly sort progress string""" + def lessThan(self, left, right): + source_model = self.sourceModel() + progress_indexes = [source_model.Columns.index("active_site"), + source_model.Columns.index("remote_site")] + if left.column() in progress_indexes: + left_data = self.sourceModel().data(left, QtCore.Qt.DisplayRole) + right_data = self.sourceModel().data(right, QtCore.Qt.DisplayRole) + left_val = re.sub("[^0-9]", '', left_data) + right_val = re.sub("[^0-9]", '', right_data) + + return int(left_val) < int(right_val) + + return super(RepresentationSortProxyModel, self).lessThan(left, right) + + +class RepresentationModel(TreeModel, BaseRepresentationModel): + + doc_fetched = QtCore.Signal() + refreshed = QtCore.Signal(bool) + + SiteNameRole = QtCore.Qt.UserRole + 2 + ProgressRole = QtCore.Qt.UserRole + 3 + SiteSideRole = QtCore.Qt.UserRole + 4 + IdRole = QtCore.Qt.UserRole + 5 + ContextRole = QtCore.Qt.UserRole + 6 + + Columns = [ + "name", + "subset", + "asset", + "active_site", + "remote_site" + ] + + column_labels_mapping = { + "name": "Name", + "subset": "Subset", + "asset": "Asset", + "active_site": "Active", + "remote_site": "Remote" + } + + def __init__(self, dbcon, header, version_ids): + super(RepresentationModel, self).__init__() + self.dbcon = dbcon + self._data = [] + self._header = header + self.version_ids = version_ids + + manager = ModulesManager() + sync_server = active_site = remote_site = None + active_provider = remote_provider = None + + project = dbcon.Session["AVALON_PROJECT"] + if project: + sync_server = manager.modules_by_name["sync_server"] + active_site = sync_server.get_active_site(project) + remote_site = sync_server.get_remote_site(project) + + # TODO refactor + active_provider = \ + sync_server.get_provider_for_site(project, + active_site) + if active_site == 'studio': + active_provider = 'studio' + + remote_provider = \ + sync_server.get_provider_for_site(project, + remote_site) + + if remote_site == 'studio': + remote_provider = 'studio' + + self.sync_server = sync_server + self.active_site = active_site + self.active_provider = active_provider + self.remote_site = remote_site + self.remote_provider = remote_provider + + self.doc_fetched.connect(self.on_doc_fetched) + + self._docs = {} + self._icons = lib.get_repre_icons() + self._icons["repre"] = qtawesome.icon("fa.file-o", + color=style.colors.default) + + def set_version_ids(self, version_ids): + self.version_ids = version_ids + self.refresh() + + def data(self, index, role): + item = index.internalPointer() + + if role == self.IdRole: + return item.get("_id") + + if role == QtCore.Qt.DecorationRole: + # Add icon to subset column + if index.column() == self.Columns.index("name"): + if item.get("isMerged"): + return item["icon"] + else: + return self._icons["repre"] + + active_index = self.Columns.index("active_site") + remote_index = self.Columns.index("remote_site") + if role == QtCore.Qt.DisplayRole: + progress = None + label = '' + if index.column() == active_index: + progress = item.get("active_site_progress", 0) + elif index.column() == remote_index: + progress = item.get("remote_site_progress", 0) + + if progress is not None: + # site added, sync in progress + progress_str = "not avail." + if progress >= 0: + # progress == 0 for isMerged is unavailable + if progress == 0 and item.get("isMerged"): + progress_str = "not avail." + else: + progress_str = "{}% {}".format(int(progress * 100), + label) + + return progress_str + + if role == QtCore.Qt.DecorationRole: + if index.column() == active_index: + return item.get("active_site_icon", None) + if index.column() == remote_index: + return item.get("remote_site_icon", None) + + if role == self.SiteNameRole: + if index.column() == active_index: + return item.get("active_site_name", None) + if index.column() == remote_index: + return item.get("remote_site_name", None) + + if role == self.SiteSideRole: + if index.column() == active_index: + return "active" + if index.column() == remote_index: + return "remote" + + if role == self.ProgressRole: + if index.column() == active_index: + return item.get("active_site_progress", 0) + if index.column() == remote_index: + return item.get("remote_site_progress", 0) + + return super(RepresentationModel, self).data(index, role) + + def on_doc_fetched(self): + self.clear() + self.beginResetModel() + subsets = set() + assets = set() + repre_groups = {} + repre_groups_items = {} + group = None + self._items_by_id = {} + for doc in self._docs: + if len(self.version_ids) > 1: + group = repre_groups.get(doc["name"]) + if not group: + group_item = Item() + group_item.update({ + "_id": doc["_id"], + "name": doc["name"], + "isMerged": True, + "childRow": 0, + "active_site_name": self.active_site, + "remote_site_name": self.remote_site, + "icon": qtawesome.icon( + "fa.folder", + color=style.colors.default + ) + }) + self.add_child(group_item, None) + repre_groups[doc["name"]] = group_item + repre_groups_items[doc["name"]] = 0 + group = group_item + + progress = lib.get_progress_for_repre(doc, + self.active_site, + self.remote_site) + + active_site_icon = self._icons.get(self.active_provider) + remote_site_icon = self._icons.get(self.remote_provider) + + data = { + "_id": doc["_id"], + "name": doc["name"], + "subset": doc["context"]["subset"], + "asset": doc["context"]["asset"], + "isMerged": False, + + "active_site_icon": active_site_icon, + "remote_site_icon": remote_site_icon, + "active_site_name": self.active_site, + "remote_site_name": self.remote_site, + "active_site_progress": progress[self.active_site], + "remote_site_progress": progress[self.remote_site] + } + subsets.add(doc["context"]["subset"]) + assets.add(doc["context"]["subset"]) + + item = Item() + item.update(data) + + current_progress = { + 'active_site_progress': progress[self.active_site], + 'remote_site_progress': progress[self.remote_site] + } + if group: + group = self._sum_group_progress(doc["name"], group, + current_progress, + repre_groups_items) + + self.add_child(item, group) + + # finalize group average progress + for group_name, group in repre_groups.items(): + items_cnt = repre_groups_items[group_name] + active_progress = group.get("active_site_progress", 0) + group["active_site_progress"] = active_progress / items_cnt + remote_progress = group.get("remote_site_progress", 0) + group["remote_site_progress"] = remote_progress / items_cnt + + self.endResetModel() + self.refreshed.emit(False) + + def refresh(self): + docs = [] + session_project = self.dbcon.Session['AVALON_PROJECT'] + if not session_project: + return + + if self.version_ids: + # Simple find here for now, expected to receive lower number of + # representations and logic could be in Python + docs = list(self.dbcon.find( + {"type": "representation", "parent": {"$in": self.version_ids}, + "files.sites.name": {"$exists": 1}}, self.projection())) + self._docs = docs + + self.doc_fetched.emit() + + @classmethod + def projection(cls): + return { + "_id": 1, + "name": 1, + "context.subset": 1, + "context.asset": 1, + "context.version": 1, + "context.representation": 1, + 'files.sites': 1 + } + + def _sum_group_progress(self, repre_name, group, current_item_progress, + repre_groups_items): + """ + Update final group progress + Called after every item in group is added + + Args: + repre_name(string) + group(dict): info about group of selected items + current_item_progress(dict): {'active_site_progress': XX, + 'remote_site_progress': YY} + repre_groups_items(dict) + Returns: + (dict): updated group info + """ + repre_groups_items[repre_name] += 1 + + for key, progress in current_item_progress.items(): + group[key] = (group.get(key, 0) + max(progress, 0)) + + return group diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py new file mode 100644 index 0000000000..6b94fc6e44 --- /dev/null +++ b/openpype/tools/loader/widgets.py @@ -0,0 +1,1591 @@ +import os +import sys +import inspect +import datetime +import pprint +import traceback +import collections + +from Qt import QtWidgets, QtCore, QtGui + +from avalon import api, pipeline +from avalon.lib import HeroVersionType + +from openpype.tools.utils import lib as tools_lib +from openpype.tools.utils.delegates import ( + VersionDelegate, + PrettyTimeDelegate +) +from openpype.tools.utils.widgets import OptionalMenu +from openpype.tools.utils.views import ( + TreeViewSpinner, + DeselectableTreeView +) + +from .model import ( + SubsetsModel, + SubsetFilterProxyModel, + FamiliesFilterProxyModel, + RepresentationModel, + RepresentationSortProxyModel +) +from . import lib + + +class OverlayFrame(QtWidgets.QFrame): + def __init__(self, label, parent): + super(OverlayFrame, self).__init__(parent) + + label_widget = QtWidgets.QLabel(label, self) + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter) + + self.label_widget = label_widget + + label_widget.setStyleSheet("background: transparent;") + self.setStyleSheet(( + "background: rgba(0, 0, 0, 127);" + "font-size: 60pt;" + )) + + def set_label(self, label): + self.label_widget.setText(label) + + +class LoadErrorMessageBox(QtWidgets.QDialog): + def __init__(self, messages, parent=None): + super(LoadErrorMessageBox, self).__init__(parent) + self.setWindowTitle("Loading failed") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + body_layout = QtWidgets.QVBoxLayout(self) + + main_label = ( + "Failed to load items" + ) + main_label_widget = QtWidgets.QLabel(main_label, self) + body_layout.addWidget(main_label_widget) + + item_name_template = ( + "Subset: {}
" + "Version: {}
" + "Representation: {}
" + ) + exc_msg_template = "{}" + + for exc_msg, tb, repre, subset, version in messages: + line = self._create_line() + body_layout.addWidget(line) + + item_name = item_name_template.format(subset, version, repre) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + body_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + body_layout.addWidget(message_label_widget) + + if tb: + tb_widget = QtWidgets.QLabel(tb.replace("\n", "
"), self) + tb_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + body_layout.addWidget(tb_widget) + + footer_widget = QtWidgets.QWidget(self) + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + buttonBox = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) + buttonBox.setStandardButtons( + QtWidgets.QDialogButtonBox.StandardButton.Ok + ) + buttonBox.accepted.connect(self._on_accept) + footer_layout.addWidget(buttonBox, alignment=QtCore.Qt.AlignRight) + body_layout.addWidget(footer_widget) + + def _on_accept(self): + self.close() + + def _create_line(self): + line = QtWidgets.QFrame(self) + line.setFixedHeight(2) + line.setFrameShape(QtWidgets.QFrame.HLine) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + return line + + +class SubsetWidget(QtWidgets.QWidget): + """A widget that lists the published subsets for an asset""" + + active_changed = QtCore.Signal() # active index changed + version_changed = QtCore.Signal() # version state changed for a subset + load_started = QtCore.Signal() + load_ended = QtCore.Signal() + refreshed = QtCore.Signal(bool) + + default_widths = ( + ("subset", 200), + ("asset", 130), + ("family", 90), + ("version", 60), + ("time", 125), + ("author", 75), + ("frames", 75), + ("duration", 60), + ("handles", 55), + ("step", 10), + ("repre_info", 65) + ) + + def __init__( + self, + dbcon, + groups_config, + family_config_cache, + enable_grouping=True, + tool_name=None, + parent=None + ): + super(SubsetWidget, self).__init__(parent=parent) + + self.dbcon = dbcon + self.tool_name = tool_name + + model = SubsetsModel( + dbcon, + groups_config, + family_config_cache, + grouping=enable_grouping + ) + proxy = SubsetFilterProxyModel() + family_proxy = FamiliesFilterProxyModel() + family_proxy.setSourceModel(proxy) + + subset_filter = QtWidgets.QLineEdit() + subset_filter.setPlaceholderText("Filter subsets..") + + groupable = QtWidgets.QCheckBox("Enable Grouping") + groupable.setChecked(enable_grouping) + + top_bar_layout = QtWidgets.QHBoxLayout() + top_bar_layout.addWidget(subset_filter) + top_bar_layout.addWidget(groupable) + + view = TreeViewSpinner() + view.setObjectName("SubsetView") + view.setIndentation(20) + view.setStyleSheet(""" + QTreeView::item{ + padding: 5px 1px; + border: 0px; + } + """) + view.setAllColumnsShowFocus(True) + + # Set view delegates + version_delegate = VersionDelegate(self.dbcon) + column = model.Columns.index("version") + view.setItemDelegateForColumn(column, version_delegate) + + time_delegate = PrettyTimeDelegate() + column = model.Columns.index("time") + view.setItemDelegateForColumn(column, time_delegate) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(top_bar_layout) + layout.addWidget(view) + + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + view.setSortingEnabled(True) + view.sortByColumn(1, QtCore.Qt.AscendingOrder) + view.setAlternatingRowColors(True) + + self.data = { + "delegates": { + "version": version_delegate, + "time": time_delegate + }, + "state": { + "groupable": groupable + } + } + + self.proxy = proxy + self.model = model + self.view = view + self.filter = subset_filter + self.family_proxy = family_proxy + + # settings and connections + self.proxy.setSourceModel(self.model) + self.proxy.setDynamicSortFilter(True) + self.proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + self.view.setModel(self.family_proxy) + self.view.customContextMenuRequested.connect(self.on_context_menu) + + for column_name, width in self.default_widths: + idx = model.Columns.index(column_name) + view.setColumnWidth(idx, width) + + actual_project = dbcon.Session["AVALON_PROJECT"] + self.on_project_change(actual_project) + + selection = view.selectionModel() + selection.selectionChanged.connect(self.active_changed) + + version_delegate.version_changed.connect(self.version_changed) + + groupable.stateChanged.connect(self.set_grouping) + + self.filter.textChanged.connect(self.proxy.setFilterRegExp) + self.filter.textChanged.connect(self.view.expandAll) + model.refreshed.connect(self.refreshed) + + self.model.refresh() + + def get_subsets_families(self): + return self.model.get_subsets_families() + + def set_family_filters(self, families): + self.family_proxy.setFamiliesFilter(families) + + def is_groupable(self): + return self.data["state"]["groupable"].checkState() + + def set_grouping(self, state): + with tools_lib.preserve_selection(tree_view=self.view, + current_index=False): + self.model.set_grouping(state) + + def set_loading_state(self, loading, empty): + view = self.view + + if view.is_loading != loading: + if loading: + view.spinner.repaintNeeded.connect(view.viewport().update) + else: + view.spinner.repaintNeeded.disconnect() + + view.is_loading = loading + view.is_empty = empty + + def _repre_contexts_for_loaders_filter(self, items): + version_docs_by_id = { + item["version_document"]["_id"]: item["version_document"] + for item in items + } + version_docs_by_subset_id = collections.defaultdict(list) + for item in items: + subset_id = item["version_document"]["parent"] + version_docs_by_subset_id[subset_id].append( + item["version_document"] + ) + + subset_docs = list(self.dbcon.find( + { + "_id": {"$in": list(version_docs_by_subset_id.keys())}, + "type": "subset" + }, + { + "schema": 1, + "data.families": 1 + } + )) + subset_docs_by_id = { + subset_doc["_id"]: subset_doc + for subset_doc in subset_docs + } + version_ids = list(version_docs_by_id.keys()) + repre_docs = self.dbcon.find( + # Query all representations for selected versions at once + { + "type": "representation", + "parent": {"$in": version_ids} + }, + # Query only name and parent from representation + { + "name": 1, + "parent": 1 + } + ) + repre_docs_by_version_id = { + version_id: [] + for version_id in version_ids + } + repre_context_by_id = {} + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + repre_docs_by_version_id[version_id].append(repre_doc) + + version_doc = version_docs_by_id[version_id] + repre_context_by_id[repre_doc["_id"]] = { + "representation": repre_doc, + "version": version_doc, + "subset": subset_docs_by_id[version_doc["parent"]] + } + return repre_context_by_id, repre_docs_by_version_id + + def on_project_change(self, project_name): + """ + Called on each project change in parent widget. + + Checks if Sync Server is enabled for a project, pushes changes to + model. + """ + enabled = False + if project_name: + self.model.reset_sync_server(project_name) + if self.model.sync_server: + enabled_proj = self.model.sync_server.get_enabled_projects() + enabled = project_name in enabled_proj + + lib.change_visibility(self.model, self.view, "repre_info", enabled) + + def on_context_menu(self, point): + """Shows menu with loader actions on Right-click. + + Registered actions are filtered by selection and help of + `loaders_from_representation` from avalon api. Intersection of actions + is shown when more subset is selected. When there are not available + actions for selected subsets then special action is shown (works as + info message to user): "*No compatible loaders for your selection" + + """ + + point_index = self.view.indexAt(point) + if not point_index.isValid(): + return + + # Get selected subsets without groups + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + + items = lib.get_selected_items(rows, self.model.ItemRole) + + # Get all representation->loader combinations available for the + # index under the cursor, so we can list the user the options. + available_loaders = api.discover(api.Loader) + if self.tool_name: + available_loaders = lib.remove_tool_name_from_loaders( + available_loaders, self.tool_name + ) + + repre_loaders = [] + subset_loaders = [] + for loader in available_loaders: + # Skip if its a SubsetLoader. + if api.SubsetLoader in inspect.getmro(loader): + subset_loaders.append(loader) + else: + repre_loaders.append(loader) + + loaders = list() + + # Bool if is selected only one subset + one_item_selected = (len(items) == 1) + + # Prepare variables for multiple selected subsets + first_loaders = [] + found_combinations = None + + is_first = True + repre_context_by_id, repre_docs_by_version_id = ( + self._repre_contexts_for_loaders_filter(items) + ) + for item in items: + _found_combinations = [] + version_id = item["version_document"]["_id"] + repre_docs = repre_docs_by_version_id[version_id] + for repre_doc in repre_docs: + repre_context = repre_context_by_id[repre_doc["_id"]] + for loader in pipeline.loaders_from_repre_context( + repre_loaders, + repre_context + ): + # do not allow download whole repre, select specific repre + if tools_lib.is_sync_loader(loader): + continue + + # skip multiple select variant if one is selected + if one_item_selected: + loaders.append((repre_doc, loader)) + continue + + # store loaders of first subset + if is_first: + first_loaders.append((repre_doc, loader)) + + # store combinations to compare with other subsets + _found_combinations.append( + (repre_doc["name"].lower(), loader) + ) + + # skip multiple select variant if one is selected + if one_item_selected: + continue + + is_first = False + # Store first combinations to compare + if found_combinations is None: + found_combinations = _found_combinations + # Intersect found combinations with all previous subsets + else: + found_combinations = list( + set(found_combinations) & set(_found_combinations) + ) + + if not one_item_selected: + # Filter loaders from first subset by intersected combinations + for repre, loader in first_loaders: + if (repre["name"], loader) not in found_combinations: + continue + + loaders.append((repre, loader)) + + # Subset Loaders. + for loader in subset_loaders: + loaders.append((None, loader)) + + loaders = lib.sort_loaders(loaders) + + # Prepare menu content based on selected items + menu = OptionalMenu(self) + if not loaders: + action = lib.get_no_loader_action(menu, one_item_selected) + menu.addAction(action) + else: + repre_contexts = pipeline.get_repres_contexts( + repre_context_by_id.keys(), self.dbcon) + + menu = lib.add_representation_loaders_to_menu( + loaders, menu, repre_contexts) + + # Show the context action menu + global_point = self.view.mapToGlobal(point) + action = menu.exec_(global_point) + if not action or not action.data(): + return + + # Find the representation name and loader to trigger + action_representation, loader = action.data() + + self.load_started.emit() + + if api.SubsetLoader in inspect.getmro(loader): + subset_ids = [] + subset_version_docs = {} + for item in items: + subset_id = item["version_document"]["parent"] + subset_ids.append(subset_id) + subset_version_docs[subset_id] = item["version_document"] + + # get contexts only for selected menu option + subset_contexts_by_id = pipeline.get_subset_contexts(subset_ids, + self.dbcon) + subset_contexts = list(subset_contexts_by_id.values()) + options = lib.get_options(action, loader, self, subset_contexts) + + error_info = _load_subsets_by_loader( + loader, subset_contexts, options, subset_version_docs + ) + + else: + representation_name = action_representation["name"] + + # Run the loader for all selected indices, for those that have the + # same representation available + + # Trigger + repre_ids = [] + for item in items: + representation = self.dbcon.find_one( + { + "type": "representation", + "name": representation_name, + "parent": item["version_document"]["_id"] + }, + {"_id": 1} + ) + if not representation: + self.echo("Subset '{}' has no representation '{}'".format( + item["subset"], representation_name + )) + continue + repre_ids.append(representation["_id"]) + + # get contexts only for selected menu option + repre_contexts = pipeline.get_repres_contexts(repre_ids, + self.dbcon) + options = lib.get_options(action, loader, self, + list(repre_contexts.values())) + + error_info = _load_representations_by_loader( + loader, repre_contexts, options=options + ) + + self.load_ended.emit() + + if error_info: + box = LoadErrorMessageBox(error_info) + box.show() + + def selected_subsets(self, _groups=False, _merged=False, _other=True): + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + + subsets = list() + if not any([_groups, _merged, _other]): + self.echo(( + "This is a BUG: Selected_subsets args must contain" + " at least one value set to True" + )) + return subsets + + for row in rows: + item = row.data(self.model.ItemRole) + if item.get("isGroup"): + if not _groups: + continue + + elif item.get("isMerged"): + if not _merged: + continue + else: + if not _other: + continue + + subsets.append(item) + + return subsets + + def group_subsets(self, name, asset_ids, items): + field = "data.subsetGroup" + + if name: + update = {"$set": {field: name}} + self.echo("Group subsets to '%s'.." % name) + else: + update = {"$unset": {field: ""}} + self.echo("Ungroup subsets..") + + subsets = list() + for item in items: + subsets.append(item["subset"]) + + for asset_id in asset_ids: + filtr = { + "type": "subset", + "parent": asset_id, + "name": {"$in": subsets}, + } + self.dbcon.update_many(filtr, update) + + def echo(self, message): + print(message) + + +class VersionTextEdit(QtWidgets.QTextEdit): + """QTextEdit that displays version specific information. + + This also overrides the context menu to add actions like copying + source path to clipboard or copying the raw data of the version + to clipboard. + + """ + def __init__(self, dbcon, parent=None): + super(VersionTextEdit, self).__init__(parent=parent) + self.dbcon = dbcon + + self.data = { + "source": None, + "raw": None + } + + # Reset + self.set_version(None) + + def set_version(self, version_doc=None, version_id=None): + # TODO expect only filling data (do not query them here!) + if not version_doc and not version_id: + # Reset state to empty + self.data = { + "source": None, + "raw": None, + } + self.setText("") + self.setEnabled(True) + return + + self.setEnabled(True) + + print("Querying..") + + if not version_doc: + version_doc = self.dbcon.find_one({ + "_id": version_id, + "type": {"$in": ["version", "hero_version"]} + }) + assert version_doc, "Not a valid version id" + + if version_doc["type"] == "hero_version": + _version_doc = self.dbcon.find_one({ + "_id": version_doc["version_id"], + "type": "version" + }) + version_doc["data"] = _version_doc["data"] + version_doc["name"] = HeroVersionType( + _version_doc["name"] + ) + + subset = self.dbcon.find_one({ + "_id": version_doc["parent"], + "type": "subset" + }) + assert subset, "No valid subset parent for version" + + # Define readable creation timestamp + created = version_doc["data"]["time"] + created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ") + created = datetime.datetime.strftime(created, "%b %d %Y %H:%M") + + comment = version_doc["data"].get("comment", None) or "No comment" + + source = version_doc["data"].get("source", None) + source_label = source if source else "No source" + + # Store source and raw data + self.data["source"] = source + self.data["raw"] = version_doc + + if version_doc["type"] == "hero_version": + version_name = "hero" + else: + version_name = tools_lib.format_version(version_doc["name"]) + + data = { + "subset": subset["name"], + "version": version_name, + "comment": comment, + "created": created, + "source": source_label + } + + self.setHtml(( + "

{subset}

" + "

{version}

" + "Comment
" + "{comment}

" + + "Created
" + "{created}

" + + "Source
" + "{source}" + ).format(**data)) + + def contextMenuEvent(self, event): + """Context menu with additional actions""" + menu = self.createStandardContextMenu() + + # Add additional actions when any text so we can assume + # the version is set. + if self.toPlainText().strip(): + + menu.addSeparator() + action = QtWidgets.QAction("Copy source path to clipboard", + menu) + action.triggered.connect(self.on_copy_source) + menu.addAction(action) + + action = QtWidgets.QAction("Copy raw data to clipboard", + menu) + action.triggered.connect(self.on_copy_raw) + menu.addAction(action) + + menu.exec_(event.globalPos()) + del menu + + def on_copy_source(self): + """Copy formatted source path to clipboard""" + source = self.data.get("source", None) + if not source: + return + + path = source.format(root=api.registered_root()) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(path) + + def on_copy_raw(self): + """Copy raw version data to clipboard + + The data is string formatted with `pprint.pformat`. + + """ + raw = self.data.get("raw", None) + if not raw: + return + + raw_text = pprint.pformat(raw) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(raw_text) + + +class ThumbnailWidget(QtWidgets.QLabel): + + aspect_ratio = (16, 9) + max_width = 300 + + def __init__(self, dbcon, parent=None): + super(ThumbnailWidget, self).__init__(parent) + self.dbcon = dbcon + + self.current_thumb_id = None + self.current_thumbnail = None + + self.setAlignment(QtCore.Qt.AlignCenter) + + # TODO get res path much better way + default_pix_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + "default_thumbnail.png" + ) + self.default_pix = QtGui.QPixmap(default_pix_path) + + def height(self): + width = self.width() + asp_w, asp_h = self.aspect_ratio + + return (width / asp_w) * asp_h + + def width(self): + width = super(ThumbnailWidget, self).width() + if width > self.max_width: + width = self.max_width + return width + + def set_pixmap(self, pixmap=None): + if not pixmap: + pixmap = self.default_pix + self.current_thumb_id = None + + self.current_thumbnail = pixmap + + pixmap = self.scale_pixmap(pixmap) + self.setPixmap(pixmap) + + def resizeEvent(self, _event): + if not self.current_thumbnail: + return + cur_pix = self.scale_pixmap(self.current_thumbnail) + self.setPixmap(cur_pix) + + def scale_pixmap(self, pixmap): + return pixmap.scaled( + self.width(), self.height(), QtCore.Qt.KeepAspectRatio + ) + + def set_thumbnail(self, entity=None): + if not entity: + self.set_pixmap() + return + + if isinstance(entity, (list, tuple)): + if len(entity) == 1: + entity = entity[0] + else: + self.set_pixmap() + return + + thumbnail_id = entity.get("data", {}).get("thumbnail_id") + if thumbnail_id == self.current_thumb_id: + if self.current_thumbnail is None: + self.set_pixmap() + return + + self.current_thumb_id = thumbnail_id + if not thumbnail_id: + self.set_pixmap() + return + + thumbnail_ent = self.dbcon.find_one( + {"type": "thumbnail", "_id": thumbnail_id} + ) + if not thumbnail_ent: + return + + thumbnail_bin = pipeline.get_thumbnail_binary( + thumbnail_ent, "thumbnail", self.dbcon + ) + if not thumbnail_bin: + self.set_pixmap() + return + + thumbnail = QtGui.QPixmap() + thumbnail.loadFromData(thumbnail_bin) + + self.set_pixmap(thumbnail) + + +class VersionWidget(QtWidgets.QWidget): + """A Widget that display information about a specific version""" + def __init__(self, dbcon, parent=None): + super(VersionWidget, self).__init__(parent=parent) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + label = QtWidgets.QLabel("Version", self) + data = VersionTextEdit(dbcon, self) + data.setReadOnly(True) + + layout.addWidget(label) + layout.addWidget(data) + + self.data = data + + def set_version(self, version_doc): + self.data.set_version(version_doc) + + +class FamilyModel(QtGui.QStandardItemModel): + def __init__(self, dbcon, family_config_cache): + super(FamilyModel, self).__init__() + + self.dbcon = dbcon + self.family_config_cache = family_config_cache + + self._items_by_family = {} + + def refresh(self): + families = set() + if self.dbcon.Session.get("AVALON_PROJECT"): + result = list(self.dbcon.aggregate([ + {"$match": { + "type": "subset" + }}, + {"$project": { + "family": {"$arrayElemAt": ["$data.families", 0]} + }}, + {"$group": { + "_id": "family_group", + "families": {"$addToSet": "$family"} + }} + ])) + if result: + families = set(result[0]["families"]) + + root_item = self.invisibleRootItem() + + for family in tuple(self._items_by_family.keys()): + if family not in families: + item = self._items_by_family.pop(family) + root_item.removeRow(item.row()) + + self.family_config_cache.refresh() + + new_items = [] + for family in families: + family_config = self.family_config_cache.family_config(family) + label = family_config.get("label", family) + icon = family_config.get("icon", None) + + if family_config.get("state", True): + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + + if family not in self._items_by_family: + item = QtGui.QStandardItem(label) + item.setFlags( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsUserCheckable + ) + new_items.append(item) + self._items_by_family[family] = item + + else: + item = self._items_by_family[label] + item.setData(label, QtCore.Qt.DisplayRole) + + item.setCheckState(state) + + if icon: + item.setIcon(icon) + + if new_items: + root_item.appendRows(new_items) + + +class FamilyProxyFiler(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(FamilyProxyFiler, self).__init__(*args, **kwargs) + + self._filtering_enabled = False + self._enabled_families = set() + + def set_enabled_families(self, families): + if self._enabled_families == families: + return + + self._enabled_families = families + if self._filtering_enabled: + self.invalidateFilter() + + def is_filter_enabled(self): + return self._filtering_enabled + + def set_filter_enabled(self, enabled=None): + if enabled is None: + enabled = not self._filtering_enabled + elif self._filtering_enabled == enabled: + return + + self._filtering_enabled = enabled + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + if not self._filtering_enabled: + return True + + if not self._enabled_families: + return False + + index = self.sourceModel().index(row, self.filterKeyColumn(), parent) + if index.data(QtCore.Qt.DisplayRole) in self._enabled_families: + return True + return False + + +class FamilyListView(QtWidgets.QListView): + active_changed = QtCore.Signal(list) + + def __init__(self, dbcon, family_config_cache, parent=None): + super(FamilyListView, self).__init__(parent=parent) + + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.setAlternatingRowColors(True) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + family_model = FamilyModel(dbcon, family_config_cache) + proxy_model = FamilyProxyFiler() + proxy_model.setDynamicSortFilter(True) + proxy_model.setSourceModel(family_model) + + self.setModel(proxy_model) + + family_model.dataChanged.connect(self._on_data_change) + self.customContextMenuRequested.connect(self._on_context_menu) + + self._family_model = family_model + self._proxy_model = proxy_model + + def set_enabled_families(self, families): + self._proxy_model.set_enabled_families(families) + + self.set_enabled_family_filtering(True) + + def set_enabled_family_filtering(self, enabled=None): + self._proxy_model.set_filter_enabled(enabled) + + def refresh(self): + self._family_model.refresh() + + self.active_changed.emit(self.get_enabled_families()) + + def get_enabled_families(self): + """Return the checked family items""" + model = self._family_model + checked_families = [] + for row in range(model.rowCount()): + index = model.index(row, 0) + if index.data(QtCore.Qt.CheckStateRole) == QtCore.Qt.Checked: + family = index.data(QtCore.Qt.DisplayRole) + checked_families.append(family) + + return checked_families + + def set_all_unchecked(self): + self._set_checkstates(False, self._get_all_indexes()) + + def set_all_checked(self): + self._set_checkstates(True, self._get_all_indexes()) + + def _get_all_indexes(self): + indexes = [] + model = self._family_model + for row in range(model.rowCount()): + index = model.index(row, 0) + indexes.append(index) + return indexes + + def _set_checkstates(self, checked, indexes): + if not indexes: + return + + if checked is None: + state = None + elif checked: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + + self.blockSignals(True) + + for index in indexes: + index_state = index.data(QtCore.Qt.CheckStateRole) + if index_state == state: + continue + + new_state = state + if new_state is None: + if index_state == QtCore.Qt.Checked: + new_state = QtCore.Qt.Unchecked + else: + new_state = QtCore.Qt.Checked + + index.model().setData(index, new_state, QtCore.Qt.CheckStateRole) + + self.blockSignals(False) + + self.active_changed.emit(self.get_enabled_families()) + + def _change_selection_state(self, checked): + indexes = self.selectionModel().selectedIndexes() + self._set_checkstates(checked, indexes) + + def _on_data_change(self, *_args): + self.active_changed.emit(self.get_enabled_families()) + + def _on_context_menu(self, pos): + """Build RMB menu under mouse at current position (within widget)""" + menu = QtWidgets.QMenu(self) + + # Add enable all action + action_check_all = QtWidgets.QAction(menu) + action_check_all.setText("Enable All") + action_check_all.triggered.connect(self.set_all_checked) + # Add disable all action + action_uncheck_all = QtWidgets.QAction(menu) + action_uncheck_all.setText("Disable All") + action_uncheck_all.triggered.connect(self.set_all_unchecked) + + menu.addAction(action_check_all) + menu.addAction(action_uncheck_all) + + # Get mouse position + global_pos = self.viewport().mapToGlobal(pos) + menu.exec_(global_pos) + + def event(self, event): + if not event.type() == QtCore.QEvent.KeyPress: + pass + + elif event.key() == QtCore.Qt.Key_Space: + self._change_selection_state(None) + return True + + elif event.key() == QtCore.Qt.Key_Backspace: + self._change_selection_state(False) + return True + + elif event.key() == QtCore.Qt.Key_Return: + self._change_selection_state(True) + return True + + return super(FamilyListView, self).event(event) + + +class RepresentationWidget(QtWidgets.QWidget): + load_started = QtCore.Signal() + load_ended = QtCore.Signal() + + default_widths = ( + ("name", 120), + ("subset", 125), + ("asset", 125), + ("active_site", 85), + ("remote_site", 85) + ) + + commands = {'active': 'Download', 'remote': 'Upload'} + + def __init__(self, dbcon, tool_name=None, parent=None): + super(RepresentationWidget, self).__init__(parent=parent) + self.dbcon = dbcon + self.tool_name = tool_name + + headers = [item[0] for item in self.default_widths] + + model = RepresentationModel(self.dbcon, headers, []) + + proxy_model = RepresentationSortProxyModel(self) + proxy_model.setSourceModel(model) + + label = QtWidgets.QLabel("Representations", self) + + tree_view = DeselectableTreeView() + tree_view.setModel(proxy_model) + tree_view.setAllColumnsShowFocus(True) + tree_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + tree_view.setSortingEnabled(True) + tree_view.sortByColumn(1, QtCore.Qt.AscendingOrder) + tree_view.setAlternatingRowColors(True) + tree_view.setIndentation(20) + tree_view.setStyleSheet(""" + QTreeView::item{ + padding: 5px 1px; + border: 0px; + } + """) + tree_view.collapseAll() + + for column_name, width in self.default_widths: + idx = model.Columns.index(column_name) + tree_view.setColumnWidth(idx, width) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label) + layout.addWidget(tree_view) + + # self.itemChanged.connect(self._on_item_changed) + tree_view.customContextMenuRequested.connect(self.on_context_menu) + + self.tree_view = tree_view + self.model = model + self.proxy_model = proxy_model + + self.sync_server_enabled = False + actual_project = dbcon.Session["AVALON_PROJECT"] + self.on_project_change(actual_project) + + self.model.refresh() + + def on_project_change(self, project_name): + """ + Called on each project change in parent widget. + + Checks if Sync Server is enabled for a project, pushes changes to + model. + """ + enabled = False + if project_name: + self.model.reset_sync_server(project_name) + if self.model.sync_server: + enabled_proj = self.model.sync_server.get_enabled_projects() + enabled = project_name in enabled_proj + + self.sync_server_enabled = enabled + lib.change_visibility(self.model, self.tree_view, + "active_site", enabled) + lib.change_visibility(self.model, self.tree_view, + "remote_site", enabled) + + def _repre_contexts_for_loaders_filter(self, items): + repre_ids = [] + for item in items: + repre_ids.append(item["_id"]) + + repre_docs = list(self.dbcon.find( + { + "type": "representation", + "_id": {"$in": repre_ids} + }, + { + "name": 1, + "parent": 1 + } + )) + version_ids = [ + repre_doc["parent"] + for repre_doc in repre_docs + ] + version_docs = self.dbcon.find({ + "_id": {"$in": version_ids} + }) + + version_docs_by_id = {} + version_docs_by_subset_id = collections.defaultdict(list) + for version_doc in version_docs: + version_id = version_doc["_id"] + subset_id = version_doc["parent"] + version_docs_by_id[version_id] = version_doc + version_docs_by_subset_id[subset_id].append(version_doc) + + subset_docs = list(self.dbcon.find( + { + "_id": {"$in": list(version_docs_by_subset_id.keys())}, + "type": "subset" + }, + { + "schema": 1, + "data.families": 1 + } + )) + subset_docs_by_id = { + subset_doc["_id"]: subset_doc + for subset_doc in subset_docs + } + repre_context_by_id = {} + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + + version_doc = version_docs_by_id[version_id] + repre_context_by_id[repre_doc["_id"]] = { + "representation": repre_doc, + "version": version_doc, + "subset": subset_docs_by_id[version_doc["parent"]] + } + return repre_context_by_id + + def on_context_menu(self, point): + """Shows menu with loader actions on Right-click. + + Registered actions are filtered by selection and help of + `loaders_from_representation` from avalon api. Intersection of actions + is shown when more subset is selected. When there are not available + actions for selected subsets then special action is shown (works as + info message to user): "*No compatible loaders for your selection" + + """ + point_index = self.tree_view.indexAt(point) + if not point_index.isValid(): + return + + # Get selected subsets without groups + selection = self.tree_view.selectionModel() + rows = selection.selectedRows(column=0) + + items = lib.get_selected_items(rows, self.model.ItemRole) + + selected_side = self._get_selected_side(point_index, rows) + + # Get all representation->loader combinations available for the + # index under the cursor, so we can list the user the options. + available_loaders = api.discover(api.Loader) + + filtered_loaders = [] + for loader in available_loaders: + # Skip subset loaders + if api.SubsetLoader in inspect.getmro(loader): + continue + + if ( + tools_lib.is_sync_loader(loader) + and not self.sync_server_enabled + ): + continue + + filtered_loaders.append(loader) + + if self.tool_name: + filtered_loaders = lib.remove_tool_name_from_loaders( + filtered_loaders, self.tool_name + ) + + loaders = list() + already_added_loaders = set() + label_already_in_menu = set() + + repre_context_by_id = ( + self._repre_contexts_for_loaders_filter(items) + ) + + for item in items: + repre_context = repre_context_by_id[item["_id"]] + for loader in pipeline.loaders_from_repre_context( + filtered_loaders, + repre_context + ): + if tools_lib.is_sync_loader(loader): + both_unavailable = ( + item["active_site_progress"] <= 0 + and item["remote_site_progress"] <= 0 + ) + if both_unavailable: + continue + + for selected_side in self.commands.keys(): + item = item.copy() + item["custom_label"] = None + label = None + selected_site_progress = item.get( + "{}_site_progress".format(selected_side), -1) + + # only remove if actually present + if tools_lib.is_remove_site_loader(loader): + label = "Remove {}".format(selected_side) + if selected_site_progress < 1: + continue + + if tools_lib.is_add_site_loader(loader): + label = self.commands[selected_side] + if selected_site_progress >= 0: + label = 'Re-{} {}'.format(label, selected_side) + + if not label: + continue + + item["selected_side"] = selected_side + item["custom_label"] = label + + if label not in label_already_in_menu: + loaders.append((item, loader)) + already_added_loaders.add(loader) + label_already_in_menu.add(label) + + else: + item = item.copy() + item["custom_label"] = None + + if loader not in already_added_loaders: + loaders.append((item, loader)) + already_added_loaders.add(loader) + + loaders = lib.sort_loaders(loaders) + + menu = OptionalMenu(self) + if not loaders: + action = lib.get_no_loader_action(menu) + menu.addAction(action) + else: + repre_contexts = pipeline.get_repres_contexts( + repre_context_by_id.keys(), self.dbcon) + menu = lib.add_representation_loaders_to_menu(loaders, menu, + repre_contexts) + + self._process_action(items, menu, point) + + def _process_action(self, items, menu, point): + """ + Show the context action menu and process selected + + Args: + items(dict): menu items + menu(OptionalMenu) + point(PointIndex) + """ + global_point = self.tree_view.mapToGlobal(point) + action = menu.exec_(global_point) + + if not action or not action.data(): + return + + self.load_started.emit() + + # Find the representation name and loader to trigger + action_representation, loader = action.data() + repre_ids = [] + data_by_repre_id = {} + selected_side = action_representation.get("selected_side") + + for item in items: + if tools_lib.is_sync_loader(loader): + site_name = "{}_site_name".format(selected_side) + data = { + "_id": item.get("_id"), + "site_name": item.get(site_name), + "project_name": self.dbcon.Session["AVALON_PROJECT"] + } + + if not data["site_name"]: + continue + + data_by_repre_id[data["_id"]] = data + + repre_ids.append(item.get("_id")) + + repre_contexts = pipeline.get_repres_contexts(repre_ids, + self.dbcon) + options = lib.get_options(action, loader, self, + list(repre_contexts.values())) + + errors = _load_representations_by_loader( + loader, repre_contexts, + options=options, data_by_repre_id=data_by_repre_id) + + self.model.refresh() + + self.load_ended.emit() + + if errors: + box = LoadErrorMessageBox(errors) + box.show() + + def _get_optional_labels(self, loaders, selected_side): + """Each loader could have specific label + + Args: + loaders (tuple of dict, dict): (item, loader) + selected_side(string): active or remote + + Returns: + (dict) {loader: string} + """ + optional_labels = {} + if selected_side: + if selected_side == 'active': + txt = "Localize" + else: + txt = "Sync to Remote" + optional_labels = {loader: txt for _, loader in loaders + if tools_lib.is_sync_loader(loader)} + return optional_labels + + def _get_selected_side(self, point_index, rows): + """Returns active/remote label according to column in 'point_index'""" + selected_side = None + if self.sync_server_enabled: + if rows: + source_index = self.proxy_model.mapToSource(point_index) + selected_side = self.model.data(source_index, + self.model.SiteSideRole) + return selected_side + + def set_version_ids(self, version_ids): + self.model.set_version_ids(version_ids) + + def _set_download(self): + pass + + def change_visibility(self, column_name, visible): + """ + Hides or shows particular 'column_name'. + + "asset" and "subset" columns should be visible only in multiselect + """ + lib.change_visibility(self.model, self.tree_view, column_name, visible) + + +def _load_representations_by_loader(loader, repre_contexts, + options, + data_by_repre_id=None): + """Loops through list of repre_contexts and loads them with one loader + + Args: + loader (cls of api.Loader) - not initialized yet + repre_contexts (dicts) - full info about selected representations + (containing repre_doc, version_doc, subset_doc, project info) + options (dict) - qargparse arguments to fill OptionDialog + data_by_repre_id (dict) - additional data applicable on top of + options to provide dynamic values + """ + error_info = [] + + if options is None: # not load when cancelled + return + + for repre_context in repre_contexts.values(): + try: + if data_by_repre_id: + _id = repre_context["representation"]["_id"] + data = data_by_repre_id.get(_id) + options.update(data) + pipeline.load_with_repre_context( + loader, + repre_context, + options=options + ) + except pipeline.IncompatibleLoaderError as exc: + print(exc) + error_info.append(( + "Incompatible Loader", + None, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + repre_context["version"]["name"] + )) + + except Exception as exc: + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info.append(( + str(exc), + formatted_traceback, + repre_context["representation"]["name"], + repre_context["subset"]["name"], + repre_context["version"]["name"] + )) + return error_info + + +def _load_subsets_by_loader(loader, subset_contexts, options, + subset_version_docs=None): + """ + Triggers load with SubsetLoader type of loaders + + Args: + loader (SubsetLoder): + subset_contexts (list): + options (dict): + subset_version_docs (dict): {subset_id: version_doc} + """ + error_info = [] + + if options is None: # not load when cancelled + return + + if loader.is_multiple_contexts_compatible: + subset_names = [] + for context in subset_contexts: + subset_name = context.get("subset", {}).get("name") or "N/A" + subset_names.append(subset_name) + + context["version"] = subset_version_docs[context["subset"]["_id"]] + try: + pipeline.load_with_subset_contexts( + loader, + subset_contexts, + options=options + ) + except Exception as exc: + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join( + traceback.format_exception( + exc_type, exc_value, exc_traceback + ) + ) + error_info.append(( + str(exc), + formatted_traceback, + None, + ", ".join(subset_names), + None + )) + else: + for subset_context in subset_contexts: + subset_name = subset_context.get("subset", {}).get("name") or "N/A" + + version_doc = subset_version_docs[subset_context["subset"]["_id"]] + subset_context["version"] = version_doc + try: + pipeline.load_with_subset_context( + loader, + subset_context, + options=options + ) + except Exception as exc: + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "\n".join( + traceback.format_exception( + exc_type, exc_value, exc_traceback + ) + ) + error_info.append(( + str(exc), + formatted_traceback, + None, + subset_name, + None + )) + + return error_info diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py index 49ade4a989..6e44afd841 100644 --- a/openpype/tools/project_manager/project_manager/__init__.py +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -1,9 +1,11 @@ __all__ = ( "IDENTIFIER_ROLE", + "PROJECT_NAME_ROLE", "HierarchyView", "ProjectModel", + "ProjectProxyFilter", "CreateProjectDialog", "HierarchyModel", @@ -20,12 +22,14 @@ __all__ = ( from .constants import ( - IDENTIFIER_ROLE + IDENTIFIER_ROLE, + PROJECT_NAME_ROLE ) from .widgets import CreateProjectDialog from .view import HierarchyView from .model import ( ProjectModel, + ProjectProxyFilter, HierarchyModel, HierarchySelectionModel, diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 67dea79e59..7ca4aa9492 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -17,6 +17,9 @@ ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 # Item has opened editor (per column) EDITOR_OPENED_ROLE = QtCore.Qt.UserRole + 6 +# Role for project model +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 7 + # Allowed symbols for any name NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 7ee43a6b61..5b6ed78b50 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -9,7 +9,8 @@ from .constants import ( DUPLICATED_ROLE, HIERARCHY_CHANGE_ABLE_ROLE, REMOVED_ROLE, - EDITOR_OPENED_ROLE + EDITOR_OPENED_ROLE, + PROJECT_NAME_ROLE ) from .style import ResourceCache @@ -29,7 +30,7 @@ class ProjectModel(QtGui.QStandardItemModel): def __init__(self, dbcon, *args, **kwargs): self.dbcon = dbcon - self._project_names = set() + self._items_by_name = {} super(ProjectModel, self).__init__(*args, **kwargs) @@ -37,33 +38,62 @@ class ProjectModel(QtGui.QStandardItemModel): """Reload projects.""" self.dbcon.Session["AVALON_PROJECT"] = None - project_items = [] + new_project_items = [] - none_project = QtGui.QStandardItem("< Select Project >") - none_project.setData(None) - project_items.append(none_project) + if None not in self._items_by_name: + none_project = QtGui.QStandardItem("< Select Project >") + self._items_by_name[None] = none_project + new_project_items.append(none_project) - database = self.dbcon.database + project_docs = self.dbcon.projects( + projection={"name": 1}, + only_active=True + ) project_names = set() - for project_name in database.collection_names(): - # Each collection will have exactly one project document - project_doc = database[project_name].find_one( - {"type": "project"}, - {"name": 1} - ) - if not project_doc: + for project_doc in project_docs: + project_name = project_doc.get("name") + if not project_name: continue - project_name = project_doc.get("name") - if project_name: - project_names.add(project_name) - project_items.append(QtGui.QStandardItem(project_name)) + project_names.add(project_name) + if project_name not in self._items_by_name: + project_item = QtGui.QStandardItem(project_name) + project_item.setData(project_name, PROJECT_NAME_ROLE) - self.clear() + self._items_by_name[project_name] = project_item + new_project_items.append(project_item) - self._project_names = project_names + root_item = self.invisibleRootItem() + for project_name in tuple(self._items_by_name.keys()): + if project_name is None or project_name in project_names: + continue + project_item = self._items_by_name.pop(project_name) + root_item.removeRow(project_item.row()) - self.invisibleRootItem().appendRows(project_items) + if new_project_items: + root_item.appendRows(new_project_items) + + +class ProjectProxyFilter(QtCore.QSortFilterProxyModel): + """Filters default project item.""" + def __init__(self, *args, **kwargs): + super(ProjectProxyFilter, self).__init__(*args, **kwargs) + self._filter_default = False + + def set_filter_default(self, enabled=True): + """Set if filtering of default item is enabled.""" + if enabled == self._filter_default: + return + self._filter_default = enabled + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + if not self._filter_default: + return True + + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + return source_index.data(PROJECT_NAME_ROLE) is not None class HierarchySelectionModel(QtCore.QItemSelectionModel): diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 7c71f4b451..a19031ceda 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -2,19 +2,26 @@ from Qt import QtWidgets, QtCore, QtGui from . import ( ProjectModel, + ProjectProxyFilter, HierarchyModel, HierarchySelectionModel, HierarchyView, - CreateProjectDialog + CreateProjectDialog, + PROJECT_NAME_ROLE ) -from openpype.style import load_stylesheet from .style import ResourceCache +from openpype.style import load_stylesheet from openpype.lib import is_admin_password_required from openpype.widgets import PasswordDialog from openpype import resources +from openpype.api import ( + get_project_basic_paths, + create_project_folders, + Logger +) from avalon.api import AvalonMongoDB @@ -24,12 +31,14 @@ class ProjectManagerWindow(QtWidgets.QWidget): def __init__(self, parent=None): super(ProjectManagerWindow, self).__init__(parent) + self.log = Logger.get_logger(self.__class__.__name__) + self._initial_reset = False self._password_dialog = None self._user_passed = False self.setWindowTitle("OpenPype Project Manager") - self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + self.setWindowIcon(QtGui.QIcon(resources.get_openpype_icon_filepath())) # Top part of window top_part_widget = QtWidgets.QWidget(self) @@ -40,11 +49,15 @@ class ProjectManagerWindow(QtWidgets.QWidget): dbcon = AvalonMongoDB() project_model = ProjectModel(dbcon) + project_proxy = ProjectProxyFilter() + project_proxy.setSourceModel(project_model) + project_proxy.setDynamicSortFilter(True) + project_combobox = QtWidgets.QComboBox(project_widget) project_combobox.setSizeAdjustPolicy( QtWidgets.QComboBox.AdjustToContents ) - project_combobox.setModel(project_model) + project_combobox.setModel(project_proxy) project_combobox.setRootModelIndex(QtCore.QModelIndex()) style_delegate = QtWidgets.QStyledItemDelegate() project_combobox.setItemDelegate(style_delegate) @@ -57,12 +70,19 @@ class ProjectManagerWindow(QtWidgets.QWidget): create_project_btn = QtWidgets.QPushButton( "Create project...", project_widget ) + create_folders_btn = QtWidgets.QPushButton( + ResourceCache.get_icon("asset", "default"), + "Create Starting Folders", + project_widget + ) + create_folders_btn.setEnabled(False) project_layout = QtWidgets.QHBoxLayout(project_widget) project_layout.setContentsMargins(0, 0, 0, 0) project_layout.addWidget(project_combobox, 0) project_layout.addWidget(refresh_projects_btn, 0) project_layout.addWidget(create_project_btn, 0) + project_layout.addWidget(create_folders_btn) project_layout.addStretch(1) # Helper buttons @@ -124,12 +144,14 @@ class ProjectManagerWindow(QtWidgets.QWidget): refresh_projects_btn.clicked.connect(self._on_project_refresh) create_project_btn.clicked.connect(self._on_project_create) + create_folders_btn.clicked.connect(self._on_create_folders) project_combobox.currentIndexChanged.connect(self._on_project_change) save_btn.clicked.connect(self._on_save_click) add_asset_btn.clicked.connect(self._on_add_asset) add_task_btn.clicked.connect(self._on_add_task) self._project_model = project_model + self._project_proxy_model = project_proxy self.hierarchy_view = hierarchy_view self.hierarchy_model = hierarchy_model @@ -139,6 +161,7 @@ class ProjectManagerWindow(QtWidgets.QWidget): self._refresh_projects_btn = refresh_projects_btn self._project_combobox = project_combobox self._create_project_btn = create_project_btn + self._create_folders_btn = create_folders_btn self._add_asset_btn = add_asset_btn self._add_task_btn = add_task_btn @@ -147,8 +170,17 @@ class ProjectManagerWindow(QtWidgets.QWidget): self.setStyleSheet(load_stylesheet()) def _set_project(self, project_name=None): + self._create_folders_btn.setEnabled(project_name is not None) + self._project_proxy_model.set_filter_default(project_name is not None) self.hierarchy_view.set_project(project_name) + def _current_project(self): + row = self._project_combobox.currentIndex() + if row < 0: + return None + index = self._project_proxy_model.index(row, 0) + return index.data(PROJECT_NAME_ROLE) + def showEvent(self, event): super(ProjectManagerWindow, self).showEvent(event) @@ -167,6 +199,7 @@ class ProjectManagerWindow(QtWidgets.QWidget): project_name = self._project_combobox.currentText() self._project_model.refresh() + self._project_proxy_model.sort(0, QtCore.Qt.AscendingOrder) if self._project_combobox.count() == 0: return self._set_project() @@ -176,10 +209,12 @@ class ProjectManagerWindow(QtWidgets.QWidget): if row >= 0: self._project_combobox.setCurrentIndex(row) - self._set_project(self._project_combobox.currentText()) + selected_project = self._current_project() + self._set_project(selected_project) def _on_project_change(self): - self._set_project(self._project_combobox.currentText()) + selected_project = self._current_project() + self._set_project(selected_project) def _on_project_refresh(self): self.refresh_projects() @@ -193,6 +228,30 @@ class ProjectManagerWindow(QtWidgets.QWidget): def _on_add_task(self): self.hierarchy_view.add_task() + def _on_create_folders(self): + project_name = self._current_project() + if not project_name: + return + + qm = QtWidgets.QMessageBox + ans = qm.question(self, + "OpenPype Project Manager", + "Confirm to create starting project folders?", + qm.Yes | qm.No) + if ans == qm.Yes: + try: + # Get paths based on presets + basic_paths = get_project_basic_paths(project_name) + if not basic_paths: + pass + # Invoking OpenPype API to create the project folders + create_project_folders(basic_paths, project_name) + except Exception as exc: + self.log.warning( + "Cannot create starting folders: {}".format(exc), + exc_info=True + ) + def show_message(self, message): # TODO add nicer message pop self.message_label.setText(message) diff --git a/openpype/tools/settings/local_settings/projects_widget.py b/openpype/tools/settings/local_settings/projects_widget.py index a48c504d59..9cd3b9a38e 100644 --- a/openpype/tools/settings/local_settings/projects_widget.py +++ b/openpype/tools/settings/local_settings/projects_widget.py @@ -809,7 +809,7 @@ class ProjectSettingsWidget(QtWidgets.QWidget): self.modules_manager = modules_manager - projects_widget = _ProjectListWidget(self) + projects_widget = _ProjectListWidget(self, only_active=True) roos_site_widget = RootSiteWidget( modules_manager, project_settings, self ) diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index 8235cf8642..ab6b27bdaf 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -3,6 +3,7 @@ import json from Qt import QtWidgets, QtGui, QtCore from openpype.tools.settings import CHILD_OFFSET from .widgets import ExpandingWidget +from .lib import create_deffered_value_change_timer class BaseWidget(QtWidgets.QWidget): @@ -329,6 +330,20 @@ class BaseWidget(QtWidgets.QWidget): class InputWidget(BaseWidget): + def __init__(self, *args, **kwargs): + super(InputWidget, self).__init__(*args, **kwargs) + + # Input widgets have always timer available (but may not be used). + self._value_change_timer = create_deffered_value_change_timer( + self._on_value_change_timer + ) + + def start_value_timer(self): + self._value_change_timer.start() + + def _on_value_change_timer(self): + pass + def create_ui(self): if self.entity.use_label_wrap: label = None diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index c420a8cdc5..be2264340b 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -609,14 +609,23 @@ class ProjectWidget(SettingsCategoryWidget): self.project_list_widget.refresh() def _on_reset_crash(self): - self.project_list_widget.setEnabled(False) + self._set_enabled_project_list(False) super(ProjectWidget, self)._on_reset_crash() def _on_reset_success(self): - if not self.project_list_widget.isEnabled(): - self.project_list_widget.setEnabled(True) + self._set_enabled_project_list(True) super(ProjectWidget, self)._on_reset_success() + def _set_enabled_project_list(self, enabled): + if ( + enabled + and self.modify_defaults_checkbox + and self.modify_defaults_checkbox.isChecked() + ): + enabled = False + if self.project_list_widget.isEnabled() != enabled: + self.project_list_widget.setEnabled(enabled) + def _create_root_entity(self): self.entity = ProjectSettings(change_state=False) self.entity.on_change_callbacks.append(self._on_entity_change) @@ -637,7 +646,8 @@ class ProjectWidget(SettingsCategoryWidget): if self.modify_defaults_checkbox: self.modify_defaults_checkbox.setEnabled(True) - self.project_list_widget.setEnabled(True) + + self._set_enabled_project_list(True) except DefaultsNotDefined: if not self.modify_defaults_checkbox: @@ -646,7 +656,7 @@ class ProjectWidget(SettingsCategoryWidget): self.entity.set_defaults_state() self.modify_defaults_checkbox.setChecked(True) self.modify_defaults_checkbox.setEnabled(False) - self.project_list_widget.setEnabled(False) + self._set_enabled_project_list(False) except StudioDefaultsNotDefined: self.select_default_project() @@ -666,8 +676,10 @@ class ProjectWidget(SettingsCategoryWidget): def _on_modify_defaults(self): if self.modify_defaults_checkbox.isChecked(): + self._set_enabled_project_list(False) if not self.entity.is_in_defaults_state(): self.reset() else: + self._set_enabled_project_list(True) if not self.entity.is_in_studio_state(): self.reset() diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py index ba86fe82dd..cfb9d4a4b1 100644 --- a/openpype/tools/settings/settings/dict_mutable_widget.py +++ b/openpype/tools/settings/settings/dict_mutable_widget.py @@ -3,6 +3,7 @@ from uuid import uuid4 from Qt import QtWidgets, QtCore, QtGui from .base import BaseWidget +from .lib import create_deffered_value_change_timer from .widgets import ( ExpandingWidget, IconButton @@ -284,6 +285,10 @@ class ModifiableDictItem(QtWidgets.QWidget): self.confirm_btn = None + self._key_change_timer = create_deffered_value_change_timer( + self._on_timeout + ) + if collapsible_key: self.create_collapsible_ui() else: @@ -516,6 +521,10 @@ class ModifiableDictItem(QtWidgets.QWidget): if self.ignore_input_changes: return + self._key_change_timer.start() + + def _on_timeout(self): + key = self.key_value() is_key_duplicated = self.entity_widget.validate_key_duplication( self.temp_key, key, self ) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index b2b129da86..a28bee8d36 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -21,6 +21,7 @@ from .base import ( BaseWidget, InputWidget ) +from openpype.widgets.sliders import NiceSlider from openpype.tools.settings import CHILD_OFFSET @@ -399,26 +400,60 @@ class TextWidget(InputWidget): def _on_value_change(self): if self.ignore_input_changes: return + self.start_value_timer() + def _on_value_change_timer(self): self.entity.set(self.input_value()) class NumberWidget(InputWidget): + _slider_widget = None + def _add_inputs_to_layout(self): kwargs = { "minimum": self.entity.minimum, "maximum": self.entity.maximum, - "decimal": self.entity.decimal + "decimal": self.entity.decimal, + "steps": self.entity.steps } self.input_field = NumberSpinBox(self.content_widget, **kwargs) + input_field_stretch = 1 + + slider_multiplier = 1 + if self.entity.show_slider: + # Slider can't handle float numbers so all decimals are converted + # to integer range. + slider_multiplier = 10 ** self.entity.decimal + slider_widget = NiceSlider(QtCore.Qt.Horizontal, self) + slider_widget.setRange( + int(self.entity.minimum * slider_multiplier), + int(self.entity.maximum * slider_multiplier) + ) + if self.entity.steps is not None: + slider_widget.setSingleStep( + self.entity.steps * slider_multiplier + ) + + self.content_layout.addWidget(slider_widget, 1) + + slider_widget.valueChanged.connect(self._on_slider_change) + + self._slider_widget = slider_widget + + input_field_stretch = 0 + + self._slider_multiplier = slider_multiplier self.setFocusProxy(self.input_field) - self.content_layout.addWidget(self.input_field, 1) + self.content_layout.addWidget(self.input_field, input_field_stretch) self.input_field.valueChanged.connect(self._on_value_change) self.input_field.focused_in.connect(self._on_input_focus) + self._ignore_slider_change = False + self._ignore_input_change = False + def _on_input_focus(self): self.focused_in() @@ -429,10 +464,28 @@ class NumberWidget(InputWidget): def set_entity_value(self): self.input_field.setValue(self.entity.value) + def _on_slider_change(self, new_value): + if self._ignore_slider_change: + return + + self._ignore_input_change = True + self.input_field.setValue(new_value / self._slider_multiplier) + self._ignore_input_change = False + def _on_value_change(self): if self.ignore_input_changes: return - self.entity.set(self.input_field.value()) + + self.start_value_timer() + + def _on_value_change_timer(self): + value = self.input_field.value() + if self._slider_widget is not None and not self._ignore_input_change: + self._ignore_slider_change = True + self._slider_widget.setValue(value * self._slider_multiplier) + self._ignore_slider_change = False + + self.entity.set(value) class RawJsonInput(SettingsPlainTextEdit): @@ -523,7 +576,9 @@ class RawJsonWidget(InputWidget): def _on_value_change(self): if self.ignore_input_changes: return + self.start_value_timer() + def _on_value_change_timer(self): self._is_invalid = self.input_field.has_invalid_value() if not self.is_invalid: self.entity.set(self.input_field.json_value()) @@ -738,4 +793,7 @@ class PathInputWidget(InputWidget): def _on_value_change(self): if self.ignore_input_changes: return + self.start_value_timer() + + def _on_value_change_timer(self): self.entity.set(self.input_value()) diff --git a/openpype/tools/settings/settings/lib.py b/openpype/tools/settings/settings/lib.py new file mode 100644 index 0000000000..577aaa5671 --- /dev/null +++ b/openpype/tools/settings/settings/lib.py @@ -0,0 +1,18 @@ +from Qt import QtCore + +# Offset of value change trigger in ms +VALUE_CHANGE_OFFSET_MS = 300 + + +def create_deffered_value_change_timer(callback): + """Deffer value change callback. + + UI won't trigger all callbacks on each value change but after predefined + time. Timer is reset on each start so callback is triggered after user + finish editing. + """ + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.setInterval(VALUE_CHANGE_OFFSET_MS) + timer.timeout.connect(callback) + return timer diff --git a/openpype/tools/settings/settings/style/__init__.py b/openpype/tools/settings/settings/style/__init__.py index 5a57642ee1..f1d9829a04 100644 --- a/openpype/tools/settings/settings/style/__init__.py +++ b/openpype/tools/settings/settings/style/__init__.py @@ -10,4 +10,4 @@ def load_stylesheet(): def app_icon_path(): - return resources.pype_icon_filepath() + return resources.get_openpype_icon_filepath() diff --git a/openpype/tools/settings/settings/style/style.css b/openpype/tools/settings/settings/style/style.css index 250c15063f..b77b575204 100644 --- a/openpype/tools/settings/settings/style/style.css +++ b/openpype/tools/settings/settings/style/style.css @@ -114,6 +114,30 @@ QPushButton[btn-type="expand-toggle"] { background: #21252B; } +/* SLider */ +QSlider::groove { + border: 1px solid #464b54; + border-radius: 0.3em; +} +QSlider::groove:horizontal { + height: 8px; +} +QSlider::groove:vertical { + width: 8px; +} +QSlider::handle { + width: 10px; + height: 10px; + + border-radius: 5px; +} +QSlider::handle:horizontal { + margin: -2px 0; +} +QSlider::handle:vertical { + margin: 0 -2px; +} + #GroupWidget { border-bottom: 1px solid #21252B; } @@ -122,6 +146,15 @@ QPushButton[btn-type="expand-toggle"] { border: 1px solid #464b54; background: #21252B; } + +#ProjectListWidget QListView:disabled { + background: #282C34; +} + +#ProjectListWidget QListView::item:disabled { + color: #4e5254; +} + #ProjectListWidget QLabel { background: transparent; font-weight: bold; @@ -225,8 +258,6 @@ QTabBar::tab:!selected:hover { background: #333840; } - - QTabBar::tab:first:selected { margin-left: 0; } @@ -381,12 +412,15 @@ QHeaderView::section { font-weight: bold; } -QTableView::item:pressed, QListView::item:pressed, QTreeView::item:pressed { +QAbstractItemView::item:pressed { background: #78879b; color: #FFFFFF; } -QTableView::item:selected:active, QTreeView::item:selected:active, QListView::item:selected:active { +QAbstractItemView::item:selected:active { + background: #3d8ec9; +} +QAbstractItemView::item:selected:!active { background: #3d8ec9; } diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index b821c3bb2c..a461f3e675 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -92,11 +92,15 @@ class NumberSpinBox(QtWidgets.QDoubleSpinBox): min_value = kwargs.pop("minimum", -99999) max_value = kwargs.pop("maximum", 99999) decimals = kwargs.pop("decimal", 0) + steps = kwargs.pop("steps", None) + super(NumberSpinBox, self).__init__(*args, **kwargs) self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setDecimals(decimals) self.setMinimum(min_value) self.setMaximum(max_value) + if steps is not None: + self.setSingleStep(steps) def focusInEvent(self, event): super(NumberSpinBox, self).focusInEvent(event) @@ -598,6 +602,12 @@ class NiceCheckbox(QtWidgets.QFrame): return super(NiceCheckbox, self).mouseReleaseEvent(event) +class ProjectListModel(QtGui.QStandardItemModel): + sort_role = QtCore.Qt.UserRole + 10 + filter_role = QtCore.Qt.UserRole + 11 + selected_role = QtCore.Qt.UserRole + 12 + + class ProjectListView(QtWidgets.QListView): left_mouse_released_at = QtCore.Signal(QtCore.QModelIndex) @@ -608,11 +618,35 @@ class ProjectListView(QtWidgets.QListView): super(ProjectListView, self).mouseReleaseEvent(event) +class ProjectListSortFilterProxy(QtCore.QSortFilterProxyModel): + + def __init__(self, *args, **kwargs): + super(ProjectListSortFilterProxy, self).__init__(*args, **kwargs) + self._enable_filter = True + + def filterAcceptsRow(self, source_row, source_parent): + if not self._enable_filter: + return True + + index = self.sourceModel().index(source_row, 0, source_parent) + is_active = bool(index.data(self.filterRole())) + is_selected = bool(index.data(ProjectListModel.selected_role)) + + return is_active or is_selected + + def is_filter_enabled(self): + return self._enable_filter + + def set_filter_enabled(self, value): + self._enable_filter = value + self.invalidateFilter() + + class ProjectListWidget(QtWidgets.QWidget): default = "< Default >" project_changed = QtCore.Signal() - def __init__(self, parent): + def __init__(self, parent, only_active=False): self._parent = parent self.current_project = None @@ -621,8 +655,17 @@ class ProjectListWidget(QtWidgets.QWidget): self.setObjectName("ProjectListWidget") label_widget = QtWidgets.QLabel("Projects") + project_list = ProjectListView(self) - project_list.setModel(QtGui.QStandardItemModel()) + project_model = ProjectListModel() + project_proxy = ProjectListSortFilterProxy() + + project_proxy.setFilterRole(ProjectListModel.filter_role) + project_proxy.setSortRole(ProjectListModel.sort_role) + project_proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + project_proxy.setSourceModel(project_model) + project_list.setModel(project_proxy) # Do not allow editing project_list.setEditTriggers( @@ -636,11 +679,27 @@ class ProjectListWidget(QtWidgets.QWidget): layout.addWidget(label_widget, 0) layout.addWidget(project_list, 1) + if only_active: + inactive_chk = None + else: + inactive_chk = QtWidgets.QCheckBox(" Show Inactive Projects ") + inactive_chk.setChecked(not project_proxy.is_filter_enabled()) + + layout.addSpacing(5) + layout.addWidget(inactive_chk, 0) + layout.addSpacing(5) + + inactive_chk.stateChanged.connect(self.on_inactive_vis_changed) + project_list.left_mouse_released_at.connect(self.on_item_clicked) self.project_list = project_list + self.project_proxy = project_proxy + self.project_model = project_model + self.inactive_chk = inactive_chk self.dbcon = None + self._only_active = only_active def on_item_clicked(self, new_index): new_project_name = new_index.data(QtCore.Qt.DisplayRole) @@ -675,6 +734,14 @@ class ProjectListWidget(QtWidgets.QWidget): else: self.select_project(self.current_project) + def on_inactive_vis_changed(self): + if self.inactive_chk is None: + # should not happen. + return + + enable_filter = not self.inactive_chk.isChecked() + self.project_proxy.set_filter_enabled(enable_filter) + def validate_context_change(self): return not self._parent.entity.has_unsaved_changes @@ -687,12 +754,18 @@ class ProjectListWidget(QtWidgets.QWidget): self.select_project(self.default) def select_project(self, project_name): - model = self.project_list.model() + model = self.project_model + proxy = self.project_proxy + found_items = model.findItems(project_name) if not found_items: found_items = model.findItems(self.default) index = model.indexFromItem(found_items[0]) + model.setData(index, True, ProjectListModel.selected_role) + + index = proxy.mapFromSource(index) + self.project_list.selectionModel().clear() self.project_list.selectionModel().setCurrentIndex( index, QtCore.QItemSelectionModel.SelectionFlag.SelectCurrent @@ -704,11 +777,9 @@ class ProjectListWidget(QtWidgets.QWidget): selected_project = index.data(QtCore.Qt.DisplayRole) break - model = self.project_list.model() + model = self.project_model model.clear() - items = [self.default] - mongo_url = os.environ["OPENPYPE_MONGO"] # Force uninstall of whole avalon connection if url does not match @@ -726,17 +797,37 @@ class ProjectListWidget(QtWidgets.QWidget): self.dbcon = None self.current_project = None + items = [(self.default, True)] + if self.dbcon: - database = self.dbcon.database - for project_name in database.collection_names(): - project_doc = database[project_name].find_one( - {"type": "project"}, - {"name": 1} + + for doc in self.dbcon.projects( + projection={"name": 1, "data.active": 1}, + only_active=self._only_active + ): + items.append( + (doc["name"], doc.get("data", {}).get("active", True)) ) - if project_doc: - items.append(project_doc["name"]) - for item in items: - model.appendRow(QtGui.QStandardItem(item)) + + for project_name, is_active in items: + + row = QtGui.QStandardItem(project_name) + row.setData(is_active, ProjectListModel.filter_role) + row.setData(False, ProjectListModel.selected_role) + + if is_active: + row.setData(project_name, ProjectListModel.sort_role) + + else: + row.setData("~" + project_name, ProjectListModel.sort_role) + + font = row.font() + font.setItalic(True) + row.setFont(font) + + model.appendRow(row) + + self.project_proxy.sort(0) self.select_project(selected_project) diff --git a/openpype/tools/standalonepublish/app.py b/openpype/tools/standalonepublish/app.py index 81a53c52b8..2ce757f773 100644 --- a/openpype/tools/standalonepublish/app.py +++ b/openpype/tools/standalonepublish/app.py @@ -231,7 +231,7 @@ def main(): qt_app = QtWidgets.QApplication([]) # app.setQuitOnLastWindowClosed(False) qt_app.setStyleSheet(style.load_stylesheet()) - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) qt_app.setWindowIcon(icon) def signal_handler(sig, frame): diff --git a/openpype/tools/standalonepublish/widgets/widget_asset.py b/openpype/tools/standalonepublish/widgets/widget_asset.py index c39d71b055..eb22883c11 100644 --- a/openpype/tools/standalonepublish/widgets/widget_asset.py +++ b/openpype/tools/standalonepublish/widgets/widget_asset.py @@ -273,8 +273,11 @@ class AssetWidget(QtWidgets.QWidget): def _set_projects(self): project_names = list() - for project in self.dbcon.projects(): - project_name = project.get("name") + + for doc in self.dbcon.projects(projection={"name": 1}, + only_active=True): + + project_name = doc.get("name") if project_name: project_names.append(project_name) @@ -299,7 +302,9 @@ class AssetWidget(QtWidgets.QWidget): def on_project_change(self): projects = list() - for project in self.dbcon.projects(): + + for project in self.dbcon.projects(projection={"name": 1}, + only_active=True): projects.append(project['name']) project_name = self.combo_projects.currentText() if project_name in projects: diff --git a/openpype/tools/tray/pype_info_widget.py b/openpype/tools/tray/pype_info_widget.py index 2965463c37..2ca625f307 100644 --- a/openpype/tools/tray/pype_info_widget.py +++ b/openpype/tools/tray/pype_info_widget.py @@ -214,7 +214,7 @@ class PypeInfoWidget(QtWidgets.QWidget): self.setStyleSheet(style.load_stylesheet()) - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowTitle("OpenPype info") diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index ed66f1a80f..35b254513f 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -200,7 +200,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): doubleclick_time_ms = 100 def __init__(self, parent): - icon = QtGui.QIcon(resources.pype_icon_filepath()) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) super(SystemTrayIcon, self).__init__(icon, parent) @@ -308,7 +308,7 @@ class PypeTrayApplication(QtWidgets.QApplication): splash_widget.hide() def set_splash(self): - splash_pix = QtGui.QPixmap(resources.pype_splash_filepath()) + splash_pix = QtGui.QPixmap(resources.get_openpype_splash_filepath()) splash = QtWidgets.QSplashScreen(splash_pix) splash.setMask(splash_pix.mask()) splash.setEnabled(False) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py new file mode 100644 index 0000000000..1827bc7e9b --- /dev/null +++ b/openpype/tools/utils/delegates.py @@ -0,0 +1,449 @@ +import time +from datetime import datetime +import logging +import numbers + +import Qt +from Qt import QtWidgets, QtGui, QtCore + +from avalon.lib import HeroVersionType +from .models import ( + AssetModel, + TreeModel +) +from . import lib + +if Qt.__binding__ == "PySide": + from PySide.QtGui import QStyleOptionViewItemV4 +elif Qt.__binding__ == "PyQt4": + from PyQt4.QtGui import QStyleOptionViewItemV4 + +log = logging.getLogger(__name__) + + +class AssetDelegate(QtWidgets.QItemDelegate): + bar_height = 3 + + def sizeHint(self, option, index): + result = super(AssetDelegate, self).sizeHint(option, index) + height = result.height() + result.setHeight(height + self.bar_height) + + return result + + def paint(self, painter, option, index): + # Qt4 compat + if Qt.__binding__ in ("PySide", "PyQt4"): + option = QStyleOptionViewItemV4(option) + + painter.save() + + item_rect = QtCore.QRect(option.rect) + item_rect.setHeight(option.rect.height() - self.bar_height) + + subset_colors = index.data(AssetModel.subsetColorsRole) + subset_colors_width = 0 + if subset_colors: + subset_colors_width = option.rect.width() / len(subset_colors) + + subset_rects = [] + counter = 0 + for subset_c in subset_colors: + new_color = None + new_rect = None + if subset_c: + new_color = QtGui.QColor(*subset_c) + + new_rect = QtCore.QRect( + option.rect.left() + (counter * subset_colors_width), + option.rect.top() + ( + option.rect.height() - self.bar_height + ), + subset_colors_width, + self.bar_height + ) + subset_rects.append((new_color, new_rect)) + counter += 1 + + # Background + bg_color = QtGui.QColor(60, 60, 60) + if option.state & QtWidgets.QStyle.State_Selected: + if len(subset_colors) == 0: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color.setRgb(70, 70, 70) + else: + item_rect.setTop(item_rect.top() + (self.bar_height / 2)) + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color.setAlpha(100) + else: + bg_color.setAlpha(0) + + # When not needed to do a rounded corners (easier and without + # painter restore): + # painter.fillRect( + # item_rect, + # QtGui.QBrush(bg_color) + # ) + pen = painter.pen() + pen.setStyle(QtCore.Qt.NoPen) + pen.setWidth(0) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush(bg_color)) + painter.drawRoundedRect(option.rect, 3, 3) + + if option.state & QtWidgets.QStyle.State_Selected: + for color, subset_rect in subset_rects: + if not color or not subset_rect: + continue + painter.fillRect(subset_rect, QtGui.QBrush(color)) + + painter.restore() + painter.save() + + # Icon + icon_index = index.model().index( + index.row(), index.column(), index.parent() + ) + # - Default icon_rect if not icon + icon_rect = QtCore.QRect( + item_rect.left(), + item_rect.top(), + # To make sure it's same size all the time + option.rect.height() - self.bar_height, + option.rect.height() - self.bar_height + ) + icon = index.model().data(icon_index, QtCore.Qt.DecorationRole) + + if icon: + mode = QtGui.QIcon.Normal + if not (option.state & QtWidgets.QStyle.State_Enabled): + mode = QtGui.QIcon.Disabled + elif option.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + + if isinstance(icon, QtGui.QPixmap): + icon = QtGui.QIcon(icon) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QColor): + pixmap = QtGui.QPixmap(option.decorationSize) + pixmap.fill(icon) + icon = QtGui.QIcon(pixmap) + + elif isinstance(icon, QtGui.QImage): + icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon)) + option.decorationSize = icon.size() / icon.devicePixelRatio() + + elif isinstance(icon, QtGui.QIcon): + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + actualSize = option.icon.actualSize( + option.decorationSize, mode, state + ) + option.decorationSize = QtCore.QSize( + min(option.decorationSize.width(), actualSize.width()), + min(option.decorationSize.height(), actualSize.height()) + ) + + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + icon.paint( + painter, icon_rect, + QtCore.Qt.AlignLeft, mode, state + ) + + # Text + text_rect = QtCore.QRect( + icon_rect.left() + icon_rect.width() + 2, + item_rect.top(), + item_rect.width(), + item_rect.height() + ) + + painter.drawText( + text_rect, QtCore.Qt.AlignVCenter, + index.data(QtCore.Qt.DisplayRole) + ) + + painter.restore() + + +class VersionDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that display version integer formatted as version string.""" + + version_changed = QtCore.Signal() + first_run = False + lock = False + + def __init__(self, dbcon, *args, **kwargs): + self.dbcon = dbcon + super(VersionDelegate, self).__init__(*args, **kwargs) + + def displayText(self, value, locale): + if isinstance(value, HeroVersionType): + return lib.format_version(value, True) + assert isinstance(value, numbers.Integral), ( + "Version is not integer. \"{}\" {}".format(value, str(type(value))) + ) + return lib.format_version(value) + + def paint(self, painter, option, index): + fg_color = index.data(QtCore.Qt.ForegroundRole) + if fg_color: + if isinstance(fg_color, QtGui.QBrush): + fg_color = fg_color.color() + elif isinstance(fg_color, QtGui.QColor): + pass + else: + fg_color = None + + if not fg_color: + return super(VersionDelegate, self).paint(painter, option, index) + + if option.widget: + style = option.widget.style() + else: + style = QtWidgets.QApplication.style() + + style.drawControl( + style.CE_ItemViewItem, option, painter, option.widget + ) + + painter.save() + + text = self.displayText( + index.data(QtCore.Qt.DisplayRole), option.locale + ) + pen = painter.pen() + pen.setColor(fg_color) + painter.setPen(pen) + + text_rect = style.subElementRect(style.SE_ItemViewItemText, option) + text_margin = style.proxy().pixelMetric( + style.PM_FocusFrameHMargin, option, option.widget + ) + 1 + + painter.drawText( + text_rect.adjusted(text_margin, 0, - text_margin, 0), + option.displayAlignment, + text + ) + + painter.restore() + + def createEditor(self, parent, option, index): + item = index.data(TreeModel.ItemRole) + if item.get("isGroup") or item.get("isMerged"): + return + + editor = QtWidgets.QComboBox(parent) + + def commit_data(): + if not self.first_run: + self.commitData.emit(editor) # Update model data + self.version_changed.emit() # Display model data + editor.currentIndexChanged.connect(commit_data) + + self.first_run = True + self.lock = False + + return editor + + def setEditorData(self, editor, index): + if self.lock: + # Only set editor data once per delegation + return + + editor.clear() + + # Current value of the index + item = index.data(TreeModel.ItemRole) + value = index.data(QtCore.Qt.DisplayRole) + if item["version_document"]["type"] != "hero_version": + assert isinstance(value, numbers.Integral), ( + "Version is not integer" + ) + + # Add all available versions to the editor + parent_id = item["version_document"]["parent"] + version_docs = list(self.dbcon.find( + { + "type": "version", + "parent": parent_id + }, + sort=[("name", 1)] + )) + + hero_version_doc = self.dbcon.find_one( + { + "type": "hero_version", + "parent": parent_id + }, { + "name": 1, + "data.tags": 1, + "version_id": 1 + } + ) + + doc_for_hero_version = None + + selected = None + items = [] + for version_doc in version_docs: + version_tags = version_doc["data"].get("tags") or [] + if "deleted" in version_tags: + continue + + if ( + hero_version_doc + and doc_for_hero_version is None + and hero_version_doc["version_id"] == version_doc["_id"] + ): + doc_for_hero_version = version_doc + + label = lib.format_version(version_doc["name"]) + item = QtGui.QStandardItem(label) + item.setData(version_doc, QtCore.Qt.UserRole) + items.append(item) + + if version_doc["name"] == value: + selected = item + + if hero_version_doc and doc_for_hero_version: + version_name = doc_for_hero_version["name"] + label = lib.format_version(version_name, True) + if isinstance(value, HeroVersionType): + index = len(version_docs) + hero_version_doc["name"] = HeroVersionType(version_name) + + item = QtGui.QStandardItem(label) + item.setData(hero_version_doc, QtCore.Qt.UserRole) + items.append(item) + + # Reverse items so latest versions be upper + items = list(reversed(items)) + for item in items: + editor.model().appendRow(item) + + index = 0 + if selected: + index = selected.row() + + # Will trigger index-change signal + editor.setCurrentIndex(index) + self.first_run = False + self.lock = True + + def setModelData(self, editor, model, index): + """Apply the integer version back in the model""" + version = editor.itemData(editor.currentIndex()) + model.setData(index, version["name"]) + + +def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"): + """Parse datetime to readable timestamp + + Within first ten seconds: + - "just now", + Within first minute ago: + - "%S seconds ago" + Within one hour ago: + - "%M minutes ago". + Within one day ago: + - "%H:%M hours ago" + Else: + "%Y-%m-%d %H:%M:%S" + + """ + + assert isinstance(t, datetime) + if now is None: + now = datetime.now() + assert isinstance(now, datetime) + diff = now - t + + second_diff = diff.seconds + day_diff = diff.days + + # future (consider as just now) + if day_diff < 0: + return "just now" + + # history + if day_diff == 0: + if second_diff < 10: + return "just now" + if second_diff < 60: + return str(second_diff) + " seconds ago" + if second_diff < 120: + return "a minute ago" + if second_diff < 3600: + return str(second_diff // 60) + " minutes ago" + if second_diff < 86400: + minutes = (second_diff % 3600) // 60 + hours = second_diff // 3600 + return "{0}:{1:02d} hours ago".format(hours, minutes) + + return t.strftime(strftime) + + +def pretty_timestamp(t, now=None): + """Parse timestamp to user readable format + + >>> pretty_timestamp("20170614T151122Z", now="20170614T151123Z") + 'just now' + + >>> pretty_timestamp("20170614T151122Z", now="20170614T171222Z") + '2:01 hours ago' + + Args: + t (str): The time string to parse. + now (str, optional) + + Returns: + str: human readable "recent" date. + + """ + + if now is not None: + try: + now = time.strptime(now, "%Y%m%dT%H%M%SZ") + now = datetime.fromtimestamp(time.mktime(now)) + except ValueError as e: + log.warning("Can't parse 'now' time format: {0} {1}".format(t, e)) + return None + + if isinstance(t, float): + dt = datetime.fromtimestamp(t) + else: + # Parse the time format as if it is `str` result from + # `pyblish.lib.time()` which usually is stored in Avalon database. + try: + t = time.strptime(t, "%Y%m%dT%H%M%SZ") + except ValueError as e: + log.warning("Can't parse time format: {0} {1}".format(t, e)) + return None + dt = datetime.fromtimestamp(time.mktime(t)) + + # prettify + return pretty_date(dt, now=now) + + +class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that displays a timestamp as a pretty date. + + This displays dates like `pretty_date`. + + """ + + def displayText(self, value, locale): + + if value is None: + # Ignore None value + return + + return pretty_timestamp(value) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py new file mode 100644 index 0000000000..d01dbbd169 --- /dev/null +++ b/openpype/tools/utils/lib.py @@ -0,0 +1,625 @@ +import os +import sys +import contextlib +import collections + +from Qt import QtWidgets, QtCore, QtGui + +import avalon.api +from avalon import style +from avalon.vendor import qtawesome + +from openpype.api import get_project_settings +from openpype.lib import filter_profiles + + +def format_version(value, hero_version=False): + """Formats integer to displayable version name""" + label = "v{0:03d}".format(value) + if not hero_version: + return label + return "[{}]".format(label) + + +@contextlib.contextmanager +def application(): + app = QtWidgets.QApplication.instance() + + if not app: + print("Starting new QApplication..") + app = QtWidgets.QApplication(sys.argv) + yield app + app.exec_() + else: + print("Using existing QApplication..") + yield app + + +def defer(delay, func): + """Append artificial delay to `func` + + This aids in keeping the GUI responsive, but complicates logic + when producing tests. To combat this, the environment variable ensures + that every operation is synchonous. + + Arguments: + delay (float): Delay multiplier; default 1, 0 means no delay + func (callable): Any callable + + """ + + delay *= float(os.getenv("PYBLISH_DELAY", 1)) + if delay > 0: + return QtCore.QTimer.singleShot(delay, func) + else: + return func() + + +class SharedObjects: + jobs = {} + + +def schedule(func, time, channel="default"): + """Run `func` at a later `time` in a dedicated `channel` + + Given an arbitrary function, call this function after a given + timeout. It will ensure that only one "job" is running within + the given channel at any one time and cancel any currently + running job if a new job is submitted before the timeout. + + """ + + try: + SharedObjects.jobs[channel].stop() + except (AttributeError, KeyError, RuntimeError): + pass + + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.timeout.connect(func) + timer.start(time) + + SharedObjects.jobs[channel] = timer + + +@contextlib.contextmanager +def dummy(): + """Dummy context manager + + Usage: + >> with some_context() if False else dummy(): + .. pass + + """ + yield + + +def iter_model_rows(model, column, include_root=False): + """Iterate over all row indices in a model""" + indices = [QtCore.QModelIndex()] # start iteration at root + + for index in indices: + # Add children to the iterations + child_rows = model.rowCount(index) + for child_row in range(child_rows): + child_index = model.index(child_row, column, index) + indices.append(child_index) + + if not include_root and not index.isValid(): + continue + + yield index + + +@contextlib.contextmanager +def preserve_states(tree_view, + column=0, + role=None, + preserve_expanded=True, + preserve_selection=True, + expanded_role=QtCore.Qt.DisplayRole, + selection_role=QtCore.Qt.DisplayRole): + """Preserves row selection in QTreeView by column's data role. + This function is created to maintain the selection status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + tree_view (QWidgets.QTreeView): the tree view nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + Returns: + None + """ + # When `role` is set then override both expanded and selection roles + if role: + expanded_role = role + selection_role = role + + model = tree_view.model() + selection_model = tree_view.selectionModel() + flags = selection_model.Select | selection_model.Rows + + expanded = set() + + if preserve_expanded: + for index in iter_model_rows( + model, column=column, include_root=False + ): + if tree_view.isExpanded(index): + value = index.data(expanded_role) + expanded.add(value) + + selected = None + + if preserve_selection: + selected_rows = selection_model.selectedRows() + if selected_rows: + selected = set(row.data(selection_role) for row in selected_rows) + + try: + yield + finally: + if expanded: + for index in iter_model_rows( + model, column=0, include_root=False + ): + value = index.data(expanded_role) + is_expanded = value in expanded + # skip if new index was created meanwhile + if is_expanded is None: + continue + tree_view.setExpanded(index, is_expanded) + + if selected: + # Go through all indices, select the ones with similar data + for index in iter_model_rows( + model, column=column, include_root=False + ): + value = index.data(selection_role) + state = value in selected + if state: + tree_view.scrollTo(index) # Ensure item is visible + selection_model.select(index, flags) + + +@contextlib.contextmanager +def preserve_expanded_rows(tree_view, column=0, role=None): + """Preserves expanded row in QTreeView by column's data role. + + This function is created to maintain the expand vs collapse status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + Arguments: + tree_view (QWidgets.QTreeView): the tree view which is + nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + if role is None: + role = QtCore.Qt.DisplayRole + model = tree_view.model() + + expanded = set() + + for index in iter_model_rows(model, column=column, include_root=False): + if tree_view.isExpanded(index): + value = index.data(role) + expanded.add(value) + + try: + yield + finally: + if not expanded: + return + + for index in iter_model_rows(model, column=column, include_root=False): + value = index.data(role) + state = value in expanded + if state: + tree_view.expand(index) + else: + tree_view.collapse(index) + + +@contextlib.contextmanager +def preserve_selection(tree_view, column=0, role=None, current_index=True): + """Preserves row selection in QTreeView by column's data role. + + This function is created to maintain the selection status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + tree_view (QWidgets.QTreeView): the tree view nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + if role is None: + role = QtCore.Qt.DisplayRole + model = tree_view.model() + selection_model = tree_view.selectionModel() + flags = selection_model.Select | selection_model.Rows + + if current_index: + current_index_value = tree_view.currentIndex().data(role) + else: + current_index_value = None + + selected_rows = selection_model.selectedRows() + if not selected_rows: + yield + return + + selected = set(row.data(role) for row in selected_rows) + try: + yield + finally: + if not selected: + return + + # Go through all indices, select the ones with similar data + for index in iter_model_rows(model, column=column, include_root=False): + value = index.data(role) + state = value in selected + if state: + tree_view.scrollTo(index) # Ensure item is visible + selection_model.select(index, flags) + + if current_index_value and value == current_index_value: + selection_model.setCurrentIndex( + index, selection_model.NoUpdate + ) + + +class FamilyConfigCache: + default_color = "#0091B2" + _default_icon = None + + def __init__(self, dbcon): + self.dbcon = dbcon + self.family_configs = {} + self._family_filters_set = False + self._require_refresh = True + + @classmethod + def default_icon(cls): + if cls._default_icon is None: + cls._default_icon = qtawesome.icon( + "fa.folder", color=cls.default_color + ) + return cls._default_icon + + def family_config(self, family_name): + """Get value from config with fallback to default""" + if self._require_refresh: + self._refresh() + + item = self.family_configs.get(family_name) + if not item: + item = { + "icon": self.default_icon() + } + if self._family_filters_set: + item["state"] = False + return item + + def refresh(self, force=False): + self._require_refresh = True + + if force: + self._refresh() + + def _refresh(self): + """Get the family configurations from the database + + The configuration must be stored on the project under `config`. + For example: + + {"config": { + "families": [ + {"name": "avalon.camera", label: "Camera", "icon": "photo"}, + {"name": "avalon.anim", label: "Animation", "icon": "male"}, + ] + }} + + It is possible to override the default behavior and set specific + families checked. For example we only want the families imagesequence + and camera to be visible in the Loader. + """ + self._require_refresh = False + self._family_filters_set = False + + self.family_configs.clear() + # Skip if we're not in host context + if not avalon.api.registered_host(): + return + + # Update the icons from the project configuration + project_name = os.environ.get("AVALON_PROJECT") + asset_name = os.environ.get("AVALON_ASSET") + task_name = os.environ.get("AVALON_TASK") + if not all((project_name, asset_name, task_name)): + return + + matching_item = None + project_settings = get_project_settings(project_name) + profiles = ( + project_settings + ["global"] + ["tools"] + ["loader"] + ["family_filter_profiles"] + ) + if profiles: + asset_doc = self.dbcon.find_one( + {"type": "asset", "name": asset_name}, + {"data.tasks": True} + ) + tasks_info = asset_doc.get("data", {}).get("tasks") or {} + task_type = tasks_info.get(task_name, {}).get("type") + profiles_filter = { + "task_types": task_type, + "hosts": os.environ["AVALON_APP"] + } + matching_item = filter_profiles(profiles, profiles_filter) + + families = [] + if matching_item: + families = matching_item["filter_families"] + + if not families: + return + + self._family_filters_set = True + + # Replace icons with a Qt icon we can use in the user interfaces + for family in families: + family_info = { + "name": family, + "icon": self.default_icon(), + "state": True + } + + self.family_configs[family] = family_info + + +class GroupsConfig: + # Subset group item's default icon and order + _default_group_config = None + + def __init__(self, dbcon): + self.dbcon = dbcon + self.groups = {} + + @classmethod + def default_group_config(cls): + if cls._default_group_config is None: + cls._default_group_config = { + "icon": qtawesome.icon( + "fa.object-group", + color=style.colors.default + ), + "order": 0 + } + return cls._default_group_config + + def refresh(self): + """Get subset group configurations from the database + + The 'group' configuration must be stored in the project `config` field. + See schema `config-1.0.json` + + """ + # Clear cached groups + self.groups.clear() + + group_configs = [] + project_name = self.dbcon.Session.get("AVALON_PROJECT") + if project_name: + # Get pre-defined group name and apperance from project config + project_doc = self.dbcon.find_one( + {"type": "project"}, + projection={"config.groups": True} + ) + + if project_doc: + group_configs = project_doc["config"].get("groups") or [] + else: + print("Project not found! \"{}\"".format(project_name)) + + # Build pre-defined group configs + for config in group_configs: + name = config["name"] + icon = "fa." + config.get("icon", "object-group") + color = config.get("color", style.colors.default) + order = float(config.get("order", 0)) + + self.groups[name] = { + "icon": qtawesome.icon(icon, color=color), + "order": order + } + + return self.groups + + def ordered_groups(self, group_names): + # default order zero included + _orders = set([0]) + for config in self.groups.values(): + _orders.add(config["order"]) + + # Remap order to list index + orders = sorted(_orders) + + _groups = list() + for name in group_names: + # Get group config + config = self.groups.get(name) or self.default_group_config() + # Base order + remapped_order = orders.index(config["order"]) + + data = { + "name": name, + "icon": config["icon"], + "_order": remapped_order, + } + + _groups.append(data) + + # Sort by tuple (base_order, name) + # If there are multiple groups in same order, will sorted by name. + ordered_groups = sorted( + _groups, key=lambda _group: (_group.pop("_order"), _group["name"]) + ) + + total = len(ordered_groups) + order_temp = "%0{}d".format(len(str(total))) + + # Update sorted order to config + for index, group_data in enumerate(ordered_groups): + order = index + inverse_order = total - index + + # Format orders into fixed length string for groups sorting + group_data["order"] = order_temp % order + group_data["inverseOrder"] = order_temp % inverse_order + + return ordered_groups + + def active_groups(self, asset_ids, include_predefined=True): + """Collect all active groups from each subset""" + # Collect groups from subsets + group_names = set( + self.dbcon.distinct( + "data.subsetGroup", + {"type": "subset", "parent": {"$in": asset_ids}} + ) + ) + if include_predefined: + # Ensure all predefined group configs will be included + group_names.update(self.groups.keys()) + + return self.ordered_groups(group_names) + + def split_subsets_for_groups(self, subset_docs, grouping): + """Collect all active groups from each subset""" + subset_docs_without_group = collections.defaultdict(list) + subset_docs_by_group = collections.defaultdict(dict) + for subset_doc in subset_docs: + subset_name = subset_doc["name"] + if grouping: + group_name = subset_doc["data"].get("subsetGroup") + if group_name: + if subset_name not in subset_docs_by_group[group_name]: + subset_docs_by_group[group_name][subset_name] = [] + + subset_docs_by_group[group_name][subset_name].append( + subset_doc + ) + continue + + subset_docs_without_group[subset_name].append(subset_doc) + + ordered_groups = self.ordered_groups(subset_docs_by_group.keys()) + + return ordered_groups, subset_docs_without_group, subset_docs_by_group + + +def create_qthread(func, *args, **kwargs): + class Thread(QtCore.QThread): + def run(self): + func(*args, **kwargs) + return Thread() + + +def get_repre_icons(): + try: + from openpype_modules import sync_server + except Exception: + # Backwards compatibility + from openpype.modules import sync_server + + resource_path = os.path.join( + os.path.dirname(sync_server.sync_server_module.__file__), + "providers", "resources" + ) + icons = {} + # TODO get from sync module + for provider in ['studio', 'local_drive', 'gdrive']: + pix_url = "{}/{}.png".format(resource_path, provider) + icons[provider] = QtGui.QIcon(pix_url) + + return icons + + +def get_progress_for_repre(doc, active_site, remote_site): + """ + Calculates average progress for representation. + + If site has created_dt >> fully available >> progress == 1 + + Could be calculated in aggregate if it would be too slow + Args: + doc(dict): representation dict + Returns: + (dict) with active and remote sites progress + {'studio': 1.0, 'gdrive': -1} - gdrive site is not present + -1 is used to highlight the site should be added + {'studio': 1.0, 'gdrive': 0.0} - gdrive site is present, not + uploaded yet + """ + progress = {active_site: -1, + remote_site: -1} + if not doc: + return progress + + files = {active_site: 0, remote_site: 0} + doc_files = doc.get("files") or [] + for doc_file in doc_files: + if not isinstance(doc_file, dict): + continue + + sites = doc_file.get("sites") or [] + for site in sites: + if ( + # Pype 2 compatibility + not isinstance(site, dict) + # Check if site name is one of progress sites + or site["name"] not in progress + ): + continue + + files[site["name"]] += 1 + norm_progress = max(progress[site["name"]], 0) + if site.get("created_dt"): + progress[site["name"]] = norm_progress + 1 + elif site.get("progress"): + progress[site["name"]] = norm_progress + site["progress"] + else: # site exists, might be failed, do not add again + progress[site["name"]] = 0 + + # for example 13 fully avail. files out of 26 >> 13/26 = 0.5 + avg_progress = {} + avg_progress[active_site] = \ + progress[active_site] / max(files[active_site], 1) + avg_progress[remote_site] = \ + progress[remote_site] / max(files[remote_site], 1) + return avg_progress + + +def is_sync_loader(loader): + return is_remove_site_loader(loader) or is_add_site_loader(loader) + + +def is_remove_site_loader(loader): + return hasattr(loader, "remove_site_on_representation") + + +def is_add_site_loader(loader): + return hasattr(loader, "add_site_to_representation") diff --git a/openpype/tools/utils/models.py b/openpype/tools/utils/models.py new file mode 100644 index 0000000000..c5e1ce1b12 --- /dev/null +++ b/openpype/tools/utils/models.py @@ -0,0 +1,500 @@ +import re +import time +import logging +import collections + +import Qt +from Qt import QtCore, QtGui +from avalon.vendor import qtawesome +from avalon import style, io +from . import lib + +log = logging.getLogger(__name__) + + +class TreeModel(QtCore.QAbstractItemModel): + + Columns = list() + ItemRole = QtCore.Qt.UserRole + 1 + item_class = None + + def __init__(self, parent=None): + super(TreeModel, self).__init__(parent) + self._root_item = self.ItemClass() + + @property + def ItemClass(self): + if self.item_class is not None: + return self.item_class + return Item + + def rowCount(self, parent=None): + if parent is None or not parent.isValid(): + parent_item = self._root_item + else: + parent_item = parent.internalPointer() + return parent_item.childCount() + + def columnCount(self, parent): + return len(self.Columns) + + def data(self, index, role): + if not index.isValid(): + return None + + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + item = index.internalPointer() + column = index.column() + + key = self.Columns[column] + return item.get(key, None) + + if role == self.ItemRole: + return index.internalPointer() + + def setData(self, index, value, role=QtCore.Qt.EditRole): + """Change the data on the items. + + Returns: + bool: Whether the edit was successful + """ + + if index.isValid(): + if role == QtCore.Qt.EditRole: + + item = index.internalPointer() + column = index.column() + key = self.Columns[column] + item[key] = value + + # passing `list()` for PyQt5 (see PYSIDE-462) + if Qt.__binding__ in ("PyQt4", "PySide"): + self.dataChanged.emit(index, index) + else: + self.dataChanged.emit(index, index, [role]) + + # must return true if successful + return True + + return False + + def setColumns(self, keys): + assert isinstance(keys, (list, tuple)) + self.Columns = keys + + def headerData(self, section, orientation, role): + + if role == QtCore.Qt.DisplayRole: + if section < len(self.Columns): + return self.Columns[section] + + super(TreeModel, self).headerData(section, orientation, role) + + def flags(self, index): + flags = QtCore.Qt.ItemIsEnabled + + item = index.internalPointer() + if item.get("enabled", True): + flags |= QtCore.Qt.ItemIsSelectable + + return flags + + def parent(self, index): + + item = index.internalPointer() + parent_item = item.parent() + + # If it has no parents we return invalid + if parent_item == self._root_item or not parent_item: + return QtCore.QModelIndex() + + return self.createIndex(parent_item.row(), 0, parent_item) + + def index(self, row, column, parent=None): + """Return index for row/column under parent""" + + if parent is None or not parent.isValid(): + parent_item = self._root_item + else: + parent_item = parent.internalPointer() + + child_item = parent_item.child(row) + if child_item: + return self.createIndex(row, column, child_item) + else: + return QtCore.QModelIndex() + + def add_child(self, item, parent=None): + if parent is None: + parent = self._root_item + + parent.add_child(item) + + def column_name(self, column): + """Return column key by index""" + + if column < len(self.Columns): + return self.Columns[column] + + def clear(self): + self.beginResetModel() + self._root_item = self.ItemClass() + self.endResetModel() + + +class Item(dict): + """An item that can be represented in a tree view using `TreeModel`. + + The item can store data just like a regular dictionary. + + >>> data = {"name": "John", "score": 10} + >>> item = Item(data) + >>> assert item["name"] == "John" + + """ + + def __init__(self, data=None): + super(Item, self).__init__() + + self._children = list() + self._parent = None + + if data is not None: + assert isinstance(data, dict) + self.update(data) + + def childCount(self): + return len(self._children) + + def child(self, row): + + if row >= len(self._children): + log.warning("Invalid row as child: {0}".format(row)) + return + + return self._children[row] + + def children(self): + return self._children + + def parent(self): + return self._parent + + def row(self): + """ + Returns: + int: Index of this item under parent""" + if self._parent is not None: + siblings = self.parent().children() + return siblings.index(self) + return -1 + + def add_child(self, child): + """Add a child to this item""" + child._parent = self + self._children.append(child) + + +class AssetModel(TreeModel): + """A model listing assets in the silo in the active project. + + The assets are displayed in a treeview, they are visually parented by + a `visualParent` field in the database containing an `_id` to a parent + asset. + + """ + + Columns = ["label"] + Name = 0 + Deprecated = 2 + ObjectId = 3 + + DocumentRole = QtCore.Qt.UserRole + 2 + ObjectIdRole = QtCore.Qt.UserRole + 3 + subsetColorsRole = QtCore.Qt.UserRole + 4 + + doc_fetched = QtCore.Signal(bool) + refreshed = QtCore.Signal(bool) + + # Asset document projection + asset_projection = { + "type": 1, + "schema": 1, + "name": 1, + "silo": 1, + "data.visualParent": 1, + "data.label": 1, + "data.tags": 1, + "data.icon": 1, + "data.color": 1, + "data.deprecated": 1 + } + + def __init__(self, dbcon=None, parent=None, asset_projection=None): + super(AssetModel, self).__init__(parent=parent) + if dbcon is None: + dbcon = io + self.dbcon = dbcon + self.asset_colors = {} + + # Projections for Mongo queries + # - let ability to modify them if used in tools that require more than + # defaults + if asset_projection: + self.asset_projection = asset_projection + + self.asset_projection = asset_projection + + self._doc_fetching_thread = None + self._doc_fetching_stop = False + self._doc_payload = {} + + self.doc_fetched.connect(self.on_doc_fetched) + + self.refresh() + + def _add_hierarchy(self, assets, parent=None, silos=None): + """Add the assets that are related to the parent as children items. + + This method does *not* query the database. These instead are queried + in a single batch upfront as an optimization to reduce database + queries. Resulting in up to 10x speed increase. + + Args: + assets (dict): All assets in the currently active silo stored + by key/value + + Returns: + None + + """ + # Reset colors + self.asset_colors = {} + + if silos: + # WARNING: Silo item "_id" is set to silo value + # mainly because GUI issue with perserve selection and expanded row + # and because of easier hierarchy parenting (in "assets") + for silo in silos: + item = Item({ + "_id": silo, + "name": silo, + "label": silo, + "type": "silo" + }) + self.add_child(item, parent=parent) + self._add_hierarchy(assets, parent=item) + + parent_id = parent["_id"] if parent else None + current_assets = assets.get(parent_id, list()) + + for asset in current_assets: + # get label from data, otherwise use name + data = asset.get("data", {}) + label = data.get("label", asset["name"]) + tags = data.get("tags", []) + + # store for the asset for optimization + deprecated = "deprecated" in tags + + item = Item({ + "_id": asset["_id"], + "name": asset["name"], + "label": label, + "type": asset["type"], + "tags": ", ".join(tags), + "deprecated": deprecated, + "_document": asset + }) + self.add_child(item, parent=parent) + + # Add asset's children recursively if it has children + if asset["_id"] in assets: + self._add_hierarchy(assets, parent=item) + + self.asset_colors[asset["_id"]] = [] + + def on_doc_fetched(self, was_stopped): + if was_stopped: + self.stop_fetch_thread() + return + + self.beginResetModel() + + assets_by_parent = self._doc_payload.get("assets_by_parent") + silos = self._doc_payload.get("silos") + if assets_by_parent is not None: + # Build the hierarchical tree items recursively + self._add_hierarchy( + assets_by_parent, + parent=None, + silos=silos + ) + + self.endResetModel() + + has_content = bool(assets_by_parent) or bool(silos) + self.refreshed.emit(has_content) + + self.stop_fetch_thread() + + def fetch(self): + self._doc_payload = self._fetch() or {} + # Emit doc fetched only if was not stopped + self.doc_fetched.emit(self._doc_fetching_stop) + + def _fetch(self): + if not self.dbcon.Session.get("AVALON_PROJECT"): + return + + project_doc = self.dbcon.find_one( + {"type": "project"}, + {"_id": True} + ) + if not project_doc: + return + + # Get all assets sorted by name + db_assets = self.dbcon.find( + {"type": "asset"}, + self.asset_projection + ).sort("name", 1) + + # Group the assets by their visual parent's id + assets_by_parent = collections.defaultdict(list) + for asset in db_assets: + if self._doc_fetching_stop: + return + parent_id = asset.get("data", {}).get("visualParent") + assets_by_parent[parent_id].append(asset) + + return { + "assets_by_parent": assets_by_parent, + "silos": None + } + + def stop_fetch_thread(self): + if self._doc_fetching_thread is not None: + self._doc_fetching_stop = True + while self._doc_fetching_thread.isRunning(): + time.sleep(0.001) + self._doc_fetching_thread = None + + def refresh(self, force=False): + """Refresh the data for the model.""" + # Skip fetch if there is already other thread fetching documents + if self._doc_fetching_thread is not None: + if not force: + return + self.stop_fetch_thread() + + # Clear model items + self.clear() + + # Fetch documents from mongo + # Restart payload + self._doc_payload = {} + self._doc_fetching_stop = False + self._doc_fetching_thread = lib.create_qthread(self.fetch) + self._doc_fetching_thread.start() + + def flags(self, index): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + def setData(self, index, value, role=QtCore.Qt.EditRole): + if not index.isValid(): + return False + + if role == self.subsetColorsRole: + asset_id = index.data(self.ObjectIdRole) + self.asset_colors[asset_id] = value + + if Qt.__binding__ in ("PyQt4", "PySide"): + self.dataChanged.emit(index, index) + else: + self.dataChanged.emit(index, index, [role]) + + return True + + return super(AssetModel, self).setData(index, value, role) + + def data(self, index, role): + if not index.isValid(): + return + + item = index.internalPointer() + if role == QtCore.Qt.DecorationRole: + column = index.column() + if column == self.Name: + # Allow a custom icon and custom icon color to be defined + data = item.get("_document", {}).get("data", {}) + icon = data.get("icon", None) + if icon is None and item.get("type") == "silo": + icon = "database" + color = data.get("color", style.colors.default) + + if icon is None: + # Use default icons if no custom one is specified. + # If it has children show a full folder, otherwise + # show an open folder + has_children = self.rowCount(index) > 0 + icon = "folder" if has_children else "folder-o" + + # Make the color darker when the asset is deprecated + if item.get("deprecated", False): + color = QtGui.QColor(color).darker(250) + + try: + key = "fa.{0}".format(icon) # font-awesome key + icon = qtawesome.icon(key, color=color) + return icon + except Exception as exception: + # Log an error message instead of erroring out completely + # when the icon couldn't be created (e.g. invalid name) + log.error(exception) + + return + + if role == QtCore.Qt.ForegroundRole: # font color + if "deprecated" in item.get("tags", []): + return QtGui.QColor(style.colors.light).darker(250) + + if role == self.ObjectIdRole: + return item.get("_id", None) + + if role == self.DocumentRole: + return item.get("_document", None) + + if role == self.subsetColorsRole: + asset_id = item.get("_id", None) + return self.asset_colors.get(asset_id) or [] + + return super(AssetModel, self).data(index, role) + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filters to the regex if any of the children matches allow parent""" + def filterAcceptsRow(self, row, parent): + regex = self.filterRegExp() + if not regex.isEmpty(): + pattern = regex.pattern() + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if source_index.isValid(): + # Check current index itself + key = model.data(source_index, self.filterRole()) + if re.search(pattern, key, re.IGNORECASE): + return True + + # Check children + rows = model.rowCount(source_index) + for i in range(rows): + if self.filterAcceptsRow(i, source_index): + return True + + # Otherwise filter it + return False + + return super( + RecursiveSortFilterProxyModel, self + ).filterAcceptsRow(row, parent) diff --git a/openpype/tools/utils/views.py b/openpype/tools/utils/views.py new file mode 100644 index 0000000000..bed5655647 --- /dev/null +++ b/openpype/tools/utils/views.py @@ -0,0 +1,86 @@ +import os +from avalon import style +from Qt import QtWidgets, QtCore, QtGui, QtSvg + + +class DeselectableTreeView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + + def mousePressEvent(self, event): + + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + + QtWidgets.QTreeView.mousePressEvent(self, event) + + +class TreeViewSpinner(QtWidgets.QTreeView): + size = 160 + + def __init__(self, parent=None): + super(TreeViewSpinner, self).__init__(parent=parent) + + loading_image_path = os.path.join( + os.path.dirname(os.path.abspath(style.__file__)), + "svg", + "spinner-200.svg" + ) + self.spinner = QtSvg.QSvgRenderer(loading_image_path) + + self.is_loading = False + self.is_empty = True + + def paint_loading(self, event): + rect = event.rect() + rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight()) + rect.moveTo( + rect.x() + rect.width() / 2 - self.size / 2, + rect.y() + rect.height() / 2 - self.size / 2 + ) + rect.setSize(QtCore.QSizeF(self.size, self.size)) + painter = QtGui.QPainter(self.viewport()) + self.spinner.render(painter, rect) + + def paint_empty(self, event): + painter = QtGui.QPainter(self.viewport()) + rect = event.rect() + rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight()) + qtext_opt = QtGui.QTextOption( + QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter + ) + painter.drawText(rect, "No Data", qtext_opt) + + def paintEvent(self, event): + if self.is_loading: + self.paint_loading(event) + elif self.is_empty: + self.paint_empty(event) + else: + super(TreeViewSpinner, self).paintEvent(event) + + +class AssetsView(TreeViewSpinner, DeselectableTreeView): + """Item view. + This implements a context menu. + """ + + def __init__(self): + super(AssetsView, self).__init__() + self.setIndentation(15) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setHeaderHidden(True) + + def mousePressEvent(self, event): + index = self.indexAt(event.pos()) + if not index.isValid(): + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ShiftModifier: + return + elif modifiers == QtCore.Qt.ControlModifier: + return + + super(AssetsView, self).mousePressEvent(event) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py new file mode 100644 index 0000000000..b9b542c123 --- /dev/null +++ b/openpype/tools/utils/widgets.py @@ -0,0 +1,499 @@ +import logging +import time + +from . import lib + +from Qt import QtWidgets, QtCore, QtGui +from avalon.vendor import qtawesome, qargparse + +from avalon import style + +from .models import AssetModel, RecursiveSortFilterProxyModel +from .views import AssetsView +from .delegates import AssetDelegate + +log = logging.getLogger(__name__) + + +class AssetWidget(QtWidgets.QWidget): + """A Widget to display a tree of assets with filter + + To list the assets of the active project: + >>> # widget = AssetWidget() + >>> # widget.refresh() + >>> # widget.show() + + """ + + refresh_triggered = QtCore.Signal() # on model refresh + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal() # on view selection change + current_changed = QtCore.Signal() # on view current index change + + def __init__(self, dbcon, multiselection=False, parent=None): + super(AssetWidget, self).__init__(parent=parent) + + self.dbcon = dbcon + + self.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # Tree View + model = AssetModel(dbcon=self.dbcon, parent=self) + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(model) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + view = AssetsView() + view.setModel(proxy) + if multiselection: + asset_delegate = AssetDelegate() + view.setSelectionMode(view.ExtendedSelection) + view.setItemDelegate(asset_delegate) + + # Header + header = QtWidgets.QHBoxLayout() + + icon = qtawesome.icon("fa.arrow-down", color=style.colors.light) + set_current_asset_btn = QtWidgets.QPushButton(icon, "") + set_current_asset_btn.setToolTip("Go to Asset from current Session") + # Hide by default + set_current_asset_btn.setVisible(False) + + icon = qtawesome.icon("fa.refresh", color=style.colors.light) + refresh = QtWidgets.QPushButton(icon, "") + refresh.setToolTip("Refresh items") + + filter = QtWidgets.QLineEdit() + filter.textChanged.connect(proxy.setFilterFixedString) + filter.setPlaceholderText("Filter assets..") + + header.addWidget(filter) + header.addWidget(set_current_asset_btn) + header.addWidget(refresh) + + # Layout + layout.addLayout(header) + layout.addWidget(view) + + # Signals/Slots + selection = view.selectionModel() + selection.selectionChanged.connect(self.selection_changed) + selection.currentChanged.connect(self.current_changed) + refresh.clicked.connect(self.refresh) + set_current_asset_btn.clicked.connect(self.set_current_session_asset) + + self.set_current_asset_btn = set_current_asset_btn + self.model = model + self.proxy = proxy + self.view = view + + self.model_selection = {} + + def set_current_asset_btn_visibility(self, visible=None): + """Hide set current asset button. + + Not all tools support using of current context asset. + """ + if visible is None: + visible = not self.set_current_asset_btn.isVisible() + self.set_current_asset_btn.setVisible(visible) + + def _refresh_model(self): + # Store selection + self._store_model_selection() + time_start = time.time() + + self.set_loading_state( + loading=True, + empty=True + ) + + def on_refreshed(has_item): + self.set_loading_state(loading=False, empty=not has_item) + self._restore_model_selection() + self.model.refreshed.disconnect() + self.refreshed.emit() + print("Duration: %.3fs" % (time.time() - time_start)) + + # Connect to signal + self.model.refreshed.connect(on_refreshed) + # Trigger signal before refresh is called + self.refresh_triggered.emit() + # Refresh model + self.model.refresh() + + def refresh(self): + self._refresh_model() + + def get_active_asset(self): + """Return the asset item of the current selection.""" + current = self.view.currentIndex() + return current.data(self.model.ItemRole) + + def get_active_asset_document(self): + """Return the asset document of the current selection.""" + current = self.view.currentIndex() + return current.data(self.model.DocumentRole) + + def get_active_index(self): + return self.view.currentIndex() + + def get_selected_assets(self): + """Return the documents of selected assets.""" + selection = self.view.selectionModel() + rows = selection.selectedRows() + assets = [row.data(self.model.DocumentRole) for row in rows] + + # NOTE: skip None object assumed they are silo (backwards comp.) + return [asset for asset in assets if asset] + + def select_assets(self, assets, expand=True, key="name"): + """Select assets by item key. + + Args: + assets (list): List of asset values that can be found under + specified `key` + expand (bool): Whether to also expand to the asset in the view + key (string): Key that specifies where to look for `assets` values + + Returns: + None + + Default `key` is "name" in that case `assets` should contain single + asset name or list of asset names. (It is good idea to use "_id" key + instead of name in that case `assets` must contain `ObjectId` object/s) + It is expected that each value in `assets` will be found only once. + If the filters according to the `key` and `assets` correspond to + the more asset, only the first found will be selected. + + """ + + if not isinstance(assets, (tuple, list)): + assets = [assets] + + # convert to list - tuple cant be modified + assets = set(assets) + + # Clear selection + selection_model = self.view.selectionModel() + selection_model.clearSelection() + + # Select + mode = selection_model.Select | selection_model.Rows + for index in lib.iter_model_rows( + self.proxy, column=0, include_root=False + ): + # stop iteration if there are no assets to process + if not assets: + break + + value = index.data(self.model.ItemRole).get(key) + if value not in assets: + continue + + # Remove processed asset + assets.discard(value) + + selection_model.select(index, mode) + if expand: + # Expand parent index + self.view.expand(self.proxy.parent(index)) + + # Set the currently active index + self.view.setCurrentIndex(index) + + def set_loading_state(self, loading, empty): + if self.view.is_loading != loading: + if loading: + self.view.spinner.repaintNeeded.connect( + self.view.viewport().update + ) + else: + self.view.spinner.repaintNeeded.disconnect() + + self.view.is_loading = loading + self.view.is_empty = empty + + def _store_model_selection(self): + index = self.view.currentIndex() + current = None + if index and index.isValid(): + current = index.data(self.model.ObjectIdRole) + + expanded = set() + model = self.view.model() + for index in lib.iter_model_rows( + model, column=0, include_root=False + ): + if self.view.isExpanded(index): + value = index.data(self.model.ObjectIdRole) + expanded.add(value) + + selection_model = self.view.selectionModel() + + selected = None + selected_rows = selection_model.selectedRows() + if selected_rows: + selected = set( + row.data(self.model.ObjectIdRole) + for row in selected_rows + ) + + self.model_selection = { + "expanded": expanded, + "selected": selected, + "current": current + } + + def _restore_model_selection(self): + model = self.view.model() + not_set = object() + expanded = self.model_selection.pop("expanded", not_set) + selected = self.model_selection.pop("selected", not_set) + current = self.model_selection.pop("current", not_set) + + if ( + expanded is not_set + or selected is not_set + or current is not_set + ): + return + + if expanded: + for index in lib.iter_model_rows( + model, column=0, include_root=False + ): + is_expanded = index.data(self.model.ObjectIdRole) in expanded + self.view.setExpanded(index, is_expanded) + + if not selected and not current: + self.set_current_session_asset() + return + + current_index = None + selected_indexes = [] + # Go through all indices, select the ones with similar data + for index in lib.iter_model_rows( + model, column=0, include_root=False + ): + object_id = index.data(self.model.ObjectIdRole) + if object_id in selected: + selected_indexes.append(index) + + if not current_index and object_id == current: + current_index = index + + if current_index: + self.view.setCurrentIndex(current_index) + + if not selected_indexes: + return + selection_model = self.view.selectionModel() + flags = selection_model.Select | selection_model.Rows + for index in selected_indexes: + # Ensure item is visible + self.view.scrollTo(index) + selection_model.select(index, flags) + + def set_current_session_asset(self): + asset_name = self.dbcon.Session.get("AVALON_ASSET") + if asset_name: + self.select_assets([asset_name]) + + +class OptionalMenu(QtWidgets.QMenu): + """A subclass of `QtWidgets.QMenu` to work with `OptionalAction` + + This menu has reimplemented `mouseReleaseEvent`, `mouseMoveEvent` and + `leaveEvent` to provide better action hightlighting and triggering for + actions that were instances of `QtWidgets.QWidgetAction`. + + """ + + def mouseReleaseEvent(self, event): + """Emit option clicked signal if mouse released on it""" + active = self.actionAt(event.pos()) + if active and active.use_option: + option = active.widget.option + if option.is_hovered(event.globalPos()): + option.clicked.emit() + super(OptionalMenu, self).mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + """Add highlight to active action""" + active = self.actionAt(event.pos()) + for action in self.actions(): + action.set_highlight(action is active, event.globalPos()) + super(OptionalMenu, self).mouseMoveEvent(event) + + def leaveEvent(self, event): + """Remove highlight from all actions""" + for action in self.actions(): + action.set_highlight(False) + super(OptionalMenu, self).leaveEvent(event) + + +class OptionalAction(QtWidgets.QWidgetAction): + """Menu action with option box + + A menu action like Maya's menu item with option box, implemented by + subclassing `QtWidgets.QWidgetAction`. + + """ + + def __init__(self, label, icon, use_option, parent): + super(OptionalAction, self).__init__(parent) + self.label = label + self.icon = icon + self.use_option = use_option + self.option_tip = "" + self.optioned = False + + def createWidget(self, parent): + widget = OptionalActionWidget(self.label, parent) + self.widget = widget + + if self.icon: + widget.setIcon(self.icon) + + if self.use_option: + widget.option.clicked.connect(self.on_option) + widget.option.setToolTip(self.option_tip) + else: + widget.option.setVisible(False) + + return widget + + def set_option_tip(self, options): + sep = "\n\n" + mak = (lambda opt: opt["name"] + " :\n " + opt["help"]) + self.option_tip = sep.join(mak(opt) for opt in options) + + def on_option(self): + self.optioned = True + + def set_highlight(self, state, global_pos=None): + body = self.widget.body + option = self.widget.option + + role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window + body.setBackgroundRole(role) + body.setAutoFillBackground(state) + + if not self.use_option: + return + + state = option.is_hovered(global_pos) + role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window + option.setBackgroundRole(role) + option.setAutoFillBackground(state) + + +class OptionalActionWidget(QtWidgets.QWidget): + """Main widget class for `OptionalAction`""" + + def __init__(self, label, parent=None): + super(OptionalActionWidget, self).__init__(parent) + + body = QtWidgets.QWidget() + body.setStyleSheet("background: transparent;") + + icon = QtWidgets.QLabel() + label = QtWidgets.QLabel(label) + option = OptionBox(body) + + icon.setFixedSize(24, 16) + option.setFixedSize(30, 30) + + layout = QtWidgets.QHBoxLayout(body) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(icon) + layout.addWidget(label) + layout.addSpacing(6) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(6, 1, 2, 1) + layout.setSpacing(0) + layout.addWidget(body) + layout.addWidget(option) + + body.setMouseTracking(True) + label.setMouseTracking(True) + option.setMouseTracking(True) + self.setMouseTracking(True) + self.setFixedHeight(32) + + self.icon = icon + self.label = label + self.option = option + self.body = body + + # (NOTE) For removing ugly QLable shadow FX when highlighted in Nuke. + # See https://stackoverflow.com/q/52838690/4145300 + label.setStyle(QtWidgets.QStyleFactory.create("Plastique")) + + def setIcon(self, icon): + pixmap = icon.pixmap(16, 16) + self.icon.setPixmap(pixmap) + + +class OptionBox(QtWidgets.QLabel): + """Option box widget class for `OptionalActionWidget`""" + + clicked = QtCore.Signal() + + def __init__(self, parent): + super(OptionBox, self).__init__(parent) + + self.setAlignment(QtCore.Qt.AlignCenter) + + icon = qtawesome.icon("fa.sticky-note-o", color="#c6c6c6") + pixmap = icon.pixmap(18, 18) + self.setPixmap(pixmap) + + self.setStyleSheet("background: transparent;") + + def is_hovered(self, global_pos): + if global_pos is None: + return False + pos = self.mapFromGlobal(global_pos) + return self.rect().contains(pos) + + +class OptionDialog(QtWidgets.QDialog): + """Option dialog shown by option box""" + + def __init__(self, parent=None): + super(OptionDialog, self).__init__(parent) + self.setModal(True) + self._options = dict() + + def create(self, options): + parser = qargparse.QArgumentParser(arguments=options) + + decision = QtWidgets.QWidget() + accept = QtWidgets.QPushButton("Accept") + cancel = QtWidgets.QPushButton("Cancel") + + layout = QtWidgets.QHBoxLayout(decision) + layout.addWidget(accept) + layout.addWidget(cancel) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(parser) + layout.addWidget(decision) + + accept.clicked.connect(self.accept) + cancel.clicked.connect(self.reject) + parser.changed.connect(self.on_changed) + + def on_changed(self, argument): + self._options[argument["name"]] = argument.read() + + def parse(self): + return self._options.copy() diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index b542e6e718..6fff0d0278 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -376,6 +376,9 @@ class TasksWidget(QtWidgets.QWidget): task (str): Name of the task to select. """ + task_view_model = self._tasks_view.model() + if not task_view_model: + return # Clear selection selection_model = self._tasks_view.selectionModel() @@ -383,8 +386,8 @@ class TasksWidget(QtWidgets.QWidget): # Select the task mode = selection_model.Select | selection_model.Rows - for row in range(self._tasks_model.rowCount()): - index = self._tasks_model.index(row, 0) + for row in range(task_view_model.rowCount()): + index = task_view_model.index(row, 0) name = index.data(TASK_NAME_ROLE) if name == task_name: selection_model.select(index, mode) @@ -430,7 +433,6 @@ class FilesWidget(QtWidgets.QWidget): # Pype's anatomy object for current project self.anatomy = Anatomy(io.Session["AVALON_PROJECT"]) # Template key used to get work template from anatomy templates - # TODO change template key based on task self.template_key = "work" # This is not root but workfile directory diff --git a/openpype/version.py b/openpype/version.py index 17bd0ff892..f8ed9c7c2f 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.4.0-nightly.4" +__version__ = "3.5.0-nightly.1" diff --git a/openpype/widgets/sliders.py b/openpype/widgets/sliders.py new file mode 100644 index 0000000000..32ade58af5 --- /dev/null +++ b/openpype/widgets/sliders.py @@ -0,0 +1,139 @@ +from Qt import QtWidgets, QtCore, QtGui + + +class NiceSlider(QtWidgets.QSlider): + def __init__(self, *args, **kwargs): + super(NiceSlider, self).__init__(*args, **kwargs) + self._mouse_clicked = False + self._handle_size = 0 + + self._bg_brush = QtGui.QBrush(QtGui.QColor("#21252B")) + self._fill_brush = QtGui.QBrush(QtGui.QColor("#5cadd6")) + + def mousePressEvent(self, event): + self._mouse_clicked = True + if event.button() == QtCore.Qt.LeftButton: + self._set_value_to_pos(event.pos()) + return event.accept() + return super(NiceSlider, self).mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._mouse_clicked: + self._set_value_to_pos(event.pos()) + + super(NiceSlider, self).mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + self._mouse_clicked = True + super(NiceSlider, self).mouseReleaseEvent(event) + + def _set_value_to_pos(self, pos): + if self.orientation() == QtCore.Qt.Horizontal: + self._set_value_to_pos_x(pos.x()) + else: + self._set_value_to_pos_y(pos.y()) + + def _set_value_to_pos_x(self, pos_x): + _range = self.maximum() - self.minimum() + handle_size = self._handle_size + half_handle = handle_size / 2 + pos_x -= half_handle + width = self.width() - handle_size + value = ((_range * pos_x) / width) + self.minimum() + self.setValue(value) + + def _set_value_to_pos_y(self, pos_y): + _range = self.maximum() - self.minimum() + handle_size = self._handle_size + half_handle = handle_size / 2 + pos_y = self.height() - pos_y - half_handle + height = self.height() - handle_size + value = (_range * pos_y / height) + self.minimum() + self.setValue(value) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + + painter.fillRect(event.rect(), QtCore.Qt.transparent) + + painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + + horizontal = self.orientation() == QtCore.Qt.Horizontal + + rect = self.style().subControlRect( + QtWidgets.QStyle.CC_Slider, + opt, + QtWidgets.QStyle.SC_SliderGroove, + self + ) + + _range = self.maximum() - self.minimum() + _offset = self.value() - self.minimum() + if horizontal: + _handle_half = rect.height() / 2 + _handle_size = _handle_half * 2 + width = rect.width() - _handle_size + pos_x = ((width / _range) * _offset) + pos_y = rect.center().y() - _handle_half + 1 + else: + _handle_half = rect.width() / 2 + _handle_size = _handle_half * 2 + height = rect.height() - _handle_size + pos_x = rect.center().x() - _handle_half + 1 + pos_y = height - ((height / _range) * _offset) + + handle_rect = QtCore.QRect( + pos_x, pos_y, _handle_size, _handle_size + ) + + self._handle_size = _handle_size + _offset = 2 + _size = _handle_size - _offset + if horizontal: + if rect.height() > _size: + new_rect = QtCore.QRect(0, 0, rect.width(), _size) + center_point = QtCore.QPoint( + rect.center().x(), handle_rect.center().y() + ) + new_rect.moveCenter(center_point) + rect = new_rect + + ratio = rect.height() / 2 + fill_rect = QtCore.QRect( + rect.x(), + rect.y(), + handle_rect.right() - rect.x(), + rect.height() + ) + + else: + if rect.width() > _size: + new_rect = QtCore.QRect(0, 0, _size, rect.height()) + center_point = QtCore.QPoint( + handle_rect.center().x(), rect.center().y() + ) + new_rect.moveCenter(center_point) + rect = new_rect + + ratio = rect.width() / 2 + fill_rect = QtCore.QRect( + rect.x(), + handle_rect.y(), + rect.width(), + rect.height() - handle_rect.y(), + ) + + painter.save() + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(self._bg_brush) + painter.drawRoundedRect(rect, ratio, ratio) + + painter.setBrush(self._fill_brush) + painter.drawRoundedRect(fill_rect, ratio, ratio) + + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(self._fill_brush) + painter.drawEllipse(handle_rect) + painter.restore() diff --git a/repos/avalon-core b/repos/avalon-core index f48fce09c0..8aee68fa10 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit f48fce09c0986c1fd7f6731de33907be46b436c5 +Subproject commit 8aee68fa10ab4d79be1a91e7728a609748e7c3c6 diff --git a/start.py b/start.py index 6473a926d0..f3adabd942 100644 --- a/start.py +++ b/start.py @@ -96,6 +96,7 @@ Attributes: import os import re import sys +import platform import traceback import subprocess import site @@ -179,6 +180,12 @@ else: ssl_cert_file = certifi.where() os.environ["SSL_CERT_FILE"] = ssl_cert_file +if "--headless" in sys.argv: + os.environ["OPENPYPE_HEADLESS_MODE"] = "1" + sys.argv.remove("--headless") +else: + if os.getenv("OPENPYPE_HEADLESS_MODE") != "1": + os.environ.pop("OPENPYPE_HEADLESS_MODE", None) import igniter # noqa: E402 from igniter import BootstrapRepos # noqa: E402 @@ -333,6 +340,80 @@ def set_modules_environments(): os.environ.update(env) +def is_tool(name): + try: + import os.errno as errno + except ImportError: + import errno + + try: + devnull = open(os.devnull, "w") + subprocess.Popen( + [name], stdout=devnull, stderr=devnull + ).communicate() + except OSError as exc: + if exc.errno == errno.ENOENT: + return False + return True + + +def _startup_validations(): + """Validations before OpenPype starts.""" + try: + _validate_thirdparty_binaries() + except Exception as exc: + if os.environ.get("OPENPYPE_HEADLESS_MODE"): + raise + + import tkinter + from tkinter.messagebox import showerror + + root = tkinter.Tk() + root.attributes("-alpha", 0.0) + root.wm_state("iconic") + if platform.system().lower() != "windows": + root.withdraw() + + showerror( + "Startup validations didn't pass", + str(exc) + ) + root.withdraw() + sys.exit(1) + + +def _validate_thirdparty_binaries(): + """Check existence of thirdpart executables.""" + low_platform = platform.system().lower() + binary_vendors_dir = os.path.join( + os.environ["OPENPYPE_ROOT"], + "vendor", + "bin" + ) + + error_msg = ( + "Missing binary dependency {}. Please fetch thirdparty dependencies." + ) + # Validate existence of FFmpeg + ffmpeg_dir = os.path.join(binary_vendors_dir, "ffmpeg", low_platform) + if low_platform == "windows": + ffmpeg_dir = os.path.join(ffmpeg_dir, "bin") + ffmpeg_executable = os.path.join(ffmpeg_dir, "ffmpeg") + if not is_tool(ffmpeg_executable): + raise RuntimeError(error_msg.format("FFmpeg")) + + # Validate existence of OpenImageIO (not on MacOs) + if low_platform != "darwin": + oiio_tool_path = os.path.join( + binary_vendors_dir, + "oiio", + low_platform, + "oiiotool" + ) + if not is_tool(oiio_tool_path): + raise RuntimeError(error_msg.format("OpenImageIO")) + + def _process_arguments() -> tuple: """Process command line arguments. @@ -343,7 +424,7 @@ def _process_arguments() -> tuple: # check for `--use-version=3.0.0` argument and `--use-staging` use_version = None use_staging = False - print_versions = False + commands = [] for arg in sys.argv: if arg == "--use-version": _print("!!! Please use option --use-version like:") @@ -366,17 +447,38 @@ def _process_arguments() -> tuple: " proper version string.")) sys.exit(1) + if arg == "--validate-version": + _print("!!! Please use option --validate-version like:") + _print(" --validate-version=3.0.0") + sys.exit(1) + + if arg.startswith("--validate-version="): + m = re.search( + r"--validate-version=(?P\d+\.\d+\.\d+(?:\S*)?)", arg) + if m and m.group('version'): + use_version = m.group('version') + sys.argv.remove(arg) + commands.append("validate") + else: + _print("!!! Requested version isn't in correct format.") + _print((" Use --list-versions to find out" + " proper version string.")) + sys.exit(1) + if "--use-staging" in sys.argv: use_staging = True sys.argv.remove("--use-staging") if "--list-versions" in sys.argv: - print_versions = True + commands.append("print_versions") sys.argv.remove("--list-versions") # handle igniter # this is helper to run igniter before anything else if "igniter" in sys.argv: + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": + _print("!!! Cannot open Igniter dialog in headless mode.") + sys.exit(1) import igniter return_code = igniter.open_dialog() @@ -389,7 +491,7 @@ def _process_arguments() -> tuple: sys.argv.pop(idx) sys.argv.insert(idx, "tray") - return use_version, use_staging, print_versions + return use_version, use_staging, commands def _determine_mongodb() -> str: @@ -424,6 +526,11 @@ def _determine_mongodb() -> str: if not openpype_mongo: _print("*** No DB connection string specified.") + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": + _print("!!! Cannot open Igniter dialog in headless mode.") + _print( + "!!! Please use `OPENPYPE_MONGO` to specify server address.") + sys.exit(1) _print("--- launching setup UI ...") result = igniter.open_dialog() @@ -527,6 +634,9 @@ def _find_frozen_openpype(use_version: str = None, except IndexError: # no OpenPype version found, run Igniter and ask for them. _print('*** No OpenPype versions found.') + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": + _print("!!! Cannot open Igniter dialog in headless mode.") + sys.exit(1) _print("--- launching setup UI ...") import igniter return_code = igniter.open_dialog() @@ -590,8 +700,16 @@ def _find_frozen_openpype(use_version: str = None, if not is_inside: # install latest version to user data dir - version_path = bootstrap.install_version( - openpype_version, force=True) + if os.getenv("OPENPYPE_HEADLESS_MODE", "0") != "1": + import igniter + version_path = igniter.open_update_window(openpype_version) + else: + version_path = bootstrap.install_version( + openpype_version, force=True) + + openpype_version.path = version_path + _initialize_environment(openpype_version) + return openpype_version.path if openpype_version.path.is_file(): _print(">>> Extracting zip file ...") @@ -724,6 +842,11 @@ def boot(): # ------------------------------------------------------------------------ os.environ["OPENPYPE_ROOT"] = OPENPYPE_ROOT + # ------------------------------------------------------------------------ + # Do necessary startup validations + # ------------------------------------------------------------------------ + _startup_validations() + # ------------------------------------------------------------------------ # Play animation # ------------------------------------------------------------------------ @@ -738,7 +861,7 @@ def boot(): # Process arguments # ------------------------------------------------------------------------ - use_version, use_staging, print_versions = _process_arguments() + use_version, use_staging, commands = _process_arguments() if os.getenv("OPENPYPE_VERSION"): if use_version: @@ -766,13 +889,47 @@ def boot(): # Get openpype path from database and set it to environment so openpype can # find its versions there and bootstrap them. openpype_path = get_openpype_path_from_db(openpype_mongo) + + if getattr(sys, 'frozen', False): + local_version = bootstrap.get_version(Path(OPENPYPE_ROOT)) + else: + local_version = bootstrap.get_local_live_version() + + if "validate" in commands: + _print(f">>> Validating version [ {use_version} ]") + openpype_versions = bootstrap.find_openpype(include_zips=True, + staging=True) + openpype_versions += bootstrap.find_openpype(include_zips=True, + staging=False) + v: OpenPypeVersion + found = [v for v in openpype_versions if str(v) == use_version] + if not found: + _print(f"!!! Version [ {use_version} ] not found.") + list_versions(openpype_versions, local_version) + sys.exit(1) + + # print result + result = bootstrap.validate_openpype_version( + bootstrap.get_version_path_from_list( + use_version, openpype_versions)) + + _print("{}{}".format( + ">>> " if result[0] else "!!! ", + bootstrap.validate_openpype_version( + bootstrap.get_version_path_from_list( + use_version, openpype_versions) + )[1]) + ) + sys.exit(1) + + if not openpype_path: _print("*** Cannot get OpenPype path from database.") if not os.getenv("OPENPYPE_PATH") and openpype_path: os.environ["OPENPYPE_PATH"] = openpype_path - if print_versions: + if "print_versions" in commands: if not use_staging: _print("--- This will list only non-staging versions detected.") _print(" To see staging versions, use --use-staging argument.") @@ -803,6 +960,13 @@ def boot(): # no version to run _print(f"!!! {e}") sys.exit(1) + # validate version + _print(f">>> Validating version [ {str(version_path)} ]") + result = bootstrap.validate_openpype_version(version_path) + if not result[0]: + _print(f"!!! Invalid version: {result[1]}") + sys.exit(1) + _print(f"--- version is valid") else: version_path = _bootstrap_from_code(use_version, use_staging) diff --git a/tools/build_win_installer.ps1 b/tools/build_win_installer.ps1 index a0832e0135..49fa803742 100644 --- a/tools/build_win_installer.ps1 +++ b/tools/build_win_installer.ps1 @@ -105,6 +105,46 @@ $env:BUILD_VERSION = $openpype_version iscc +Write-Host ">>> " -NoNewline -ForegroundColor green +Write-Host "Detecting host Python ... " -NoNewline +$python = "python" +if (Get-Command "pyenv" -ErrorAction SilentlyContinue) { + $pyenv_python = & pyenv which python + if (Test-Path -PathType Leaf -Path "$($pyenv_python)") { + $python = $pyenv_python + } +} +if (-not (Get-Command $python -ErrorAction SilentlyContinue)) { + Write-Host "!!! Python not detected" -ForegroundColor red + Set-Location -Path $current_dir + Exit-WithCode 1 +} +$version_command = @' +import sys +print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1])) +'@ + +$p = & $python -c $version_command +$env:PYTHON_VERSION = $p +$m = $p -match '(\d+)\.(\d+)' +if(-not $m) { + Write-Host "!!! Cannot determine version" -ForegroundColor red + Set-Location -Path $current_dir + Exit-WithCode 1 +} +# We are supporting python 3.7 only +if (($matches[1] -lt 3) -or ($matches[2] -lt 7)) { + Write-Host "FAILED Version [ $p ] is old and unsupported" -ForegroundColor red + Set-Location -Path $current_dir + Exit-WithCode 1 +} elseif (($matches[1] -eq 3) -and ($matches[2] -gt 7)) { + Write-Host "WARNING Version [ $p ] is unsupported, use at your own risk." -ForegroundColor yellow + Write-Host "*** " -NoNewline -ForegroundColor yellow + Write-Host "OpenPype supports only Python 3.7" -ForegroundColor white +} else { + Write-Host "OK [ $p ]" -ForegroundColor green +} + Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Creating OpenPype installer ... " -ForegroundColor white @@ -114,7 +154,7 @@ from distutils.util import get_platform print('exe.{}-{}'.format(get_platform(), sys.version[0:3])) "@ -$build_dir = & python -c $build_dir_command +$build_dir = & $python -c $build_dir_command Write-Host "Build directory ... ${build_dir}" -ForegroundColor white $env:BUILD_DIR = $build_dir diff --git a/tools/ci_tools.py b/tools/ci_tools.py index 3c1aaae991..337b19a346 100644 --- a/tools/ci_tools.py +++ b/tools/ci_tools.py @@ -3,7 +3,34 @@ import sys from semver import VersionInfo from git import Repo from optparse import OptionParser +from github import Github +import os +def get_release_type_github(Log, github_token): + # print(Log) + minor_labels = ["type: feature", "type: deprecated"] + patch_labels = ["type: enhancement", "type: bug"] + + g = Github(github_token) + repo = g.get_repo("pypeclub/OpenPype") + + labels = set() + for line in Log.splitlines(): + match = re.search("pull request #(\d+)", line) + if match: + pr_number = match.group(1) + pr = repo.get_pull(int(pr_number)) + for label in pr.labels: + labels.add(label.name) + + if any(label in labels for label in minor_labels): + return "minor" + + if any(label in labels for label in patch_labels): + return "path" + + return None + def remove_prefix(text, prefix): return text[text.startswith(prefix) and len(prefix):] @@ -36,7 +63,7 @@ def get_log_since_tag(version): def release_type(log): regex_minor = ["feature/", "(feat)"] - regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/"] + regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/", "update"] for reg in regex_minor: if re.search(reg, log): return "minor" @@ -69,7 +96,7 @@ def bump_file_versions(version): file_regex_replace(filename, regex, pyproject_version) -def calculate_next_nightly(token="nightly"): +def calculate_next_nightly(type="nightly", github_token=None): 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() @@ -78,7 +105,10 @@ def calculate_next_nightly(token="nightly"): last_release, last_release_tag = get_last_version("release") last_release_v = VersionInfo.parse(last_release) - bump_type = release_type(get_log_since_tag(last_release)) + bump_type = get_release_type_github( + get_log_since_tag(last_release_tag), + github_token + ) if not bump_type: return None @@ -86,10 +116,10 @@ def calculate_next_nightly(token="nightly"): # print(next_release_v) if next_release_v > last_pre_v_finalized: - next_tag = next_release_v.bump_prerelease(token=token).__str__() + next_tag = next_release_v.bump_prerelease(token=type).__str__() return next_tag elif next_release_v == last_pre_v_finalized: - next_tag = last_pre_v.bump_prerelease(token=token).__str__() + next_tag = last_pre_v.bump_prerelease(token=type).__str__() return next_tag def finalize_latest_nightly(): @@ -125,30 +155,36 @@ def main(): help="finalize latest prerelease to a release") parser.add_option("-p", "--prerelease", dest="prerelease", action="store", - help="define prerelease token") + help="define prerelease type") parser.add_option("-f", "--finalize", dest="finalize", action="store", - help="define prerelease token") + help="define prerelease type") 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") + parser.add_option("-g", "--github_token", + dest="github_token", action="store", + help="github token") (options, args) = parser.parse_args() if options.bump: - last_CI, last_CI_tag = get_last_version("CI") last_release, last_release_tag = get_last_version("release") - bump_type_CI = release_type(get_log_since_tag(last_CI_tag)) - bump_type_release = release_type(get_log_since_tag(last_release_tag)) - if bump_type_CI is None or bump_type_release is None: + bump_type_release = get_release_type_github( + get_log_since_tag(last_release_tag), + options.github_token + ) + if bump_type_release is None: print("skip") + else: + print(bump_type_release) if options.nightly: - next_tag_v = calculate_next_nightly() + next_tag_v = calculate_next_nightly(github_token=options.github_token) print(next_tag_v) bump_file_versions(next_tag_v) diff --git a/website/docs/admin_openpype_commands.md b/website/docs/admin_openpype_commands.md index 1a91e2e7fe..7a46ee7906 100644 --- a/website/docs/admin_openpype_commands.md +++ b/website/docs/admin_openpype_commands.md @@ -18,11 +18,14 @@ Running OpenPype without any commands will default to `tray`. ```shell openpype_console --use-version=3.0.0-foo+bar ``` +`--headless` - to run OpenPype in headless mode (without using graphical UI) `--use-staging` - to use staging versions of OpenPype. `--list-versions [--use-staging]` - to list available versions. +`--validate-version` to validate integrity of given version + For more information [see here](admin_use#run-openpype). ## Commands @@ -52,7 +55,7 @@ openpype_console tray --debug --- ### `launch` arguments {#eventserver-arguments} You have to set either proper environment variables to provide URL and credentials or use -option to specify them. If you use `--store_credentials` provided credentials will be stored for later use. +option to specify them. | Argument | Description | | --- | --- | @@ -60,16 +63,13 @@ option to specify them. If you use `--store_credentials` provided credentials wi | `--ftrack-url` | URL to ftrack server (can be set with `FTRACK_SERVER`) | | `--ftrack-user` |user name to log in to ftrack (can be set with `FTRACK_API_USER`) | | `--ftrack-api-key` | ftrack api key (can be set with `FTRACK_API_KEY`) | -| `--ftrack-events-path` | path to event server plugins (can be set with `FTRACK_EVENTS_PATH`) | -| `--no-stored-credentials` | will use credential specified with options above | -| `--store-credentials` | will store credentials to file for later use | | `--legacy` | run event server without mongo storing | | `--clockify-api-key` | Clockify API key (can be set with `CLOCKIFY_API_KEY`) | | `--clockify-workspace` | Clockify workspace (can be set with `CLOCKIFY_WORKSPACE`) | To run ftrack event server: ```shell -openpype_console eventserver --ftrack-url= --ftrack-user= --ftrack-api-key= --ftrack-events-path= --no-stored-credentials --store-credentials +openpype_console eventserver --ftrack-url= --ftrack-user= --ftrack-api-key= ``` --- diff --git a/website/docs/admin_use.md b/website/docs/admin_use.md index 4ad08a0174..178241ad19 100644 --- a/website/docs/admin_use.md +++ b/website/docs/admin_use.md @@ -56,6 +56,19 @@ openpype_console --list-versions You can add `--use-staging` to list staging versions. ::: +If you want to validate integrity of some available version, you can use: + +```shell +openpype_console --validate-version=3.3.0 +``` + +This will go through the version and validate file content against sha 256 hashes +stored in `checksums` file. + +:::tip Headless mode +Add `--headless` to run OpenPype without graphical UI (useful on server or on automated tasks, etc.) +::: + ### Details When you run OpenPype from executable, few check are made: diff --git a/website/docs/artist_hosts_nuke_tut.md b/website/docs/artist_hosts_nuke_tut.md new file mode 100644 index 0000000000..4d116bd958 --- /dev/null +++ b/website/docs/artist_hosts_nuke_tut.md @@ -0,0 +1,337 @@ +--- +id: artist_hosts_nuke_tut +title: Nuke +sidebar_label: Nuke +--- + +:::note +OpenPype supports Nuke version **`11.0`** and above. +::: + +## OpenPype global tools + +- [Set Context](artist_tools.md#set-context) +- [Work Files](artist_tools.md#workfiles) +- [Create](artist_tools.md#creator) +- [Load](artist_tools.md#loader) +- [Manage (Inventory)](artist_tools.md#inventory) +- [Publish](artist_tools.md#publisher) +- [Library Loader](artist_tools.md#library-loader) + +## Nuke specific tools + +
+
+ +### Set Frame Ranges + +Use this feature in case you are not sure the frame range is correct. + +##### Result + +- setting Frame Range in script settings +- setting Frame Range in viewers (timeline) + +
+
+ +![Set Frame Ranges](assets/nuke_setFrameRanges.png) + +
+
+ + +
+ +![Set Frame Ranges Timeline](assets/nuke_setFrameRanges_timeline.png) + +
+ +1. limiting to Frame Range without handles +2. **Input** handle on start +3. **Output** handle on end + +
+
+ +### Set Resolution + +
+
+ + +This menu item will set correct resolution format for you defined by your production. + +##### Result + +- creates new item in formats with project name +- sets the new format as used + +
+
+ +![Set Resolution](assets/nuke_setResolution.png) + +
+
+ + +### Set Colorspace + +
+
+ +This menu item will set correct Colorspace definitions for you. All has to be configured by your production (Project coordinator). + +##### Result + +- set Colorspace in your script settings +- set preview LUT to your viewers +- set correct colorspace to all discovered Read nodes (following expression set in settings) + +
+
+ +![Set Colorspace](assets/nuke_setColorspace.png) + +
+
+ + +### Apply All Settings + +
+
+ +It is usually enough if you once per while use this option just to make yourself sure the workfile is having set correct properties. + +##### Result + +- set Frame Ranges +- set Colorspace +- set Resolution + +
+
+ +![Apply All Settings](assets/nuke_applyAllSettings.png) + +
+
+ +### Build Workfile + +
+
+ +This tool will append all available subsets into an actual node graph. It will look into database and get all last [versions](artist_concepts.md#version) of available [subsets](artist_concepts.md#subset). + + +##### Result + +- adds all last versions of subsets (rendered image sequences) as read nodes +- ~~adds publishable write node as `renderMain` subset~~ + +
+
+ +![Build First Work File](assets/nuke_buildFirstWorkfile.png) + +
+
+ +## Nuke QuickStart + +This QuickStart is short introduction to what OpenPype can do for you. It attempts to make an overview for compositing artists, and simplifies processes that are better described in specific parts of the documentation. + +### Launch Nuke - Shot and Task Context +OpenPype has to know what shot and task you are working on. You need to run Nuke in context of the task, using Ftrack Action or OpenPype Launcher to select the task and run Nuke. + +![Run Nuke From Ftrack](assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png) +![Run Nuke From Launcher](assets/nuke_tut/nuke_RunNukeLauncher_p2.png) + +:::tip Admin Tip - Nuke version +You can [configure](admin_settings_project_anatomy.md#Attributes) which DCC version(s) will be available for current project in **Studio Settings β†’ Project β†’ Anatomy β†’ Attributes β†’ Applications** +::: + +### Nuke Initial setup +Nuke OpenPype menu shows the current context + +![Context](assets/nuke_tut/nuke_Context.png) + +Launching Nuke with context stops your timer, and starts the clock on the shot and task you picked. + +Openpype makes initial setup for your Nuke script. It is the same as running [Apply All Settings](artist_hosts_nuke.md#apply-all-settings) from the OpenPype menu. + +- Reads frame range and resolution from Avalon database, sets it in Nuke Project Settings, +Creates Viewer node, sets it’s range and indicates handles by In and Out points. + +- Reads Color settings from the project configuration, and sets it in Nuke Project Settings and Viewer. + +- Sets project directory in the Nuke Project Settings to the Nuke Script Directory + +:::tip Tip - Project Settings +After Nuke starts it will automatically **Apply All Settings** for you. If you are sure the settings are wrong just contact your supervisor and he will set them correctly for you in project database. +::: + +### Save Nuke script – the Work File +Use OpenPype - Work files menu to create a new Nuke script. Openpype offers you the preconfigured naming. +![Context](assets/nuke_tut/nuke_WorkFileSaveAs.png) + +The Next Available Version checks the work folder for already used versions and offers the lowest unused version number automatically. + +Subversion can be used to distinguish or name versions. For example used to add shortened artist name. + +More about [workfiles](artist_tools#workfiles). + + +:::tip Admin Tips +- **Workfile Naming** + + - The [workfile naming](admin_settings_project_anatomy#templates) is configured in anatomy, see **Studio Settings β†’ Project β†’ Anatomy β†’ Templates β†’ Work** + +- **Open Workfile** + + - You can [configure](project_settings/settings_project_nuke#create-first-workfile) Nuke to automatically open the last version, or create a file on startup. See **Studio Settings β†’ Project β†’ Global β†’ Tools β†’ Workfiles** + +- **Nuke Color Settings** + + - [Color setting](project_settings/settings_project_nuke) for Nuke can be found in **Studio Settings β†’ Project β†’ Anatomy β†’ Color Management and Output Formats β†’ Nuke** +::: + +### Load plate +Use Load from OpenPype menu to load any plates or renders available. + +![Asset Load](assets/nuke_tut/nuke_AssetLoader.png) + +Pick the plate asset, right click and choose Load Image Sequence to create a Read node in Nuke. + +Note that the Read node created by OpenPype is green. Green color indicates the highest version of asset is loaded. Asset versions could be easily changed by [Manage](#managing-versions). Lower versions will be highlighted by orange color on the read node. + +![Asset Load](assets/nuke_tut/nuke_AssetLoadOutOfDate.png) + +More about [Asset loader](artist_tools#loader). + +### Create Write Node +To create OpenPype managed Write node, select the Read node you just created, from OpenPype menu, pick Create. +In the Instance Creator, pick Create Write Render, and Create. + +![OpenPype Create](assets/nuke_tut/nuke_Creator.png) + +This will create a Group with a Write node inside. + +![OpenPype Create](assets/nuke_tut/nuke_WriteNodeCreated.png) + +:::tip Admin Tip - Configuring write node +You can configure write node parameters in **Studio Settings β†’ Project β†’ Anatomy β†’ Color Management and Output Formats β†’ Nuke β†’ Nodes** +::: + +#### What Nuke Publish Does +From Artist perspective, Nuke publish gathers all the stuff found in the Nuke script with Publish checkbox set to on, exports stuff and raises the Nuke script (workfile) version. + +The Pyblish dialog shows the progress of the process. + +The left column of the dialog shows what will be published. Typically it is one or more renders or prerenders, plus work file. + +![OpenPype Publish](assets/nuke_tut/nuke_PyblishDialogNuke.png) + +The right column shows the publish steps + +##### Publish steps +1. Gathers all the stuff found in the Nuke script with Publish checkbox set to on +2. Collects all the info (from the script, database…) +3. Validates components to be published (checks render range and resolution...) +4. Extracts data from the script + - generates thumbnail + - creates review(s) like h264 + - adds burnins to review(s) + - Copies and renames components like render(s), review(s), Nuke script... to publish folder +5. Integrates components (writes to database, sends preview of the render to Ftrack ... +6. Increments Nuke script version, cleans up the render directory + +Gathering all the info and validating usually takes just a few seconds. Creating reviews for long, high resolution shots can however take significant amount of time when publishing locally. + +##### Pyblish Note and Intent +![Note and Intent](assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png) + +Artist can add Note and Intent before firing the publish button. The Note and Intent is ment for easy communication between artist and supervisor. After publish, Note and Intent can be seen in Ftrack notes. + +##### Pyblish Checkbox + +![Note and Intent](assets/nuke_tut/nuke_PyblishCheckBox.png) + +Pyblish Dialog tries to pack a lot of info in a small area. One of the more tricky parts is that it uses non-standard checkboxes. Some squares can be turned on and off by the artist, some are mandatory. + +If you run the publish and decide to not publish the Nuke script, you can turn it off right in the Pyblish dialog by clicking on the checkbox. If you decide to render and publish the shot in lower resolution to speed up the turnaround, you have to turn off the Write Resolution validator. If you want to use an older version of the asset (older version of the plate...), you have to turn off the Validate containers, and so on. + +More info about [Using Pyblish](artist_tools#publisher) + +:::tip Admin Tip - Configuring validators +You can configure Nuke validators like Output Resolution in **Studio Settings β†’ Project β†’ Nuke β†’ Publish plugins** +::: + +### Review +![Write Node Review](assets/nuke_tut/nuke_WriteNodeReview.png) + +When you turn the review checkbox on in your OpenPype write node, here is what happens: +- OpenPype uses the current Nuke script to + - Load the render + - Optionally apply LUT + - Render Prores 4444 with the same resolution as your render +- Use Ffmpeg to convert the Prores to whatever review(s) you defined +- Use Ffmpeg to add (optional) burnin to the review(s) from previous step + +Creating reviews is a part of the publishing process. If you choose to do a local publish or to use existing frames, review will be processed also on the artist's machine. +If you choose to publish on the farm, you will render and do reviews on the farm. + +So far there is no option for using existing frames (from your local / check render) and just do the review on the farm. + +More info about [configuring reviews](pype2/admin_presets_plugins#extractreview). + +:::tip Admin Tip - Configuring Reviews +You can configure reviewsin **Studio Settings β†’ Project β†’ Global β†’ Publish plugins β†’ ExtractReview / ExtractBurnin** +Reviews can be configured separately for each host, task, or family. For example Maya can produce different review to Nuke, animation task can have different burnin then modelling, and plate can have different review then model. +::: + +### Render and Publish + +![OpenPype Create](assets/nuke_tut/nuke_WriteNode.png) + +Let’s say you want to render and publish the shot right now, with only a Read and Write node. You need to decide if you want to render, check the render and then publish it, or you want to execute the render and publish in one go. + +If you wish to check your render before publishing, you can use your local machine or your farm to render the write node as you would do without OpenPype, load and check your render (OpenPype Write has a convenience button for that), and if happy, use publish with Use existing frames option selected in the write node to generate the review on your local machine. + +If you want to render and publish on the farm in one go, run publish with On farm option selected in the write node to render and make the review on farm. + +![Versionless](assets/nuke_tut/nuke_RenderLocalFarm.png) + +### Version-less Render + +![Versionless](assets/nuke_tut/nuke_versionless.png) + +OpenPype is configured so your render file names have no version number until the render is fully finished and published. The main advantage is that you can keep the render from the previous version and re-render only part of the shot. With care, this is handy. + +Main disadvantage of this approach is that you can render only one version of your shot at one time. Otherwise you risk to partially overwrite your shot render before publishing copies and renames the rendered files to the properly versioned publish folder. + +When making quick farm publishes, like making two versions with different color correction, care must be taken to let the first job (first version) completely finish before the second version starts rendering. + +### Managing Versions + +![Versionless](assets/nuke_tut/nuke_ManageVersion.png) + +OpenPype checks all the assets loaded to Nuke on script open. All out of date assets are colored orange, up to date assets are colored green. + +Use Manage to switch versions for loaded assets. + +## Troubleshooting + +### Fixing Validate Containers + +![Versionless](assets/nuke_tut/nuke_ValidateContainers.png) + +If your Pyblish dialog fails on Validate Containers, you might have an old asset loaded. Use OpenPype - Manage... to switch the asset(s) to the latest version. + +### Fixing Validate Version +If your Pyblish dialog fails on Validate Version, you might be trying to publish already published version. Rise your version in the OpenPype WorkFiles SaveAs. + +Or maybe you accidentaly copied write node from different shot to your current one. Check the write publishes on the left side of the Pyblish dialog. Typically you publish only one write. Locate and delete the stray write from other shot. \ No newline at end of file diff --git a/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png b/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png new file mode 100644 index 0000000000..92e1b4dad7 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png differ diff --git a/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png b/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png new file mode 100644 index 0000000000..f7f807a94f Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png differ diff --git a/website/docs/assets/nuke_tut/nuke_AssetLoader.png b/website/docs/assets/nuke_tut/nuke_AssetLoader.png new file mode 100644 index 0000000000..e52abdc428 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AssetLoader.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Context.png b/website/docs/assets/nuke_tut/nuke_Context.png new file mode 100644 index 0000000000..65bb288764 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Context.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Create.png b/website/docs/assets/nuke_tut/nuke_Create.png new file mode 100644 index 0000000000..2c843c05df Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Create.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Creator.png b/website/docs/assets/nuke_tut/nuke_Creator.png new file mode 100644 index 0000000000..454777574a Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Creator.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Load.png b/website/docs/assets/nuke_tut/nuke_Load.png new file mode 100644 index 0000000000..2a345dc69f Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Load.png differ diff --git a/website/docs/assets/nuke_tut/nuke_ManageVersion.png b/website/docs/assets/nuke_tut/nuke_ManageVersion.png new file mode 100644 index 0000000000..c9f2091347 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_ManageVersion.png differ diff --git a/website/docs/assets/nuke_tut/nuke_NukeColor.png b/website/docs/assets/nuke_tut/nuke_NukeColor.png new file mode 100644 index 0000000000..5c4f9a15e0 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_NukeColor.png differ diff --git a/website/docs/assets/nuke_tut/nuke_Publish.png b/website/docs/assets/nuke_tut/nuke_Publish.png new file mode 100644 index 0000000000..b53b6cc06c Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Publish.png differ diff --git a/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png b/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png new file mode 100644 index 0000000000..2c5d59c9d5 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png differ diff --git a/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png b/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png new file mode 100644 index 0000000000..e98a4b9553 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png differ diff --git a/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png b/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png new file mode 100644 index 0000000000..3519ecc22d Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png b/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png new file mode 100644 index 0000000000..4c4c8977a0 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png new file mode 100644 index 0000000000..75faaec572 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png new file mode 100644 index 0000000000..27fec32ae4 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png new file mode 100644 index 0000000000..a42ee6d7b9 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png differ diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png new file mode 100644 index 0000000000..2a36cad380 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png differ diff --git a/website/docs/assets/nuke_tut/nuke_ValidateContainers.png b/website/docs/assets/nuke_tut/nuke_ValidateContainers.png new file mode 100644 index 0000000000..78e0f2edd7 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_ValidateContainers.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png b/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png new file mode 100644 index 0000000000..115a321285 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png b/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png new file mode 100644 index 0000000000..661f44632a Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png b/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png new file mode 100644 index 0000000000..450589ee3a Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WriteNode.png b/website/docs/assets/nuke_tut/nuke_WriteNode.png new file mode 100644 index 0000000000..5ce3e81aab Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNode.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png b/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png new file mode 100644 index 0000000000..b283593d6a Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png b/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png new file mode 100644 index 0000000000..68651cdd6c Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png differ diff --git a/website/docs/assets/nuke_tut/nuke_WriteSettings.png b/website/docs/assets/nuke_tut/nuke_WriteSettings.png new file mode 100644 index 0000000000..cf00adbee6 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteSettings.png differ diff --git a/website/docs/assets/nuke_tut/nuke_versionless.png b/website/docs/assets/nuke_tut/nuke_versionless.png new file mode 100644 index 0000000000..fbb98c55e2 Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_versionless.png differ diff --git a/website/docs/module_ftrack.md b/website/docs/module_ftrack.md index 005270b3b9..8e3806828d 100644 --- a/website/docs/module_ftrack.md +++ b/website/docs/module_ftrack.md @@ -51,10 +51,7 @@ There are specific launch arguments for event server. With `openpype_console eve - **`--ftrack-user "your.username"`** : Ftrack Username - **`--ftrack-api-key "00000aaa-11bb-22cc-33dd-444444eeeee"`** : User's API key -- **`--store-crededentials`** : Entered credentials will be stored for next launch with this argument _(It is not needed to enter **ftrackuser** and **ftrackapikey** args on next launch)_ -- **`--no-stored-credentials`** : Stored credentials are loaded first so if you want to change credentials use this argument - `--ftrack-url "https://yourdomain.ftrackapp.com/"` : Ftrack server URL _(it is not needed to enter if you have set `FTRACK_SERVER` in OpenPype' environments)_ -- `--ftrack-events-path "//Paths/To/Events/"` : Paths to events folder. May contain multiple paths separated by `;`. _(it is not needed to enter if you have set `FTRACK_EVENTS_PATH` in OpenPype' environments)_ So if you want to use OpenPype's environments then you can launch event server for first time with these arguments `openpype_console.exe eventserver --ftrack-user "my.username" --ftrack-api-key "00000aaa-11bb-22cc-33dd-444444eeeee" --store-credentials`. Since that time, if everything was entered correctly, you can launch event server with `openpype_console.exe eventserver`. @@ -64,8 +61,6 @@ So if you want to use OpenPype's environments then you can launch event server f - `FTRACK_API_USER` - Username _("your.username")_ - `FTRACK_API_KEY` - User's API key _("00000aaa-11bb-22cc-33dd-444444eeeee")_ - `FTRACK_SERVER` - Ftrack server url _(")_ -- `FTRACK_EVENTS_PATH` - Paths to events _("//Paths/To/Events/")_ - We do not recommend you this way. @@ -103,10 +98,12 @@ Event server should **not** run more than once! It may cause major issues. `sudo vi /opt/openpype/run_event_server.sh` - add content to the file: ```sh -#!/usr/bin/env -export OPENPYPE_DEBUG=3 -pushd /mnt/pipeline/prod/openpype-setup -. openpype_console eventserver --ftrack-user --ftrack-api-key +#!/usr/bin/env bash +export OPENPYPE_DEBUG=1 +export OPENPYPE_MONGO= + +pushd /mnt/path/to/openpype +./openpype_console eventserver --ftrack-user --ftrack-api-key ``` - change file permission: `sudo chmod 0755 /opt/openpype/run_event_server.sh` @@ -146,9 +143,11 @@ WantedBy=multi-user.target - add content to the service file: ```sh @echo off -set OPENPYPE_DEBUG=3 -pushd \\path\to\file\ -openpype_console.exe eventserver --ftrack-user --ftrack-api-key +set OPENPYPE_DEBUG=1 +set OPENPYPE_MONGO= + +pushd \\path\to\openpype +openpype_console.exe eventserver --ftrack-user --ftrack-api-key ``` - download and install `nssm.cc` - create Windows service according to nssm.cc manual diff --git a/website/sidebars.js b/website/sidebars.js index 3a4b933b9a..38e4206b84 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -18,7 +18,7 @@ module.exports = { label: "Integrations", items: [ "artist_hosts_hiero", - "artist_hosts_nuke", + "artist_hosts_nuke_tut", "artist_hosts_maya", "artist_hosts_blender", "artist_hosts_harmony", diff --git a/website/yarn.lock b/website/yarn.lock index b4c12edeb6..066d156d97 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -6594,9 +6594,9 @@ prism-react-renderer@^1.1.1: integrity sha512-GHqzxLYImx1iKN1jJURcuRoA/0ygCcNhfGw1IT8nPIMzarmKQ3Nc+JcG0gi8JXQzuh0C5ShE4npMIoqNin40hg== prismjs@^1.23.0: - version "1.24.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.24.0.tgz#0409c30068a6c52c89ef7f1089b3ca4de56be2ac" - integrity sha512-SqV5GRsNqnzCL8k5dfAjCNhUrF3pR0A9lTDSCUZeh/LIshheXJEaP0hwLz2t4XHivd2J/v2HR+gRnigzeKe3cQ== + version "1.25.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" + integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg== process-nextick-args@~2.0.0: version "2.0.1"